Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions wordpress/CVE-2026-63030/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# CVE-2026-63030: WordPress Core Unauthenticated RCE Chain ("wp2shell")

WordPress core ships a REST API batch endpoint (`/wp-json/batch/v1`) that dispatches
multiple sub-requests from a single call. `WP_REST_Server::serve_batch_request_v1()`
builds two parallel arrays while looping over sub-requests: one entry per sub-request
(`$requests`), and one entry per _successfully parsed_ sub-request (`$matches`). A
sub-request whose `path` fails `wp_parse_url()` pushes an error onto `$requests` but
nothing onto `$matches`, desynchronizing the two arrays (CWE-436, CVE-2026-63030).
Dispatch then does `$match = $matches[$i]`, so every sub-request after a parse failure
executes under the route/handler that was actually matched for the _next_ sub-request
in line, while keeping its own already-parsed params.

This is chained with CVE-2026-60137: `WP_Query`'s `author__not_in` handling only
sanitized the parameter when it arrived as an array; a scalar string was interpolated
into raw SQL instead. `/wp/v2/posts` normally casts `author_exclude` to an array of
ints before it reaches `WP_Query`, but `/wp/v2/categories` has no such arg at all, so a
value smuggled in on a `/wp/v2/categories` sub-request never gets sanitized by anyone
-- and route confusion can make that sub-request execute under the _posts_ controller's
`get_items()`, which reads `author_exclude` straight into `author__not_in`.

- Affected: WordPress 6.9.0-6.9.4, 7.0.0-7.0.1
- Fixed: WordPress 6.9.5, 7.0.2 (also backported to 6.8.6)

## Environment Setup

```sh
docker compose up -d
```

- **Vulnerable**: WordPress 7.0.1 at http://localhost:8080
- **Patched**: WordPress 7.0.2 at http://localhost:8081

## Test vulnerable version

```sh
python3 poc.py http://localhost:8080
python3 poc.py http://localhost:8080 'SELECT DATABASE()'
python3 poc.py http://localhost:8080 -c 'echo "you got pwned" > /tmp/pwned.txt && id'
```

## Test patched version

```sh
python3 poc.py http://localhost:8081
python3 poc.py http://localhost:8081 'SELECT DATABASE()'
python3 poc.py http://localhost:8081 -c 'echo "you got pwned" > /tmp/pwned.txt && id'
```

## Resources

- https://github.com/sergiointel/wp2shell-poc
- https://github.com/lbherrera/wp2shell-union-based
- https://nvd.nist.gov/vuln/detail/CVE-2026-63030
- https://nvd.nist.gov/vuln/detail/CVE-2026-60137
104 changes: 104 additions & 0 deletions wordpress/CVE-2026-63030/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
services:
db-vulnerable:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
volumes:
- db_vulnerable:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
interval: 5s
timeout: 5s
retries: 20

wordpress-vulnerable:
# Last release before the CVE-2026-63030 / CVE-2026-60137 (wp2shell) fix.
image: wordpress:7.0.1-php8.3-apache
restart: unless-stopped
depends_on:
db-vulnerable:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db-vulnerable
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
ports:
- "8080:80"
volumes:
- wp_vulnerable:/var/www/html

wp-init-vulnerable:
image: wordpress:cli-php8.3
depends_on:
- wordpress-vulnerable
user: "0"
environment:
WORDPRESS_DB_HOST: db-vulnerable
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WP_URL: http://localhost:8080
volumes:
- wp_vulnerable:/var/www/html
- ./init/install.sh:/usr/local/bin/install.sh:ro
entrypoint: ["sh", "/usr/local/bin/install.sh"]

db-patched:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
volumes:
- db_patched:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
interval: 5s
timeout: 5s
retries: 20

wordpress-patched:
# First release containing the fix for CVE-2026-63030 / CVE-2026-60137.
image: wordpress:7.0.2-php8.3-apache
restart: unless-stopped
depends_on:
db-patched:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db-patched
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
ports:
- "8081:80"
volumes:
- wp_patched:/var/www/html

wp-init-patched:
image: wordpress:cli-php8.3
depends_on:
- wordpress-patched
user: "0"
environment:
WORDPRESS_DB_HOST: db-patched
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WP_URL: http://localhost:8081
volumes:
- wp_patched:/var/www/html
- ./init/install.sh:/usr/local/bin/install.sh:ro
entrypoint: ["sh", "/usr/local/bin/install.sh"]

volumes:
db_vulnerable:
wp_vulnerable:
db_patched:
wp_patched:
34 changes: 34 additions & 0 deletions wordpress/CVE-2026-63030/init/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/sh

set -eu

WP="wp --path=/var/www/html --allow-root"

echo "[init] waiting for wp-config.php..."
until [ -f /var/www/html/wp-config.php ]; do
sleep 2
done

echo "[init] installing WordPress (retrying until the DB accepts connections)..."
until $WP core is-installed >/dev/null 2>&1; do
$WP core install \
--url="${WP_URL}" \
--title="wp2shell testbed" \
--admin_user=admin \
--admin_password=admin \
--admin_email=admin@example.test \
--skip-email >/dev/null 2>&1 && break
sleep 3
done

published_count=$($WP post list --post_type=post --post_status=publish --format=count)
if [ "${published_count}" -eq 0 ]; then
$WP post create --post_title="Hello world" --post_status=publish --post_content="Just a regular published post."
fi

# wp-cli runs as root and lazily creates wp-content/uploads/... (e.g. via
# wp_upload_dir()), leaving it root-owned. That breaks Apache's ability to
# write uploaded files later.
[ -d /var/www/html/wp-content/uploads ] && chown -R 33:33 /var/www/html/wp-content/uploads

echo "[init] done"
Loading