Меня заинтриговал комментарий 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.
Комментарии (22)
aborouhin
17.02.2025 16:38Удивило, насколько такие экзотические кейсы обработки юникода тем не менее поддерживаются софтом. Покопировал этот смайлик туда-сюда через несколько разных приложений - везде скрытые символы сохраняются. Но не везде остаются скрытыми. Скажем, MS Word показывает смайлик и 4 прямоугольника-плейсхолдера, а WhatsApp Desktop - прямоугольник и два очень длинных пробела.
NeoCode
17.02.2025 16:38Интересно, Юникод еще не Тьюринг-полный, или уже?
CitizenOfDreams
17.02.2025 16:38Интересно, Юникод еще не Тьюринг-полный, или уже?
Он уже, кажется, может тест Тьюринга пройти (в отличие от его создателей).
Rive
17.02.2025 16:38Некоторые медиаторы (мессенджеры, форумы и т. п.) просто вырежут странные внедрения в юникод (возможно, это санация против попыток взлома).
SergeyNovak
17.02.2025 16:38Проверил в телеге. Отправил и оно даже преобразовало смайлик в анимашку. Выбирал и в режимередактирования и без редактирования полное сообщение копировал. Вставил в декодер - увидел свой текст.
SquareRootOfZero
17.02.2025 16:38Наверное, чем-то таким занимался бы Ленин вместо письма молоком, если бы тот рассказ написали веком позже.
akakoychenko
17.02.2025 16:38Ох, как же на днях меня текст с эмодзи выбесил... Делаешь len(text) в python - ровно 255 символов. Вставляешь в Microsoft SQL Server в поле nvarchar(255), и ловишь ошибку переполнения буффера...
mkmax
17.02.2025 16:38Вставил в пуск-выполнить. Да, очень скрытые символы. Блокнот тоже красиво рисует.
CitizenOfDreams
17.02.2025 16:38Никогда не думал, что буду с ностальгией вспоминать времена 437, 866, 1251 и 8859, когда с кодировками все было так просто, понятно и безопасно.
itdude
17.02.2025 16:38Я бы сказал, что РІ те времена РЅРµ РІСЃС‘ РїСЂСЏРј так хорошо было.
gafaroff77
17.02.2025 16:38:) лучше и не сказать.
Даа, время Shtirlitz минуло с приходом UTF8. Однако, все еще в запасах держу сей инструмент, иногда пригождается.
nesolodov
17.02.2025 16:38Там, на HN, была ещё одна интересная ссылка: https://github.com/KuroLabs/stegcloak
шифрование скрытой полезной нагрузки с помощью AES-256-CTRXobotun
17.02.2025 16:38У вас в https://github.com/KuroLabs/stegcloak  затесался, и ссылка побилась. Или это шутка юмора была?
Drazd
17.02.2025 16:38Да, но... В Notepad++ "спрятанные" символы видно очень бытсро. Соответственно всякие DLP будут находить подобные утечки
impwx
Практическое применение сомнительно - слишком просто обнаруживается и вырезается, plausible deniability как в случае со стеганографией отсутствует.
datacompboy
Практическое применение -- обход проверки на длину строки, чтобы вызвать buffer overflow или просто стриггерить DoS вызванный медленной обработкой данных
impwx
Это как должна быть реализована проверка длины строки, чтобы ее можно было таким образом обойти?
datacompboy
Использовать длину в символах вместо длины в байтах... :)
Там, чтобы при обрезании не получать поломанных символов...
impwx
Не, ну если складывать метры с килограммами, то и не такое можно наворотить. Обычно получить количество байт гораздо проще, чем число видимых символов - из известных мне языков только Python делает наоборот. Другое дело, что с такой логикой всё сломается гораздо раньше - с первым символом, кодируемым более чем одним байтом. Скрытые символы ситуацию не ухудшат.
Ну а с точки зрения DoS или увеличения времени обработки они, кажется, не более опасны, чем если добавить в текст много пробелов.
iroln
В Python не прокатит.
datacompboy
Собственно, см. ниже :))
Player17
Как у мелкомягких, была проблема с длиной строки, после длительного пробела, который весь не отображался в окне, стояло расширение, которое, в свою очередь, тоже не было видно. Визуально вычислить вредоносный файл не было возможности.