ログイン認証を自作して claude で脆弱性を診断してみた

ログイン認証を自作して claude で脆弱性を診断してみた

ログインの仕組みを自作し、Claude Code に脆弱性を診断させてもらった結果、複数の弱点が数分で見つかり、実際に認証を突破されました。この記事では、作ったシステム・診断で挙がった脆弱性・実際の攻撃方法を順に紹介します。
2026.10.21

はじめに

業務で Amazon Cognito を使っていて、認証の仕組みが気になったので自作して検証してみました。

自作した認証を Claude Code に読み込ませて脆弱性を診断してもらい、実際に破れるかどうかを試しました。
一通りの対策を入れたつもりでしたが、結果としては複数の脆弱性が数分で見つかりました。また、署名鍵が漏れたと仮定したところログインを突破できました。

この記事では、作ったもの・診断で挙がった脆弱性・実際に破った過程を順に書いていきます。

自作してみる

構成

ユーザー名とパスワード(数字4桁)を検証するだけの、最小の認証システムを作りました。構成は「API Gateway + Lambda + DynamoDB」で、ログインページから認証システムを呼び出します。トークンは署名付き JWT(HS256)で発行しています。

パスワードを数字4桁にしたのは、パスワードの強度を検証の対象にしていなかったためです。今回検証したかったのは、ログインからトークンの発行・検証までを含むシステム全体の守りを確かめることです。

用意したエンドポイントは次の2つです。

メソッド・パス 役割
POST /login-lib ユーザー名とパスワードを検証し、成功すれば署名付き JWT を発行する
GET /me Authorization ヘッダの JWT を検証し、sub のユーザーが実在すれば本人として扱い sub を返す

入れた対策

認証には次の対策を入れています。

対策 実装
パスワードを平文で保存しない scrypt(ソルト付き)でハッシュ化して保存
パスワード比較の時間差をなくす timingSafeEqual による定数時間比較
ユーザーの存在を応答時間から推測させない ユーザー不在時もダミーハッシュで同程度の計算時間をかける
入力の素性チェック username は英小文字・数字・_ の3〜32文字、password は数字4桁ちょうど
総当たりの抑止(アプリ側) 連続失敗5回で該当ユーザーを300秒ロック
総当たりの抑止(APIゲートウェイ側) HTTP API ステージに毎秒10・バースト20のスロットリング
ログに秘密を残さない パスワードは一切出力せず、username は先頭1文字以外をマスク
失敗理由を外に出さない 認証失敗はすべて同一の 401・同一メッセージ
ネットワークの分離 Lambda を NAT なしの隔離サブネットに置き、DynamoDB へは Gateway 型 VPC エンドポイント経由
権限の最小化 ログイン用 Lambda には GetItem と UpdateItem、/me 用 Lambda には GetItem だけを付与

ログイン処理は以下です。

lambda/diy-login-lib/index.ts(抜粋)
const user = await getUser(username);

// ユーザー不在: ダミーハッシュで時間を合わせてから、存在時の失敗と同じ応答を返す
if (!user) {
  verifyPassword(password, dummy.salt, dummy.hash);
  return authFailed(); // 401・汎用メッセージ
}

if (isLocked(user, now)) return locked(); // 429

const ok = verifyPassword(password, user.salt, user.passwordHash);
if (!ok) {
  await registerFailure(username, now); // 失敗カウンタ+1、しきい値に達したらロック
  return authFailed();
}

await resetFailures(username);
// 署名付き JWT(HS256)を発行して返す
const token = await new SignJWT({})
  .setProtectedHeader({ alg: 'HS256' })
  .setSubject(username)
  .setIssuedAt()
  .setExpirationTime('3600s')
  .sign(jwtSecret);
return json(200, { token, tokenType: 'jwt', expiresIn: 3600 });

パスワードの検証は、ハッシュを再計算して定数時間で比較します。

lambda/diy-login-lib/crypto.ts(抜粋)
export function verifyPassword(password, saltHex, expectedHashHex) {
  const { hash } = hashPassword(password, saltHex);
  const a = Buffer.from(hash, 'hex');
  const b = Buffer.from(expectedHashHex, 'hex');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b); // 定数時間比較
}

普通に使う分には問題ない

テストユーザー alice / 1234 を登録してデモ画面から試すと、正しい組はログインが通ります。

間違った組や不正な入力は弾かれます。ステータスと本文を見ても、ユーザー名の有無は区別できません(valid の token は省略しています)。

