原文:https://paulbutler.org/2025/smuggling-arbitrary-data-through-an-emoji/
作者:Paul Butler
譯者:Kurt Pan
GUB-42的這篇黑客新聞評論吸引了我:
使用ZWJ(Zero Width Joiner)序列,理論上可以在單個表情符號中編碼無限量的資料。
是否真的可以在單個表情符號中編碼任意資料?
簡言之,是的。而且我找到了不用ZWJ的方法。實際上,你可以用任意Unicode字符編碼資料。
This sentence has a hidden message󠅟󠅘󠄐󠅝󠅩󠄜󠄐󠅩󠅟󠅥󠄐󠅖󠅟󠅥󠅞󠅔󠄐󠅤󠅘󠅕󠄐󠅘󠅙󠅔󠅔󠅕󠅞󠄐󠅝󠅕󠅣󠅣󠅑󠅗󠅕󠄐󠅙󠅞󠄐󠅤󠅘󠅕󠄐󠅤󠅕󠅨󠅤󠄑.
(嘗試將上面這行粘貼到該解碼器中)
Unicode將文本表示為一系列碼位,每個碼位基本上只是Unicode聯盟將含義分配給的數字。通常,特定的碼位寫為U+XXXX ,其中XXXX是表示大寫十六進制數字。
對於簡單的拉丁字母文本,在屏幕上出現的Unicode碼位和字元之間有一對一的映射。例如, U+0067代表字元g。
對於其他書寫系統,某些屏幕上的字元可能由多個碼位表示。字元की(在天城文中出現)由碼位U+0915和U+0940的連續對錶示。
Unicode將256個碼位指定為“變體選擇符”,稱為VS-1至VS-256。它們沒有自己的屏幕表示,但用於修改前面字符的呈現。
大部分 Unicode 字元並沒有相關的變體選擇符。由於 Unicode 是一個不斷演進且旨在與未來相容的標準,變體選擇符在轉換過程中應該被保留,即使處理它們的程式碼並不了解它們的含義。因此,碼位 U+0067(「g」)後面跟著 U+FE01(變體選擇符 2)呈現為小寫「g」,與單獨的 U+0067 完全相同;但若你複製並貼上這個字元,變體選擇符也會一併被複製。
由於 256 個變體選擇符剛好足以表示一個位元組的所有取值,這就提供了一種方法,可以在任何其他 Unicode 碼位中「隱藏」一個位元組的資料。
事實上,Unicode 規範並未明確規定關於多個變體選擇符連續出現的情況,只是暗示在呈現時應該忽略它們。
看到我想表達什麼了嗎?
我們可以將一系列變體選擇符連接起來,從而表示任意的位元組字串。
例如,假設我們要編碼資料 [0x68, 0x65, 0x6c, 0x6c, 0x6f],這代表文本「hello」。我們可以通過將每個位元組轉換成對應的變體選擇符,再將它們連接在一起來實現這一點。
變體選擇符被分成兩個碼位範圍:原始的 16 個位於 U+FE00 至 U+FE0F,另外 240 個位於 U+E0100 至 U+E01EF(包含這兩個範圍的端點)。
要將一個位元組轉換為變體選擇符,我們可以使用類似下面的 Rust 程式碼:
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
}
為了編碼位元 [0x68, 0x65, 0x6c, 0x6c, 0x6f] ,我們可以運行:
fn main() {
println!("{}", encode('😊', &[0x68, 0x65, 0x6c, 0x6c, 0x6f]));
}
輸出
😊󠅘󠅕󠅜󠅜󠅟
它看起來就像常規的表情符號一樣,但試試將其粘貼到解碼器中。
相反,如果我們使用格式化輸出,我們會看到發生了什麼:
fn main() {
println!("{:?}", encode('😊', &[0x68, 0x65, 0x6c, 0x6c, 0x6f]));
}
這將打印:
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"
揭示了原始輸出中“隱藏”的字符。
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
}
}
fn decode(variation_selectors: &str) -> Vec<u8> {
let mut result = Vec::new();
for variation_selector in variation_selectors.chars() {
if let Some(byte) = variation_selector_to_byte(variation_selector) {
result.push(byte);
} else if !result.is_empty() {
return result;
}
// 注意:在遇到第一個變體選擇符之前,我們會忽略所有非變體選擇符,以便跳過「基底字元」。
}
result
}
使用之:
use std::str::from_utf8;
fn main() {
let result = encode('😊', &[0x68, 0x65, 0x6c, 0x6c, 0x6f]);
println!("{:?}", from_utf8(&decode(&result)).unwrap()); // "hello"
}
請注意,基底字元不必是表情符號——變體選擇符對普通字元的處理方式是相同的,只不過使用表情符號會更有趣。
需要明確指出,這是一種對 Unicode 的濫用,你不應該這麼做。如果你開始思考其實際用途,請立即停止這個想法。
話雖如此,我能想到幾種可能被(濫)用的不軌方式:
由於以這種方式編碼的資料在呈現時是不可見的,人工版主或審查者不會察覺它們的存在。
有些技術利用文本中細微的變化來“水印”一條訊息,使得如果該訊息發送給多個人後被洩露,就能追蹤到原始接收者。變體選擇符序列便是一種能夠在大部分複製/貼上操作中保持有效、允許任意資料密度的方法。如果你願意,甚至可以對每個字元都進行水印處理。