Се­год­ня мы погово­рим о том, какие есть типы вызовов фун­кций и как они реали­зова­ны в раз­ных ком­пилято­рах. Это поможет нам в деле вос­ста­нов­ления логики прог­рамм при пол­ном отсутс­твии исходных кодов. Одна­ко сна­чала мы погово­рим об осо­бен­ностях сте­ка, которые тебе, воз­можно, неиз­вес­тны.
 

Адресация аргументов в стеке

Ба­зовая кон­цепция сте­ка вклю­чает в себя лишь две опе­рации — занесе­ние эле­мен­та в стек и сня­тие пос­ледне­го занесен­ного эле­мен­та со сте­ка. Дос­туп к про­изволь­ному эле­мен­ту — это что‑то новень­кое! Одна­ко такое отступ­ление от канонов сущес­твен­но уве­личи­вает ско­рость работы. Если нужен, ска­жем, тре­тий по сче­ту эле­мент, почему бы не вытащить его из сте­ка нап­рямую, не сни­мая пер­вые два?

Стек – это не толь­ко «стоп­ка», как учат популяр­ные учеб­ники по прог­рамми­рова­нию, но еще и мас­сив. А раз так, то, зная положе­ние ука­зате­ля вер­шины сте­ка (а не знать его мы не можем, ина­че, куда при­каже­те класть оче­ред­ной эле­мент?), и раз­мер эле­мен­тов, мы смо­жем вычис­лить сме­щение любого из эле­мен­тов, пос­ле чего не сос­тавит никако­го тру­да его про­читать.

По­пут­но отме­тим один из недос­татков сте­ка: как и любой дру­гой гомоген­ный мас­сив, стек может хра­нить дан­ные лишь одно­го типа, нап­ример, двой­ные сло­ва. Если же тре­бует­ся занес­ти один байт (ска­жем, аргу­мент типа char), то при­ходит­ся рас­ширять его до двой­ного сло­ва и заносить его целиком. Ана­логич­но, если аргу­мент занима­ет четыре сло­ва (double, int64), на его переда­чу рас­ходу­ется два сте­ковых эле­мен­та!

По­мимо переда­чи аргу­мен­тов стек исполь­зует­ся и для сох­ранения адре­са воз­вра­та из фун­кции, что тре­бует в зависи­мос­ти от типа вызова фун­кции (ближ­него или даль­него) от одно­го до двух эле­мен­тов. Ближ­ний (near) вызов дей­ству­ет в рам­ках одно­го сег­мента — в этом слу­чае дос­таточ­но сох­ранить лишь сме­щение коман­ды, сле­дующей за инс­трук­цией CALL. Если же вызыва­ющая фун­кция находит­ся в одном сег­менте, а вызыва­емая в дру­гом, то помимо сме­щения при­ходит­ся запоми­нать и сам сег­мент, что­бы знать, в какое мес­то вер­нуть­ся.

Пос­коль­ку адрес воз­вра­та заносит­ся пос­ле аргу­мен­тов, то отно­ситель­но вер­шины сте­ка аргу­мен­ты ока­зыва­ются «за» ним и их сме­щение варь­иру­ется в зависи­мос­ти от того, один эле­мент занима­ет адрес воз­вра­та или два. К счастью, плос­кая модель памяти Windows NT и всех пос­леду­ющих поз­воля­ет забыть о моделях памяти как о страш­ном сне и всю­ду исполь­зовать толь­ко ближ­ние вызовы.

Не­опти­мизи­рующие ком­пилято­ры исполь­зуют для адре­сации аргу­мен­тов спе­циаль­ный регистр (как пра­вило, RBP), копируя в него зна­чение регис­тра‑ука­зате­ля вер­шины сте­ка в самом начале фун­кции. Пос­коль­ку стек рас­тет свер­ху вниз, то есть от стар­ших адре­сов к млад­шим, сме­щение всех аргу­мен­тов (вклю­чая адрес воз­вра­та) положи­тель­но, а сме­щение N-го по сче­ту аргу­мен­та вычис­ляет­ся по фор­муле

arg_offset = N * size_element + size_return_address

Здесь:

  • N — номер аргу­мен­та, счи­тая от вер­шины сте­ка, начиная с нуля;
  • size_element — раз­мер одно­го эле­мен­та сте­ка, в общем слу­чае рав­ный раз­ряднос­ти сег­мента (под Windows NT — четыре бай­та);
  • size_return_address — раз­мер в бай­тах, занима­емый адре­сом воз­вра­та (под Windows NT — обыч­но четыре бай­та).

Час­то при­ходит­ся решать и обратную задачу: зная сме­щение эле­мен­та, опре­делять, к какому по сче­ту аргу­мен­ту про­исхо­дит обра­щение. В этом нам поможет сле­дующая фор­мула, эле­мен­тарно выводя­щаяся из пре­дыду­щей:

​ Пос­коль­ку перед копиро­вани­ем в RBP текуще­го зна­чения RSP ста­рое зна­чение RBP при­ходит­ся сох­ранять в том же самом сте­ке, в при­веден­ную фор­мулу при­ходит­ся вно­сить поп­равку, добав­ляя к раз­меру адре­са воз­вра­та еще и раз­мер регис­тра RBP (EBP в 32-раз­рядном режиме, который на сегод­няшний день все еще прек­расно живет и здравс­тву­ет).

С точ­ки зре­ния хакера глав­ное дос­тоинс­тво такой адре­сации аргу­мен­тов в том, что, уви­дев где‑то в середи­не кода инс­трук­цию типа MOV RAX,[RBP+0x10], мож­но мгно­вен­но вычис­лить, к какому имен­но аргу­мен­ту про­исхо­дит обра­щение. Одна­ко опти­мизи­рующие ком­пилято­ры для эко­номии регис­тра RBP адре­суют аргу­мен­ты непос­редс­твен­но через RSP.

Раз­ница прин­ципи­аль­на! Зна­чение RSP не оста­ется пос­тоян­ным на про­тяже­нии выпол­нения фун­кции и изме­няет­ся вся­кий раз при занесе­нии и сня­тии дан­ных из сте­ка, сле­дова­тель­но, не оста­ется пос­тоян­ным и сме­щение аргу­мен­тов отно­ситель­но RSP. Теперь, что­бы опре­делить, к какому имен­но аргу­мен­ту про­исхо­дит обра­щение, необ­ходимо знать, чему равен RSP в дан­ной точ­ке прог­раммы, а для выяс­нения это­го все его изме­нения при­ходит­ся отсле­живать от самого начала фун­кции!

Под­робнее о такой «хит­рой» адре­сации мы погово­рим потом, а для начала вер­немся к пре­дыду­щему при­меру (надо ж его «добить») и раз­берем вызыва­емую фун­кцию (исходник к ней смот­ри в пре­дыду­щей статье – при­мер на Visual C++):

void MyFunc(double, struct XT) proc near
arg_0 = qword ptr 8
arg_8 = qword ptr 10h

IDA рас­позна­ла два аргу­мен­та, переда­ваемых фун­кции. Одна­ко не сто­ит безого­вороч­но это­му доверять, если один аргу­мент (нап­ример, int64) переда­ется в нес­коль­ких машин­ных сло­вах, то IDA оши­боч­но при­мет его не за один, а за нес­коль­ко аргу­мен­тов! Поэто­му резуль­тат, получен­ный IDA, надо трак­товать так: фун­кции переда­ется не менее двух аргу­мен­тов.

Впро­чем, и здесь не все глад­ко! Ведь ник­то не меша­ет вызыва­емой фун­кции залезать в стек материн­ской так далеко, как она захочет! Может быть, нам не переда­вали никаких аргу­мен­тов вов­се, а мы самоволь­но полез­ли в стек и стя­нули что‑то отту­да. Хотя это слу­чает­ся в основном вследс­твие прог­раммист­ских оши­бок из‑за путани­цы с про­тоти­пами, счи­тать­ся с такой воз­можностью необ­ходимо. Ког­да‑нибудь вы все рав­но с этим встре­титесь, так что будь­те начеку.

Чис­ло, сто­ящее пос­ле arg, выража­ет сме­щение аргу­мен­та отно­ситель­но начала кад­ра сте­ка.

Из­вле­каем передан­ные аргу­мен­ты из регис­тров про­цес­сора и раз­меща­ем их в памяти (при этом вспо­мина­ем, что переда­вали из вызыва­ющей фун­кции):

mov [rsp+arg_8], rdx ; указатель на буфер
movsd [rsp+arg_0], xmm0 ; значение с плавающей запятой

Да­лее ини­циали­зиру­ем стек, под­готав­лива­ем регис­тры к работе, про­изво­дим необ­ходимые вычис­ления, затем кла­дем в регис­тры зна­чения для переда­чи парамет­ров фун­кции printf:

sub rsp, 28h
mov eax, 1
imul rax, 0
mov rcx, [rsp+28h+arg_8]
add rcx, rax
mov rax, rcx
mov r9, rax
mov rax, [rsp+28h+arg_8]
mov r8d, [rax+14h]
movsd xmm1, [rsp+28h+arg_0]
movq rdx, xmm1
lea rcx, _Format ; "%f,%x,%s\n"
call printf

Об­рати вни­мание, перед вызовом printf прог­рамма в трех регис­трах раз­меща­ет зна­чения парамет­ров для переда­чи, а в чет­вертом регис­тре RCX (так же для переда­чи) помеща­ет ука­затель на фор­матную стро­ку спе­цифи­като­ров вывода: %f,%x,%s\n. Фун­кция printf, как извес­тно, име­ет перемен­ное чис­ло аргу­мен­тов, тип и количес­тво которых как раз и зада­ют спе­цифи­като­ры. Вспом­ним: спер­ва в стек мы заноси­ли ука­затель на стро­ку, и дей­стви­тель­но, край­ний пра­вый спе­цифи­катор %s обоз­нача­ет вывод стро­ки. Затем в стек заноси­лась перемен­ная типа int и вто­рой спра­ва спе­цифи­катор, есть – вывод целого чис­ла в шес­тнад­цатерич­ной фор­ме.

А вот затем идет пос­ледний спе­цифи­катор %f. Заг­лянув в руководс­тво прог­раммис­та по Visual C++, мы проч­тем, что спе­цифи­катор %f выводит вещес­твен­ное зна­чение, которое в зависи­мос­ти от типа может занимать и четыре бай­та (float), и восемь (double). В нашем слу­чае оно явно занима­ет восемь бай­тов, сле­дова­тель­но, это double. Таким обра­зом, мы вос­ста­нови­ли про­тотип нашей фун­кции, вот он:

cdecl MyFunc(double a, struct B b)

Тип вызова cdecl озна­чает, что стек вычища­ет вызыва­ющая фун­кция. Вот толь­ко, увы, под­линный порядок переда­чи аргу­мен­тов вос­ста­новить невоз­можно. C++ Builder, кста­ти, так же вычищал стек вызыва­ющей фун­кци­ей, но самоволь­но изме­нял порядок переда­чи парамет­ров.

Мо­жет показать­ся, что если прог­рамму собира­ли в C++ Builder, то мы прос­то изме­няем порядок аргу­мен­тов на обратный, вот и все. Увы, это не так прос­то. Если име­ло мес­то явное пре­обра­зова­ние типа фун­кции в cdecl, то C++ Builder без лиш­ней самоде­ятель­нос­ти пос­тупил бы так, как ему велели, и тог­да бы обра­щение поряд­ка аргу­мен­тов дало бы невер­ный резуль­тат!

