Фундаментальные основы хакерства. Соглашение о быстрых вызовах — fastcall

Содержание статьи

  • Идентификация передачи и приема регистров
  • Практическое исследование механизма передачи аргументов через регистры
  • Передача вещественных значений
  • Соглашение о вызовах функций __thiscall
  • Параметры по умолчанию
  • Техника исследования механизма передачи параметров неизвестным компилятором

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

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *