crypto
stream-cipher-fans-when

tldr;
The “CSPRNG” doesn’t have a real secret; it’s just a fixed, global byte permutation applied to a keystream derived from a known function of the counter. Because the keystream before permutation is fully known to us, the only unknown is the 256-byte permutation reused across all blocks.
We recover that permutation by matching ciphertext columns against the known unpermuted keystream columns using English-likeness scoring with a one-shot assignment (Hungarian) algorithm. Once the permutation is found, decryption is immediate and reveals the flag.
cipher.py— the vulnerable encryption schemeAIW-truncated.txt— the public-knowledge plaintext source (Alice in Wonderland)encrypted.bin— ciphertext we must decrypt
details — what to do
The generating code that places the secret into the ciphertext path is in
cipher.py:This write the encrypted data to
encrypted.bin:
with open('AIW.txt', 'rb') as f:
aiw = f.read()[random.randint(0, 1000):]
with open('encrypted.bin', 'wb') as f:
for block in encrypt(aiw):
f.write(block)an meth example

meth

solve
import re
from pathlib import Path
import numpy as np
CHUNK = 256
enc = np.frombuffer(Path("encrypted.bin").read_bytes(), dtype=np.uint8)
C = enc.reshape((-1, CHUNK)) # (B,256)
B = C.shape[0]
def raw_block(counter: int) -> np.ndarray:
data = (str(counter) * 1337).encode()
state = np.zeros(CHUNK, dtype=np.uint8)
for i in range(0, len(data), CHUNK):
chunk = data[i : i + CHUNK]
chunk = chunk + b"\0" * (CHUNK - len(chunk))
state ^= np.frombuffer(chunk, dtype=np.uint8)
return state
S = np.stack([raw_block(i) for i in range(B)], axis=0) # (B,256)
def byte_w(b: int) -> float:
if not (b in {9, 10, 13} or 32 <= b <= 126):
return -10.0
w = 1.0
if b == 32:
w += 0.7
if b in b"etaoinshrdlucmfwypvbgkqjxz":
w += 0.4
if b in b"ETAOINSHRDLUCMFWYPVBGKQJXZ":
w += 0.2
if b in b",.;:'\"!?-()[]{}_*/\\":
w += 0.15
if b in b"0123456789":
w += 0.05
return w
W = np.zeros((CHUNK, CHUNK), dtype=float)
for j in range(CHUNK):
colC = C[:, j]
for k in range(CHUNK):
W[j, k] = float(sum(byte_w(int(b)) for b in (colC ^ S[:, k])))
def hungarian(cost):
cost = np.array(cost, dtype=float)
n = cost.shape[0]
u = np.zeros(n + 1)
v = np.zeros(n + 1)
p = np.zeros(n + 1, dtype=int)
way = np.zeros(n + 1, dtype=int)
for i in range(1, n + 1):
p[0] = i
j0 = 0
minv = np.full(n + 1, float("inf"))
used = np.zeros(n + 1, dtype=bool)
while True:
used[j0] = True
i0 = p[j0]
delta = float("inf")
j1 = 0
for j in range(1, n + 1):
if not used[j]:
cur = cost[i0 - 1, j - 1] - u[i0] - v[j]
if cur < minv[j]:
minv[j] = cur
way[j] = j0
if minv[j] < delta:
delta = minv[j]
j1 = j
for j in range(0, n + 1):
if used[j]:
u[p[j]] += delta
v[j] -= delta
else:
minv[j] -= delta
j0 = j1
if p[j0] == 0:
break
while True:
j1 = way[j0]
p[j0] = p[j1]
j0 = j1
if j0 == 0:
break
a = np.zeros(n, dtype=int)
for j in range(1, n + 1):
a[p[j] - 1] = j - 1
return a
cost = W.max() - W
perm = hungarian(cost) # perm[j] = k
P = C ^ S[:, perm]
pt = bytes(P.reshape(-1).tolist())
Path("decrypted.txt").write_bytes(pt)
m = re.search(rb"[a-zA-Z]*\{[^}]+\}", pt)
print("Flag:", m.group(0).decode() if m else "<not found>")
> python solve.py
Flag: watctf{https://graydon2.dreamwidth.org/319755.html}watctf{https://graydon2.dreamwidth.org/319755.html}
java-oracle
Consult the Java Oracle Secure Vault — but beware, its predictions leak more than your morning espresso. nc challs.watctf.org 2013
tldr;
The service prints an AES-CBC ciphertext (IV || CT) for a JSON blob that contains the flag. It then acts as a padding oracle: for any hex ciphertext you submit, it decrypts and tells you whether PKCS#7 padding is valid (Valid padding) or not (Invalid padding).
Although it only reveals the flag if your submitted plaintext equals the original JSON exactly (which we can’t forge without an encryption oracle), Vaudenay’s padding-oracle attack lets us decrypt the original ciphertext block-by-block by submitting forged two-block inputs J || C_i. Reading the recovered JSON reveals the flag.
import binascii
import json
import socket
import sys
BS = 16
def recv_line(f):
line = f.readline()
if not line:
raise RuntimeError("disconnected")
return line.decode(errors="ignore").rstrip("\r\n")
def split_blocks(b, n=16):
return [b[i : i + n] for i in range(0, len(b), n)]
def unpad(b: bytes) -> bytes:
if len(b) < BS or len(b) % BS != 0:
raise ValueError
p = b[-1]
if p < 1 or p > BS or b[-p:] != bytes([p]) * p:
raise ValueError
return b[:-p]
def build_batch(Ci: bytes, I: bytearray, pos: int):
pad = BS - pos
tail = bytearray(BS)
for j in range(pos + 1, BS):
tail[j] = I[j] ^ pad
lines = []
for g in range(256):
J = bytearray(BS)
J[pos] = g
for j in range(pos + 1, BS):
J[j] = tail[j]
lines.append(binascii.hexlify(bytes(J) + Ci).decode())
return lines
def recover_block(f, Ci: bytes, Prev: bytes) -> bytes:
I = bytearray(BS)
for pos in range(BS - 1, -1, -1):
lines = build_batch(Ci, I, pos)
f.write(("\n".join(lines) + "\n").encode())
f.flush()
hit = None
for idx in range(256):
s = recv_line(f)
if ("Valid padding" in s) or ("Access granted!" in s):
if hit is None:
hit = idx
if hit is None:
raise RuntimeError("no hit")
I[pos] = hit ^ (BS - pos)
return bytes(a ^ b for a, b in zip(I, Prev))
def main():
host, port = sys.argv[1], int(sys.argv[2])
s = socket.create_connection((host, port), timeout=900)
s.settimeout(900)
f = s.makefile("rwb", buffering=0)
banner = recv_line(f)
recv_line(f)
raw = bytes.fromhex(banner)
iv0, ct = raw[:BS], raw[BS:]
C = split_blocks(ct, BS)
rec = []
for i in range(len(C) - 1, -1, -1):
Ci = C[i]
Prev = iv0 if i == 0 else C[i - 1]
Pi = recover_block(f, Ci, Prev)
rec.insert(0, Pi)
pt = unpad(b"".join(rec))
print("[+] Plaintext:", pt)
j = json.loads(pt.decode())
print("[+] Flag:", j["access_code"])
main()
> python solve.py
[+] Plaintext: b'{"access_code": "watctf{quantum_helix_padding_oracle}", "facility": "quantum_reactor_z9", "clearance": "alpha"}'
[+] Flag: watctf{quantum_helix_padding_oracle}Flag: watctf{quantum_helix_padding_oracle}
permpress

import re
import socket
import sys
HOST = "challs.watctf.org"
PORT = 2333
TIMEOUT = 30.0
# --- FNV-1a 64-bit (matches simplehash::fnv1a_64 on i32::to_le_bytes) ---
FNV_OFFSET_64 = 0xCBF29CE484222325
FNV_PRIME_64 = 0x100000001B3
def fnv1a_64(data: bytes) -> int:
h = FNV_OFFSET_64
for b in data:
h ^= b
h = (h * FNV_PRIME_64) & 0xFFFFFFFFFFFFFFFF
return h
def hash_bucket(x: int) -> int:
b = x.to_bytes(4, "little", signed=True) # i32::to_le_bytes
return fnv1a_64(b) % 32
# Precompute 32 buckets (each is size 8 with FNV-1a here)
BUCKETS = [[] for _ in range(32)]
for v in range(256):
BUCKETS[hash_bucket(v)].append(v)
def makefile(sock: socket.socket):
return sock.makefile("rwb", buffering=0)
# -------- robust recv helpers (do NOT require newline) --------
def recv_until(fp, token: bytes) -> bytes:
buf = b""
while token not in buf:
b = fp.read(1)
if not b:
raise RuntimeError("connection closed while waiting for prompt")
buf += b
return buf
def recv_line_containing(fp, needle: bytes) -> bytes:
line = b""
while True:
ch = fp.read(1)
if not ch:
raise RuntimeError("connection closed while reading line")
line += ch
if ch == b"\n":
if needle in line:
return line
line = b""
def wait_for_choice_prompt(fp):
recv_until(fp, b"Enter your choice:")
def sendline(fp, s: str):
fp.write(s.encode() + b"\n")
fp.flush()
# -------- oracle interaction --------
def alt_pair_perm(a: int, b: int) -> list[int]:
out = [0] * 256
for i in range(128):
out[2 * i] = a
out[2 * i + 1] = b
return out
def submit_perm_get_value(fp, perm_list):
# IMPORTANT: assume we're ALREADY at the "Enter your choice:" prompt.
sendline(fp, "1")
recv_until(fp, b"Enter the permutation")
sendline(fp, " ".join(map(str, perm_list)))
# The oracle prints a full line with println!
line = recv_line_containing(fp, b"The oracle has divined")
m = re.search(rb"divined\.\.\.\s*(-?\d+)", line)
if not m:
raise RuntimeError(f"unexpected oracle line: {line!r}")
val = int(m.group(1))
# After printing the result, the server reprints the menu + prompt.
wait_for_choice_prompt(fp)
return val
def sample_pair(fp, a: int, b: int, max_tries: int = 16) -> set[int]:
vals = set()
perm = alt_pair_perm(a, b)
for _ in range(max_tries):
vals.add(submit_perm_get_value(fp, perm))
if len(vals) >= 2:
break
return vals
def recover_bucket(fp, items: list[int]) -> dict[int, int]:
assert len(items) >= 2
a = items[0]
pair_sets = {}
for s in items[1:]:
vs = sample_pair(fp, a, s)
tries = 0
while len(vs) < 2 and tries < 24:
vs |= sample_pair(fp, a, s, max_tries=4)
tries += 1
if len(vs) < 2:
raise RuntimeError(f"couldn't separate values for pair ({a},{s}), got {vs}")
pair_sets[s] = vs
inter = None
for vs in pair_sets.values():
inter = vs if inter is None else (inter & vs)
if not inter or len(inter) != 1:
raise RuntimeError(f"anchor intersection ambiguous: {inter}")
val_a = next(iter(inter))
mapping = {a: val_a}
for s, vs in pair_sets.items():
rest = list(vs - {val_a})
if len(rest) != 1:
raise RuntimeError(f"pair ambiguity for {s}: set={vs} anchor={val_a}")
mapping[s] = rest[0]
return mapping
def guess_and_finish(fp, perm_map: dict[int, int]):
full = [perm_map[i] for i in range(256)]
# We should currently be at the prompt thanks to the last submit_perm_get_value
sendline(fp, "2")
recv_until(fp, b"Enter the permutation")
sendline(fp, " ".join(map(str, full)))
# Print whatever comes (flag or failure)
while True:
ch = fp.read(1)
if not ch:
break
sys.stdout.write(ch.decode(errors="ignore"))
sys.stdout.flush()
def main():
print("[*] Buckets (sizes):", [len(b) for b in BUCKETS])
with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as s:
s.settimeout(TIMEOUT)
fp = makefile(s)
# Eat initial banner/menu until the FIRST prompt
wait_for_choice_prompt(fp)
perm_map = {}
for bi, items in enumerate(BUCKETS):
print(f"[*] Recovering bucket {bi} with {len(items)} items...")
mapping = recover_bucket(fp, items)
perm_map.update(mapping)
print(f" recovered {len(mapping)} indices (total {len(perm_map)}/256)")
print("[*] All indices recovered; submitting guess...")
guess_and_finish(fp, perm_map)
main()use rand::{seq::SliceRandom, Rng};
use rustc_hash::FxBuildHasher;
use std::{hash::{BuildHasher, Hash}, io::{self, BufRead, Read, Write}};
use simplehash::fnv1a_64;
fn do_hash(x: i32) -> u64 {
fnv1a_64(&x.to_le_bytes())
}
const HASH_SIZE: usize = 32;
fn test_perm_restricted_memory(perm: &[i32]) -> bool {
let mut map = [-1i32; HASH_SIZE];
for x in perm {
let idx = (do_hash(*x) % HASH_SIZE as u64) as usize;
if map[idx] == *x {
// duplicate, fail
return false;
} else {
// not a duplicate, continue
}
map[idx] = *x;
}
true
}
fn parse_perm(inp: &str) -> Result<Vec<i32>, &'static str> {
let ans: Vec<i32> = inp.trim().split(' ').map(|x| x.parse::<u32>().unwrap() as i32).collect();
if ans.len() != 256 {
return Err("permutation is not of length 256");
}
for x in &ans {
if *x < 0 || 256 <= *x {
return Err("permutation element out of range");
}
}
if !test_perm_restricted_memory(&ans) {
return Err("permutation has non-unique elements");
}
Ok(ans)
}
fn compose_perms(p1: &[i32], p2: &[i32]) -> Vec<i32> {
p1.iter().map(|i| p2[*i as usize]).collect()
}
fn main() {
let mut rng = rand::rng();
let mut cipherperm: Vec<i32> = (0..256).collect();
cipherperm.shuffle(&mut rng);
let stdin = io::stdin();
let mut line_iter = stdin.lock().lines();
let mut get_line = || -> String {
line_iter.next().unwrap().unwrap()
};
println!("Welcome to the Permutation Oracle.");
loop {
println!("Main Menu");
println!("1. Give the oracle a permutation");
println!("2. Guess the secret permutation");
print!("Enter your choice: ");
io::stdout().flush().unwrap();
let choice = get_line();
let choice: u32 = choice.trim().parse().unwrap();
if choice == 1 {
print!("Enter the permutation seperated by spaces: ");
io::stdout().flush().unwrap();
let perm_str = get_line();
let perm = match parse_perm(&perm_str) {
Err(e) => {
println!("Error: {e}");
continue;
},
Ok(v) => v,
};
let res = compose_perms(&perm, &cipherperm);
let i = rng.random_range(0..res.len());
println!("The oracle has divined... {}", res[i]);
} else if choice == 2 {
print!("Enter the permutation seperated by spaces: ");
io::stdout().flush().unwrap();
let perm_str = get_line();
let perm = match parse_perm(&perm_str) {
Err(e) => {
println!("Error: {e}");
continue;
},
Ok(v) => v,
};
if perm == cipherperm {
println!("Good job! Here's your reward: {}", std::env::var("FLAG").unwrap());
} else {
println!("Unfortunately, wrong :/");
}
}
}
}python solve.py
[*] Buckets (sizes): [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]
[*] Recovering bucket 0 with 8 items...
recovered 8 indices (total 8/256)
[*] Recovering bucket 1 with 8 items...
recovered 8 indices (total 16/256)
[*] Recovering bucket 2 with 8 items...
recovered 8 indices (total 24/256)
[*] Recovering bucket 3 with 8 items...
recovered 8 indices (total 32/256)
[*] Recovering bucket 4 with 8 items...
recovered 8 indices (total 40/256)
[*] Recovering bucket 5 with 8 items...
recovered 8 indices (total 48/256)
[*] Recovering bucket 6 with 8 items...
recovered 8 indices (total 56/256)
[*] Recovering bucket 7 with 8 items...
recovered 8 indices (total 64/256)
[*] Recovering bucket 8 with 8 items...
recovered 8 indices (total 72/256)
[*] Recovering bucket 9 with 8 items...
recovered 8 indices (total 80/256)
[*] Recovering bucket 10 with 8 items...
recovered 8 indices (total 88/256)
[*] Recovering bucket 11 with 8 items...
recovered 8 indices (total 96/256)
[*] Recovering bucket 12 with 8 items...
recovered 8 indices (total 104/256)
[*] Recovering bucket 13 with 8 items...
recovered 8 indices (total 112/256)
[*] Recovering bucket 14 with 8 items...
recovered 8 indices (total 120/256)
[*] Recovering bucket 15 with 8 items...
recovered 8 indices (total 128/256)
[*] Recovering bucket 16 with 8 items...
recovered 8 indices (total 136/256)
[*] Recovering bucket 17 with 8 items...
recovered 8 indices (total 144/256)
[*] Recovering bucket 18 with 8 items...
recovered 8 indices (total 152/256)
[*] Recovering bucket 19 with 8 items...
recovered 8 indices (total 160/256)
[*] Recovering bucket 20 with 8 items...
recovered 8 indices (total 168/256)
[*] Recovering bucket 21 with 8 items...
recovered 8 indices (total 176/256)
[*] Recovering bucket 22 with 8 items...
recovered 8 indices (total 184/256)
[*] Recovering bucket 23 with 8 items...
recovered 8 indices (total 192/256)
[*] Recovering bucket 24 with 8 items...
recovered 8 indices (total 200/256)
[*] Recovering bucket 25 with 8 items...
recovered 8 indices (total 208/256)
[*] Recovering bucket 26 with 8 items...
recovered 8 indices (total 216/256)
[*] Recovering bucket 27 with 8 items...
recovered 8 indices (total 224/256)
[*] Recovering bucket 28 with 8 items...
recovered 8 indices (total 232/256)
[*] Recovering bucket 29 with 8 items...
recovered 8 indices (total 240/256)
[*] Recovering bucket 30 with 8 items...
recovered 8 indices (total 248/256)
[*] Recovering bucket 31 with 8 items...
recovered 8 indices (total 256/256)
[*] All indices recovered; submitting guess...
seperated by spaces: Good job! Here's your reward: watctf{1nd1v1du4l_p3rm5_l0v3_uniqueness}
Main Menu
1. Give the oracle a permutation
2. Guess the secret permutation
Enter your choice: Traceback (most recent call last):
File "/home/dreams/code/ctfs/waterloo/crypto/permpress/solve.py", line 157, in <module>
File "/home/dreams/code/ctfs/waterloo/crypto/permpress/solve.py", line 154, in main
File "/home/dreams/code/ctfs/waterloo/crypto/permpress/solve.py", line 131, in guess_and_finish
# Print whatever comes (flag or failure)
File "/home/dreams/miniconda3/envs/ctfs/lib/python3.10/socket.py", line 717, in readinto
return self._sock.recv_into(b)
TimeoutError: timed outLast updated