ファイルが見た目どおりのものでないことに気づける最後の場所は、エディタです。この記事で扱うのは、不可視の Unicode 文字や双方向制御文字を使ったファイルと、4 つのエディタ(Vim、Neovim、Helix、sim)がそれをどう描くかです。ドキュメントに書かれていること、私たちが実際に試したこと、確かめられなかったことを、分けて書きます。

攻撃のあらまし

2021 年、Nicholas Boucher と Ross Anderson は、Unicode の双方向制御文字を使うと、画面上の見た目とコンパイラが読む内容を食い違わせられることを示しました [1]。この攻撃は Trojan Source と呼ばれ、CVE-2021-42574 が割り当てられています [2]。原因は表示のしくみにあります。Unicode 双方向アルゴリズムは、アラビア語やヘブライ語が左から右の行の中でも右から左に読めるよう、文字を表示のために並べ替えます [3]。並べ替えが変えるのは見え方だけで、保存されている文字の並びは変わりません。コンパイラが読むのは、保存された並びのほうです。U+202E(右から左への上書き)のような制御文字を使うと、保存されたテキストではコードのところに、画面ではコメントが見える、あるいはその逆が起こせます。研究者がエディタに勧めていることは短く、これらの文字を知覚できるようにせよ、というものです [1]。

攻撃の形を真似た行を示します。山括弧のトークンは、実際には見えない文字です。

if role == "user<U+202E> <U+2066># only admins<U+2069> <U+2066>":

保存された状態では、この行は role を、たまたま「# only admins」という語を含む文字列と比べています。制御文字を黙って描くエディタや、制御文字を解釈して描くエディタでは、レビューする人が別の読み方をしてしまうかもしれません。仕掛けはこれだけで、エディタが見せない限り、この行のどこにも目に見える手がかりはありません。

ほかに何が隠れるか

双方向制御文字はもっともよく知られた例です。sim の仕様は、そのまま信用してはいけない文字として、次を挙げています。

  • 双方向制御文字 U+200E–200F、U+202A–202E、U+2066–2069、それに U+061C(アラビア文字マーク)
  • U+200B(ゼロ幅スペース)と、ファイル先頭以外にある U+FEFF(バイトオーダーマーク)
  • 制御文字。C0、DEL、C1 で、端末がエスケープシーケンスとして解釈することがあります
  • 何も見えない空白。ノーブレークスペース、行末の空白、タブ

ここまでが v0.1.0 のリストです。sim 0.2.0 は、何も描かない文字をすべて表示します(SAFE-10、SAFE-11)。AI アシスタントへの隠れた指示を運べるタグ文字、公開された拡張機能にコードを隠したことのある異体字セレクタ、フィラー、ソフトハイフン、行区切り、未割り当てのコードポイントで、絵文字と旗にはいくつか例外があります。どの文字をなぜ表示するかはセキュリティのページに、種類ごとに Neovim 0.12.5 と sim が何を描いたかは比較のページにあります。

Trojan Source にはもう一つの手口があります。ほかの文字体系の似た文字(ホモグリフ)を使うもので、これには別の CVE があります [1]。これは別の問題で、あとの「sim がしないこと」で触れます。

4 つのエディタの表示

U+202E、U+2066、U+2069、U+200B、U+FEFF、ノーブレークスペースを含むファイルを作り、それぞれのエディタを端末で開いて、描かれたものを確かめました。実行しなかったエディタについては、表にそう書き、マニュアルを引用しています。

