phpguard: Shield unpatched PHP from php2shell multipart attacks
Pro module
nginx-module-phpguard requires the Pro plan ($20/mo or $200/yr per server) or higher. The Plus plan covers open-source modules only. Upgrade to Pro.
Debian/Ubuntu installation
These docs apply to the APT package nginx-module-phpguard provided by the GetPageSpeed Extras repository.
- Configure the APT repository as described in APT repository setup.
- Install the module:
sudo apt-get update
sudo apt-get install nginx-module-phpguard
Show suites and architectures
| Distro | Suite | Component | Architectures |
|----------|-------------------------|-------------|-----------------|
| debian | bookworm | main | amd64, arm64 |
| debian | bookworm-early | main | amd64, arm64 |
| debian | bookworm-mainline | main | amd64, arm64 |
| debian | bookworm-mainline-early | main | amd64, arm64 |
| debian | trixie | main | amd64, arm64 |
| debian | trixie-early | main | amd64, arm64 |
| debian | trixie-mainline | main | amd64, arm64 |
| debian | trixie-mainline-early | main | amd64, arm64 |
| ubuntu | focal | main | amd64, arm64 |
| ubuntu | focal-early | main | amd64, arm64 |
| ubuntu | focal-mainline | main | amd64, arm64 |
| ubuntu | focal-mainline-early | main | amd64, arm64 |
| ubuntu | jammy | main | amd64, arm64 |
| ubuntu | jammy-early | main | amd64, arm64 |
| ubuntu | jammy-mainline | main | amd64, arm64 |
| ubuntu | jammy-mainline-early | main | amd64, arm64 |
| ubuntu | noble | main | amd64, arm64 |
| ubuntu | noble-early | main | amd64, arm64 |
| ubuntu | noble-mainline | main | amd64, arm64 |
| ubuntu | noble-mainline-early | main | amd64, arm64 |
| ubuntu | resolute | main | amd64, arm64 |
| ubuntu | resolute-early | main | amd64, arm64 |
| ubuntu | resolute-mainline | main | amd64, arm64 |
| ubuntu | resolute-mainline-early | main | amd64, arm64 |
An nginx module that shields unpatched PHP from the request-borne multipart paths of php2shell (CVE-2026-109618, CVE-2026-109619, CVE-2026-115560) without touching PHP. It was tested against PHP 5.6, 7.4, 8.1, 8.4 and 8.5, behind both PHP-FPM and Apache mod_php.
It is a virtual patch, not a fix. The PHP patches repair the memory bugs inside PHP. phpguard leaves them in place and stops attacker requests from reaching them through the multipart parser. Upgrade PHP when you can.
What it does
phpguard only acts on multipart/form-data requests; every other request
costs one header check. For a multipart request, nginx reads the whole
body before any byte reaches PHP and checks it with a streaming parser
that mirrors PHP's own main/rfc1867.c:
- Refuses the session upload progress field (
PHP_SESSION_UPLOAD_PROGRESS, or whateversession.upload_progress.nameis) in every spelling PHP accepts. This field is the confirmed trigger for CVE-2026-109619 and CVE-2026-115560. PHP's upload progress bars stop working as a result. - Accepts only canonical multipart. Anything where PHP and a correct parser could disagree is refused:
- preamble or epilogue;
- LF-only lines;
- NUL or control bytes in headers;
- folded or duplicate headers;
- a boundary string anywhere except a real delimiter, including the part PHP finds after 5120 LF-free bytes when it skips an upload it does not keep;
- ambiguous quoting.
A smuggled part therefore has nowhere to hide.
- Bounds PHP's parse memory. Every part is given an estimated cost,
measured on all tested PHP versions and rounded up:
- 2 KB per part;
- 1 KB per [ level of a field name, 5 KB per level for a file input,
since PHP builds five $_FILES arrays per level and does not count
empty file inputs against max_file_uploads;
- 4 bytes per byte of form value.
The request is refused once the total exceeds phpguard_max_memory.
This covers the parse-time out-of-memory path of CVE-2026-109618.
It does not cover:
- out-of-memory conditions an application reaches through other input;
- the other stale-state paths of CVE-2026-109619 that any fatal error can open (OPcache, the 8.5 fatal-error backtrace);
- any future bug outside the multipart parser.
Configuration
server {
phpguard block; # off | detect | block (on = block)
phpguard_status 403; # policy refusals; malformed bodies get 400
# defaults shown
phpguard_progress_name PHP_SESSION_UPLOAD_PROGRESS; # repeatable
phpguard_max_memory 32m; # estimated PHP parse memory per request
phpguard_max_parts 1000;
phpguard_max_name_length 512;
phpguard_max_nesting 32; # '[' in a field name
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm.sock;
include fastcgi_params;
}
}
Enable it at server level, so the body is inspected wherever it is first
read. $phpguard holds the refusal reason (empty when the request passed)
for logs. It works with request buffering on or off, HTTP/1.1, chunked and
HTTP/2, in front of FastCGI or a proxied Apache. Keep phpguard_max_memory
well under the smallest memory_limit among your PHP apps.
Performance
| Request | Cost |
|---|---|
| Not multipart (GET, urlencoded, JSON, static) | One Content-Type check; no measurable difference in worker CPU or req/s |
| Small multipart form | At most ~4 µs of worker CPU per request; req/s within noise |
| File uploads | Parser runs at ~3 GiB/s on random data (~0.33 ms CPU per MB); 160–360 MiB/s on an adversarial all-dash body |
| Memory | ~5 KB per multipart request in flight; the body is not copied |
Multipart bodies are always fully buffered before the backend sees them.
That is already nginx's default; with *_request_buffering off, uploads
lose streaming to the backend.
Proof (lab/)
The lab runs nginx 1.30.6, built with ASan and UBSan, against PHP 5.6, 7.4, 8.1, 8.4 and 8.5 as both PHP-FPM and Apache mod_php: ten targets. Each target has three pools:
- normal;
upload_max_filesize=1K;file_uploads=Off.
The last two force PHP's skip-by-line-scan path.
The oracle is PHP itself. With session.upload_progress.cleanup=0, the
session file gains an upload_progress_ key exactly when the vulnerable
code ran; $_POST and $_FILES are checked too.
| Suite | Cases | Reached PHP's progress code unguarded | Through phpguard |
|---|---|---|---|
| Hand-built variants (38 spellings × 10 targets × 3 pools, buffered and unbuffered) | 1,140 | 812 | 0 |
| Grammar fuzzer through real PHP | 30,000 | 1,832 | 0 |
| Legit clients (urllib3, browser-shaped, curl; HTTP/1.1, chunked, HTTP/2) | 340 | n/a | 328 passed, PHP saw identical data |
| Memory budget, at and over each limit | 110 | n/a | over-budget refused; worst allowed parse 23.4 MiB |
| Random mixes aimed just under the budget | 400 | n/a | all passed; peak ≤ 61% of estimate, max 17.5 MiB |
Before the cost model existed, bodies built from 999 empty file inputs
with 32-deep names passed the old per-field limits. PHP spent about
120 MiB parsing them, enough to fail PHP 8.1 at the default 128 MiB
memory_limit. The cost model refuses them.
The 12 refused legit bodies all use a field name ending in a backslash.
PHP misparses those anyway: it turns name="x\"; filename="a.pdf" into a
POST field instead of the upload.
The parser seam (ngx_http_phpguard_lex.h, ngx_http_phpguard_parse.h)
is also checked by:
t/unit.c, under ASan/UBSan;- a libFuzzer target that asserts memory safety and identical verdicts for any split of the body across buffers;
t/bench.c, which measures parser throughput.
fuzz/regressions/ keeps every input the fuzzer has found.
make unit # hermetic seam tests
make fuzz # 10 min libFuzzer (needs Homebrew LLVM on macOS)
make lab-test # Docker lab: variants, legit, budgets, fuzz
cd lab && docker compose exec -T harness python3 run.py stress bench
Known limits
mbstring.encoding_translation=Onconverts field names after parsing; this is not modelled.- A field or file name ending in a backslash is refused (see above).
- Applications that read
php://inputthemselves bypass PHP's multipart parser, so they are outside this module's scope.