OSV 1.4.0 · github-reviewed · 修改于 2026-06-16 03:29
发布时间
2026-06-16 03:29
GitHub 审查时间
2026-06-16 03:29
NVD 发布时间
2026-05-29 00:16
源文件
advisories/github-reviewed/2026/06/GHSA-w7vc-732c-9m39/GHSA-w7vc-732c-9m39.json
[!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.
jwt/api_jws.py
PyJWS.decode() / PyJWS.decode_complete()_load() (parsing and Base64URL decoding)In jwt/api_jws.py, decode_complete() does the following (order matters):
_load(jwt) first, which decodes the token segmentsheader.get("b64") and if False, it replaces payload = detached_payload and rebuilds the signing inputThis behavior is visible in decode_complete():
_load(jwt) happens before the b64=false handlingpayload = 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])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:
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.)
(For GHSA, this phrasing is strong: “confirmed” + “likely since feature introduction”.)
A service verifies signed HTTP requests or webhooks using detached JWS:
detached_payload"b64": false and crit:["b64"].PyJWS.decode(...detached_payload=...).File: server_localhost.py
Purpose: real HTTP endpoint (POST /verify) that calls PyJWT detached verification and prints:
ok / time_ms / peak_bytes / token_len / error.
[+] 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:
File: client_localhost.py Purpose: generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server.
=== 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
=== 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
=== 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
File: flood_localhost.py Purpose: sends N concurrent invalid-signature requests over HTTP to demonstrate queueing/worker starvation.
total_wall_ms: 1374.5405770000616
(16, 401, 1156.4504789998864, 21.350951999920653, 32000983, 'InvalidSignatureError')
(19, 401, 1151.2852699997893, 21.208721999755653, 32000983, 'InvalidSignatureError')
(18, 401, 1102.7211239997996, 21.685218999664357, 32000983, 'InvalidSignatureError')
(13, 401, 1102.0718189997751, 21.26572200040755, 32000983, 'InvalidSignatureError')
(11, 401, 1095.9345460000804, 20.586017000368884, 32000983, 'InvalidSignatureError')
(17, 401, 1085.2552810001725, 22.893039000337012, 32000983, 'InvalidSignatureError')
(10, 401, 1078.3629560000918, 22.737160999895423, 32000983, 'InvalidSignatureError')
(7, 401, 1048.2011740000416, 22.476282000297942, 32000983, 'InvalidSignatureError')
(8, 401, 378.93017700025666, 21.377330999712285, 32000983, 'InvalidSignatureError')
(1, 401, 281.45106800002395, 21.34223099983501, 32000983, 'InvalidSignatureError')
Interpretation
Prevent unbounded resource consumption from an attacker-controlled payload segment that is unused in b64=false detached flow.
In _load() (or by refactoring parse order), do not Base64-decode payload_segment until after you know whether b64=false applies.
Two safe options:
Reject non-empty payload segment when b64=false
b64 is false and payload_segment is non-empty → raise DecodeError before decodingdetached_payload onlySkip decoding payload segment entirely when b64=false
This aligns with the idea that detached payload is the trusted payload input for verification; the compact payload segment should not become a resource amplification vector.
(Implementation context: the current decode order and unconditional base64url_decode(payload_segment) are visible in the file and line region around _load() and decode_complete() ([GitHub][1]).)
b64=false) is not needed in your app, reject tokens where header includes "b64": false.