チームメンバーがChatGPTで生成した変更ログの一節を、あなたのPR説明欄に貼り付ける。英語は問題なく読める。差分もきれいに見える。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+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/PDI | LTR/RTL混在段落、アラビア語/ヘブライ語/ペルシア語テキスト | Trojan-Source (CVE-2021-42574) — ソースコードの視覚順序を改ざん |
| タグ文字 (Tag characters) | U+E0000–U+E007F | 元々プレーンテキスト内の言語タグ付け用に予約(RFC 5198で事実上非推奨化)、絵文字フラグシーケンスでのみ存続 | ステガノグラフィチャネル、ChatGPTその他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+115F / U+1160 ハングル初声/中声フィラー | ハイフネーション位置の提案、書記素クラスター制御 | Word/Docs/Slackからのプレーンテキストペーストで生き残る、部分文字列検索とトークン化を破壊 |
不可視ではないコードポイントも悪意を持ちうることに注意してください。同形異義語攻撃は、キリル文字の а (U+0430) をラテン文字の a (U+0061) に置き換え、ほとんどのフォントサイズで見分けがつきません。これは別問題であり(Unicode Technical Report 36の混同可能文字検出で扱われます)、本ツールの対象外です。検出ツールは、グリフを生成しないコードポイント、または他の文字のレンダリングに副作用しか及ぼさないコードポイントに厳密に限定しています。
なぜ重要か——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 ⮜ */ }
ほとんどのエディタは数ヶ月以内にこの問題にパッチを当てました。VS Codeは警告バーを表示し、rustcは text_direction_codepoint_in_literal を出力します。しかしパッチが対象とするのはエディタとコンパイラであり、ツールチェーンの残りの部分を流れるドキュメント、README、Markdownファイル、設定ファイル、JSON blob、シェルスニペットは対象外です。JSON設定やYAMLリリースマニフェストに隠された双方向制御文字は、依然としてレビュアーのほとんどに見えないままです。
検出側の修正は機械的です。双方向ブロック全体は明確に定義されており、これを取り除けば視覚順序がバイト順序と一致する文字列が得られます。自分で作成していない受信テキストに対してこのツールを実行すれば、双方向文字のカウントによってさらに詳しく調べるべきかが分かります。
タグ文字によるAIウォーターマーク
UnicodeタグブロックU+E0000–U+E007Fは、元々1998年にプレーンテキストでの言語タグ付け用に提案されました。RFC 5198 によって汎用メカニズムとしては非推奨化され、公式には絵文字の地域フラグシーケンス(スコットランドの 🏴 などで使われる地域インジケーターパターン)の内部でのみ存続しています。ブロックの残りは未割り当ての不可視空間——存在し、何にもレンダリングされず、周囲のテキストと相互作用しないコードポイント——です。
このため、このブロックはほぼ完璧なステガノグラフィチャネルとなります。各ASCIIバイトはコードポイントに U+E0000 を加算することでエンコードでき、印字可能ASCIIに1対1で対応する128個の不可視グリフが得られます。32バイトのペイロード(UUID、HMAC、フィンガープリント)は32個の不可視タグ文字にエンコードされ、通常の文章に紛れ込ませることができます。
2024年から2025年にかけて、複数の独立したレポート(特に Joseph Thackerの分析 およびRiley Goodsideによる後続調査)が、ChatGPTに帰属する応答を含むLLM出力に、意図的に埋め込まれたウォーターマークと区別できないタグ文字シーケンスが含まれていることを記録しています。それをモデル自身が追加したのか、ラッピングシステムプロンプトが追加したのか、上流プロバイダが後付けで注入したのかは、帰属させるのが難しい場合があります。しかしメカニズムは同じです。Markdown、メール、Slack、GitHub、PDFへのコピーペーストを生き残る不可視バイトです。
AIアシストで書かれた文章を自分の名前で公開する場合や、AI生成コードをリポジトリに受け入れる場合は、公開ボタンを押す前にタグ文字の有無を知りたいはずです。検出ツールはU+E0000–U+E007F範囲全体をフラグし、単純なオフセットスキームでエンコードされたASCIIペイロードがあればデコードし、単一モードで連続部分を除去します。
コピーペースト汚染
不可視文字の最も一般的——そして最も退屈な——発生源はリッチテキストエディタです。Microsoft Wordは両端揃え改行位置にソフトハイフンを挿入します。Google Docsは句読点に接するイタリック実行の周りにZWJを挿入します。Slackは @mentions の内部やコードスパンの周りにU+200Bを挿入し、レンダラの自動リンク化を防ぎます。Notionは多言語混在の見出しを貼り付けるとRLMマーカーをラウンドトリップさせます。メールクライアントはquoted-printableのアーティファクトを行折り返し用のソフトハイフンとして隠します。翻訳メモリツールはレイアウト固定のためにスクリプト境界ごとにRLM/LRMを挿入します。
そのテキストがリッチテキスト環境を離れ、プレーンテキストの宛先——データベースの varchar、YAMLファイル、Markdown投稿、コードコメント、HTTPヘッダー——に到達すると、不可視文字も一緒に付いて来て静かに物事を壊します:
- 部分文字列検索が一致しなくなる:
"production"は"pr\u200bduction"と等しくありません。 - 視覚的に同一の2つの文字列が異なるダイジェストを生成するため、ハッシュおよび署名チェックが断続的に失敗します。
- URLパーサーはZWSPが埋め込まれたホストを拒否しますが、テンプレートエンジンはそれらを喜んでレンダリングするため、見た目は正しいのにクリックすると404になるmailto/httpリンクが生まれます。
- ソースバイトが可視文字より長いため、コンパイラエラーが誤った列番号を指します。
- ソフトハイフンの追加・削除に対して、差分ツールが「変更なし」と表示します。
Polyfill.ioの2024年のサプライチェーン事件と、それ以前のnpmタイポスクワッティング事例のいくつかは、パッケージメタデータに混同可能文字と不可視文字を組み合わせてカジュアルなレビューを回避しました。Trojan-Source論文 は package.json の name フィールドやGitコミットメッセージにおける類似の手法を列挙しています。教訓はサプライチェーンに限った話ではありません。リッチテキストソースからセキュリティ関連のコンテキストにテキストが流れるあらゆる場所で、実際に何が含まれているかを見る手段が必要だということです。
検出と除去の方法——ワークフロー
ゼロ幅文字検出ツール を開きます。ページは1画面構成です。入力用のテキストエリア、各不可視文字にラベル付きピルを重ねた注釈付きレンダリング、カテゴリ別カウントの要約テーブル、除去モードセレクタ。
任意のテキストを貼り付けます。検出は同期的に動作し、キー入力のたびに実行されます。有用な情報が4つ表示されます:
- 総カウント — 発見された不可視コードポイントの総数、カテゴリ別内訳付き。クリーンなドキュメントはゼロを報告します。
- 文字単位の注釈 — 各不可視コードポイントは、Unicode名とコードポイントを付してインラインでハイライトされます。ホバーで完全な説明とバイトオフセットが表示されます。
- コードポイント頻度 — どの特定コードポイントが最も多く出現するか。U+200Bが200個、それ以外何もないドキュメントはWordペーストです。32個のタグ文字が連続するドキュメントはおそらくウォーターマークです。
- クリーニング後の出力 — 選択カテゴリを除去した同じテキスト、コピー可能な状態。
除去モードセレクタには4つのポジションがあり、最も一般的な4つのクリーンアップ意図に対応します:
- すべて (All) — カテゴリにかかわらずすべての不可視コードポイントを除去します。ソースがプレーンテキストで、これらの文字が存在する正当な理由がない場合に使います。ほとんどのコード、設定ファイル、JSON、YAML、ログ行はこのカテゴリに該当します。
- ゼロ幅のみ (Zero-width only) — ZWSP、ZWNJ、ZWJ、WJ、BOM、ハングルフィラー、MVS、不可視数学記号を除去します。双方向制御文字(RTLテキストで正当に必要となる場合があるため)と異体字セレクタ(絵文字表現が依存するため)は保持します。レイアウト意図を保持したい多言語混在テキストのクリーンアップに使います。
- 双方向のみ (Bidi only) — 双方向ブロックのみを除去します。ソースコード、設定ファイル、視覚順序がバイト順序と一致する必要があるあらゆる場所に使います。絵文字やデーヴァナーガリー内の正当なZWJシーケンスは保持されます。
- タグのみ (Tag only) — U+E0000–U+E007F範囲を除去します。AI生成テキストで疑わしいカテゴリがウォーターマーク面のみの場合に使います。それ以外は保持します。
- 異体字のみ (Variation only) — U+FE00–U+FE0FおよびU+E0100–U+E01EFを除去します。デザインツール(Figma、Sketch、Illustrator)からエクスポートした際に、プレーンなグリフであるべきコピーに絵文字異体字セレクタが挿入される場合に有用です。
モードを選択するとクリーニング後出力がその場で更新されます。ボタンでコピーするか、バイナリ的にクリーンな転送用に .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ステップを失敗させたい場合——、PCRE対応の grep はmacOS(Homebrew経由)およびGNU grep 3.4以降で動作します:
# 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 using sed (BSD/GNU portable form below)
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のメモ帳は作成するすべてのテキストファイルの先頭にUTF-8 BOM (U+FEFF) を書き込みます。一部のMicrosoftツール——特に古いExcel——はBOMなしのUTF-8 CSVを読み込もうとしません。cmd.exe から実行するPowerShellスクリプトも同様に、アクティブコードページではなくUTF-8として扱うためにBOMを期待します。テキストがクリップボードではなくファイル由来の場合、先頭BOMを除去する前に、それが意味を持つかを明示的に判断してください。検出ツールはBOMを位置にかかわらずゼロ幅コードポイントとして報告します。そのレポートが警告かアーティファクトかはあなたが判断します。
ソフトハイフンはリッチテキストでは正常です。 U+00ADはレンダリングエンジンにハイフネーション位置を提案する推奨方法です。組版されたPDFやEPUBブックには正当に数百個含まれることがあります。ソフトハイフンを除去するのは、ターゲットがプレーンテキスト——コード、設定、データベースフィールド、ログ行——の場合に限ってください。組版ドキュメント内では、これらを除去するとセキュリティ上の利益なしに行分割品質が低下します。
タグ文字は常にウォーターマークというわけではありません。 U+E0000–U+E007F範囲には今でも1つの公式用途があります:絵文字の地域フラグシーケンスです。ウェールズの旗 🏴 は黒旗 (U+1F3F4)、タグでエンコードされたISO地域コード gbwls、CANCEL TAG (U+E007F) 終端子で構成されています。タグブロック全体を除去するとこれらのフラグが削除されます。Wikipediaおよび一部のUnicodeデモンストレーションも、歴史的な例の一部として今でもタグ文字を使用しています。分類前に連続部分を検査してください——フラグベースとU+E007Fの間の連続タグ文字はフラグであり、通常の文章内の自由浮動クラスターがウォーターマーク面です。
クライアントサイドのクリーンアップは上流を修正しません。 不可視文字のソースがCMS、翻訳メモリ、LLM APIである場合、ブラウザで除去しても、たまたま手元にあるコピーをクリーニングするだけです。同じソースからの次のコピーには同じ問題があります。検出ツールはフィルタではなく顕微鏡として扱ってください——ソースに関する仮説を確認するために使い、実際の除去ステップは制御できる境界(Webhook、CIステップ、pre-commitフック、上記実装のいずれかを使用するサーバーサイド正規化ルーチン)に置いてください。
言及に値する6つ目の落とし穴:バイト長は文字長ではなく、可視幅でもありません。可視文字50個と不可視文字80個の文字列は、JavaScriptでは String.length が130、Pythonでは len() が130ですが、ターミナルでの wcswidth は50です。ハッシュ関数、Content-Lengthヘッダー、データベースの VARCHAR(N) 制限、認証署名はすべて130を見ます。可視幅で正規化された文字列をバイト数で保存して比較すると、本来別であるべき入力で偽の等価性が、人間が同一と呼ぶ入力で偽の非等価性が得られます。Unicode正規化 のNFC / NFKC正規化はいくつかのケース(結合マーク、互換分解)を扱いますが、不可視文字は除去しません……
他の検出ツールとの比較
公開Web上には不可視文字ビューアがいくつかあります。カテゴリカバレッジ、プライバシー、ワークフロー形状が異なります。
invisiblecharacterviewer.com は標準リファレンスです:最小限のUI、各不可視コードポイントをラベル付きピルで表示、英語のみ。カバレッジはゼロ幅と双方向に焦点を当て、タグ文字と異体字セレクタは別カテゴリとして分類されません。処理はクライアントサイドです。単一文字の発見には適していますが、除去ステップが欲しい場合にはあまり有用ではありません。
toolszone.net/invisible-character-detector はタグ文字を含むより広範なカテゴリリストを提供しますが、カテゴリ別除去モードがありません——検出のみです。出力はカウントとインラインハイライトで、クリーニング済みテキストのエクスポートはありません。サイトはすべてのページでサードパーティ分析を読み込みます。
unicode-table.com/en/blocks/tags/ は特にタグ文字向けのUnicodeブロックリファレンスです。これは検出ツールではなくドキュメントツールです——コードポイントを持参すると、それが何かを教えてくれます。検出ツールの出力を読む際の相互参照として有用です。
Diffchecker および類似の差分ツールは、2つの文字列を比較する際に不可視文字を特殊記号で表示しますが、分類や除去はしません。「何が変わったか」には答えますが、「何が隠れているか」には答えません。
ZeroToolの検出ツールは、他の単一ツールでは提供されない4つのプロパティを組み合わせることで自らを位置づけています:5つのカテゴリすべて(タグ文字と異体字セレクタを含む)の完全なカバレッジ、最も一般的な4つのクリーンアップ意図に対応する4つの明示的な除去モード、テレメトリなしの完全クライアントサイド処理、英語・中国語・日本語・韓国語でレンダリングされるUI。特に除去モードは、「このテキストをクリーンアップする」が「不可視のものをすべて除去する」を意味することはまれである——絵文字、RTLテキスト、意図的な書式設定はすべて外科的な保持が必要——という運用現実を反映しています。
関連資料
内部:
- Unicodeテキストコンバーター — 任意の文字列をコードポイント、名前、バイトとして検査する。テスト用に不可視文字を意図的に構築する必要がある場合の逆方向。
- 文字列エスケープ —
\uXXXX、\xXX、\nnn、パーセントエンコードシーケンスをC、JavaScript、Python、JSON、URLの規約全体でエンコード・デコードする。不可視文字クリーンアップを完全なテキストエンコーディング可視性で補完。
外部:
- Trojan Source: Invisible Vulnerabilities — CVE-2021-42574とCVE-2021-42694を記述したBoucher & Anderson論文。攻撃テンプレートと緩和策ガイダンス付き。
- Unicode Technical Report 9 — Unicode Bidirectional Algorithm — Trojan-Sourceが悪用する埋め込みおよび隔離演算子を含む、双方向制御文字の正準仕様。
- Unicode Technical Report 36 — Unicode Security Considerations — 不可視文字、混同可能文字、識別子なりすましに対する標準自身の脅威モデル。
- Joseph Thacker — Hiding and Finding Text with Unicode Tags — LLM出力から取られた例とともに、タグブロックにASCIIペイロードをエンコードする実践的なウォークスルー。
- RFC 5198 — Unicode Format for Network Interchange — 一般テキスト向けU+E0001 LANGUAGE TAGおよびタグブロック残部を事実上非推奨化し、攻撃者とウォーターマーカーが現在使用する未割り当て不可視空間としてブロックを残したIETFガイダンス。