Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
На платформе x64 фактически существует только одно соглашение вызовов функций — fastcall. Судя по дизассемблерным листингам, компилятор при генерации кода автоматически меняет любое другое прописанное программистом соглашение на него. В этой статье мы разберемся, что такое fastcall, рассмотрим используемые в этом соглашении параметры и другие важные понятия.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Как следует из названия, 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++ имеется только одно соглашение вызова. Первые четыре целочисленных параметра или указателя передаются в регистрах RCX
, RDX
, R8
, R9
. Для C++ в подавляющем большинстве вызовов методов первый целочисленный параметр занимает указатель this
. Первые четыре параметра, представленные значениями с плавающей запятой (вещественные значения), передаются в регистрах XMM0
, XMM1
, XMM2
, XMM3
расширения SSE. На 64-битной платформе набор этого расширения был увеличен с 8 до 16 регистров.
Дополнительные параметры, если они есть, передаются через стек. Вызывающая функция резервирует место в стеке, а вызываемая может разместить там переданные в регистрах переменные.
Регистры процессора архитектуры x86_64
Поскольку вызываемая и вызывающая функции вынуждены придерживаться общих соглашений при передаче параметров через регистры, компилятору приходится помещать параметры в те регистры, в которых их ожидает вызываемая функция, а не туда, куда ему «удобно». В результате перед каждой соответствующей соглашению fastcall функцией появляется код, «тасующий» содержимое регистров строго определенным образом — заданным стандартом для x64 (мы рассмотрели его в предыдущем разделе).
При анализе кода вызывающей функции не всегда можно распознать передачу параметров через регистры (если только их инициализация не будет слишком наглядна). Поэтому приходится обращаться непосредственно к вызываемой функции. Регистры, которые функция сохраняет в стеке сразу после получения управления, не передают параметры, и из списка «кандидатов» их можно вычеркнуть.
Есть ли среди оставшихся такие, содержимое которых используется без явной инициализации? В первом приближении функция принимает параметры именно через эти регистры. При детальном же рассмотрении проблемы всплывает несколько оговорок. Во‑первых, через регистры могут передаваться (и очень часто передаются) неявные параметры функции — указатель this
, указатели на виртуальные таблицы объекта и так далее. Во‑вторых, если криворукий программист, надеясь, что значение переменной после объявления должно быть равно нулю, забывает об инициализации, а компилятор помещает переменную в регистр, то при анализе программы она может быть принята за передаваемый через регистр параметр функции.
Самое интересное, что этот регистр может по случайному стечению обстоятельств явно инициализироваться вызывающей функцией. Представим, что программист перед этим вызывал функцию, возвращаемого значения которой не использовал. Компилятор поместил неинициализированную переменную в RAX
. Причем, если функция при своем нормальном завершении возвращает ноль (как часто и бывает), все может работать… Чтобы выловить этот баг, исследователю придется проанализировать алгоритм и выяснить, действительно ли в RAX
находится код успешного завершения функции, или же имеет место наложение переменных? Впрочем, если откинуть клинические случаи, передача аргументов через регистры не сильно усложняет анализ, в чем мы сейчас и убедимся.
Для закрепления всего сказанного давай рассмотрим следующий пример:
#include <stdio.h>#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;}
Исходник fastcall_4_args
Вывод приложения — сумма чисел в шестнадцатеричном формате
Перед построением этого примера отключи оптимизацию. Результат его обработки компилятором Microsoft Visual C++ 2019 должен выглядеть так:
main proc near sub rsp, 28h
Все аргументы помещаются в регистры. Судя по их значениям, в обратном порядке. Ради незначительной оптимизации компилятор решил не задействовать регистры полностью, а, зная типы данных наперед, использовать только необходимое пространство. Таким образом, вместо того, чтобы выделять регистры целиком: R9
, R8
, RDX
, RCX
, были отданы только половины первых трех (R9D
, R8D
, EDX
) и лишь восьмая часть последнего — регистр CL
.
mov r9d, 4 ; dmov r8d, 3 ; cmov edx, 2 ; bmov cl, 1 ; a
В итоге IDA без нашей помощи восстановила прототип вызываемой функции. Но если бы у нее не получилось это сделать? Тогда бы мы гадали: Long
, как и int
, занимает 32 бита, char
— единственный тип данных, занимающий один байт.
call MyFunc(char,int,long,int)mov edx, eax
Неважно, когда ты будешь читать этот текст, возможно, во времена Windows 17 и Intel Core i9, однако системные программисты могут изменить размеры базовых типов данных в соответствии с архитектурой вычислительных систем. Чтобы узнать их размер конкретно на твоей машине, можно воспользоваться такой незамысловатой программой:
#include <iostream>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;}
Полученный при выполнении функции MyFunc
результат (сумма параметров) передается функции printf
для вывода на консоль в шестнадцатеричном виде:
lea rcx, _Format ; "%xn" call printf xor eax, eax add rsp, 28h retnmain endp
Дизассемблерный листинг функции MyFunc
выглядит следующим образом:
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
Сначала функция разматывает содержимое регистров по стеку. Обрати внимание, как она это делает: в аргументах заданы размеры. Они прибавляются к отрицательному значению вершины стека — RSP
, и по получившемуся адресу в стеке кладется значение из соответствующего по размеру регистра.
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]
После этого в регистре EAX
накапливается сумма всех шестнадцатеричных значений, переданных в функцию MyFunc
, и в него же возвращается результат:
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 нагородил дополнительного кода: он загружает значения регистров в стек, как бы обменивая их значения, но в итоге все равно вызывает MyFunc
и передает четыре параметра в регистрах. Как же это неоптимально!
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
:
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-битного регистра CL
копируется в AL
. Понятно, что 8-битным является первый передаваемый параметр типа char
. Но если мы заглянем в main
, то обнаружим, что оттуда не передается 8-битный параметр. Между тем передается 32-битный ECX: mov ecx, eax
. И уже в MyFunc
берется только четвертая часть от ECX
и помещается в AL
.
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 тип Long
тоже 32-битный и он тоже предназначен для обработки целочисленных значений. Следовательно, использует те же регистры процессора.
Далее к находящемуся в регистре ECX
значению постепенно прибавляется каждое значение аргумента, ранее помещенное в стек:
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 <stdio.h>#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
Эге‑гей! Даже с отключенной оптимизацией компилятор не стал дважды вызывать функцию strlen
, а сразу подставил вычисленные результаты:
mov r8d, 3 ; c — очевидно, это третий параметр — strlen("333"), то есть число 3 lea rdx, [rsp+38h+b]; b — указатель на переменную b mov cl, 1 ; a — первый параметр, тип char, число 1, результат strlen("1")
Параметры функции, как и положено, передаются в обратном порядке. По имеющейся информации, даже без помощи IDA мы можем запросто восстановить прототип функции: MyFunc(char,int *,int)
.
call MyFunc(char,int *,int); Передаются три аргумента, а возвращается один — в регистре EAX mov edx, eax lea rcx, _Format ; "%xn"
После зарядки форматной строки результат отправляется на печать. Затем регистр EAX
обнуляется. Стек восстанавливается и деинициализируется.
call printf xor eax, eax mov rcx, [rsp+38h+var_10] xor rcx, rsp ; StackCookie call __security_check_cookie add rsp, 38h retn
Ознакомимся с дизассемблерным листингом функции MyFunc
:
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]
Берем значение из ячейки по адресу [rdx+rcx]
и складываем с содержимым регистра EAX
, в котором было сохранено значение переменной arg_0
. Затем перезаписываем этот регистр.
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
Источник: xakep.ru