グループウェアのメール機能で、迷惑メールの振り分けを「語句を含む」条件で運用している。カード会社名などを条件に登録してあったが、最近のフィッシングはこの条件をすり抜けて受信箱に届いていた。原因は、標題と差出人の文字のあいだに挟まれた見えない文字である。対策として、普通のメールには入らない文字そのものを振り分けの条件にした。
直近のメール
含んでいたメール
拾えたメール
(普通のメールの巻き込み)
語句の条件をすり抜けるフィッシング
このグループウェアでは、個人設定のメール振り分けに「迷惑メール」という振り分け保存があり、条件に合うメールを保存先の[ごみ箱]へ移す。条件は「標題」または「差出人」が指定の語句「を含む」かどうかで、設定画面で1行ずつ登録する。詐欺に名前を使われやすいカード会社や行政機関の名称を、この形で登録していた。
すり抜けたフィッシングは、画面上では登録済みの名称とまったく同じに見える。それでも振り分けられず、受信箱に残っていた。
原因 — 文字のあいだに挟まる「見えない文字」
すり抜けたメールの標題と差出人を1文字ずつコードポイントに分解すると、名称の文字のあいだに、幅を持たない文字や、文字の並ぶ向きを変える制御文字が挟まっていた。画面には何も表示されないが、文字列としては別のものになる。語句の条件は登録した文字列が連続して現れるかを見るため、あいだに1文字でも挟まれば一致しない。
// 例1: 幅を持たない文字を挟む(〇〇カードは架空の名称)
画面の表示 〇〇カード
実際の文字列 〇 〇 [U+200B] カ [U+200C] ー [U+200D] ド
語句「〇〇カード」を含む → 一致しない
// 例2: 向きを変える制御文字を前に置く
実際の文字列 [U+202E] 国 税 庁
画面の表示 庁税国
語句「国税庁」を含む → 一致しない
[U+…] は画面に何も出ない文字を、説明のために書き出したもの。実際のメールで確認できた文字は次のとおり。どれも本来は組版や多言語表記のための正規の文字で、メールの標題や差出人に入れる必要はない。
| コードポイント | 名前 | 本来の用途 | 画面での見え方 |
|---|---|---|---|
U+200B | ゼロ幅スペース | 改行してよい位置を示す | 何も見えない |
U+200C | ゼロ幅非接合子 | つながって書かれる文字を切り離す | 何も見えない |
U+200D | ゼロ幅接合子 | 文字や絵文字をつないで1つにする | 何も見えない |
U+2060 | 単語結合子 | その位置で改行させない | 何も見えない |
U+FEFF | BOM(ゼロ幅ノーブレークスペース) | ファイル先頭でバイト順を示す | 何も見えない |
U+202E | 右から左への上書き(RLO) | 以降の文字を右から左へ並べる | 以降の文字が逆順に表示される |
U+200E | 左から右マーク(LRM) | 文字の向きを判定する手がかり | 何も見えない |
U+2061 | 関数適用 | 数式の意味づけ(不可視の演算子) | 何も見えない |
U+2063 | 不可視の区切り | 数式の意味づけ(不可視の演算子) | 何も見えない |
U+202E だけは見え方が変わる。この文字より後ろが右から左に並ぶため、「国税庁」を含む差出人が「庁税国」と表示されたメールがあった。読み手には不自然に見えるが、文字列としてはやはり「国税庁」と一致しないので、語句の条件には掛からない。
対策1 — 見えない文字そのものを条件にする
詐欺の名乗りは手口ごとに変わる。一方で、見えない文字は名乗りが変わっても共通して使われている。そして、日本語の業務メールの標題や差出人にこれらの文字が入ることは通常ない。そこで、条件の語句に見えない文字を1文字だけ入れ、「その文字を含む」メールをごみ箱へ移すことにした。
普通のメールには入らない文字を条件にする。
登録したのは次の15条件。表の主要6種を標題・差出人の両方に置き、6種以外で実際のメールに出ていた3文字を、出ていた欄に足した。
| 欄 | 含む文字 | 件数 |
|---|---|---|
| 標題 | U+200B U+200C U+200D U+2060 U+FEFF U+202E | 6 |
| 差出人 | U+200B U+200C U+200D U+2060 U+FEFF U+202E | 6 |
| 追加 | 差出人 U+200E/標題 U+2061/標題 U+2063 | 3 |
| 計 | 15 |
登録の前に、直近のメール2,318通にこの15条件を当てた。見えない文字を含んでいたのは188通で、15条件で拾えるのは185通。普通のメールが条件に掛かるものは0通だった。振り分け先はごみ箱で、削除ではないため、万一普通のメールが入っても戻せる。
登録の方法 — 手で打てない文字をブラウザ自動操作で入れる
条件を決めても、設定画面の入力欄に見えない文字は手で打てない。キーボードに無い文字であり、コピー&ペーストで入れようとしても、入ったかどうかを画面で確かめられない。15件を手作業で正しく登録するのは事実上できない。
そこで、ブラウザ自動操作ライブラリの Playwright を使い、AIエージェントが書いたスクリプトで入力欄に文字を流し込んだ。手順は次のとおり。
- 人がログインする。自動操作用の専用 Chrome を起動し、人がグループウェアにログインする。ID・パスワードは AI に渡さない。
- ログイン済みのブラウザに接続する。スクリプトは Chrome DevTools Protocol(CDP)でこのブラウザに接続し、ログイン済みの画面をそのまま使う。
- 登録済みの条件を読む。設定画面を全ページ読み、既にある条件を取得する。既存の条件には触らず、未登録のものだけを追加の対象にする。
- 入力して読み戻す。[条件を増やす]で行を足し、「標題/差出人」・文字・「を含む」を設定する。入力直後に入力欄の値を読み戻し、狙った1文字と一致するかを確かめる。
- 保存して照合する。保存先が[ごみ箱]であることを確かめてから保存し、設定画面を読み直して、追加した条件が1件ずつ登録されているかを照合する。
# 処理の骨格(簡略化)。見えない文字はエスケープで書くと、ソース上でもどの文字か分かる
CHARS = ["\u200b", "\u200c", "\u200d", "\u2060", "\ufeff", "\u202e"]
RULES = [("標題", c) for c in CHARS] + [("差出人", c) for c in CHARS]
browser = p.chromium.connect_over_cdp(CDP_ENDPOINT) # 人がログイン済みの Chrome
want = [r for r in RULES if r not in registered(page)] # 未登録だけ
for field, ch in want:
box = add_row(page, field)
box.fill(ch)
assert box.input_value() == ch # 入力欄に入ったかを読み戻す
save(page)
assert set(want) <= registered(page) # 保存後に設定画面を読み直して照合
registered()・add_row()・save() は画面ごとの操作をまとめた関数。中身は製品の画面構成に依存するので省略している。このグループウェアの入力欄は、見えない文字を消さずに保存した。入力欄の側で不可視文字を取り除く製品では、この方法は使えない。保存後に設定画面を読み直しているのは、この点を確かめるためでもある。条件の数は、既存の語句条件と合わせて57件になった。
効果確認 — 設定から2日後
設定から2日後に、設定後に届いたメールを集計した。全16フォルダのメール一覧を読み、標題と差出人に見えない文字を含むメールがどのフォルダにあるかを数えた。メールは開いていない。開くと既読になるため、一覧の表示だけを使っている。
| 項目 | 件数 |
|---|---|
| 設定後に届いた、見えない文字を含むメール | 6通 |
| うち、ごみ箱に振り分けられたもの | 6通 |
| 取りこぼし(受信箱などに残ったもの) | 0通 |
| 誤判定(普通のメールがごみ箱に入ったもの) | 0通 |
この数え方には限界がある。一覧表示では標題・差出人が途中で切れるため、切れた後ろにある文字は数えられない。ただし、登録前の検証も同じ一覧画面で行っており、前後の比べ方は揃っている。
対策2 — 契約のない会社の名前を条件にする
見えない文字を使わない詐欺は、この対策の後も届いた。名乗っていたのは、ネット証券、クレジットカード会社、レンタルサーバ事業者の3社である。
3社とも当社と契約がないことを、人が確認した。契約がなければ、その会社から本物の通知が届くことはない。したがって、契約のない会社の名前が標題や差出人に出てくるメールは、すべて詐欺として扱える。この前提に立ち、3社の社名を語句の条件に足した。
- 表記違いも登録する。振り分けの語句条件が英字の大文字・小文字を区別するかどうかが画面から分からないため、英字・カタカナの表記や大文字・小文字の違いを別々の条件にした。3社で11件。
- 登録前に過去のメールに当てる。過去約2,400通に11件を当て、該当するのが3社名義のメールだけで、普通のメールの巻き込みが0通であることを確かめてから登録した。登録の手順は対策1と同じで、条件の数は68件になった。
社名と登録した文字列の一覧は、この記事には載せない。公開すると攻撃側に表記を変えて回避されやすくなり、取引の有無という社内の情報にもなるためである。
役割分担
| 担当 | 内容 |
|---|---|
| 人 | どの会社と契約がないかの確認。取引の有無は社内の事実で、メールを眺めても分からない |
| 人 | 設定を書き換えてよいかの判断。設定変更は自動承認の対象にしておらず、人の「登録して」の指示で実行した |
| AI | 見えない文字の測定(どの文字が・どの欄に・何通) |
| AI | 条件の候補作成と、過去のメールに当てた誤判定の検証 |
| AI | 設定画面への登録と、保存後の照合 |
1週間後の再確認は、グループウェアの ToDo リストに期限付きで登録してある。
運用上の注意
- 一覧表示は途中で切れる。効果確認を一覧画面で行う限り、標題・差出人の後ろのほうにある見えない文字は数えられない。件数は「一覧で見える範囲での数」として扱う。
- 契約したら条件を外す。対策2の条件は「契約がない」ことが前提である。将来その会社と契約した場合、条件を外さないと本物の通知がごみ箱に入る。
- 語句より先に文字を見直す。新しい詐欺がすり抜けたら、まず標題・差出人に未登録の見えない文字が入っていないかを調べる。文字の条件は名乗りが変わっても効くが、語句の条件は名乗りが変わるたびに足す必要がある。
- 振り分け先はごみ箱にしておく。誤判定があっても戻せるよう、削除ではなくごみ箱へ移す設定にしている。