Впро­чем, под­линный порядок сле­дова­ния аргу­мен­тов в про­тоти­пе фун­кции не игра­ет никакой роли. Важ­но лишь свя­зать переда­ваемые и при­нима­емые аргу­мен­ты, что мы и сде­лали.

Об­ратите вни­мание: это ста­ло воз­можно лишь при сов­мес­тном ана­лизе и вызыва­емой, и вызыва­ющей фун­кций! Ана­лиз лишь одной из них ничего бы не дал!

info

Ни­ког­да не сле­дует безого­вороч­но полагать­ся на дос­товер­ность стро­ки спе­цифи­като­ров. Пос­коль­ку спе­цифи­като­ры фор­миру­ются «вруч­ную» самим прог­раммис­том, тут воз­можны ошиб­ки, под­час весь­ма труд­ноуло­вимые и дающие пос­ле ком­пиляции чрез­вычай­но загадоч­ный код!

Да­лее деини­циали­зиру­ем стек и зак­ругля­емся.

add rsp, 28h
retn
 

Соглашения о вызовах

Кое‑какие прод­вижения уже есть — мы уве­рен­но вос­ста­нови­ли про­тотип нашей пер­вой фун­кции. Но это толь­ко начало. Еще мно­го миль пред­сто­ит прой­ти… Если устал — передох­ни, тяп­ни ква­са, побол­тай с кем‑нибудь и про­дол­жим на све­жую голову. Мы прис­тупа­ем еще к одной очень важ­ной теме — срав­нитель­ному ана­лизу раз­ных типов вызовов фун­кций и их реали­зации в раз­ных ком­пилято­рах.

 

stdcall

Нач­нем с изу­чения стан­дар­тно­го сог­лашения о вызове — stdcall. Рас­смот­рим сле­дующий при­мер.

Исходник примера stdcall
Ис­ходник при­мера stdcall
#include <stdio.h>
#include <string.h>
int __stdcall MyFunc(int a, int b, const char* c){
return a + b + strlen(c);
}
int main(){
printf("%x\n", MyFunc(0x666, 0x777, "Hello,World!"));
}

Вот, как дол­жен выг­лядеть резуль­тат его ком­пиляции в Visual C++ с отклю­чен­ной опти­миза­цией, то есть клю­чом /0d (в ином слу­чае ком­пилятор так заоп­тимизи­рует код, что исчезнет вся­кая поз­наватель­ная сос­тавля­ющая):

main proc near
sub rsp, 28h

IDA хорошо нам под­ска­зыва­ет, что кон­стан­та c содер­жит стро­ку Hello,World!. Ука­затель на нее помеща­ется в регистр R8, пред­назна­чен­ный для переда­чи целочис­ленных парамет­ров или, собс­твен­но, ука­зате­лей. Пер­вым по поряд­ку переда­ется ука­затель на стро­ку, заг­лянув в исходные тек­сты (бла­го они у нас есть), мы обна­ружим, что это самый пра­вый аргу­мент, переда­ваемый фун­кции. Сле­дова­тель­но, перед нами вызов типа stdcall или cdecl, но не PASCAL.

lea r8, c ; "Hello,World!"
mov edx, 777h ; b
mov ecx, 666h ; a

Сле­дом помеща­ем в два 32-бит­ных регис­тра EDX и ECX зна­чения двух перемен­ных 0x777 и 0x666, соот­ветс­твен­но. Пос­леднее – ока­залось самым левым аргу­мен­том. Что показы­вает нам пра­виль­но вос­ста­нов­ленный про­тотип фун­кции. Но так быва­ет не всег­да, IDA иног­да оши­бает­ся.

call MyFunc(int,int,char const *)

