
Executive Summary
Simon Koeck showed that Google’s shared developer-docs platform (DevSite) would hand a Flask session cookie to anyone who requested /oauth2authorize. No Google account was required. The cookie was large, signed rather than encrypted, and zlib-compressed. Decoding it revealed a pickled oauth2client.client.OAuth2WebServerFlow object whose fields included a live OAuth client_id and client_secret. That same secret was the Flask SECRET_KEY. Koeck recomputed the itsdangerous cookie-session HMAC offline and matched the signature Google had just set. From there a forged session could put arbitrary bytes under the flow key. The OAuth callback unpickled those bytes. That is unauthenticated remote code execution. He proved it on production with time.sleep(N) and a set of negative controls, not with a shell. Google Bug Hunters paid $90,000 (T2 S0 RCE $75,000 × 1.2 for report quality). The fix landed 7 Oct 2026; the write-up is dated 12 Oct 2026.
This draft follows Koeck’s section order: how the cookie was found, why one secret did two jobs, how a forged cookie reached pickle.loads, which sibling hosts shared the key, the four stacked fixes, and the VRP timeline. Around that spine we add kitchen-table pictures, labeled diagrams, a CWE/ATT&CK map, Flask and pickle internals for operators, and a hunting checklist. The published time.sleep gadget is kept verbatim. This draft does not add a command-executing pickle, a drop-in cookie forger, or any live OAuth secret.
SECRET_KEY, derived with HMAC-SHA1(secret, b"cookie-session")) + CWE-502 (pickle.loads of google_oauth2_flow_<csrf> bytes on the callback). Source is unauthenticated GET /oauth2authorize. Sink is pickle REDUCE. Impact is S0 RCE on a shared DevSite key. ATT&CK T1190 / T1550 / T1059. Koeck names four additional hosts besides developers.google.com. No CVE is assigned in the write-up; do not invent one.
How to read this piece
- If you run a Flask app and you do not reverse Python: read the green boxes, the two-GET picture, and the four fixes. Action:
SECRET_KEYfrom a server-side secret store, never an OAuth client secret, never in a cookie. Do not pickle anything the client can echo back. - If you review session implementations: the blue boxes plus Koeck’s verbatim HMAC and decode snippets are the payload. Treat every signed cookie as public data plus a MAC, not as a vault.
- If you hunt unauthenticated RCE on docs platforms: look for pre-auth endpoints that set large Flask cookies,
" b"JSON tags, and pickle opcodes in session blobs. Timing oracles beat noisy command payloads on someone else’s production.

How I found it
Koeck was mapping the OAuth dance that Google’s developer documentation sites share. A request to /oauth2authorize plants a Flask session cookie and bounces the browser toward Google’s sign-in page. The interesting part is that this happens before anyone is logged in. The endpoint exists to start a login, so it has to mint state for a user who does not exist yet.
He fetched that endpoint with an ordinary curl. No cookies in, a Set-Cookie out, a redirect to sign-in. The command from the write-up:
curl -s -D headers.txt -o /dev/null \
'https://developers.google.com/oauth2authorize?return_url=%2F'
The cookie was the usual Flask three-part string, session=<payload>.<timestamp>.<signature>, but it weighed about 1,200 bytes. A CSRF token and a return URL do not need that much room. He decoded it.
Flask session cookies are signed, not encrypted. Anyone holding the bytes can read the JSON. A leading dot on the payload is Flask’s flag for zlib compression. Koeck’s decoder, as published:
import base64
import json
import pickletools
import zlib
payload, timestamp, signature = cookie.rsplit(".", 2)
def b64decode(value):
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
compressed = payload.startswith(".")
raw = b64decode(payload[1:] if compressed else payload)
session = json.loads(zlib.decompress(raw) if compressed else raw)
That produced a session shaped like this:
{
"_permanent": true,
"google_oauth2_csrf_token": "...",
"google_oauth2_flow_<csrf>": {
" b": "..."
}
}
The " b" key is Flask’s JSON tagger marking a bytes value. Base64-decoding those bytes yielded a pickle. pickletools.dis printed an oauth2client.client.OAuth2WebServerFlow object. Its fields, as Koeck printed them:
client_id = "<redacted>.apps.googleusercontent.com"
client_secret = b"<redacted>"
A pickle in a cookie is already the wrong shape for a client-held blob. An OAuth client secret sitting in that pickle in plaintext is worse. Koeck redacted the values because the secret was still live when he wrote. Then he tried the client secret as the Flask SECRET_KEY. The signature on the cookie Google had just issued verified against it. The key that stamps the cookie was sitting inside the cookie.


