Ведущий исследователь Том Корт из компании Context, предоставляющей услуги информационной безопасности, рассказывает о том, как ему удалось обнаружить потенциально опасный баг в коде клиента Steam.
Внимательно следящие за безопасностью PC-игроки заметили, что недавно Valve выпустила новое обновление клиента Steam.
В этом посте я хочу
С июля, когда Valve (наконец-то) скомпилировала свой код со включенными современными защитами от эксплойтов, он мог бы приводить всего лишь к отказу клиента, а RCE было возможно только в сочетании с отдельной уязвимостью утечки информации.
Мы заявили Valve об уязвимости 20 февраля 2018 года, и, к чести компании, она устранила её в бета-ветке меньше 12 часов спустя. Исправление было перенесено в стабильную ветку 22 марта 2018 года.
Краткий обзор
Основа уязвимости заключалась в повреждении кучи внутри библиотеки клиента Steam, которое можно было вызвать удалённо, в той части кода, которая занималась восстановлением фрагментированной датаграммы из нескольких полученных UDP-пакетов.
Клиент Steam выполняет обмен данными через собственный протокол (Steam protocol), который реализован поверх UDP. В этом протоколе есть две области, особенно интересные в связи с уязвимостью:
- Длина пакетов
- Общая длина восстановленной датаграммы
Ошибка вызывалась отсутствием простой проверки. Код не проверял, что длина первой фрагментированной датаграммы меньше или равна общей длине датаграммы. Это кажется обычным недосмотром с учётом того, что для всех последующих пакетов, передающих фрагменты датаграммы, проверка выполняется.
Без дополнительных багов утечек данных повреждение кучи на современных операционных системах контролировать очень сложно, поэтому удалённое выполнение кода реализовать трудно. Однако в этом случае благодаря собственному распределителю памяти Steam и отсутствовавшей в двоичном файле steamclient.dll (до прошлого июля) ASLR, этот баг можно было использовать как основу для очень надёжного эксплойта.
Ниже представлено техническое описание уязвимости и связанного с ней эксплойта до момента
реализации выполнения кода.
Подробности уязвимости
Необходимая для понимания информация
Протокол
Сторонние лица (например https://imfreedom.org/wiki/Steam_Friends) на основе анализа генерируемого клиентом Steam трафика выполнили реверс-инжиниринг и создали подробную документацию протокола Steam. Изначально протокол был задокументирован в 2008 году и с тех пор сильно не менялся.
Протокол реализован как протокол передачи с установлением соединения поверх потока датаграмм UDP. Пакеты, в соответствии с документацией по ссылке выше, имеют следующую структуру:
Важные аспекты:
- Все пакеты начинаются с 4 байтов "VS01"
- packet_len описывает длину полезной информации (для нефрагментированных датаграмм значение равно длине данных)
- type описывает тип пакета, который может иметь следующие значения:
- 0x2 Вызов аутентификации
- 0x4 Принятие подключения
- 0x5 Сброс подключения
- 0x6 Пакет является фрагментом датаграммы
- 0x7 Пакет является отдельной датаграммой
- Поля source и destination являются идентификаторами, назначаемыми для правильной маршрутизации пакетов в нескольких подключениях внутри клиента Steam
- В случае, если пакет является фрагментом датаграммы:
- split_count обозначает количество фрагментов, на которые разбита датаграмма
- data_len обозначает общую длину восстановленной датаграммы
- Первоначальная обработка этих UDP-пакетов происходит в функции CUDPConnection::UDPRecvPkt внутри steamclient.dll
Шифрование
Полезная информация пакета датаграммы зашифрована AES-256 с помощью ключа, согласуемого между клиентом и сервером в каждой сессии. Согласование ключей выполняется следующим образом:
- Клиент генерирует 32-байтный случайный ключ AES, а RSA перед отправкой на сервер шифрует его публичным ключом Valve.
- Сервер, обладая приватным ключом, может расшифровать это значение и принять его в качестве ключа AES-256, который будет использоваться в сессии
- После согласования ключа вся полезная информация в текущей сессии шифруется этим ключом.
Уязвимость
Уязвимость присутствует внутри метода RecvFragment класса CUDPConnection. В релизной версии библиотеки steamclient символы отсутствуют, однако при поиске по строкам бинарника в интересующей нас функции обнаруживается ссылка на "CUDPConnection::RecvFragment". Вход в эту функцию выполняется, когда клиент получает UDP-пакет, содержащий датаграмму Steam типа 0x6 («фрагмент датаграммы»).
1. Функция начинается с проверки состояния подключения, чтобы убедиться, что находится в состоянии "Connected".
2. Затем проверяется поле data_len в датаграмме Steam, чтобы убедиться, что оно содержит меньше 0x20000060 байт (похоже, это значение выбрано произвольно).
3. Если проверка пройдена, то функция проверяет, собирает ли соединение фрагменты какой-то датаграммы, или это первый пакет потока.
4. Если это первый пакет в потоке, то затем проверяется поле split_count, чтобы узнать, на сколько пакетов растянется этот поток
5. Если поток разделён на несколько пакетов, то проверяется поле seq_no_of_first_pkt, чтобы убедиться, что оно совпадает с порядковым номером текущего пакета. Это гарантирует, что пакет является первым в потоке.
6. Поле data_len снова проверяется относительно ограничения в 0x20000060 байт. Кроме того, проверяется, что split_count меньше 0x709b пакетов.
7. Если эти условия соблюдаются, то задаётся булево значение, обозначающее, что теперь мы собираем фрагменты. Также выполняется проверка того, что у нас ещё нет буфера, выделенного для хранения фрагментов.
8. Если указатель на буфер сбора фрагментов не равен нулю, то текущий буфер сбора фрагментов освобождается и выделяется новый буфер (см. жёлтый прямоугольник на рисунке ниже). Именно здесь проявляется ошибка. Ожидается, что буфер сбора фрагментов выделяется в размере data_len байтов. Если всё выполнено успешно (а код не выполняет проверку — незначительная ошибка), то затем полезная информация датаграммы копируется в этот буфер с помощью memmove, доверяя тому, что в packet_len указано количество байтов для копирования.
Важнейшим недосмотром разработчика стало то, что не выполняется проверка "packet_len меньше или равен data_len". Это значит, что существует возможность передать data_len меньше чем packet_len и иметь до 64 КБ данных (из-за того, что поле packet_len имеет ширину 2 байта), скопированных в очень маленький буфер, что приводит к возможности эксплойта повреждения кучи.
Эксплуатация уязвимости
В этом разделе подразумевается, что присутствует способ обхода ASLR. Это приводит к тому, что перед началом эксплуатации известен начальный адрес steamclient.dll.
Спуфинг пакетов
Чтобы UDP-пакеты атакующей стороны были приняты клиентом, она должна изучить исходящую (клиент -> сервер) датаграмму, которая посылается для того, чтобы узнать идентификаторы соединения клиента/сервера, а также порядковый номер. Затем атакующая сторона должна выполнить спуфинг IP-адресов и портов источника/назначения вместе с идентификаторами клиента/сервера и увеличить изученный порядковый номер на единицу.
Управление памятью
Для выделения памяти больше 1024 (0x400) байт используется стандартный системный распределитель. Для выделения памяти меньше или равной 1024 байтам Steam использует собственный распределитель, работающий одинаково на всех поддерживаемых платформах. В этой статье не будет подробного обсуждения этого распределителя, за исключением следующих ключевых аспектов:
- От системного распределителя запрашиваются большие блоки памяти, которые затем разделяются на фрагменты фиксированного размера для использования под запросы выделения памяти клиента Steam.
- Выделение выполняется последовательно, между используемыми фрагментами нет разделяющих их метаданных.
- Каждый большой блок хранит собственный список свободной памяти, реализованный в виде односвязного списка.
- Вершина списка свободной памяти указывает на первый свободный фрагмент в памяти, а первые 4 байта этого фрагмента указывают на следующий свободный фрагмент (если он существует).
Выделение памяти
При выделении памяти первый свободный блок отсоединяется от вершины списка свободной памяти, а первые 4 байта этого блока, соответствующие next_free_block, копируются в переменную-член freelist_head внутри класса распределителя.
Освобождение памяти
При освобождении блока поле freelist_head копируется в первые 4 байта освобождаемого блока (next_free_block), а адрес освобождаемого блока копируется в переменную-член freelist_head класса распределителя.
Как получить примитив записи
В куче возникает переполнение буфера, и в зависимости от размера вызвавших повреждение пакетов выделение памяти может управляться или стандартным распределителем Windows (при выделении памяти больше 0x400 байт) или собственным распределителем Steam (при выделении памяти меньше 0x400 байт). Из-за нехватки мер обеспечения безопасности в собственном распределителе Steam, я решил, что для эксплойта проще использовать его.
Вернёмся к разделу об управлении памятью: известно, что вершина списка свободной памяти блоков заданного размера хранится как переменная-член класса распределителя, а указатель на следующий свободный блок в списке хранится как первые 4 байта каждого свободного блока списка.
При наличии свободного блока рядом с блоком, в котором произошло переполнение, повреждение кучи позволяет нам перезаписать указатель next_free_block. Если учесть, что кучу можно подготовить к этому, то перезаписанному указателю next_free_block можно задать адрес для записи, после чего последующее выделение памяти будет записано в это место.
Что использовать: датаграммы или фрагменты
Ошибка с повреждением памяти возникает в коде, отвечающем за обработку фрагментов датаграмм (пакетов типа 6). После возникновения повреждения функция RecvFragment() находится в состоянии, при котором ожидает получения дальнейших фрагментов. Однако если они поступают, то выполняется проверка:
fragment_size + num_bytes_already_received < sizeof(collection_buffer)
Но очевидно, что это не такой случай, потому что наш первый пакет уже нарушил это правило (существование ошибки возможно пропуску этой проверки) и возникнет ошибка. Чтобы избежать этого, нужно после повреждения памяти избежать метода CUDPConnection::RecvFragment().
К счастью, CUDPConnection::RecvDatagram() по-прежнему может получать и обрабатывать отправляемые пакеты типа 7 (датаграммы), пока RecvFragment() не действует, и это можно использовать для запуска примитива записи.
Проблемы с шифрованием
Ожидается, что пакеты, получаемые RecvDatagram() и RecvFragment(), будут зашифрованы. В случае RecvDatagram() расшифровка выполняется почти сразу после получения. В случае RecvFragment() она происходит после получения последнего фрагмента в сессии.
Возникает проблема эксплуатации уязвимости, потому что мы не знаем ключа шифрования, который создаётся в каждой сессии. Это значит, что любой OP-код/шелл-код, который мы отправим, будет «расшифрован» с помощью AES256, что превратит наши данные в мусор. Поэтому необходимо найти способ эксплуатации, возможный почти сразу после получения пакета, прежде чем процедуры расшифровки получат возможность обработать полезную информацию, содержащуюся в буфере пакетов.
Как добиться выполнения кода
Учитывая описанное выше ограничение расшифровки, эксплуатация должна выполняться до расшифровки входящих данных. Это накладывает дополнительные ограничения, но задача всё равно выполнима: можно перезаписать указатель так, чтобы он указывал на объект CWorkThreadPool, хранящийся в предсказуемом месте внутри раздела данных двоичного файла. Хотя подробности и внутренний функционал этого класса неизвестны, по его имени можно предположить, что он поддерживает пул потоков, которые можно использовать, когда необходимо выполнить «работу». Изучив несколько отладочных строк в двоичном файле, можно понять, что среди таких работ есть шифрование и расшифровка (CWorkItemNetFilterEncrypt, CWorkItemNetFilterDecrypt), поэтому когда эти задачи ставятся в очередь, применяется класс CWorkThreadPool. Перезаписав этот указатель и записав в него нужное нам место, мы сможем имитировать указатель vtable и связанную с ним vtable, что позволяет нам выполнить код, например, при вызове CWorkThreadPool::AddWorkItem(), который обязательно происходит до любых процессов расшифровки.
На рисунке ниже показана успешная эксплуатация уязвимости до этапа получения управления над регистром EIP.
С этого момента можно создать цепочку ROP, приводящую к выполнению произвольного кода. В видео ниже показано, как злоумышленник удалённо запускает калькулятор Windows в полностью пропатченной версии Windows 10.
Подводим итоги
Если вы добрались до этой части статьи, до благодарю вас за упорство! Надеюсь, вам стало понятно, что это очень простой баг, который было довольно легко эксплуатировать из-за нехватки современных средств защиты от эксплойтов. Вероятно, уязвимый код был очень старым, но во всём остальном он хорошо работал, поэтому разработчики не видели необходимости исследовать его или обновлять скрипты его сборки. Урок здесь заключается в том, что разработчикам важно периодически проводить ревью старого кода и систем сборки, чтобы убедиться в их соответствии современным стандартам безопасности, даже если сам функционал кода остаётся неизменным. Было удивительно найти в 2018 году такой простой баг, обладающий столь серьёзными последствиями на очень популярной программной платформе. Это должно стать стимулом к поиску таких уязвимостей для всех исследователей!
Напоследок стоит рассказать о процессе ответственного раскрытия информации. Об этом баге мы сообщили Valve в письме к её службе безопасности (security@valvesoftware.com) примерно в 16 часов GMT и всего 8 часов спустя исправление было создано и запушено в бета-ветку клиента Steam. Благодаря этому Valve теперь находится на первом месте в нашей (воображаемой) таблице конкурса «Кто быстрее устранит уязвимость» — приятное исключение по сравнению с раскрытием ошибок другим компаниям, которое часто часто оборачивается долгим процессом согласований.
Страница, на которой описаны подробности всех обновлений клиента
Комментарии (12)
geoolekom
03.06.2018 18:38В Valve работает всего 400 человек, а баг — простой. Наверное, успели все согласовать за семь часов, и за час — починили)
devalone
03.06.2018 20:42+2А зачем они вообще велосипедили свой протокол?
vassabi
03.06.2018 21:18+1обычно это делается для скорости и управляемости — первое, чтобы запрашивать большими кусками, а не бегать в системный менеджер по каждому чиху, а второе — чтобы под разными ОС все-равно иметь одинаковое (т.е. самописное) поведение.
devalone
03.06.2018 21:19+1я про Steam protocol, а не про менеджер памяти
vassabi
03.06.2018 21:26+2дык и там то же самое — снаружи вы ограничиваете штатным UDP и прибитыми гвоздями стандартами (чтобы работало стабильно во всех массовых ОС), а внутри у вас гибкость и расширяемость (а также независимость от заморочек ОС).
Husebolt
По мне так это самый настоящий бэкдор
vassabi
это вы не видели настоящих бекдоров: я однажды видел (в продакшене! ) легаси код, который не проверял пароль, если логин заканчивался на определенную кобинацию символов. Т.е. если логин был «Иванов999», то оно влогинивало с правами юзера «Иванов». Вот это был ахтунг…
devalone
Хороший бекдор тот, про который нельзя однозначно сказать, что это бекдор :)
vassabi
если такое поведение убирают по первому же багрепорту, то это баг а не бекдор.
(вы будете смеяться, но тот ахтунг с мастер-логином так там и остался — руководство взяло ответственность за его наличие на себя)
devalone
вот не обязательно, никто ж не будет признаваться в бекдоре
ну, почти никто) Тем более, что баг в статье более серьёзный, чем мастер пароль, который ещё нужно угадать.
ZurgInq
Что бы эксплуатировать эту уязвимость, надо прослушивать трафик. Что проблематично, если защита цели уже не пробита в других местах
devalone
а в чём проблема для АНБ прослушивать трафик? А ещё трафик можно прослушивать(и менять), находясь в одной сети с жертвой(arp spoofing).