Defending File Uploads
Almost every attack in this cluster ends the same way: a file the attacker controls is stored somewhere it can execute or be served back dangerously. The highest-leverage fix is a design choice - put uploads where they can’t run - and validation then stops the bad file ever landing. This page is ordered by effectiveness; the first control neutralises web-shell RCE even when a filter is bypassed.
1. Store uploads so they can’t execute (the design fix)
Section titled “1. Store uploads so they can’t execute (the design fix)”This is the control that matters most, because it removes the impact even if a malicious file gets through:
- Store outside the webroot, or in object storage / a bucket, and serve files through a download script - never let the upload directory be a URL the server will execute.
- If files must live on disk under a served path, make that directory non-executable: no PHP/CGI handler,
php_admin_flag engine off(Apache), or a location block that serves everything as a static download. - Better still, keep uploads on a separate server or container from the app, so a web shell that does run compromises only the upload host.
Get this right and an uploaded shell.php is an inert file, not RCE - which is why the bypass pages in this cluster all assume it’s missing.
2. Validate extension and content together - and require they match
Section titled “2. Validate extension and content together - and require they match”Neither check alone is enough; do both, and confirm they agree:
- Extension: whitelist the allowed types with an end-anchored test (
/\.(png|jpe?g|gif)$/) soshell.php.pngis the only accepted shape, and blacklist dangerous extensions as a backstop (.php,.phtml,.phar,.php5,.asp,.aspx,.jsp) in case the whitelist is bypassed. The blacklist should match the extension anywhere in the name (shell.php.pngstill contains.php). - Content: verify the file signature (magic bytes) and the MIME type (
mime_content_type()on the stored temp file, not the client-suppliedContent-Typeheader), and check they match the claimed extension. - Validate on the back end. Client-side checks are UX only and are trivially bypassed.
3. Randomise stored names and broker access
Section titled “3. Randomise stored names and broker access”Closes filename injection and directory disclosure:
- Generate a random stored name (e.g. a UUID) and build every filesystem path from that generated name, never from the user-supplied filename. Because the user’s name never reaches a path, this removes path-based filename attacks (traversal, extension tricks on disk).
- It does not, on its own, stop SQL or command injection via the original filename - those depend on how the name is used, not where the file is stored. If you keep the original name (for display/download), write and read it with parameterised queries (it’s untrusted data, not “sanitised”), and if any processing step takes the name, pass it to a native API or an argv vector with no shell - never interpolate it into a query string or a command line.
- Serve downloads through a script that looks up the file by id, enforces authorization (so it isn’t an IDOR/LFI in its own right), and sends
Content-Disposition: attachment,X-Content-Type-Options: nosniff, and an accurateContent-Type. - Return
403on direct access to the storage directory.
4. Harden the runtime (limits the blast radius)
Section titled “4. Harden the runtime (limits the blast radius)”- Least privilege: run the app as a low-privileged user so a shell that does execute isn’t root and can’t read unrelated secrets.
- Disable dangerous sinks: PHP
disable_functions = system,exec,shell_exec,passthru,popen,proc_open,pcntl_execwhere unused - this is what blunts a web shell that reaches execution. - OS-enforced isolation: the separate upload server/container from section 1, plus seccomp/AppArmor, is what actually confines a running shell. Note PHP’s
open_basedirrestricts PHP’s own file functions only; a command spawned viasystem()runs outside its scope, so don’t rely on it to contain a successful upload-to-RCE. - Limits and hygiene: cap file size (blunts DoS uploads), keep image/parsing libraries patched, and scan uploads for known-malicious content.
5. Defense in depth
Section titled “5. Defense in depth”- WAF: catches known shell/polyglot signatures; a secondary layer, evadable, never the fix.
- Logging and alerting: uploads of double-extensions, executable types, or files whose content and extension disagree are high-signal. Suppress verbose server errors so a failed upload doesn’t leak the storage path or stack traces.
For the report
Section titled “For the report”- Primary (design): store uploads outside the webroot / in a non-executable location or separate host, served via a brokered download script.
- Validation: whitelist + blacklist the extension and validate signature/MIME, requiring all to agree, on the back end.
- Naming/access: build disk paths from a random generated name; treat the original filename as untrusted (parameterised DB writes, no-shell processing); authorized download script with
nosniff+Content-Disposition,403on the directory. - Runtime: low-privileged account,
disable_functions, OS isolation (notopen_basediralone), size limits, patched libraries.
Avoid presenting a single check - a MIME test, an extension blacklist, a WAF - as the fix; this cluster is a catalogue of how each falls on its own.
Related
Section titled “Related”- Shells - the payloads non-executable storage renders inert
- Content-Type bypass - why content validation alone isn’t enough
- Post-upload - the filename/disclosure issues section 3 closes