TaggedJSONSerializer emits " b" for bytes. Seeing that tag in a session is a hunt signal: something non-JSON is riding along. oauth2client.client.OAuth2WebServerFlow is the old Google API Python client flow class; it pickle-round-trips by default. Putting it in a client-visible session means the client now holds whatever that object held, including secrets. Compression (leading .) is a size tell, not a confidentiality control.Why a 1,200-byte session cookie is a smell
A Flask session that only stores a CSRF nonce and a return path is tens of bytes, maybe a couple of hundred with zlib overhead. Twelve hundred bytes means an object graph. Object graphs in cookies are how desktop-era RPC habits leak into HTTP. The next question is always the same: is this JSON data, or is it a serialized program? Here it was both. JSON on the outside, pickle on the inside, HMAC around the pair.
The authorize URL taking return_url=%2F is ordinary OAuth-state plumbing. It is not the bug. The bug is what the server stuffed into the cookie it set while doing that plumbing, and what it later did with that cookie on the way back.
One secret, two jobs
Leaking an OAuth client secret in a cookie is already a reportable finding. The part that turned it into RCE is that the same value verified Flask’s session MAC, so Koeck measured that instead of stopping at a lucky guess.
Flask signs through itsdangerous with the cookie-session key derivation. Offline, he recomputed the signature:
import hmac
derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(
derived_key,
f"{payload}.{timestamp}".encode(),
"sha1",
)
expected matched the signature on the cookie he had been handed seconds earlier, byte for byte. He then flipped one character of the signature (the server rejected it) and re-signed the original payload with the recovered secret (the server accepted it). Session integrity was gone. Every key and value in the session could be chosen by the client and still carry a MAC the server would honor.


URLSafeTimedSerializer / Signer path: HMAC-SHA1, key derivation salt cookie-session, message payload.timestamp. The derivation exists so that one SECRET_KEY can mint many contextual keys. It does not help if that SECRET_KEY is an OAuth client secret sitting in the payload. Reusing application secrets across security domains (OAuth client vs session MAC vs CSRF vs encryption) is how a medium finding becomes a critical one. Rotation of the OAuth secret is also rotation of every live session, which is why Koeck’s first fix item is “rotate both and kill every session signed with them.”Signed is not encrypted
Teams still talk about “secure cookies” as if a MAC were a lockbox. A MAC answers one question: did this byte string come from someone who knew the key? It answers nothing about whether the byte string is a secret. Flask’s default session is a public JSON document with a signature. Anything you put in it is a public JSON document. Bytes tagged " b" are a public JSON document that happens to be a pickle. If the MAC key is also in that document, the one question a MAC can answer is no longer useful, because the client now knows the key.
HttpOnly and Secure flags do not change this. They stop Javascript and cleartext HTTP from lifting the cookie. They do not stop the owner of the browser — or anyone who can issue the first GET — from reading a cookie the server just set on them.
From forged cookie to code execution
The OAuth callback reads the flow object back out of the session and unpickles it. Koeck’s data-flow, as published:
session cookie
-> verify Flask signature
-> read google_oauth2_flow_<csrf>
-> base64-decode the bytes
-> pickle.loads(...)
That pickle.loads is only “safe” while nobody else can mint a valid cookie. Once the key is public, the assumption is empty. He built a session with a CSRF token he chose and placed an arbitrary pickle under the matching flow key:
session = {
"_permanent": True,
"google_oauth2_csrf_token": "attacker-chosen",
"google_oauth2_flow_attacker-chosen": {
" b": base64.b64encode(attacker_blob).decode()
},
}
The OAuth state parameter is attacker-controlled as well, so nothing on the server has to match a previously stored value. The server checks the MAC, finds the flow entry, and hands the bytes to pickle.loads.
Pickle’s REDUCE opcode names a callable and its arguments. Unpickling calls it. A public signing key plus this sink is unauthenticated RCE.
Koeck did not run commands on Google’s production. He proved execution with time. The payload reduced to time.sleep(N):
class Sleep:
def __init__(self, seconds):
self.seconds = seconds
def __reduce__(self):
return (time.sleep, (self.seconds,))
Same forged session every time, only the number changed:
time.sleep(0) -> 0.207 s
time.sleep(3) -> 3.193 s
time.sleep(5) -> 5.194 s
time.sleep(8) -> 8.283 s
The response time tracked the integer in the pickle, which only happens if the server imported time and actually called sleep(N).
Then the controls, to rule out a slow endpoint or jitter:
- No cookie:
Invalid request. - One flipped byte in the signature:
Invalid request. - Valid signature, no flow entry:
Invalid request state. - Valid signature with the real flow: reached Google’s token exchange and failed later with
invalid_granton a fake auth code. - Valid signature with the sleep payload: delayed by exactly the number he picked.


calc.exe is how you get a visit from their lawyers. Asking it to wait five seconds, then eight, then three, and writing down the times, is how you prove it would have run calc.exe without doing it. The waiting is the proof. The other four tests are how you show the waiting is not the network having a bad day.time.sleep is imported in almost every Python process, needs no filesystem, and has a huge signal-to-noise ratio against jitter once N is a few seconds. The control matrix is the part bounty programs actually want: broken MAC fails closed, missing key fails closed, honest flow proceeds to token exchange, only the gadget delays. attacker_blob in the published snippet is a placeholder for the pickled Sleep instance, not a filled os.system payload. This draft leaves it that way. state matching the CSRF in the cookie is what lets the callback find google_oauth2_flow_attacker-chosen without a server-side session store.What pickle REDUCE actually does
Python’s pickle is a small bytecode VM. REDUCE pops a callable and a tuple of arguments and calls callable(*args). __reduce__ on a class is how an object tells the VM “rebuild me by calling this.” Koeck’s Sleep says “rebuild me by calling time.sleep with N.” The reconstruction is the side effect. There is no later “use the object” step that you can sandbox after the fact. If pickle.loads runs, the callable already ran.
That is why “we only unpickle objects we expect” is not a defense once the attacker chooses the bytes. The callback expected an OAuth2WebServerFlow. Pickle does not care. It cares about the opcodes. Switching to a data-only format (JSON with a schema, protobuf with a schema, even a HMAC’d identifier that keys a server-side store) is the fix Koeck lists last, and it is the one that makes a forged cookie inert even if the MAC key leaks again.
Why this is unauthenticated
Nothing in the chain requires a Google account, a docs-site login, or a prior session. The first GET mints the cookie. The client secret is in that cookie. The second request is the callback with a cookie the attacker signed. CVSS UI:N, PR:N, AV:N. The “two GET requests” line in the write-up is literal. The sleep PoC is still RCE: arbitrary callables, including ones that start processes. Koeck chose not to demonstrate those on Google’s servers. That choice is ethics, not a limit of the sink.
How far it went
This was not one application. It was the shared DevSite platform, so the signing key recovered from developers.google.com produced valid sessions on the sibling documentation hosts Koeck listed:
firebase.google.comdeveloper.android.comsource.android.comdocs.partner.android.com
The index blurb on his site says the forged session became code execution across seven Google domains. The write-up itself names the four hosts above plus the developers.google.com origin. Treat the platform, not a single hostname, as the blast radius. A shared SECRET_KEY across docs properties is a shared RCE across docs properties.


Domain attributes may even be parent-scoped. Even when they are host-only, a stolen SECRET_KEY forges a host-only cookie for each sibling. Scope in a VRP sense is the platform. Inventory question for defenders: how many production hostnames share this SECRET_KEY, and how many of them expose /oauth2authorize or an equivalent callback that unpickles?Fixing it
Koeck lists four changes, and they stack. Any one of them would have broken the published chain. All four lined up, which is how two GET requests execute code.
- Rotate both exposed OAuth client secrets and kill every session signed with them.
- Give each deployment its own high-entropy Flask
SECRET_KEYfrom a server-side store, with nothing to do with an OAuth credential. - Keep the OAuth flow object on the server. The cookie should hold an opaque reference and nothing sensitive.
- Drop
picklefor anything crossing a client boundary. A data-only format with a schema check makes a forged cookie inert.
Thanks in the original post go to the Google Bug Hunters team and the DevSite engineers who investigated and fixed the issue. The write-up records the fix as 7 Oct 2026, 15:20.

os.urandom(32) or a cloud secret manager, unique per environment, never derived from another credential. Fix 3 is the real session design: store the flow in Redis/memcache keyed by an opaque CSRF, put only the CSRF in the cookie. Fix 4 is a language-level ban: pickle.loads on client data is CWE-502 regardless of the MAC. JSON + schema, or itsdangerous with a whitelist serializer, or no client-held object at all.What “opaque reference” looks like in Flask
A CSRF token, signed if you like, is an identifier. The server looks it up. The flow object, the client secret, the redirect URI, the scopes, never leave the process or the cache. If the cookie is stolen, the thief has a bearer token for that login attempt, which is already the OAuth state threat model, not RCE. If the cookie is forged, there is no cache entry, and the callback returns Invalid request state — the same error Koeck got when he sent a valid MAC without a flow entry, except now even a valid MAC cannot create the flow entry from thin air.
Timeline
| When | Event |
|---|---|
| Aug 6, 2026 · 22:48 | Report submitted |
| Aug 7, 2026 · 08:12 | Triaged |
| Aug 7, 2026 · 16:23 | Accepted |
| Aug 27, 2026 | Reward decided: $90,000. Normal Google Applications (T2), S0 RCE: $75,000 base × 1.2 for exceptional report quality. |
| Oct 7, 2026 · 15:20 | Fixed |
| Oct 12, 2026 | Write-up published on simonkoeck.com |
Triage in under ten hours and accept the same day is a fast VRP cycle for an S0. The reward formula is public Google VRP: T2 (Normal Google Applications) S0 RCE base $75,000, quality multiplier 1.2. Two months from accept to fix is the engineering work of rotating secrets and changing how DevSite stores OAuth state across a family of hostnames.
CWE, ATT&CK, and what this is not
| Piece | CWE | Role in the chain |
|---|---|---|
| OAuth secret in the cookie | CWE-315 / CWE-522 | Confidential credential in a client-held blob |
| Secret is also SECRET_KEY | CWE-321 analog (key reuse) | MAC key recoverable from the message |
| pickle.loads on flow bytes | CWE-502 | Sink: untrusted deserialization |
| REDUCE callable | CWE-830 analog | Execution primitive once pickle runs |
| Pre-auth authorize endpoint | CWE-306 (missing authn) as delivery | No account needed to obtain the cookie |
| Shared platform key | CWE-1329 analog | One secret, many hosts |

