0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM で作られたフィッシングメールに備える:文面の不自然さに頼らない検知と、訓練の見直し方

0
Posted at

「日本語がおかしいメールは怪しい」という見分け方は、LLM が文面を書くようになってほぼ使えなくなりました。この記事では、文面の出来に関係なく残るシグナル(送信元、依頼の種類、リンクの行き先)でメールを判定する考え方を、Python の小さな判定コードで確かめます。後半では、利用者向けの訓練をどう変えるかを、英国 NCSC のガイダンスをもとに整理します。

AI を使った攻撃全体の動向は LLM で攻撃はどう変わったか:2026 年の脅威レポートから読む悪用の実態と防御側の優先順位 で、侵入された後の備えは 攻撃側の AI エージェントにゼロトラストで備える で扱っています。

LLM で何が変わったか:文面の質と個別化は攻撃者が手に入れた

Heiding、Schneier らの研究チームは、参加者を 4 つの群に分けて作り方の違うメールを送り、リンクのクリック率を比べました(arXiv:2412.00586、2026 年に Expert Systems with Applications 誌に掲載)。

メールの作り方 クリック率
一般的なフィッシングメール(対照群) 12%
人間の専門家が作ったスピアフィッシング 54%
AI が標的の調査から文面まで自動で作ったもの 54%
AI が作り、人間が手直ししたもの 56%

AI による自動化は、人間の専門家と同じ水準に達しています。この研究では、AI が公開情報から作った標的のプロフィールも 88% で正確だったとされています。Microsoft の Digital Defense Report 2025 も、AI で自動化したフィッシングのクリック率を 54%、従来型の 4.5 倍として紹介しています。

公的機関も同じ認識です。FBI は 2024 年 5 月の注意喚起で、AI によって受信者ごとに作り分けられ、文法や綴りも正しいメッセージが使われていると警告しました。NCSC のフィッシング対策ガイダンスの事例にも、文法も綴りも正しく、よく書かれたフィッシングメールが受信箱に届いた例が載っています。

一方で、LLM が変えられないものもあります。

  • どこから送ったか。 なりすました組織のドメインからは、DMARC を通るメールを送れません。似たドメインを取るか、正規のアカウントを乗っ取る必要があります
  • 何をさせたいか。 目的は、認証情報の入力、送金や振込先の変更、マルウェアの実行など、限られた種類の行動です
  • どこへ誘導するか。 リンクの行き先や返信先は、攻撃者が管理する場所になります

検知と訓練は、文面ではなくこの 3 つに寄せていきます。

文面に頼らないシグナル

シグナル 見るもの LLM で変わるか 見逃す場合
送信ドメイン認証 DMARC の結果 変わらない 似たドメイン、正規アカウントの乗っ取り
似たドメイン 既知の取引先ドメインとの類似 変わらない 乗っ取られた正規アカウント
初めての送信元 過去にやり取りしたドメインか 変わらない 乗っ取られた正規アカウント
表示名となりすまし 役員名・部署名を社外から名乗っていないか 変わらない 表示名を使わない手口
依頼の種類 振込先変更・認証情報入力・送金など 変わらない 依頼を次のやり取りに回す手口

表の最後の列が示すとおり、正規の取引先アカウントが乗っ取られた場合は、送信元のシグナルがすべて正常になります。 そのため、依頼の種類による判定は、送信元の判定とは独立に置く必要があります。NCSC のガイダンスも、高額の依頼などは別の経路で確認する手順にして、偽装しにくくすることを勧めています。

コードで確かめる:文面を見ずに判定する

上の表のシグナルをメールのヘッダーとリンクから取り出し、次の 4 段階で扱いを決めるコードです。

  • 送信元にも依頼にも問題がある → 隔離して利用者に警告
  • 依頼だけが要確認 → 配信するが、登録済みの連絡先で確認してから処理
  • 送信元だけが怪しい → 配信して注意表示
  • 問題なし → 配信

「依頼の種類」は本文のキーワードで見ていますが、見ているのは文章の上手さではなく「何を求めているか」です。

  • 実行環境: Python 3.13.16(標準ライブラリのみ)
  • LLM は呼び出していません。LLM が書いたことを想定した自然な日本語の文面を、固定値として与えています
  • ドメインはすべて .example などの検証用の名前です
mail_signals.py
import re
import unicodedata
from email import message_from_bytes, policy
from email.utils import parseaddr
from html.parser import HTMLParser
from urllib.parse import urlparse

# 自社と取引先のドメイン(実運用では設定や取引先マスタから読む)
OWN_DOMAINS = {"example.co.jp"}
KNOWN_DOMAINS = {"example.co.jp", "supplier-a.example", "bank-b.example"}
# 過去にやり取りしたことのある送信元ドメイン(実運用ではメールログから作る)
SEEN_DOMAINS = {"supplier-a.example", "bank-b.example", "news.example"}
# 役員・経理など、なりすまされやすい表示名
VIP_NAMES = {"山田 太郎", "経理部"}
# 文面の出来ではなく「何を求めているか」を見る(別経路の確認が必要な依頼)
HIGH_RISK_REQUESTS = {
    "支払先・口座の変更": re.compile(r"振込先|口座.{0,6}変更|支払先.{0,6}変更"),
    "認証情報の入力": re.compile(r"パスワード|ログインして|再認証|認証コード"),
    "ギフトカード・送金": re.compile(r"ギフトカード|至急.{0,10}送金"),
}


def skeleton(domain: str) -> str:
    """見た目が似た文字を寄せる(簡易版。実運用では Unicode の confusables を使う)"""
    d = unicodedata.normalize("NFKC", domain.lower())
    for a, b in (("rn", "m"), ("vv", "w"), ("0", "o"), ("1", "l"), ("i", "l")):
        d = d.replace(a, b)
    return d


def edit_distance(a: str, b: str) -> int:
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def lookalike_of(domain: str) -> str | None:
    if domain in KNOWN_DOMAINS:
        return None
    for known in KNOWN_DOMAINS:
        if skeleton(domain) == skeleton(known) or edit_distance(domain, known) <= 2:
            return known
    return None


class LinkParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links, self._href, self._text = [], None, ""

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            self._href, self._text = dict(attrs).get("href", ""), ""

    def handle_data(self, data):
        if self._href is not None:
            self._text += data

    def handle_endtag(self, tag):
        if tag == "a" and self._href is not None:
            self.links.append((self._text.strip(), self._href))
            self._href = None


def domain_of(addr: str) -> str:
    return parseaddr(addr)[1].rpartition("@")[2].lower()


def analyze(raw: bytes) -> list[str]:
    msg = message_from_bytes(raw, policy=policy.default)
    findings = []
    from_name, _ = parseaddr(msg["From"])
    from_dom = domain_of(msg["From"])

    auth = str(msg.get("Authentication-Results", ""))
    m = re.search(r"dmarc=(\w+)", auth)
    if not m or m.group(1) != "pass":
        findings.append(f"DMARC が pass でない({m.group(1) if m else '結果なし'})")
    if msg["Reply-To"] and domain_of(msg["Reply-To"]) != from_dom:
        findings.append(f"返信先が別ドメイン({domain_of(msg['Reply-To'])})")
    if (look := lookalike_of(from_dom)):
        findings.append(f"既知のドメイン {look} に似た送信元({from_dom})")
    if from_dom not in SEEN_DOMAINS | OWN_DOMAINS:
        findings.append("初めてやり取りする送信元ドメイン")
    if from_name in VIP_NAMES and from_dom not in OWN_DOMAINS:
        findings.append(f"社内の表示名「{from_name}」を社外から名乗っている")

    body = msg.get_body(preferencelist=("html", "plain"))
    text = body.get_content() if body else ""
    parser = LinkParser()
    parser.feed(text)
    for label, href in parser.links:
        host = (urlparse(href).hostname or "").lower()
        shown = re.search(r"[\w.-]+\.[a-z]{2,}", label.lower())
        if shown and shown.group(0) != host:
            findings.append(f"リンクの表示({shown.group(0)})と実際の行き先({host})が違う")
    plain = re.sub(r"<[^>]+>", "", text)
    for name, pat in HIGH_RISK_REQUESTS.items():
        if pat.search(plain):
            findings.append(f"要確認の依頼: {name}")
    return findings


def decide(findings: list[str]) -> str:
    requests = [f for f in findings if f.startswith("要確認の依頼")]
    sender = [f for f in findings if not f.startswith("要確認の依頼")]
    if requests and sender:
        return "隔離して利用者に警告"
    if requests:
        # 送信元が正当でも、取引先のアカウント乗っ取りはありうる
        return "配信するが、依頼は登録済みの連絡先で確認してから処理"
    if sender:
        return "配信して注意表示"
    return "配信"
run.py
from mail_signals import analyze, decide

# 文面はどれも自然な日本語にしてある(LLM が書いた文面を想定した固定値)
MAILS = {
    "1. いつもの取引先からの請求書": """\
From: 佐藤 花子 <sato@supplier-a.example>
To: keiri@example.co.jp
Subject: 10月分のご請求書送付のご案内
Authentication-Results: mx.example.co.jp; spf=pass; dkim=pass; dmarc=pass header.from=supplier-a.example
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit

いつもお世話になっております。10月分の請求書を添付いたしました。
ご確認のほど、よろしくお願いいたします。
""",
    "2. 似たドメインからの口座変更依頼": """\
From: 佐藤 花子 <sato@suppIier-a.example>
Reply-To: sato.hanako@freemail.example
To: keiri@example.co.jp
Subject: 【重要】お振込先口座変更のお知らせ
Authentication-Results: mx.example.co.jp; spf=pass; dkim=pass; dmarc=pass header.from=suppiier-a.example
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 8bit

<p>いつもお世話になっております。監査対応に伴い、11月分より振込先口座を変更させていただくこととなりました。
新しい口座情報は <a href="https://files.attacker.example/acct">supplier-a.example/notice</a> よりご確認ください。</p>
""",
    "3. 本物の取引先アカウントからの口座変更依頼": """\
From: 佐藤 花子 <sato@supplier-a.example>
To: keiri@example.co.jp
Subject: お振込先口座変更のお願い
Authentication-Results: mx.example.co.jp; spf=pass; dkim=pass; dmarc=pass header.from=supplier-a.example
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit

いつもお世話になっております。社内の口座統合により、次回のお支払いから振込先を下記に変更させていただけますでしょうか。
""",
    "4. 役員を名乗る社外からのメール": """\
From: 山田 太郎 <ceo.yamada@exec-mail.example>
To: keiri@example.co.jp
Subject: 本日中のご対応のお願い
Authentication-Results: mx.example.co.jp; spf=pass; dkim=pass; dmarc=pass header.from=exec-mail.example
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit

お疲れさまです。取引先への手土産用に、至急ギフトカードを10枚ほど送金扱いで手配してもらえますか。会議中のため電話には出られません。
""",
}

for title, raw in MAILS.items():
    findings = analyze(raw.encode("utf-8"))
    print(f"■ {title} → {decide(findings)}")
    for f in findings:
        print(f"   - {f}")

実行結果です。

■ 1. いつもの取引先からの請求書 → 配信
■ 2. 似たドメインからの口座変更依頼 → 隔離して利用者に警告
   - 返信先が別ドメイン(freemail.example)
   - 既知のドメイン supplier-a.example に似た送信元(suppiier-a.example)
   - 初めてやり取りする送信元ドメイン
   - リンクの表示(supplier-a.example)と実際の行き先(files.attacker.example)が違う
   - 要確認の依頼: 支払先・口座の変更
■ 3. 本物の取引先アカウントからの口座変更依頼 → 配信するが、依頼は登録済みの連絡先で確認してから処理
   - 要確認の依頼: 支払先・口座の変更
■ 4. 役員を名乗る社外からのメール → 隔離して利用者に警告
   - 初めてやり取りする送信元ドメイン
   - 社内の表示名「山田 太郎」を社外から名乗っている
   - 要確認の依頼: ギフトカード・送金

4 通とも文面は自然ですが、判定は分かれました。

  • 2 通目は DMARC が pass です。 攻撃者は自分で取った似たドメイン(大文字の I を使った suppIier-a.example)に正しく SPF・DKIM・DMARC を設定できるので、DMARC は「なりすまし先のドメインからは送れない」ことしか保証しません。似たドメインの検知、初めての送信元、返信先とリンク先の食い違いで止まっています
  • 3 通目は送信元のシグナルがすべて正常です。 取引先のアカウントが乗っ取られていれば、このメールは本物と区別できません。依頼の種類だけを理由に、メール以外の登録済みの連絡先で確認する手順に回しています
  • 4 通目は、役員の名前を社外のアドレスから名乗っていることで止まっています。文面の「電話には出られません」は別経路の確認を妨げる典型ですが、コードはその文を見ていません

この実装で守れないこと

  • 似た文字の判定は簡易版です。 実運用では Unicode の confusables(UTS #39)のデータや、国際化ドメイン名の Punycode 表記も見る必要があります
  • キーワードで依頼を見る部分は回避できます。 「振込先」を言い換えれば素通りします。この部分は、メール側の判定よりも、業務側で「振込先の変更は登録済みの連絡先に電話で確認しない限り処理しない」と決めておくことの方が効きます
  • メール以外の経路は対象外です。 SMS、チャット、電話、偽の CAPTCHA でコマンドを貼り付けさせる手口などは、別の仕組みで備えます

防御側も LLM を使う場合の注意

Heiding らの研究では、LLM にメールの意図を判定させる検知も試され、高い精度が報告されています。文面の「不自然さ」ではなく「何をさせようとしているか」を読ませるのは、上のキーワード判定の弱点を補う使い方です。

ただし、評価は数百通規模のデータで行われたもので、検知を回避するように文面を調整してくる攻撃者に対して同じ精度が出るかは、別に確かめる必要があります。また、メール本文は第三者が書ける信頼できない入力なので、判定用の LLM にはツールを持たせず、出力を決まったラベルに丸めるなど、プロンプトインジェクションを前提にした設計 が必要です。LLM の判定は、上の決定的なシグナルに足す 1 つのシグナルとして扱うのが安全です。

見抜けなかった場合に被害を止める:フィッシング耐性のある認証

どれだけ検知を重ねても、一部のフィッシングは届きます。認証情報を盗むタイプについては、偽サイトで入力されても使えない認証にしておくのが最も確実です。

CISA は、FIDO/WebAuthn(パスキーはこの方式)や PKI ベースの認証をフィッシング耐性のある多要素認証としています。認証がサイトのドメインに結び付くので、似たドメインの偽サイトでは成立しません。文面がどれだけ自然でも効果が変わらないことが、LLM 時代の対策として重要な点です。

日本でも、2025 年に証券口座への不正アクセスと不正取引が急増しました。ワンタイムパスワードも偽サイト経由でリアルタイムに中継されて突破されうることから、日本証券業協会は 2025 年 7 月に、ログインや出金などの重要な操作でフィッシング耐性のある多要素認証を必須とするガイドライン改正案を公表しました。金融庁も 2026 年 4 月に警察庁や業界団体と共同でフィッシング対策の啓発を始め、パスキーなどの認証を利用者に勧めています。

送金や振込先の変更のように、認証を必要としない「依頼」型の被害については、上で書いた別経路での確認が同じ役割を果たします。

訓練の見直し:「見抜く」から「止まって報告する」へ

NCSC のガイダンスは、フィッシングの訓練、とくに模擬メールを使った訓練は重視されすぎていると指摘し、訓練ですべてのフィッシングを見抜けるようにはならないとしています。LLM で文面の質が上がった今、この指摘はさらに重くなります。訓練の内容は次のように変えると、文面の出来に左右されにくくなります。

教える基準を「文面の違和感」から「依頼の種類」に変える。 「日本語が不自然なら怪しい」と教えると、自然な文面は安全だという誤った基準を与えます。代わりに、「振込先の変更」「パスワードや認証コードの入力」「ギフトカードの購入」「至急で、ほかの人に確認できない状況」のような依頼は、文面がどれだけ自然でも、登録済みの連絡先で確認するまで応じない、という行動の基準を教えます。

模擬メールの文面を本物の水準に合わせる。 不自然な文面の模擬メールばかりだと、「見抜けた」という成功体験が実際の攻撃では役に立ちません。ただし NCSC は、模擬メールの訓練を始める前に人事部門と相談することを勧めています。

クリック率ではなく報告を測る。 NCSC は、クリック率だけを指標にすると報告をためらわせるとして、報告の数も測ることを勧めています。報告は手早く簡単にでき、報告した人に結果を返す仕組みにします。

クリックした人を責めない。 罰を恐れる人は失敗を報告しなくなります。NCSC は、利用者を責めても、忙しさやストレスといったクリックの原因は変わらないとしています。クリックした直後に報告してもらえれば、パスワードの変更やセッションの無効化が間に合います。

NCSC は代わりの訓練として、社員自身にフィッシングメールを作らせる方法も挙げています。手口を作る側から理解できるので、「どこを見て判断するか」を文面以外に向けるきっかけになります。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?