PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS
- When
- Where
- Global (internet)
- Category
- cyber_advisory · pip
> [!NOTE] > Practical impact depends on whether request body-size limits are enforced upstream (proxy/web-server/framework). Deployments with typical body-size caps (≤2 MB) bound the amplifier significantly; deployments accepting larger token inputs are more exposed. When verifying detached JWS tokens using the unencoded-payload option (`"b64": false`, RFC 7797), PyJWT performs **Base64URL decoding of the compact-serialization payload segment** *before* enforcing the detached-payload rules. For `b64=false`, PyJWT later **discards** that decoded payload and replaces it with the caller-provided `detached_payload`. In practice, this turns the middle segment into an attacker-controlled “work amplifier”: a remote client can supply an arbitrarily large Base64URL payload segment that forces **CPU work + memory allocations** even if the signature is invalid. This creates an **unauthenticated DoS** vector against any endpoint that verifies detached JWS using PyJWT. --- ## Affected Component(s) * `jwt/api_jws.py` * `PyJWS.decode()` / `PyJWS.decode_complete()` * `_load()` (parsing and Base64URL decoding) --- ## Root Cause (exact logic flaw) ### What happens in the code In `jwt/api_jws.py`, `decode_complete()` does the following (order matters): * Calls `_load(jwt)` first, which decodes the token segments * Only after that, checks `header.get("b64")` and if `False`, it replaces `payload = detached_payload` and rebuilds the signing input This behavior is visible in `decode_complete()`: * `_load(jwt)` happens **before** the `b64=false` handling * then `payload = detached_payload` and `signing_input = ... detached_payload` happens afterward ([GitHub][1]) Inside `_load()`, PyJWT unconditionally performs: * `payload = base64url_decode(payload_segment)` This is the expensive step the attacker can amplify ([GitHub][1]) ### Why this becomes a vulnerability For `b64=false` detached JWS, the payload segment in compact form is effectively **not needed** for verification in PyJWT’s own logic (since the library uses `detached_payload` as the real payload). Yet PyJWT still decodes it first, meaning: * cost is paid **even when signature is invalid** * the decoded bytes are **discarded** * attacker controls the size of this cost via token length --- ## Impact (evidence-driven) ### Security impact * **Unauthenticated remote DoS**: decoding work happens before signature rejection → attacker does not need signing key. * **CPU amplification**: Base64URL decode time scales linearly with payload segment size. * **Memory amplification**: decoded output allocates large byte buffers (tens of MB per request). * **Operational impact**: request queueing / worker starvation under modest concurrency bursts. ### Standards context (RFC 7797) RFC 7797 explicitly notes this option is used when payload is large and/or detached, and discusses interoperability requirements around marking it critical (“crit” with “b64”). ([IETF Datatracker][2]) (PyJWT supports `crit` validation, but the issue here is decode order / unbounded decode of an unused segment.) --- ## Affected Versions * **Confirmed affected:** PyJWT **2.12.1** (tested from your local editable install and repo). * **Likely affected:** all versions that include detached payload support for JWS decoding, which was introduced in **2.4.0** (“Add detached payload support for JWS encoding and decoding”). ([pyjwt.readthedocs.io][3]) (For GHSA, this phrasing is strong: “confirmed” + “likely since feature introduction”.) --- # Threat Model ### Typical real deployment A service verifies signed HTTP requests or webhooks using detached JWS: * token is provided in JSON body / query / header * actual payload is the HTTP request body passed as `detached_payload` ### Attacker * remote unauthenticated client * can send requests to verify endpoint * does **not** need a valid signature (invalid signature still triggers the expensive decode path) ### Attack chain 1. Attacker crafts a JWS compact token with header containing `"b64": false` and `crit:["b64"]`. 2. Attacker inflates the **payload segment** (middle segment) to millions of Base64URL characters. 3. Server calls `PyJWS.decode(...detached_payload=...)`. 4. PyJWT decodes the inflated segment (CPU + memory). 5. Signature is rejected afterward (401) — but resources already consumed. 6. Repeated requests or bursts cause queueing/worker starvation → DoS. --- # Proof of Concept - file names + results ## PoC placement * [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py) * [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py) * [flood_localhost.py](https://github.com/user-attachments/files/26132760/flood_localhost.py) --- ## PoC # 1 - Localhost verification server **File:** [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py) **Purpose:** real HTTP endpoint (`POST /verify`) that calls PyJWT detached verification and prints: `ok / time_ms / peak_bytes / token_len / error`. ### Results (server console output) ```text [+] Listening on http://127.0.0.1:8000 [+] POST /verify JSON: {"token": "..."} [127.0.0.1] ok=True time_ms=0.102 peak_bytes=2624 token_len=117 err=None [127.0.0.1] ok=False time_ms=2.012 peak_bytes=2000983 token_len=500078 err=InvalidSignatureError [127.0.0.1] ok=True time_ms=1.591 peak_bytes=2001061 token_len=500117 err=None [127.0.0.1] ok=True time_ms=0.065 peak_bytes=2304 token_len=117 err=None [127.0.0.1] ok=False time_ms=7.534 peak_bytes=8000983 token_len=2000078 err=InvalidSignatureError [127.0.0.1] ok=True time_ms=6.347 peak_bytes=8001061 token_len=2000117 err=None [127.0.0.1] ok=True time_ms=0.066 peak_bytes=2304 token_len=117 err=None [127.0.0.1] ok=False time_ms=23.034 peak_bytes=32000983 token_len=8000078 err=InvalidSignatureError [127.0.0.1] ok=True time_ms=22.097 peak_bytes=32001061 token_len=8000117 err=None ``` **Key takeaways from these results** * At **8,000,000 chars**, a single invalid-signature request still causes: * **~23 ms** server work * **~32 MB** peak allocations * returns **401** (invalid signature) → attacker does not need key. --- ## PoC # 2 - Localhost network client **File:** [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py) **Purpose:** generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server. ### Results (client output) #### payload-chars = 500,000 ```text === BASELINE (valid b64=false token) === HTTP: 200 client_wall_ms: 6.3499... server_time_ms: 0.10197... server_peak_bytes: 2624 === ATTACK (INVALID signature - attacker needs no key) === HTTP: 401 client_wall_ms: 4.1010... server_time_ms: 2.01217... server_peak_bytes: 2000983 error: InvalidSignatureError === ATTACK (VALID signature - accepted path still wastes) === HTTP: 200 client_wall_ms: 3.6586... server_time_ms: 1.59092... server_peak_bytes: 2001061 ``` #### payload-chars = 2,000,000 ```text === BASELINE === HTTP: 200 server_time_ms: 0.06527... server_peak_bytes: 2304 === ATTACK (INVALID signature) === HTTP: 401 server_time_ms: 7.53430... server_peak_bytes: 8000983 === ATTACK (VALID signature) === HTTP: 200 server_time_ms: 6.34682... server_peak_bytes: 8001061 ``` #### payload-chars = 8,000,000 ```text === BASELINE === HTTP: 200 server_time_ms: 0.06573... server_peak_bytes: 2304 === ATTACK (INVALID signature) === HTTP: 401 server_time_ms: 23.03403... server_peak_bytes: 32000983 === ATTACK (VALID signature) === HTTP: 200 server_time_ms: 22.09702... server_peak_bytes: 32001061 ``` **Why this is strong evidence** * The server clearly does heavy work **before** rejecting invalid signatures. * The “valid signature” case shows even accepted requests waste resources due to unused payload segment. --- ## PoC # 3 - Localhost flood / burst concurrency **File:** [f
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-15 19:29 UTC
Defaxon links out to the original reporting and never republishes article text.
Correlated events
Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.