Фингерпринтинг текст непечатаемыми символами
}{@kepСимволы нулевой ширины — это непечатаемые управляющие символы, которые не отображаются большинством приложений. Например, в это предложение я вставил десять пробелов нулевой ширины, вы это заметили? (Подсказка: вставьте предложение в Diff Checker, чтобы увидеть местоположение символов!). Эти символы можно использовать как уникальные «отпечатки» текста для идентификации пользователей.
Зачем?
Ну, изначальная причина не слишком интересна. Несколько лет назад я с командой участвовали в соревнованиях по различным видеоиграм. У команды была приватная страничка для важных объявлений, среди прочего. Но в итоге эти объявления стали репостить в других местах, с издевательствами над командой, раскрывая конфиденциальную информацию и командную тактику.
Защита сайта казалась довольно стойкой, поэтому мы выдвинули предположение, что действует инсайдер, который входит по логину и паролю, а потом просто копирует объявление и размещает в другом месте. Поэтому я разработал скрипт, который в каждом объявлении невидимо отпечатывает имя пользователя, которому отображается это объявление.
После недавнего поста Зака Айсана стало понятно, что людям интересна тема непечатаемых символов. Так что я решил опубликовать этот метод здесь вместе с интерактивной демонстрацией для всех. Примеры кода обновлены для современного JavaScript, но общая логика одинакова.
Как?
Точные шаги и логика описаны ниже, но если в двух словах: строка имени пользователя преобразуется в двоичную форму, затем двоичный файл преобразуется в серию непечатаемых символов, представляющих каждый бит. Потом непечатаемая строка незаметно вставляется в текст. Если текст опубликован на другом сайте, строку непечатаемых символов можно извлечь и провести обратный процесс, чтобы выяснить имя пользователя, который сделал копипаст!
Фингерпринтинг текста
1. Получить имя пользователя, вошедшего в систему, и преобразовать его в двоичный файл.
Здесь мы просто преобразуем каждую букву имени пользователя в двоичный эквивалент.
const zeroPad = num => ‘00000000’.slice(String(num).length) + num; const textToBinary = username => ( username.split('').map(char => zeroPad(char.charCodeAt(0).toString(2))).join(' ') );
2. Взять имя пользователя в бинарном формате и преобразовать его в непечатаемые символы
Следующий скрипт перебирает двоичную строку и преобразует каждый бит 1 в непечатаемый пробел, каждый 0 — в непечатаемый символ запрета лигатур (non-joiner). После преобразования каждой буквы вставляем непечатаемый символ разрешения лигатур (joiner) — и переходим к следующей.
const binaryToZeroWidth = binary => ( binary.split('').map((binaryNum) => { const num = parseInt(binaryNum, 10); if (num === 1) { return ''; // zero-width space } else if (num === 0) { return ''; // zero-width non-joiner } return ''; // zero-width joiner }).join('') // zero-width no-break space );
3. Вставка «имени пользователя» в непечатаемый конфиденциальный текст
Здесь просто вставляем блок непечатаемых символов в конфиденциальный текст.
Извлечение имени пользователя из помеченного текста
Те же действия в обратном порядке.
1. Извлечь непечатаемое «имя пользователя» из конфиденциального текста
Удалить конфиденциальный текст из строки, оставив только непечатаемые символы.
2. Преобразовать непечатаемое «имя пользователя» обратно в двоичный файл
Здесь мы разбиваем строку на фрагменты, с учётом добавленных межбуквенных разделителей. Это даёт эквивалент в управляющих символах для каждой буквы имени пользователя! Перебираем символы и возвращаем 1 или 0, чтобы воссоздать двоичную строку. Если не находим соответствующий 1 или 0, то значит попали на межбуквенный разделитель (символ разрешения лигатур) и, таким образом, завершиили двоичное преобразование для символа: можно добавить к строке один пробел и переходить к следующему символу.
const zeroWidthToBinary = string => ( string.split('').map((char) => { // zero-width no-break space if (char === '') { // zero-width space return '1'; } else if (char === '') { // zero-width non-joiner return '0'; } return ' '; // add single space }).join('') );
3. Преобразование имени пользователя из двоичного формата обратно в текст
В конце концов, анализируем двоичную строку и преобразуем каждую серию 1 и 0 в соответствующий символ.
const binaryToText = string => ( string.split(' ').map(num => String.fromCharCode(parseInt(num, 2))).join('') );
Заключение
Компании как никогда много внимания уделяют утечкам информации и поиску инсайдеров. Этот лишь один из многих трюков, которые можно использовать. В зависимости от направления вашей работы, может быть жизненно важно понимать риски, связанные с копированием текста. Очень немногие приложения отображают непечатаемые символы. Например, вы можете предположить, что ваш терминал попытается их отобразить (мой нет!).
Если вернуться к секретной доске объявлений, то план сработал как надо. Вскоре после внедрения скрипта вышло новое объявление. В течение нескольких часов текст распространили в другом месте с прикрепленной непечатаемой строкой. Имя пользователя виновника успешно идентифицировали, и его забанили: хэппи-энд!
Конечно, есть определённые оговорки по использованию этого метода. Например, если пользователь знает о скрипте, то теоретически может заменить непечатаемые символы, чтобы подставить другого человека. Так что лучше вместо имени пользователя вставлять уникальный секретный ID.
Чтобы поиграться со скриптом, запускайте демо или смотрите исходный код.