feat(waf): block anonymous WP REST batch endpoint (wp2shell CVE-2026-63030)
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 2m56s

Adds a frontend virtual patch denying /wp-json/batch/v1 and the
?rest_route=/batch/v1 fallback (including the %2F-encoded spelling) for
requests without a wordpress_logged_in_* cookie.

wp2shell chains CVE-2026-60137 (core SQL injection) with CVE-2026-63030
(REST batch-route confusion) into unauthenticated RCE on WP 6.9.0-6.9.4
and 7.0.0-7.0.1. Exploits are public and were used against this fleet on
2026-07-19/20; one site was compromised through this path, including a
re-injection of wp-includes/plugin.php nine minutes after it was patched.

Anonymous-only by design: batch/v1 is used legitimately by the block
editor for multi-entity saves, so a blanket deny would break wp-admin.
Placed ahead of the SPOE handoff so blocked requests never cost a WAF
round-trip, and it works regardless of Coraza mode (whp02 and sdbees run
detect_only, where a Coraza rule would log but not block).

This is a virtual patch, not a fix. It removes reachability only, and
stays until every site is confirmed on a fixed release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:02:45 -07:00
parent 2a2b9739fc
commit b892438070
+29
View File
@@ -96,6 +96,35 @@ frontend web
acl has_login_cookie req.cook(whplc) -m found acl has_login_cookie req.cook(whplc) -m found
http-request deny deny_status 403 if METH_POST wp_login_path !has_login_cookie !is_local !is_trusted_ip !is_whitelisted http-request deny deny_status 403 if METH_POST wp_login_path !has_login_cookie !is_local !is_trusted_ip !is_whitelisted
# WordPress REST batch endpoint lockdown ("wp2shell": CVE-2026-63030 +
# CVE-2026-60137). Chaining a core SQL injection with REST batch-route
# confusion gives unauthenticated RCE on WP 6.9.0-6.9.4 and 7.0.0-7.0.1
# (fixed in 6.9.5 / 7.0.2). Exploits are public and were used against this
# fleet on 2026-07-19/20; one site was compromised via this path before
# patching. This is a virtual patch: it does not repair the vulnerable
# application logic, it only removes reachability, so it stays until every
# site is confirmed on a fixed release.
#
# Both routing forms must be covered -- a rule matching only the pretty
# permalink path leaves the ?rest_route= fallback wide open, and urlp()
# does not URL-decode, hence the third ACL for the %2F spelling.
#
# Anonymous-only. batch/v1 is used legitimately by the block editor for
# multi-entity saves, so a blanket deny would break wp-admin for real
# users; requiring a wordpress_logged_in_* cookie costs them nothing.
# req.cook() needs an exact name and WordPress suffixes a per-site hash,
# so this substring-matches the raw Cookie header instead.
#
# Immediate deny, not tarpit -- holding connections open helps an attacker
# who is already scripting this. Honors the same whitelist as above.
acl wp_batch_path path_beg /wp-json/batch/v1
acl wp_batch_route urlp(rest_route) -i -m beg /batch/v1
acl wp_batch_route_enc query -i -m sub rest_route=%2Fbatch%2Fv1
acl has_wp_logged_in req.hdr(Cookie) -i -m sub wordpress_logged_in_
http-request deny deny_status 403 if wp_batch_path !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
http-request deny deny_status 403 if wp_batch_route !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
http-request deny deny_status 403 if wp_batch_route_enc !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
# IP blocking using map file (manual blocks only) # IP blocking using map file (manual blocks only)
# Map file format: /etc/haproxy/blocked_ips.map contains "<ip_or_cidr> 1" per line # Map file format: /etc/haproxy/blocked_ips.map contains "<ip_or_cidr> 1" per line
# Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock # Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock