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

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

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

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

По­пут­но отме­тим один из недос­татков сте­ка: как и любой дру­гой гомоген­ный мас­сив, стек может хра­нить дан­ные лишь одно­го типа, нап­ример двой­ные сло­ва. Если же тре­бует­ся занес­ти один байт (ска­жем, аргу­мент типа 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

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

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

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

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

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


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

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

    Подписаться

  • Подписаться
    Уведомить о
    4 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии