Pickle Surprise: Pickle deserialization RCE in oauth2client
Introduction
pickle is Python’s built-in serialization/deserialization library. It allows
users to capture Python state in a format that is transferable between
interpreters. It has long been a rich source of bugs for users who rely on it to
deserialize untrusted content. What makes pickle more dangerous than other
serialization/deserialization utilities, such as JSON, is an object’s ability to
override a special method called __reduce__, which embeds a callable into the
pickled object. Upon deserialization, the callable can execute arbitrary Python
code.
oauth2client is a deprecated Google library that implements OAuth 2.0 and
integrates with Flask, a Python web framework. Its pickle deserialization is
reachable from attacker-controlled input. If an attacker can forge a session
cookie, that cookie can contain a payload that executes arbitrary code on the
host before authentication.
In this post, I explain the bug, demonstrate the exploit, and alert any downstream users I may have missed.
The Bug
There are two functions I will highlight to help explain the bug: the source of the legitimate pickled OAuth 2.0 flow and the vulnerable destination of the forged pickle payload.
-
The
_make_flow()function, which produces the serialized flow.flow = client.OAuth2WebServerFlow( client_id=self.client_id, client_secret=self.client_secret, scope=self.scopes, # ... ) flow_key = _FLOW_KEY.format(csrf_token) session[flow_key] = pickle.dumps(flow) # ← pickled, stored in session -
The
_get_flow_for_token()function, which deserializes the pickle.flow_pickle = session.pop( _FLOW_KEY.format(csrf_token), None) if flow_pickle is None: return None else: return pickle.loads(flow_pickle) # ← deserialized from session
The cookie signature is validated before _get_flow_for_token() is called.
According to the project’s recommended default setup, the Flask app is
instantiated with the oauth2client library integration and initialized with a
SECRET_KEY. The key suggested by Google’s Bookshelf example is
'secret'. The oauth2client docstring uses 'your-secret-key'.
This secret key is used to sign a cookie using HMAC. The cookie
contains a pickled OAuth flow object.
The attacker must know or correctly guess the signing key. A wrong guess fails Flask’s cookie signature check, so the forged session is not loaded. The sequence of operations that leads to exploitation is as follows:
sequenceDiagram
participant Attacker
participant App as Flask app
Attacker->>Attacker: Create new pickle from payload defining __reduce__
Attacker->>Attacker: Build session with new pickle and matching CSRF token
Attacker->>Attacker: Sign forged session cookie with candidate key
Attacker->>App: Send forged cookie with OAuth callback
App->>App: Validate cookie signature
App->>App: Validate callback state and matching CSRF token
App->>App: _get_flow_for_token() calls pickle.loads()
Note over App: Pickle payload executes during deserialization
To validate the vulnerability, I created a simple proof of concept (PoC) that writes a file to the target server’s filesystem. Here is an excerpt:
class RCEPayload:
def __reduce__(self):
return (eval, ("open('poc_proof.txt', 'w').write('RCE achieved')",))
When pickle.loads is called, eval is run. When I ran the PoC in my test
environment with oauth2client 4.1.3, Flask 3.1.3, and Python 3.13.2, I saw
poc_proof.txt written to disk with the marker text.
Disclosure
I disclosed this vulnerability and the exploit PoC to Google on February 22, 2026. The report was marked as WONTFIX due to the project’s archived state,
even though PyPI Stats reported 24,545,418 downloads of
oauth2client in the last month when checked on October 11, 2026. These counts
measure downloads, not unique users or vulnerable deployments.
I am sharing these details without a fix strictly for educational purposes and
to help downstream users mitigate the issue.
If your app is using:
from oauth2client.contrib.flask_util import UserOAuth2
oauth2 = UserOAuth2(app)
and you are using a weak secret key, then you may be vulnerable.
Brief Aside: Cracking HMAC Signatures
Assuming a weak secret, the HMAC-signed cookie can be brute-forced offline without server interaction. You only need one cookie/signing key. Hashcat 7.1.2 gives 2.53 billion guesses per second for mode 29100 (Flask Session Cookie) on my GPU (NVIDIA GeForce RTX 5080).
| Signing key choice | Full search time | Average time |
|---|---|---|
| Random 8 digits | <1 second | <1 second |
| Random 8 lowercase letters | ~83 seconds | ~41 seconds |
| Random 10 lowercase letters | ~15.5 hours | ~7.7 hours |
| Random 8 letters and digits | ~24 hours | ~12 hours |
| Random 10 letters and digits | ~10.5 years | ~5.3 years |
| Random 16 hexadecimal digits | ~231 years | ~115 years |
| Random 32 hexadecimal digits | ~4 × 10²¹ years | ~2 × 10²¹ years |
Lessons Learned
- Probably avoid
pickle. This OAuth flow could have been JSON. - Use
google-authinstead of the deprecatedoauth2client. - Don’t use a hard-coded, weak, or shared example
SECRET_KEY.
References
oauth2client 4.1.3 Flask integration: Source for
_make_flow(), _get_flow_for_token(), and callback handling.
Google’s Bookshelf example: Example Flask application that sets
SECRET_KEY to 'secret'.
Python 3.13 pickle documentation: Serialization behavior,
__reduce__, and the warning against unpickling untrusted data.
Flask configuration documentation: The role of SECRET_KEY
in signing session cookies and guidance on choosing a secure value.
HMAC overview: Background on keyed message authentication.
oauth2client download statistics: PyPI Stats reported 24,545,418 downloads in the last month when checked on October 11, 2026. This is a download count, not a count of vulnerable deployments.
Hashcat example hashes: Supported hash modes and example hashes. The GPU throughput reported above is from my own measurement.