An upload endpoint that accepts a .php file is reachable - confirming the server parses and executes it requires a benign arithmetic oracle that proves evaluation without delivering a real payload.
File upload is not one vulnerability - it is a chain. An endpoint that accepts a .php or .jsp file is reachable. One where that file then lands in a web-accessible directory and the server evaluates it as code is confirmed remote code execution. Severity lives at the end of the chain, and reaching that end requires a benign oracle that proves evaluation without running a real payload.
Every file-upload vulnerability passes through the same sequence of gates: the server accepts the upload, stores the file in a location the web layer can reach, and then - the gate that decides severity - serves or evaluates the uploaded content when a request addresses it. A scanner that only probes the first gate and stops at 200 OK has confirmed reachability, not impact.
The probe is a two-step sequence. First, upload a plain .txt file with a known string body to the same endpoint. Retrieve it at the inferred storage path and confirm the raw text comes back - that is the storage-path baseline. Second, upload a file with the target server-side extension and a body containing an arithmetic expression the interpreter will evaluate. If the response body contains the computed result rather than the source text, the file was executed.
# Step 1: establish the storage path and confirm raw serving POST /api/upload Content-Disposition: form-data; name="file"; filename="probe_62615533.txt" Content-Type: text/plain marker_62615533 HTTP/1.1 200 OK Location: /uploads/probe_62615533.txt GET /uploads/probe_62615533.txt HTTP/1.1 200 OK body: marker_62615533 # raw - not executed # Step 2: arithmetic oracle in a server-side extension POST /api/upload Content-Disposition: form-data; name="file"; filename="probe.php" Content-Type: application/octet-stream <?php echo 7919*7907; ?> GET /uploads/probe.php HTTP/1.1 200 OK body: 62615533 # evaluated - RCE confirmed # No shell command runs. No file is written. The oracle proves evaluation only.
The same oracle adapts per technology stack: JSP uses <%= 7919*7907 %>, ASPX uses <%= (7919*7907).ToString() %>, and server-side JavaScript running under Node.js eval renders the arithmetic result the same way. The oracle is language-agnostic in intent; the probe is language-specific in form.
Most upload endpoints implement a denylist of dangerous extensions rather than an allowlist of safe ones. A denylist is never complete. The techniques below are each a separate probe - a scanner that checks only the literal .php extension silently misses every bypass that follows it.
| Technique | Example filename | Why it works | Confirmed on |
|---|---|---|---|
| Double extension | shell.php.jpg | App checks the last extension; Apache executes on the first | Apache with AddHandler directives |
| MIME type mismatch | shell.php | Content-Type: image/jpeg bypasses MIME check; extension drives execution | Servers that trust Content-Type for storage but name for execution |
| Case variation | shell.Php / shell.pHP | Case-insensitive denylist on Windows or with strcasecmp missing | Windows IIS, some PHP stacks on Linux with weak comparison |
| Null byte (legacy) | shell.php%00.jpg | C-level path truncation ends the name at the null byte | PHP < 5.3.4; older C runtimes |
| Alternate PHP extensions | shell.php5 / shell.phtml / shell.phar | All processed by mod_php unless the handler list is explicit | Apache mod_php without explicit FilesMatch allowlist |
Each bypass is a separate probe with its own confirming oracle request. Reporting a single probe against the literal extension and marking the others as covered is the false-negative path - the denylist gap that matters is the one the developer did not think of.
Gate 2 - whether the file lands in a web-accessible path - is not always visible from the upload response. The storage path may be returned in a Location header, embedded in a JSON body, inferred from a predictable naming convention, or require a separate API call to retrieve the file URL. When the path is opaque, the probe falls back to a content-based confirmation: upload a file whose body contains a unique marker, then scan likely paths (/uploads/, /files/, /media/, /static/, /public/) for a response that echoes the marker. If none are reachable, the endpoint stores outside the webroot and the severity drops significantly - code execution requires an additional path traversal or SSRF to complete the chain.
Content-type and download-forcing headers also matter. A server that responds with Content-Disposition: attachment and Content-Type: application/octet-stream is instructing the browser not to execute the file inline, but that does not prevent the server runtime from evaluating it first. The oracle result in the body is the authority, not the response headers - a PHP file evaluated server-side returns its output to the network regardless of what the Content-Type header says about how the browser should handle it.
The remediation is an allowlist, not a denylist: only the extensions the application genuinely needs (JPEG, PNG, PDF) are permitted, validated on both the filename and the content magic bytes, stored outside the webroot by default, and served through a controlled path that sets Content-Disposition: attachment and a safe Content-Type. Any approach that starts from a list of dangerous things to block will eventually miss one.