valid          { status: 200, ms: 1490, json: { tokenType: 'jwt', expiresIn: 3600 } }
wrong_password { status: 401, ms: 448, json: { message: '認証に失敗しました' } }
no_user        { status: 401, ms: 622, json: { message: '認証に失敗しました' } }
bad_format     { status: 400, ms: 45, json: { message: 'リクエストの形式が不正です' } }

誤パスワードと存在しないユーザーは、どちらも 401 で本文も同じです。応答時間も448ms / 622ms で、どちらも数百ミリ秒でした。

Claude に脆弱性を診断させてみる

ソースコードを Claude Code に読み込ませて脆弱性を診断してもらうと、次の脆弱性が挙がりました。

  • 試行回数に上限が無い
    ロックは300秒で解除され失敗カウンタも0に戻るので、username ごとに5回/5分のペースで試し続けられる。送信元ごとの制限も無い
  • ハッシュの流出だけで総当たりできる
    照合に要るのは DynamoDB の salt と passwordHash だけなので、これが漏れるとロックもスロットリングも通らずに手元で試せる
  • 弱いパスワードを弾くガードが無い
    形式(数字4桁)を確かめるだけで、1234 / 0000 のような推測しやすい値も登録できる。攻撃者が推測しやすい値だけに絞って試すと、ロックに触れない範囲(5回)でも一定の割合で当たってしまう可能性がある
  • ロック設定がアトミックでない
    ロックの確認(ユーザーの読み取り)と、ロックの設定(失敗を記録した後の書き込み)が別の処理になっている。そのため、同時に送った要求はどれもロックが書き込まれる前に確認を通り、5回を超えてパスワードの照合まで進む
  • ロック状態でのユーザー列挙
    ロックの有無(429/401)で、ユーザーの実在が漏れる
  • トークン偽造
    JWT が HS256(共通鍵方式)+署名鍵の直書きで、鍵が分かれば任意ユーザーのトークンをログインなしで自作できる
  • 可用性
    ロックを悪用して、正規ユーザーを締め出せる。たとえば攻撃者が alice でわざと5回失敗すると、alice 本人も300秒ログインできなくなる。また、スロットリングの上限(毎秒10)はステージ全体で共有なので、攻撃者が上限まで送り続けると、ほかの利用者のリクエストも弾かれる

数値4桁が弱いという指摘も上がりましたが、今回の検証の主対象ではありません。

認証を破らせてみた(1)ブルートフォース

ブルートフォース(総当たり)は、ログインAPIに送る方法と、流出したハッシュを手元で照合する方法の両方を試しました。

APIを呼び出す

alice(正解は 1234)に 0000 から順に投げると、ロックで打ち切られます。

{ mode: 'online', path: '/login-lib', username: 'alice',
  attempts: 15, elapsedMs: 3207,
  statusCounts: { '401': 5, '429': 10 },
  found: null }

5回連続で失敗した後、6回目から 429(ロック)に変わり、正解に到達する前に止まりました。
しかし、ロックは300秒で解除され失敗カウンタも0に戻るので、1件ずつ順に送った場合でも、5回/5分のペースで試行を続けられます。試行回数の合計に上限は無いので、4桁なら全10000通りに約7日で総当たり可能です。
パスワード強度を上げれば総当たりに必要な日数は延びますが、上限なく試せることは変わりません。

なお、オンラインの総当たりは実在する username を狙う必要がありますが、実在するかどうかは総当たりの最初の6回で分かります。実在すれば6回目から 429 に変わり、存在しなければ 401 のままだからです。

同時にリクエストを送った場合

上の結果は1件ずつ順に送ったものです。ロックの確認と設定は別の処理になっているので、同時に送ると5回の上限を超えて照合されます。alice に、形式は正しいが誤ったパスワード 9999 を10件同時に送った結果です。

status分布: { '401': 10 }

10件全てが401(パスワードの照合まで進んで失敗した)で、ロックの 429 は1件もありませんでした。同時送信したリクエストが、どれもロックが書き込まれる前に確認され、5回の上限を超えて処理されています。

この脆弱性は「試行回数に上限が無い」と組み合わさると、総当たりをさらに速めてしまいます。

ハッシュが漏れた場合

DynamoDB のテーブルやバックアップが流出し、salt と passwordHash が攻撃者の手に渡った状況を想定します。

この場合、ロックもスロットリングも無関係に、手元で総当たりできます。ログイン処理は @noble/hashes の scrypt でハッシュを作っていますが、保存形式は同じ scrypt(N=16384, r=8, p=1, 32 バイト)なので、Node.js 標準の scryptSync でも同じハッシュを計算できます。