Об­рати вни­мание, пос­ле вызова фун­кции отсутс­тву­ют коман­ды очис­тки сте­ка от занесен­ных в него аргу­мен­тов. Если ком­пилятор не схит­рил и не при­бег­нул к отло­жен­ной очис­тке, то, ско­рее все­го, стек очи­щает сама вызыва­емая фун­кция, зна­чит, тип вызова — stdcall (что, собс­твен­но, и тре­бова­лось доказать).

mov edx, eax

Те­перь, переда­ем воз­вра­щен­ное фун­кци­ей зна­чение сле­дующей фун­кции, как аргу­мент.

lea rcx, _Format ; "%x\n"

Эта сле­дующая фун­кция printf, и стро­ка спе­цифи­като­ров показы­вают, что передан­ный аргу­мент име­ет тип int.

call printf
xor eax, eax
add rsp, 28h
retn
main endp

Те­перь рас­смот­рим фун­кцию MyFunc:

; int __fastcall MyFunc(int a, int b, const char *c)
int MyFunc(int, int, char const *) proc near

IDA пыта­ется самос­тоятель­но вос­ста­новить про­тотип фун­кции и… обла­мыва­ется. Ины­ми сло­вами, дела­ет это не всег­да успешно. Нап­ример, «Ида» оши­боч­но пред­положи­ла тип вызова fastcall, хотя на самом деле – stdcall. Вспом­ним: fastcall на 32-бит­ной плат­форме пред­полага­ет переда­чу парамет­ров через регис­тры про­цес­сора, тог­да как на плат­форме x64 пер­вые четыре парамет­ра всег­да переда­ются через регис­тры про­цес­сора, незави­симо от ука­зан­ного типа вызова.

var_18 = qword ptr -18h
var_10 = qword ptr -10h
arg_0 = dword ptr 8
arg_8 = dword ptr 10h
arg_10 = qword ptr 18h

Пе­редан­ные аргу­мен­ты из регис­тров помеща­ются в память, затем пос­ле ини­циали­зации сте­ка про­исхо­дит раз­мещение чис­ловых зна­чений в регис­трах, где про­исхо­дит их сло­жение: add ecx, eax.

mov [rsp+arg_10], r8
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
sub rsp, 18h
mov eax, [rsp+18h+arg_8]
mov ecx, [rsp+18h+arg_0]
add ecx, eax
mov eax, ecx

Пре­обра­зова­ние двой­ного сло­ва (EAX) в учет­верен­ное (RAX):

cdqe

Ко­пиро­вание из сте­ка ука­зате­ля в стро­ку, в регистр RCX и в перемен­ную var_10. Далее ини­циали­зиру­ем перемен­ную var_18 зна­чени­ем -1, оче­вид­но, она будет счет­чиком.

mov rcx, [rsp+18h+arg_10]
mov [rsp+18h+var_10], rcx
mov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFh
loc_140001111:

И дей­стви­тель­но, на сле­дующем шаге она уве­личи­вает­ся на еди­ницу. К тому же здесь мы видим мет­ку безус­ловно­го перехо­да. Похоже, вмес­то того, что­бы узнать дли­ну стро­ки пос­редс­твом вызова биб­лиотеч­ной фун­кции strlen, ком­пилятор решил самос­тоятель­но сге­нери­ровать код. Что ж, флаг ему в руки!

inc [rsp+18h+var_18]

Зна­чения перемен­ных вновь копиру­ются в регис­тры для про­веде­ния опе­раций над ними.

mov rcx, [rsp+18h+var_10]
mov rdx, [rsp+18h+var_18]
cmp byte ptr [rcx+rdx], 0

Зна­чения регис­тров RCX и RDX скла­дыва­ются, а сум­ма срав­нива­ется с нулем. В слу­чае, если выраже­ние тож­дес­твен­но, то флаг ZF уста­нав­лива­ется в еди­ницу, в обратном слу­чае – в ноль. Инс­трук­ция JNZ про­веря­ет флаг Z. Если он равен нулю, тог­да про­исхо­дит переход на мет­ку loc_140001111, отку­да блок кода начина­ет выпол­нять­ся поновой.

