DEV Community

Testing the AWS WAF Mitigations for PHP2Shell on Both CloudFront and an ALB

Introduction

On 2026-10-21 the PHP project disclosed PHP2Shell (CVE-2026-109618 / CVE-2026-109619): CVSS v4.0 9.2, unauthenticated remote code execution (PHP security advisory, CERT-EU Security Advisory 2026-018).

As a stopgap until PHP can be upgraded, an AWS Security Blog post published four AWS WAF custom rules: PHP2Shell, Block-Multipart-FormData, Block-Oversize-Body and RateLimit-Posts. The AWS Security Bulletin also points to SizeRestrictions_BODY in AWSManagedRulesCommonRuleSet and to the PHP managed rule group (version 2.3, now the default), which adds protection for CVE-2026-109619.

Classmethod has already tested the AWS Security Blog rules thoroughly on an ALB, including the fact that PHP_SESSION_UPLOAD_PROGRESS placed after the first 8 KB of the body goes through.

This article reproduces that test on both CloudFront and an ALB and verifies what was still open:

  • How far does raising CloudFront's body inspection limit (16 KB default, 64 KB max) get you?
  • How does the ALB's Web ACL behind CloudFront affect the result?
  • What changes when the origin only accepts traffic through CloudFront?
  • How long does the rate-based rule take to react once the threshold is crossed?

📁 Code: waf-php2shell-mitigation (part of AWS CDK Reference Architectures).

Note
AWS WAF is a mitigation, not a fix. Upgrading to PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or later is the only complete remediation. PHP 8.1 and older are also affected but are end-of-life, so there is no official fixed release (the PHP team published minimal source backports, and some distributions and LTS vendors provide backported packages). Move to a supported version.

TL;DR

The AWS Security Blog rules are a stopgap until PHP is upgraded; the AWS Security Blog post itself states that the real fix is upgrading PHP. As a stopgap, the rules behaved as specified on both CloudFront and the ALB.

AWS WAF, however, inspects a request body only from the start up to a size limit: 8 KB on an ALB (fixed), 16 KB by default on CloudFront, up to 64 KB (AWS WAF documentation). On the ALB, a string ending at byte 8,192 was blocked; shifting it by one byte let it through. The Block-Oversize-Body rule from the same post (a size limit meant for CVE-2026-109618), when set to BLOCK, also stops requests whose bodies go past that limit.

How to configure that rule depends on your setup:

  • If you use CloudFront, raise the inspection limit to 64 KB and block bodies larger than 64 KB with Block-Oversize-Body. The whole body is then inspected, and legitimate requests up to 64 KB still pass.
  • With CloudFront + ALB + WAF, set the ALB's "block bodies over 8 KB" rule to COUNT and let the ALB accept traffic from CloudFront only. Left in BLOCK, the ALB stops legitimate requests that CloudFront already allowed. If the ALB Web ACL includes AWSManagedRulesCommonRuleSet, override its SizeRestrictions_BODY (blocks bodies over 8 KB) to COUNT for the same reason.
  • If you expose only an ALB, blocking bodies over 8 KB is the baseline. According to the AWS Security Blog post, both CVEs sit on the multipart/form-data processing path, so an app that takes no file uploads and only accepts JSON over 8 KB could block Block-Multipart-FormData instead of using the size limit (not verified in this article). An app that accepts file uploads over 8 KB cannot be fully protected by WAF, so prioritize the PHP upgrade.

If your app changes session.name or session.upload_progress.name from the defaults, rewrite the rule strings to the actual names. Matching is case-sensitive, so this also applies when only the letter case differs.

Architecture

Architecture

Client ─▶ CloudFront + Web ACL (us-east-1, 64 KB body inspection)
           ─▶ ALB + Web ACL (ap-northeast-1, fixed 8 KB)
              ─▶ Lambda container (nginx + PHP-FPM 8.4.27 + Lambda Web Adapter)
Client ─────────▶ ALB direct (comparison only, allow-listed IP)
Enter fullscreen mode Exit fullscreen mode

A WAF decision does not depend on the PHP version behind it, so the origin runs a patched PHP 8.4.27 as a Lambda container image. On the PHP side, it applies these server-side mitigations from the AWS Security Blog post:

