> For the complete documentation index, see [llms.txt](https://thienguen.gitbook.io/ctf-writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://thienguen.gitbook.io/ctf-writeups/ctf/2025/watctf-f25/crypto.md).

# crypto

## stream-cipher-fans-when

<figure><img src="/files/011BW3YBCH839rpMEYPP" alt=""><figcaption></figcaption></figure>

### tldr;

{% hint style="success" %}
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.&#x20;

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.
{% endhint %}

* `cipher.py` — the vulnerable encryption scheme
* `AIW-truncated.txt` — the public-knowledge plaintext source (Alice in Wonderland)
* `encrypted.bin` — ciphertext we must decrypt

### details — what to do&#x20;

* The generating code that places the secret into the ciphertext path is in <mark style="color:$danger;">`cipher.py`</mark>:
  * This write the encrypted data to <mark style="color:$danger;">`encrypted.bin`</mark>:

```python
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

<figure><img src="/files/f9CG0TTI9mhoxjgWjlQa" alt=""><figcaption></figcaption></figure>

### meth

<figure><img src="/files/8GruPJjT8AWWeedUhfqn" alt=""><figcaption></figcaption></figure>

### solve

```python
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}
```

* <mark style="color:$success;">**`watctf{https://graydon2.dreamwidth.org/319755.html}`**</mark>

## java-oracle <a href="#java-oracle" id="java-oracle"></a>

> Consult the Java Oracle Secure Vault — but beware, its predictions leak more than your morning espresso. nc challs.watctf.org 2013

### tldr;

{% hint style="success" %}
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`).&#x20;

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.
{% endhint %}

```python
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}
```

* <mark style="color:$success;">**`Flag: watctf{quantum_helix_padding_oracle}`**</mark>

## permpress <a href="#permpress" id="permpress"></a>

<figure><img src="/files/ghxFEnMXaO0ysBs9QwzN" alt=""><figcaption></figcaption></figure>

{% tabs %}
{% tab title="solve.py" %}

```python
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()
```

{% endtab %}

{% tab title="main.rs" %}

```rust
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 :/");
            }
        }
    }
}
```

{% endtab %}

{% tab title="example run" %}

```bash
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 out
```

{% endtab %}
{% endtabs %}
