Hardening Legacy Linux Web Stacks Against PHP RCE
Overview
When recent unauthenticated PHP RCE advisories dropped (targeting PHP_SESSION_UPLOAD_PROGRESS and oversized request bodies), I was in the middle of auditing several inherited Linux web servers I took over following a team retirement.
While we work through a broader migration to move legacy workloads onto fresh, Terraform-managed infrastructure, I wanted to immediately reduce our PHP attack surface and add defense-in-depth controls across the existing hosts—without causing production downtime.
Because I was working across live systems that lacked a 1:1 staging twin, I leaned heavily on Claude Code and AWS Systems Manager (SSM) to orchestrate the discovery, build hash-checked apply/rollback scripts, and run an automated curl verification suite after every change. Here is how we locked the environment down.
1. Turning Off PHP Where It Isn’t Needed
The best way to reduce PHP risk on an inherited server is to verify whether the host actually needs PHP running at all:
- Reverse Proxy Nodes: During read-only discovery, I found Nginx configurations still wired to pass
*.phprequests to an activephp-fpmpool for a legacy management tool that was no longer used. We backed up and removed the PHP location blocks, reloaded Nginx gracefully, and masked the service (systemctl mask php-fpm) so it can’t be started accidentally. - Non-PHP Application Servers: Even when a core web application doesn’t use PHP, default Apache
php.confpackages often globally route any*.phprequest (including nonexistent URLs) tophp-fpm. We disabled the global handler, denied.phpexecution across public document roots, and restricted internal database utilities strictly to VPN subnets.
2. Hardening Public PHP Workloads
Where PHP was legitimately required for public-facing web properties, we layered multiple controls at both the web server and the PHP-FPM pool:
- Removed legacy surface area: Used
<LocationMatch>at the server config level (which cannot be overridden by.htaccessfiles in the web root) to block legacy diagnostic scripts and restrict non-production vhosts strictly to internal VPN traffic. - Enforced strict request body limits: Configured
LimitRequestBodyin Apache alongside matchingSecRequestBodyLimitcaps in ModSecurity so oversized POST payloads are dropped at the edge before reaching PHP. - Locked down the PHP-FPM pool: Set
expose_php = Offto stop broadcasting version headers and enforcedphp_admin_flag[session.upload_progress.enabled] = offin the pool config. Usingphp_admin_flagensures that neither.htaccessnor runtimeini_set()calls can re-enable session upload progress.
3. Catching the 1,001-Argument ModSecurity Bypass
To block exploit attempts before they reached PHP-FPM, we added a ModSecurity virtual patch to deny any request containing the PHP_SESSION_UPLOAD_PROGRESS parameter.
Standard exploit tests immediately returned 403 Forbidden. Then we tested an edge case: what happens if an attacker stuffs a POST request with 1,000 junk parameters and puts the malicious trigger as the 1,001st parameter?
In our initial test, it bypassed the WAF rule and returned 200 OK.
Digging into the legacy ModSecurity configuration revealed why:
SecArgumentsLimitwas set to1000.- Years earlier, a previous admin had added
SecRuleRemoveById 200001—disabling the stock ModSecurity rule that rejects requests when the body parser hits a limit (REQBODY_ERROR), likely to work around false positives on complex content uploads.
Because rule 200001 was disabled, ModSecurity simply stopped inspecting parameters after argument 1,000 and passed the rest of the request through to Apache.
The Fix: After running a log-only rule to verify normal traffic patterns, we deployed a strict enforcement rule returning 400 Bad Request on any REQBODY_ERROR (any unparseable body or >1,000 arguments). Re-running the 1,001-argument test immediately flipped the response from 200 OK to 400 Bad Request.
4. Hardening systemd Against Scanner OOM Spikes
Vulnerability disclosures always bring waves of aggressive internet scanning, which can easily trigger Out-Of-Memory (OOM) events on smaller instances. During our audit, we noticed two dangerous default settings:
- Several services were running with the default
Restart=nopolicy—meaning ifsystemd-oomdkilled the web server during a traffic spike, it would stay down until manually restarted. php-fpmdefaulted toOOMPolicy=stop, meaning a single OOM-killed worker process could take down the entire PHP pool.
We deployed systemd drop-in overrides (10-restart.conf) across the fleet setting Restart=on-failure, RestartSec=5s, and OOMPolicy=continue, and removed an unused legacy background agent to free up system memory. Testing with kill -9 confirmed the web daemon recovered automatically in under 6 seconds.
Orchestrating Changes Safely with Claude Code
Making dozens of web server, WAF, and systemd changes across live production servers requires tight guardrails. Rather than hand-editing config files over SSH, I used Claude Code to help build a structured deployment workflow over AWS SSM:
- Read-Only Discovery First: Every step started with non-destructive SSM scripts checking active listeners, file checksums, and weeks of access logs to confirm nothing legitimate would break.
- Hash-Guarded Apply & Rollback Scripts: Every change was packaged as a paired
apply.shandrollback.sh. Scripts verified the SHA-256 hash of existing config files before making changes, used drop-in files (zz-*.conf) instead of editing base configs wherever possible, and verifiedapachectl configtest/nginx -tbefore reloading. - Automated Regression Suite: We built a 50+ check bash test harness that verified public pages, authentication flows, request size caps, and our ModSecurity bypass tests before and after every single step.
Forty-eight hours after wrapping up the deployment, automated scanners swept across our public IPs firing PHP_SESSION_UPLOAD_PROGRESS payloads. Every probe was dropped at the edge with a 403, with zero worker crashes or false positives.
