Artica Wiki

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 PHP Upload Progress switch in Core Detection

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.

Security Events: every trigger attempt refused, one row each

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.

Security Events filtered to this rule

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.

One blocked request in full

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.

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:

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