Недавно мне попался один любопытный твит, отправленный @FakeUnicode. В нём был фрагмент JS-кода, который выглядел довольно безобидным. Однако, выполнение этого кода привело к неожиданному эффекту. Мне понадобилось некоторое время для того, чтобы разобраться в том, что происходит. В итоге я решил рассказать о том, как я исследовал код. Возможно, кому-то это пригодится.

Вот фрагмент кода, о котором идёт речь:

for(A in {А:0}){
  alert(unescape(escape(A).replace(/u.{8}/g,[])))
};

Как вы думаете, что случится, если его выполнить?


Спойлер: поломается Хабр. Поэтому в статье приведён «корректный» код, исходные символы смотрите в оригинале статьи.

Вполне очевидно, что перед нами цикл for-in, который перебирает перечислимые свойства объекта. В объекте есть лишь одно свойство, которое имеет имя A, поэтому я подумал, что команда alert выведет букву A. Однако, я ошибался.


Сообщение, выведенное командой alert.

Это меня очень удивило, поэтому я, вооружившись консолью разработчика Chrome, принялся исследовать таинственный код. Если вы хотите повторить мои эксперименты — скопируйте код из вышеупомянутого твита в консоль и приступайте к работе.

Обнаружение скрытых кодовых точек


Для начала, чтобы понять, что именно происходит в этом коде, я решил его упростить.

for(A in {А:0}){console.log(А)};
// А

Пока ничего интересного увидеть не удалось. Поэтому я продолжил.

for(А in {А:0}){console.log(escape(А))};
// A%uDB40%uDD6C%uDB40%uDD77%uDB40%uDD61%uDB40%uDD79%uDB40%uDD73%uDB40%uDD20%uDB40%uDD62%uDB40%uDD65%uDB40%uDD20%uDB40%uDD77%uDB40%uDD61%uDB40%uDD72%uDB40%uDD79%uDB40%uDD20%uDB40%uDD6F%uDB40%uDD66%uDB40%uDD20%uDB40%uDD4A%uDB40%uDD61%uDB40%uDD76%uDB40%uDD61%uDB40%uDD73%uDB40%uDD63%uDB40%uDD72%uDB40%uDD69%uDB40%uDD70%uDB40%uDD74%uDB40%uDD20%uDB40%uDD63%uDB40%uDD6F%uDB40%uDD6E%uDB40%uDD74%uDB40%uDD61%uDB40%uDD69%uDB40%uDD6E%uDB40%uDD69%uDB40%uDD6E%uDB40%uDD67%uDB40%uDD20%uDB40%uDD71%uDB40%uDD75%uDB40%uDD6F%uDB40%uDD74%uDB40%uDD65%uDB40%uDD73%uDB40%uDD2E%uDB40%uDD20%uDB40%uDD4E%uDB40%uDD6F%uDB40%uDD20%uDB40%uDD71%uDB40%uDD75%uDB40%uDD6F%uDB40%uDD74%uDB40%uDD65%uDB40%uDD73%uDB40%uDD20%uDB40%uDD3D%uDB40%uDD20%uDB40%uDD73%uDB40%uDD61%uDB40%uDD66%uDB40%uDD65%uDB40%uDD21

Ну ничего ж себе! Откуда всё это взялось? Я решил немного притормозить и посмотреть на длину строки.

for(А in {А:0}){console.log(А.length)};
// 129

Интересная картина. Далее я скопировал А из объекта и в ходе копирования обнаружил, что консоль Chrome работает тут с чем-то скрытым. Оказалось, что при попытке пробежаться по строке с помощью клавиш-стрелок, курсор в ней «застревает».

Давайте взглянем на то, что скрыто в имени свойства объекта. Посмотрим на значения всех 129-ти кодовых единиц.

const propertyName = 'А';
for(let i = 0; i < propertyName.length; i++) {
  console.log(propertyName[i]);
  // для получения значений кодовых единиц воспользуемся charCodeAt
  console.log(propertyName.charCodeAt(i));
}
// A
// 65
// ?
// 56128
// ?
// 56684
// ...

Этот фрагмент кода выводит сначала букву А, которая имеет кодовую единицу со значением 65, за ней следует довольно много кодовых единиц, значения которых находятся где-то в районе 56000. Команда console.log() выводит их с помощью знаменитого знака вопроса, который указывает на то, что система не знает, как ей обрабатывать подобные кодовые единицы.

Суррогатные пары в JavaScript


