Меня заинтриговал комментарий 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 — хорошая проверка этого:

Однако в целом модели не испытывают особого желания декодировать их. Впрочем, если дать некоторым моделям интерпретатор кода, то они действительно могут решить эту задачу!
Вот пример того, как Gemini 2 Flash решает задачу всего за семь секунд при помощи Codename Goose и foreverVM (примечание: я работаю над разработкой foreverVM).
Можно также посмотреть более длинное видео о том, как эту задачу решает Claude.
Автор: PatientZero