Claude Codeでブルートフォース対策を実装する — ログインの試行制限とロック悪用
IPとアカウントの二軸カウンタをRedisで作り、ロックを悪用した締め出しを防ぐ設計を、Claude Codeに書かせてテストで確かめます。
Claude Codeでブルートフォース対策を実装する — ログインの試行制限とロック悪用
ログインのブルートフォース対策は、失敗回数を数えて止めるだけなら簡単です。難しいのは、止める仕組みそのものを攻撃に使われないようにする点にあります。
「5回間違えたらアカウントをロック」とだけ書くと、他人のIDに5回わざと失敗すれば、その人を締め出せます。この記事では、IP・アカウント・その組み合わせの3本のカウンタに役割を分け、ロックを悪用されにくくする設計を、Claude Codeに実装させて確かめます。
Redisで試行回数を数える土台は手元の環境で動かして確認しました。汎用のスライディングウィンドウ、429、Retry-Afterの作り方はClaude CodeでRedisのレート制限を実装する記事にあります。ここではログイン固有の部分、つまり何をキーに数え、超えたら何を止めるかに絞ります。
アカウント単位だけのロックは、なぜ攻撃に使えるのか
OWASPのAuthentication Cheat Sheetは、失敗回数のカウンタを送信元IPではなくアカウントに結びつけるよう勧めています。多数のIPから試行されても数え漏らさないためです。
ただし同じ節に、ロックアウトが他のユーザーを締め出すサービス拒否に使われるおそれがあるという注意も書かれています。対策の一例として、ロック中でもパスワード再設定の流れ経由ならログインできるようにする案が挙げられています。
つまり、アカウント単位の単一カウンタには次の板挟みがあります。
- IPだけで数える: 攻撃者がIPを散らすと、1アカウントへの試行が積み上がらない
- アカウントだけで数える: 攻撃者が1つのIPから被害者を何度も失敗させ、本人を締め出せる
どちらかを選ぶのではなく、目的の違う3本を持つ構成にします。
3本のカウンタと役割
IPカウンタ
1つの送信元からの試行総数を数えます。IPを変えずに多数のアカウントを試す攻撃を止める役です。
IP×アカウントのペア
同じ送信元が同じアカウントを狙う試行を数えます。超えたらそのペアだけを拒否します。本人は別のIPから入れます。
アカウント全体
IPを散らした攻撃を検知します。超えても全面ロックにはせず、追加の確認を求める段階に上げます。
手元のRedisで二軸カウンタの挙動を確かめる
設計の良し悪しは、攻撃者と本人を同時に動かして初めて分かります。Redis 6.0.9を一時ポートで起動し、次のLuaスクリプトを載せて、攻撃者のIPと本人のIPから alice に失敗を重ねました。
-- KEYS[1]=ip, KEYS[2]=account, KEYS[3]=ip+account
-- ARGV: ttl, ipMax, pairMax, acctMax
local ttl = tonumber(ARGV[1])
local function hit(key)
local n = redis.call('INCR', key)
if n == 1 then redis.call('EXPIRE', key, ttl) end
return n
end
local ip = hit(KEYS[1])
if ip > tonumber(ARGV[2]) then return { 'ip_blocked', ip } end
local pair = hit(KEYS[3])
if pair > tonumber(ARGV[3]) then return { 'pair_blocked', pair } end
local acct = hit(KEYS[2])
if acct > tonumber(ARGV[4]) then return { 'step_up', acct } end
return { 'allow', acct }しきい値はIPが20回、ペアが5回、アカウント全体が50回、窓は900秒としました。数値は例であり、サービスの利用実態に合わせて決める部分です。
SHA=$(redis-cli -p 6399 SCRIPT LOAD "$(cat login_guard.lua)")
redis-cli -p 6399 EVALSHA $SHA 3 ip:203.0.113.9 acct:alice \
pair:203.0.113.9:alice 900 20 5 50攻撃者のIPから8回続けた結果は次のとおりでした。
| 試行 | 戻り値 | アカウント全体の累計 |
|---|---|---|
| 1〜5回目 | 戻り値allow | アカウント全体の累計1〜5 |
| 6〜8回目 | 戻り値pair_blocked | アカウント全体の累計5のまま |
6回目以降はペアの上限で止まり、アカウント全体のカウンタは5から増えていません。ここが設計の肝です。すでに拒否した試行をアカウント全体にまで加算すると、攻撃者は1つのIPから叩くだけでアカウント全体の段階を引き上げられます。ペアの判定を先に行い、超えたら以降のカウンタに触れずに戻します。
最初の実装は3本すべてを無条件に加算していました。その実装では同じ攻撃で、アカウント全体の累計が拒否分まで積み上がりました。順序を変えた実装との差は、この1点だけです。
続けて、本人の別IP(198.51.100.4)から1回試すと allow でした。ペアが別のキーなので、攻撃者のロックを引き継ぎません。
最後に、ここまでのキーを消さずに、70個のIPから1回ずつ alice を狙いました。アカウント全体の累計はこの時点で6です。ペアは1回ずつなので、累計が50に達するまでの44回は allow でした。残りの26回は step_up で、累計は76まで進みました。IPを散らす攻撃は、ペアでは止まらずアカウント全体で見つかる形になります。
アカウント全体が超えたときは、ロックでなく追加確認にする
step_up を受けたアプリが何をするかは設計の自由度が大きい部分です。全面ロックにすると、分散攻撃の最中は本人も入れなくなり、結局は締め出しの道具になります。そこで、次のような扱いにします。
アカウント全体が超えたときの扱い
全面ロック
攻撃が続く間、本人も入れません。攻撃者にとっては目的のサービス拒否が成立します。
追加確認へ上げる
信頼済み端末のCookieを持つ本人だけ通常どおり通し、それ以外にはCAPTCHA、遅延、メール確認のいずれかを挟みます。
OWASPはCAPTCHAを、突破される場合もあるため防御の層を足す手段として捉え、少数回の失敗の後から要求する形を示しています。パスワード再設定の経路を残す案とあわせて、ロック中の本人に出口を用意するという考え方です。
応答とタイミングを揃えて、アカウントの存在を漏らさない
カウンタを入れても、応答の違いから「そのIDは存在する」「ロックされている」と分かると、攻撃者に手がかりを渡します。OWASPは、ID違い・パスワード違い・アカウント不在・ロック・無効化のどれでも同じ汎用メッセージを返すよう求めています。HTTPステータスの違いも情報漏えいになることがあると指摘しています。
処理時間にも注意が要ります。同じ文書の疑似コードでは、ユーザーが存在しないときに早期に返す実装だと、存在するときよりハッシュ計算を省けて応答が速くなります。ハッシュ計算と照合をどのケースでも通す書き方が、差を小さくする例として示されています。
ここで先ほどのカウンタと噛み合わせます。ペア拒否の応答を「パスワード違い」と同じ文面にするなら、拒否した場合もダミーのハッシュ計算を走らせて時間を近づける必要があります。計算を省くとロックされているかどうかが時間で分かるためです。
Claude Codeに書かせる手順
実装は一度に任せず、設計の合意を取ってから進めます。変更を確認してから反映したいときは、プランモードが使えます。
claude --permission-mode planセッション中に Shift+Tab を押して切り替える方法もあります。プランモードでは、Claudeがファイルを読んで計画を示すだけで、承認するまで編集は行われません。ここで次の依頼文を渡します。
ログイン処理の試行制限を実装して。条件は次のとおり。
- カウンタは IP / アカウント / IP+アカウントの3本。Redisのキー名は login:ip:{ip} など
- 判定は1つのLuaスクリプトで原子的に行う。順序は ip → pair → account
- pair が上限を超えたら account は加算しない
- account が上限を超えても全面ロックにせず、step_up を返す
- どの失敗でも同じエラー文面を返し、ハッシュ計算は必ず通す
計画だけ先に見せて。実装はまだしないで。計画に条件が反映されているかを読んでから承認します。特に確認する差分は、加算の順序です。順序が逆だと前節のアカウント全体の水増しが再現します。
規約はCLAUDE.mdに書いておくと、次回以降のセッションでも前提として読み込まれます。
## 認証まわりの規約
- ログイン失敗の応答は原因によらず同一文面にする(ID不在・ロック・パスワード違いを区別しない)
- 失敗カウンタは login:ip / login:acct / login:pair の3本。変更するときは login_guard の統合テストも直す
- アカウントの全面ロックは実装しない。上限超過は step_up を返すテストで締め出しを再現させる
実装が合っているかは、攻撃者と本人の両方を動かすテストで見ます。Claude Codeには、テストを書かせて実行し、失敗を直させる流れがそのまま使えます。
login_guard の統合テストを書いて、実行して失敗を直して。
1. 同じIPから alice に6回失敗 → 6回目が pair_blocked、account カウンタは5のまま
2. 別のIPから alice に1回 → allow
3. 1〜2の後、キーを消さずに70個のIPから1回ずつ → 累計50を超えた51回目以降が step_up(44回 allow、26回 step_up)
4. 同一IPから存在しないIDに失敗しても、ある場合と同じ文面とステータス4つ目は応答の同一性を見るため、ユニットテストでアプリ側の戻り値を比べます。1〜3は上のLuaスクリプトの結果とそのまま対応するので、期待値の根拠が手元に残ります。
同時実行で競合しないか
もう1つ、原子性の確認があります。200並行でカウンタを更新して、件数が落ちないかを見ます。
for i in $(seq 200); do
redis-cli -p 6399 EVALSHA $SHA 3 ip:1.1.1.1 acct:bob \
pair:1.1.1.1:bob 900 1000 1000 1000 >/dev/null &
done; wait
redis-cli -p 6399 GET acct:bob手元ではLua版が200を返しました。比較のため、GETしてからSETで1を足す非原子の書き方を同じ200並行で動かすと、25まで落ちました。数字は実行ごとに変わりますが、読んで書く方式は並行時に数え漏らし、上限判定が甘くなります。
Claudeが書いたコードにGETとSETの組み合わせが紛れていないかも、差分を見る観点になります。
試行は検証の前に数える
パスワードを検証してから失敗時に加算する書き方だと、並行リクエストが検証を同時に通過し、上限を超えて推測できます。ここでは試行を受けた時点で先に加算するので、検証の前に必ず数えます。成功したら該当ペアのキーを消す、という後処理だけを追加します。
どのカウンタを何で止めるか
| 状況 | 効くカウンタ | 止め方 |
|---|---|---|
| 1つのIPから1アカウントを総当たり | 効くカウンタペア | 止め方そのペアのみ拒否 |
| 1つのIPから多数のアカウントを順に試す | 効くカウンタIP | 止め方IPごと拒否 |
| 多数のIPから1アカウントを狙う | 効くカウンタアカウント全体 | 止め方追加確認へ |
| 攻撃者が本人の締め出しを狙う | 効くカウンタペア | 止め方本人の別IPは通る |
最後の行が、冒頭で挙げた悪用への答えです。ただし共有NATの内側など、複数の利用者が同じIPを使う環境では、ペアのしきい値を低くしすぎると同僚が巻き込まれます。IPカウンタの上限も同様なので、しきい値は想定する利用者数を見て決めます。
まとめ
ロックを悪用されない鍵は、拒否した試行を上位のカウンタに数えない順序と、アカウント全体の上限超過を全面ロックにしない扱いの2つです。両方とも、テストで攻撃者と本人を同時に動かせば挙動で確認できます。
しきい値の数値と、step_up を受けた後の画面(CAPTCHA、メール確認など)はサービスごとに決める部分なので、Claude Codeには数値を固定する前の計画段階で相談し、実装後に上のテストで裏取りする流れにすると、数値を決め直しやすくなります。Redis自体の調査にはRedisプラグインを使ったレビューも使えます。全体の使い方はClaude Code完全ガイドを参照してください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。