Выведенные символы являются частями так называемых суррогатных пар, которые используются для представления кодовых точек, значения которых превышает 16 бит, или, другими словами, тех, которые имеют значение кодовой точки большее, чем 65535. Это нужно потому, что сам по себе Unicode определяет 1114122 различных кодовых точек, а строковой формат, используемый JavaScript — это UTF-16. Это означает, что только первые 65536 кодовых точек, определённых в Unicode, могут быть представлены в JavaScript с помощью одной кодовой единицы.

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

Кстати, я однажды делал доклад на эту тему, вот презентация, которая, вполне возможно, поможет вам лучше понять концепции кодовых точек, суррогатных пар и эмотиконов.

Итак, мы обнаружили 129 кодовых единиц, 128 из которых являются суррогатными парами, представляющими 64 кодовые точки. Что это за кодовые точки?

Для того, чтобы извлечь из строки значения кодовых точек, очень пригодится цикл for-of, который перебирает кодовые точки строки (а не кодовые единицы, как первый цикл for). Мы воспользуемся оператором ..., внутри которого работает цикл for-of.

console.log([...'А']);
// (65) ["А", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]

Как видите, console.log() даже не знает, как отображать те кодовые точки, которые мы получили, поэтому взглянем подробнее на то, с чем мы работаем.

//для того, чтобы получить значения кодовых точек, воспользуемся методом codePointAt
console.log([...'А'].map(c => c.codePointAt(0)));
// [65, 917868, 917879, ...]

Работая с кодовыми единицами и с кодовыми точками в JavaScript, обратите внимание на две функции: charCodeAt() и codePointAt(). Они ведут себя слегка по-разному, поэтому, прежде чем пользоваться ими, вам, возможно, пригодится посмотреть документацию.

Имена идентификаторов в объектах JavaScript


Кодовые точки 917868, 917879 и другие — это часть вариантных селекторов Unicode. Вариантные селекторы используются для указания стандартизованных вариантных последовательностей для математических символов, эмотиконов, букв монгольского квадратного письма и унифицированных идеограмм ККЯ, соответствующим совместимым идеограммам ККЯ. Их обычно не используют в одиночку. Почему это важно в нашем исследовании?

Если обратиться к спецификации ECMAScript, можно обнаружить, что идентификатор имени свойства может включать в себя нечто большее, чем просто «обычные символы».

  Identifier ::
  IdentifierName но не ReservedWord
IdentifierName ::
  IdentifierStart
  IdentifierName IdentifierPart
IdentifierStart ::
  UnicodeLetter
  $
  _
  \ UnicodeEscapeSequence
IdentifierPart ::
  IdentifierStart
  UnicodeCombiningMark
  UnicodeDigit
  UnicodeConnectorPunctuation
  <ZWNJ>
  <ZWJ>

В соответствии с тем, что представлено выше, идентификатор может состоять из IdentifierName и
IdentifierPart</code>. Тут важно обратить внимание на определение <code>IdentifierPart</code>. Например, допустимы следующие имена идентификаторов, учитывая то, что особый символ не находится в самом начале идентификатора:

<source>
const examples = {
  // Пример UnicodeCombiningMark
  somethingi: 'LATIN SMALL LETTER I WITH CIRCUMFLEX',
  somethingi\u0302: 'I + COMBINING CIRCUMFLEX ACCENT',

  // Пример UnicodeDigit
  something?: 'ARABIC-INDIC DIGIT ONE',
  something\u0661: 'ARABIC-INDIC DIGIT ONE',

  // Пример UnicodeConnectorPunctuation
  something?: 'DASHED LOW LINE',
  something\ufe4d: 'DASHED LOW LINE',

  //Пример символов ZWJ и ZWNJ
  something\u200c: 'ZERO WIDTH NON JOINER',
  something\u200d: 'ZERO WIDTH JOINER'
}

Если выполнить это выражение, получится следующее:

{
  somethingi?: "ARABIC-INDIC DIGIT ONE",
  somethingi: "I + COMBINING CIRCUMFLEX ACCENT",
  something?: "ARABIC-INDIC DIGIT ONE"
  something?: "DASHED LOW LINE",
  something: "ZERO-WIDTH NON-JOINER",
  something: "ZERO-WIDTH JOINER"
}

Всё это привело меня к небольшому открытию. Вот, что говорит на эту тему спецификация ECMAScript:

«Два символа IdentifierName, канонически эквивалентные в соответствии со стандартом Юникода, не являются одинаковыми, если они не представлены совершенно одинаковыми последовательностями кодовых единиц.»

Это означает, что два идентификатора ключа объекта могут выглядеть абсолютно одинаково, но состоять из разных кодовых единиц, и это означает, что они оба будут включены в объект. В нашем случае, например, символ i? и символ i со следующим за ним COMBINING CIRCUMFLEX ACCENT выглядят абсолютно одинаково, но имеют разные значения кодовых единиц. Как результат, кажется, что в объекте имеются два свойства с одинаковыми именами. То же самое справедливо и для ключей со следующими за ними символами ZWJ и ZWNJ. Выглядят они одинаково, но одинаковыми не являются.

Однако, вернёмся к нашей теме. Значения вариантных селекторов, которые мы обнаружили, принадлежат к категории UnicodeCombiningMark, что делает их приемлемыми именами идентификаторов (даже если они невидимы). Они невидимы, так как, наиболее вероятно, система показывает лишь результат их воздействия на текст, если они использованы в допустимой комбинации.

Функция escape и замена символов


Функция escape() проходится по всем кодовым единицам и выполняет замену определённых символов шестнадцатеричными управляющими последовательностями. Она трансформирует в строку и букву А, и все части суррогатных пар. То, что было невидимым, превратится в длинную строку, с которой мы уже сталкивались в самом начале.

A%uDB40%uDD6C%uDB40%uDD77%uDB40%uDD61%uDB40%uDD79%uDB40%uDD73%uDB40%uDD20%uDB40%uDD62%uDB40%uDD65%uDB40%uDD20%uDB40%uDD77%uDB40%uDD61%uDB40%uDD72%uDB40%uDD79%uDB40%uDD20%uDB40%uDD6F%uDB40%uDD66%uDB40%uDD20%uDB40%uDD4A%uDB40%uDD61%uDB40%uDD76%uDB40%uDD61%uDB40%uDD73%uDB40%uDD63%uDB40%uDD72%uDB40%uDD69%uDB40%uDD70%uDB40%uDD74%uDB40%uDD20%uDB40%uDD63%uDB40%uDD6F%uDB40%uDD6E%uDB40%uDD74%uDB40%uDD61%uDB40%uDD69%uDB40%uDD6E%uDB40%uDD69%uDB40%uDD6E%uDB40%uDD67%uDB40%uDD20%uDB40%uDD71%uDB40%uDD75%uDB40%uDD6F%uDB40%uDD74%uDB40%uDD65%uDB40%uDD73%uDB40%uDD2E%uDB40%uDD20%uDB40%uDD4E%uDB40%uDD6F%uDB40%uDD20%uDB40%uDD71%uDB40%uDD75%uDB40%uDD6F%uDB40%uDD74%uDB40%uDD65%uDB40%uDD73%uDB40%uDD20%uDB40%uDD3D%uDB40%uDD20%uDB40%uDD73%uDB40%uDD61%uDB40%uDD66%uDB40%uDD65%uDB40%uDD21

Весь фокус заключается в том, что пользователь @FakeUnicode подобрал специфические вариантные селекторы, в частности, те, что оканчиваются числами, которые можно преобразовать к реальным символам. Взглянем на пример:

// допустимая суррогатная пара
'%uDB40%uDD6C'.replace(/u.{8}/g,[]);
// %6C  6C (hex) === 108 (dec)  LATIN SMALL LETTER L
unescape('%6C')
// 'l'

Единственное, что выглядит немного таинственным — это то, что в примере использован пустой массив [] как значение для строковой замены, которое будет получено с использованием toString(), что означает, что оно будет преобразовано к пустой строке — ''.

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

При таком подходе в невинно выглядящем коде можно скрытно закодировать некое сообщение.

Итоги


Снова взглянем на наш пример.

for(A in {А:0}){
  alert(unescape(escape(A).replace(/u.{8}/g,[])))
};

Вот основные особенности этого кода:

  • Имя свойства объекта A:0 играет здесь особую роль, так как между буквой А и двоеточием имеется множество «скрытых кодовых единиц».

  • Скрытые символы преобразуются в строку с использованием escape().

  • С помощью функции replace() выполняется маппинг.

  • Результаты маппинга обрабатываются функцией unescape(), преобразуются в обычные символы и отображаются окне сообщения.

Думаю, всё то, о чём мы говорили выше — штука очень интересная. В этом маленьком примере затронуто множество тем, имеющих отношение к Unicode. Поэтому, если вы хотите лучше во всём этом разобраться, рекомендую почитать статьи Матиаса Байненса об Unicode и JavaScript: JavaScript has a Unicode problem, JavaScript character escape sequences.

Уважаемые читатели! Как вы воспользовались бы техникой работы со скрытыми символами, описанной в этом материале?
Поделиться с друзьями
-->

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