
作者 | Paul Butler 翻譯 | 鄭麗媛 出品 | CSDN(ID:CSDNnews)
【CSDN 編者按】在數字通訊的世界裡,人們經常使用表情符號來讓對話變得更加生動有趣。但你是否曾想過,這些看似簡單的表情符號背後可能隱藏著一些秘密資料?本文作者聽聞這一可能性後,親測發現:在普通文字或表情符號中,真的可以嵌入不可見的資料,實現資訊的隱蔽傳輸。
最近,有個網友在 Hacker News 上的評論引起了我的興趣:
“理論上,使用零寬度連線符(ZWJ)序列,你可以在一個表情符號中編碼無限量的資料。”
那麼,真的可以在一個表情符號中編碼任意資料嗎?
我試了一下,真的可以——只不過我用的方法並不需要 ZWJ,而且在任何 Unicode 字元中都可以編碼資料。

背景介紹
Unicode 以程式碼點的序列形式表示文字,每個程式碼點基本上只是一個數字,由 Unicode 聯盟為其賦予特定含義。通常來說,一個具體的程式碼點會寫成 U+XXXXXXXX,其中 XXXXXXXX 是用大寫的十六進位制表示的數字。
對於簡單的拉丁字母文字,Unicode 程式碼點和螢幕上顯示的字元之間存在一一對應的關係。例如,U+0067 代表字元“g”。
對於其他書寫系統,某些螢幕上顯示的字元可能由多個程式碼點表示。例如,印地語中的 की 字元就是由連續的兩個程式碼點 U+0915 和 U+0940 組合而成。

變體選擇器
Unicode 指定了 256 個程式碼點作為“變體選擇器”,從 VS-1 到 VS-256。這些選擇器本身沒有螢幕顯示的表現形式,而是用於修改前一個字元的顯示效果。
大多數 Unicode 字元並沒有與之關聯的變體。由於 Unicode 是一個不斷發展的標準,並且旨在保持未來相容性,因此即使程式碼處理程式不瞭解其含義,也應保留變體選擇器。例如,程式碼點“g”(U+0067)後面跟著 VS-2(U+FE01)時,顯示為小寫的“g”,與單獨的“g”(U+0067)完全相同。但如果你複製並貼上它,變異選擇器會隨之一起被複制。
既然 256 正好是一個位元組的變體數量,這就為我們提供了一種方法,可以在任何其他 Unicode 程式碼點中“隱藏”一個位元組的資料。
實際上,Unicode 規範中並未明確提到多個變體選擇器的序列,只是暗示在渲染過程中應該忽略它們——所以,你明白我的意思了嗎?
我們可以將一系列變體選擇器連線起來,表示任意的位元組字串。
例如,假設我們想要編碼資料“hello”,它對應的位元組值是 [0x68, 0x65, 0x6c, 0x6c, 0x6f]。我們可以透過將每個位元組轉換為對應的變體選擇器,然後將它們串聯起來。
變體選擇器的程式碼點被分為兩段:最初的 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;
}
// note: we ignore non-variation selectors until we have
// encountered the first one, as a way of skipping the "base
// character".
}
result
}
使用示例如下:
use std::str::from_utf8;
fn main() {
let result = encode('😊', &[0x68, 0x65, 0x6c, 0x6c, 0x6f]);
println!("{:?}", from_utf8(&decode(&result)).unwrap()); // "hello"
}
請注意,基礎字元不一定是表情符號——變體選擇器的處理方式對於常規字元也是一樣的,只不過用表情符號看起來更有趣。

這種方法會被濫用嗎?
準確來說,這確實是對 Unicode 的一種濫用——如果你的腦海中正在考慮這種技術的實際用途,請打消這個念頭。
話雖如此,我還是想到了幾種可能的惡意用途:
(1)繞過人工內容稽核過濾器:由於這種方式編碼的資料在渲染後不可見,人工稽核員將無法察覺這些資料的存在。
(2)給文字新增水印:有些技術可以利用文字中的微妙變化給訊息新增“水印”,以便在訊息被髮送給多人後洩露時,可以追蹤到原始接收者。變體選擇器序列提供了一種持久化的方式,能夠經受住大多數複製/貼上操作,且允許儲存任意密度的資料。如果你願意,甚至可以對每個字元進行標記。

LLM 能解碼嗎?
自從這篇文章出現在 Hacker News 後,有些人開始問 LLM(大語言模型)能否處理這種隱藏資料。
一般來說,分詞器確實似乎會將變體選擇器作為標記保留下來,因此理論上模型是可以訪問它們的。OpenAI 的分詞器就是一個很好的例子:

可總體來說,模型並不會主動解碼這些資料——不過當與程式碼直譯器結合使用時,有一些模型能夠成功解碼它們。以下是 Gemini 2 Flash 在 7 秒內成功解碼的示例,使用了 Codename Goose 和 foreverVM(免責宣告:我在 foreverVM 團隊中工作)。
原文連結:https://paulbutler.org/2025/smuggling-arbitrary-data-through-an-emoji/
官方站點:www.linuxprobe.com
Linux命令大全:www.linuxcool.com

劉遄老師QQ:5604215
Linux技術交流群:2636170
(新群,火熱加群中……)
想要學習Linux系統的讀者可以點選"閱讀原文"按鈕來了解書籍《Linux就該這麼學》,同時也非常適合專業的運維人員閱讀,成為輔助您工作的高價值工具書!