cover_image

通過表情符號隱藏任意資料

Kurt Pan XPTY
2025年02月17日 11:42

原文: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󠅟󠅘󠄐󠅝󠅩󠄜󠄐󠅩󠅟󠅥󠄐󠅖󠅟󠅥󠅞󠅔󠄐󠅤󠅘󠅕󠄐󠅘󠅙󠅔󠅔󠅕󠅞󠄐󠅝󠅕󠅣󠅣󠅑󠅗󠅕󠄐󠅙󠅞󠄐󠅤󠅘󠅕󠄐󠅤󠅕󠅨󠅤󠄑.

(嘗試將上面這行粘貼到該解碼器中)

  • https://emoji.paulbutler.org/?mode=decode

一些背景

Unicode將文本表示為一系列碼位,每個碼位基本上只是Unicode聯盟將含義分配給的數字。通常,特定的碼位寫為U+XXXX ,其中XXXX是表示大寫十六進制數字。

對於簡單的拉丁字母文本,在屏幕上出現的Unicode碼位和字元之間有一對一的映射。例如, U+0067代表字元g

對於其他書寫系統,某些屏幕上的字元可能由多個碼位表示。字元की(在天城文中出現)由碼位U+0915U+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 - 16as 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('😊', &[0x680x650x6c0x6c0x6f]));
}

輸出

😊󠅘󠅕󠅜󠅜󠅟

它看起來就像常規的表情符號一樣,但試試將其粘貼到解碼器中。

  • https://emoji.paulbutler.org/?mode=decode

相反,如果我們使用格式化輸出,我們會看到發生了什麼:

fn main() {
    println!("{:?}", encode('😊', &[0x680x650x6c0x6c0x6f]));
}

這將打印:

"😊\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 - 0xFE00as u8)
    } else if (0xE0100..=0xE01EF).contains(&variation_selector) {
        Some((variation_selector - 0xE0100 + 16as 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('😊', &[0x680x650x6c0x6c0x6f]);
    println!("{:?}", from_utf8(&decode(&result)).unwrap()); // "hello"
}

請注意,基底字元不必是表情符號——變體選擇符對普通字元的處理方式是相同的,只不過使用表情符號會更有趣。

這會被濫用嗎?

需要明確指出,這是一種對 Unicode 的濫用,你不應該這麼做。如果你開始思考其實際用途,請立即停止這個想法。

話雖如此,我能想到幾種可能被(濫)用的不軌方式:

  1. 隱匿資料通過人工內容過濾器

由於以這種方式編碼的資料在呈現時是不可見的,人工版主或審查者不會察覺它們的存在。

  1. 為文本添加水印

有些技術利用文本中細微的變化來“水印”一條訊息,使得如果該訊息發送給多個人後被洩露,就能追蹤到原始接收者。變體選擇符序列便是一種能夠在大部分複製/貼上操作中保持有效、允許任意資料密度的方法。如果你願意,甚至可以對每個字元都進行水印處理。