A correctly configured web server and PHP-FPM stack never returns HTTP 200 for a .php file inside an upload, temporary, or media directory. If it does, that file executed as PHP code. In production, that file is a webshell, and the host is compromised.
This is a binary condition. The PHP-FPM playbook assigns it PAGE severity because a healthy server cannot produce this signal. If your access logs show a 200 response for a PHP file in a restricted path, start incident response immediately: isolate the host, preserve forensic data, and assume the attacker has achieved code execution within the PHP-FPM worker’s user context.
What this detects
The playbook defines this as PHP Execution on Restricted Paths: any HTTP 200 response for a .php file in a user-content directory (uploads, tmp, media, cache, or equivalent).
A 200 means the web server forwarded the request to PHP-FPM, a worker accepted it, and PHP executed the file. There is no gray area: a correctly configured server returns 403 or 404 for any .php request in these directories, so this signal cannot false-fire on a healthy system.
Modern PHP frameworks route all traffic through a single front controller (index.php). Direct .php access outside the application root is almost always malicious. On a framework-based application (Laravel, Symfony, WordPress with rewrites), any request for a standalone .php file in an upload directory is a webshell by definition.
Blocked attempts (403 or 404 responses) are not incidents, but they are intelligence worth collecting. A rising volume of 403 or 404 probes for .php files in upload paths indicates targeted reconnaissance. Log them for forensics and alert on volume spikes.
The attack vectors
Two mechanisms allow PHP-FPM to execute code in directories where it should not.
Direct .php upload
The application accepts a file upload and stores it in a writable directory such as /uploads/, /tmp/, or /media/. The file is named with a .php extension. When the attacker requests it, the nginx PHP handler location block matches \.php$ and forwards the request to PHP-FPM. The worker executes the file.
This succeeds when nginx does not explicitly deny PHP execution in the upload directory. The security.limit_extensions setting does not help here because the file legitimately has a .php extension and is in the allowed list. The protection must come from the web server.
The nginx path-info exploit
The attacker uploads a file with a benign extension, for example avatar.jpg, that contains embedded PHP code. They then request it using a crafted path that triggers PHP’s path-info resolution:
GET /uploads/avatar.jpg/index.php
With cgi.fix_pathinfo = 1 (the PHP default), PHP resolves the path by walking backward until it finds an existing file. It sets SCRIPT_FILENAME to /uploads/avatar.jpg and PATH_INFO to /index.php. PHP-FPM then attempts to execute avatar.jpg as PHP.
This vector is blocked by security.limit_extensions when set to its default, because .jpg is not in the allowed list. But if an operator sets security.limit_extensions to empty to resolve an application issue, the restriction is removed entirely and the exploit succeeds.
The following diagram shows how a request traverses each defense layer and where it should be blocked:
flowchart TD
A["GET /uploads/shell.php"] --> B{"nginx: deny location
for /uploads/ matches?"}
B -->|"yes, deny first"| C["403 Forbidden
blocked"]
B -->|"no, PHP handler matches"| D{"try_files $uri =404
file exists on disk?"}
D -->|"missing"| E["404 Not Found
blocked"]
D -->|"exists"| F["PHP-FPM receives
SCRIPT_FILENAME"]
F --> G{"security.limit_extensions
allows file extension?"}
G -->|"no, .jpg blocked"| H["FPM denies
blocked"]
G -->|"yes, .php allowed"| I["Worker executes PHP
HTTP 200: confirmed RCE"]Only one path reaches the bottom node. Every other branch is a defense layer that, when correctly configured, blocks the request.
Detection procedure
All commands below are read-only and safe on production hosts. Customize the directory list to match your application’s actual upload paths.
# Confirmed RCE: any PHP file executed in a restricted directory
grep -E "(uploads|tmp|media|cache)/.*\.php.*\" 200" /var/log/nginx/access.log
# Include rotated logs during incident response
zgrep -E "(uploads|tmp|media|cache)/.*\.php.*\" 200" /var/log/nginx/access.log*
# Blocked probes (forensic intelligence, not an incident)
grep -E "(uploads|tmp|media|cache)/.*\.php.*\" 40[34]" /var/log/nginx/access.log
# Currently executing scripts in PHP-FPM workers
# (status path varies: check pm.status_path in your pool config)
curl -s "http://127.0.0.1/fpm-status?full" | grep -i "script"
# Check cgi.fix_pathinfo in the FPM SAPI config
# (do not use php -r ini_get -- it reads CLI config, not FPM)
grep -r "cgi.fix_pathinfo" /etc/php/*/fpm/
# Check which extensions PHP-FPM will execute
grep -r "security.limit_extensions" /etc/php/*/fpm/pool.d/
A non-empty result from the first command is a confirmed compromise. The matching log line identifies the webshell file, the requesting IP, the timestamp, and the user agent. Preserve this data before taking remediation action. Modifying or deleting the webshell destroys forensic evidence.
If the cgi.fix_pathinfo check returns 1, the path-info exploit surface is present. If the security.limit_extensions check returns empty or is missing from the output, verify the compiled default is in effect.
Lockdown: preventing PHP execution in user-content paths
Three independent configuration layers prevent PHP execution in restricted directories. Each addresses a different vector. Deploy all three.
nginx location blocks with correct ordering
nginx evaluates regex location blocks in order of appearance. The first match wins. A deny rule for upload directories must appear before the general PHP handler, or the deny rule is never evaluated.
# Deny PHP execution in user-content directories
# This block MUST come before the general PHP handler
location ~* ^/(uploads|tmp|media|cache)/.*\.php$ {
deny all;
}
# General PHP handler
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
# ...
}
The try_files $uri =404 directive inside the PHP handler is a second web-server-side defense. It verifies the file exists on disk before forwarding to PHP-FPM. Non-existent files, including path-info exploit attempts that resolve to a non-PHP file, get a 404 without involving PHP-FPM.
try_files only works when nginx and PHP-FPM share the same document root. If PHP-FPM runs on a separate host with a different filesystem layout, nginx cannot verify file existence. In that topology, rely on security.limit_extensions and nginx deny rules.
security.limit_extensions in PHP-FPM pool config
Restricts which file extensions the pool will execute. The compiled default blocks the path-info exploit because .jpg is not in the allowed list.
; /etc/php/8.x/fpm/pool.d/www.conf
security.limit_extensions = .php .phar
Do not set this to empty. The PHP manual explicitly warns that an empty value allows all extensions. If an operator sets it to empty to work around an application issue, the path-info exploit becomes exploitable on any uploaded file regardless of extension.
cgi.fix_pathinfo
The third layer. Setting cgi.fix_pathinfo = 0 in php.ini disables PHP’s path-info resolution. This setting has limited effect under PHP-FPM. The real protections against the path-info exploit are security.limit_extensions and nginx try_files. Treat cgi.fix_pathinfo = 0 as defense in depth, not a primary mitigation.
Defense in depth: limiting blast radius
If a webshell does execute, these per-pool settings limit what the attacker can do:
open_basedir: restricts which filesystem paths PHP can access. A compromised worker cannot read/etc/passwdor application code outside the allowed paths. Configured per-pool viaphp_admin_value[open_basedir].disable_functions: removes dangerous PHP functions such asexec,system,passthru,shell_exec, andproc_open. Without these, a webshell cannot spawn system processes. Configured per-pool viaphp_admin_value[disable_functions].- Process user isolation: each pool should run as a distinct, least-privilege user. If a pool runs as root or a shared user, a compromised application in one pool can affect others.
These do not prevent execution in upload directories. They reduce the damage when prevention fails.
Verifying the lockdown
After applying the configuration, verify that PHP execution in restricted paths is blocked. Test on a non-production host if possible.
# Create a test file in an upload directory
# WARNING: remove immediately after testing. File integrity monitoring
# and automated security scanners may flag this file as a webshell.
echo '<?php echo "rce-test"; ?>' > /var/www/html/uploads/_rce_check.php
# Request it: must return 403 or 404, never 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost/uploads/_rce_check.php
# Clean up
rm /var/www/html/uploads/_rce_check.php
If the response code is 200, the lockdown is incomplete. Re-check location block ordering, security.limit_extensions, and try_files before proceeding.
Common pitfalls
Location block ordering. nginx evaluates regex locations in order of appearance. If the general PHP handler (location ~ \.php$) appears before the upload-directory deny rule, the deny rule is never reached. Verify with nginx -T to see the effective configuration order.
Setting security.limit_extensions to empty. The PHP manual warns that an empty value allows all extensions. Operators sometimes set this to empty to resolve a legitimate application issue, re-enabling the path-info exploit. If the setting must change, explicitly list the extensions the application requires.
try_files with separate document roots. try_files $uri =404 checks the local filesystem. If PHP-FPM runs on a separate host or container with a different mount layout, nginx cannot verify file existence and the directive is ineffective. In split topologies, rely on nginx deny rules and security.limit_extensions.
Path traversal bypass. Path traversal sequences (../) can bypass directory-based restrictions if the location regex is not anchored. A request like /uploads/../../app/config/shell.php can escape the upload directory. Anchor deny rules to the start of the path (^/uploads/) and test with traversal sequences.
Relying solely on cgi.fix_pathinfo. cgi.fix_pathinfo has limited effect under PHP-FPM. Setting it to 0 is not sufficient on its own. Always pair it with security.limit_extensions and nginx-side controls.
Signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
HTTP 200 for .php in restricted paths | Confirmed remote code execution | Any single occurrence means the host is compromised |
HTTP 403/404 for .php in restricted paths | Probing attempts that were blocked | Rising volume indicates targeted reconnaissance |
Per-worker script field (FPM full status) | Shows which PHP file each worker is executing right now | Script paths in upload, tmp, or media directories during incident response |
| Status page exposure | If publicly accessible, attacker can read request URIs, script paths, and pool config | Public HTTP 200 on /fpm-status or /status |
| Pool process owner | Workers running as root or a shared user amplify blast radius | All pools running as www-data or root |
How Netdata helps
- HTTP log analysis at per-second granularity catches any 200 response for a
.phpfile in an upload directory within seconds. Alert on this as a page, not a trend. - PHP-FPM integration exposes worker counts, active/idle ratios, and per-worker script paths via the full status page. During an incident, the
scriptfield shows what each worker is executing right now. - Cross-correlation links upload-path request spikes to FPM worker utilization, opcache invalidation, and system resource usage.
- Anomaly detection flags unusual request paths, status code distributions, and traffic from new source IPs without hand-tuned thresholds.
Related guides
- PHP-FPM 502 Bad Gateway: the web server cannot reach the pool
- PHP-FPM 504 Gateway Timeout: requests accepted but never finishing in time
- PHP-FPM active processes near max_children: reading pool utilization
- PHP-FPM in containers: cgroup limits and the silent OOM kill
- PHP-FPM “child N exited on signal 11 (SIGSEGV)”: worker segfaults
- PHP-FPM “connect() failed (111: Connection refused) while connecting to upstream”
- PHP-FPM crash loop and fork storm: workers dying faster than they serve
- PHP-FPM dynamic mode scaling lag: why the pool cannot keep up with bursts
- PHP-FPM emergency restart: “failed processes threshold reached, initiating reload”
- PHP-FPM graceful reload: the brief no-worker window on SIGUSR2
- How PHP-FPM actually works in production: a mental model for operators
- PHP-FPM idle processes at zero: no burst headroom left






