English · Ελληνικά
/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.
Δύο θύρες, και το web root διαρρέει ένα hostname στον τίτλο του — το συνηθισμένο σημάδι να κάνεις fuzz virtual hosts αντί για paths.
Και οι δύο εκδόσεις είναι τρέχουσες — κανένα CVE επιπέδου έκδοσης να ρίξω. Αυτό είναι το σημάδι: το bug δεν θα είναι σε αυτό πάνω στο οποίο τρέχει η κλινική, θα είναι σε αυτό που η κλινική έχτισε. Η μπροστινή σελίδα είναι ένα στατικό φυλλάδιο χωρίς τίποτα που να δέχεται input, οπότε έκανα fuzz vhosts, φιλτράροντας το redirect που σερβίρει ο κύριος host για ονόματα που δεν κάνουν resolve:
Ένα hit: research.bedside.htb. Για να κρατήσω τα πάντα scriptable από macOS δεν άγγιξα ποτέ το /etc/hosts — κάθε request απλά κουβαλά το vhost με το χέρι:
Εκείνη η κεφαλίδα X-Powered-By είναι χειροβαλμένη — κανένα framework δεν την γράφει — και ονομάζει ολόκληρο το παιχνίδι: pdfminer.six. Η ίδια η πύλη είναι μια φόρμα upload αρχείου ("ανέβασε ακτινογραφίες, CT scans, ερευνητικά έγγραφα για να τροφοδοτήσεις το training μοντέλου AI της κλινικής"), με μια ειδοποίηση που λιστάρει αποδεκτά formats και δύο γραμμές που έχουν περισσότερη σημασία απ' όσο φαίνονται: "Οι συλλογές μπορούν να ανεβούν σαν archives" και "Ορισμένα formats αρχείων μπορεί να μετατραπούν σε τυποποιημένα formats." Ένα upload που δέχεται archives και μετατρέπει αρχεία σιωπηλά είναι ένα upload που κάνει parse πολλά στο παρασκήνιο. Το parsing είναι όπου σπάνε τα πράγματα.
Πριν αγγίξω το exploit έκανα το upload να μου πει τους δικούς του κανόνες. Το να στείλω ένα αρχείο που απορρίπτει το whitelist διαρρέει δύο χρήσιμα πράγματα — το πραγματικό αποδεκτό σύνολο, και το απόλυτο path αποθήκευσης:
Δύο γεγονότα προκύπτουν. Πρώτο, το 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. Άξιζε να δοκιμαστεί, αδιέξοδο.)
Το 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:
Ένα ζωντανό reverse shell που προσγειώνεται στο terminal μου είναι το πιο θορυβώδες πράγμα που μπορείς να κάνεις, οπότε δεν χρησιμοποίησα ποτέ ένα. Αντ' αυτού απέδειξα εκτέλεση out-of-band: κάνε το payload να κάνει curl ένα marker πίσω σε έναν listener που ελέγχω. Ο στόχος τραβάει — request/response, τίποτα inbound.
Εκτέλεση κώδικα σαν datawrangler σε έναν host που αυτοαποκαλείται data-wrangler. Η καθυστέρηση ~30 δευτερολέπτων είναι η ομολογία: το upload μου δεν πυροδότησε τίποτα — ένα προγραμματισμένο job ξύπνησε, βρήκε ένα νέο PDF, και το επεξεργάστηκε. (Ένας πρώιμος marker γραμμένος κατευθείαν στο web root είχε αποτύχει σιωπηλά: ο watcher τρέχει σαν datawrangler και δεν μπορεί να γράψει τον φάκελο uploads/ που ανήκει στον www-data. Το callback παρακάμπτει εντελώς τα δικαιώματα αρχείων.)
Container recon, exfiltrated με τον ίδιο τρόπο — κάνε pipe το output εντολής σε ένα POST που ο listener μου αποθηκεύει στον δίσκο:
Αυτό επιβεβαιώνει όλη τη θεωρία: ο βρόχος κάνει glob *.pdf κάθε 30 δευτερόλεπτα και τροφοδοτεί το καθένα στο pdf2txt.py — pdfminer κατευθείαν μέσα στο CVE. Εξηγεί επίσης έναν τοίχο από πριν: το process_pdf() απορρίπτει ρητά symlinks και μη-κανονικά αρχεία, οπότε η ιδέα archive-symlink δεν επρόκειτο ποτέ να δουλέψει. Ο developer σκέφτηκε εκείνη τη συγκεκριμένη επίθεση. Δεν σκέφτηκε ένα PDF που δείχνει σε ένα pickle. Το output προσγειώνεται στο /datastore/staging — έναν φάκελο έξω από το /app, και ο datawrangler είναι στην ομάδα dataops που τον κατέχει. Το κοινόχρηστο storage ανάμεσα σε containers είναι ακριβώς το είδος της ραφής που αξίζει να ανοίξεις.
Το 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 να μην συμπτύσσει βοηθητικά τα ../ πριν στείλει:
Αυτό δεν είναι το /etc/passwd του container — έχει developer, _laurel, polkitd, χρήστες που το minimal container δεν έχει. Η υπηρεσία στη θύρα 3000 διαβάζει αρχεία από το filesystem του host, και το traversal διαβάζει οποιοδήποτε από αυτά. Μια καταχώρηση /etc/passwd είναι μια συστατική, όχι τρόπος εισόδου — αλλά αυθαίρετη ανάγνωση αρχείου host σημαίνει ότι μπορώ απλά να ζητήσω το κλειδί SSH του developer και το flag χρήστη. Το κλειδί δεν είναι στο id_ed25519· μια γρήγορη σάρωση υποψήφιων ονομάτων το βρίσκει στο id_rsa:
Ένα unencrypted κλειδί ed25519 (αποθηκευμένο ως id_rsa), comment developer@bedside. Διόρθωσε τα perms του και περπάτησε κατευθείαν στον host, έξω από το container για πάντα:
Μία εντολή, χωρίς password: ένα script training MONAI που τρέχει σαν root. Διαβάζοντάς το, η ευάλωτη γραμμή ξεχωρίζει αμέσως — συνεχίζει από το νεότερο checkpoint χρησιμοποιώντας το CheckpointLoader του MONAI, που καλεί torch.load(…, weights_only=False) από κάτω, και το torch.load κάνει deserialize με pickle. Η ίδια πληγή με το foothold, φορώντας διαφορετικό παλτό: τάισέ το με ένα κακόβουλο .pt και το __reduce__ του τρέχει σαν root.
Το σχέδιο γράφεται μόνο του — φύτεψε ένα κακόβουλο checkpoint στο /datastore/checkpoints/, τρέξε τον trainer, πάρε root — αλλά το script έχει guards που μετατρέπουν ένα one-liner σε καβγά.
Ο 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.
Ένα ωμό 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:
Και τα δύο αρχεία φτάνουν στο box μέσω της πύλης — δεν χρειάζεται να φιλοξενήσω τίποτα. Το scan.png είναι ήδη επιτρεπτό· το evil.pt είναι ένα έγκυρο ZIP, οπότε το ανεβάζω μετονομασμένο σε evil.zip και περνά τον έλεγχο MIME. Μετά ένα μόνο payload pickle-RCE (πυροδοτημένο με τον ίδιο τρόπο δύο-αρχείων) τα αντιγράφει από το uploads/ στο /datastore σαν datawrangler, και ρίχνει έναν βρόχο cleanup ώστε το αδέσποτο output .txt του watcher να μην μπορεί ποτέ να σπάσει το pipeline:
Με ένα έγκυρο checkpoint στη θέση του και το pipeline κρατημένο καθαρό, τρέξε τον sudo trainer από το shell του developer. Το torch.load κάνει unpickle το checkpoint μου σαν root και το __reduce__ πυροδοτείται:
Εκείνο το TypeError μοιάζει με άλλη μία αποτυχία. Δεν είναι. Το os.system επιστρέφει έναν ακέραιο exit code, και ο loader του torch περίμενε ένα dict, οπότε πέταξε error στην τιμή επιστροφής — αλλά εκείνη η τιμή επιστροφής υπάρχει μόνο επειδή η εντολή έτρεξε ήδη. Η εξαίρεση πυροδοτείται μετά το payload, όχι αντί για αυτό. Μία γραμμή παρακάτω, τα bits s στο /bin/bash είναι ορισμένα, και το /bin/bash -p κρατά το euid=0. Root.
Το 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.
gz/zip του whitelist, το path αποθήκευσης σε ένα error MIME, το glob *.pdf στον watcher, το comment "// For demo" που εξέθεσε το API στη θύρα 3000 σαν ψεύτικο. Κάθε πινακίδα ήταν στην πηγή ή στο μήνυμα error, όχι στις υποθέσεις μου.developer δεν μπορούσε να γράψει το /datastore, αλλά μια δεύτερη ταυτότητα που μοιραζόταν το volume μπορούσε. Το privesc ήταν συντονισμός δύο-shells, όχι μία εντολή.Δύο flags, μία κλινική που εμπιστεύτηκε τα μηχανήματά της ακριβώς δύο φορές παραπάνω.