Nginx: Disable Execution in Upload Directories to Stop WebShells
A WebShell needs write then execute. Disable script parsing in the upload directory and an upload vuln drops to low. Nginx disable-exec for upload dirs: regex intercepts and order traps, multi-site snippets, write-path permission tightening, 403 proof test, three failure-debug points.
Applicable scenarios
Nginx disable-execution in upload directories applies to every site that accepts user uploads: avatars, attachments, images, resumes, ticket files. Even if the upload API validates file type, attackers can still land a WebShell via parse bugs, double extensions, or races. If PHP under that directory is not parsed, the WebShell is inert text. Same hardening for WordPress, Discuz, DedeCMS, and leftover legacy sites. Many old sites never restricted execution in upload dirs—one of the highest-replay intrusion paths.
Prerequisites
- Nginx 1.10+, PHP via PHP-FPM over fastcgi (this design intercepts fastcgi requests)
- Known upload directory paths, e.g. /www/wwwroot/site/upload, /uploads, /static/attach
- Permission to edit Nginx main or vhost config and reload
How it works
Whether a PHP file executes depends on whether Nginx hands the request to PHP-FPM via fastcgi_pass. A WebShell needs write + execute. Disable-execution in the upload dir is a gate on step two: Nginx location regex matches all .php, .php5, .phtml, and similar executable extensions under the upload dir and returns 403 or 404. The request never reaches PHP-FPM. Trap: in Nginx location matching, a prefix match with ^~ outranks regex. A sloppy static-asset location on the upload dir skips the regex deny. Same if the regex is placed after the generic PHP rule location ~ \.php$—order kills it. Location order and prefix tokens must follow this article exactly.
Procedure
Step 1: Add intercept rules in the site server block
server {
listen 80;
server_name example.com;
root /www/wwwroot/site;
# Disable script execution in the upload directory (must be placed before the generic PHP parse rule)
location ~* ^/(uploads|upload|static/attach)/.*\.(php|php5|php7|phtml|pht)$ {
deny all;
# Can also change to return 404; to avoid revealing "this path is specially protected" to attackers
}
# Alternative form that disables execute permission for the whole upload directory: match the directory then deny PHP
location ~* ^/uploads/.*\.php$ {
return 404;
}
# Keep the generic PHP parse rule unchanged; place it after the rules above
location ~ \.php$ {
fastcgi_pass unix:/tmp/php-cgi.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
}
Key: deny all or return 404 must live in their own location block, and that block must be evaluated before location ~ \.php$. Same-class regex locations: first match in source order wins.
Step 2: Bulk-harden multiple sites
Extract the intercept into a shared snippet and include it:
# /etc/nginx/snippets/deny-php-upload.conf
location ~* ^/(uploads|upload|images|attach)/.*\.(php|phtml|pht)$ {
deny all;
}
# Insert one line in each site vhost before the PHP parse rule
include snippets/deny-php-upload.conf;
Step 3: Tighten the write path too
Config-layer deny-exec is the backstop. Tighten writes in parallel: upload API force-renames to a random name and strips the original extension; upload dir owned by www, no execute bit (chmod -R 755 uploads && find uploads -type f -exec chmod 644 {} \;); PHP-FPM open_basedir confined to the site root.
Verification
Syntax check and graceful reload:
nginx -t && nginx -s reload
Then prove the intercept: create a test file echo '<?php echo "executed"; ?>' > /www/wwwroot/site/uploads/test.php, hit http://example.com/uploads/test.php in a browser or curl. Must return 403 (or 404). Page must not contain executed. Then hit a normal image in the upload dir: 200. Static assets unaffected. Finally check the main site: any dynamic page still parses. PHP not collateral-damaged.
FAQ
FAQ 1: Intercept configured, WebShell still executes? Three usual causes. One: regex only matches /uploads, real path is /data/attach or another alias. Dump full config with nginx -T and check paths. Two: nested location; inner location ~ \.php$ wins first. Add the same deny on the inner block. Three: attacker uploaded .user.ini or .htaccess for directory-level hijack. Deny those at Nginx too: location ~ (\.user\.ini|\.htaccess)$ { deny all; }.
FAQ 2: After disable-exec, legitimate PHP in the upload dir returns 403? That directory really has scripts that must run. That is an anti-pattern. Move executable PHP out of the upload dir into the app dir; leave only static assets. If you cannot migrate immediately, whitelist that subpath: before the deny block, add a normal parse location for that exact subdirectory, and restrict the executable file list via a whitelist comparison. Do not leave an unrestricted execute hole.
Summary
Disable-execution in upload dirs is one of the highest ROI site hardenings. One regex cuts the write path from the execute path. Upload vulns drop from fatal to low. Three landing rules: intercept location must sit before the generic PHP parse rule; paths must cover every upload alias; prove it with a real PHP file. Put this rule in the site deploy template. Pair with periodic scans of upload dirs for suspicious scripts. Close the loop.