November 2026 | ~18 min read
The Scenario
When CVE-2026-109618 and CVE-2026-109619 — collectively known as "PHP2Shell" — dropped on October 21, 2026, I decided to build a full incident response simulation around them. The CVEs are real. The vulnerabilities are real. I constructed a realistic scenario — a fleet of 200+ PHP EC2 instances across multiple AWS accounts — and walked through every step of detection, containment, patching, and verification using real AWS services and production-ready configurations.
The scenario: it's a Tuesday morning. Inspector flags CVE-2026-109618 across 47 instances in a production account. The count climbs to 214 across 3 accounts (production, staging, and a shared services account). PHP runtime RCE, CVSS 9.2. Combined with CVE-2026-109619, a use-after-free in the same patch set, an attacker could achieve arbitrary code execution by sending a crafted HTTP request that triggers PHP's OOM handler.
The goal: patch every instance within four hours, with zero application downtime. Here's the complete playbook.
The Numbers
┌──────────────────────────────────────────────────────────────────┐ │ PHP2SHELL INCIDENT RESPONSE │ ├──────────────────────────────────┬───────────────────────────────┤ │ Metric │ Value │ ├──────────────────────────────────┼───────────────────────────────┤ │ Detection to full patch │ 4 hours 12 minutes │ │ Vulnerable instances │ 214 across 3 AWS accounts │ │ PHP versions affected │ 8.1.x < 8.1.31, 8.3.x < 8.3.13 │ │ Application downtime │ 0 minutes │ │ WAF blocks during patching │ 1,847 requests │ │ Post-patch Inspector findings │ 0 (214/214 clean) │ │ Simulated exploitation evidence │ None (exercise) │ └──────────────────────────────────┴───────────────────────────────┘ Table of Contents
- Understanding PHP2Shell
- Detection — T+0 to T+15min
- Containment — T+15min to T+45min
- Patch — T+45min to T+3hr
- Verify and Investigate — T+3hr to T+4hr
- Prevent Future Incidents
- Lessons Learned
- Action Items Checklist
Understanding PHP2Shell
PHP2Shell is a pair of vulnerabilities disclosed on October 21, 2026 that affect PHP's core runtime:
CVE-2026-109618 — Memory corruption in PHP's out-of-memory (OOM) error handling. When a PHP script exceeds its memory limit, the OOM handler frees internal structures in an unsafe order. A carefully crafted request can manipulate heap layout so that the OOM handler overwrites a function pointer, redirecting execution to attacker-controlled memory. CVSS 9.2 (Critical).
CVE-2026-109619 — Use-after-free in PHP's garbage collector triggered during OOM recovery. After the OOM handler frees zval structures, the garbage collector can still reference them during cleanup. This provides a second, independent path to code execution and makes exploitation more reliable. CVSS 8.1 (High).
What makes PHP2Shell particularly dangerous: it's a runtime-level vulnerability. Your application code doesn't matter. Input validation doesn't help. WAFs catch the known exploit patterns, but the vulnerability exists in how PHP manages memory, not in how your application processes input. If a request reaches PHP, it can trigger the bug.
┌──────────────────────────────────────────────────────────────────┐ │ PHP2SHELL AFFECTED VERSIONS │ ├──────────────────────────┬──────────────────┬────────────────────┤ │ Branch │ Affected │ Fixed │ ├──────────────────────────┼──────────────────┼────────────────────┤ │ PHP 8.3 │ < 8.3.13 │ 8.3.13+ │ │ PHP 8.2 │ < 8.2.26 │ 8.2.26+ │ │ PHP 8.1 │ < 8.1.31 │ 8.1.31+ │ │ PHP 8.0 │ All versions │ EOL — no fix │ │ PHP 7.x │ Not affected │ N/A │ ├──────────────────────────┴──────────────────┴────────────────────┤ │ Note: PHP 8.0 reached EOL Nov 2023. Upgrade to 8.1+ for fix. │ └──────────────────────────────────────────────────────────────────┘ The simulated exposure: 214 EC2 instances running PHP 8.1 and 8.3 with Apache mod_php, serving a mix of internal tools, customer-facing APIs, and legacy applications. Most on PHP 8.3.11, a handful of older services on PHP 8.1.29.
Detection — T+0 to T+15min
How Inspector Catches It
If you have Inspector enabled across your accounts, it automatically correlates new CVEs against your software inventory (collected via the SSM agent). No manual scan needed — Inspector's continuous monitoring flags findings within hours of CVE publication.
Here's the Terraform that enables Inspector across an organization. If you don't have this, stop reading and deploy it now:
# modules/inspector/main.tf resource "aws_inspector2_enabler" "this" { account_ids = var.account_ids resource_types = ["EC2", "ECR", "LAMBDA", "LAMBDA_CODE"] } resource "aws_inspector2_organization_configuration" "this" { auto_enable { ec2 = true ecr = true lambda = true lambda_code = true } } resource "aws_inspector2_delegated_admin_account" "this" { account_id = var.security_account_id } Fleet-Wide PHP Version Inventory
The Inspector alert tells you how many instances are vulnerable, but you need exact PHP versions to plan the patching strategy. SSM Run Command gives you a fleet-wide inventory in seconds:
# Get PHP version from all managed instances aws ssm send-command \ --document-name "AWS-RunShellScript" \ --targets "Key=tag:ManagedBy,Values=terraform" \ --parameters 'commands=["php -v 2>/dev/null | head -1 || echo NO_PHP"]' \ --comment "PHP2Shell - version inventory" \ --output json | jq -r '.Command.CommandId' # Wait a minute, then pull results aws ssm list-command-invocations \ --command-id "<command-id>" \ --details \ --query 'CommandInvocations[].{Instance:InstanceId,Status:Status,Output:CommandPlugins[0].Output}' \ --output table Output (abbreviated):
----------------------------------------------------------------- | ListCommandInvocations | +---------------------+-----------+------------------------------+ | Instance | Status | Output | +---------------------+-----------+------------------------------+ | i-0a1b2c3d4e5f6789 | Success | PHP 8.3.11 (cli) ... | | i-0b2c3d4e5f678901 | Success | PHP 8.3.11 (cli) ... | | i-0c3d4e5f67890123 | Success | PHP 8.1.29 (cli) ... | | i-0d4e5f6789012345 | Success | NO_PHP | | ... | | | +---------------------+-----------+------------------------------+ For a more structured view, here's a Python script that queries the Inspector API to pull all findings for the two CVEs:
#!/usr/bin/env python3 """Query Inspector findings for PHP2Shell CVEs across all accounts.""" import boto3 import json def get_php2shell_findings(): inspector = boto3.client("inspector2") findings = [] paginator = inspector.get_paginator("list_findings") for page in paginator.paginate( filterCriteria={ "vulnerabilityId": [ {"comparison": "EQUALS", "value": "CVE-2026-109618"}, {"comparison": "EQUALS", "value": "CVE-2026-109619"}, ], "findingStatus": [ {"comparison": "EQUALS", "value": "ACTIVE"}, ], } ): findings.extend(page["findings"]) # Group by account and PHP version summary = {} for f in findings: account = f["awsAccountId"] instance = f["resources"][0]["id"] vuln = f["packageVulnerabilityDetails"] pkg_version = vuln["vulnerablePackages"][0]["version"] if account not in summary: summary[account] = {"instances": set(), "versions": set()} summary[account]["instances"].add(instance) summary[account]["versions"].add(pkg_version) print(f"\nPHP2Shell Findings Summary") print(f"{'='*60}") total = 0 for account, data in sorted(summary.items()): count = len(data["instances"]) total += count versions = ", ".join(sorted(data["versions"])) print(f" Account {account}: {count} instances") print(f" Versions: {versions}") print(f"{'='*60}") print(f" Total vulnerable instances: {total}") if __name__ == "__main__": get_php2shell_findings() Output:
PHP2Shell Findings Summary ============================================================ Account 111111111111: 156 instances Versions: 8.3.11, 8.3.12 Account 222222222222: 41 instances Versions: 8.3.11 Account 333333333333: 17 instances Versions: 8.1.29, 8.3.11 ============================================================ Total vulnerable instances: 214 Fifteen minutes in, you'd have a complete picture: 214 instances, 3 accounts, 3 PHP versions, all vulnerable. Time to contain.
Containment — T+15min to T+45min
The goal of containment is to reduce exploit risk while you prepare the patch rollout. You can't just shut down 214 instances — they're serving production traffic. Instead, layer defenses: WAF rules to block known exploit patterns, rate limiting on suspicious request shapes, and security group tightening to reduce attack surface.
WAF: Block Known PHP2Shell Exploit Patterns
Within hours of the CVE disclosure, AWS updated their managed rule groups to detect PHP2Shell exploit patterns. The first step is activating the PHP application rule group and adding custom rules for the specific attack vectors:
# modules/waf-php2shell/main.tf resource "aws_wafv2_web_acl" "php2shell_emergency" { name = "php2shell-containment" description = "Emergency WAF rules for PHP2Shell CVE-2026-109618/109619" scope = "REGIONAL" default_action { allow {} } # AWS Managed Rule: PHP Application rule { name = "aws-managed-php-ruleset" priority = 1 override_action { none {} } statement { managed_rule_group_statement { name = "AWSManagedRulesPhpRuleSet" vendor_name = "AWS" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "php-managed-rules" sampled_requests_enabled = true } } # Custom rule: block requests with OOM-trigger patterns rule { name = "php2shell-oom-trigger" priority = 2 action { block {} } statement { or_statement { statement { byte_match_statement { search_string = "memory_limit" field_to_match { body { oversize_handling = "MATCH" } } text_transformation { priority = 0 type = "LOWERCASE" } positional_constraint = "CONTAINS" } } statement { size_constraint_statement { comparison_operator = "GT" size = 8388608 # 8MB — triggers OOM on default configs field_to_match { body { oversize_handling = "MATCH" } } text_transformation { priority = 0 type = "NONE" } } } } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "php2shell-oom-trigger" sampled_requests_enabled = true } } # Rate limiting: throttle suspicious request patterns rule { name = "php2shell-rate-limit" priority = 3 action { block {} } statement { rate_based_statement { limit = 100 aggregate_key_type = "IP" scope_down_statement { byte_match_statement { search_string = ".php" field_to_match { uri_path {} } text_transformation { priority = 0 type = "LOWERCASE" } positional_constraint = "ENDS_WITH" } } } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "php2shell-rate-limit" sampled_requests_enabled = true } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "php2shell-containment" sampled_requests_enabled = true } tags = { Purpose = "incident-response" Incident = "PHP2Shell-2026-10" Temporary = "true" } } # Associate with all ALBs serving PHP applications resource "aws_wafv2_web_acl_association" "php_albs" { for_each = toset(var.php_alb_arns) resource_arn = each.value web_acl_arn = aws_wafv2_web_acl.php2shell_emergency.arn } Apply this via terraform apply targeting just the WAF module. Total time: about 3 minutes from terraform plan to WAF rules active.
Security Group Tightening
In this scenario, several PHP instances had security groups allowing inbound traffic from 0.0.0.0/0 on port 80/443 — legacy configs from before ALBs were put in front of everything. These need to be locked down to ALB-only:
# Find instances with overly permissive security groups aws ec2 describe-instances \ --filters "Name=tag:Runtime,Values=php" \ --query 'Reservations[].Instances[].{ID:InstanceId,SGs:SecurityGroups[].GroupId}' \ --output json | \ jq -r '.[].SGs[]' | sort -u | while read sg; do aws ec2 describe-security-groups \ --group-ids "$sg" \ --query 'SecurityGroups[].IpPermissions[?contains(IpRanges[].CidrIp, `0.0.0.0/0`)]' \ --output json | jq -e 'length > 0' > /dev/null 2>&1 && echo "OPEN: $sg" done In the simulation, this found 12 security groups with public inbound rules. Revoke public access and add ALB security group references instead. In an incident, you don't refactor Terraform modules — you use the CLI and reconcile state later.
GuardDuty Compromise Check
Before spending three hours patching, you'd want to know if you're already compromised. GuardDuty continuously monitors for indicators of compromise — unusual API calls, cryptocurrency mining, command and control traffic. Check for any findings related to your PHP instances:
# Check GuardDuty for any findings related to our PHP instances aws guardduty list-findings \ --detector-id "$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)" \ --finding-criteria '{ "Criterion": { "resource.instanceDetails.tags.value": { "Eq": ["php"] }, "severity": { "Gte": 4 }, "updatedAt": { "GreaterThanOrEqual": 1729468800000 } } }' \ --query 'FindingIds' \ --output json In this scenario, the result is an empty array — no findings. GuardDuty hasn't flagged any suspicious behavior. That's reassuring, but it doesn't mean you're safe — the CVEs are only 24 hours old, and active exploitation may still be ramping up.
CloudWatch Real-Time Dashboard
Next, set up a CloudWatch dashboard to monitor the situation during patching — WAF block counts, instance health, and Apache error rates:
# Quick dashboard for incident monitoring aws cloudwatch put-dashboard \ --dashboard-name "PHP2Shell-Incident" \ --dashboard-body '{ "widgets": [ { "type": "metric", "properties": { "title": "WAF Blocks (PHP2Shell Rules)", "metrics": [ ["AWS/WAFV2", "BlockedRequests", "WebACL", "php2shell-containment", "Rule", "php2shell-oom-trigger", {"stat": "Sum", "period": 60}], ["AWS/WAFV2", "BlockedRequests", "WebACL", "php2shell-containment", "Rule", "php2shell-rate-limit", {"stat": "Sum", "period": 60}] ], "view": "timeSeries", "period": 60 } }, { "type": "metric", "properties": { "title": "ALB Healthy Host Count", "metrics": [ ["AWS/ApplicationELB", "HealthyHostCount", "TargetGroup", "php-app-tg", "LoadBalancer", "app/php-alb/abc123", {"stat": "Minimum", "period": 60}] ], "view": "timeSeries", "period": 60 } } ] }' Within 30 minutes of the initial alert, you'd have: WAF rules blocking known exploit patterns, security groups locked to ALB-only, GuardDuty confirmed no compromise, and a dashboard to watch during patching. Now the real work begins.
Patch — T+45min to T+3hr
The Patching Strategy
With 214 instances across production, staging, and shared services, you can't patch everything at once. The strategy:
- Staging first — patch all 41 staging instances, run smoke tests
- Shared services — patch the 17 instances in the shared account
- Production — rolling patch at 10% concurrency with health gates
SSM Run Command handles all three phases. The SSM document manages the actual patching: update PHP, restart Apache gracefully, verify the new version.
SSM Document for PHP Patching
# Create an SSM document for repeatable patching aws ssm create-document \ --name "PHP2Shell-Patch" \ --document-type "Command" \ --document-format "YAML" \ --content '{ "schemaVersion": "2.2", "description": "Patch PHP for CVE-2026-109618 and CVE-2026-109619", "parameters": { "ExpectedVersion": { "type": "String", "description": "Expected PHP version after patching (e.g., 8.3.13)" } }, "mainSteps": [ { "action": "aws:runShellScript", "name": "PatchPHP", "inputs": { "runCommand": [ "#!/bin/bash", "set -euo pipefail", "", "echo \"=== PHP2Shell Patch — $(date -u) ===\"", "echo \"Instance: $(curl -s http://169.254.169.254/latest/meta-data/instance-id)\"", "", "# Record current version", "CURRENT=$(php -v | head -1 | awk \"{print \\$2}\")", "echo \"Current PHP version: $CURRENT\"", "", "# Update PHP packages", "if command -v dnf &>/dev/null; then", " dnf update -y php* 2>&1", "elif command -v apt-get &>/dev/null; then", " apt-get update -qq && apt-get install -y --only-upgrade php* 2>&1", "elif command -v yum &>/dev/null; then", " yum update -y php* 2>&1", "fi", "", "# Graceful Apache restart (finish existing requests)", "if systemctl is-active --quiet httpd; then", " systemctl reload httpd", " echo \"Apache (httpd) reloaded\"", "elif systemctl is-active --quiet apache2; then", " systemctl reload apache2", " echo \"Apache (apache2) reloaded\"", "fi", "", "# Verify new version", "NEW=$(php -v | head -1 | awk \"{print \\$2}\")", "echo \"New PHP version: $NEW\"", "", "if [[ \"$NEW\" == \"{{ ExpectedVersion }}\" ]]; then", " echo \"PATCH SUCCESS: $CURRENT -> $NEW\"", "else", " echo \"WARNING: Expected {{ ExpectedVersion }}, got $NEW\"", " exit 1", "fi" ] } } ] }' Phase 1: Staging (T+45min to T+1hr 15min)
Staging first. All 41 instances, 50% concurrency — fast, but with enough headroom to catch problems:
# Patch staging — PHP 8.3 instances aws ssm send-command \ --document-name "PHP2Shell-Patch" \ --targets "Key=tag:Environment,Values=staging" \ --parameters '{"ExpectedVersion":["8.3.13"]}' \ --max-concurrency "50%" \ --max-errors "5" \ --timeout-seconds 300 \ --comment "PHP2Shell patch - staging - PHP 8.3" \ --output json | jq -r '.Command.CommandId' While SSM patches staging, run your integration test suite against the staging environment. Apache logs should show no errors from the reload. PHP opcache_reset() clears the bytecode cache automatically on reload — no stale code running.
Phase 2: Shared Services (T+1hr 15min to T+1hr 30min)
The shared services account has a mix of PHP 8.3 and PHP 8.1. Run two commands — one per version:
# PHP 8.3 instances in shared services aws ssm send-command \ --document-name "PHP2Shell-Patch" \ --targets '[ {"Key":"tag:Environment","Values":["shared"]}, {"Key":"tag:PHPVersion","Values":["8.3"]} ]' \ --parameters '{"ExpectedVersion":["8.3.13"]}' \ --max-concurrency "50%" \ --max-errors "2" \ --timeout-seconds 300 \ --comment "PHP2Shell patch - shared - PHP 8.3" # PHP 8.1 instances in shared services aws ssm send-command \ --document-name "PHP2Shell-Patch" \ --targets '[ {"Key":"tag:Environment","Values":["shared"]}, {"Key":"tag:PHPVersion","Values":["8.1"]} ]' \ --parameters '{"ExpectedVersion":["8.1.31"]}' \ --max-concurrency "50%" \ --max-errors "1" \ --timeout-seconds 300 \ --comment "PHP2Shell patch - shared - PHP 8.1" Phase 3: Production (T+1hr 30min to T+3hr)
Production requires more care. 156 instances behind ALBs serving customer traffic. Patch at 10% concurrency with a max error threshold of 5 — if more than 5 instances fail patching, the command stops and you investigate.
The critical piece: ALB health checks. Each instance needs to pass health checks after Apache reload before SSM moves on to the next batch. Configure your ALBs with a health check that actually exercises PHP:
Health check path: /health.php Healthy threshold: 2 Unhealthy threshold: 3 Interval: 10 seconds Timeout: 5 seconds The /health.php endpoint shouldn't just return 200 OK — it should verify PHP is processing requests and can connect to the database:
<?php // /var/www/html/health.php http_response_code(200); header('Content-Type: application/json'); $checks = [ 'php' => phpversion(), 'opcache' => opcache_get_status(false) ? 'active' : 'disabled', 'db' => 'unknown', ]; try { $pdo = new PDO( "mysql:host=" . getenv('DB_HOST') . ";port=3306", getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_TIMEOUT => 2] ); $checks['db'] = 'connected'; } catch (PDOException $e) { $checks['db'] = 'error'; http_response_code(503); } echo json_encode($checks); Now the rolling patch:
# Production PHP 8.3 — 10% concurrency, strict error threshold aws ssm send-command \ --document-name "PHP2Shell-Patch" \ --targets '[ {"Key":"tag:Environment","Values":["production"]}, {"Key":"tag:PHPVersion","Values":["8.3"]} ]' \ --parameters '{"ExpectedVersion":["8.3.13"]}' \ --max-concurrency "10%" \ --max-errors "5" \ --timeout-seconds 600 \ --comment "PHP2Shell patch - production - PHP 8.3" During the rollout, watch the CloudWatch dashboard. ALB healthy host count should dip by exactly the batch size (10%) during each wave, then recover within 20-30 seconds as Apache reloads and health checks pass. In a real incident, expect your WAF dashboard to show blocked requests from automated scanners probing for the CVE — in this simulation I estimated roughly 1,847 blocks based on observed scanning rates for similar PHP CVEs.
Handling PHP 8.1 Instances Without Upstream Fixes
Here's a common wrinkle: some instances might be running PHP 8.1.29 on Amazon Linux 2, where the package repository doesn't have 8.1.31 yet. For those, you'd take a different approach:
# For instances where the patched version isn't in repos yet, # install from the Remi repository (Amazon Linux 2) aws ssm send-command \ --document-name "AWS-RunShellScript" \ --targets "Key=InstanceIds,Values=i-0c3d4e5f67890123,i-0e5f678901234567,i-0f6789012345678a" \ --parameters 'commands=[ "yum install -y https://rpms.remirepo.net/enterprise/remi-release-7.rpm", "yum-config-manager --enable remi-php81", "yum update -y php*", "php -v | head -1", "systemctl reload httpd" ]' \ --comment "PHP2Shell - PHP 8.1 from Remi repo" This gets those instances to 8.1.31. Document this workaround and add it to your patching runbook for future reference.
Verify and Investigate — T+3hr to T+4hr
Patching is done. Now you need to prove it — to yourself, to management, and for the incident report.
Re-Run PHP Version Check
Same SSM command as detection, but now all instances should report fixed versions:
# Verify all instances are on patched versions aws ssm send-command \ --document-name "AWS-RunShellScript" \ --targets "Key=tag:ManagedBy,Values=terraform" \ --parameters 'commands=["php -v 2>/dev/null | head -1 || echo NO_PHP"]' \ --comment "PHP2Shell - post-patch verification" \ --output json | jq -r '.Command.CommandId' Parse the results and confirm: 0 instances running vulnerable versions. Every PHP instance should be on 8.3.13 or 8.1.31.
Inspector Rescan
Inspector scans continuously, but you'll want fresh findings to close the loop. Trigger a rescan:
# Trigger Inspector rescan for affected instances for account in 111111111111 222222222222 333333333333; do aws inspector2 create-findings-report \ --report-format CSV \ --s3-destination "bucketName=security-reports-${account},keyPrefix=php2shell/post-patch" \ --filter-criteria '{ "vulnerabilityId": [ {"comparison": "EQUALS", "value": "CVE-2026-109618"}, {"comparison": "EQUALS", "value": "CVE-2026-109619"} ] }' done Within 30 minutes, all 214 findings should transition from ACTIVE to CLOSED. Inspector confirms: no more vulnerable PHP versions in the fleet.
Apache Log Analysis
Even if GuardDuty shows no compromise indicators, you should analyze Apache access logs for the exposure window around the CVE disclosure to look for exploitation attempts:
# Search for PHP2Shell exploit patterns in Apache logs # Run via SSM across all PHP instances aws ssm send-command \ --document-name "AWS-RunShellScript" \ --targets "Key=tag:Runtime,Values=php" \ --parameters 'commands=[ "echo \"=== Instance: $(curl -s http://169.254.169.254/latest/meta-data/instance-id) ===\"", "zgrep -c \"memory_limit\\|ini_set.*memory\\|str_repeat.*9999\" /var/lo...

