最終更新:
見えない文字の世界 - ゼロ幅文字 / 不可視文字が引き起こすトラブル
あなたが入力した文字列は 10 文字のはずなのに、システムは 12 文字だと言い張る。目を凝らしても余計な文字は見えない。犯人は「ゼロ幅文字」- 画面上に一切表示されないのに、データとしては確かに存在する不可視の文字です。この記事では、Unicode に定義された不可視文字の種類と用途、文字数カウントへの影響、そして混入したときに何が壊れるのかという典型パターンと対処法を解説します。
不可視文字の一覧 - 見えないのに存在する文字たち
Unicode には、画面に表示されない (または幅がゼロの) 文字が複数定義されています。これらは「バグ」ではなく、テキスト処理に必要な正当な理由で存在しています。
| 文字名 | コードポイント | 用途 | 文字数カウント | 表示幅 |
|---|---|---|---|---|
| ゼロ幅スペース (ZWSP) | U+200B | 改行可能位置の指定 | 1 文字としてカウント | 0 |
| ゼロ幅接合子 (ZWJ) | U+200D | 文字の結合 (絵文字合成) | 1 文字としてカウント | 0 |
| ゼロ幅非接合子 (ZWNJ) | U+200C | 文字の結合を防止 | 1 文字としてカウント | 0 |
| 左から右マーク (LRM) | U+200E | テキスト方向の制御 | 1 文字としてカウント | 0 |
| 右から左マーク (RLM) | U+200F | テキスト方向の制御 | 1 文字としてカウント | 0 |
| バイトオーダーマーク (BOM) | U+FEFF | エンコーディングの識別 | 1 文字としてカウント (読み込み時に除去する処理系では消える) | 0 |
| ソフトハイフン (SHY) | U+00AD | ハイフネーション位置の指定 | 1 文字としてカウント | 通常 0 (改行時のみ表示) |
| ワードジョイナー (WJ) | U+2060 | 改行禁止位置の指定 | 1 文字としてカウント | 0 |
これらの文字はすべて、テキスト処理において正当な役割を持っています。問題は、意図せずテキストに混入した場合に、目に見えないまま文字数カウントを狂わせることです。
ゼロ幅スペース (U+200B) - 最も厄介な不可視文字
ゼロ幅スペース (Zero Width Space, ZWSP) は、テキスト中に「ここで改行してもよい」という情報を埋め込むための文字です。タイ語やクメール語のように単語間にスペースを入れない言語で、ブラウザが適切な位置で改行できるようにするために使われます。
しかし、ZWSP は Web ページからテキストをコピー&ペーストする際に混入しやすく、以下のようなトラブルを引き起こします。
- フォームに入力した文字列が「文字数オーバー」と判定される (見た目は制限内なのに)
- パスワードのコピペが失敗する (ZWSP が混入して別の文字列になる)
- 検索が一致しない (同じに見える文字列なのに検索でヒットしない)
- CSV ファイルのデータが正しくパースされない
- プログラムのソースコードに混入してコンパイルエラーになる
特に深刻なのはパスワードへの混入です。Web サイトからパスワードをコピーして貼り付けた際に ZWSP が紛れ込むと、見た目は正しいパスワードなのにログインできない事態が発生します。パスワードの文字数と安全性を考える上でも、不可視文字の存在は無視できません。
ゼロ幅接合子 (U+200D) - 絵文字を合成する魔法の文字
ゼロ幅接合子 (Zero Width Joiner, ZWJ) は、不可視文字の中で最もポジティブな役割を果たしている文字です。絵文字の文字数カウントで詳しく解説していますが、ZWJ は複数の絵文字を結合して新しい絵文字を作り出します。
| 表示される絵文字 | 構成要素 | コードポイント数 | 文字数カウント (JavaScript) |
|---|---|---|---|
| 👨👩👧👦 (家族) | 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 | 7 | 11 (サロゲートペア含む) |
| 👩💻 (女性テクノロジスト) | 👩 + ZWJ + 💻 | 3 | 5 |
| 🏳️🌈 (レインボーフラッグ) | 🏳️ + ZWJ + 🌈 | 4 | 6 |
| 👨🍳 (男性料理人) | 👨 + ZWJ + 🍳 | 3 | 5 |
家族の絵文字 👨👩👧👦 は、見た目は 1 つの絵文字ですが、内部的には 4 つの絵文字と 3 つの ZWJ で構成されています。JavaScript の .length プロパティでは 11 と返されます。SNS の文字数制限では、この絵文字 1 つで大量の文字数を消費する可能性があります。
方向制御文字 - 右から左に書く言語のための仕組み
アラビア語やヘブライ語は右から左 (RTL: Right-to-Left) に書く言語です。これらの言語と英語 (左から右、LTR) が混在するテキストでは、文字の表示方向を制御する不可視文字が必要になります。
U+200E (Left-to-Right Mark) と U+200F (Right-to-Left Mark) は、テキストの方向を明示的に指定するための文字です。これらが意図せず混入すると、テキストの表示順序が乱れたり、文字数カウントが狂ったりします。
2021 年には、方向制御文字を悪用したセキュリティ脆弱性「Trojan Source」が報告されました。ソースコード中に方向制御文字を埋め込むことで、人間の目には正常に見えるコードが、コンパイラには異なるロジックとして解釈される攻撃手法です。この脆弱性は、不可視文字がセキュリティ上のリスクにもなりうることを示しました。
BOM (U+FEFF) - ファイルの先頭に潜む不可視文字
バイトオーダーマーク (BOM: Byte Order Mark) は、テキストファイルのエンコーディングを識別するためにファイルの先頭に付加される文字です。UTF-8 の BOM は 3 バイト (EF BB BF) で、Windows のメモ帳で保存したファイルに付加されることがあります。
BOM は多くのプログラムで無視されますが、以下のケースで問題を引き起こします。
- PHP ファイルの先頭に BOM があると、
header()関数が動作しない (出力が既に始まったと判定される) - CSV ファイルの先頭に BOM があると、最初のカラム名が正しく認識されない
- JSON ファイルに BOM があると、パーサーがエラーを返す場合がある
- シェルスクリプトの先頭に BOM があると、シバン (
#!/bin/bash) が認識されない
ゼロ幅文字を使ったステガノグラフィ (透かし技術)
不可視文字の「見えない」という特性を逆手に取った技術がステガノグラフィ (電子透かし) です。テキスト中にゼロ幅文字のパターンを埋め込むことで、見た目を変えずに隠し情報を仕込むことができます。
手法そのものは 1 つで、使う文字も U+200B・U+200C・U+200D・U+FEFF といったゼロ幅文字に限られます。違いは「埋め込んだビット列を何として読むか」だけです。
| 用途 | 埋め込む内容 | 読み取りに必要な操作 |
|---|---|---|
| 隠しメッセージの伝達 | 任意のビット列 | 受信側が同じ対応表でデコードする |
| 流出元の特定 (受信者ごとの透かし) | 受信者を識別するビット列 | 配布時に記録したパターンと突き合わせる |
| 無断コピーの検出 | 出典を示すビット列 | コピー先のテキストを文字コード単位で調べる |
例えば、4 種類のゼロ幅文字を 2 ビットの情報として扱い (U+200B = 00, U+200C = 01, U+200D = 10, U+FEFF = 11)、テキストの各単語の間にゼロ幅文字を挿入することで、バイナリデータを隠し持たせることができます。
この技術は、企業が機密文書の流出元を特定するために使われることがあります。各受信者に異なるゼロ幅文字パターンを埋め込んでおけば、文書が外部に流出した際に、どの受信者から漏れたかを特定できます。
不可視文字の検出と除去
不可視文字が混入したテキストを正しく処理するには、検出と除去の方法を知っておく必要があります。
| 方法 | 対象 | コード例 |
|---|---|---|
| JavaScript 正規表現 | 主要なゼロ幅文字 | str.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, '') |
| Python 正規表現 | 同上 | re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text) |
| テキストエディタ | 不可視文字・混同しやすい文字 | VS Code: editor.unicodeHighlight.invisibleCharacters |
| コマンドライン | ファイル内の不可視文字 | cat -v filename または xxd filename |
| PHP | 主要なゼロ幅文字 | preg_replace('/[\x{200B}-\x{200F}\x{FEFF}]/u', '', $str) |
JavaScript の正規表現 /[\u200B-\u200F\u2028-\u202F\uFEFF]/g が覆う範囲は、「主要なゼロ幅文字」に限られます。実際に通してみると ZWSP (U+200B) と BOM (U+FEFF) は消えますが、この記事の冒頭表に挙げたワードジョイナー (U+2060) とソフトハイフン (U+00AD)、それに後述する分離系の方向制御文字 (U+2066〜U+2069) は範囲外で、そのまま残ります。フォームの入力値をサーバー側で洗うのは有効な対策ですが、除去したい文字は自分で数え上げて文字クラスに書く必要があります。
ただし、すべての不可視文字を無条件に除去するのは危険です。ZWJ は絵文字の合成に必要であり、除去すると絵文字が分解されてしまいます。ZWNJ はペルシア語やヒンディー語の正しい表記に不可欠です。不可視文字の除去は、用途と文脈を理解した上で慎重に行う必要があります。
プログラミング言語ごとの不可視文字の扱い
ソースコードに ZWSP が混入したときの挙動は、言語ごとに、そして処理系の版ごとに違います。エラーで止まってくれる言語もあれば、識別子の一部として黙って受け入れる言語もあり、後者のほうがはるかに厄介です。次の表は 2026 年 8 月時点の代表的な挙動で、同じ言語でもコンパイラや版によって変わりうるため、最終的には自分のツールチェーンで試すのが確実です。
| 言語 | 識別子に混入した ZWSP | 文字列リテラル中の ZWSP | 検出の手がかり |
|---|---|---|---|
| JavaScript (Node.js) | 構文エラー (識別子に使える文字ではない) | 文字列の一部として保持 | ESLint の no-irregular-whitespace |
| Python | SyntaxError (印字できない文字として拒否) | 文字列の一部として保持 | インタープリタ自体が止まる |
| Rust | コンパイルエラー (トークンとして解釈できない) | 文字列の一部として保持 | ZWNJ・ZWJ なら uncommon_codepoints 警告 |
| C / C++ (clang) | 識別子として通り、警告のみ | 文字列の一部として保持 | clang の -Wunicode-zero-width |
ここで取り違えられやすいのが、ZWSP と ZWNJ・ZWJ の違いです。JavaScript の識別子に使える文字には ZWNJ (U+200C) と ZWJ (U+200D) が含まれており、ZWSP (U+200B) は含まれていません。つまり var he\u200Bllo は構文エラーで止まりますが、var he\u200Cllo はそのまま通り、見た目が同じ hello とは別の変数として宣言されます。
この差は実務上の意味が正反対です。ZWSP は「エディタでは見えないのに、実行した瞬間に落ちる」ため、混入していれば必ず気づけます。厄介なのは通ってしまう ZWNJ・ZWJ の側で、宣言も参照も成功する一方、後から手で打ち直した hello は「未定義の変数」になります。文字が見えないまま、変数名の不一致だけが残るわけです。
変数名・関数名の長さの目安を考える際にも、不可視文字の混入リスクは頭の片隅に置いておくべきです。コードレビューでは目視で検出できないため、リンターやエディタの設定で自動検出する仕組みを整えることが重要です。
不可視文字が引き起こす典型的な失敗パターン
混入の経路と壊れ方には決まった型があります。どれも原因が目に見えないため、切り分けに時間がかかるのが共通点です。
- 文字列比較が静かに失敗する: 見た目が同じ 2 つの文字列が等しくならない。手で打ち込んだテストデータでは通るのに、コピーして作った実データだけ一致しない、という形で表面化する
- 検索でヒットしない: 商品名や記事タイトルにゼロ幅文字が混ざると、利用者が画面に見えるとおりに入力しても一致しない
- 一意制約や重複チェックをすり抜ける: メールアドレスや ID の途中に ZWSP が入っていると、システムは別の値として扱うため、見た目が同じデータが並んで登録されてしまう
- PDF や Web ページからのコピペで混入する: 整形のために埋め込まれたソフトハイフン (U+00AD) やゼロ幅スペースがそのまま貼り付けられ、フォーム入力のバリデーションで文字数超過と判定される
- 差分表示では見つからない: レビュー画面や diff は幅ゼロの文字を描かないため、コードレビューでも原稿の校正でも素通りしてしまう
文字数カウントツールと不可視文字
文字数カウントツールが不可視文字をどう扱うかは、ツールによって異なります。一部のツールは不可視文字を無視してカウントし、別のツールはそのままカウントします。Unicode の基本を理解していないと、ツール間で文字数が一致しない原因を特定できません。
テキストの文字数を正確にカウントしたい場合は、まず不可視文字の有無を確認し、必要に応じて除去してからカウントすることをお勧めします。「見えない文字」の存在を知っているだけで、文字数にまつわる多くのトラブルを未然に防ぐことができます。
不可視文字とセキュリティ - 見えない脅威
先に触れた Trojan Source は、双方向テキストの制御に使われる 9 種類の不可視文字 (U+202A〜U+202E の埋め込み・上書き系と、U+2066〜U+2069 の分離系) を悪用して、ソースコードの見た目と実際の実行ロジックを乖離させる手法です。不可視文字がセキュリティ上の脅威になる型は、これ以外にもいくつか知られています。
| 攻撃手法 | 使用する不可視文字 | 影響 | 対策 |
|---|---|---|---|
| Trojan Source | 方向制御文字 (U+202A〜U+202E, U+2066〜U+2069) | コードレビューで見抜けない悪意のあるロジック | コンパイラの警告を有効化 |
| ホモグラフ攻撃 | 見た目が同じ異なる文字 (U+0430 vs U+0061) | フィッシング URL の偽装 | Punycode 表示の確認 |
| ZWSP インジェクション | U+200B | 入力バリデーションの回避 | サーバーサイドでの不可視文字除去 |
| BOM インジェクション | U+FEFF | ファイルパーサーの誤動作 | BOM の自動除去処理 |
この攻撃が成立する仕組みは単純です。コメントや文字列リテラルの中に方向制御文字を仕込むと、エディタや Web の差分表示は指示どおりに文字を並べ替えて描画する一方、コンパイラは並べ替え前のバイト列のとおりに解釈します。結果として、画面上では「権限を確認したうえで処理する」と読めるコードが、実際には確認を経ずに処理を実行する、という食い違いを作れてしまいます。悪用されているのは不正な文字ではなく、双方向テキストを正しく表示するための正当な仕組みそのものです。
この攻撃は、コードレビューという人間の目による検証プロセスを無力化する点で特に危険です。対策としては、コンパイラやリンターで方向制御文字の使用を警告する設定を有効にすること、CI/CD パイプラインに不可視文字の検出ステップを組み込むことが推奨されます。
Git コミットメッセージの書き方の記事でも触れていますが、コードの品質管理においてリンターの活用は不可欠です。不可視文字の検出も、リンターの重要な役割のひとつです。