hunt-session について
セッション固定、無効化の不備、トークンローテーション、エントロピーなど、セッション管理の弱点を評価するセキュリティテスト用スキルです。2つのセッションを使う段階的な手順と誤検知対策を示します。
主な機能
- 01セッション固定とログイン後の再生成確認
- 02ログアウト・認証情報変更後の無効化テスト
- 03Cookie属性とセッションIDの分析
- 04JWT失効とリフレッシュトークンのローテーション確認
- 052セッション検証と誤検知対策
活用シーン
- 01ログアウトやパスワード変更後もセッションが有効かテスト
- 02ログイン時に攻撃者指定のセッションIDが再生成されるか確認
- 03セッショントークンの予測可能性とCookie保護を評価
- 04リフレッシュトークン再利用検知とJWT失効を検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-sessionインストール後、Claude Code で/hunt-sessionを入力して読み込まれたか確認します。
詳細
HUNT-SESSION — セッション管理
重要資産を狙うターゲット
セッション固定による管理者の乗っ取り=重大です。パスワード変更後もセッションが有効なままの場合=高〜重大です(盗まれたCookieによる永続的なアカウント乗っ取り。被害者はパスワードをリセットして無効化したと思っています)。
価値の高い攻撃チェーン:
- セッション固定 — サーバーがクライアントによって設定されたセッションIDを受け入れ、ログイン時に再生成しない → 攻撃者があらかじめIDを仕込み、被害者が認証すると、攻撃者は認証済みセッションを乗っ取り、永続的なアカウント乗っ取りにつながります。
- ログアウト時に無効化されない —
/logoutの後も古いトークンが使える → 盗難可能な期間が終わりません。 - パスワード/メールアドレス変更時に無効化されない — 盗まれたセッションが、被害者の「ハッキングされたかもしれないので、パスワードをリセットしよう」という行動の後も有効なままです → 永続的なアカウント乗っ取りにつながります。これはセッション関連のバグの中で、最も高額な報奨金につながるクラスです。
- ローテーション検出を伴わないリフレッシュトークンの再利用 — 漏えいしたリフレッシュトークンから、アクセス トークンをいつまでも新規発行できます。再利用検出がなければ、正規ユーザーが後からリフレッシュしても、攻撃者側のトークン系列は失効しません。
- 推測可能/エントロピーの低いセッションID — 連番、タイムスタンプ由来、または userId 由来のID → ブルートフォース、または他のユーザーのセッションを計算できます。
expも失効リストもないセッション用JWT — 盗まれたJWTは永続的なアクセスを許します。ログアウトは形だけです。
背景 — 各フェーズの基盤となるパターン
以下に、架空のCVE/報告IDはありません。このスキルに反映されているのは、名称があり、公開文書で説明されているパターンです。
- セッション固定、ログインCSRF、認証時にセッションを再生成しない問題 — OWASP WSTG-SESS-03/WSTG-SESS-01、およびセッション固定に関するACROS/Mitja Kolšekの古典的な論文。最も影響が大きいパターンは、SSO/管理者ユーザーのセッションを固定することです。
- SameSite=Lax の兄弟サブドメインCSRFがセッション状態に到達する問題 — Argo CD CVE-2024-22424(兄弟サブドメインからのトップレベルのクロスサイト遷移でLax Cookieが送信されます)。セッションCookieが唯一のCSRF防御として
SameSite=Laxに依存している場合に適用します。 - リフレッシュトークンのローテーションと自動再利用検出 — Auth0/IETF OAuth-Security-BCPのモデルでは、ローテーションされたリフレッシュトークンが再利用された場合、トークンファミリー全体を無効化する必要があります。これがないことこそが、立証すべき中核的なバグです。
- Device Bound Session Credentials(DBSC) — W3C/ChromeのDBSCドラフトでは、セッションをTPM/デバイスキーに紐付けます。ダウングレードをテストしてください。DBSCチャレンジを取り除いても、サーバーは紐付けのないCookieを受け入れますか?
- Cookie属性の強化 — OWASP WSTG-SESS-02、RFC 6265bisに基づく
__Host-/__Secure-プレフィックス。HttpOnlyの欠如が指摘事項となるのは、実際のXSS/DOMシンクがある場合に限ります(hunt-xss/hunt-domと組み合わせてください)。 - エントロピー — NIST SP 800-63Bでは、セッション識別子に64ビット以上のエントロピーを求めています。長さにかかわらず、デコードするとカウンター/タイムスタンプ/userIdになるものはすべて指摘事項として扱ってください。
関連項目:アカウント乗っ取りへの連鎖 → hunt-ato、JWTのalg/kid改ざん → hunt-api-misconfig、OAuthのcode/stateの不備 → hunt-oauth、CSRFの仕組み → hunt-csrf、Cookie窃取につながるシンク → hunt-xss/hunt-dom。
攻撃対象領域のシグナル
Set-Cookie: session=... # name varies: sid, JSESSIONID, connect.sid,
# PHPSESSID, ASP.NET_SessionId, laravel_session, _csrf
/login /logout /api/login /oauth/token
/auth/refresh /api/token/refresh # refresh-token rotation surface
/account/change-password /settings/email
?sid= ?session= in URL # session-in-URL → leaks via Referer/logs (finding)
# Header signals worth flagging immediately:
Set-Cookie: session=abc; Path=/ # no HttpOnly/Secure/SameSite
Set-Cookie: session=abc; SameSite=None # None without Secure = rejected by modern browsers, but flag
Set-Cookie: __Host-sess=...; Secure; Path=/ # GOOD — hard to fixate
Sec-Session-Registration: ... # DBSC in play → test downgrade
ステップごとのハンティング手法
2セッションルール。 無効化/固定に関する主張はすべて、実際のフローで取得した2つの具体的なセッション、つまり攻撃者 A と被害者 B を使って立証してください。ハードコードされたプレースホルダー文字列は使いません。以下のヘルパーは、
curlのNetscape jarから実際のCookieを取得します。
TARGET=target.com
JAR_A=$(mktemp); JAR_B=$(mktemp)
# Robust session-cookie extractor: handles #HttpOnly_ prefix lines and any
# cookie name (sid/JSESSIONID/connect.sid/PHPSESSID/...). Prints name=value.
get_cookie () { # $1=jar $2=name-regex (default: common session names)
local jar="$1" re="${2:-session|sid|sess|JSESSIONID|connect\.sid|PHPSESSID|laravel_session}"
awk -v re="$re" '
/^#HttpOnly_/ { sub(/^#HttpOnly_/,""); } # strip jar HttpOnly marker
/^#/ { next } # skip remaining comments
NF>=7 && $6 ~ re { print $6"="$7 } # field6=name field7=value
' "$jar" | tail -1
}
フェーズ1 — セッション固定(ログイン時の再生成)
# Step 1: grab a pre-auth session the SERVER hands an anonymous client.
curl --max-time 30 --connect-timeout 10 -s -L -c "$JAR_A" "https://$TARGET/login" -o /dev/null
PRE=$(get_cookie "$JAR_A"); echo "pre-auth: $PRE"
# Step 1b (stronger): can we FORCE an arbitrary ID? attacker-chosen value.
FIX="session=AAAAdeadbeefAAAA"
# Step 2: authenticate while CARRYING the pre-auth/forced cookie (reuse same jar).
curl --max-time 30 --connect-timeout 10 -s -L -c "$JAR_A" -b "$JAR_A" -X POST "https://$TARGET/login" \
-d "[email protected]&password=CorrectHorse1" -o /dev/null
POST=$(get_cookie "$JAR_A"); echo "post-auth: $POST"
# DECISION:
# - If $POST == $PRE (value unchanged across the auth boundary) AND that value
# now returns authenticated data → FIXATION. The server reused the anon ID.
# - If the forced $FIX value is accepted and authenticates → CRITICAL fixation
# (attacker controls the ID; no email/XSS needed to plant it).
AUTH=$(curl --max-time 30 --connect-timeout 10 -s -L -b "$JAR_A" "https://$TARGET/api/me")
echo "$AUTH" | head -c 200
誤検知対策: 値が変化しても、自動的に安全とは限りません。読み取り可能なCookieをローテーションしながら、2つ目のCookieをキーとしてサーバー側の安定したセッションを維持するアプリもあります。Set-Cookie の一式をすべて比較し、古い値が本当に無効になっていることを確認してください(フェーズ2)。また、/api/me が汎用的な200レスポンスやランディングページではなく、自分自身のIDを返すことも確認してください。
フェーズ 2 — ログアウト時の無効化
# A logs in for real (fresh jar), capture A's live session.
curl --max-time 30 --connect-timeout 10 -s -L -c "$JAR_A" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"CorrectHorse1"}' -o /dev/null
A=$(get_cookie "$JAR_A"); echo "A=$A"
# Baseline: what does an authenticated /api/me look like for A? (capture body, not just code)
BEFORE=$(curl --max-time 30 --connect-timeout 10 -s -L -b "$JAR_A" "https://$TARGET/api/me")
# Logout A.
curl --max-time 30 --connect-timeout 10 -s -L -b "$JAR_A" -X POST "https://$TARGET/api/logout" -o /dev/null
# Replay A's OLD cookie value explicitly (do NOT reuse the jar — logout may have
# overwritten it). Compare body + code against the authenticated baseline.
AFTER=$(curl --max-time 30 --connect-timeout 10 -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]')
echo "AFTER: $AFTER"
FP 対策(必須):
- ステータスコードを信用しないでください。キャッシュ/エッジの 200 や汎用的な SPA シェルは、誰に対しても 200 を返します。
AFTERとBEFOREの本文を比較してください。AFTERに A の固有の識別マーカー(メールアドレス、ユーザー ID、CSRF トークン、アカウント名)が残っている場合にのみ、指摘は実在します。 - ネガティブコントロールで確認してください。ランダムな値やでたらめな値の Cookie では、同じ認証済み本文が返ってはなりません。でたらめな Cookie でもユーザーデータを含む 200 が返る場合、そのエンドポイントはセッションで制御されていないため、ここでの指摘には該当しません。
- 短時間待ってから、さらに別の IP から再テストしてください。サーバーによっては次のアクセス時に遅延して期限切れにしたり、セッションを IP に固定したりすることがあります。
フェーズ 3 — パスワード/メールアドレス変更時の無効化(永続的な ATO の中核)
# This is the real two-session flow. A = attacker holding a stolen/old session.
# B = the victim who changes their password believing it revokes access.
# (In a real engagement A is a session you legitimately captured for a TEST account
# that you also control as B — never use a real third party.)
# 1) Log the TEST account in as session A, capture it.
curl --max-time 30 --connect-timeout 10 -s -L -c "$JAR_A" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"OldPass!1"}' -o /dev/null
SESSION_A=$(get_cookie "$JAR_A"); echo "SESSION_A=$SESSION_A"
BEFORE=$(curl --max-time 30 --connect-timeout 10 -s -L -H "Cookie: $SESSION_A" "https://$TARGET/api/profile")
# 2) Log the SAME account in as session B (separate jar = "the victim's browser").
curl --max-time 30 --connect-timeout 10 -s -L -c "$JAR_B" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"OldPass!1"}' -o /dev/null
# 3) Victim (session B) changes the password.
curl --max-time 30 --connect-timeout 10 -s -L -b "$JAR_B" -X POST "https://$TARGET/api/change-password" \
-H 'Content-Type: application/json' \
-d '{"old_password":"OldPass!1","new_password":"BrandNew!2"}' -o /dev/null
# 4) THE TEST: replay the OLD SESSION_A captured in step 1.
AFTER=$(curl --max-time 30 --connect-timeout 10 -s -L -H "Cookie: $SESSION_A" "https://$TARGET/api/profile" -w '\n[%{http_code}]')
echo "AFTER pw-change: $AFTER"
判定と FP 対策:
AFTERが 200 を返し、かつ本文にアカウント固有のデータが残っている場合(BEFOREと本文を比較)のみ、指摘が確認されたことになります。公開/SPA ルートで単に 200 が返るだけでは証拠になりません。- エンドポイントがセッションで制御されていることを証明するため、でたらめな Cookie によるネガティブコントロールをもう一度実施してください。
- メールアドレス変更(
/settings/email)と全デバイスからのログアウトについても、同じ手順を繰り返してください。アプリは、操作中のセッション(B)は無効化しても、兄弟セッション(A)は無効化しないことがよくあります。この兄弟セッションが残る状態こそ、hunt-atoが連鎖させる永続的な ATO の原型です。 - 重大度の判定基準: パスワード変更エンドポイントに現在のパスワード/MFA による追加認証もない場合(
hunt-mfa-bypassに従う)、A は読み取り専用から完全なアカウント乗っ取りへ移行できます。重大度を引き上げてください。
フェーズ 4 — Cookie 属性の分析
curl --max-time 30 --connect-timeout 10 -sI -L "https://$TARGET/" | grep -i '^set-cookie'
- HttpOnly がない →
document.cookieから Cookie にアクセスできます。実際の XSS/DOM シンク(hunt-xss/hunt-dom)と連鎖する場合にのみ指摘してください。記録はしますが、単独で重大度 High として報告しないでください。 - Secure がない → Cookie が暗号化されていない HTTP で送信されます。ネットワーク攻撃者につながる攻撃経路として、
hunt-tls-network(ダウングレード/HSTS の欠落)と組み合わせてください。 - SameSite がない/
None→ CSRF の到達可能性があります。SameSite=Laxは、兄弟サブドメインからのトップレベルナビゲーションで回避可能です(Argo CD のCVE-2024-22424と同種)。hunt-csrfに引き継いでください。 __Host-/__Secure-プレフィックスがない → サブドメインやセキュアでないコンテキストからセッションを上書き/固定化できます。これらのプレフィックスがあれば Cookie 固定化はほぼ防げるため、フェーズ 1 の前提条件として、プレフィックスがないことを指摘してください。
フェーズ 5 — セッション ID のエントロピー
# Collect a LARGE sample (200+) of freshly-issued IDs. -L is required: a 302
# /login often sets the cookie on the redirect target, not the first response.
N=200; SAMP=$(mktemp)
for i in $(seq 1 $N); do
J=$(mktemp)
curl --max-time 30 --connect-timeout 10 -s -L -c "$J" "https://$TARGET/login" -o /dev/null
get_cookie "$J" | cut -d= -f2- >> "$SAMP"
rm -f "$J"
done
sort "$SAMP" | uniq -d | head # duplicates = catastrophic (re-use)
awk '{print length($0)}' "$SAMP" | sort -n | uniq -c # length distribution
目視で判断せず、以下を分析してください。
- 連番/単調増加 — デコードした値を
sort -nで並べてください。差分が一定の +1/+N であれば、予測可能です。 - デコード可能な構造 — 各 ID を
base64 -d/16 進デコードし、埋め込まれたuserId、UNIX タイムスタンプ、PID を探してください。 - ビットエントロピー — 生のバイト列を
entまたはdieharderに入力してください。NIST SP 800-63B が求める値は 64 ビット以上です。10 個のサンプルでは何かを主張するには少なすぎるため、数百個を集めてください。 - FP 防止策: 長くてランダムに見えるトークンでも、強度の証明にはなりません。証明になるのは、構造のデコードと大規模サンプルによるエントロピー推定のみです。逆に、短いトークンでも文字ごとのエントロピーが高ければ問題ない場合があります。文字数を数えるのではなく、測定してください。
フェーズ 6 — セッションとしての JWT
JWT="eyJ..." # captured from Authorization: Bearer or a cookie
# Decode header + payload safely (base64url padding fix).
b64url(){ local s="${1//-/+}"; s="${s//_//}"; printf '%s' "$s===" | base64 -d 2>/dev/null; }
b64url "$(cut -d. -f1 <<<"$JWT")" | jq . # header: alg, kid
b64url "$(cut -d. -f2 <<<"$JWT")" | jq . # claims: exp, iat, sub, jti
expがない、または数年先に設定されている → 有効期限がありません。jtiがない → サーバーが失効リストを維持できないため、ログアウトしても実際には失効させられません。- 失効テスト: ログアウトしてから、同じ JWT を
/api/meに再送してください。ユーザー情報が返る場合、トークンはサーバー側で失効させられません。これが JWT セッションの永続化に関する指摘です。キャッシュされた 200 を避けるため、本文を比較してください。 - 改ざん(alg/kid/鍵の混同)は
hunt-api-misconfigの担当です。 重複して調査せず、jwt_tool $JWT -T/-X aをそちらに引き継いでください。
フェーズ 7 — リフレッシュトークンのローテーションと再利用検出
# 1) Obtain a refresh token (login or /oauth/token), then rotate it once.
RT1=$(curl --max-time 30 --connect-timeout 10 -s -L -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"OldPass!1"}' | jq -r '.refresh_token')
# 2) Use RT1 to mint a new access token — server SHOULD return a rotated RT2.
R2=$(curl --max-time 30 --connect-timeout 10 -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT1\"}")
RT2=$(jq -r '.refresh_token' <<<"$R2"); echo "rotated? RT1!=RT2 -> $([ "$RT1" != "$RT2" ] && echo yes || echo NO-ROTATION)"
# 3) REUSE-DETECTION test: replay the OLD RT1 again (simulating the leaked token).
REPLAY=$(curl --max-time 30 --connect-timeout 10 -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT1\"}" -w '\n[%{http_code}]')
echo "RT1 replay: $REPLAY"
# 4) Then confirm RT2 was KILLED by the replay (correct BCP behaviour invalidates
# the whole family). If RT2 still works after RT1 was replayed → no family-revocation.
curl --max-time 30 --connect-timeout 10 -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT2\"}" -w '\n[%{http_code}]'
指摘事項: ローテーションなし(RT1==RT2)=長期間有効で窃取可能な認証情報です。再利用検出なしでのローテーション(RT1 を再送してもトークンが発行される、または再送後も RT2 が有効)=OAuth Security BCP に基づく、漏えいトークンの永続化バグです。OOB に関する注意: SSRF/ログ/JS バンドル経由で RT が漏えいした疑いがある場合、推測で判断せず、hunt-ssrf/hunt-source-leak でトークンの到達可能性を確認してください。
フェーズ 8 — OAuth/SSO セッションの紐付けと DBSC のダウングレード
# SSO linkage: after IdP callback, is the app session bound to the IdP session?
# - Log out at the IdP only; replay the app session cookie. Still 200 with user
# data → app session outlives the IdP session (single-logout gap).
# DBSC downgrade: if responses carry Sec-Session-Registration / Sec-Session-Id,
# strip the device-bound proof header and replay the plain cookie:
curl --max-time 30 --connect-timeout 10 -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]'
# If the plain (non-bound) cookie is still accepted → device-binding is advisory,
# not enforced → a stolen cookie defeats DBSC entirely.
OAuth の state/redirect_uri/コードインジェクションは hunt-oauth に任せ、このフェーズではセッション層のバインドのみを扱います。
チェーン表
| セッションに関する所見 | チェーン先 | 影響 |
|---|---|---|
セッション固定(__Host- のない Cookie を強制) | 管理者/SSO ユーザーを誘導し、仕込まれた ID で認証させる | 管理者セッションの乗っ取り(Critical) |
| ログアウト/パスワード変更時に無効化されない | hunt-xss/hunt-dom による Cookie 窃取 → 有効なセッションを再利用 | 被害者がリセットした後も ATO が継続 |
| リフレッシュトークンの再利用検知がない | RT の漏えい(SSRF/ログ/バンドル) → アクセストークンを無限に発行 | ATO が継続し、パスワード変更後も有効 |
SameSite=Lax のみ | 同一サイトのサブドメインからのトップレベルナビゲーション(CVE-2024-22424 クラス) → CSRF | 状態変更 / ログイン CSRF → 固定 |
JWT に exp/jti がない | トークンが盗まれると、サーバー側で失効できない | 永続的なアクセス |
| DBSC のダウングレードを受け入れる | デバイスバインディングがあってもプレーンな Cookie を窃取可能 | 唯一の窃取対策を無効化 |
| 予測可能な ID | 別ユーザーのセッションを計算/総当たりで特定 | 他ユーザーへの ATO |
検証(誤検知を抑えるための原則)
セッションに関する所見を主張する前に、必ず以下を確認してください。
- プレースホルダーではなく、実際の 2 つのセッションを使う — 固定化/無効化に関する主張にはすべて、上記の
curlフローで取得した A と B を使用します。 - ステータスだけでなく、本文の差分を確認する — 200 でも、アカウント固有の識別マーカーが本文に含まれ、認証済みのベースラインとの差分が確認できなければ意味がありません。
- 否定対照 — ゴミデータ/ランダムな Cookie では失敗し、「有効なまま」の Cookie では成功することを確認します。そうでなければ、そのエンドポイントはセッションで保護されておらず、所見には該当しません。
- キャッシュ/エッジを確認する — キャッシュバスターを付けて再リクエストし、2 つ目の IP からも試します。エッジでキャッシュされた 200 や、IP に紐付いた 200 の可能性を除外します。
- 窃取チェーンでは OOB を確認する — 影響が Cookie/トークンの持ち出し(XSS、SSRF、ログ漏えい)に依存する場合、断定するのではなく、Collaborator で帯域外の受信を確認します。
- 静的な設定と状態を区別する —
HttpOnly/Secure/SameSiteがないことはポリシー上の所見です。実際の悪用手段(XSS、ネットワーク上の MITM、CSRF)と組み合わさった場合にのみ High として報告してください。属性の欠落のみであれば Low/Informational です。
深刻度:
- セッション固定による管理者/SSO の乗っ取り:Critical
- パスワード/メールアドレス変更時に無効化されない、または検知なしでリフレッシュトークンを再利用できる:High → Critical(MFA/ステップアップ認証もない場合は深刻度を引き上げます)
- 予測可能/重複するセッション ID:High
- ログアウト時に無効化されない:Medium → High(窃取手段によります)
HttpOnly/SameSiteの欠落のみ:チェーンされるまでは Low/Informational
検証
このセルフテストを実行し、セッションハンティングの準備ができていることを確認します。
-
スキルの整合性 — スキルファイルが読み取り可能で、形式が正しいことを確認します。
grep -q "name: hunt-session" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
カテゴリの確認 — スキルにカテゴリがあることを確認します。
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
落とし穴のセクション — 落とし穴が文書化されていることを確認します。
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
3 つのテストすべてで、スキルが適切に構成され、使用できる状態であることを確認します。
落とし穴
- ログイン前のセッションがない状態でのセッション固定 — ログイン後にアプリが新しいセッション ID を発行する場合、セッション固定はできません。ログイン前後のセッション ID の挙動をテストしてください。
- URL 内のセッション ID —
?sessionid=xxxは Referer ヘッダー経由で漏えいします。サードパーティードメインへの Referer 漏えいを実証してください。 - XSS がない状態での HttpOnly 欠落 — HttpOnly の欠落は多層防御上の問題であり、XSS が実証されない限り脆弱性ではありません。
- セッションタイムアウトが長すぎる — セッションタイムアウトの長さはポリシー上の問題であり、(物理アクセスや共有デバイスのシナリオと組み合わされない限り)バグではありません。