; php.ini
session.upload_progress.enabled = 0
memory_limit = -1        ; the real ceiling is the Lambda memory size
post_max_size = 8M

; PHP-FPM pool
pm = static
pm.max_children = 1
pm.max_requests = 1      ; replace the worker after every request
Enter fullscreen mode Exit fullscreen mode

Telling which WAF and which rule acted, from HTTP headers

With WAF on both CloudFront and the ALB, a bare 403 does not tell you which layer answered. Every rule therefore names itself:

const ruleAction = (ruleName: string, action: RuleAction): wafv2.CfnWebACL.RuleActionProperty =>
  action === 'BLOCK'
    ? { block: { customResponse: { responseCode: 403,
        responseHeaders: [{ name: 'x-php2shell-blocked-by', value: `${label}:${ruleName}` }] } } }
    : { count: { customRequestHandling: {
        insertHeaders: [{ name: `php2shell-${label}-${ruleName.toLowerCase()}`, value: 'counted' }] } } };
Enter fullscreen mode Exit fullscreen mode

BLOCK returns x-php2shell-blocked-by: cloudfront:PHP2Shell; COUNT inserts x-amzn-waf-php2shell-alb-oversize: counted into the origin request, and the PHP app echoes it back. Headers inserted by the CloudFront Web ACL travel through the ALB to the origin, and the BLOCK response header keeps its own name (no x-amzn-waf- prefix).

Note that clients can also send headers starting with x-amzn-waf-. If your application changes its behavior based on these headers in production, add a rule that blocks x-amzn-waf- headers arriving from outside, as the AWS WAF documentation describes.

Result 1: rule conditions (reproducing the ALB test on both layers)

Request via CloudFront ALB direct
POST, cookie PHPSESSID + PHP_SESSION_UPLOAD_PROGRESS in body 403 403
POST, both strings in body 403 403
POST, PROGRESS only / cookie only 200 200
PUT, both strings in body 200 200
multipart PROGRESS field + cookie 403 403
lowercase cookie key phpsessid 200 200
lowercase php_session_upload_progress in body 200 200
JSON or text/plain containing both strings (false positive) 403 403

The rule uses TextTransformation: NONE, so it only matches PHP's default names. If your app renames the session cookie or the upload progress field, change the rule strings too.

Result 2: string position vs inspection limit

Cookie PHPSESSID, with PHP_SESSION_UPLOAD_PROGRESS ending at byte N (oversize rule in COUNT):

N ALB direct CloudFront KB_16 CloudFront KB_64
8,192 403 403 403
8,193 200 403 403
16,385 200 200 403
65,536 200 200 403
65,537 200 200 200

WAF logs agree: requestBodySizeInspectedByWAF is 8192 on the ALB and 65536 on CloudFront KB_64, and larger bodies carry oversizeFields: ["REQUEST_BODY"].

Result 3: oversize rule in BLOCK (8,192 bytes)

Everything from 8,193 bytes is blocked regardless of position. So is a legitimate ~19 KB JSON POST with no rule string at all. An API that accepts JSON over 8 KB cannot use this as is.

Result 4: with WAF on both CloudFront and the ALB, the ALB blocks legitimate requests

CloudFront can inspect the first 64 KB of the body, so I raised CloudFront's Block-Oversize-Body threshold to 65,536 bytes. CloudFront then passes legitimate requests up to 64 KB and blocks bodies too large to inspect in full.

The ALB's Web ACL is where this goes wrong. Putting CloudFront in front of an ALB that already had WAF, or adding the same rules to both layers as defense in depth, is common. Even when the origin only accepts CloudFront, requests through CloudFront are evaluated again by the ALB Web ACL.

ALB Block-Oversize-Body (8,192 bytes) Legit 19 KB JSON PROGRESS at byte 65,537
BLOCK 403 alb:Oversize 403 cloudfront:Oversize
COUNT 200 403 cloudfront:Oversize

(Both through CloudFront; CloudFront blocks above 65,536 bytes.)

At first I thought raising CloudFront's limit would be enough. With the ALB rule in BLOCK, though, the ALB's 8 KB rule stops the 19 KB JSON that CloudFront inspected and allowed. To benefit from CloudFront's setting, the ALB size rule has to be COUNT.

Result 5: restricting ALB access to CloudFront

COUNT on the ALB opens a different problem: a request sent straight to the ALB skips CloudFront, and the ALB inspects only 8 KB. A request with PROGRESS ending at byte 8,193, sent directly, was 403 with the ALB rule in BLOCK and 200 with it in COUNT.

If you follow the AWS best practice and restrict the ALB to traffic from CloudFront, the Result 4 fix (COUNT on the ALB) is not a problem. Setting it to COUNT while the ALB is still directly reachable, however, is dangerous.

As the documentation describes, I combined a security group and a custom header. The security group admits only the CloudFront origin-facing managed prefix list (com.amazonaws.global.cloudfront.origin-facing). That list is shared by every CloudFront distribution, so on its own it also lets other distributions in. I therefore set an X-Origin-Verify origin custom header on CloudFront, and the ALB listener rule forwards only requests whose header value matches (default action 403).

const cloudFrontPrefixList = ec2.PrefixList.fromLookup(this, 'CloudFrontOriginFacing', {
  prefixListName: 'com.amazonaws.global.cloudfront.origin-facing',
});
albSecurityGroup.addIngressRule(ec2.Peer.prefixList(cloudFrontPrefixList.prefixListId), ec2.Port.tcp(80));

listener.addAction('FromCloudFront', {
  priority: 10,
  conditions: [elbv2.ListenerCondition.httpHeader('X-Origin-Verify', [originVerifyValue])],
  action: elbv2.ListenerAction.forward([targetGroup]),
});
Enter fullscreen mode Exit fullscreen mode

This approach relies on keeping the header name and value secret. For this verification CloudFront talks to the ALB over HTTP (port 80); the documentation recommends HTTPS and rotating the header regularly. Another option is to keep the ALB off the internet and connect an internal ALB to CloudFront as a VPC origin.

For Results 1–4, I had added an exception that let my own IP address reach the ALB directly, for comparison. After removing that exception and redeploying, a direct curl to the ALB got no response at all, not even a 403, and timed out: the security group drops the traffic before it reaches the ALB. Through CloudFront, requests still returned 200, and requests with the PHP2Shell pattern were still blocked with 403.

Other observations

Check Result
RateLimit-Posts (100 per 60 s, COUNT) at ~2.4 req/s first match on request #138, 57 s in
1,100,000-byte body, ALB → Lambda 413 (1 MB Lambda target limit), not in the ALB WAF log
WCU CloudFront 50 / ALB 45 (AWSManagedRulesCommonRuleSet alone is 700)
pm.max_requests=1 FPM worker PID changes on every request
Lambda container cold start 3.2 s first, 0.2 s after; ~48 MB memory

The rate-based rule first matched on request #138 (in COUNT; in BLOCK, blocking would start there). The threshold is 100 per 60 seconds, so about 16 seconds and 38 requests went through after it was crossed. If you set the threshold to exactly the number you can tolerate, requests that arrive while detection lags go through on top of it. The overshoot is roughly "request rate × lag": here, 2.4 req/s × ~16 s ≈ 38. Faster attacks overshoot more, so set the threshold below what you can tolerate. The AWS WAF documentation also says rate-based rules are not meant for precise rate limiting, and that the detection lag is usually under 30 seconds but can stretch to minutes.

Cost

About $40/month if left running (ALB ~$18, two Web ACLs $10, eight rules $8, plus requests). With KB_64, only requests whose bodies exceed 16 KB pay an extra $0.30 per million per additional 16 KB. The verification itself, deploy to destroy within a few hours, cost under $1.

Wrap-up

The AWS Security Blog rules behave as specified on both CloudFront and the ALB, but only for strings within the inspected part of the body. Even with CloudFront's limit raised to 64 KB, an 8 KB rule left on the ALB blocks legitimate requests, and a directly reachable ALB lets requests through with everything past 8 KB uninspected. Configure WAF for the whole request path rather than for CloudFront and the ALB separately.

That said, every measure verified here is a mitigation. Upgrade PHP first.

References

Top comments (0)