October 28, 2026
Web Shell Upload via Content-Type Restriction Bypass
The error message that tells you exactly which header to change, and why the PHP payload itself was never the hard part

By Kaustubh Asthana
5 min read
The last lab had no validation at all standing between an uploaded file and the server running it. This one adds a check, and the entire exercise is figuring out exactly what that check is actually looking at, because it turns out not to be what the filename suggests.
Starting From the Same Baseline
Logged in with wiener's credentials, same as before.
I uploaded an ordinary PNG first, to confirm the storage and serving pattern before touching anything resembling an exploit.
Same as the previous lab, uploads land in a folder called avatars and the filename survives untouched.
Confirming the Serving Path Again
I widened Burp's HTTP history filter, since the default settings hide most non-image content and I needed the full picture of every request being made.
With that done, I confirmed the uploaded PNG was being served back from files/avatars, exactly the pattern from before.
Writing the Payload
I wrote the same kind of one-line PHP file as last time, echoing the contents of carlos's secret file.
It's worth pausing on why this particular line and not something else, since file_get_contents wasn't the only option available. Anything that gets PHP to produce visible output works for proving code execution. system or exec or shell_exec would run an actual operating system command and print its output, which is a more aggressive proof of execution since it reaches the OS rather than just the filesystem. readfile is a closer cousin to what I used, it reads a file and writes its contents straight to the response without needing an explicit echo. The payload could also have been built to accept input, something like a GET parameter passed straight into system, turning the uploaded file into a reusable shell that runs whatever command is requested rather than one hardcoded action. I went with file_get_contents wrapped in echo because the lab's objective was reading one specific file, and that's the smallest payload that proves it, not because any of the alternatives would have failed.
The First Attempt, and the Error That Mattered
I uploaded exploit.php directly, same as the previous lab.
This is the message that answers the question of what to change next, and it's worth reading literally rather than skimming past it. It doesn't say the extension is wrong, it names MIME types, image/jpeg and image/png, the same values that appear in a multipart upload's Content-Type header for that file part, not anywhere in the filename. That's the tell. The check isn't looking at what the file is named, it's looking at a header value the browser sets based on what it thinks the file's type is, and that header is just as editable as anything else in the request once it's sitting in an interceptor.
Finding the Field to Change
I pulled the failed upload request up to see its exact shape.
Inside the multipart body, the file's own section carries its own Content-Type header, separate from the Content-Type header on the overall request. That inner header is what the server was actually reading when it rejected the upload, and it's a value the client sets, meaning nothing stops it from saying one thing while the actual bytes behind it say another.
I changed the Content-Type on that part to image/jpeg, one of the two values the error message had just told me were accepted, while leaving the filename as exploit.php and the PHP payload completely untouched.
The Upload That Worked
Sending that modified request got a different result entirely.
The filename still ended in .php, and the actual bytes were still the PHP payload from the start. The only thing that changed was a single declared value the server trusted without checking it against the file's real content, and that was the entire gap this lab was built around.
Executing It
I requested the uploaded file directly.
That string was carlos's secret, read straight off the filesystem by the PHP I'd uploaded, confirming the server had executed the file rather than served its source.
Submitting the result
Why This Matters
The filename extension and the declared Content-Type are two completely independent pieces of information about an uploaded file, and a validation check that only looks at one of them is trusting something the client controls entirely. Here the server cared about Content-Type and ignored the extension, which meant a filename ending in .php sailed through untouched as long as the header claimed to be an image. A different application might check the extension and ignore Content-Type, which would make this exact bypass fail and a renamed file succeed instead. Neither check alone tells you what a file actually is, only what it claims to be, and the fix has to verify the file's real content rather than trusting either label.
Takeaways From This Lab
- An error message that names specific accepted values is telling you exactly what field the check inspects, read it literally rather than treating it as generic rejection text.
- A multipart file upload carries its own Content-Type header inside the file's own section, separate from the request's overall headers, and that's the value worth looking for when a MIME-type-based rejection shows up.
- The PHP payload itself has no fixed shape, file_get_contents, readfile, system, or a parameter-driven command runner all prove the same underlying flaw, the choice only affects what the proof of execution looks like, not whether the vulnerability is real.