scripts/bruteforce.ts(抜粋)
for (let i = 0; i < 10000; i++) {
  const pin = String(i).padStart(4, '0');
  const h = scryptSync(pin, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
  if (h === passwordHash) { found = pin; break; }
}

流出想定のハッシュに対して、手元(開発マシン1台、シングルスレッド)で実行した結果です。

{ mode: 'offline', found: '1234', elapsedMs: 23214 }

scrypt を使っていても、1234 に到達するまで約23秒でした(1回あたり約18.8ミリ秒、全10000通りでも約3分)。パスワードを6桁(100万通り)にしたとしても、同じ1回あたりの速度で約5.2時間です。

問題は、照合に必要なものが DynamoDB の salt と passwordHash だけで、テーブルの外に置いた秘密値が何も無い構成にあります。

構成として足りていなかったガード

総当たりに対しては、パスワードを強くすることとは別に、次のようなガードが構成に入っていませんでした。

  • 弱いパスワードを弾くガード
    登録のときに、1234・0000 のような推測しやすい値や短い値を拒否する。今回は形式(数字4桁)を確かめるだけで、推測しやすい値もそのまま登録できる
  • オンラインの試行を続けさせない仕組み
    • 送信元 IP ごとのレート制限(AWS WAF のレートベースルールなど)。AWS WAF は API Gateway の REST API には付けられるが HTTP API には付けられないので、今回の構成では CloudFront を前に置くか、REST API にする必要がある
    • 失敗が続くほどロック時間を延ばし、5回/5分のペースで試し続けられないようにする
    • 照合の前に、条件付き更新で試行回数を加算し(上限に達していれば更新が失敗する)、加算できた要求だけを照合する。同時に送られても、5回を超えて照合されない
    • 失敗の連続を検知して通知する
    • MFA(パスワードが当たっても、それだけではログインできないようにする)
  • ハッシュだけでは照合できないようにする仕組み
    ハッシュを作るときに、テーブルとは別の場所(Secrets Manager・KMS など)に置いた秘密の値を混ぜる。テーブルやバックアップが漏れても、その値が無ければ手元で総当たりできない。あわせて、テーブルとバックアップを読める権限を絞る

認証を破らせてみた(2)ロックアウト + 鍵漏洩でログインをスキップ

診断の一覧のうち「ロック状態でのユーザー列挙」と「トークン偽造」を組み合わせると、次の3つの手順で、ログインを経由せずに本人として扱われました。

  1. ロックの有無から、実在する username を特定する
  2. 署名鍵を使って、その username のトークンを手元で作る
  3. 作ったトークンを /me に提示して、本人として通す

手順1: ロックの有無から、実在する username を特定する。
応答時間はダミーハッシュで平準化しましたが、ロックの有無までは隠せていませんでした。存在しない mallory に誤パスワードを7回送ってもすべて 401 のままですが、実在する alice は5回失敗した後、6回目から 429(ロック)に変わります。この差から有効な username を特定できます。

mallory(存在しない): 401 401 401 401 401 401 401
alice  (実在する)  : 401 401 401 401 401 429 429

手順2: 署名鍵を使って、トークンを手元で作る。
JWT は HS256(共通鍵方式)で署名しています。共通鍵方式は「検証できる=同じ鍵で署名も作れる」ので、鍵さえ分かれば、ログインなしで任意の username の有効なトークンを手元で作れてしまいます。

今回の実装では、鍵の文字列(demo-insecure-secret-change-me)を CDK スタックに直書きし、環境変数 JWT_SECRET で Lambda に渡しています。今回はソースコードを Claude Code に読み込ませているので鍵はソースコードからそのまま分かりましたが、実際に署名鍵が流出した事例は複数存在します。

この鍵を使うと、攻撃者は手元で次のようにトークンを作れます。手順は、ログイン処理(前掲のコード)がサーバ側で署名する手順と同じです。

// 攻撃者の手元で、サーバと同じ手順で署名する(鍵が同じなら本物と区別できない)
async function forge(sub: string, secret: string) {
  return new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(sub)
    .setIssuedAt()
    .setExpirationTime('3600s')
    .sign(new TextEncoder().encode(secret));
}

const forged = await forge('alice', 'demo-insecure-secret-change-me');

手順3: 作ったトークンを /me に提示して、本人として通す。 /me は、提示された JWT の署名を検証し、sub のユーザーが実在すれば本人(sub)として応答するエンドポイントです。今回の構成では、サーバは発行したトークンを記録していません(JWT を使うときによくある構成です)。届いたトークンの署名が正しければ、自分が発行したものとして受け入れます。そのため、偽造した alice のトークンも、署名が正しく alice も実在すれば、本物のログインで得たトークンと区別されずに通ります。

lambda/diy-me/index.ts(抜粋)
// /me: Authorization: Bearer <JWT> を同じ鍵で検証する
const { payload } = await jwtVerify(token, jwtSecret);
// sub のユーザーが実在するかを確かめ、実在すれば本人として通す
const user = await getUser(payload.sub);
if (!user) return json(401, { message: 'トークンが不正です' });
return json(200, { message: 'Passed!!', sub: payload.sub, exp: payload.exp });

実際に、正しい鍵で作ったトークンを /me に送ると、結果は次のようになりました。

正しい鍵 + 実在する alice      : 200 { message: 'Passed!!', sub: 'alice', exp: 1792400945 }
正しい鍵 + 実在しない username  : 401 { message: 'トークンが不正です' }
違う鍵   + 実在する alice      : 401 { message: 'トークンが不正です' }

偽造したトークンは、正規クライアントと同じように Authorization ヘッダで提示するだけです。たとえば次のように、ターミナルから直接送れます。

curl -s https://<api>/me \
  -H "Authorization: Bearer <偽造したJWT>"
# => {"message":"Passed!!","sub":"alice","exp":<有効期限>}

あるいは、攻撃者が自分のブラウザでデモ画面を開き、画面がトークンを保存している場所(今回は localStorage)に偽造トークンを書き込んでリロードすると、ログインを経由せずに画面が alice として開きます。

localStorage.setItem('diy_demo_token', '<偽造したJWT>'); location.reload();

鍵はどこから漏れるのか

鍵が漏れる代表的なルートとしては、以下のようなものが考えられます。

  • ソースコード/git 履歴への直書き
    今回の実装では、CDK スタックに鍵の文字列を直書きしています。リポジトリを見られる人・CI のログ・流出で、そのまま鍵が分かります
  • Lambda の環境変数・設定として平文で見える
    今回はその鍵を環境変数 JWT_SECRET として Lambda に渡しています。lambda:GetFunctionConfiguration の権限や、CloudFormation テンプレートからも読めます
  • 署名鍵が弱いと、正規トークン1つから総当たりで割り出される
    secret-... のような短く意味のある文字列は特に危険です

まとめ

試してみてわかったことは以下です。

脆弱性は組み合わさると強い
ロックアウトによる有効なユーザー名の特定は、単体で見れば認証を突破できるものではありません。しかし、他の脆弱性と組み合わせることで、結果的に認証自体をスキップできてしまいました。

単一の壁に依存するセキュリティは脆い
スロットリング・ネットワークの分離・ロックなどの対策は入れていましたが、/me が本人かどうかを確かめているのは、トークンの署名検証(と sub のユーザーの実在確認)だけです。ほかの対策は偽造したトークンの提示を止めないので、署名鍵が漏洩するとそのまま突破されてしまいました。

対策を入れたつもりでも簡単に破られた
ある程度の対策は入れたつもりでしたが、Claude に診断させると数分で複数の脆弱性が挙がり、実際に突破されました。認証の突破に使ったもの以外も含めると7つの脆弱性が挙がり、そのうち4つは実際に攻撃として再現できました。今回は API が2つだけの非常にシンプルな構成なので、1つずつ潰していけば、いずれは潰し切れるかもしれません。しかし、実際のシステムは機能も構成要素もはるかに多く複雑なので、専任のチームや外部の診断なしに、自前で書いた認証の脆弱性を潰し切るのはかなり難易度が高いのではないかと感じました。

今回出てきた脆弱性と対策の方向を、次の表にまとめます。

脆弱性 対策の方向
試行回数に上限が無い 送信元 IP ごとのレート制限、失敗が続くほどロック時間を延ばす、失敗の連続の検知
ハッシュの流出だけで総当たりできる テーブルとは別に置いた秘密の値(ペッパー)をハッシュに混ぜる、テーブル・バックアップを読める権限を絞る
弱いパスワードを弾くガードが無い 推測しやすい値・短い値の登録を拒否する
ロック設定の非原子性 照合の前に、条件付き更新で試行回数を加算し(上限に達していれば更新が失敗する)、加算できた要求だけを照合する
ロック状態でのユーザー列挙 ロック時も含めて応答を一定にする
トークン偽造 鍵を SSM / Secrets Manager で管理し強いランダム値にする、非対称鍵にする
可用性 送信元 IP ごとのレート制限

このほか、MFA とトークンの失効も今回の実装にはありません。

最近はセキュリティ関連の事件も多いので、どのようなものが脆弱性になり得るのか、という点は今後も意識していきたいと思います。


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AI白書

関連記事