FlowingDev

Unicodeに潜む幽霊:あなたのテキストを破壊する見えない文字の恐怖

見えないUnicode文字やそっくり文字が、いかにしてコードを破壊し、セキュリティリスクを引き起こし、イライラするほど巧妙なバグを生み出すのか。その謎を解き明かします。

ツールを試す: Unicodeテキストインスペクタ

ひと言で言うと

Unicodeは、私たちのグローバルなインターネットを可能にしているテキストの世界標準ですが、その広大さゆえに、目に見えない文字や見た目がそっくりな文字、その他の奇妙な文字が含まれており、一見単純な文字列をバグの地雷原に変えてしまうことがあります。

それが解決する問題

コンピューティングの原始のスープの中では、世界はシンプルでした。我々にはASCIIがありました。それは127文字を与えてくれました:大文字、小文字、数字、句読点、そしていくつかの制御コード。それはきれいで、整頓されていて、1バイトに完璧に収まりました。そして、それは絶望的なほどアングロセントリック(英語中心主義)でもありました。もし ¿Qué pasa? や 你好 や спасибо と書きたかったなら、残念、お手上げでした。

これが「コードページ地獄」として知られる混沌の時代を招きました。異なる地域のコンピュータは、ASCIIを拡張した異なる8ビット文字セットを使用していました。Windows-1252(西ヨーロッパ言語)で書かれたドキュメントは、KOI8-R(ロシア語)を使用するシステムで開くと、ぐちゃぐちゃの表示(我々が愛情を込めて mojibake 、文字化けと呼ぶもの)になってしまいました。国際的にテキストを共有するのは、壊れた伝言ゲームをするようなものでした。

そこへ、白馬に乗った騎士のようにUnicodeコンソーシアムが登場しました。彼らのミッションは、すべての現代および歴史的な書記体系のための、単一で統一された文字セットを作ること。すべてを統べる一つの標準です。'A'から'€'、そして'うんちの山'の絵文字(💩)まで、すべての文字が「コードポイント」と呼ばれる自分だけのユニークな番号を与えられることになりました。

それは現代世界を動かす記念碑的な成果でした。しかし、この壮大な統一は、それ自体が素晴らしくもオタク的な問題を生み出しました。人間の言語の複雑さに対応するため、Unicodeは単に見える文字以上のものを含める必要がありました。それは次のようなものを必要としました:

  • 結合文字: eのような他の文字の上に配置されるように設計された、それ自体が独立した文字であるアクセントマーク(´)。
  • ゼロ幅文字: 改行を示唆できる(U+200B ゼロ幅スペース)または絵文字を接着できる(U+200D ゼロ幅接合子)見えないマーカー。
  • 曖昧な文字: 数十種類もの異なるスペース、ダッシュ、引用符。
  • そっくりな文字(ホモグリフ): ラテン文字の a とキリル文字の а は多くのフォントで同一に見えますが、コンピュータにとっては a と b ほどに違うものです。

突如として、WYSIWYG(What You See Is What You Get)は WYSIWYN(What You See Is Not What You Get) になりました。"cat" のように見える文字列が、実は見えない文字を含んでいて長さが3ではなく4になるかもしれません。変数名 safeString が、ラテン文字の 'a' の代わりにギリシャ文字の 'α' を隠しているかもしれません。これがテキストインスペクターが解決する問題です。それはX線ゴーグルを装着して、あなたの文字列が実際に何でできているのか、その生の、フィルタリングされていない真実を見せ、機械に潜む隠れた幽霊を暴き出すのです。

内部の仕組み

文字列を解剖するためには、その3つの基本的な層を理解する必要があります:抽象的な文字、そのバイト表現、そしてその間に隠れる奇妙なものたちです。

コードポイント:文字のアドレス

Unicodeの核心は、巨大なリストにすぎません。すべての文字にはコードポイントと呼ばれる一意の番号が割り当てられています。これはUnicodeユニバースにおけるその文字の永続的なアドレスです。私たちはこれを U+XXXX という表記法で書きます。ここで XXXX は16進数です。

  • U+0041 は A (ラテン大文字A)
  • U+00E9 は é (アクサンテギュ付きラテン小文字E)
  • U+20AC は € (ユーロ記号)
  • U+1F4A9 は 💩 (うんちの山)

コードポイントは抽象的な概念です。バイトでもフォントでもありません。それは単に文字にマッピングされた番号です。その番号をどのように保存するかはまた別の話です。

エンコーディング:コードポイントをバイトに保存する

「コードポイント」をファイルに保存することはできません。保存できるのはバイトだけです。エンコーディングとは、コードポイントのシーケンスをバイトのシーケンスに変換するためのルールのセットです。

今日のエンコーディングの王様はUTF-8です。その天才性は、可変長設計にあります。

  • 元のASCIIセットにも含まれる文字(A, U+0041 など)に対しては、UTF-8は単一のバイトを使用します。これはASCIIが使用したのと全く同じバイトです。これにより、後方互換性が保たれ、採用が容易になりました。
  • 他の文字については、2、3、または4バイトのシーケンスを使用します。各バイトの最初の数ビットが信号として機能し、現在の文字が何バイトで構成されているかをコンピュータに伝えます。

¡Hola! を見てみましょう:

文字 コードポイント UTF-8 バイト (16進数)
¡ U+00A1 C2 A1
H U+0048 48
o U+006F 6F
l U+006C 6C
a U+0061 61
! U+0021 21

テキストインスペクターは、この逆のプロセスを実行します。文字列の生のバイトを読み取り、エンコーディング(通常はUTF-8)に従って解釈し、それを構成するコードポイントのシーケンスを表示します。

見えないトラブルメーカーたち

ここからが面白いところです。テキストインスペクターの主な仕事は、何も見えない文字に光を当てることです。

カテゴリ 文字の例とコードポイント その狡猾な目的
ゼロ幅スペース U+200B 何も見えない。長い単語やURLの中で改行に適した場所を示唆する、見えない文字。
ゼロ幅接合子 U+200D 絵文字のマジックグルー。👨 + ZWJ + 👩 + ZWJ + 👧 = 👨‍👩‍👧。通常は繋がらない文字を結合します。
ノーブレークスペース U+00A0 通常のスペースに見えるが、改行を禁止する。100 km や Dr. Strangeのような表記に便利。
結合文字 U+0301 (結合アクサンテギュ) それ自体が文字であるアクセント(´)。直前の文字の上に描画されます。
そっくり文字(ホモグリフ) U+0430 (キリル文字 小文字 A) ほとんどのフォントでラテン文字の a (U+0061) と同じに見えるが、全く異なるコードポイント。

これは正規化という概念につながります。文字 é は2つの方法で表現できます:

  1. 合成済み形式 (NFC): 単一のコードポイント U+00E9。
  2. 分解済み形式 (NFD): 2つのコードポイント、e (U+0065) の後に結合アクセント ´ (U+0301) が続く。

視覚的には、これらは同一です。しかし、単純なバイト単位の比較を行うコンピュータにとって、"\u00E9" は "e\u0301" と等しくありません。テキストインスペクターは、どちらの形式を持っているかを明らかにし、それらを相互に変換するのに役立ちます。

現場で本当にあった怖い話

コピペが招いた大惨事

ある若手開発者が、バグを修正するために夜遅くまで作業していました。彼はブログで解決策を見つけました。それは const timeout = 100; という一行のJavaScriptでした。彼はそれをコピーし、コードエディタに貼り付け、保存しました。すると、SyntaxError: Invalid or unexpected token という不可解なエラーでアプリケーションのビルド全体が失敗しました。

彼はその行を睨みつけます。完璧に見えます。手で打ち直してみます。うまくいきます。コピーした行をもう一度貼り付けます。壊れます。彼は正気を失いかけているのでしょうか?1時間も髪をかきむしった後、先輩開発者がその行をじっと見て言いました。「それをテキストインスペクターに貼り付けてみて」

結果:const[U+00A0]timeout[U+00A0]=[U+00A0]100;。ブログのCSSがコードをきれいに見せるために、標準のスペース(U+0020)をノーブレークスペース(U+00A0)に置き換えていたのです。それらは同じに見えますが、JavaScriptエンジンはその文脈で「ノーブレークスペース」が何であるかを知りません。

教訓: Web(またはPDF、Word文書)からコピーしたテキストは、無実が証明されるまでは有罪です。それはしばしば「スマート」クオート、非標準のスペース、その他の見えないグレムリンで汚染されています。

ログインできなかった幽霊ユーザー

ある新規ユーザーが François という名前でサービスに登録しました。システムは喜んでアカウントを作成しました。翌日、Françoisはログインしようとします。彼は自分の名前を入力し、エンターキーを押しますが…「無効なユーザー名またはパスワードです」。彼は注意深く再試行しますが、同じ結果です。彼は締め出されてしまいました。

データベースでは、彼の名前は分解済み文字で保存されていました:F, r, a, n, c, o, i, s そして U+0327 (結合セディーユ)。しかし、ログインフォームは合成済みの文字 ç (U+00E7) を送信していました。視覚的には c + ¸ は ç と同じです。しかし、サーバーは単純な文字列比較を行っていました:François(分解済み)は François(合成済み)と等しくありません。WHERE username = '...' クエリは失敗したのです。

教訓: ユーザー入力は、データベースに保存したり比較を行ったりする前に、常に一貫した形式(NFCが最も一般的な選択肢)に正規化しましょう。

偽装ドメインの罠

ある従業員が、自社のIT部門から来たように見えるメールを受け取りました。「セキュリティ更新が必要です:アカウントを保護するために microsоft.com/update にログインしてください」。リンクは正当に見えます。ドメイン名もそこにあります。彼はそれをクリックし、本物そっくりのページで認証情報を入力し、日常業務に戻りました。

彼はフィッシング詐欺に遭ったばかりです。ドメインは microsoft.com ではありませんでした。それは microsоft.com でした。2番目の 'o' はラテン文字の 'o' (U+006F) ではなく、キリル文字の 'о' (U+043E) でした。これはIDNホモグラフ攻撃です。人間の目には、完璧な偽造品です。DNSシステムにとっては、それは全く異なるアドレスであり、詐欺師のサーバーにつながります。

教訓: 文字セットが混在する識別子には、最大限の警戒を。現代のブラウザにはいくつかの保護機能がありますが、ホモグラフ攻撃の原則は、ユーザー名、検証ルール、その他文字列がセキュリティ目的で使用されるあらゆる場所で、常に脅威となります。

よくある間違いと落とし穴

  • string.length が文字数をカウントすると仮定すること。 多くの言語(JavaScriptなど)では、知覚される文字数ではなく、コードユニットをカウントします。例えば、JSでは "👍🏽".length は4です。なぜなら、「サムズアップ」絵文字(👍、2ユニット)と「中間の肌の色調修飾子」(🏽、2ユニット)で構成されているからです。
  • すべての空白を同じものとして扱うこと。 文字列に trim() を実行しても、途中に隠れている U+200B ゼロ幅スペースは削除されません。\s+ の正規表現は U+00A0 ノーブレークスペースを捉えられないかもしれません。何を狩っているのかを知る必要があります。
  • 正規化を無視すること。 Françoisの例で見たように、見た目は同じでも内部のバイト表現が異なる文字列を比較するのは、古典的でイライラするバグです。string1.normalize() === string2.normalize() はあなたの友達です。
  • 自分の目を信じること。 レンダリングされたテキストを眺めているだけでは、これらの問題をデバッグすることはできません。個々のコードポイントとその名前を表示するテキストインスペクターが、そこにあるものの真実を確認する唯一の方法です。
  • 自作の「不正文字」除去機能を作ること。 すべての「奇妙な」文字を削除するための正規表現を書こうとすることは、骨折り損のくたびれもうけです。いくつかを見逃すか、もっと悪いことに、他の言語で必要な正当な文字を削除してしまい、ユーザーの名前やテキストを台無しにしてしまいます。

なぜ気にかけるべきなのか

テキストが予期せぬ動作をしたときはいつでも、Unicodeテキストインスペクターに手を伸ばすべきです。それは不可欠なデバッグツールです。次のような場合に考えてみてください:

  • 文字列比較が、「明らかに」成功すべきなのに失敗する。
  • 完全に有効に見えるコード行で構文エラーが発生する。
  • ユーザー名、メールアドレス、URLなどのユーザー提供の入力を検証している。
  • 複数のシステムからのデータを扱っており、特にそれらが異なる言語を伴う場合。
  • string.length がなぜ「間違った」数値を返すのかを理解する必要がある。
  • 堅牢で、安全で、グローバルなユーザーに対応できるシステムを構築している。

要するに、テキストが何を意味するかについてコンピュータと人間との間で意見が食い違ったときは、大抵バイトに関してはコンピュータが正しく、テキストインスペクターがその翻訳者となってくれるのです。

さらに詳しく

理論はOK。さあ手を動かそう — 100%ブラウザ内で。

ツールを試す: Unicodeテキストインスペクタ