
30 декабря 2024 года, пока большинство из нас суетились в преддверии празднования Нового года, Казначейство США готовило для Финансового комитета Сената США важное уведомление. В нём сообщалось, что его системы, которые, очевидно, содержат особо чувствительные конфиденциальные данные, были взломаны группой правительственных хакеров Китая.
Даже не знаю, как пропустил эту новость. Обычно я всё активно отслеживаю, особенно уязвимости опенсорсного ПО, которые касаются казначейства моей страны
И это ещё не самое безумное. Сейчас я расскажу вам, как именно они это сделали!
Начну с того, что это была старая добрая атака внедрением SQL-кода (подробнее об этих атаках скажу чуть позже).
За защиту серверов казначейства отчасти отвечает инструмент управления привилегированным доступом (Privileged Access Management, PAM) компании Beyond Trust (просто гениальное имя для поставщика услуг безопасности). К сожалению для них (да и для всех нас, у кого есть записи в казначействе ()), сотрудники Beyond Trust были вынуждены сообщить эту новость правительству, поскольку именно их ПО хакеры использовали в качестве точки входа.
Но давайте не будем спешить вешать всех собак на эту компанию. На её месте мог оказаться любой из нас, так как в основе уязвимости лежит PostgreSQL — одна из самых распространённых реляционных баз данных в мире.
Хотя здесь есть один нюанс — это была нетипичная явная уязвимость к внедрению SQL-кода (SQLi). Для осуществления подобной атаки необходимо, чтобы жертва использовала вывод внутреннего метода экранирования строк Postgres и передавала его непосредственно в
psql
(инструмент командной строки в Postgres). Или как об этом сказали специалисты самой PostgreSQL:> В частности, для внедрения SQL-кода нужно, чтобы приложение использовало вывод функции для создания ввода в
psql
, то есть в интерактивный терминал PostgreSQL. — выдержка из отчёта PostgreSQL о регистрации уязвимости в системе CVE.
Как же так получилось, что в PostgreSQL более 9 лет просидела нераскрытой уязвимость нулевого дня? Причём ещё и уязвимость SQLi.
▍ Коротко о внедрении SQL-кода
Для тех, кто не в курсе, уязвимость к SQL-инъекции стара как мир.
«Не учи меня тайной магии, колдунья, она писалась при мне!» — Аслан (король Нарнии)
Внедрение SQL-кода длительное время являлось краеугольным камнем теории безопасности для разработчиков и исследователей. Обычно именно с этой темы начинается первая глава любого учебника по кибербезопасности.
Возьмём, к примеру, этот ужасный код Ruby:
# @Substack, добавь выделение синтаксиса. Обещаю, это не сложно.
require 'pg'
# Подключение к базе данных PostgreSQL.
conn = PG.connect(dbname: 'testdb', user: 'user', password: 'password')
# Получение ввода пользователя из командной строки.
puts "Enter your username:"
username = gets.chomp
# Простой запрос уязвим к SQL-инъекции.
result = conn.exec("SELECT * FROM users WHERE username = '#{username}'")
# Вывод результата.
puts result.getvalue(0, 0) # Предполагается, что в результате содержится столбец.
Здесь, если пользователь введёт в качестве имени admin' OR '1' = '1
, фактически будет выполнен такой запрос:
SELECT * FROM users WHERE username = 'admin' OR '1' = '1'
Поскольку 1 == 1
, в ответе вернутся записи всех пользователей из базы данных.
Конечно, это вымышленный пример, но можно экстраполировать использование аналогичного паттерна в гораздо более злостных целях — как, например, в истории с bobby tables (думали, я буду говорить о внедрении SQL-кода и не вспомню о bobby tables??)
Поэтому лучше записать запрос так:
conn.exec_params("SELECT * FROM users WHERE username = $1", [username])
В этом коде используется метод exec_params
из пакета pg
, который помещает на место нашего плейсхолдера $1
очищенное имя пользователя.
Примечание. Этот доработанный код Ruby включает так называемую «подготовленную инструкцию» (prepared statement). Подготовленные инструкции — это своеобразный стандарт индустрии по защите от SQLi.
Подобный вид атак настолько известен, что их существование воспринимается почти как данность. Все разработчики знают, что от них нужно защищаться. Поэтому весьма дико, что подобная уязвимость около 10 лет скрывалась в PostgreSQL, одном из самых пристально наблюдаемых опенсорсных проектов. Как вышло, что в системе, которую используют тысячи разработчиков и экспертов по безопасности, столь фундаментальная вещь оставалась незамеченной так долго.
Что ж, в данном случае атака внедрением SQL-кода была на порядок сложнее нашего вымышленного примера (или случая с bobby tables).
▍ Атака
В основе атаки лежало всего два байта: c0 27
.
Примечание. Байты c0 27 представлены в шестнадцатеричном виде.
Далее для лучшего понимания процесса пробежимся по стеку вызовов.
Атакующий включил эти два байта в путь выполнения кода, который в конечном итоге достиг метода pg_escape_string
.
pg_escape_string
— это метод из PHP, применяемый для очистки ввода. В Beyond Trust он использовался в файле dbquote.php.
Пока вроде всё в порядке. Специалисты Beyond Trust проявили должную осмотрительность, применяя метод очистки к строке ввода пользователя, используемой в запросе PostgreSQL.
Но идём дальше…
По факту pg_escape_string
не выполняет экранирование сам. Он вызывает функцию PostgreSQL PQescapeStringInternal
.
PQescapeStringInternal
является частьюlibpq
, библиотеки C, предоставляющей интерфейсы для взаимодействия с сервером Postgres и, что важно, включающей инструмент командной строки psql. Вамlibpq
также может быть знакома как «одна из библиотек в Dockerfile, которую ради соответствия SOC 2 приходится патчить, закрывая обнаруженные уязвимости».
Метод PQescapeStringInternal
существует именно для этого — чтобы «экранировать» строку, то есть очищать её, для безопасного использования в Postgres. Чтобы это реализовать, PQescapeStringInternal
должен вызвать pg_utf_mblen
, который отвечает за определение длины символов Юникода, состоящих из нескольких байтов.
А вот здесь стоит остановиться и внимательно рассмотреть pg_utf_mblen
.
Через этот метод проходит любой текст в PostgreSQL, требующий экранирования.
// Код на момент 2025/03/13
int
pg_utf_mblen(const unsigned char *s)
{
int len;
if ((*s & 0x80) == 0)
len = 1;
else if ((*s & 0xe0) == 0xc0)
len = 2;
else if ((*s & 0xf0) == 0xe0)
len = 3;
else if ((*s & 0xf8) == 0xf0)
len = 4;
#ifdef NOT_USED
else if ((*s & 0xfc) == 0xf8)
len = 5;
else if ((*s & 0xfe) == 0xfc)
len = 6;
#endif
else
len = 1;
return len;
}
(Ссылка на исходный код для самых пытливых умов).
Если вкратце, то этот метод нужен, потому что Postgres позволяет использовать эмодзи (не делайте этого).
CREATE TABLE "
_table" (
id serial PRIMARY KEY,
description text
);
Несмотря на то, что внешне эмодзи представляет всего 1 символ, в кодировке UTF-8 он занимает 4 байта.
- Символ ASCII A занимает 1 байт.
- Символ é (являющийся частью расширенного набора Юникода) занимает 2 байта.
- Символ 中 (китайский символ Юникода) занимает 3 байта.
- Эмодзи
в кодировке UTF-8 выглядит как
F0 9F 98 80
, то есть занимает 4 байта.
Так что при парсинге символа Юникода (например, эмодзи) Postgres нужно знать, сколько байтов он содержит.
Хорошо, теперь вернёмся к нашим двум байтам: c0 27
.
Поскольку строка начинается с c0
, pg_utf_mblen
делает вывод, что длина этого символа Юникода равна 2 (видно в блоках if/else
выше).
Postgres способна определить это, так как здесь мы имеем дело с кодировкой UTF. По сути, в первом байте хранится информация об общей длине символа UTF.
0xc0
, он же110xxxxx
, означает, что перед нами два байта.
В итоге эта длина 2 используется в PQescapeStringInternal
.
А вот и момент, где возникает баг.
Метод PQescapeStringInternal
не проверяет, представлена ли строка, которую он парсит с помощью pg_utf_mblen
, в кодировке Юникода. Он просто получает длину 2 и использует следующий байт, которым в нашем случае является 27
.
Значение 27
представляет не что иное, как излюбленный символ для атак внедрением SQL-кода: '
(одинарную кавычку). PQescapeStringInternal
слепо копирует эту кавычку, не экранируя.
И всё бы ничего, если бы ребята из Beyond Trust не написали функциональность, которая позволяла пользователям напрямую программно взаимодействовать с psql
.
В итоге хакеры просто взяли в оборот эту неэкранированную кавычку и получили полный контроль над psql
. Всего этого я не знал, пока не вник в инцидент. И если получение атакующим доступа к вашей БД через psql
ещё не достаточно пугает, то по факту это также даёт ему возможность выполнять произвольные системные команды:
! <command>
// или...
! ./break_all_the_things
Так что, да… ничего хорошего.
Ну и прежде, чем подвести итоги, опять же, для самых пытливых умов дам ссылку на исправление бага.
▍ Выводы: строки, конечно, загадочны, но Юникод ещё загадочнее
В ходе своего расследования я наткнулся на меткое изречение одного из пользователей YouTube:

