2 ポイント 投稿者 GN⁺ 2025-02-13 | 1件のコメント | WhatsAppで共有
  • Unicode の variation selector を連結すると、画面には見えないがコピー&ペーストには付いてくるバイト列を1文字の後ろに隠せる
  • VS-1 から VS-256 まで 256 個の variation selector があり、1バイト範囲と正確に対応するマッピングを作れる
  • 😊 の後ろに hello のバイト [0x68, 0x65, 0x6c, 0x6c, 0x6f] を付けても、見た目は普通の絵文字1文字のように見える
  • デコードでは U+FE00..U+FE0FU+E0100..U+E01EF の範囲を探して再びバイトへ戻し、ベース文字 は絵文字である必要はない
  • この方法は Unicode の悪用であり、人間向けのコンテンツフィルターを回避したり、テキストに ウォーターマーク を埋め込んだりする用途に悪用されうる

見えないデータが1文字に付く仕組み

  • Unicode テキストは コードポイント(codepoint) のシーケンスとして表現され、通常は U+XXXX 形式で表記される
  • 単純なラテン文字では、コードポイントと画面に見える文字は 1:1 で対応する
    • 例: U+0067 は文字 g を表す
  • 他の文字体系では、画面上の1文字が複数のコードポイントで構成されることがある
    • 例: デーヴァナーガリーで キー と読まれる文字は U+0915U+0940 の連続ペアで表現される

variation selector をデータ保存領域のように使う

  • Unicode は 256 個の variation selector コードポイントを定義しており、名前は VS-1 から VS-256 までとなっている
  • variation selector 自体は画面に表示されず、直前の文字の表示方法を変えるために使われる
  • ほとんどの Unicode 文字には関連する異体字指定がないが、Unicode は将来互換性を重視しているため、意味を知らない処理系でも variation selector を保持しなければならない
    • U+0067(g) の後ろに U+FE01(VS-2) を付けても、画面上では小文字の g に見える
    • コピー&ペーストすると variation selector も一緒に付いてくる
  • 256 個の variation selector はちょうど 1バイト を表せる個数なので、任意の Unicode コードポイントの後ろに 1 バイトのデータを隠せる
  • Unicode 仕様は複数の variation selector が連続するシーケンスを具体的には扱っておらず、レンダリング中には無視されるべきことを示唆している
  • 複数の variation selector を連結すると、任意のバイト列 を1文字の後ろに表現できる

バイトを variation selector にエンコードする

  • variation selector は 2 つのコードポイント範囲に分かれている
    • U+FE00 .. U+FE0F: 最初の 16 個
    • U+E0100 .. U+E01EF: 残りの 240 個
  • バイトを variation selector に変換する規則は単純
    • バイトが 16 より小さければ 0xFE00 + byte
    • それ以外は 0xE0100 + (byte - 16)
  • エンコードではまず ベース文字(base character) を1つ置き、その後ろに各バイトを variation selector に変換して連結する
fn byte_to_variation_selector(byte: u8) -> char {
    if byte < 16 {
        char::from_u32(0xFE00 + byte as u32).unwrap()
    } else {
        char::from_u32(0xE0100 + (byte - 16) as u32).unwrap()
    }
}
fn encode(base: char, bytes: &[u8]) -> String {
    let mut result = String::new();
    result.push(base);
    for byte in bytes {
        result.push(byte_to_variation_selector(*byte));
    }
    result
}
  • hello を表すバイト [0x68, 0x65, 0x6c, 0x6c, 0x6f]😊 の後ろに付けると、見た目は普通の絵文字のように見える文字列になる
  • 通常の出力では隠し文字は見えないが、Rust のデバッグフォーマットで出力すると \u{e0158} のような隠れたコードポイントが現れる
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"

隠されたバイトを再び読み出す方法

  • デコードでは文字を走査しながら、variation selector 範囲にあるコードポイントを再びバイトへ変換する
  • U+FE00..U+FE0F 範囲は variation_selector - 0xFE00 で復元する
  • U+E0100..U+E01EF 範囲は variation_selector - 0xE0100 + 16 で復元する
  • 最初の variation selector に出会うまでの通常文字は ベース文字 とみなして無視する
  • variation selector ではない文字に出会い、すでに結果があればデコードを終了する
fn variation_selector_to_byte(variation_selector: char) -> Option<u8> {
    let variation_selector = variation_selector as u32;
    if (0xFE00..=0xFE0F).contains(&variation_selector) {
        Some((variation_selector - 0xFE00) as u8)
    } else if (0xE0100..=0xE01EF).contains(&variation_selector) {
        Some((variation_selector - 0xE0100 + 16) as u8)
    } else {
        None
    }
}
  • 同じエンコード結果をデコードしてから UTF-8 として解釈すると "hello" が得られる
  • ベース文字は絵文字である必要はなく、通常の文字でも variation selector の扱いは同じ
  • 絵文字を使う理由は、そのほうが面白いから

悪用の可能性

  • この方法は Unicode の悪用 であり、使うべきではない
  • レンダリング結果ではデータが見えないため、人間のモデレーターやレビュアーは隠しデータの存在に気づきにくい
  • 人間向けのコンテンツフィルターを通過してデータを隠す手段として悪用されうる
  • テキスト ウォーターマーキング にも利用できる
    • メッセージを複数人に送ったあと流出した場合、元の受信者を追跡できる
    • variation selector のシーケンスは大半のコピー&ペーストで残る
    • 任意のデータ密度を許容し、望めばすべての文字にウォーターマークを入れることもできる

LLM は隠しデータを処理できるか

  • Hacker News に投稿された後、LLM がこのような隠しデータをどう扱うのかという質問が出た
  • 一般にトークナイザーは variation selector を トークンとして保持 しているようで、理論上はモデルがアクセスできる
  • OpenAI tokenizer はこれを確認するためのチェックツールとして使える
  • 全体として、モデルは内部で直接デコードを試みることはあまりないように見える
  • コードインタープリターと組み合わせれば、一部のモデルは隠しデータを展開できる

1件のコメント

 
GN⁺ 2025-02-13
Hacker News のコメント
  • Unicode の悪用としては、これは氷山の一角にすぎない。同じような手法で、Unicode 文字列を受け取るさまざまなシステムのバッファをあふれさせることができる。たいていはエラーやクラッシュで終わるが、運がよければかなり面白い挙動も出る
    Python 3 以前の時代にペネトレーションテストをしていたとき、発音区別符号だけで1文字を複数バイトに膨らませ、バックエンド Web サーバーのバッファをあふれさせたことがある。そのときはクラッシュして自動再起動する程度だったが、十分に掘り下げれば特定のシステムやソフトウェアのエクスプロイトに使えそうに見えた

    • Google CTF quals 2024 の「encrypted runner」問題は、この発想を元にしていた
    • その通り。Zalgo テキストは Web サイトの入力フィールドでよくあるテストケースだが、たいてい面白いことは起きない。たまにデータベースの長さ制限例外に触れるくらいで、通常プロセスが落ちることもなく、例外は現在のスレッド内で終わる
      最近のフォームでも、JavaScript を切るだけで同じように誘発できる。最良の場合でも、デバッグが有効になっていてスタックトレースやクエリが出力され、多少の情報が漏れる程度だ。もう一つよくあるミスは、テキスト文字列で \n\r\n の長さを数え間違えることだ。JavaScript は通常キャリッジリターンを1バイトとして数えるが、HTTP 仕様では2バイトが必要になる
      unescape(encodeURIComponent("ç")).length は JavaScript でバイト長を手早く確認するための雑な方法で、\r\n 問題は長さを数える前に文字列を整形すればよい
    • 初心者なので聞きたいのだけど、これがどう起きるのか、あるいはどうやったのか、もう少し説明してもらえる? 試してみる価値のある抜け穴のように感じる
  • これはかわいいが、あえて必要ではない。Unicode には PUA(private use area) という大きな範囲があり、この範囲のコードはいかなる文字にもマッピングされておらず、今後もマッピングされないため、内部用途やユーザー定義用途に使われる
    たとえば fish-shell では、トークンを文字列として安全にパースするとき、エスケープされていない特殊文字を文字列中の別の Unicode コードポイントのように置き換えるが、PUA 領域に入れておき、後でパイプライン上で捕捉する。API 境界の外に露出させてはいけないが、遭遇したときはそのまま通過させることが推奨されており、ほとんどのシステムやライブラリもそうする。明らかな漏えい経路になり得るが、多くの普通の開発者は Unicode について「国際化の問題を避けるには常に Unicode を使え」以上のことをあまり知らないため、そのまま開いた状態になっていることが多い

    • 実際に試してみると、private use 文字は私の環境では四角としてレンダリングされた(󰀀)。ここでの要点は、コピー&ペースト時に隠れ、他の文字の「一部」のように扱われるようエンコードすることだ
    • 違いは、PUA 文字はたいてい何らかの形でかなり目立ってレンダリングされるが、variation selector はそうではない、という点にある
    • 抜けている文脈がある。これは Open Heart Protocol の投稿をめぐる議論から出てきたアイデアだ
      https://news.ycombinator.com/item?id=42791378
      API が絵文字しか受け付けないという制約のため、すぐに犯罪的な活用可能性が議論された。その用途では PUA は使えず、絵文字の中にエンコードする必要がある
    • これは private-use area というより、指定非文字(noncharacter) の用途に近いのでは? PUA は Unicode にまだ入っていない文字体系の非公式エンコーディングや Apple ロゴなどにも使われるので、こういう使い方をすると衝突が心配になる
      指定非文字には 0xFFFF0xFFFE や各面の最後の2コードポイントだけでなく、Arabic Presentation Forms の途中にある領域も含まれる。こういう用途に使う非文字をもっと確保できるよう、後からリストに追加されたのだと理解している
    • 正直、このコメントを用意されていたデコーダに貼り付けてみた。ここまで要点を外すはずがなく、中に隠しメッセージがあるだろうと思ったのだが、本当に外していたのか、この Web サイトがそれらを取り除いているようだ
      認識されない PUA 文字で任意の文字を見えないようにウォーターマークすることはできない。結合文字として扱われないからだ。代わりに、別個にレンダリングされるプレースホルダーの四角が表示される。例: — もちろん private use area を本当に私的に使っているなら、四角ではないかもしれない
  • 10年ほど前、Windows のファイル名の途中に U+202D LEFT-TO-RIGHT OVERRIDE を入れて同僚たちを驚かせたことがある。funnypicturegnp.exefunnypictureexe.png のように見えた
    写真のプレビューのように見えるカスタムアイコンまで付けると、かなりそれらしくなった

    • フィッシング検知の仕事をしていたが、攻撃者がよく使うパターンだった。.exe はほとんど自動ブロックされるが、最近の悪性拡張子は .html であることが多く、難読化された window.location リダイレクトで偽ログインページを表示することがよくある
      cute-cat-lmth.png のような RTL 悪用は比較的よくあったが、検知も非常に簡単で、そうしたメールは即座にフィッシングとしてマークしていた
    • そのソースコード版が CVE-2021-42574 で、Web サイトもある
      https://trojansource.codes/
      基本的にコメントのように見えるが、コンパイルされるとコードとして動作するコードを隠せる。ただし、多くのテキストエディタはすでにこうした怪しいコメントを見えるようにしているため、CVE としての扱いは議論があったと記憶している
    • この特定のトリックは知らなかったが、何十年もの間、潜在的に怪しいメディアファイルを常に「右クリック → プログラムから開く」で開いていた偏執的な習慣が正当化されてうれしい
    • guitar_tab.txt という名前の bat ファイルを作ったことがある
  • 実際のユースケースとして、Sanity はこのトリックを使って Content Source Maps を「プレビューモード」でウェブページに提供される実際のテキスト内にエンコードしている0。編集者はそのテキストやコンテンツをクリックするだけで、深いコンテンツ構造の中にある元の位置まで簡単に追跡できる
    欠点や制限もある。たとえば日付・タイムスタンプ、URL、ID のように、そのままパースしたり使ったりする必要がある値には追加されないよう防ぐ必要がある。それでもかなり面白いトリックだ
    0 https://www.sanity.io/docs/stega
    [1] https://github.com/sanity-io/content-source-maps

  • これを LLM 出力のウォーターマーキングに使うアイデアは気に入っている。ちょうどいい落としどころだ。どうせコピー&ペーストするだけの低品質な生成器の 99% は否応なく引っかかるし、他の主要なユースケースにはほとんど影響しない
    各文字や出力トークンごとにどれくらい入れるのかも気になる。ユーザー ID、プロンプト参照、日付、トークン番号のようなものだろうか? ターミナルではどう解釈されるのかも気になるし、本当にすばらしい

    • なぜ皆が AI ウォーターマーキングが機能すると考えているのか分からない。どんなウォーターマークでも即座に、簡単に取り除けるので、絶対にまともには機能しないはずだ
      本当の AI 対策は、すべての人間の相互作用に実在の身元で検証された鍵署名を要求することだけだが、それも A: 絶対に起きないだろうし、B: 腐敗した政府のある国や、民間産業の影響を強く受ける腐敗した政府のある国、たとえば米国で悪用され得る
    • データセットに入れる前に行われる 前処理があまりに多いので、こうした小細工が実際に通用するなら意外だと思う
    • ほとんどの Linux ターミナルでは、渡したものは単なるバイト列としてそのまま流れる。この手法は UTF-8 に適合していて追加のグリフを使わないため、Unicode に準拠したターミナルでは人間の目には見えない。いくつかで試してみた
      もちろん文を xxd に送れば現れる。現在のトップコメントの PUA 提案は、すぐに見えてしまうものとは違う
      追加テストをしてみると、ターミナルに貼り付けた後 xxd ではメッセージが完全に無変形で通過するが、ターミナル上で選択して再度貼り付けると、mate terminal と konsole の X selection では数語だけ残って切り詰められた。切り詰めがターミナルによるものか X によるものかは分からない。xterm では最後の e が変形し、選択内容はさらに大きく切り詰められた
      ファイルには文が無変形で記録される。したがって、ターミナルの外へコピーするときに一部のデータが落ちる、という可能性に近そうだ。文をテストファイルに echo し、ブラウザで開いてテキストをコピーして確認した
    • LLM ウォーターマーキングには、はるかに堅牢で検出しにくい別のアプローチもある。LLM が可能な次トークンごとに確率を与える 確率分布を作り、その中からランダムサンプリングして出力を作る、という点を利用する
      生成時のサンプリング方式に細工を入れれば、後で LLM を再度走らせて出力の傾向を観察し、フィンガープリントを検出できる。たとえば高確率トークンと低確率トークンを交互に選ぶ、といった具合だ。実際の実装は当然はるかに精巧になるだろうが、アイデアはそういう方向だ
  • 興味深い点として、スクリーンリーダーは文字単位で移動すると、このような variation selector を検出できる。例の上で矢印キーで移動すると、「Smiling face with smiling eyes」「Symbol e zero one five five」「Symbol e zero one five c」のように読み上げられる
    ただし使用する音声合成器によって変わるし、文書を普通に読んでいる最中ならそうした文字があるか分からないので、全体として大きな利点ではない

    • オンラインのテキスト全般が、見えないが聞くといら立つ文字で汚染されているので、自分のスクリーンリーダーでは 非 ASCII 文字をすべて取り除くスクリプトを使っている
  • StegCloak0 も似た系統で、隠したペイロードを AES-256-CTR で暗号化し、このアイデアをさらに一歩押し進めている。なかなか良い小技だ
    0 https://github.com/KuroLabs/stegcloak

    • Better Discord プラグインの中に、これか似た方式を使うものがあるようだ。他の人には何でもないように見える 完全暗号化メッセージを送れる
      ただし相手がデコードするには、パスワードの秘密値を共有する必要がある
    • Cloudflare DNS の TXT レコードで試そうとしたが、Cloudflare は賢いことに、TXT フィールドへ貼り付ける際にデコードしてしまう
  • タイトルは少し誤解を招く。「ベース文字は絵文字である必要はなく、variation selector の処理は通常の文字でも同じだ。絵文字にするとより面白いだけだ」と書かれている
    この方式を 非絵文字文字に使うと、よりステルス性が高く、より厄介になる

    • そこまで厄介というわけではなさそうだ。検出器は難なく作れる。実際の異体字が存在しない文字に variant が付いていたら表示すればよい。むしろ署名用途にも使えそうだ
  • 単純な LLM 出力ウォーターマーキングよりも、これは logprobs データを一緒にパッケージするすっきりした方法になり得るように思える
    基本的には、生成された全トークンの確率情報を含めて、生成過程に多少の透明性を与えるということだ。OpenAI API 仕様にも含まれているし、llama.cpp のような複数のエンジンもこの情報を提供している。通常は別フィールドとして付くが、mikupad0 のような可視化方法もある
    たぶん悪いアイデアだろうが、それでも頭をくすぐる発想だ

  • すばらしい手法。ASCII を反映しており、UI 要素、特に Web アプリではあまり目にしない Unicode Tag 文字もある
    Tag 文字のユニークな点は、一部の LLM が隠されたテキストを ASCII として解釈し、その指示に従うこと、さらには直接書き出すこともある点
    https://embracethered.com/blog/posts/2024/hiding-and-finding...
    Microsoft が Copilot で修正した実際のエクスプロイトの概念実証もある
    https://embracethered.com/blog/posts/2024/m365-copilot-promp...