エディタ 見えるもの 根拠
Vim <202e> という 16 進のトークン マニュアルに「Unprintable and zero-width Unicode characters are displayed as <xxxx>. There is no option to specify these characters.」(表示できない文字とゼロ幅の Unicode 文字は <xxxx> と表示され、この文字を指定するオプションはない)とある [4]。Vim 自体は実行していません。
Neovim <202e>、<2066>、<200b>、<feff> 0.12.5 で実行しました。0.12.6 のソースにある表示不能文字の表には U+200B–200F、U+202A–202E、U+2060–206F、U+FEFF が入っており [6]、マニュアルは Vim と同じ一文を載せたうえで、表示できない文字には SpecialKey のハイライトを使うと述べています [5]。
Helix 25.07.1 何も見えない。どれも空白 1 マスになった リリースビルドを実行し、書き出したバイト列も記録しました。文字をそのまま端末に送ってはいませんでした。ゼロ幅文字の扱いは 2024 年 7 月から未解決の要望(イシュー #11255)で [7]、C0 制御文字が端末にそのまま書き出される件は 2026 年 9 月に報告され、未解決です(イシュー #16320)[8]。25.07.1 は最新のリリースで [9]、開発ブランチでは違うかもしれません。
sim マゼンタのブロックの <U+202E> 仕様(SAFE-8、UI-15)と、インストール済みのビルドでの実行。

公平のために 2 点を書き添えます。まず、Vim と Neovim はこれらの文字をきちんと見せます。どちらでも、文字のあるところにトークンが見えます。Neovim の既定のカラースキームでは、本物の U+202E のトークンは、同じ文字列をファイルに打ち込んだものとは違う色で描かれるので、そこでは色で見分けられます。次に、Helix の穴はリリース版の話です。イシュートラッカーを見れば、開発者が把握していることがわかります。今後のリリースがどうなるかは、この記事では言えません。

良い表示に必要なこと

4 つを見比べて、隠れた文字の表示には、次の 4 つの性質が要ると考えています。

  1. 見えること。 空白でも、幅ゼロの字形でもないこと。
  2. 打ち込んで偽装できないこと。 本物の U+202E の表示が <202e> という文字列なら、その 5 つの普通の文字を含むファイルは、違って見えなければなりません。色を変えるのも一つの方法で、専用の背景のブロックにするのも一つの方法です。
  3. セルがどんな状態でも保たれること。 本文では見えても、選択中、検索のマッチ、カーソル行、外部から入った変更の中、薄くなった遠い行では見えない文字は、攻撃者がその状態へ誘導できる文字です。
  4. 端末にそのまま届かないこと。 端末はエスケープシーケンスを実行するので、制御文字をそのまま書き出すと、見えないだけでは済まないことがあります。

sim は、この 4 つを軸に作っています。仕様では、双方向制御文字、U+200B、ファイル途中の U+FEFF を <U+202E> の形で表示し、どれも端末にそのまま送らないと定めています(SAFE-8)。flat と depth の見た目では、そのトークンを警告色のマゼンタのブロックで描きます。マゼンタは、インターフェースのほかのものには使いません(UI-15、COLOR-1)。このブロックは、そのセルの最終決定です。選択、検索のマッチ、カーソル行の背景、変更の色づけ、見出しの帯、遠い行のフォグがあっても、ブロックが勝ちます。色が 1 色しかない calm では、トークンは反転表示になります。ファイルに書かれた文字列 <U+202E> は 8 個の普通の文字で、ブロックはつきません。

仕様には、この性質が見つけた間違いも記録されています。最初の版では、ほかのスタイルのない文字にだけこのスタイルを与えていたため、変更された単語の中に書き込まれた右から左への上書き文字、つまり攻撃者自身の変更では、ブロックなしで描かれていました。2026 年 10 月 17 日のセキュリティレビューで見つかり、修正されました。いまは、状態ごとのテストが、3 つの見た目すべてで、トークンについて描画器が書き出すバイト列そのものを検証します。

sim が、本物の U+202E、文字列としての <U+202E>、分離文字、行末の空白、ノーブレークスペースを表示している

sim がしないこと

  • 似た文字の検出はしません。 ラテン文字の識別子の中のキリル文字の「а」は、リストにありません。この問題と対策は Unicode のセキュリティレポートに述べられています [10]。sim のドキュメントは、これを扱うとは言っていません。
  • リストは固定です。 v0.1.0 では、上に挙げた仕様のリストがすべてです。0.2.0 では、Unicode 18.0.0 の不可視文字の表になります。Unicode の紛らわしい文字をすべて調べるものではありません。
  • 見せることは、防ぐことではありません。 <U+202E> が見えても、どうするかは読む人が決めます。sim は、そのファイルを開くことを拒みません。
  • コードレビューのツールではありません。 Trojan Source にもっとも出会いやすいのは、エディタではなくウェブページです。

自分のツリーで調べる

どのエディタを使っていても、これらの文字は直接検索できます。次の ripgrep のコマンドは、ツリーのうち双方向制御文字を含む行を一覧にします(テスト用のファイルで実行して確かめました)。

rg -nP '[\x{202A}-\x{202E}\x{2066}-\x{2069}]' .

次のコマンドは、上のデモ行を、本物の文字を含めてファイルに書き出します。自分のエディタで開いてみてください。

printf 'if role == "user\xe2\x80\xae \xe2\x81\xa6# only admins\xe2\x81\xa9 \xe2\x81\xa6":\n' > demo.py

その行に何も変わったものが見えないなら、あなたのエディタについて、ひとつわかったことになります。