AppSec · Aug 21, 2026 · 7 min read

File upload: confirming a dangerous extension reaches an execution context

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.

01Three gates, one chain

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.

Client ──► upload .php file ──► Server Gate 1: extension accepted? reachable Gate 2: file stored under webroot? reachable + stored Gate 3: GET /uploads/file.php server evaluates code? response body: 62615533 confirmed execution Attacker reads oracle value ──► RCE confirmed without a real command
The arithmetic oracle 7919 x 7907 = 62615533 confirms server-side evaluation without spawning a process or touching a real resource.

02The benign oracle: proving execution without a payload

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.

upload-oracle-probe.httphttp
# 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.

03Extension bypass techniques per server stack

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.

TechniqueExample filenameWhy it worksConfirmed on
Double extensionshell.php.jpgApp checks the last extension; Apache executes on the firstApache with AddHandler directives
MIME type mismatchshell.phpContent-Type: image/jpeg bypasses MIME check; extension drives executionServers that trust Content-Type for storage but name for execution
Case variationshell.Php / shell.pHPCase-insensitive denylist on Windows or with strcasecmp missingWindows IIS, some PHP stacks on Linux with weak comparison
Null byte (legacy)shell.php%00.jpgC-level path truncation ends the name at the null bytePHP < 5.3.4; older C runtimes
Alternate PHP extensionsshell.php5 / shell.phtml / shell.pharAll processed by mod_php unless the handler list is explicitApache 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.

Proven - CriticalFile execution via uploaded .php5 extension - /api/upload - /uploads/
POST /api/upload filename="probe.php5" body: <?php echo 7919*7907; ?> ──► 201 Created Location: /uploads/probe.php5 GET /uploads/probe.php5 ──► 200 OK body: 62615533 (arithmetic evaluated by PHP runtime) baseline: same file with .txt extension returns raw source text unchanged
Oracle: arithmetic (7919 x 7907 = 62615533). No real command executed. Severity critical - server-side code execution confirmed in the upload directory. Denylist allowed .php5 while blocking .php; a literal-extension check missed this variant.

04Storage path discovery and webroot placement

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.

Upload response exposes storage path: Location: /uploads/probe.php -- Gate 2 confirmed directly Upload response opaque (200 OK, no path): probe /uploads/probe_62615533.txt ──► 200 OK body: marker webroot confirmed probe /files/probe_62615533.txt ──► 404 not this path probe /var/www/uploads/ (direct) ──► 403 or 404 outside webroot - gate 2 closed
Storage path discovery uses a benign marker file, not the dangerous extension. Gate 2 is confirmed before Gate 3 is probed.

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.

5
bypass techniques, each a separate probe
0
real commands run during confirmation
3
gates to confirm before raising critical

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.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →