September 25, 2026
File Uploads Still Lead to RCE — What I Learned Breaking Avatar Upload on PortSwigger
File upload looks harmless. “Just let users set a profile picture.” That one feature is still one of the most common ways a web app hands…

By Gabriel Odusanya
6 min read
File upload looks harmless. "Just let users set a profile picture." That one feature is still one of the most common ways a web app hands an attacker remote code execution.
I spent a session on PortSwigger's Web Security Academy file-upload labs on Kali with Burp Suite. Three labs, three different "defenses," same outcome: a PHP file on disk that the server was willing to execute. The secret in /home/carlos/secret came back in the HTTP response.
This is not a guide for attacking live sites. It's what I found in a legal lab, how the requests actually looked, and what I'd insist on before shipping an upload feature.
File Uploads Leading to RCE
A profile picture looks like a small feature. You choose an image, the site stores it, and the account page shows your face. In practice that same upload box is still one of the most common ways a web application hands an attacker remote code execution. I worked through PortSwigger Web Security Academy's file upload labs on Kali Linux with Burp Suite, using the same avatar flow on each lab, and three different defenses still ended the same way: the server saved a PHP file and ran it when I requested the URL. This write-up is about what I found there, how the requests actually looked, and how a real application can close the same gaps. These were authorized academy labs, not attacks on live systems.
Remote code execution from an upload is usually a chain, not a single dramatic bug. The application has to accept a file that is really server-side code, store that file somewhere the web server can reach, and then execute it when somebody visits the file's address. Break any one of those steps and you do not get a shell. A lot of products only break one step, and they break it with something the client still controls: the file extension, the Content-Type header inside the multipart body, or the filename sitting in Content-Disposition. Outdated interpreters and sloppy server configuration still matter, but in these labs the headers were enough.
Every lab used the same rhythm. I proxied the browser through Burp, logged in as wiener with the lab password peter, opened My account, and watched POST /my-account/avatar. A normal image upload showed me that files landed under /files/avatars/ and were fetched again with a GET. In Burp the first successful avatar request was just a screenshot PNG being stored, which told me the feature was live and the response would confirm the saved path.
On Kali I created a small file in Documents called exploit.php. I used touch and nano, then put in one line: a PHP script that prints the contents of /home/carlos/secret. That is the same style of payload the academy itself uses to prove execution. It is not a general-purpose attack toolkit. It is a receipt that the server ran code.
The first lab, Remote code execution via web shell upload, had no defense that mattered. I uploaded exploit.php with no renamed extension and no header tricks. The application stored it. A GET to /files/avatars/exploit.php came back 200, and the response body was the secret rather than PHP source. That is the baseline. If the application does not check type, name, or content, and the upload directory is mapped so PHP can run, the avatar form is already a code-deploy endpoint.
The second lab, Web shell upload via Content-Type restriction bypass, looked more serious at first. The same PHP file came back 403 Forbidden. The server said application/x-php was not allowed and that only image/jpeg and image/png were welcome. In the request you can see filename set to exploit.php and Content-Type set to application/x-php. That message is easy to trust until you look at where the type came from.
In a multipart upload each part carries its own headers. Content-Disposition holds the field name and the filename. Content-Type on that part is not a laboratory reading of the file. It is a label the client wrote. The browser sets it. Burp Repeater can change it. I left the filename as exploit.php and left the body as PHP. I only changed Content-Type to image/jpeg. The upload succeeded. Requesting /files/avatars/exploit.php executed the script again. The lesson is blunt. A backend that accepts a file because the multipart Content-Type says image/jpeg has not validated a file. It has validated a sentence the attacker typed.
The third lab, Web shell upload via path traversal, was the one that felt closest to a real review finding. Uploading exploit.php was allowed, but fetching it from /files/avatars/ did not always run it as PHP. Disabling script execution in the upload folder is the right instinct. The mistake was assuming the file had to stay in that folder. The stored path was still built from the filename in Content-Disposition, and that value is also client-controlled. In Repeater I set filename to ../exploit.php. The application stripped the traversal and reported that avatars/exploit.php had been uploaded. There was a filter, but it was a simple string clean.
I then used ..%2fexploit.php so the slash was URL-encoded. After decode, the traversal came back. The file could be written under /files/ rather than only under /files/avatars/. When execution is off inside avatars, a GET to that folder may just show the PHP source instead of running it. That response is still useful, because it proves the file landed and that the next step is to call it from a directory that does execute scripts.
Taken together the three labs are the same payload with a different lie. In the first there was no lock. In the second the lock opened if I put a note on the box that said I was an image. In the third the lock was on one cupboard, and the filename could place the box in the hallway. That is why file upload stays a reliable path to RCE even when a team believes they already "only allow images" or "don't execute uploads." They are often checking a header they do not own, or enforcing a folder rule that the filename can walk around.
Avoiding this in a real application means treating upload as a write-to-disk API and breaking the chain in more than one place. Do not decide safety from the multipart Content-Type, and do not treat the original filename as a trusted path. Detect type from the bytes of the file, allow only the types the feature actually needs, and generate the stored name on the server so the user's string never reaches the filesystem.
Keep uploads outside the web root or in object storage when you can. If they must live on the web server, that directory must not pass files to PHP or any other interpreter, and the file must be unable to leave that directory. Re-encode images so you keep a picture you created rather than bytes the client sent. Cap size on the server, require the right authentication, and do not publish a file until checks are finished. Review the web server configuration as carefully as the application code, because an old handler mapping can execute a file the application thought was only an avatar.
What I will keep from the session is how little ceremony it took inside a lab that was built to teach the point. I did not need a rare exploit chain.
I needed a one-line PHP file, Burp Repeater, and the fact that Content-Type and filename are just text in the request. After a normal image upload, those are the two fields worth changing one at a time.
If type is decided by the header, you have the second lab. If the save path is the upload folder plus the name the user sent, you have the third. An upload feature that trusts that metadata and stores the result under a document root that runs code is not a profile picture feature.
It is remote code execution with extra steps.