Ещё одним хорошим справочным ресурсом по теме будет Rust Book, Chapter 8.
Если говорить в целом, то строки — сложная штука. И в разных языках программирования эту сложность преподносят программистам по-разному. В Rust решили по умолчанию требовать корректную обработку данных
String
во всех программах, то есть программисту нужно изначально вдумчиво использовать данные в формате UTF-8. Такой компромисс раскрывает дополнительную сложность строк, которая не столь очевидна в других языках, зато избавляет вас от необходимости обрабатывать возможные ошибки, связанные с символами не в кодировке ASCII.
После внимательного изучения инцидента со взломом систем казначейства я гораздо лучше понял, как кодирование, хранение и обработка строк могут внести неожиданное поведение в любое ПО, даже PostgreSQL.
Например, Пол Батлер писал о том, как можно «Скрытно передавать данные через эмодзи». Так что да, теоретически вы можете закодировать безграничное количество произвольных данных в любой символ Юникода. Только представьте себе горизонты возможностей!
Всё это вдохновило меня более углублённо изучить строки и систему Юникод.
Вот вы, к примеру, знали, что символы Юникода определяются стандартами Юникода, которые регулируются одноимённым Консорциумом?
Ну и скучное же получилось предложение…
А знали ли вы, что за небольшую сумму в $5 000 можно зарегистрировать себе личный символ Юникода?
Вот это уже дело.
На данный момент такую эксклюзивную регистрацию оформили почти 100 человек. Среди них и те, кто сделал это в знак внимания своему возлюбленному, и крупные технологические компании, демонстрирующие Консорциуму своё почтение.
Вот пара моих любимых:
- Oakland A (бейсбольная команда из главной лиги) потратила $15 000 на символы бейсбольного мяча
, слона
и листопадного дерева
.
- И сеть ресторанов Buffalo Wild Wings, зарегистрировавшая за собой символ окорочка
- Кому интересно, здесь полный список.
К слову, написать эту статью меня побудил YouTube-ролик на канале PwnFunction.
Надеюсь, она вам понравилась.
Автор: Bright_Translate