RC RANDOM CHAOS

The cookie carried its key

A Flask session cookie on developers.google.com carried its own signing key, letting a researcher forge sessions and reach RCE across Google DevSite hosts.

· 3 min read
The cookie carried its key

A Flask session cookie on developers.google.com was signed with a secret it carried inside its own payload. A researcher mapping the OAuth flow that Google’s developer documentation sites share pulled that secret out, forged a session, and turned it into unauthenticated remote code execution across several of those sites. Google paid $90,000.

The entry point is open to anyone. Requesting /oauth2authorize sets a Flask session cookie and redirects you to Google’s sign-in page, with no account required:

curl -s -D headers.txt -o /dev/null \
  'https://developers.google.com/oauth2authorize?return_url=%2F'

The cookie ran about 1,200 bytes, which is large for a CSRF token and a return URL. Flask session cookies are signed, not encrypted, so whoever holds one can read the contents. A leading dot means the payload is zlib-compressed. Decoded, the session held the OAuth CSRF token and a google_oauth2_flow_<csrf> entry whose single " b" key is Flask’s tag for a bytes value. Base64-decoding those bytes produced a pickle, and pickletools.dis showed an oauth2client.client.OAuth2WebServerFlow object with its fields spelled out, including the OAuth client_id and client_secret in plaintext.

A live OAuth client secret in a cookie that any unauthenticated request can fetch is already a finding. The reuse made it worse: the same secret was Flask’s SECRET_KEY.

Flask signs cookies through itsdangerous, deriving the signing key from SECRET_KEY with an HMAC over the salt cookie-session, then HMACing payload.timestamp. Recomputing that offline with the leaked secret reproduced the signature on the issued cookie byte for byte:

derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(derived_key, f"{payload}.{timestamp}".encode(), "sha1")

Flipping one character of the signature was rejected; re-signing the original payload with the recovered secret was accepted. Session integrity was gone. Every key and value in the session could now be set by the client and still produce a signature the server accepts.

The sink sits in the OAuth callback. It reads the flow object back out of the session, base64-decodes the bytes, and calls pickle.loads on them. That call is only safe while nobody else can forge a valid cookie. pickle’s REDUCE opcode names a callable and its arguments, and unpickling calls it, so attacker-controlled bytes through pickle.loads is code execution. With the signing key public, a forged session with an attacker-chosen CSRF token and a matching flow entry carries whatever pickle you want. The state parameter is attacker-supplied too, so nothing needs to line up on the server side.

Rather than run commands on Google’s production, the researcher proved it with time. A payload whose __reduce__ returned (time.sleep, (N,)) made the response time track N:

time.sleep(0) -> 0.207 s
time.sleep(3) -> 3.193 s
time.sleep(5) -> 5.194 s
time.sleep(8) -> 8.283 s

Controls ruled out a slow endpoint. No cookie, a flipped signature byte, and a valid signature with no flow entry each returned an error. A valid signature with the real flow reached Google’s token exchange and failed later with invalid_grant. Only the valid signature carrying the sleep payload produced the delay.

The same key validated sessions beyond developers.google.com: firebase.google.com, developer.android.com, source.android.com, and docs.partner.android.com. One shared DevSite signing key covered the set.

Three design decisions stacked to produce this. A secret was reused for two jobs, OAuth client authentication and cookie signing. The signing key was stored inside the object it signs, so leaking the payload leaked the key. And the callback unpickled data that crossed a client boundary. The researcher’s fix list addresses each: rotate both exposed OAuth client secrets and invalidate every session signed with them; give each deployment its own high-entropy SECRET_KEY from a server-side store, unrelated to any OAuth credential; keep the flow object on the server with the cookie holding only an opaque reference; and replace pickle with a data-only format behind a schema check, which makes a forged cookie inert even if a key leaks again.

The payout came in as an S0 RCE on a Normal Google Application (T2): a $75,000 base, multiplied by 1.2 for report quality, for $90,000.

Share

Keep Reading

Latest on the Wire

Full wire →

New signal daily · RSS

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.