На плат­форме 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

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

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

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

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