На плат­форме 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
Ре­гис­тры про­цес­сора архи­тек­туры 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("%x\n", MyFunc(0x1, 0x2, 0x3, 0x4));
return 0;
}
Исходник fastcall_4_args
Ис­ходник fastcall_4_args
Вывод приложения — сумма чисел в шестнадцатеричном формате
Вы­вод при­ложе­ния — сум­ма чисел в шес­тнад­цатерич­ном фор­мате

Пе­ред пос­тро­ением это­го при­мера отклю­чи опти­миза­цию. Резуль­тат его обра­бот­ки ком­пилято­ром Microsoft Visual C++ 2019 дол­жен выг­лядеть так:

main proc near
sub rsp, 28h

Все аргу­мен­ты помеща­ются в регис­тры. Судя по их зна­чени­ям, в обратном поряд­ке. Ради нез­начитель­ной опти­миза­ции ком­пилятор решил не задей­ство­вать регис­тры пол­ностью, а, зная типы дан­ных наперед, исполь­зовать толь­ко необ­ходимое прос­транс­тво. Таким обра­зом, вмес­то того, что­бы выделять регис­тры целиком: R9, R8, RDX, RCX, были отда­ны толь­ко полови­ны пер­вых трех (R9D, R8D, EDX) и лишь вось­мая часть пос­ледне­го — регистр CL.

mov r9d, 4 ; d
mov r8d, 3 ; c
mov edx, 2 ; b
mov 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 ; "%x\n"
call printf
xor eax, eax
add rsp, 28h
retn
main 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]
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 нагоро­дил допол­нитель­ного кода: он заг­ружа­ет зна­чения регис­тров в стек, как бы обме­нивая их зна­чения, но в ито­ге все рав­но вызыва­ет 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
retn
main 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
retn
MyFunc(char, int, long, int) endp

К сло­ву, ком­пиляция выпол­нена из‑под све­жень­кой Windows 11, одна­ко это никак не пов­лияло на дизас­сем­блер­ный лис­тинг.

Выполнение приложения командной строки в 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("%x\n", 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

Эге‑гей! Даже с отклю­чен­ной опти­миза­цией ком­пилятор не стал дваж­ды вызывать фун­кцию 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 ; "%x\n"

Пос­ле заряд­ки фор­матной стро­ки резуль­тат отправ­ляет­ся на печать. Затем регистр 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↓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]

Бе­рем зна­чение из ячей­ки по адре­су [rdx+rcx] и скла­дыва­ем с содер­жимым регис­тра EAX, в котором было сох­ранено зна­чение перемен­ной arg_0. Затем переза­писы­ваем этот регистр.

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

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии