NGワードフィルタを5段階処理で構築!リート対策と正規化の実装例

こんにちは。monoAI technologyエンジニアのみのっちです。

ユーザー生成コンテンツを扱うサービスで「forbidden」を禁止ワードに登録しても、「f0rb1dd3n」や「forbidden」で簡単に突破される——この問題に直面したことはないでしょうか。単純な完全一致では、全角・半角の混在、ゼロ幅文字の挿入、数字によるアルファベット置換(リートスピーク)といった回避手法に追いつけません。今回、Unicode正規化とリートスピーク対策を組み合わせた5段階の多層防御フィルタを実装しました。処理順序と各ステップで使う具体的な手法を以下に紹介します。

みのっちです。UGCサービスで「forbidden」を「f0rb1dd3n」等に置き換えて突破される問題に直面し、Unicode正規化とリート対策を組み合わせた5段階の多層防御フィルタを実装しました。誤検出を防ぐホワイトリスト優先マスクの勘所も含めて紹介します。

何が起きたか:NGワード回避の実態

ユーザー生成コンテンツを受け付けるサービスでは、不適切な語句をブラックリストで管理し、投稿時にフィルタリングするのが一般的です。しかし、単純な完全一致や部分一致による検出では、以下のような回避手法に対応できません。

  • 全角・半角の混在:「forbidden」のように全角文字を混ぜる
  • ゼロ幅文字の挿入:「for​bidden」のように見えない文字(U+200B等)を挿入し、文字列比較を回避
  • リートスピーク(Leet Speak):「f0rb1dd3n」のように数字や記号でアルファベットを置き換える表記法

さらに、ホワイトリスト(許可語)とブラックリスト(禁止語)の衝突も課題です。「assistant」を許可語として登録しても、「ass」が禁止語に含まれる場合、正当な単語まで誤検出してしまう。

成功基準は明確です。悪意ある回避を検出しつつ、誤検出を最小化し、ユーザー体験を損なわないこと。この2つの要件を満たすため、5段階の多層防御フィルタを設計しました。

前提・用語の整理

以下、技術用語を整理します。

