Skip to content

About

Layered AWS WAF virtual patch for the PHP2Shell RCE vulnerabilities (CVE-2026-109618 / CVE-2026-109619): Terraform, managed + custom rules, Count-to-Block rollout, detection and tests

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

PHP2Shell Shield

Layered AWS WAF virtual patching for the PHP2Shell RCE vulnerabilities (CVE-2026-109618 and CVE-2026-109619), built with Terraform

CI Terraform AWS WAF PHP

AWS Security Portfolio Project 02 | Vulnerability Response | Web Application Firewall | Detection Engineering | Infrastructure as Code


Table of Contents


Overview

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 AWSManagedRulesPHPRuleSet pinned 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.


The Vulnerabilities

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.


Architecture

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]
Loading

Defense Layers

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.


What WAF Can't Do

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.


Project Structure

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

How to Deploy

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 apply

Give 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.


Count → Block Rollout

# 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 block

Full step-by-step procedure, false-positive handling and rollback guidance: docs/ROLLOUT-RUNBOOK.md.


Testing

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

Detection and Monitoring

  • WAF logs go to aws-waf-logs-php2shell-shield, encrypted with a customer-managed KMS key (rotation on). The Authorization header is redacted; cookies stay visible because PHPSESSID is 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. Set alert_email to get notified.

Cost and Cleanup

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 destroy

The KMS key enters a 7-day pending-deletion window.


Screenshots

See screenshots/README.md for the capture list.


What I Learned

  • 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: MATCH size rule, an attacker can push the payload past what WAF inspects.
  • Exceptions can become bypasses. A STARTS_WITH path 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.

Interview Q&A

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.


References


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.

About

Layered AWS WAF virtual patch for the PHP2Shell RCE vulnerabilities (CVE-2026-109618 / CVE-2026-109619): Terraform, managed + custom rules, Count-to-Block rollout, detection and tests

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages