- Unicode の variation selector を連結すると、画面には見えないがコピー&ペーストには付いてくるバイト列を1文字の後ろに隠せる
- VS-1 から VS-256 まで 256 個の variation selector があり、1バイト範囲と正確に対応するマッピングを作れる
😊 の後ろに hello のバイト [0x68, 0x65, 0x6c, 0x6c, 0x6f] を付けても、見た目は普通の絵文字1文字のように見える
- デコードでは
U+FE00..U+FE0F と U+E0100..U+E01EF の範囲を探して再びバイトへ戻し、ベース文字 は絵文字である必要はない
- この方法は Unicode の悪用であり、人間向けのコンテンツフィルターを回避したり、テキストに ウォーターマーク を埋め込んだりする用途に悪用されうる
見えないデータが1文字に付く仕組み
- Unicode テキストは コードポイント(codepoint) のシーケンスとして表現され、通常は
U+XXXX 形式で表記される
- 単純なラテン文字では、コードポイントと画面に見える文字は 1:1 で対応する
- 他の文字体系では、画面上の1文字が複数のコードポイントで構成されることがある
- 例: デーヴァナーガリーで
キー と読まれる文字は U+0915 と U+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件のコメント
Hacker News のコメント
Unicode の悪用としては、これは氷山の一角にすぎない。同じような手法で、Unicode 文字列を受け取るさまざまなシステムのバッファをあふれさせることができる。たいていはエラーやクラッシュで終わるが、運がよければかなり面白い挙動も出る
Python 3 以前の時代にペネトレーションテストをしていたとき、発音区別符号だけで1文字を複数バイトに膨らませ、バックエンド 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 を使え」以上のことをあまり知らないため、そのまま開いた状態になっていることが多い
)。ここでの要点は、コピー&ペースト時に隠れ、他の文字の「一部」のように扱われるようエンコードすることだhttps://news.ycombinator.com/item?id=42791378
API が絵文字しか受け付けないという制約のため、すぐに犯罪的な活用可能性が議論された。その用途では PUA は使えず、絵文字の中にエンコードする必要がある
指定非文字には
0xFFFF、0xFFFEや各面の最後の2コードポイントだけでなく、Arabic Presentation Forms の途中にある領域も含まれる。こういう用途に使う非文字をもっと確保できるよう、後からリストに追加されたのだと理解している認識されない PUA 文字で任意の文字を見えないようにウォーターマークすることはできない。結合文字として扱われないからだ。代わりに、別個にレンダリングされるプレースホルダーの四角が表示される。例:
— もちろん private use area を本当に私的に使っているなら、四角ではないかもしれない10年ほど前、Windows のファイル名の途中に U+202D LEFT-TO-RIGHT OVERRIDE を入れて同僚たちを驚かせたことがある。
funnypicturegnp.exeがfunnypictureexe.pngのように見えた写真のプレビューのように見えるカスタムアイコンまで付けると、かなりそれらしくなった
.exeはほとんど自動ブロックされるが、最近の悪性拡張子は .html であることが多く、難読化されたwindow.locationリダイレクトで偽ログインページを表示することがよくあるcute-cat-lmth.pngのような RTL 悪用は比較的よくあったが、検知も非常に簡単で、そうしたメールは即座にフィッシングとしてマークしていた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 対策は、すべての人間の相互作用に実在の身元で検証された鍵署名を要求することだけだが、それも A: 絶対に起きないだろうし、B: 腐敗した政府のある国や、民間産業の影響を強く受ける腐敗した政府のある国、たとえば米国で悪用され得る
もちろん文を
xxdに送れば現れる。現在のトップコメントの PUA 提案は、すぐに見えてしまうものとは違う追加テストをしてみると、ターミナルに貼り付けた後
xxdではメッセージが完全に無変形で通過するが、ターミナル上で選択して再度貼り付けると、mate terminal と konsole の X selection では数語だけ残って切り詰められた。切り詰めがターミナルによるものか X によるものかは分からない。xterm では最後のeが変形し、選択内容はさらに大きく切り詰められたファイルには文が無変形で記録される。したがって、ターミナルの外へコピーするときに一部のデータが落ちる、という可能性に近そうだ。文をテストファイルに
echoし、ブラウザで開いてテキストをコピーして確認した生成時のサンプリング方式に細工を入れれば、後で LLM を再度走らせて出力の傾向を観察し、フィンガープリントを検出できる。たとえば高確率トークンと低確率トークンを交互に選ぶ、といった具合だ。実際の実装は当然はるかに精巧になるだろうが、アイデアはそういう方向だ
興味深い点として、スクリーンリーダーは文字単位で移動すると、このような variation selector を検出できる。例の上で矢印キーで移動すると、「Smiling face with smiling eyes」「Symbol e zero one five five」「Symbol e zero one five c」のように読み上げられる
ただし使用する音声合成器によって変わるし、文書を普通に読んでいる最中ならそうした文字があるか分からないので、全体として大きな利点ではない
StegCloak0 も似た系統で、隠したペイロードを AES-256-CTR で暗号化し、このアイデアをさらに一歩押し進めている。なかなか良い小技だ
0 https://github.com/KuroLabs/stegcloak
ただし相手がデコードするには、パスワードの秘密値を共有する必要がある
タイトルは少し誤解を招く。「ベース文字は絵文字である必要はなく、variation selector の処理は通常の文字でも同じだ。絵文字にするとより面白いだけだ」と書かれている
この方式を 非絵文字文字に使うと、よりステルス性が高く、より厄介になる
単純な 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...