用語説明
リートスピーク(Leet Speak)アルファベットを数字や記号で置き換える表記法。例:elite31337forbiddenf0rb1dd3n
Unicode正規化(NFKC)互換性分解と正準合成を行い、全角英数を半角に、合成文字を分解後再合成して統一する正規化方式(UAX #15参照)
ゼロ幅文字画面に表示されないが文字列比較では異なる文字として扱われるUnicode文字(U+200B、U+FEFF等)
ホワイトリスト優先マスクブラックリスト照合の前に許可語を*で置換し、意図的に検出対象から外す手法

NFKCはUnicode標準(UAX #15)で定義されており、識別子やセキュリティ上の懸念がある場合に推奨される形式です。多くの書式設定の違いを排除し、互換性等価文字間の差異を統合する。

設計の方針と5段階処理フロー

なぜ5段階か

単一の手法では回避される。これが多層防御を選んだ理由です。文字種の制限→正規化→リート対策→ホワイトリスト保護→ブラックリスト照合の順で、段階的に防御を積み重ねます。

処理順序の決定理由は以下の通り。

  • ホワイトリストを先にマスクする理由:「assistant」内の「ass」が誤検出されるのを防ぐため、ブラックリスト照合の前に許可語を保護する
  • リート変換を正規化の後に行う理由:全角数字「0」も半角「0」に揃えてから「o」に置換できる。正規化前にリート変換すると、全角数字が対象外になる

全体フローの概要

5段階の処理は次の順序で実行されます。

  1. 文字種チェック:半角英数字・アンダースコア・ハイフンのみ、20文字以内に制限
  2. 正規化処理:Unicode NFKC正規化、小文字化、ゼロ幅文字の除去
  3. リートスピーク対策:数字→アルファベット置換(0→o、1→i、3→e等)
  4. ホワイトリスト優先マスク:許可語を長い順に*で置換
  5. ブラックリスト照合(3段階):完全一致→部分一致→難読化一致(正規表現)
No
Yes
Yes
No
入力文字列
①文字種チェック
制限内?
リジェクト
②正規化処理
③リートスピーク対策
④ホワイトリスト優先マスク
⑤ブラックリスト照合
一致?
検出・通知
通過

実装の詳細

ステップ①:文字種チェック

最初のステップは、入力文字列が半角英数字・アンダースコア・ハイフンのみで構成され、20文字以内であることを検証します。この事前チェックにより、不正な入力を早期にリジェクト。後続の処理負荷を軽減する仕組みです。

実装は正規表現による単純なパターンマッチ。

// 疑似コード例
if (!Regex.IsMatch(input, @"^[a-zA-Z0-9_-]{1,20}$")) {
    return RejectResult("文字種または長さが制限を超えています");
}

ステップ②:正規化処理

正規化処理では、以下の3つの操作を順次実行します。

  1. Unicode NFKC正規化:全角英数を半角に統一、合成文字を分解後再合成
  2. 小文字化:大文字小文字の違いによる回避を防ぐ
  3. ゼロ幅文字の除去:U+200B(ゼロ幅スペース)、U+FEFF(ゼロ幅非改行スペース)等を削除

NFKC正規化は、Unicode標準で定義された互換性分解と正準合成の組み合わせです。例えば、全角の「A」(U+FF21)は半角の「A」(U+0041)に統一される。これにより、「forbidden」と「forbidden」が同一の文字列として扱われるようになります。

// 疑似コード例
string normalized = input.Normalize(NormalizationForm.FormKC);
normalized = normalized.ToLower();
normalized = normalized.Replace("\u200B", "")  // ゼロ幅スペース
                       .Replace("\uFEFF", "")  // ゼロ幅非改行スペース
                       .Replace("\u200C", "")  // ゼロ幅非接合子
                       .Replace("\u200D", ""); // ゼロ幅接合子

ゼロ幅文字は、画面上は見えないものの、文字列比較では異なる文字として扱われるため、明示的に除去する必要があります。

ステップ③:リートスピーク対策

リートスピークは、数字や記号でアルファベットを置き換える表記法。代表的な置換パターンを以下に示します。

数字置換後のアルファベット
0o
1i
3e
4a
5s
7t

実装は、正規化後の文字列に対して置換辞書を順次適用します。置換順序によっては意図しない変換が起きる可能性があるため、注意が必要。例えば、「13」を「1」→「i」、「3」→「e」の順で置換すると「ie」になりますが、「3」→「e」、「1」→「i」の順だと「1e」になる。今回は、単純に数字を順番に置換する方針を採りました。

// 疑似コード例
string leetConverted = normalized
    .Replace("0", "o")
    .Replace("1", "i")
    .Replace("3", "e")
    .Replace("4", "a")
    .Replace("5", "s")
    .Replace("7", "t");

ステップ④:ホワイトリスト優先マスク

ホワイトリスト優先マスクは、許可語を含む正当な入力が誤検出されないよう、ブラックリスト照合の前に保護する仕組み。

処理の狙いは明確です。「assistant」を許可語として登録している場合、「ass」が禁止語に含まれると、「assistant」全体が誤検出されてしまう。これを防ぐため、許可語を先に*で置換し、ブラックリスト照合の対象から外します。

重要なのは、長い順にマスクする点。部分一致の優先順位を制御するため、文字数が多い語から順に置換します。例えば、「assistant」と「ass」の両方が許可語に含まれる場合、「assistant」を先にマスクしないと、「ass」が先にマスクされて「***istant」になってしまいます。

// 疑似コード例
List<string> whitelist = LoadWhitelist();
whitelist.Sort((a, b) => b.Length.CompareTo(a.Length)); // 長い順

string masked = leetConverted;
foreach (string word in whitelist) {
    masked = masked.Replace(word, new string('*', word.Length));
}

ステップ⑤:ブラックリスト照合(3段階)

最後のステップは、ブラックリストとの照合。3段階の照合を順次実行します。

  1. 完全一致:マスク後の文字列全体がNGワードと一致するか判定
  2. 部分一致(Contains):NGワードが文字列内に含まれるか判定
  3. 難読化一致(正規表現):NGワード内の各文字間に任意文字の挿入を許容

完全一致と部分一致は標準的な文字列比較で実現できる。難読化一致は、NGワードの各文字間に任意の文字(0文字以上)を許容する正規表現を使います。例えば、「forbidden」を「f.*o.*r.*b.*i.*d.*d.*e.*n」のようなパターンに変換し、「f-o-r-b-i-d-d-e-n」のような挿入パターンも検出できるようにします。

// 疑似コード例
List<string> blacklist = LoadBlacklist();

foreach (string ngWord in blacklist) {
    // ① 完全一致
    if (masked == ngWord) {
        return DetectResult(ngWord, "完全一致");
    }
    
    // ② 部分一致
    if (masked.Contains(ngWord)) {
        return DetectResult(ngWord, "部分一致");
    }
    
    // ③ 難読化一致(正規表現)
    string pattern = string.Join(".*", ngWord.ToCharArray());
    if (Regex.IsMatch(masked, pattern)) {
        return DetectResult(ngWord, "難読化一致");
    }
}

return PassResult();

実装のポイントと注意点

5段階処理を実装する上で、いくつかのポイントと注意点があります。

処理順序の厳守

正規化→リート変換→ホワイトリストマスク→ブラックリスト照合の順序は厳守する必要がある。順序を入れ替えると、期待した結果が得られません。

例えば、リート変換を正規化の前に行うと、全角数字「0」が半角「0」に変換されず、リート対策が不完全になる。また、ホワイトリストマスクをブラックリスト照合の後に行うと、誤検出を防げません。

ホワイトリストの管理

ホワイトリストは、長い順にソートして管理します。これにより、部分一致の優先順位を制御できる。ソートのコストは初回のみであり、実行時のパフォーマンスへの影響は軽微です。

難読化一致のパフォーマンス

難読化一致は正規表現を使うため、ブラックリストのサイズが大きいとパフォーマンスに影響する。NGワードの件数が数百を超える場合、正規表現のコンパイルやキャッシュを検討する必要があります。

まとめ

今回実装したNGワードフィルタは、文字種チェック→正規化→リートスピーク対策→ホワイトリスト優先マスク→ブラックリスト照合(完全一致・部分一致・難読化一致)という5段階の多層防御を採用しています。

単純な完全一致では回避される全角・半角の混在、ゼロ幅文字の挿入、リートスピークといった手法に対し、Unicode正規化と段階的な変換・照合を組み合わせることで、悪意ある回避を検出しつつ、誤検出を最小化できました。

特にホワイトリスト優先マスクは、正当な単語が誤検出されるのを防ぐ上で重要な役割を果たしています。ブラックリスト照合の前に許可語を保護することで、ユーザー体験を損なわずにフィルタリングを実現できる。

UGC系サービスでNGワードフィルタを実装する際、同じ課題に直面する方の参考になれば幸いです。

🔗 参考リンク