Layered AWS WAF virtual patching for the PHP2Shell RCE vulnerabilities (CVE-2026-109618 and CVE-2026-109619), built with Terraform
AWS Security Portfolio Project 02 | Vulnerability Response | Web Application Firewall | Detection Engineering | Infrastructure as Code
- Overview
- The Vulnerabilities
- Architecture
- Defense Layers
- What WAF Can't Do
- Project Structure
- How to Deploy
- Count → Block Rollout
- Testing
- Detection and Monitoring
- Cost and Cleanup
- Screenshots
- What I Learned
- Interview Q&A
- References
On October 21, 2026 the PHP project shipped emergency fixes for two critical (CVSS v4.0 9.2) unauthenticated remote-code-execution bugs, together known as PHP2Shell. AWS published guidance the same day on using AWS WAF to reduce exposure until the patches are rolled out.
This project turns that advisory into a working, tested, reproducible deployment:
- A realistic PHP workload (nginx + php-fpm) behind an Application Load Balancer
- An AWS WAF web ACL with eight layered rules: AWS managed rule groups (with
AWSManagedRulesPHPRuleSetpinned to Version_2.3), custom signature, multipart, oversize and rate-limit rules, and an optional label-based exception - A one-variable Count → Block rollout, the same way you'd ship it in production
- Server-side hardening that covers what a WAF can't (patched PHP,
php.ini, FPM pool) - Detection: KMS-encrypted WAF logs, saved Logs Insights queries, a per-rule dashboard and an SNS alarm
- An automated test suite that checks every layer's expected status code in both modes
- CI with
terraform validate, tflint, checkov and shellcheck
The goal isn't just "turn on WAF". It's to show where each control works, where it doesn't (see COVERAGE.md), and to treat patching as the real fix.
| CVE-2026-109618 | CVE-2026-109619 | |
|---|---|---|
| Class | Memory corruption when PHP runs out of memory mid-request | Stale global state reused by the next request on the same worker |
| Reached via | multipart/form-data parsing, oversized bodies, header amplification, fatal_error_backtraces (8.5), INI diagnostics |
multipart/form-data with PHP_SESSION_UPLOAD_PROGRESS and a PHPSESSID session |
| When | Before any application code runs | Before any application code runs |
| Advisory | GHSA-w8hw-w9fx-j79w | GHSA-6wmf-4rv5-p3rp |
| Fixed in | PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 (8.1 and older: no upstream fix) | same |
Both bugs fire before your code runs, so application input validation can't help. That's why an edge control (WAF) plus server configuration matters here.
flowchart LR
C[Client / attacker] -->|HTTP| ALB[Application Load Balancer<br/>drop_invalid_header_fields]
subgraph ACL[AWS WAF web ACL - REGIONAL - first 8 KB of body]
direction TB
R10[10 RateLimit-Posts] --> R20[20 PHP2Shell-Signature]
R20 --> R30[30 AWS PHP rule set<br/>Version_2.3 - PHP2Shell_BODY]
R30 --> R40[40 Label exception<br/>optional]
R40 --> R50[50 AWS Core rule set]
R50 --> R55[55 AWS Known bad inputs]
R55 --> R60[60 Block-Multipart-FormData<br/>except /upload.php]
R60 --> R70[70 Block-Oversize-Body<br/>except /upload.php]
end
ALB -. every request .-> ACL
ALB -->|allowed| EC2
subgraph EC2[EC2 - Amazon Linux 2023 - IMDSv2 hop limit 1]
NGINX[nginx] --> FPM[php-fpm 8.4.27<br/>upload_progress off<br/>pm.max_requests=1<br/>cgroup memory limit]
end
ACL -->|logs| CWL[(CloudWatch Logs<br/>aws-waf-logs-*<br/>KMS encrypted)]
ACL -->|per-rule metrics| CW[CloudWatch dashboard]
CW --> ALARM[PHP2Shell activity alarm] --> SNS[SNS email]
CWL --> QI[Saved Logs Insights queries]
| Priority | Rule | Type | Targets | Why it's there |
|---|---|---|---|---|
| 10 | RateLimit-Posts |
Rate-based (100 POST / 60 s / IP) | Both | Slows retry-heavy exploitation |
| 20 | PHP2Shell-Signature |
Custom | CVE-2026-109619 | POST + PHPSESSID (cookie key or body) + PHP_SESSION_UPLOAD_PROGRESS; version-independent backup for the managed rule |
| 30 | AWSManagedRulesPHPRuleSet |
AWS managed, pinned Version_2.3 | CVE-2026-109619 | Contains AWS's PHP2Shell_BODY virtual patch |
| 40 | PHP2Shell-Label-Exception |
Label match (optional) | — | Blocks the PHP2Shell_Body label everywhere except one confirmed false-positive path |
| 50 | AWSManagedRulesCommonRuleSet |
AWS managed | Generic | Baseline OWASP coverage; SizeRestrictions_BODY handed to rule 70 |
| 55 | AWSManagedRulesKnownBadInputsRuleSet |
AWS managed | Generic | Log4j, Java deserialization and other known-bad payloads (added after a checkov finding) |
| 60 | Block-Multipart-FormData |
Custom | Both | Multipart is the entry point for both bugs, so it's only allowed on /upload.php |
| 70 | Block-Oversize-Body |
Size constraint, OversizeHandling: MATCH |
CVE-2026-109618 | ALB inspects only 8 KB, so anything larger is treated as a match |
Plus server-side controls in app/:
| Control | File | CVE |
|---|---|---|
Patched php:8.4.27-fpm-alpine (Terraform refuses unpatched images) |
terraform/variables.tf |
both |
session.upload_progress.enabled = 0 (ini + php_admin_flag) |
app/php/* |
109619 |
pm.max_requests = 1 (recycle every worker) |
app/php/zz-php2shell-pool.conf |
109619 |
memory_limit = -1 + container cgroup memory limit |
app/php/*, user data |
109618 |
php_admin_* so scripts and PHP_VALUE can't override |
app/php/zz-php2shell-pool.conf |
both |
log_errors, E_ALL, no display_errors |
app/php/zz-php2shell-hardening.ini |
visibility |
Reasoning behind each choice: docs/DESIGN-DECISIONS.md.
A WAF can't parse multipart internals, see PHP worker memory, or change server config. AWS says so in the advisory, and this project treats it as a design constraint:
| Element | WAF coverage |
|---|---|
| CVE-2026-109619 request pattern | Full |
| Multipart uploads | Conditional: only where the app takes none |
| Oversized bodies | Partial: header amplification fits under 8 KB |
| Repeated attempts | Partial: one request can be enough |
fatal_error_backtraces, INI diagnostics |
None: server config only |
Full matrix with the residual risk for each row: docs/COVERAGE.md.
php2shell-shield/
├── terraform/
│ ├── versions.tf # Provider + default tags
│ ├── variables.tf # Inputs, incl. patched-PHP and rule-version guards
│ ├── main.tf # Locals: rule_mode switch, metric names
│ ├── network.tf # VPC, 2 public subnets, security groups
│ ├── alb.tf # ALB, target group, HTTP/HTTPS listeners
│ ├── compute.tf # EC2 (IMDSv2, encrypted EBS, SSM, no SSH)
│ ├── waf.tf # Web ACL — the 8 rules — and ALB association
│ ├── logging.tf # KMS, WAF logs, saved Logs Insights queries
│ ├── monitoring.tf # Dashboard, alarm, SNS
│ ├── outputs.tf
│ ├── terraform.tfvars.example
│ └── templates/user_data.sh.tftpl
├── app/
│ ├── nginx/default.conf
│ ├── php/zz-php2shell-hardening.ini
│ ├── php/zz-php2shell-pool.conf
│ └── www/{index,upload,healthz}.php
├── tests/validate.sh # 8 cases, expected codes per mode
├── scripts/verify-waf.sh # Read-only AWS CLI checks from the advisory
├── queries/*.logsinsights # Deployed as saved CloudWatch queries
├── docs/
│ ├── COVERAGE.md
│ ├── ROLLOUT-RUNBOOK.md
│ └── DESIGN-DECISIONS.md
├── screenshots/
└── .github/workflows/ci.yml
Prerequisites: an AWS account (sandbox recommended), AWS CLI v2 with credentials, Terraform ≥ 1.6, curl.
git clone https://github.com/amahjoshdevsec/php2shell-shield.git
cd php2shell-shield/terraform
cp terraform.tfvars.example terraform.tfvars
# set allowed_ingress_cidrs to your IP: curl -s https://checkip.amazonaws.com
terraform init
terraform plan
terraform applyGive the instance about two minutes to bootstrap, then open the alb_url output. The status page shows PHP 8.4.27 and the hardened settings.
# 1. Deployed in count mode by default: nothing is blocked, everything is logged
./tests/validate.sh --mode count # generates sample matches
# 2. Review: dashboard_url output + Logs Insights saved query "03-count-mode-would-block"
./scripts/verify-waf.sh # confirm Version_2.3, no group-wide Count override
# 3. Enforce
cd terraform && terraform apply -var rule_mode=block && cd ..
./tests/validate.sh --mode blockFull step-by-step procedure, false-positive handling and rollback guidance: docs/ROLLOUT-RUNBOOK.md.
tests/validate.sh sends one request per layer. The payloads only match signatures (a cookie name, a form field name, a body size). None of them is an exploit, and the backend is patched.
| # | Request | Count | Block | Proves |
|---|---|---|---|---|
| 1 | GET / |
200 | 200 | Baseline |
| 2 | Small urlencoded POST / |
200 | 200 | No false positive on normal forms |
| 3 | multipart POST / |
200 | 403 | Rule 60 |
| 4 | multipart POST /upload.php |
200 | 200 | Upload exemption works |
| 5 | 10 KB body POST / |
200 | 403 | Rule 70 (beyond the ALB inspection limit) |
| 6 | PHPSESSID cookie + PHP_SESSION_UPLOAD_PROGRESS → /upload.php |
200 | 403 | Rules 20/30 still protect the exempt path |
| 7 | multipart POST /upload.php/../index.php |
200 | 403 | Path-normalization bypass is closed |
| 8 | 20 KB upload → /upload.php |
200 | 200 | Documents the known gap honestly |
- WAF logs go to
aws-waf-logs-php2shell-shield, encrypted with a customer-managed KMS key (rotation on). TheAuthorizationheader is redacted; cookies stay visible becausePHPSESSIDis evidence. - Saved Logs Insights queries (from
queries/): blocked-by-rule, all PHP2Shell matches, count-mode would-block by path, top POST sources. - Dashboard: Allowed/Blocked/Counted for the ACL plus each rule.
- Alarm
php2shell-shield-php2shell-activity: fires on any PHP2Shell match, counted or blocked, so probes during the Count window aren't missed. Setalert_emailto get notified.
Running cost is roughly $1.50–2 per day (us-east-1): ALB ~$0.55, WAF web ACL + 8 rules ~$0.43, public IPv4 ~$0.36, t3.micro ~$0.25, KMS and CloudWatch the rest. Request charges are negligible at lab volume.
cd terraform && terraform destroyThe KMS key enters a 7-day pending-deletion window.
See screenshots/README.md for the capture list.
- Virtual patching buys time; it doesn't fix the bug. The WAF covers the request pattern fully but can't touch worker memory or server config. Patching (enforced in Terraform here) is the real fix.
- Inspection limits matter. An ALB lets WAF see only 8 KB of the body. Without an
OversizeHandling: MATCHsize rule, an attacker can push the payload past what WAF inspects. - Exceptions can become bypasses. A
STARTS_WITHpath exemption plus nginx's path normalization would have exempted every script. Decoding, normalizing and matching exactly fixes it, and a test proves it. - Roll out in Count first. One variable moves the whole ACL between Count and Block, logging is on before any rule changes, and the alarm covers both modes.
- Managed rule versions are part of the attack surface. Rolling back to Version_2.2 silently removes the patch, so the variable validation refuses it.
Q: A critical PHP RCE drops this morning. What do you do in the first hour? Inventory affected hosts and versions. Confirm WAF logging is on. Check that the PHP managed rule group is on the patched version (Version_2.3) and isn't overridden to Count. Deploy the extra rules in Count, review for hours, then Block. Patch and harden php.ini in parallel. The WAF buys time; the patch closes the bug.
Q: Why not just block multipart/form-data everywhere?
It breaks every file upload. Rule 60 blocks it everywhere except the one upload endpoint. That endpoint stays protected by the signature rules, patched PHP, and session.upload_progress being disabled.
Q: What's the 8 KB problem?
On an ALB, WAF only inspects the first 8 KB of the body, and that can't be raised (CloudFront and API Gateway go up to 64 KB). A signature after byte 8,192 is invisible. Rule 70 uses OversizeHandling: MATCH to treat anything bigger as suspicious on paths that shouldn't receive large bodies.
Q: You found a false positive in PHP2Shell_BODY. Now what?
Don't disable the rule or roll the version back. Keep it in Count and add a label-based rule after it that blocks the awswaf:managed:aws:php-app:PHP2Shell_Body label everywhere except the narrowest possible path. Then patch that application first, because it's now unprotected by that rule. Here, that's php2shell_exempt_path.
Q: Why both a custom rule and the managed rule? Defense in depth and version independence. If someone pins or rolls back the managed group, rule 20 still catches the known pattern, and its metric gives an unambiguous count of attempts.
Q: Why memory_limit = -1? Isn't that dangerous?
CVE-2026-109618 triggers when PHP hits its own limit mid-request. Removing PHP's limit and bounding memory with the container cgroup keeps PHP off the vulnerable path while still capping memory. RLIMIT_AS would trigger the same path.
Q: What would you change for production? CloudFront in front (64 KB inspection, Shield Standard), Firewall Manager to push the ACL across accounts, private subnets with a NAT or VPC endpoints, flow logs and ALB access logs, logs shipped to a central security account (see Project 01), and ECS/EKS instead of a single instance.
- AWS Security Blog: Using AWS WAF to apply mitigations for PHP2Shell RCE vulnerabilities (CVE-2026-109618 and CVE-2026-109619). https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/
- AWS Security Bulletin 2026-143-AWS
- GitHub advisories GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp
- AWS WAF docs: PHP application managed rule group, managed rule group versioning, body inspection limits, oversize handling
Disclaimer: This is an educational lab for defensive security. It contains no exploit code; test requests only match WAF signatures. Deploy it only in an AWS account you own.