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

от автора

Меня заинтриговал комментарий 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.


ссылка на оригинал статьи https://habr.com/ru/articles/883342/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *