[Перевод] Осторожнее с копипастом: фингерпринтинг текста непечатаемыми символами

Не хотите читать? Посмотрите демо.

Символы нулевой ширины — это непечатаемые управляющие символы, которые не отображаются большинством приложений. Н​апример, в э​то пред​ложение я вст​авил де​сять про​​белов н​улевой ширины, вы эт​о замет​или? (Подсказка: вставьте предложение в Diff Checker, чтобы увидеть местоположение символов!). Эти символы можно использовать как уникальные «отпечатки» текста для идентификации пользователей.

ofvq3t7vgzrbnf42lhj_vs4g9x8.png
Безусловно, он может здесь быть. И вы никогда не догадаетесь

Зачем?
Ну, изначальная причина не слишком интересна. Несколько лет назад я с командой участвовали в соревнованиях по различным видеоиграм. У команды была приватная страничка для важных объявлений, среди прочего. Но в итоге эти объявления стали репостить в других местах, с издевательствами над командой, раскрывая конфиденциальную информацию и командную тактику.

Защита сайта казалась довольно стойкой, поэтому мы выдвинули предположение, что действует инсайдер, который входит по логину и паролю, а потом просто копирует объявление и размещает в другом месте. Поэтому я разработал скрипт, который в каждом объявлении невидимо отпечатывает имя пользователя, которому отображается это объявление.

После недавнего поста Зака Айсана стало понятно, что людям интересна тема непечатаемых символов. Так что я решил опубликовать этот метод здесь вместе с интерактивной демонстрацией для всех. Примеры кода обновлены для современного 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.

Чтобы поиграться со скриптом, запускайте демо или смотрите исходный код.

© Habrahabr.ru