PHP2Shell: How We Secured 200+ EC2 Instances in 4 Hours Using AWS Inspector, Systems Manager, and Terraform
November 2026 | ~18 min read Sprint Planning Interrupted We were twenty minutes into sprint planning on a Tuesday morning when our #security-alerts Slack channel lit up. AWS Inspector had flagged CVE-2026-109
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/log/httpd/access_log* /var/log/apache2/access.log* 2>/dev/null || echo \"No matches\"",
"echo \"Large POST requests (>5MB):\"",
"awk \"\\$9 > 5242880 && /POST/\" /var/log/httpd/access_log /var/log/apache2/access.log 2>/dev/null | wc -l"
]' \
--comment "PHP2Shell - log analysis for exploitation"
In a real scenario, you'd likely see requests containing memory_limit in query strings — automated vulnerability scanners (Nuclei, Nikto) typically start probing within 12 hours of a CVE. Look for large POST requests matching the heap spray pattern. The absence of those is what tells you exploitation hasn't succeeded.
Automated Post-Patch Verification Report
Finally, generate a verification report for the incident record:
#!/usr/bin/env python3
"""Generate post-patch verification report for PHP2Shell incident."""
import boto3
import json
from datetime import datetime
def generate_report():
inspector = boto3.client("inspector2")
ssm = boto3.client("ssm")
# Get current Inspector findings for PHP2Shell CVEs
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"},
],
}
):
findings.extend(page["findings"])
active = [f for f in findings if f["status"] == "ACTIVE"]
closed = [f for f in findings if f["status"] == "CLOSED"]
report = {
"incident": "PHP2Shell (CVE-2026-109618, CVE-2026-109619)",
"report_generated": datetime.utcnow().isoformat() + "Z",
"inspector_findings": {
"active": len(active),
"closed": len(closed),
"total": len(findings),
},
"status": "RESOLVED" if len(active) == 0 else "IN_PROGRESS",
}
# Get SSM managed instance count for verification
inventory = ssm.get_inventory(
Filters=[
{
"Key": "AWS:Application.Name",
"Values": ["php*"],
"Type": "Equal",
}
]
)
report["ssm_managed_php_instances"] = len(
inventory.get("Entities", [])
)
print(json.dumps(report, indent=2))
if report["status"] == "RESOLVED":
print(f"\nPHP2Shell remediation verified.")
print(f" Active findings: {len(active)}")
print(f" Closed findings: {len(closed)}")
print(f" All {len(closed)} previously vulnerable instances patched.")
else:
print(f"\nWARNING: {len(active)} active findings remain.")
for f in active:
print(f" {f['resources'][0]['id']}: {f['title']}")
if __name__ == "__main__":
generate_report()
Output:
{
"incident": "PHP2Shell (CVE-2026-109618, CVE-2026-109619)",
"report_generated": "2026-10-22T16:12:00Z",
"inspector_findings": {
"active": 0,
"closed": 428,
"total": 428
},
"status": "RESOLVED",
"ssm_managed_php_instances": 214
}
428 closed findings (214 instances × 2 CVEs). Zero active. Incident resolved. That's the target end state.
Prevent Future Incidents
Being able to respond in four hours is good. Never needing to scramble would be better. Here are three things to build proactively, before the next critical CVE drops.
Terraform Module: Security Response Readiness
Package your incident response infrastructure into a Terraform module that any team can deploy. It provisions Inspector, SSM inventory collection, WAF rule group references, GuardDuty, and CloudWatch alarms — everything you need to detect and respond to the next PHP2Shell:
# modules/security-response-readiness/main.tf
# --- Inspector ---
resource "aws_inspector2_enabler" "ec2" {
account_ids = [data.aws_caller_identity.current.account_id]
resource_types = ["EC2"]
}
# Inspector event bridge rule for critical findings
resource "aws_cloudwatch_event_rule" "inspector_critical" {
name = "${var.environment}-inspector-critical-findings"
description = "Alert on Inspector critical/high findings"
event_pattern = jsonencode({
source = ["aws.inspector2"]
detail-type = ["Inspector2 Finding"]
detail = {
severity = ["CRITICAL", "HIGH"]
status = ["ACTIVE"]
}
})
}
resource "aws_cloudwatch_event_target" "inspector_to_sns" {
rule = aws_cloudwatch_event_rule.inspector_critical.name
target_id = "send-to-sns"
arn = var.alert_sns_topic_arn
}
# --- SSM Inventory ---
resource "aws_ssm_association" "inventory" {
name = "AWS-GatherSoftwareInventory"
association_name = "${var.environment}-software-inventory"
schedule_expression = "rate(12 hours)"
targets {
key = "tag:ManagedBy"
values = ["terraform"]
}
parameters = {
applications = "Enabled"
awsComponents = "Enabled"
customInventory = "Enabled"
}
}
# --- GuardDuty ---
resource "aws_guardduty_detector" "main" {
enable = true
datasources {
malware_protection {
scan_ec2_instance_with_findings {
ebs_volumes {
enable = true
}
}
}
runtime_monitoring {
autoEnable = "ALL"
}
}
}
# --- CloudWatch Alarm: Inspector finding count ---
resource "aws_cloudwatch_metric_alarm" "inspector_critical_count" {
alarm_name = "${var.environment}-inspector-critical-findings"
alarm_description = "Critical Inspector findings detected"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "CRITICAL"
namespace = "AWS/Inspector2"
period = 300
statistic = "Sum"
threshold = 0
treat_missing_data = "notBreaching"
alarm_actions = [var.alert_sns_topic_arn]
tags = {
Environment = var.environment
Purpose = "security-response"
}
}
# --- WAF: Pre-staged rule group reference ---
resource "aws_wafv2_web_acl" "security_baseline" {
name = "${var.environment}-security-baseline"
scope = "REGIONAL"
default_action {
allow {}
}
rule {
name = "aws-managed-common"
priority = 1
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "common-rules"
sampled_requests_enabled = true
}
}
rule {
name = "aws-managed-known-bad-inputs"
priority = 2
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "known-bad-inputs"
sampled_requests_enabled = true
}
}
rule {
name = "aws-managed-php"
priority = 3
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesPhpRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "php-rules"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "security-baseline"
sampled_requests_enabled = true
}
tags = {
Environment = var.environment
Purpose = "security-baseline"
}
}
data "aws_caller_identity" "current" {}
variable "environment" {
type = string
}
variable "alert_sns_topic_arn" {
type = string
description = "SNS topic for security alerts"
}
SSM Maintenance Window for Automated Patching
Set up automated weekly patching for PHP packages so you're never more than 7 days behind on security updates:
# modules/ssm-patching/main.tf
resource "aws_ssm_maintenance_window" "php_patching" {
name = "${var.environment}-php-weekly-patch"
schedule = "cron(0 6 ? * SUN *)" # Sunday 6 AM UTC
duration = 2
cutoff = 1
allow_unassociated_targets = false
tags = {
Environment = var.environment
Purpose = "automated-patching"
}
}
resource "aws_ssm_maintenance_window_target" "php_instances" {
window_id = aws_ssm_maintenance_window.php_patching.id
name = "php-instances"
resource_type = "INSTANCE"
targets {
key = "tag:Runtime"
values = ["php"]
}
}
resource "aws_ssm_maintenance_window_task" "patch_php" {
window_id = aws_ssm_maintenance_window.php_patching.id
task_type = "RUN_COMMAND"
task_arn = "AWS-RunPatchBaseline"
priority = 1
max_concurrency = "10%"
max_errors = "5"
targets {
key = "WindowTargetIds"
values = [aws_ssm_maintenance_window_target.php_instances.id]
}
task_invocation_parameters {
run_command_parameters {
parameter {
name = "Operation"
values = ["Install"]
}
parameter {
name = "RebootOption"
values = ["NoReboot"]
}
}
}
}
# Custom patch baseline that includes PHP packages
resource "aws_ssm_patch_baseline" "php" {
name = "${var.environment}-php-patch-baseline"
operating_system = "AMAZON_LINUX_2023"
approval_rule {
approve_after_days = 0 # Approve immediately for critical
compliance_level = "CRITICAL"
patch_filter {
key = "SEVERITY"
values = ["Critical", "Important"]
}
patch_filter {
key = "CLASSIFICATION"
values = ["Security"]
}
}
approval_rule {
approve_after_days = 3 # 3-day delay for non-critical
compliance_level = "MEDIUM"
patch_filter {
key = "SEVERITY"
values = ["Medium", "Low"]
}
}
tags = {
Environment = var.environment
Purpose = "php-patching"
}
}
Golden AMI Pipeline
The real fix is building patched PHP into the AMI so new instances launch with the latest version. Add a step to your AMI build pipeline that fails if any critical CVEs exist for the installed PHP version:
#!/bin/bash
# golden-ami-php-gate.sh — runs during AMI build
# Fails the build if PHP has known critical CVEs
set -euo pipefail
PHP_VERSION=$(php -v | head -1 | awk '{print $2}')
echo "Checking PHP $PHP_VERSION for known critical CVEs..."
# Query Inspector's vulnerability database (via NIST NVD API)
RESPONSE=$(curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?\
cpeName=cpe:2.3:a:php:php:${PHP_VERSION}:*:*:*:*:*:*:*\
&cvssV3Severity=CRITICAL" 2>/dev/null)
VULN_COUNT=$(echo "$RESPONSE" | jq -r '.totalResults // 0')
if [[ "$VULN_COUNT" -gt 0 ]]; then
echo "BLOCKING: PHP $PHP_VERSION has $VULN_COUNT critical CVE(s):"
echo "$RESPONSE" | jq -r '.vulnerabilities[].cve | "\(.id): \(.descriptions[0].value)"'
echo ""
echo "Update PHP before building this AMI."
exit 1
fi
echo "PHP $PHP_VERSION — no critical CVEs found. AMI build may proceed."
This gate runs in a Packer build pipeline. If PHP has any critical CVEs, the AMI build fails and alerts the team. No more deploying vulnerable runtimes to production.
Lessons Learned
1. Inspector continuous monitoring is non-negotiable. Inspector finds vulnerabilities for you automatically, within hours of CVE publication. If you're relying on quarterly vulnerability scans, your instances could be exposed for months. Enable Inspector on every account, for every resource type. It costs pennies compared to the alternative.
2. SSM Run Command turns "patch 214 servers" from a weekend project into a lunch break task. Without SSM, you'd be SSHing into instances one by one, or building a new AMI and cycling the entire fleet. SSM lets you patch in waves with concurrency controls and error thresholds. The entire production patch in this exercise took 90 minutes with zero downtime.
3. WAF buys you time, not safety. WAF rules block known exploit patterns, and that's valuable — it reduces your exposure while you patch. But WAF only catches known patterns. Containment is a bridge to patching, not a substitute for it.
4. Tag your instances consistently, or SSM targeting doesn't work. Tags like Runtime=php and Environment=production make it possible to target SSM commands precisely. The instances with overly permissive security groups in this scenario? They were also the ones with inconsistent tagging. Resource tagging isn't just for billing — it's your targeting mechanism during incidents.
5. The incident response you practice is the one that works. That's exactly why I built this simulation. Having the tools in place (Inspector, SSM, WAF) is only half the battle — you also need a rehearsed process. Walking through a realistic scenario end-to-end, with real commands and real configs, surfaces gaps that a tabletop discussion never would. Run this exercise with your team before the next critical CVE drops.
Action Items Checklist
Pre-Incident (Deploy Now)
- [ ] Enable Inspector across all accounts (EC2, ECR, Lambda)
- [ ] Deploy SSM agent on every EC2 instance
- [ ] Configure SSM software inventory collection (12-hour schedule)
- [ ] Enable GuardDuty with EBS malware scanning and runtime monitoring
- [ ] Deploy WAF with AWS managed rule groups (Common, Known Bad Inputs, PHP)
- [ ] Set up EventBridge rule: Inspector critical findings → SNS → Slack/PagerDuty
- [ ] Tag all instances consistently (
Runtime,Environment,ManagedBy) - [ ] Create health check endpoints that verify application stack (not just HTTP 200)
During Incident
- [ ] Pull Inspector findings — scope the blast radius (accounts, instances, versions)
- [ ] Run SSM inventory command — get exact versions fleet-wide
- [ ] Activate WAF containment rules — block known exploit patterns
- [ ] Check GuardDuty — any indicators of compromise?
- [ ] Tighten security groups — ALB-only, no direct public access
- [ ] Set up incident monitoring dashboard (WAF blocks, healthy hosts, error rates)
- [ ] Patch staging first, run smoke tests
- [ ] Patch production with rolling concurrency (10%) and health gates
- [ ] Verify: SSM version check, Inspector rescan, log analysis
Post-Incident
- [ ] Generate verification report (Inspector findings closed, versions confirmed)
- [ ] Analyze logs for exploitation attempts in the exposure window
- [ ] Update AMI pipeline with CVE gate for affected runtime
- [ ] Set up automated weekly patching via SSM Maintenance Window
- [ ] Document runbook with exact commands used
- [ ] Run tabletop exercise within 30 days
- [ ] Review and remove temporary WAF rules (keep baseline rules permanent)
- [ ] Update security group audit to catch future drift
This is a standalone post — not part of the AWS Cost Optimization Series. For cost optimization content, see the full series on Dev.to.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.