Перейти к содержанию

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.

  1. Configure the APT repository as described in APT repository setup.
  2. 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 whatever session.upload_progress.name is) 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=On converts 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://input themselves bypass PHP's multipart parser, so they are outside this module's scope.