Категория > Новости > Фундаментальные основы хакерства. Соглашение о быстрых вызовах — fastcall - «Новости»
Фундаментальные основы хакерства. Соглашение о быстрых вызовах — fastcall - «Новости»25-12-2021, 00:00. Автор: Kennett |
|
странице автора. Как следует из названия, fastcall предполагает быстрый вызов функции. Другими словами, при его использовании параметры передаются через регистры процессора, что отражается на скорости работы подпрограмм. Между тем во времена x86 fastcall не был стандартизирован, что создавало немало трудностей программисту. В оригинальном издании книги «Фундаментальные основы хакерства» Крис в свойственной ему манере очень подробно описал механизмы передачи параметров с помощью регистров для 32-битных компиляторов. Он упомянул C/C++ и Turbo Pascal от различных фирм‑разработчиков, таких как Microsoft, Borland, Watcom (эта компания ныне скорее мертва, чем жива, однако открытый проект их компилятора можно найти на GitHub). Крис подробно описал передачу как целых, так и вещественных типов данных: int, long, float, single, double, extended, real, etc. С тех пор как бал правит архитектура x86_64, обо всех соглашениях передачи параметров процедурам и функциям можно забыть, как о страшном сне. Теперь в этой сфере все устаканилось и стало стандартизировано, чего никак не могли добиться во времена x86. Благодаря возросшему количеству регистров на платформе x64 для компиляторов C/C++ имеется только одно соглашение вызова. Первые четыре целочисленных параметра или указателя передаются в регистрах Дополнительные параметры, если они есть, передаются через стек. Вызывающая функция резервирует место в стеке, а вызываемая может разместить там переданные в регистрах переменные. Регистры процессора архитектуры x86_64 Идентификация передачи и приема регистровПоскольку вызываемая и вызывающая функции вынуждены придерживаться общих соглашений при передаче параметров через регистры, компилятору приходится помещать параметры в те регистры, в которых их ожидает вызываемая функция, а не туда, куда ему «удобно». В результате перед каждой соответствующей соглашению fastcall функцией появляется код, «тасующий» содержимое регистров строго определенным образом — заданным стандартом для x64 (мы рассмотрели его в предыдущем разделе). При анализе кода вызывающей функции не всегда можно распознать передачу параметров через регистры (если только их инициализация не будет слишком наглядна). Поэтому приходится обращаться непосредственно к вызываемой функции. Регистры, которые функция сохраняет в стеке сразу после получения управления, не передают параметры, и из списка «кандидатов» их можно вычеркнуть. Есть ли среди оставшихся такие, содержимое которых используется без явной инициализации? В первом приближении функция принимает параметры именно через эти регистры. При детальном же рассмотрении проблемы всплывает несколько оговорок. Во‑первых, через регистры могут передаваться (и очень часто передаются) неявные параметры функции — указатель Самое интересное, что этот регистр может по случайному стечению обстоятельств явно инициализироваться вызывающей функцией. Представим, что программист перед этим вызывал функцию, возвращаемого значения которой не использовал. Компилятор поместил неинициализированную переменную в Практическое исследование механизма передачи аргументов через регистрыДля закрепления всего сказанного давай рассмотрим следующий пример: #include <string>// Функция MyFunc с различными типами аргументов для демонстрации механизма// их передачиint MyFunc(char a, int b, long int c, int d){ return a + b + c + d;}int main(){ printf("%xn", MyFunc(0x1, 0x2, 0x3, 0x4)); return 0;}![]() ![]() Перед построением этого примера отключи оптимизацию. Результат его обработки компилятором Microsoft Visual C++ 2019 должен выглядеть так: main proc near sub rsp, 28hВсе аргументы помещаются в регистры. Судя по их значениям, в обратном порядке. Ради незначительной оптимизации компилятор решил не задействовать регистры полностью, а, зная типы данных наперед, использовать только необходимое пространство. Таким образом, вместо того, чтобы выделять регистры целиком: mov r9d, 4 ; dmov r8d, 3 ; cmov edx, 2 ; bmov cl, 1 ; aВ итоге IDA без нашей помощи восстановила прототип вызываемой функции. Но если бы у нее не получилось это сделать? Тогда бы мы гадали: call MyFunc(char,int,long,int)mov edx, eaxНеважно, когда ты будешь читать этот текст, возможно, во времена Windows 17 и Intel Core i9, однако системные программисты могут изменить размеры базовых типов данных в соответствии с архитектурой вычислительных систем. Чтобы узнать их размер конкретно на твоей машине, можно воспользоваться такой незамысловатой программой: const int byte = 8;int main(){ std::cout << "int = " << sizeof(int) * byte << 'n'; std::cout << "long = " << sizeof(long) * byte << 'n'; std::cout << "bool = " << sizeof(bool) * byte << 'n'; std::cout << "float = " << sizeof(float) * byte << 'n'; std::cout << "double = " << sizeof(double) * byte << 'n'; // и так далее нужные тебе типы данных return 0;}Полученный при выполнении функции call printf xor eax, eax add rsp, 28h retnmainДизассемблерный листинг функции int MyFunc(char, int, long, int) proc near arg_0 = byte ptr 8 arg_8 = dword ptr 10h arg_10 = dword ptr 18h arg_18 = dword ptr 20hСначала функция разматывает содержимое регистров по стеку. Обрати внимание, как она это делает: в аргументах заданы размеры. Они прибавляются к отрицательному значению вершины стека — mov [rsp+arg_18], r9d mov [rsp+arg_10], r8d mov [rsp+arg_8], edx mov [rsp+arg_0], cl movsx eax, [rsp+arg_0]После этого в регистре add eax, [rsp+arg_8] add eax, [rsp+arg_10] add eax, [rsp+arg_18] retnint MyFunc(char, int, long, int) endpА теперь посмотрим, что сгенерировал C++Builder 10.3. Сначала main: main proc near var_14 = dword ptr -14h var_10 = qword ptr -10h var_8 = dword ptr -8 var_4 = dword ptr -4 sub rsp, 38hЗдесь мы видим, что после инициализации стека компилятор помещает параметры в регистры в прямом порядке, то есть в том, в котором их передал программист в оригинальной программе на языке высокого уровня. mov eax, 1 mov r8d, 2 mov r9d, 3 mov r10d, 4После этого, по сути, можно вызывать следующую функцию, передавая параметры в регистрах. Именно это Visual C++ и делал. Тем не менее C++Builder нагородил дополнительного кода: он загружает значения регистров в стек, как бы обменивая их значения, но в итоге все равно вызывает mov [rsp+38h+var_4], 0 mov [rsp+38h+var_8], ecx mov [rsp+38h+var_10], rdx mov ecx, eax ; int mov edx, r8d ; __int64 mov r8d, r9d mov r9d, r10d call MyFunc(char,int,long,int) lea rcx, unk_44A000 mov edx, eax; Возвращаемое значение выводим на консоль call printf mov [rsp+38h+var_4], 0 mov [rsp+38h+var_14], eax mov eax, [rsp+38h+var_4]; Обрати внимание: этот компилятор еще и сам чистит стек add rsp, 38h retnmain endpЗатем MyFunc(char, int, long, int) proc near var_14= dword ptr -14h var_10= dword ptr -10h var_C= dword ptr -0Ch var_8= dword ptr -8 var_1= byte ptr -1И тут C++Builder нагородил лишний код. После инициализации стека значение 8-битного регистра sub rsp, 18h mov al, cl; Помещает значения аргументов в стек с учетом размеров переменных mov [rsp+18h+var_1], al mov [rsp+18h+var_8], edx mov [rsp+18h+var_C], r8d mov [rsp+18h+var_10], r9dТеперь, узнав размеры параметров, можем вывести прототип (современная IDA справляется с этим без нашей помощи, но нам ведь тоже это надо уметь): MyFunc(char a, int b, int c, int d)Однако порядок следования трех последних аргументов может быть иным, также стоит учитывать, что в Windows тип Далее к находящемуся в регистре movsx ecx, [rsp+18h+var_1] add ecx, [rsp+18h+var_8] add ecx, [rsp+18h+var_C] add ecx, [rsp+18h+var_10] mov [rsp+18h+var_14], ecx; В регистре EAX возвращаем результат mov eax, [rsp+18h+var_14]; Обнуляем стек add rsp, 18h retnMyFunc(char, int, long, int) endpК слову, компиляция выполнена из‑под свеженькой Windows 11, однако это никак не повлияло на дизассемблерный листинг. Выполнение приложения командной строки в Windows 11 Как мы видим, в передаче параметров через регистры ничего сложного нет. Можно, даже не прибегая к помощи IDA, восстановить подлинный прототип вызываемой функции. Однако рассмотренная выше ситуация идеализирована, и в реальных программах передача одних лишь непосредственных значений встречается редко. Освоившись с быстрыми вызовами, давай дизассемблируем более трудный пример: #include <string.h>int MyFunc(char a, int* b, int c){ return a + b[0] + c;}int main(){ int a = 2; printf("%xn", MyFunc(strlen("1"), &a, strlen("333")));}Результат его компиляции в Visual C++ 2019 должен выглядеть так: main proc near; Объявляем переменныеb= dword ptr -18hvar_10 = qword ptr -10h; Инициализация стека sub rsp, 38h mov rax, cs:__security_cookie xor rax, rsp mov [rsp+38h+var_10], rax; Присваиваем переменной b типа int значение 2 mov [rsp+38h+b], 2Эге‑гей! Даже с отключенной оптимизацией компилятор не стал дважды вызывать функцию mov r8d, 3 ; c — очевидно, это третий параметр — strlen("333"), то есть число 3 lea rdx, [rsp+38h+b]; b — указатель на переменную b mov cl, 1 ; a — первый параметр, тип char, число 1, результат strlen("1")Параметры функции, как и положено, передаются в обратном порядке. По имеющейся информации, даже без помощи IDA мы можем запросто восстановить прототип функции: ; Передаются три аргумента, а возвращается один — в регистре EAX mov edx, eax lea rcx, _Format ; "%xn"После зарядки форматной строки результат отправляется на печать. Затем регистр call printf xor eax, eax mov rcx, [rsp+38h+var_10] xor rcx, rsp ; StackCookie call __security_check_cookie add rsp, 38h retnОзнакомимся с дизассемблерным листингом функции int MyFunc(char, int *, int) proc near ; CODE XREF: main+28↓parg_0 = byte ptr 8arg_8 = qword ptr 10harg_10 = dword ptr 18hФункция принимает три аргумента в регистрах по правилам fastcall и размещает их в стеке. mov [rsp+arg_10], r8d mov [rsp+arg_8], rdx mov [rsp+arg_0], cl; Переменная var_0 расширяется до знакового целого (signed int) movsx eax, [rsp+arg_0] mov ecx, 4; Умножением на 0 обнуляем RCX — странновато, но как вариант imul rcx, 0; Значение переменной arg_8 помещаем в регистр RDX mov rdx, [rsp+arg_8]Берем значение из ячейки по адресу add eax, [rdx+rcx]; Значение переменной arg_10 суммируем со значением в регистре EAX add eax, [rsp+arg_10]; В регистре EAX возвращаем результат retnint MyFunc(char, int *, int) endpПросто? Просто! Тогда рассмотрим результат творчества C++Builder (обновленного до 10.4.2 Sydney — это последняя на время написания данных строк версия). Код должен выглядеть так: main proc near; Объявление переменных var_28 = dword ptr -28h var_21 = byte ptr -21h var_20 = dword ptr -20h var_14 = dword ptr -14h var_10 = qword ptr -10h var_8 = dword ptr -8 var_4 = dword ptr -4 push rbp sub rsp, 50hПерейти обратно к новости |