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

Комментарии (22)


  1. impwx
    17.02.2025 16:38

    Практическое применение сомнительно - слишком просто обнаруживается и вырезается, plausible deniability как в случае со стеганографией отсутствует.


    1. datacompboy
      17.02.2025 16:38

      Практическое применение -- обход проверки на длину строки, чтобы вызвать buffer overflow или просто стриггерить DoS вызванный медленной обработкой данных


      1. impwx
        17.02.2025 16:38

        Это как должна быть реализована проверка длины строки, чтобы ее можно было таким образом обойти?


        1. datacompboy
          17.02.2025 16:38

          Использовать длину в символах вместо длины в байтах... :)
          Там, чтобы при обрезании не получать поломанных символов...


          1. impwx
            17.02.2025 16:38

            Не, ну если складывать метры с килограммами, то и не такое можно наворотить. Обычно получить количество байт гораздо проще, чем число видимых символов - из известных мне языков только Python делает наоборот. Другое дело, что с такой логикой всё сломается гораздо раньше - с первым символом, кодируемым более чем одним байтом. Скрытые символы ситуацию не ухудшат.

            Ну а с точки зрения DoS или увеличения времени обработки они, кажется, не более опасны, чем если добавить в текст много пробелов.


          1. iroln
            17.02.2025 16:38

            В Python не прокатит.


        1. datacompboy
          17.02.2025 16:38

          Собственно, см. ниже :))


        1. Player17
          17.02.2025 16:38

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


  1. aborouhin
    17.02.2025 16:38

    Удивило, насколько такие экзотические кейсы обработки юникода тем не менее поддерживаются софтом. Покопировал этот смайлик туда-сюда через несколько разных приложений - везде скрытые символы сохраняются. Но не везде остаются скрытыми. Скажем, MS Word показывает смайлик и 4 прямоугольника-плейсхолдера, а WhatsApp Desktop - прямоугольник и два очень длинных пробела.


  1. NeoCode
    17.02.2025 16:38

    Интересно, Юникод еще не Тьюринг-полный, или уже?


    1. CitizenOfDreams
      17.02.2025 16:38

      Интересно, Юникод еще не Тьюринг-полный, или уже?

      Он уже, кажется, может тест Тьюринга пройти (в отличие от его создателей).


  1. Rive
    17.02.2025 16:38

    Некоторые медиаторы (мессенджеры, форумы и т. п.) просто вырежут странные внедрения в юникод (возможно, это санация против попыток взлома).


    1. SergeyNovak
      17.02.2025 16:38

      Проверил в телеге. Отправил и оно даже преобразовало смайлик в анимашку. Выбирал и в режимередактирования и без редактирования полное сообщение копировал. Вставил в декодер - увидел свой текст.


  1. SquareRootOfZero
    17.02.2025 16:38

    Наверное, чем-то таким занимался бы Ленин вместо письма молоком, если бы тот рассказ написали веком позже.


  1. akakoychenko
    17.02.2025 16:38

    Ох, как же на днях меня текст с эмодзи выбесил... Делаешь len(text) в python - ровно 255 символов. Вставляешь в Microsoft SQL Server в поле nvarchar(255), и ловишь ошибку переполнения буффера...


  1. mkmax
    17.02.2025 16:38

    Вставил в пуск-выполнить. Да, очень скрытые символы. Блокнот тоже красиво рисует.


  1. CitizenOfDreams
    17.02.2025 16:38

    Никогда не думал, что буду с ностальгией вспоминать времена 437, 866, 1251 и 8859, когда с кодировками все было так просто, понятно и безопасно.


    1. itdude
      17.02.2025 16:38

      Я бы сказал, что РІ те времена РЅРµ РІСЃС‘ РїСЂСЏРј так хорошо было.


      1. gafaroff77
        17.02.2025 16:38

        :) лучше и не сказать.
        Даа, время Shtirlitz минуло с приходом UTF8. Однако, все еще в запасах держу сей инструмент, иногда пригождается.


  1. nesolodov
    17.02.2025 16:38

    Там, на HN, была ещё одна интересная ссылка: https://github.com/KuroLabs/stegcloak
    шифрование скрытой полезной нагрузки с помощью AES-256-CTR


    1. Xobotun
      17.02.2025 16:38

      У вас в https://github.com/KuroLabs/stegcloak затесался, и ссылка побилась. Или это шутка юмора была?


  1. Drazd
    17.02.2025 16:38

    Да, но... В Notepad++ "спрятанные" символы видно очень бытсро. Соответственно всякие DLP будут находить подобные утечки