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/passwd or application code outside the allowed paths. Configured per-pool via php_admin_value[open_basedir].
  • disable_functions: removes dangerous PHP functions such as exec, system, passthru, shell_exec, and proc_open. Without these, a webshell cannot spawn system processes. Configured per-pool via php_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

SignalWhy it mattersWarning sign
HTTP 200 for .php in restricted pathsConfirmed remote code executionAny single occurrence means the host is compromised
HTTP 403/404 for .php in restricted pathsProbing attempts that were blockedRising volume indicates targeted reconnaissance
Per-worker script field (FPM full status)Shows which PHP file each worker is executing right nowScript paths in upload, tmp, or media directories during incident response
Status page exposureIf publicly accessible, attacker can read request URIs, script paths, and pool configPublic HTTP 200 on /fpm-status or /status
Pool process ownerWorkers running as root or a shared user amplify blast radiusAll pools running as www-data or root

How Netdata helps

  • HTTP log analysis at per-second granularity catches any 200 response for a .php file 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 script field 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.