Latest News & Blog

Blocking PHP CVE-2026-109618 / 109619 exploit probes in Varnish

Blocking PHP CVE-2026-109618 / 109619 exploit probes in Varnish

Thursday 22 October, 2026

Patching PHP closes the hole. The filter below doesn't replace that. It is a second layer: it stops the attack traffic early, saving your hosting from handling the bot scanning load, and leaves a record of who is trying.

In short: two serious PHP vulnerabilities were fixed on 21 October 2026, and automated attacks started within hours. Updating PHP is the real fix. If your websites sit behind Varnish (a caching layer many PHP sites use), you can also add a small filter that turns the attack traffic away before it ever reaches PHP. Here is what it does, why it is safe for real visitors, and how to set it up. Technical details are in the boxes, for whoever runs your servers.


What happened

PHP powers a large share of the web, including WordPress. The two vulnerabilities (CVE-2026-109618 and CVE-2026-109619) let an attacker send a specially shaped request to a PHP site and potentially take control of it. Patched versions of PHP were released on 21 October, and within hours we saw automated tools trying the attack against websites.


What the filter does

Varnish sits in front of your website and handles every request first. The filter adds three quick checks there. They only look at the outside of each request (its type and its declared size), never at what is inside, so normal visitors are not slowed down.

1) Form submissions that don't say how big they are are turned away. The attack relies on this. Web browsers always state the size when someone submits a form, so real visitors are never affected.

2) Form submissions that are far too large (over 2 MB) are turned away. A real contact or checkout form is a few KB. The published attack sends several MB.

3) Unusual request types are turned away. Automated scanners use odd request types to probe websites. Normal browsing never does.

Your own team's addresses can be exempted, so staff can still test and use admin tools.

Technical detail: the three rules

1) POST/PUT/PATCH with a form Content-Type and no Content-Length (HTTP/1.1 chunked, or HTTP/2 with no declared length) returns 411 Length Required. CVE-2026-109618 needs a multipart body with no usable declared length.

2) application/x-www-form-urlencoded with Content-Length over 2,097,152 bytes returns 413 Payload Too Large. The public proof of concept uses a field name of about 3.5 MB.

3) Any method outside GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, PURGE returns 405 Method Not Allowed (scanner noise such as TRACK, PROPFIND and HAAD).

Rules 1 and 2 skip clients in a trusted ACL. Every refusal writes a php2shell: line with std.log.


How to set it up

For whoever manages your Varnish servers, it takes five steps and about half an hour:

1) Add the filter to the Varnish configuration.
2) Switch it on.
3) Reload Varnish and check it is active.
4) Test that attack-shaped requests are refused and normal ones still work.
5) Turn on a log so you can see what it blocks.

Technical detail: steps 1 and 2, the VCL

Add this to default.vcl just above sub vcl_recv. Make sure import std; is at the top, and put your own addresses in the trusted ACL.

# Your own addresses: these skip the body rules.
acl trusted {
    "127.0.0.1";
    "203.0.113.10";      # example: office
}

# php2shell probe filter (CVE-2026-109618 / 109619). Header-only.
sub php2shell_filter {
    if (
        (req.method == "POST" || req.method == "PUT" || req.method == "PATCH") &&
        !(client.ip ~ trusted)
    ) {
        # 1. form body with no usable length (H1 chunked, or H2 unknown length)
        if (
            !req.http.Content-Length &&
            req.http.Content-Type ~ "(?i)^(multipart/form-data|application/x-www-form-urlencoded)"
        ) {
            std.log("php2shell: chunked form body " + req.http.host + req.url + " from " + req.http.X-Forwarded-For);
            return (synth(411, "Length Required"));
        }

        # 2. oversized urlencoded body
        if (
            req.http.Content-Type ~ "(?i)^application/x-www-form-urlencoded" &&
            std.integer(req.http.Content-Length, 0) > 2097152
        ) {
            std.log("php2shell: urlencoded body " + req.http.Content-Length + "B " + req.http.host + req.url + " from " + req.http.X-Forwarded-For);
            return (synth(413, "Payload Too Large"));
        }
    }

    # 3. method allow list: add any method your setup uses (e.g. BAN)
    if (req.method !~ "^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|PURGE)$") {
        std.log("php2shell: method " + req.method + " " + req.http.host + req.url);
        return (synth(405, "Method Not Allowed"));
    }
}

Then call it in vcl_recv, after any PURGE or BAN handling and before POST requests are passed to the backend:

    call php2shell_filter;

Rule 1 deliberately checks only for a missing Content-Length, not for a Transfer-Encoding: chunked header. HTTP/2 has no such header, so matching on it would miss HTTP/2 attacks.

 

Technical detail: step 3, compile and reload

A Varnish reload is asynchronous, so confirm the new VCL is active afterwards.

varnishd -C -f /etc/varnish/default.vcl >/dev/null && systemctl reload varnish
sleep 10
varnishadm vcl.list | grep ^active

 

Technical detail: step 4, testing

Run these from an address that is not in the trusted ACL. Replace example.com with one of your sites.

# rule 1: chunked form post, expect 411
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Transfer-Encoding: chunked' --data 'a=1' https://example.com/

# rule 2: 3 MB urlencoded post, expect 413
head -c 3000000 /dev/zero | tr '\0' a > /tmp/big.txt
curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-binary @/tmp/big.txt https://example.com/

