Категория > Новости > Фундаментальные основы хакерства. Соглашение о быстрых вызовах — 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;} Исходник fastcall_4_argsВывод приложения — сумма чисел в шестнадцатеричном форматеПеред построением этого примера отключи оптимизацию. Результат его обработки компилятором Microsoft Visual C++ 2019 должен выглядеть так:
main proc near
sub rsp, 28h
Все аргументы помещаются в регистры. Судя по их значениям, в обратном порядке. Ради незначительной оптимизации компилятор решил не задействовать регистры полностью, а, зная типы данных наперед, использовать только необходимое пространство. Таким образом, вместо того, чтобы выделять регистры целиком:
mov r9d, 4 ; d
mov r8d, 3 ; c
mov edx, 2 ; b
mov 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]
retn
int 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
retn
main 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
retn
MyFunc(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 -18h
var_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↓p
arg_0 = byte ptr 8
arg_8 = qword ptr 10h
arg_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 возвращаем результат
retn
int 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
Перейти обратно к новости |