仮の例で考えてみます。チームメンバーが変更ログの一節を、あなたのPR説明欄に貼り付ける。その文章は、第三者のWebページをAIチャットに要約させて得たものだ。英語は問題なく読める。差分もきれいに見える。CIもグリーンだ。2週間後、セキュリティレポートがその箇条書きの1つを指摘し、なぜリリースノートにU+E0000領域(Unicodeタグブロック)の41文字の不可視文字列が含まれているのかを問いただす。チームの誰も入力していない。レンダリングされたMarkdownでは誰にも見えない。それはコピーペーストに紛れ込み、エディタを通り抜け、リンターを通り抜け、レビュアーの目を通り抜け、静的サイトジェネレータを通り抜け、顧客に出荷したアーティファクトのチェックサムにまで紛れ込んだ。同じ週、別の監査では、誰かがリッチテキストエディタから翻訳済み文字列を貼り付けたために、エラーメッセージの1つがフラグされる……

見えない文字検出ツールを試す →

これらは特殊な問題ではありません。AIアシスタント、多言語コンテンツ、リッチテキストソースを扱う日常業務で発生する出力そのものです。ZeroToolの検出ツールは、貼り付けられたあらゆるテキストを受け取り、どのコードポイントが不可視か、どのカテゴリに属するか、クリーニング後の文字列がどうなるかを正確に教えてくれます。すべての処理はブラウザ内で完結し、何もアップロードされません。

「不可視」の定義

Unicode標準は、ゼロ幅でレンダリングされる文字、またはグリフを生成せずレンダリングに副作用を及ぼす文字を意図的に定義しています。これらにはアラビア文字の整形、デーヴァナーガリーのリガチャ、ソフト改行ヒント、絵文字のZWJシーケンス、ファイルエンコーディングマーカーといった正当な用途があり、問題となるのは作者が意図しない境界——プレーンテキストエクスポート、ソースコード、ASCIIを期待するデータベースカラム——を越えたときだけです。

検出ツールは不可視コードポイントを5つのカテゴリに分類します。各カテゴリには固有の攻撃面と正当な用途があります。

カテゴリコードポイント正当な用途混入時のリスク
ゼロ幅 (Zero-width)U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WJ, U+FEFF BOM/ZWNBSP, U+3164 ハングルフィラー, U+115F / U+1160 / U+FFA0 ハングルフィラー, U+180E MVS, U+2061–U+2064 不可視数学記号ソフトラッピング、アラビア文字/インド系文字の整形、絵文字ZWJシーケンス、ファイルBOM識別子の衝突、ウォーターマーク、パーサのずれ、フィンガープリンティング
双方向 (Bidirectional)U+200E LRM, U+200F RLM, U+202A–U+202E LRE/RLE/PDF/LRO/RLO, U+2066–U+2069 LRI/RLI/FSI/PDILTR/RTL混在段落、アラビア語/ヘブライ語/ペルシア語テキストTrojan-Source (CVE-2021-42574) — ソースコードの視覚順序を改ざん
タグ文字 (Tag characters)U+E0000–U+E007F元々プレーンテキスト内の言語タグ付け用(UnicodeはU+E0001 LANGUAGE TAGを非推奨とし、タグ文字で言語タグを表すことを強く非推奨としている)、現在は絵文字の地域フラグシーケンスで使用ASCIIテキストの隠蔽(ASCII smuggling):人には見えないがLLMは読めるプロンプトインジェクション指示
異体字セレクタ (Variation selectors)U+FE00–U+FE0F (VS1–VS16), U+E0100–U+E01EF (VS17–VS256)絵文字のグリフ異体(テキスト表現vs絵文字表現)選択、CJK漢字異体字選択文字列比較を破壊、バイト長を肥大化
書式 (Formatting)U+00AD SOFT HYPHEN, U+034F COMBINING GRAPHEME JOINER, U+206A–U+206F 非推奨の書式制御文字ハイフネーション位置の提案、書記素クラスター制御プレーンテキストに貼り付けても残る、部分文字列検索とトークン化を破壊

5 つのカテゴリを合わせると、Unicode 18.0 が DerivedCoreProperties.txt で Default_Ignorable_Code_Point としている 4,174 個のコードポイントになります。不可視ではないコードポイントも悪意を持ちうることに注意してください。同形異義語攻撃は、キリル文字の а (U+0430) をラテン文字の a (U+0061) に置き換え、ほとんどのフォントサイズで見分けがつきません。これは別問題であり(混同可能文字の検出。仕様は UTS #39)、本ツールの対象外です。検出ツールは、グリフを生成しないコードポイント、または他の文字のレンダリングに副作用しか及ぼさないコードポイントに厳密に限定しています。

なぜ重要か——3つの実世界の脅威

Trojan-Source (CVE-2021-42574)

2021年11月、ケンブリッジ大学のNicholas BoucherとRoss Andersonは Trojan Source を発表し、当時ほぼすべてのコンパイラ、IDE、コードレビューツールが、ソースコード内のコメントや文字列リテラルの中であっても、双方向Unicode制御文字を Unicode双方向アルゴリズム に従ってレンダリングしていることを実証しました。RLI、LRI、PDI、RLO制御文字を挿入することで、攻撃者はコンパイラが見るバイトとレビュアーが見るグリフが異なるソースファイルを作成できます。

典型的な例では、コメントを並べ替えることで return 文が /* ... */ の内側にあるように見えるが、コンパイラはそれをライブコードとして読み取ります:

// JavaScript example, with U+202E (RLO) visualised as ⮜
const isAdmin = false;
/* Check if user is admin ⮜  begin admins only⁦‭ */
if (isAdmin) {
    console.log("You are an admin.");
/* end admins only ⮜  ⁩‭*/ }

コンパイラ側にはチェックが加わりました。Rust 1.56.1 はデフォルトでエラーになる lint、text_direction_codepoint_in_literal と text_direction_codepoint_in_comment を追加しています(Rust のセキュリティアドバイザリ)。しかしこうしたチェックが対象とするのはエディタとコンパイラであり、ツールチェーンの残りの部分を流れるドキュメント、README、Markdownファイル、設定ファイル、JSON blob、シェルスニペットは対象外です。JSON設定やYAMLリリースマニフェストに隠された双方向制御文字は、依然としてレビュアーのほとんどに見えないままです。

検出側の修正は機械的です。双方向ブロック全体は明確に定義されており、これを取り除けば視覚順序がバイト順序と一致する文字列が得られます。自分で作成していない受信テキストに対してこのツールを実行すれば、双方向文字のカウントによってさらに詳しく調べるべきかが分かります。

タグ文字によるASCIIテキストの密輸

UnicodeタグブロックU+E0000–U+E007Fは、プレーンテキストでの言語タグ付け用に設計されました(RFC 2482、1999年)。このブロックのUnicodeコード表は現在、U+E0001 LANGUAGE TAGを非推奨とし、タグ文字で言語タグを表すことを強く非推奨としています。ブロックは絵文字の地域フラグシーケンス(スコットランドの 🏴󠁧󠁢󠁳󠁣󠁴󠁿 などで使われるタグシーケンス)で使われています。それ以外のタグ文字は不可視空間——割り当て済みで、何もレンダリングされず、周囲のテキストと相互作用しないコードポイント——です。

このため、このブロックはほぼ完璧なステガノグラフィチャネルとなります。各ASCIIバイトはコードポイントに U+E0000 を加算することでエンコードでき、U+E0020–U+E007E が印字可能な ASCII 95 文字に 1 対 1 で対応します。32 文字のトークンは 32 個の不可視タグ文字にエンコードされ、通常の文章に紛れ込ませることができます。

2024年1月、Riley Goodsideは、タグ文字で書かれ貼り付けテキストに隠された指示がChatGPTへのプロンプトインジェクションになることを示しました。続いてJohann Rehbergerが、このペイロードをエンコード・デコードするツール ASCII Smuggler を公開し、LLMが回答の中でタグ文字を出力できることも示しました。隠しテキストはチャットに入るだけでなく、チャットから出ることもあるということです。彼はこの手法を、LLM向けの指示を隠し、データを堂々と密輸する方法として説明し、LLMアプリケーションはプロンプトと応答の両方でタグ文字を除去すべきだと勧めています。

タグ文字がAIのウォーターマークであることを示す公開情報はありません。OpenAIはChatGPTの出力をタグ文字でマークしているとは公表していません。2025年4月に報告されたo3とo4-miniの出力に含まれる特殊文字はU+202F NARROW NO-BREAK SPACEで、タグ文字ではありません。報告した Rumi は後に、OpenAIから「これはウォーターマークではなく、a quirk of large-scale reinforcement learning(大規模強化学習の副作用)だ」と伝えられたと追記しています。文字だけを見て、テキストがAIによって書かれたかどうかを判定できる検出ツールはありません。

Webページの内容、文書、チャット出力をプロンプト、リポジトリ、公開物に貼り付けるなら、先にタグ文字の有無を知りたいはずです。検出ツールはU+E0000–U+E007F範囲全体(イングランド・スコットランド・ウェールズの旗の中を除く)をフラグし、各タグ文字が表すASCII文字を表示し、単一モードで連続部分を除去します。

コピーペースト汚染

よくある発生源の一つは、普通のコピー&ペーストです。2024 年 8 月の Qiita 記事では、PowerPoint の行を VS Code に貼ると各改行の前に U+200B が入り込み、Shift_JIS で保存すると各行の末尾が「?」になりました。Shift_JIS にはゼロ幅スペースがないためです。

そのテキストがリッチテキスト環境を離れ、プレーンテキストの宛先——データベースの varchar、YAMLファイル、Markdown投稿、コードコメント、HTTPヘッダー——に到達すると、不可視文字も一緒に付いて来て静かに物事を壊します:

  • 部分文字列検索が一致しなくなる:"production" は "pr\u200bduction" と等しくありません。
  • 視覚的に同一の2つの文字列が異なるダイジェストを生成するため、ハッシュおよび署名チェックが断続的に失敗します。
  • ソースバイトが可視文字より長いため、コンパイラエラーが誤った列番号を指します。

教訓は一般的なものです。リッチテキストソースからセキュリティ関連のコンテキストにテキストが流れるあらゆる場所で、実際に何が含まれているかを見る手段が必要です。

検出と除去の方法——ワークフロー

見えない文字検出ツール を開き、入力欄にテキストを貼り付けます。入力欄の下の行に見つかった数が表示され、その横に「除去後のテキストをコピー」ボタンがあります。その下に、各不可視文字をラベル付きのチップで示す注釈付き表示、カテゴリ別の件数を示す統計欄、除去モードセレクタが並びます。

任意のテキストを貼り付けます。検出は同期的に動作し、キー入力のたびに実行されます。有用な情報が4つ表示されます:

  1. 総カウント — 発見された不可視コードポイントの総数、カテゴリ別内訳付き。クリーンなドキュメントはゼロを報告します。
  2. 文字単位の注釈 — 各不可視コードポイントは、その位置に ZWSP、RLO、TAG-h のような略称のチップとして表示されます。チップにマウスを重ねるとコードポイントと正式名が出ます。
  3. カテゴリ別件数 — 統計欄はカテゴリごとの件数と、可視文字数・コードポイント総数を示します。タグ文字が長く連続していれば隠された ASCII テキストの可能性が高く、先頭に BOM が 1 つだけならたいていはファイルのエンコーディング標識です。
  4. クリーニング後の出力 — 選択カテゴリを除去した同じテキスト、コピー可能な状態。

除去モードセレクタには 5 つのポジションがあります:

  • すべて (All) — カテゴリにかかわらずすべての不可視コードポイントを除去します。ソースがプレーンテキストで、これらの文字が存在する正当な理由がない場合に使います。ほとんどのコード、設定ファイル、JSON、YAML、ログ行はこのカテゴリに該当します。
  • ゼロ幅のみ (Zero-width only) — ZWSP、ZWNJ、ZWJ、WJ、BOM、ハングルフィラー、MVS、不可視数学記号を除去します。双方向制御文字(RTLテキストで正当に必要となる場合があるため)と異体字セレクタ(絵文字表現が依存するため)は保持します。レイアウト意図を保持したい多言語混在テキストのクリーンアップに使います。
  • 双方向のみ (Bidi only) — 双方向ブロックのみを除去します。ソースコード、設定ファイル、視覚順序がバイト順序と一致する必要があるあらゆる場所に使います。絵文字やデーヴァナーガリー内の正当なZWJシーケンスは保持されます。
  • タグのみ (Tag only) — U+E0000–U+E007F範囲を除去します(上記 3 つの旗は残ります)。Webページやチャットからコピーしたテキストで、疑わしいカテゴリが隠しタグテキストのみの場合に使います。それ以外は保持します。
  • 異体字のみ (Variation only) — U+FE00–U+FE0F、U+E0100–U+E01EF、モンゴル文字の自由異体字セレクタを除去します。絵文字をテキスト表示に戻したいときや、見えないセレクタのせいで文字列比較が失敗するときに使います。

モードを選択するとクリーニング後出力がその場で更新されます。ボタンでコピーするか、バイナリ的にクリーンな転送用に .txt としてダウンロードします。

ツールを使わずに検出・除去する

このツールが存在するのは、クリックがスクリプトより速いからです。しかし基礎となる検出処理は、どの言語でも正規表現で簡単に書けます。以下はCIステップ、pre-commitフック、または受信ユーザーコンテンツを監査するスクリプトに組み込める3つのリファレンス実装です。

Python版は標準ライブラリのみを使い、カテゴリ別カウントとクリーニング済み文字列を出力します。python detect_invisible.py < input.txt として実行します:

import re
import sys
import unicodedata

CATEGORIES = {
    "zero-width": r"[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]",
    "bidi":       r"[\u200E\u200F\u202A-\u202E\u2066-\u2069]",
    "tag":        r"[\U000E0000-\U000E007F]",
    "variation":  r"[\uFE00-\uFE0F\U000E0100-\U000E01EF]",
    "formatting": r"[\u00AD\u034F\u115F\u1160]",
}

def scan(text: str) -> dict[str, list[tuple[int, str, str]]]:
    findings: dict[str, list[tuple[int, str, str]]] = {k: [] for k in CATEGORIES}
    for name, pattern in CATEGORIES.items():
        for match in re.finditer(pattern, text):
            cp = match.group(0)
            findings[name].append((
                match.start(),
                f"U+{ord(cp):04X}",
                unicodedata.name(cp, "<unknown>"),
            ))
    return findings

def strip_all(text: str) -> str:
    combined = "|".join(p.strip("[]") for p in CATEGORIES.values())
    return re.sub(f"[{combined}]", "", text)

if __name__ == "__main__":
    src = sys.stdin.read()
    report = scan(src)
    total = sum(len(v) for v in report.values())
    print(f"invisible code points: {total}")
    for cat, hits in report.items():
        if hits:
            print(f"  {cat}: {len(hits)}")
            for offset, cp, name in hits[:5]:
                print(f"    @{offset} {cp} {name}")
    sys.stdout.write(strip_all(src))

JavaScript / TypeScript版はNode 20+とブラウザを対象とします。同じ正規表現が動作します。違いは、JSソースファイルでは u フラグとサロゲートペア対応構文がU+FFFFを超えるコードポイントに必要なことだけです:

const CATEGORIES = {
  "zero-width": /[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]/gu,
  "bidi":       /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu,
  "tag":        /[\u{E0000}-\u{E007F}]/gu,
  "variation":  /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/gu,
  "formatting": /[\u00AD\u034F\u115F\u1160]/gu,
};

const ALL = new RegExp(
  Object.values(CATEGORIES).map(r => r.source).join("|"),
  "gu"
);

export function detectInvisible(text) {
  const findings = {};
  for (const [name, re] of Object.entries(CATEGORIES)) {
    findings[name] = [...text.matchAll(re)].map(m => ({
      offset: m.index,
      codePoint: "U+" + m[0].codePointAt(0).toString(16).toUpperCase().padStart(4, "0"),
    }));
  }
  return findings;
}

export function stripInvisible(text) {
  return text.replace(ALL, "");
}

Bashで一行のガードが欲しい場合——たとえばMarkdown投稿に含まれるタグ文字でCIステップを失敗させたい場合——、UTF-8 ロケールで PCRE 対応の GNU grep を使います(macOS では Homebrew で入れます):

# Fail if any tag character (U+E0000–U+E007F) appears
if grep -P '[\x{E0000}-\x{E007F}]' "$file" >/dev/null; then
  echo "tag characters detected in $file" >&2
  exit 1
fi

# Strip every category in place with perl
perl -CSDA -i -pe '
  s/[\x{200B}-\x{200D}\x{2060}-\x{2064}\x{FEFF}\x{180E}\x{3164}]//g;
  s/[\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]//g;
  s/[\x{E0000}-\x{E007F}]//g;
  s/[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]//g;
  s/[\x{00AD}\x{034F}\x{115F}\x{1160}]//g;
' "$file"

perl -CSDA はSTDIN、STDOUT、@ARGV に対してUTF-8を有効化します。これはコマンドラインでPerlがマルチバイト入力を壊すのを防ぐ移植性のある方法です。同じスクリプトは追加依存なしでGit pre-commitフック、GitHub Actions、Vercelビルドステップ内で動作します。

落とし穴

大規模に不可視文字クリーンアップを実行する際に念頭に置くべき5つの境界事例:

絵文字ZWJシーケンスは正当なZWJです。 家族絵文字 👨‍👩‍👧‍👦 は MAN U+200D WOMAN U+200D GIRL U+200D BOY——4つの基本絵文字を3つのゼロ幅結合子で接着したもの——としてエンコードされています。絵文字を含む文字列からZWJを除去すると、それは横並びにレンダリングされた4つの別々の絵文字に変わります。🏳️‍🌈(白旗 + ZWJ + 虹)や職業・髪型の絵文字シーケンスも同様です。検出ツールが絵文字内のZWJをフラグするのは、「意図的なシーケンス」と「密輸されたバイト」を区別する手段がないからです——視覚的にはどちらも自身のグリフを生成しません。絵文字を保持したいテキストをクリーニングする際は 双方向のみ または タグのみ を使うか、リファレンスリストから正規絵文字シーケンスを再適用して後処理してください。

ファイルBOMは意図的な場合があります。 Windows PowerShell 5.1 は BOM のないスクリプトを旧来の「ANSI」コードページで読むため、Microsoft は非 ASCII 文字を含むスクリプトを BOM 付き UTF-8 で保存するよう勧めています。テキストがクリップボードではなくファイル由来の場合、先頭BOMを除去する前に、それが意味を持つかを明示的に判断してください。検出ツールはBOMを位置にかかわらずゼロ幅コードポイントとして報告します。そのレポートが警告かアーティファクトかはあなたが判断します。

ソフトハイフンはリッチテキストでは正常です。 U+00ADはレンダリングエンジンにハイフネーション位置を提案する推奨方法です。組版されたPDFやEPUBブックには正当に数百個含まれることがあります。ソフトハイフンを除去するのは、ターゲットがプレーンテキスト——コード、設定、データベースフィールド、ログ行——の場合に限ってください。組版ドキュメント内では、これらを除去するとセキュリティ上の利益なしに行分割品質が低下します。

タグ文字は常に隠しテキストというわけではありません。 U+E0000–U+E007F範囲には今でも1つの公式用途があります:絵文字の地域フラグシーケンスです。ウェールズの旗 🏴󠁧󠁢󠁷󠁬󠁳󠁿 は黒旗 (U+1F3F4)、タグでエンコードされたISO地域コード gbwls、CANCEL TAG (U+E007F) 終端子で構成されています。タグブロック全体を除去するとこれらのフラグが削除されます。ただし 🏴 と U+E007F の間にあるタグ文字が旗だとは限りません。そこには誰でも任意のタグテキストを入れられます。一般的な交換に推奨されるのは 3 つだけです(emoji-sequences.txt の RGI_Emoji_Tag_Sequence、Emoji 18.0):イングランド gbeng、スコットランド gbsct、ウェールズ gbwls。検出ツールはどのモードでもこの 3 つだけを残し、隠しテキストを包んだ偽の旗を含め、それ以外のタグ文字はすべてフラグします。

クライアントサイドのクリーンアップは上流を修正しません。 不可視文字のソースがCMS、翻訳メモリ、LLM APIである場合、ブラウザで除去しても、たまたま手元にあるコピーをクリーニングするだけです。同じソースからの次のコピーには同じ問題があります。検出ツールはフィルタではなく顕微鏡として扱ってください——ソースに関する仮説を確認するために使い、実際の除去ステップは制御できる境界(Webhook、CIステップ、pre-commitフック、上記実装のいずれかを使用するサーバーサイド正規化ルーチン)に置いてください。

言及に値する6つ目の落とし穴:バイト長は文字長ではなく、可視幅でもありません。可視文字 50 個と、U+200B のような BMP 内の不可視文字 80 個からなる文字列は、JavaScriptでは String.length が130、Pythonでは len() が130ですが、ターミナルでの wcswidth は50です。ハッシュ関数、Content-Lengthヘッダー、データベースの VARCHAR(N) 制限、認証署名はすべて不可視文字も数に入れます。可視幅で正規化された文字列をバイト数で保存して比較すると、本来別であるべき入力で偽の等価性が、人間が同一と呼ぶ入力で偽の非等価性が得られます。Unicode正規化 のNFC / NFKC正規化はいくつかのケース(結合マーク、互換分解)を扱いますが、不可視文字は除去しません……

他の検出ツールとの比較

2026-09-29 に、このツールの「Tag 文字の隠しテキスト」サンプル(家族の絵文字と 25 個のタグ文字を含むチャットの返信)を、ほかの 2 つのツールに貼ってみました。

Invisible Character Viewer は 2 つのゼロ幅接合子とタグの各文字を正しく表示しました。「Strip Invisible Characters」を押すと Thanks for the fix 👍 Team: 👨👩👧 See you Monday. になり、隠しテキストと一緒に接合子も消えて、家族の絵文字は 3 人に分かれました。U+00A0 や U+3000 のような目に見える空白も表示しますが、このツールはそれらを対象にしていません。

ASCII Smuggler はタグ文字を 1 つの英文に復号し、Unicode タグ 25 個とその他の不可視文字 2 個を数えました。復号結果は読むための表示で、除去後のテキストは出しません。

このツールは 1 回に 1 種類だけ除去します。「Tag のみ」なら 👨‍👩‍👧 を残して隠し文だけを消します。

関連資料

内部:

  • Unicode テキスト変換 — テキストを 𝐁𝐨𝐥𝐝 のような装飾 Unicode 文字に変換します。目に見える文字なので、このツールでは検出されません。
  • 文字列エスケープ — JavaScript、JSON、HTML エンティティのエスケープと復元。JavaScript モードは U+200B を \u200b と書くので、見つけた文字をテストに入れるときに便利です。

外部: