Фундаментальные основы хакерства. Определяем «почерк» компилятора по вызовам функций

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

  • Адресация аргументов в стеке
  • Соглашения о вызовах
  • stdcall
  • cdecl
  • WINAPI
  • PASCAL VS stdcall
  • Delphi

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

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

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

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

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

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

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

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

Здесь:

  • 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 neararg_0 = qword ptr 8arg_8 = qword ptr 10h

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

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

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

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

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

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

sub rsp, 28hmov eax, 1imul rax, 0mov rcx, [rsp+28h+arg_8]add rcx, raxmov rax, rcxmov r9, raxmov rax, [rsp+28h+arg_8]mov r8d, [rax+14h]movsd xmm1, [rsp+28h+arg_0]movq rdx, xmm1lea rcx, _Format ; "%f,%x,%sn"call printf

Об­рати вни­мание, перед вызовом printf прог­рамма в трех регис­трах раз­меща­ет зна­чения парамет­ров для переда­чи, а в чет­вертом регис­тре RCX (так же для переда­чи) помеща­ет ука­затель на фор­матную стро­ку спе­цифи­като­ров вывода: %f,%x,%sn. Фун­кция 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, 28hretn 

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

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

 

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("%xn", MyFunc(0x666, 0x777, "Hello,World!"));}

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

main proc nearsub rsp, 28h

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

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

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

call MyFunc(int,int,char const *)

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

mov edx, eax

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

lea rcx, _Format ; "%xn"

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

call printfxor eax, eaxadd rsp, 28hretnmain 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 -18hvar_10 = qword ptr -10harg_0 = dword ptr 8arg_8 = dword ptr 10harg_10 = qword ptr 18h

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

mov [rsp+arg_10], r8mov [rsp+arg_8], edxmov [rsp+arg_0], ecxsub rsp, 18hmov eax, [rsp+18h+arg_8]mov ecx, [rsp+18h+arg_0]add ecx, eaxmov eax, ecx

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

cdqe

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

mov rcx, [rsp+18h+arg_10]mov [rsp+18h+var_10], rcxmov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFhloc_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, 18hretnint MyFunc(int, int, char const *) endp

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

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

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

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

Источник: xakep.ru

Ответить

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