
php2shell の告知を受けて、ALB の AWS WAF で multipart POST を止める・絞るルールを試してみた
はじめに
PHP セキュリティチームは、2件の緊急修正を事前に告知しています。2026-10-21 14:00 UTC のリリース予定で、どちらも CVSS v4.0 のスコアは 9.2 です。
告知には CVE 番号も機能名も書かれていません。CVE-2026-109618 と CVE-2026-109619、通称 php2shell は、Flatt Security の記事などの記載によるものです。
Flatt Security は、Session Upload Progress に関わる可能性を指摘しています。session.upload_progress.enabled=0 の設定は緩和策に留まり、攻撃は依然として可能とみられるため、根本対策はパッチの適用としています。Sansec も PHP の更新を求めています。
この記事では、ALB 配下の PHP アプリの前段で AWS WAF が POST をどこまで止められるかを試しました。東京リージョンに新規の ALB(固定応答)と新規の WebACL を作り、最初の1回から止まるルール(multipart/form-data の POST、日本国外からの POST)と、回数で絞るルール(IP 単位、JA4 フィンガープリント単位のレートベースルール)を載せています。curl、Python、バージニア・東京・大阪の CloudShell から送信し、WAF ログと ALB アクセスログの記録と突き合わせました。
最初の1回から止まるルール
multipart/form-data の POST を止める
メソッドが POST で、content-type ヘッダを小文字にした値に multipart/form-data を含むリクエストを Block します。小文字化と部分一致にしてあるので、大文字小文字の混在や、パラメータの付き方が違う書き方にも一致します。CLI に渡す JSON では SearchString を base64 で書きます。平文を渡すと Invalid base64 で拒否されました。UE9TVA== が POST、bXVsdGlwYXJ0L2Zvcm0tZGF0YQ== が 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"
}
}
WebACL の Rules に加える1ルール分の定義です。レスポンス本文も返す場合は、CustomResponseBodyKey をルールに、本文の定義を WebACL の CustomResponseBodies に追加します。
Content-Type の書き方とメソッドを変えて curl で POST などを送った結果です。
| メソッド | Content-Type | ステータス |
|---|---|---|
| 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(ヘッダ付き) | 200 |
multipart/form-data の POST だけが止まり、Content-Type をカンマ区切りにしても、大文字小文字を混ぜても、パラメータの順序を変えても最初の1回から 403 になります。このルールはファイルを含まない multipart/form-data のフォームも止めるので、ファイルアップロードを受け付けるアプリには入れられません。
日本国外からの POST を止める
メソッドが POST で、送信元の国が JP ではないリクエストを Block するルールです。バージニアの CloudShell から GET を1回、POST を3回送ると、GET は 200、POST は3回とも 403 でした。WAF ログでは GET が ALLOW、POST が BLOCK で、終端ルールは国別ルール、country は US です。東京の CloudShell からは GET 1回と POST 3回がすべて 200 で、WAF ログの country は JP でした。大阪の CloudShell の POST も WAF ログでは JP と判定されています。CloudShell の出力は、各リージョンで実行したものを貼り付けて WAF ログと照合しました。
国別のルールは、multipart/form-data のルールと同じく最初の1回から止まります。
回数で絞るルール
レートベースルールは、評価ウィンドウが 60/120/300/600 秒で、上限の最小値は10です。上限付近で制限を適用し、厳密な一致は保証しないと書かれています。
上限を超えたあとに通る件数
IP 単位(10回/60秒)と JA4 単位(15回/60秒)のルールに、送り方を変えて POST を送りました。
| ルールと条件 | 送り方 | 通った POST(上限内を含む) | 最初の 403 |
|---|---|---|---|
| IP 単位、10回/60秒、multipart/form-data の POST だけを数える | curl で約1秒に30回 | 30回すべて | なし(約45秒後の次の30回は1回目から 403) |
| 同上 | Python で1秒に1回、120回 | 25回 | 26回目 |
| IP 単位、10回/60秒、POST 全体を数える | 東京の CloudShell から1秒に1回、60回 | 23回 | 24回目 |
| JA4 単位、15回/60秒、POST 全体を数える(IP 単位ルールの上限は50に上げた) | 東京と大阪の CloudShell から30回ずつ、同時に | 合計52回(大阪29回、東京23回) | 大阪の30回目、東京の24回目 |
各値は1回の測定です。東京の CloudShell は出力の貼り付けが途中で切れたため、通った件数は WAF ログで確かめました。上限の10回を超える11回目から最初の 403 までは、Python で約15.5秒、東京の CloudShell で約13秒でした。
上限10回でも、1秒に1回の送信では23〜25回が通ってから止まり、短時間の連投は30回が止まる前にすべて通りました。1回目から止めたい条件は前節のメソッド・Content-Type・国のルール、回数で絞りたい条件はレートベースルールと使い分けることになります。
IP が異なっても JA4 で合算される
JA4 は TLS の Client Hello から計算されるため、この検証の ALB には HTTPS:443 のリスナーを作りました。証明書は自己署名のものを ACM にインポートし、クライアントは証明書の検証を無効にしています。集計キーの要素が欠けたリクエストはレートベースルールの評価から外れる、とドキュメントに書かれています。
東京と大阪の CloudShell から同じ Python スクリプトで送ると、WAF ログの ja4Fingerprint は両方とも t13d351300_bfa337485184_678a800b2221 でした。送信元 IP が違っても1つのカウンタに合算され、合計52回が通ったところで両方が止まっています。合算されるのは JA4 が同じ送信元で、今回は同じ CloudShell 環境の同じスクリプトだったため同じ値になりました。
止まった理由を見る
レスポンス、WAF ログ、ALB アクセスログの3か所から、止まったルールを特定できます。
レスポンスには、Block のカスタムレスポンスとして、ステータス 403、ヘッダ x-waf-blocked-by、本文を返せます。バージニアの CloudShell から送った POST の応答です。
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)
WAF ログのブロック記録からは、終端ルール、ルールの種類、レートの上限・評価ウィンドウ・集計キー、国、JA4 を読めます。次は JA4 単位のルールで止まった記録で、IP 単位ルールの上限は50に上げてあります。rateBasedRuleList に入る集計キーの値は、ja4Fingerprint の先頭部分だけが記録されています。
{
"terminatingRuleId": "rate-limit-post-fingerprint",
"terminatingRuleType": "RATE_BASED",
"action": "BLOCK",
"rateBasedRuleList": [
{
"rateBasedRuleName": "rate-limit-post-ip",
"limitKey": "IP",
"maxRateAllowed": 50,
"evaluationWindowSec": 60,
"limitValue": "<送信元 IP>"
},
{
"rateBasedRuleName": "rate-limit-post-fingerprint",
"limitKey": "CUSTOMKEYS",
"maxRateAllowed": 15,
"evaluationWindowSec": 60,
"customValues": [
{
"key": "JA4FINGERPRINT",
"name": "",
"value": "t13d351300_bfa337485184_678a800b"
}
]
}
],
"responseCodeSent": 403,
"httpRequest": {
"clientIp": "<送信元 IP>",
"country": "JP",
"httpMethod": "POST"
},
"ja4Fingerprint": "t13d351300_bfa337485184_678a800b2221"
}
ALB アクセスログでは、WAF が止めたリクエストは elb_status_code が 403、actions_executed が waf になります。WAF を通って固定応答まで届いたリクエストは waf,fixed-response です。ALB のログは CloudWatch Logs への配信に時間がかかり、別の実行では、最後のリクエストから4分39秒後に全件が揃いました。
CloudFormation のサンプル
国別 Block、IP 単位、JA4 単位の3ルールを持つ WebACL と、ALB への関連付け、WAF ログの出力を作る CloudFormation テンプレートを公開しています。
template.yaml を保存して、次のコマンドでデプロイします。
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 は既定が Block で、Count にすると一致したリクエストを数えるだけで止めません。README では、Count で始めて WAF ログを見てから Block に切り替えることを勧めています。Count では、各ルールが Count で WebACL に入るところまでを確認しました。本文と同じ 403 を得るには RuleAction=Block にします。
BlockMultipartPost を true にすると、multipart/form-data の POST を止めるルールが優先度0に加わり、国別・IP 単位・JA4 単位のルールは優先度1〜3になります。この WebACL を ALB に付けて上の表と同じ組み合わせを送ると、手作りの WebACL と同じ結果でした。正当なアップロードやファイルなしのフォームも止まるため、既定は false です。
ReturnBlockReason は、止まった理由をレスポンスに返す設定です。false にするとルール名がレスポンスに出ません。ルール名が分かると、どのルールのどの条件で止まったかが相手に伝わるので、本番では false にします。
LoadBalancerArn にすでに別の WebACL が付いた ALB を渡すと、スタックの更新は成功し、新しい WebACL が通信を受けるようになります。既存の関連付けが置き換わるので、既存の WebACL のルールを残したい場合は、テンプレートの WebACL から必要なルールを既存の WebACL の Rules へ追加します。
まとめ
ALB の AWS WAF では、multipart/form-data の POST を最初の1回から止めるルールと、POST を回数で絞るルールを作れました。前者は Content-Type の書き方を変えても止まり、urlencoded や JSON の POST と GET は通ります。後者は上限を超えたあとにも十数件から数十件が通ってから止まるため、1回目から効かせたい条件には、メソッドや Content-Type、国で止めるルールが向いています。
ここで確かめたのは WAF のルールの動作で、php2shell を防げるかは確かめていません。Flatt も Sansec も、根本対策は PHP の更新としています。
PHP を ALB + WAF の構成で動かしていて、multipart/form-data の POST を受け付ける必要がないアプリなら、修正版を適用するまでの間に block-multipart-post のようなルールを足し、止まった理由を WAF ログで見ながら進めてください。









