PHP2Shell (CVE-2026-109618 / CVE-2026-109619)
Refusing the PHP_SESSION_UPLOAD_PROGRESS field on every site with nothing to configure: what it blocks, what it deliberately allows, and where the 4 KB body preview ends.
Artica EdgeGuard refuses any request carrying the form field PHP_SESSION_UPLOAD_PROGRESS, the attack surface named by the published PHP2Shell workaround.
There is nothing to create, scope or compile: the rule is active on every protected site from the moment the agent updates.
CVE-2026-109618 and CVE-2026-109619, reported together as "PHP2Shell", are two remote code execution flaws in PHP, each scored 9.2 on CVSS v4.
Anyone able to send HTTP requests to a PHP application can reportedly run code on the server without logging in.
This refuses a request on its way to PHP. It does not repair PHP. Update the PHP servers behind the reverse proxy as soon as fixed versions are published.
Why this field
The technical details of both flaws were unconfirmed when this was written. What is published is a workaround: turn off PHP's upload-progress feature (session.upload_progress.enabled), which is what points at the surface.
PHP begins tracking an upload when a request carries a form field named PHP_SESSION_UPLOAD_PROGRESS. Ordinary web applications essentially never send it, so its presence is the signal — and refusing it is the same decision as turning the feature off, taken one hop earlier and without touching the PHP configuration.
Nothing to configure
Unlike the rule described for the legacy Web Application Firewall, there is no rule to create, no phase to choose, no scope to set and nothing to compile.
The protection ships with the engine and is on for every site, including sites whose WAF settings have never been touched.
It can be switched off, and that is the only way it is off: Configuration → Global Settings → Attack Detection → Core Detection, last entry in the list.

The switch inherits down the hierarchy like every other detection:
| Global Settings | the default for the whole installation — stored as on |
| Groups | overrides the global default for the sites in a group |
| Tenants | overrides the group, for one site |
| Path Rules | overrides the site, on one path |
At the three levels below global, leaving the switch untouched means inherit — it does not mean off. The engine treats an unset value as enabled, so a site whose configuration predates this release keeps the protection rather than quietly losing it.
What is refused
The rule reads parameter names, not the text of the request, in every envelope a form field can arrive in.
| Multipart upload | curl -F PHP_SESSION_UPLOAD_PROGRESS=test -F file=@test.txt — the shape the published proof of concept uses |
| Urlencoded body | name=x&PHP_SESSION_UPLOAD_PROGRESS=test |
| Query string | ?PHP_SESSION_UPLOAD_PROGRESS=test |
| Any capitalisation | php_session_upload_progress is refused too. Deliberately wider than PHP: PHP indexes $_POST with the configured name and those keys are case-sensitive, so a differently-cased field almost certainly does not trigger the feature. Refusing it anyway costs nothing — no application sends a field by that name in any casing — and covers the gap between how a WAF parses a request and how PHP does. |
| Percent-encoded names | PHP%5FSESSION%5FUPLOAD%5FPROGRESS is the same field |
What is not refused, and why it matters
A request that merely mentions the field is left alone. A value is not a name.
This distinction is the whole design rather than a detail. A rule that matched the text of the request would refuse this page — its own documentation contains the field name, and publishing it sends that text in a form. It would then refuse the ticket, the changelog and the advisory that quote it, and the security engineer pasting the name into a search box.
?q=PHP_SESSION_UPLOAD_PROGRESS |
allowed — a search for the name |
body=the field PHP_SESSION_UPLOAD_PROGRESS is what PHP watches |
allowed — somebody writing about it |
a multipart part named content whose value quotes the field |
allowed — a page being published |
an ordinary upload: csrf_token, file, description |
allowed — nothing changes for sites that do not send the field |
Testing it
These three cases ship as part of the appliance's self-test, which runs them alongside seven others and reports a pass or a failure for each:
./tools/waf-selftest.sh https://www.example.com
To send them by hand, from any machine, use the scheme and port the site actually listens on.
echo test > test.txt
# With the field: EdgeGuard answers 403
curl -s -o /dev/null -w '%{http_code}\n' \
-F PHP_SESSION_UPLOAD_PROGRESS=test -F file=@test.txt https://www.example.com/
# Without it: the site answers as usual
curl -s -o /dev/null -w '%{http_code}\n' \
-F file=@test.txt https://www.example.com/
# The name as a value: also answered by the site, which is the point
curl -s -o /dev/null -w '%{http_code}\n' \
--data-urlencode 'q=PHP_SESSION_UPLOAD_PROGRESS' https://www.example.com/
The published exploit, and why not to point it at your own site
A public tool now implements the chain. It is not a payload generator despite the name: it sends HTTP requests that drive an out-of-memory condition during upload-progress handling, read leaked heap and libphp pointers out of the next response's Set-Cookie, and forge structures so that a destructor path calls zif_system. Its own documentation says the chain is disruptive — large bodies, out-of-memory conditions, and killed server processes.
That matters for how you verify this, in two ways.
First, running it against a live site is a denial of service whether or not the exploit succeeds. If you want the full chain demonstrated, the tool brings its own disposable lab; put EdgeGuard in front of that.
Second, and less obvious: a WAF cannot be judged by whether a memory-corruption chain succeeds. What a WAF can do about this class is refuse the request that starts it, before PHP processes the body. That is a different question, and it is answerable without breaking anything.
The repository carries that test:
node scripts/live-waf-test/cve-2026-109618.js your-host
It sends the field in all six shapes it can arrive in — url-encoded, multipart, in the query string, lower case, mixed case, and multipart alongside a real file upload — with tiny bodies that provoke nothing. It also sends the two cases that must not be refused: the field name as the value of another field, and as a search term. On 03/11/2026, against a Debian 9 host running nginx 1.10.3 and PHP 7.3.10, all nine answered correctly.
What that measures is that the request which starts the chain does not reach PHP. It is worth being precise that this is not the same as immunity: the trigger is refused, which is what this rule is for, and nothing here demonstrates the chain failing.
This is what the console shows while that battery runs: one row per attempt, every one refused, each scoring 5 on its own.