# rule 3: odd method, expect 405
curl -s -o /dev/null -w '%{http_code}\n' -X TRACK https://example.com/

# control: a normal small form post should behave exactly as before
curl -s -o /dev/null -w '%{http_code}\n' --data 'a=1' https://example.com/

If a site sits behind a CDN, the CDN may add a Content-Length before the request reaches Varnish, so expect little rule 1 traffic there.

 

Technical detail: step 5, the log
nano -w /etc/systemd/system/varnish-php2shell.service

Contents of which are;

[Unit]
Description=varnishncsa log of requests refused by php2shell_filter
After=varnish.service
Requires=varnish.service

[Service]
Type=simple
ExecStart=/usr/bin/varnishncsa -q 'VCL_Log ~ "php2shell"' -F '%%t %%{X-Forwarded-For}i %%m %%{Host}i%%U%%q %%s cl=%%{Content-Length}i ct="%%{Content-Type}i" ua="%%{User-Agent}i" %%{VSL:VCL_Log}x' -w /var/log/varnish/php2shell.log -a
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
User=varnishlog
Group=varnish

[Install]
WantedBy=multi-user.target

Gotcha: in a systemd unit a literal % must be written %%, or the service fails with "Invalid slot". The varnishlog user name varies by distribution: check with id varnishlog.

Log rotation, in /etc/logrotate.d/varnish-php2shell;

/var/log/varnish/php2shell.log {
  weekly
  rotate 12
  missingok
  compress
  delaycompress
  notifempty
  postrotate
    systemctl -q is-active varnish-php2shell.service && systemctl reload varnish-php2shell.service || true
  endscript
}

Enable and watch;

systemctl daemon-reload
systemctl enable --now varnish-php2shell.service
tail -f /var/log/varnish/php2shell.log

What we saw, and is it safe?

On the websites we look after, the filter caught real attack attempts on its first night. They were aimed at WordPress sites, disguised as ordinary web browsers, and every one was turned away. The rest was routine scanning. No genuine visitor was blocked.

Attacks only reached sites that were directly reachable. Sites behind a CDN such as Cloudflare saw almost none.

Because every refusal is logged, it is easy to check that nothing legitimate is being caught. If something ever is, you exempt that one site or page rather than switching the filter off.

Technical detail: the attack pattern and false positives

Exploit attempts: chunked application/x-www-form-urlencoded POSTs to /wp-admin/admin-ajax.php, a fake desktop browser User-Agent, and a forged 127.0.0.x address at the start of X-Forwarded-For. All refused with 411 by rule 1.

Scanner noise: PROPFIND, TRACK and misspelled methods (e.g. HAAD) aimed at bare server IPs. All 405.

To check for false positives, look at log lines from your own addresses, or browser User-Agents that don't fit the pattern above. A genuine client caught by rule 1 would be something like an API tool streaming a form body without a length: exempt that host or path from rule 1.


Optional extra: a stricter check

There is a fourth check you can add if you want to go further. It looks inside file uploads for a marker the public attack tool uses, and turns those requests away. It is the most precise of the checks, and it also catches attempts that the first three would let through.

The trade-off is cost. To look inside, Varnish has to hold each upload (up to 1 MB) in memory before passing it on, which can slow uploads on busy sites. Uploads bigger than 1 MB aren't checked at all. And if any of your sites shows a progress bar during uploads, this check may break it. Our advice: run it in "log only" mode for a week first, and switch it on only if nothing legitimate shows up.

Technical detail: rule 4, multipart body inspection

The public exploit tool reaches CVE-2026-109619 through PHP's session upload progress, via a PHP_SESSION_UPLOAD_PROGRESS field in a multipart body. Needs the bodyaccess vmod from varnish-modules: add import bodyaccess; at the top of default.vcl, then put this inside the untrusted-POST block, after rule 2.

        # 4. session upload-progress field in a multipart body (bodies up to 1 MB)
        if (
            req.http.Content-Type ~ "(?i)^multipart/form-data" &&
            std.integer(req.http.Content-Length, 0) <= 1048576
        ) {
            if (std.cache_req_body(1MB) &&
                bodyaccess.rematch_req_body("PHP_SESSION_UPLOAD_PROGRESS") > 0) {
                std.log("php2shell: upload_progress field " + req.http.host + req.url + " from " + req.http.X-Forwarded-For);
                return (synth(403, "Forbidden"));
            }
        }

Log-only mode: comment out the return (synth(403, ...)) line. Bodies over 1 MB pass uninspected. Won't compile without varnish-modules.


Taking it out later

This is a temporary measure. Once the log goes quiet, usually a few weeks after everyone has patched, the filter can be removed. We suggest putting a review date in the diary on the day you add it.

Technical detail: removal

Delete sub php2shell_filter and the call php2shell_filter; line (or revert the commit if your VCL is in git), then compile and reload as in step 3. Then:

systemctl disable --now varnish-php2shell.service
rm /etc/systemd/system/varnish-php2shell.service /etc/logrotate.d/varnish-php2shell
systemctl daemon-reload

Tested on Varnish 6.6 with varnish-modules 0.18.

 

If you'd like help checking your servers or setting this up, get in touch.

Written By...

Lee Simpson

Software Engineer from the command-line era, now deep in AI. Linux wizard, proud father of 3 boys.