
I tried AWS WAF rules on an ALB to block and throttle multipart POSTs after the php2shell advisory
This page has been translated by machine translation. View original
Introduction
The PHP security team has pre-announced two emergency fixes. They are scheduled for release at 14:00 UTC on 2026-10-21, and both have a CVSS v4.0 score of 9.2.
The announcement mentions neither CVE numbers nor a feature name. CVE-2026-109618 and CVE-2026-109619, also known as php2shell, come from Flatt Security's article and other sources.
Flatt Security points out a possible connection to Session Upload Progress. Setting session.upload_progress.enabled=0 is only a mitigation, and since attacks are still considered possible, they state that the fundamental fix is applying the patch. Sansec also calls for updating PHP.
In this article, I tested how far AWS WAF in front of a PHP app behind an ALB can stop POST requests. I created a new ALB (fixed response) and a new WebACL in the Tokyo region and attached rules that block from the very first request (POST with multipart/form-data, POST from outside Japan) and rules that throttle by count (rate-based rules per IP and per JA4 fingerprint). I sent requests from curl, Python, and CloudShell in Virginia, Tokyo, and Osaka, and cross-checked them against the WAF logs and ALB access logs.
Rules that block from the first request
Blocking POST requests with multipart/form-data
This rule blocks requests whose method is POST and whose lowercased content-type header value contains multipart/form-data. Because it lowercases the value and uses a partial match, it also matches mixed-case values and different ways of attaching parameters. In the JSON passed to the CLI, SearchString is written in base64. Passing plain text was rejected with Invalid base64. UE9TVA== is POST, and bXVsdGlwYXJ0L2Zvcm0tZGF0YQ== is multipart/form-data.
block-multipart-post.json
{
"Name": "block-multipart-post",
"Priority": 0,
"Statement": {
"AndStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "UE9TVA==",
"FieldToMatch": {
"Method": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
],
"PositionalConstraint": "EXACTLY"
}
},
{
"ByteMatchStatement": {
"SearchString": "bXVsdGlwYXJ0L2Zvcm0tZGF0YQ==",
"FieldToMatch": {
"SingleHeader": {
"Name": "content-type"
}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "LOWERCASE"
}
],
"PositionalConstraint": "CONTAINS"
}
}
]
}
},
"Action": {
"Block": {
"CustomResponse": {
"ResponseCode": 403,
"ResponseHeaders": [
{
"Name": "x-waf-blocked-by",
"Value": "block-multipart-post"
}
]
}
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "block-multipart-post"
}
}
This is the definition of one rule to add to the WebACL's Rules. To also return a response body, add CustomResponseBodyKey to the rule and add the body definition to the WebACL's CustomResponseBodies.
These are the results of sending POST and other requests with curl while varying the Content-Type notation and the method.
| Method | Content-Type | Status |
|---|---|---|
| POST | multipart/form-data; boundary=x | 403 |
| POST | multipart/form-data, boundary=x | 403 |
| POST | Multipart/Form-Data; boundary=x | 403 |
| POST | multipart/form-data;charset=utf-8;boundary=x | 403 |
| POST | application/x-www-form-urlencoded | 200 |
| POST | text/plain | 200 |
| POST | application/json | 200 |
| POST | multipart/mixed | 200 |
| PUT | multipart/form-data | 200 |
| GET | multipart/form-data (with header) | 200 |
Only POST requests with multipart/form-data are blocked. Even with a comma-separated Content-Type, mixed case, or a different parameter order, the first request gets a 403. This rule also blocks multipart/form-data forms that contain no files, so it cannot be used on apps that accept file uploads.
Blocking POST requests from outside Japan
This rule blocks requests whose method is POST and whose source country is not JP. When I sent one GET and three POST requests from CloudShell in Virginia, the GET returned 200 and all three POSTs returned 403. In the WAF log, the GET was ALLOW and the POSTs were BLOCK, the terminating rule was the country rule, and the country was US. From CloudShell in Tokyo, one GET and three POSTs all returned 200, and the country in the WAF log was JP. The POST from CloudShell in Osaka was also identified as JP in the WAF log. I pasted the CloudShell output from each region and cross-checked it against the WAF log.
Like the multipart/form-data rule, the country rule blocks from the very first request.
Rules that throttle by count
For rate-based rules, the evaluation window is 60/120/300/600 seconds, and the minimum limit is 10. The documentation states that the limit is applied around the threshold and that exact matching is not guaranteed.
How many requests get through after the limit is exceeded
I sent POST requests in different ways to an IP-based rule (10 requests/60 seconds) and a JA4-based rule (15 requests/60 seconds).
| Rule and condition | How sent | POSTs that got through (including those within the limit) | First 403 |
|---|---|---|---|
| IP-based, 10/60 s, counting only multipart/form-data POSTs | About 30 requests in 1 second with curl | All 30 | None (the next 30 sent about 45 seconds later got 403 from the first request) |
| Same as above | Python, 1 request per second, 120 requests | 25 | 26th request |
| IP-based, 10/60 s, counting all POSTs | CloudShell in Tokyo, 1 request per second, 60 requests | 23 | 24th request |
| JA4-based, 15/60 s, counting all POSTs (the IP-based rule's limit was raised to 50) | 30 requests each from CloudShell in Tokyo and Osaka, simultaneously | 52 in total (Osaka 29, Tokyo 23) | Osaka's 30th request, Tokyo's 24th request |
Each value is from a single measurement. Because the output pasted from CloudShell in Tokyo was cut off partway, I confirmed the number of requests that got through from the WAF log. From the 11th request, which exceeds the limit of 10, to the first 403 took about 15.5 seconds with Python and about 13 seconds with CloudShell in Tokyo.
Even with a limit of 10, when sending one request per second, 23 to 25 requests got through before blocking began, and in a short burst, all 30 requests got through before blocking. So for conditions you want to block from the first request, use the method, Content-Type, and country rules from the previous section, and for conditions you want to throttle by count, use rate-based rules.
Aggregated by JA4 even when the IPs differ
Because JA4 is calculated from the TLS Client Hello, I created an HTTPS:443 listener on the ALB for this test. I imported a self-signed certificate into ACM, and the client had certificate verification disabled. The documentation states that requests missing an aggregation key component are excluded from rate-based rule evaluation.
When I sent requests from CloudShell in Tokyo and Osaka with the same Python script, the ja4Fingerprint in the WAF log was t13d351300_bfa337485184_678a800b2221 for both. Even though the source IPs differ, they were aggregated into a single counter, and both were blocked after a total of 52 requests got through. Requests are aggregated when they share the same JA4; here, the same script in the same CloudShell environment produced the same value.
Seeing why a request was blocked
You can identify the rule that blocked a request from three places: the response, the WAF log, and the ALB access log.
As a custom response for Block, the response can return status 403, the header x-waf-blocked-by, and a body. This is the response to a POST sent from CloudShell in Virginia.
2026-10-21T06:29:03.751Z 1 post 403 x-waf-blocked-by=block-foreign-post body=blocked by WAF rule: block-foreign-post (POST from outside JP)
From a WAF log block record, you can read the terminating rule, the rule type, the rate limit, evaluation window, and aggregation key, the country, and JA4. The following is a record of a block by the JA4-based rule, with the IP-based rule's limit raised to 50. The aggregation key value in rateBasedRuleList records only the leading part of ja4Fingerprint.
{
"terminatingRuleId": "rate-limit-post-fingerprint",
"terminatingRuleType": "RATE_BASED",
"action": "BLOCK",
"rateBasedRuleList": [
{
"rateBasedRuleName": "rate-limit-post-ip",
"limitKey": "IP",
"maxRateAllowed": 50,
"evaluationWindowSec": 60,
"limitValue": "<source IP>"
},
{
"rateBasedRuleName": "rate-limit-post-fingerprint",
"limitKey": "CUSTOMKEYS",
"maxRateAllowed": 15,
"evaluationWindowSec": 60,
"customValues": [
{
"key": "JA4FINGERPRINT",
"name": "",
"value": "t13d351300_bfa337485184_678a800b"
}
]
}
],
"responseCodeSent": 403,
"httpRequest": {
"clientIp": "<source IP>",
"country": "JP",
"httpMethod": "POST"
},
"ja4Fingerprint": "t13d351300_bfa337485184_678a800b2221"
}
In the ALB access log, requests stopped by WAF have an elb_status_code of 403 and actions_executed of waf. Requests that passed WAF and reached the fixed response show waf,fixed-response. Delivery of ALB logs to CloudWatch Logs takes time; in a separate run, all entries were available 4 minutes 39 seconds after the last request.
CloudFormation sample
I have published a CloudFormation template that creates a WebACL with three rules (country-based Block, IP-based, JA4-based), the association with the ALB, and WAF log output.
Save template.yaml and deploy it with the following command.
aws cloudformation deploy \
--stack-name waf-post-guard \
--template-file template.yaml \
--region ap-northeast-1 \
--parameter-overrides \
AllowedCountries=JP \
RuleAction=Count \
LoadBalancerArn=arn:aws:elasticloadbalancing:REGION:ACCOUNT_ID:loadbalancer/app/NAME/ID
RuleAction defaults to Block; with Count, matching requests are only counted and not blocked. The README recommends starting with Count, checking the WAF logs, and then switching to Block. With Count, I only verified that each rule is added to the WebACL as Count. To get the same 403 as in this article, set RuleAction=Block.
With BlockMultipartPost set to true, the rule that blocks multipart/form-data POSTs is added at priority 0, and the country, IP-based, and JA4-based rules become priorities 1 to 3. When I attached this WebACL to an ALB and sent the same combinations as in the table above, the results were the same as with the hand-built WebACL. Because legitimate uploads and forms without files are also blocked, the default is false.
ReturnBlockReason is a setting that returns the reason for blocking in the response. When it is false, the rule name does not appear in the response. Knowing the rule name tells the requester which rule and which condition blocked the request, so set it to false in production.
If you pass a LoadBalancerArn for an ALB that already has a different WebACL attached, the stack update succeeds and the new WebACL starts receiving traffic. The existing association is replaced, so if you want to keep the rules of the existing WebACL, add the necessary rules from the template's WebACL to the Rules of the existing WebACL.
Summary
With AWS WAF on an ALB, I was able to create a rule that blocks multipart/form-data POSTs from the very first request and rules that throttle POSTs by count. The former blocks regardless of how the Content-Type is written, while urlencoded and JSON POSTs and GET requests pass through. The latter lets a dozen to several dozen requests through even after the limit is exceeded before blocking, so for conditions you want to apply from the first request, rules that block by method, Content-Type, or country are a better fit.
What I verified here is the behavior of the WAF rules; I did not verify whether they can protect against php2shell. Both Flatt and Sansec say the fundamental fix is updating PHP.
If you run PHP in an ALB + WAF setup and have an app that does not need to accept multipart/form-data POSTs, consider adding a rule like block-multipart-post until the fixed version is applied, and proceed while checking the reasons for blocks in the WAF logs.
