diff --git a/src/network-services-pentesting/pentesting-web/flask.md b/src/network-services-pentesting/pentesting-web/flask.md index 421e9641578..04133df8fff 100644 --- a/src/network-services-pentesting/pentesting-web/flask.md +++ b/src/network-services-pentesting/pentesting-web/flask.md @@ -22,6 +22,30 @@ echo "ImhlbGxvIg" | base64 -d Decoding does not verify the signature and does not reveal the secret key. +#### Inspect tagged bytes and test a leaked signing key + +A decoded Flask session may contain a tagged bytes value under the key `" b"`. If an application stores a pickled OAuth flow there, Base64-decode that value and inspect it with `pickletools.dis()` **without calling `pickle.loads()` locally**. In one OAuth implementation, this exposed a client secret inside the flow object; the application had also reused that secret as its Flask signing key. This is an application mistake, not normal Flask behavior. In the example below, `session` is the parsed JSON payload of the decoded cookie.[[6]](#references) + +```python +import base64, pickletools +flow_key = next(k for k in session if k.startswith("google_oauth2_flow_")) +blob = base64.b64decode(session[flow_key][" b"]) +pickletools.dis(blob) +``` + +For the affected cookie format, split the cookie value from the right into `payload`, `timestamp`, and `signature`. Test a recovered candidate key (as `bytes`) against the **original encoded** payload and timestamp; a match proves it can sign modified session data. This example uses the application's HMAC-SHA1 `cookie-session` derivation. Other signing configurations may differ.[[6]](#references) + +```python +import base64, hmac +payload, timestamp, signature = cookie.rsplit(".", 2) +derived = hmac.digest(candidate_secret, b"cookie-session", "sha1") +expected = hmac.digest(derived, f"{payload}.{timestamp}".encode(), "sha1") +actual = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4)) +print(hmac.compare_digest(expected, actual)) +``` + +Once the key is known, the [Flask-Unsign](#flask-unsign) signing workflow below can forge session fields. If a callback later unpickles a flow stored in those fields, see [pickle deserialization from a forged Flask session](../../pentesting-web/deserialization/#pickle-deserialization-from-a-forged-flask-session). A shared key can also make forged sessions valid on sibling deployments with the same signing configuration.[[6]](#references) + ### Flask-Unsign `flask-unsign` can decode Flask session cookies and, during an authorized test, try candidate secret keys or sign a modified session when the key is known.[[3]](#references) @@ -102,5 +126,6 @@ With a path such as `@attacker.example`, the constructed URL becomes `https://go - [3] [flask-unsign](https://github.com/Paradoxis/Flask-Unsign) - [4] [RIPsession](https://github.com/Tagvi/ripsession) - [5] [Exploiting HTTP parser inconsistencies](https://rafa.hashnode.dev/exploiting-http-parsers-inconsistencies) +- [6] [Google DevSite Flask Session Pickle RCE](https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce) {{#include ../../banners/hacktricks-training.md}} diff --git a/src/pentesting-web/deserialization/README.md b/src/pentesting-web/deserialization/README.md index 72c2378210a..fb9a323c1f9 100644 --- a/src/pentesting-web/deserialization/README.md +++ b/src/pentesting-web/deserialization/README.md @@ -234,6 +234,39 @@ print(base64.b64encode(pickle.dumps(P()))) Before checking the bypass technique, try using `print(base64.b64encode(pickle.dumps(P(),2)))` to generate an object that is compatible with python2 if you're running python3. +#### Pickle deserialization from a forged Flask session + +Flask's default session format serializes tagged JSON, not pickle.[[60]](#references) + +The dangerous pattern is **application code** putting a pickled object into a bytes-valued session field and later calling `pickle.loads()` on that field. If the session signing key is disclosed, the attacker can replace the nested object even though the outer cookie signature is valid. A real OAuth callback stored a flow under `google_oauth2_flow_` and deserialized it after checking the signed session; the OAuth client secret was both exposed inside the original flow and reused as the signing key.[[59]](#references) Unlike [Django's direct PickleSerializer session](../../network-services-pentesting/pentesting-web/django.md#pickle-backed-signed-session-cookie-rce), the unsafe pickle here is a nested application value. + +After recovering and validating the signing key, choose the callback `state` value and put a pickle under the matching flow key.[[59]](#references) Pass the pickle as a Python `bytes` value to Flask's session serializer: it emits Base64 beneath the tagged JSON key `" b"` and restores bytes when loading the cookie. Do not pass a literal `{" b": ...}` dictionary to the serializer, because Flask escapes dictionaries whose sole key looks like a tag.[[60]](#references) The state token and flow lookup key must agree, but no pre-existing server-side flow is required in this pattern.[[59]](#references) See [Flask cookie decoding and signing](../../network-services-pentesting/pentesting-web/flask.md#inspect-tagged-bytes-and-test-a-leaked-signing-key) for the preceding step. + +For an authorized, non-destructive execution check, create a pickle whose `__reduce__()` calls `time.sleep()` rather than a command. Its delay is adjustable without changing the cookie or callback structure.[[59]](#references) + +```python +import pickle, time +class Sleep: + def __init__(self, seconds): + self.seconds = seconds + def __reduce__(self): + return (time.sleep, (self.seconds,)) +attacker_blob = pickle.dumps(Sleep(3)) +``` + +Place the bytes value under the flow key selected by the callback state.[[59]](#references) + +```python +state = "attacker-chosen" +session = { + "_permanent": True, + "google_oauth2_csrf_token": state, + "google_oauth2_flow_" + state: attacker_blob, +} +``` + +Sign the session with the recovered key and send it to the callback with the matching `state`. Compare latency with a zero-second payload and invalid-signature/no-flow controls. A delay tracking the chosen interval distinguishes execution during unpickling from normal endpoint latency; a later OAuth `invalid_grant` response does not rule out prior execution.[[59]](#references) + #### Length-field truncation to reach an internal pickle sink Do not assume that an authentication check in front of `pickle.loads()` or `cPickle.loads()` makes that sink unreachable. Trace attacker-controlled values through every HTTP-to-RPC adapter and compare the **physical bytes appended by the sender** with the **lengths trusted by the receiver**. In legacy Python 2.6, packing an oversized integer into an unsigned-byte `B` field can retain only the low byte: `256` becomes `0`, while `289` becomes `33`. If the sender nevertheless appends the complete field, the two endpoints disagree about field boundaries.[[57]](#references) @@ -1370,5 +1403,7 @@ Industrialized gadget discovery: - [56] [PHP manual – `unserialize`](https://www.php.net/manual/en/function.unserialize.php) - [57] [STAR Labs - Pickling the Mailbox: A Deep Dive into CVE-2025-20393](https://starlabs.sg/blog/2026/02-pickling-the-mailbox-a-deep-dive-into-cve-2025-20393) - [58] [Horizon3.ai - Another SolarWinds Web Help Desk Deserialization Issue](https://horizon3.ai/attack-research/cve-2025-40551-another-solarwinds-web-help-desk-deserialization-issue) +- [59] [Google DevSite Flask Session Pickle RCE](https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce) +- [60] [Flask TaggedJSONSerializer source](https://github.com/pallets/flask/blob/main/src/flask/json/tag.py) {{#include ../../banners/hacktricks-training.md}}