ブルートフォース攻撃は、パスワードの候補を機械的に試す攻撃です。 オンライン認証では試行回数を抑え、パスワードが漏れても侵入を完了できない認証方式を重ねます。 一方、漏洩したハッシュを攻撃者の手元で解析するオフライン攻撃には、ログイン画面の回数制限が効きません。
ブルートフォース攻撃とは
パスワード認証に対する攻撃を考えるとき、候補の作り方と試行する場所を分ける必要があります。 ブルートフォース攻撃(総当たり攻撃)は、1つのアカウントやパスワードハッシュに対して、多数の候補を機械的に試す攻撃です。
候補の作り方には次の違いがあります。
- 単純総当たり:利用できる文字の組み合わせを順に試す
- 辞書攻撃:よく使われるパスワード、単語、過去に流出した文字列などを優先して試す
- ハイブリッド攻撃:辞書の単語に数字や記号を加え、「password2026」のような人間が作りやすい変形を試す
実際の攻撃は、全組み合わせを先頭から調べるとは限りません。 利用者が選びそうな候補から試す方が、少ない回数で成功しやすいためです。
試行する場所によって、防御も変わります。
- オンライン攻撃:ログイン画面や認証APIへリクエストを送る。レート制限、段階的な待ち時間、ボット対策、リスクベース認証によって試行できる量を減らせる
- オフライン攻撃:漏洩したパスワードハッシュを攻撃者の環境で解析する。サービス側のレート制限は届かず、パスワードの推測しにくさ、ハッシュ方式、コスト係数、ペッパーの有無などが解析コストを左右する
「英小文字8文字なら何分で破られる」といった数字を、条件なしで示すことはできません。 同じパスワードでも、オンライン認証、SHA-256を直接使ったハッシュ、適切に調整したArgon2idでは、1秒間に試せる回数が大きく異なるからです。 ただし、同じ作り方なら長いパスワードほど候補空間は広がります。 文字種の規則を増やすだけでは人間が予測しやすい置換に偏るため、十分な長さと既知の弱いパスワードの拒否を組み合わせます。
ブルートフォース攻撃が起きるパターン
典型的な弱点は次のとおりです。
- ログイン試行にレート制限がなく、同じアカウントへ何度でも試せる
- アカウント単位だけを見ており、IPアドレスや端末を変えた分散試行を捉えられない
- 短く予測しやすいパスワードや、既知の流出パスワードを登録できる
- SSHや管理画面などの高権限な入口を、必要な制限なしでインターネットへ公開している
- 画面には制限があるものの、同じ認証を行うAPIや旧エンドポイントには制限がない
- 多要素認証がなく、パスワードが一致すれば侵入が完了する
一度の推測で弱いパスワードが当たる場合もあるため、「大量の試行だけを止めれば成立しない」とは限りません。 レート制限はオンライン攻撃の成功確率を下げる土台であり、弱いパスワードの拒否や多要素認証を置き換えるものではありません。
対策法
オンライン攻撃では、同じ主体が短時間に試せる量を減らし、パスワードだけでは認証を完了できない構造を作ります。
- アカウント、IPアドレス、ネットワーク、端末など複数の単位でレート制限する
- 失敗が続くほど待ち時間を延ばし、必要に応じてボット判定や追加認証を要求する
- 固定時間の強制ロックだけに依存せず、攻撃者による意図的なロックアウトも監視する
- 可能ならパスキーやセキュリティキーなど、フィッシング耐性のある認証を導入する
- パスワードを使う場合は、既知の弱い値を拒否し、文字種の強制より長さを優先する
- 管理画面は到達元を制限し、SSHではパスワード認証を無効にして公開鍵認証を使う
- 認証失敗と成功後の異常を監視し、分散された試行も相関分析できるようにする
NIST SP 800-63Bでは、パスワードを単独の認証要素として使う場合は15文字以上、多要素認証の一部として使う場合でも8文字以上を要求しています。 受け入れる最大長は少なくとも64文字とし、文字種の組み合わせ規則を課さず、よく使われる値や流出済みの値をブロックリストで拒否するのが現在の基準です。
TypeScript(Next.js)での事例
失敗回数は、すべてのサーバインスタンスから参照できるRedisなどの共有ストアへ記録します。 プロセス内のカウンタだけでは、水平スケールや再起動によって制限が分断されるためです。
次の例はアカウント単位の最小構成です。 Redisのキーへメールアドレスを直接残さないよう、レート制限専用の鍵でHMACを計算しています。
// app/api/login/route.ts
import { createHmac } from "node:crypto";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const MAX_FAILURES = 5;
const WINDOW_SECONDS = 15 * 60;
const RATE_LIMIT_HMAC_KEY = process.env.LOGIN_RATE_LIMIT_HMAC_KEY;
if (!RATE_LIMIT_HMAC_KEY) {
throw new Error("LOGIN_RATE_LIMIT_HMAC_KEY is not configured");
}
function accountKey(email: string): string {
const normalized = email.normalize("NFC").trim().toLowerCase();
const digest = createHmac("sha256", RATE_LIMIT_HMAC_KEY)
.update(normalized)
.digest("hex");
return `login:fail:account:${digest}`;
}
export async function POST(req: Request) {
const { email, password } = await req.json();
if (typeof email !== "string" || typeof password !== "string") {
return Response.json(
{ error: "リクエストの形式が正しくありません" },
{ status: 400 }
);
}
const key = accountKey(email);
const failures = Number(await redis.get(key)) || 0;
if (failures >= MAX_FAILURES) {
return Response.json(
{ error: "ログインできません。しばらく待ってから再試行してください" },
{ status: 429 }
);
}
const user = await verifyCredentials(email, password);
if (!user) {
const updatedFailures = await redis.incr(key);
if (updatedFailures === 1) {
await redis.expire(key, WINDOW_SECONDS);
}
return Response.json(
{ error: "メールアドレスまたはパスワードが違います" },
{ status: 401 }
);
}
await redis.del(key);
return createSession(user);
}
この例だけでは、異なるアカウントへ1回ずつ試すパスワードスプレーや、送信元を分散する攻撃を止められません。 CDNやWAFではIPアドレスやネットワーク単位の制限を重ね、アプリケーションではアカウント単位の履歴を持ちます。 プロキシ経由で取得するクライアントIPは、信頼するプロキシの範囲を設定し、利用者が転送ヘッダを偽装できないことが前提です。 アカウントIDの正規化規則は実際の認証処理と一致させ、表記の違いで別のカウンタへ分かれないようにします。
また、アカウントが存在しない場合も含めて同じエラーメッセージを返し、応答時間の差も小さくします。 存在しないアカウントでもダミーのパスワードハッシュを検証すれば、処理時間から存在を推測されにくくなります。 本番では、カウンタ更新の競合、Redis障害時の扱い、正規利用者を巻き込むしきい値、監視と解除手順まで決める必要があります。
なお、これらの対策が届くのはオンライン攻撃だけです。 データベース漏洩後のオフライン攻撃には、Argon2idなどのパスワード保存用アルゴリズムと適切なコスト設定が別に必要です。
