home / ctf / k17

spot

The commitment is counted with FIONREAD but never read until after the reveal, and splice() puts a page reference in the pipe rather than bytes.

2026.09.19 K17 CTF 2026 Misc
FLAG K17{n3verm1nd_we're_br0ke_hav3_t#is_fl@g_in$tead}
ssh ctf@vm1.secso.cc   (password from the instancer)

“we’ve implemented a new system that makes it 100% impossible for us to rig the randomness. It’s also literally unhackable.”

The banner is the thesis of the challenge: it’s a commit-reveal scheme, and the whole point is to convince you it can’t be broken. It can. If you’re stuck on this one, the short version of where to look is: stop staring at the commit/reveal math, and start asking what “the commitment” actually is at the byte level, and when it’s actually read.

1. Setup

/flag is admin:admin 0400; we are ctf. The only bridge is /app/runner, setuid admin, which forks twice over a pipe pair:

  • mainexecle("/usr/local/bin/python", "python", "/app/main.py"), privileges kept (ruid=ctf, euid=admin), fd 3 = read end, fd 4 = write end
  • client — our script, setresuid(real_uid) before exec, fd 3/4 = the other ends

main.py prints /flag iff r1 ^ r2 == 67, where we commit to r1 first and r2 is a fresh secrets.token_bytes(16). A textbook commit-reveal: we commit to r1 before r2 exists, so in principle we can’t choose r1 in response to r2.

My first assumption was that the bug had to be in the fd plumbing — this pattern (fork twice, dup2 across a pipe pair, drop privileges in the client) is exactly the shape that usually hides an fd-confusion bug. I spent a while trying to get the client to retain read_pipe[0] past the dup2 calls: that fd would need to land on 3 or 4 and survive two dup2(oldfd, newfd) calls, which requires oldfd == newfd on two distinct fds — not possible for any arrangement of pre-opened fds, closed stdio slots, or RLIMIT_NOFILE tricks I could construct. If you’re checking this angle yourself: it’s a real category of bug in this kind of challenge, but it isn’t the one here. The fd plumbing in runner is correct.

So the bug has to be in what main.py does with the bytes once they’re in the pipe, not in how the pipe itself is wired up.

2. The bug — the commitment is counted, never read

while get_num_bytes_in_pipe(fd_read) < 64:        # FIONREAD only!
    ...

r2 = secrets.token_bytes(16)                      # reveal r2
f_write.write(r2.hex() + "\n"); f_write.flush()

while get_num_bytes_in_pipe(fd_read) < 128:
    ...

data = bytes.fromhex(f_read.read(128))            # <-- first and only read
commit, reveal = data[:32], data[32:]
if hashlib.sha256(reveal).digest() != commit: raise

This is the detail that matters: the first loop only calls get_num_bytes_in_pipe, which is FIONREAD — it asks the kernel how many bytes are queued, but never actually reads them. The only place main.py reads the commitment is f_read.read(128), and that happens after r2 has already been written out.

So “the commitment” isn’t fixed at the moment it’s sent. It’s whatever bytes happen to be sitting at the head of the pipe at the moment main.py finally calls .read() — and by then, it already knows r2. If we can change what’s in the pipe between “the kernel confirms 64/128 bytes are present” and “the program actually reads them,” the commit-before-reveal guarantee is gone.

3. Dead ends

These are worth listing because each one looked promising before it didn’t work, and ruling them out is most of the actual work on this challenge:

  • /proc/self/fd/4 re-opened O_RDONLY, to get a read end on our own write end and drain the filler bytes before rewriting them → EACCES. The pipes are created by runner while it is still euid=admin, so the pipefs inodes are admin 0600. We can write to fd 4, but we can’t open it fresh for reading.
  • bytes.fromhex ignores ASCII whitespace, which looked like a way to pad out extra real bytes past the 128-byte gate — except read(128) counts 128 characters, and every whitespace character still costs one character of budget. No slack there.
  • Universal newlines ("\r\n""\n") shrinks 64 filler bytes down to 32 characters on the wire, but those 32 characters still consume 32 of the 128-character budget. Doesn’t change the accounting.
  • O_DIRECT packet-mode pipes discard the tail of a write if the reader’s buffer is smaller than what was written — but TextIOWrapper calls read1(8192), well above anything we’d send, so nothing is ever discarded.
  • Finding a SHA-256 preimage so a fixed, pre-committed value matches the hash after the fact is not something we’re going to brute-force our way into. Ruled out immediately, but worth naming as the “wrong direction” this challenge wants you to briefly consider and discard.

4. The trick — put a page reference in the pipe, not bytes

The bug isn’t in main.py’s Python at all — it’s in a property of pipes that most challenges never touch: splice() and vmsplice(SPLICE_F_GIFT) don’t copy bytes into a pipe. They install a reference to a page in the pipe buffer, and the actual copy only happens when pipe_read runs, at read time. That means the contents of a pipe are not frozen the moment write()/splice() returns — they’re frozen the moment something actually reads them.

mfd = os.memfd_create("c")
os.ftruncate(mfd, 4096)
os.pwrite(mfd, b"0" * 64, 0)
os.splice(mfd, 4, 64, offset_src=0)                  # FIONREAD == 64, "committed"

r2 = bytes.fromhex(f_read.readline().strip())        # main hands over r2
r1 = (int.from_bytes(r2) ^ 67).to_bytes(16)          # decide r1 afterwards

salt = secrets.token_bytes(16)
os.pwrite(mfd, hashlib.sha256(r1 + salt).hexdigest().encode(), 0)   # rewrite the page
os.write(4, (r1 + salt).hex().encode() + b"\n")

Walking through what actually happens: FIONREAD sees 64 bytes queued (satisfied by the memfd page reference) and lets main.py proceed to reveal r2. Only then do we compute r1 from r2, and rewrite the same underlying page with a commitment that’s actually consistent with it. When main.py finally calls f_read.read(128), it reads a commitment that was authored after the reveal — the SHA-256 check passes because we computed it correctly, just late — and r1 ^ r2 == 67 by construction.

Let's go gambling!
Rolling a really big die...
It landed on 67.
Congratulations! ...
K17{n3verm1nd_we're_br0ke_hav3_t#is_fl@g_in$tead}

Notes for next time

  • “Bytes are in the pipe” and “bytes are committed” are not the same claim. A commitment that is only ever counted, never read, at the time it’s supposedly fixed, isn’t actually fixed at all. If a challenge uses FIONREAD/select/poll to decide when data has “arrived” but reads it later through a separate call, that gap is worth examining closely.
  • splice/vmsplice place page references in a pipe rather than copies, so pipe contents can still change after the write call returns, right up until something actually reads them. This is the general technique to remember: it applies anywhere a checker validates a buffer at one point in time and consumes it at a later, separate point in time — not just here.
  • This function measures length in three different units at once: byte counts (FIONREAD), character counts (TextIOWrapper.read), and decoded lengths (bytes.fromhex). Whenever a length gate is guarding a parse, it’s worth explicitly checking which of these units is actually being enforced at each step — they are not interchangeable, and mismatches between them are a recurring source of bugs in this style of challenge.
#commit-reveal#splice#pipes#linux