jnz short loc_140001111

Ког­да флаг ZF равен еди­нице, осу­щест­вля­ется выход из цик­ла и переход на сле­дующую за ним инс­трук­цию, которая накоп­ленное в перемен­ной‑счет­чике чис­ло записы­вает в регистр RCX. Пос­ле это­го про­исхо­дит сло­жение зна­чений в регис­трах RCX и RAX, как пом­ним, в пос­леднем содер­жится сум­ма двух передан­ных чис­ловых аргу­мен­тов.

mov rcx, [rsp+18h+var_18]
add rax, rcx

В завер­шении фун­кции про­исхо­дит деини­циали­зация сте­ка:

add rsp, 18h
retn
int MyFunc(int, int, char const *) endp

Воз­вра­щение целочис­ленно­го аргу­мен­та на плат­форме x64 пре­дус­мотре­но в регис­тре RAX.

На вывод прог­рамма печата­ет: de9.

Вывод программы stdcall
Вы­вод прог­раммы stdcall

Лег­ко про­верить: Hello,World! – 12 сим­волов, то есть 0xC. Открой каль­кулятор в Windows:

0x666 + 0x777 + 0xC = 0xDE9

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

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

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

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

Крис Касперски

Крис Касперски

Известный российский хакер. Легенда ][, ex-редактор ВЗЛОМа. Также известен под псевдонимами мыщъх, nezumi (яп. 鼠, мышь), n2k, elraton, souriz, tikus, muss, farah, jardon, KPNC.

Юрий Язев

Юрий Язев

Широко известен под псевдонимом yurembo. Программист, разработчик видеоигр, независимый исследователь. Старый автор журнала «Хакер».

Check Also

MEGANews. Самые важные события в мире инфосека за июль

Ком­пания Microsoft выложи­ла на GitHub пер­вую ста­биль­ную сбор­ку собс­твен­ного дис­тр…

4 комментария

  1. Аватар

    binary-51

    18.07.2021 в 12:40

    Типичная классная статья в духе мышьха. Понятно по ней, что человек «шарит» не только в тонкостях программирования в том или ином компиляторе, но что есть существенная разница, как IDA такую логику компилятора понимает.
    Вроде как только не освещен тот момент, что до совершенно бесплатных компиляторов от Майкрософта и Embarcadero было немало других бесплатных компиляторов для языков C++ и Object Pascal. А уж как бы их понял великий и ужасный IDA…
    Господь с вами, в этом и есть хакерство, а не «кнопка бесплатного интернета» в Google.
    В целом очень интересно и я как завязавший программировать и начавший жить узнал много нового для себя.

    • Юрий Язев

      Юрий Язев

      18.07.2021 в 18:44

      Спасибо за отзыв!
      Подтягиваю старые работы Криса под современные реалии: архитектуру x64, новые версии Windows, системы разработки, компиляторы. В оригинальной книге Крис рассматривал Microsoft Visual C++ 6.0, Borland Turbo C++ 3.0, Borland Turbo Pascal 4.0, которые на момент написания оригинала были никак не бесплатны. Поэтому и я взял самые популярные компиляторы, на сегодняшний день ставшие бесплатными: MS Visual C++ 2019, Embarcadero C++Builder, Embarcadero Delphi.
      В качестве дискуссии можешь предложить другие компиляторы, которые нравятся тебе, только не забудь добавить, почему имеет смысл их рассмотреть в следующих статьях. Если будет интересно, то я дополню список компиляторов.

  2. Аватар

    toxicshadow

    18.07.2021 в 22:26

    Лет пять(а то и больше) не брал в руки дизассемблер, а тут понадобилось и цикл статей «Фундаментальные основы хакерства» здорово помог в решении моей задачи:).

Оставить мнение