Контрабанда данных внутри эмодзи

в 16:23, , рубрики: Unicode, водяные знаки, кодировки текста, кодовые точки, Стеганография

Меня заинтриговал комментарий GuB-42 на Hacker News:

При помощи последовательностей ZWJ (Zero Width Joiner) теоретически можно закодировать в один эмодзи неограниченный объём данных.

Действительно ли можно закодировать в один эмодзи произвольные данные?

tl;dr: да, однако я нашёл решение и без ZWJ. На самом деле, можно закодировать данные в любой символ Unicode. Например, в этом предложении есть скрытое послание: This sentence has a hidden message󠅟󠅘󠄐󠅝󠅩󠄜󠄐󠅩󠅟󠅥󠄐󠅖󠅟󠅥󠅞󠅔󠄐󠅤󠅘󠅕󠄐󠅘󠅙󠅔󠅔󠅕󠅞󠄐󠅝󠅕󠅣󠅣󠅑󠅗󠅕󠄐󠅙󠅞󠄐󠅤󠅘󠅕󠄐󠅤󠅕󠅨󠅤󠄑. (Попробуйте вставить его в декодер.)

Вводная информация

Unicode представляет текст в виде последовательности кодовых точек, каждая из которых — это, по сути, число, которому Unicode Consortium присвоил смысл. Обычно кодовая точка записывается в виде U+XXXX, где XXXX — это шестнадцатеричное число, записанное в верхнем регистре.

Для простого текста на латинице существует уникальное сопоставление между кодовыми точками Unicode и символами, отображаемыми на экране. Например, U+0067 обозначает символ g.

В других системах письма некоторые экранные символы могут быть представлены несколькими кодовыми точками. Символ की (в письме девангари) представлен в виде последовательного соединения кодовых точек U+0915 и U+0940.

Вариантные селекторы

В Unicode 256 кодовых точек используются в качестве «вариантных селекторов», они имеют названия с VS-1 по VS-256. Сами по себе они не имеют экранного представления, а используются для изменения представления предыдущего символа.

У большинства символов Unicode нет вариаций. Так как Unicode — это развивающийся стандарт, нацеленный на совместимость с будущими изменениями, при преобразованиях вариантные селекторы должны сохраняться, даже если их смысл неизвестен обрабатывающему их коду. Поэтому кодовая точка U+0067 («g»), за которой следует U+FE01 (VS-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, и вам не стоит этого делать. Если вы задумались о практическом применении, то немедленно прекратите.

Тем не менее, я могу придумать пару способов злоумышленного использования:

1. Просачивание данных через живые фильтры контента

Так как закодированные этим способом данные невидимы при рендеринге, живой модератор не будет знать об их наличии.

2. Водяные знаки в тексте

Существуют методики использования незначительных вариаций текста для внесения в сообщения «водяных знаков», чтобы в случае утечки при отправке информации множеству людей можно было отследить исходного получателя. Последовательности вариантных селекторов позволят внести пометки, сохраняющиеся в большинстве операций копирования-вставки, и обеспечивающие произвольную плотность данных. При желании можно даже внести водяные знаки в каждый символ.

Дополнение: может ли LLM расшифровать эти данные?

Мой пост попал на Hacker News, где возник вопрос о том, как с этими скрытыми данными будут обращаться LLM.

В общем случае токенизаторы, похоже, сохраняют вариантные селекторы в качестве токенов, так что в теории модель имеет к ним доступ. Токенизатор OpenAI — хорошая проверка этого:

OpenAI tokenizer

Однако в целом модели не испытывают особого желания декодировать их. Впрочем, если дать некоторым моделям интерпретатор кода, то они действительно могут решить эту задачу!

Вот пример того, как Gemini 2 Flash решает задачу всего за семь секунд при помощи Codename Goose и foreverVM (примечание: я работаю над разработкой foreverVM).

Можно также посмотреть более длинное видео о том, как эту задачу решает Claude.

Автор: PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js