The shapes are not distinguishable in this table and the screenshot does not claim they are — most of them are a POST / whose difference is in the body, and only the query-string one shows the field in its URI. What the rows do show is that each attempt was refused on its own, which is the part that matters.
The SEVERITY column says medium while this page calls the rule critical, and both are right. They measure different things. The rule's severity is how much it contributes, and critical is 5 points. The column's severity is how bad the whole request was, derived from the total score: 5 is the least that blocks, so a request refused by this rule and nothing else sits at the bottom of the blocking range. A request carrying an actual payload — the one further down this page scored 59 — reads as critical there.
Reading a block
Each refusal is one row in Traffic → Security Events, under the attack type PHP Upload-Progress Field.

That row is not a test. It is a real exploitation attempt, and it is worth reading closely.
A POST to /index.php from an address in Germany, carrying no user agent at all — not a forged one, none: a bare tool rather than anything pretending to be a browser. It scored 59, which is not a probe. Thirteen rules fired on one request.
Opening the row shows what the request was and what decided it.

This rule contributed 5 against a threshold of 5, so it refuses on its own without needing anything else to agree. What the rest of the list shows is why the score reached 59: the field did not merely exist, it carried a payload. The SQL-injection, command-injection and XML-injection detectors each fired inside the PHP_SESSION_UPLOAD_PROGRESS parameter, and CRS 920120 — attempted multipart/form-data bypass — fired on the envelope, alongside the PHP-injection and remote-code-execution rules of the 932 and 933 families.
Under Matched Rules, management plane 2.4.43 and later show the two CVE identifiers linked to their NVD entries, and the evidence beside each rule says where it matched and what text matched it.
The value is never stored. Where the matched text would be, the event reads
(value withheld: sensitive parameter)— which is why the screenshot above shows a request that carried a webshell without showing the webshell. The value is not what this rule reads, and keeping it would put request contents in the event table.
Turning it off for one place
If the rule ever refuses something legitimate, it is switched off the way every other rule is: an exclusion scoped to a path, an address, a method or a site, from the event itself — Mark false positive on the event detail, or Protection → False Positives.
That leaves an audit entry naming who did it and a hit counter showing whether the exclusion is still doing anything.
Switching the detection off entirely is the toggle above. Both are reversible; the exclusion is narrower, and narrower is better.
Limits
Stated plainly, because each of them changes what this protects against.
- The body is read up to
security_body_preview_size, 4 KB by default.
An attacker who sends a large part before the field pushes its name past what the engine sees. Raising the directive raises the bar; it does not remove it. - A renamed field is not covered.
PHP'ssession.upload_progress.namecan change the field name. The name then belongs to the application and the engine cannot know it. - The flaws themselves are unconfirmed.
This closes the path the published workaround points at. If the real exploit goes elsewhere, only authentication in front of the site and the PHP update protect it. - Only traffic through the reverse proxy is checked.
Make sure the PHP servers cannot be reached directly. - An upload progress bar built on this PHP feature stops working on a site that uses it — the same effect as turning the feature off in PHP.
Versions
| Engine 3.0.44 | the rule, active with no configuration |
| Engine 3.0.45 + management plane 2.4.40 | the console switch, inherited down the hierarchy |
| Engine 3.0.46 | the CVE identifiers attached to each event |
| Management plane 2.4.43 | those identifiers shown on the event, linked to the NVD |
Stop telling the toolkit which version you run
The public exploit toolkit for these CVEs does not discover your PHP version. It reads it out of your own response headers.
Pointed at a site answering with server: nginx/1.10.3 and x-powered-by: PHP/7.3.10-1+0~20191008.45+debian9~1.gbp365209, it reported the exact build, the distribution, and the branch as end-of-life and unpatched — in half a second, with one request. After the three nginx directives on Install EdgeGuard on a standalone nginx the same run answers cannot determine the PHP version.
Two things that are worth being clear about, because this is easy to oversell:
- It is not protection. An attacker who guesses the version loses nothing but time; EdgeGuard refusing the field is what stops the exploit. Hiding the banner removes the shortcut, not the vulnerability.
- The toolkit declined this target anyway. Its PHP-FPM chain needs local
/procaccess and only runs in a laboratory, so it stopped before attempting anything — which is a property of that toolkit and not a defence you have.
And one that is easy to miss: x-powered-by from PHP is not the same header as the WAF Fingerprinting setting in Global Settings, which adds X-Powered-By: Artica-EdgeGuard on purpose. Hiding the upstream's version and announcing the WAF are separate decisions.
References
- Protect PHP websites with the reverse proxy — the same protection on the legacy WAF, and the OIDC method for sites with accounts
- Global Settings — where the switch lives
- Security Events — reading a refusal
- False Positives — scoping an exception
- Getting Started — the self-test that runs these cases, and the pair that catches the rule regressing from names to bodies