- It is unauthenticated RCE. It is not a stored XSS, not an OAuth open redirect by itself, and not “just” a leaked client secret.
- It is not a cryptographic break of HMAC-SHA1. The MAC worked. The key was public.
- It is not a Flask zero-day. Flask behaved as documented: signed cookies, pickle if you feed it pickle. The application chose the key and the sink.
- No CVE identifier appears in the write-up. This draft does not invent one.
Hunting notes for Flask operators
Koeck’s post is a VRP write-up, not a detection paper. The checks below are additions for people who ship Flask and OAuth together.
In your own code review
- Grep for
pickle.loads,pickle.load,cloudpickle, anddillon any path that reads a cookie, a hidden field, orrequest.data. - Grep for
SECRET_KEYassignments. If the value is an OAuth client secret, an API token, a hostname, or a string from source control, it is wrong. - Dump a real session cookie from a pre-auth endpoint. If it is hundreds of bytes or contains
" b", you are storing bytes. Disassemble those bytes before you ship. - Confirm OAuth flow objects live in server-side storage keyed by
state, not in the session cookie. - Count hostnames that share the secret. That count is your blast radius.
A defensive decode, not an exploit
Koeck’s first snippet is a decoder. Running it against a cookie from an application you own is a reasonable self-test. If json.loads succeeds and you see " b", stop and find out what the bytes are before you ever think about forging a signature. Forging a signature against a system you do not own is out of scope here and was out of scope for everyone except Koeck under VRP rules.
What will not save you
- Switching the cookie to
HttpOnly/Secure/__Host-prefix. Those are good flags. They do not hide a pickle from the client who received it. - Rotating the OAuth secret but keeping pickle on the callback. The next leak, or the next key-reuse, returns the sink.
- Encrypting the cookie with the same reused key. Confidentiality of the pickle is not the issue once the attacker can also mint the MAC.
- A WAF signature for
cposix\nsystem\n. Timing gadgets and unused opcodes will walk around it. Delete the unpickle.
If you think a Flask cookie of yours is in this shape
- Take the app out of the pickle path immediately. Opaque server-side flow objects, JSON schema, or a library that refuses pickle.
- Rotate
SECRET_KEYand every OAuth client secret that might have been in a session. Invalidate all sessions. - Assume any pre-auth visitor could have forged a callback. That is the threat model until the sink is gone.
- Check sibling hostnames and staging. Shared secrets hide in environment templates.
Lessons for people who only wanted the $90,000 headline
The payout is Google VRP doing what the public table says it does for S0 RCE on T2. The quality multiplier is for the write-up Koeck sent them: recovered key, offline HMAC match, positive and negative controls, timing table, blast radius across the platform, and a proof that did not become a production shell. That is the report shape, not a lottery ticket.
For everyone else: if your docs site, your status page, or your marketing Flask app speaks OAuth, it is in the same design space. Pre-auth cookies are still cookies. Pickle is still pickle. Secrets that do two jobs will eventually do a third job for someone else.
Key Takeaways
- Simon Koeck turned a pre-auth Flask cookie on Google DevSite into unauthenticated RCE and was paid $90,000 (T2 S0 × 1.2). Write-up 12 Oct 2026; fix 7 Oct 2026.
- The cookie was signed, not encrypted, zlib-compressed, ~1,200 bytes, and contained a pickle of
OAuth2WebServerFlowwith a liveclient_secret. - That client secret was also the Flask
SECRET_KEY. OfflineitsdangerousHMAC-SHA1 matched Google’s signature. - The callback unpickled attacker-chosen flow bytes.
REDUCEis RCE. Production proof wastime.sleep(N)plus a control matrix, not a shell. - The recovered key signed sessions on the shared DevSite hosts Koeck listed (Firebase and Android documentation properties among them).
- Four fixes, any one sufficient: rotate, unique server-side
SECRET_KEY, opaque server-side flow, no pickle on the client boundary. - This is CWE-502 plus key reuse, not a Flask CVE and not a break of HMAC. No CVE is named in the source.
Defensive Recommendations
- Never unpickle client-influenced data. Ban
pickle.loadson cookies, form fields, and queue messages that crossed a trust boundary. - Generate Flask
SECRET_KEYfrom a secret manager, unique per environment, unrelated to OAuth, API, or CSRF secrets. - Store OAuth flow objects server-side. Put only an opaque
state/ CSRF in the cookie. - Decode your own pre-auth session cookies in review.
" b"tags and kilobyte-sized sessions are stop-ship smells. - Inventory hostnames that share a signing key. Shared-platform keys share RCE.
- If a session MAC key or an OAuth client secret leaks, rotate both and invalidate sessions. They may be the same string.
- Prefer data-only session serializers (Flask’s JSON with a strict tag allow-list, or no client-side objects at all).
- When proving deserialization on production you do not own, use timing or another non-destructive oracle. Koeck’s sleep table is the model.
Conclusion
Koeck asked a documentation site for a login cookie, read an OAuth secret out of it, used that secret as the Flask signing key, and showed that the login callback would unpickle whatever he put in the next cookie. Two GET requests, no account, a clock for a proof, $90,000 from Google Bug Hunters, a platform-wide key, and a four-part fix that every Flask-plus-OAuth deployment can copy. The lesson is older than DevSite: a signature is not a vault, a pickle is a program, and a secret that does two jobs will eventually do one of them for an uninvited caller.
Original text: “How I Found a $90,000 Remote Code Execution Vulnerability in Google” by Simon Koeck at simonkoeck.com.


