Категория > Новости > Фундаментальные основы хакерства. Учимся идентифицировать аргументы функций - «Новости»

Фундаментальные основы хакерства. Учимся идентифицировать аргументы функций - «Новости»


30-05-2021, 00:02. Автор: Нинель
стра­нице авто­ра.

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


Су­щес­тву­ет три спо­соба передать аргу­мен­ты фун­кции: через стек, регис­тры и ком­биниро­ван­ный — через стек и регис­тры одновре­мен­но. К это­му спис­ку вплот­ную при­мыка­ет и неяв­ная переда­ча аргу­мен­тов через гло­баль­ные перемен­ные.


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


 

Соглашения о передаче параметров


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


Не­однознач­ность механиз­ма переда­чи аргу­мен­тов — одна из при­чин несов­мести­мос­ти раз­личных ком­пилято­ров. Кажет­ся, почему бы не зас­тавить всех про­изво­дите­лей ком­пилято­ров при­дер­живать­ся какой‑то одной схе­мы? Увы, это при­несет боль­ше проб­лем, чем решит.


Каж­дый механизм име­ет свои дос­тоинс­тва и недос­татки и, что еще хуже, тес­но свя­зан с самим язы­ком. В час­тнос­ти, «сиш­ные» воль­нос­ти с соб­людени­ем про­тоти­пов фун­кций воз­можны имен­но потому, что аргу­мен­ты из сте­ка вытал­кива­ет не вызыва­емая, а вызыва­ющая фун­кция, которая навер­няка пом­нит, что она переда­вала. Нап­ример, фун­кции main переда­ются два аргу­мен­та — количес­тво клю­чей коман­дной стро­ки и ука­затель на содер­жащий их мас­сив. Одна­ко, если прог­рамма не работа­ет с коман­дной стро­кой (или получа­ет ключ каким‑то иным путем), про­тотип main может быть объ­явлен и так: main().


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


Ми­нусом «сиш­ного» решения явля­ется нез­начитель­ное уве­личе­ние раз­мера генери­руемо­го кода, ведь пос­ле каж­дого вызова фун­кции при­ходит­ся встав­лять машин­ную коман­ду (и порой не одну) для вытал­кивания аргу­мен­тов из сте­ка, а у пас­каля эта коман­да вне­сена непос­редс­твен­но в саму фун­кцию и потому встре­чает­ся в прог­рамме один‑единс­твен­ный раз.


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




  1. С‑сог­лашение (обоз­нача­емое cdecl) пред­писыва­ет засылать аргу­мен­ты в стек спра­ва налево в поряд­ке их объ­явле­ния, а очис­тку сте­ка воз­лага­ет на пле­чи вызыва­ющей фун­кции. Име­на фун­кций, сле­дующих С‑сог­лашению, пред­варя­ются сим­волом под­черки­вания _, авто­мати­чес­ки встав­ляемо­го ком­пилято­ром. Ука­затель this (в прог­раммах, написан­ных на C++) переда­ется через стек пос­ледним по сче­ту аргу­мен­том.


  2. Пас­каль‑сог­лашение (обоз­нача­емое PASCAL) пред­писыва­ет засылать аргу­мен­ты в стек сле­ва нап­раво в поряд­ке их объ­явле­ния и воз­лага­ет очис­тку сте­ка на саму вызыва­ющую фун­кцию. Обра­ти вни­мание: в нас­тоящее вре­мя клю­чевое сло­во PASCAL счи­тает­ся уста­рев­шим и выходит из упот­ребле­ния, вмес­то него мож­но исполь­зовать ана­логич­ное сог­лашение WINAPI.


  3. Стан­дар­тное сог­лашение (обоз­нача­емое stdcall) явля­ется гиб­ридом С- и пас­каль‑сог­лашений. Аргу­мен­ты засыла­ются в стек спра­ва налево, но очи­щает стек сама вызыва­емая фун­кция. Име­на фун­кций, сле­дующих стан­дар­тно­му сог­лашению, пред­варя­ются сим­волом под­черки­вания _, а закан­чива­ются суф­фиксом @, за которым сле­дует количес­тво бай­тов, переда­ваемых фун­кции. Ука­затель this переда­ется через стек пос­ледним по сче­ту аргу­мен­том.


  4. Сог­лашение быс­тро­го вызова пред­писыва­ет переда­вать аргу­мен­ты через регис­тры. Ком­пилято­ры от Microsoft и Embarcadero под­держи­вают клю­чевое сло­во fastcall, но интер­пре­тиру­ют его по‑раз­ному. Име­на фун­кций, сле­дующих сог­лашению fastcall, пред­варя­ются сим­волом @, авто­мати­чес­ки встав­ляемым ком­пилято­ром.


  5. Сог­лашение по умол­чанию. Если явное объ­явле­ние типа вызова отсутс­тву­ет, ком­пилятор обыч­но исполь­зует собс­твен­ные сог­лашения, выбирая их по сво­ему усмотре­нию. Наиболь­шему вли­янию под­верга­ется ука­затель this, боль­шинс­тво ком­пилято­ров при вызове по умол­чанию переда­ют его через регистр. У Microsoft это RCX, у Embarcadero — RAX. Осталь­ные аргу­мен­ты так­же могут передать­ся через регис­тры, если опти­миза­тор пос­чита­ет, что так будет луч­ше. Механизм переда­чи и логика выбор­ки аргу­мен­тов у всех раз­ная и наперед неп­ред­ска­зуемая, раз­бирай­ся по ситу­ации.


x64

Вмес­те с появ­лени­ем архи­тек­туры x64 для нее было изоб­ретено толь­ко одно новое сог­лашение вызова, заменив­шее собой все осталь­ные:



  • пер­вые четыре целочис­ленных парамет­ра, в том чис­ле ука­зате­ли, переда­ются в регис­трах RCX, RDX, R8, R9;

  • пер­вые четыре зна­чения с пла­вающей запятой переда­ются в пер­вых четырех регис­трах рас­ширения SSE: XMM0 — XMM3;

  • вы­зыва­ющая фун­кция резер­виру­ет в сте­ке прос­транс­тво для аргу­мен­тов, переда­ющих­ся в регис­трах. Вызыва­емая фун­кция может исполь­зовать это прос­транс­тво для раз­мещения содер­жимого регис­тров в сте­ке;

  • лю­бые допол­нитель­ные парамет­ры переда­ются в сте­ке;

  • ука­затель или целочис­ленный аргу­мент воз­вра­щает­ся в регис­тре RAX. Зна­чение с пла­вающей запятой воз­вра­щает­ся в регис­тре XMM0.


Од­нако бла­года­ря обратной сов­мести­мос­ти с x86 сов­ремен­ные про­цес­соры на базе x86_64 так­же под­держи­вают все перечис­ленные спо­собы переда­чи парамет­ров.


Сто­ит отме­тить, что регис­тры RAX, RCX, RDX, а так­же R8...R11 — изме­няемые, тог­да как RBX, RBP, RDI, RSI, R12...R15 — неиз­меня­емые. Что это зна­чит? Это свой­ство было добав­лено в архи­тек­туру x64, оно озна­чает, что зна­чения пер­вых могут быть изме­нены непос­редс­твен­но в вызыва­емой фун­кции, тог­да как зна­чения вто­рых дол­жны быть сох­ранены в памяти в начале вызыва­емой фун­кции, а в ее кон­це, перед воз­вра­щени­ем, — вос­ста­нов­лены.


 

Цели и задачи


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


Тип сог­лашения гру­бо иден­тифици­рует­ся по спо­собу вычис­тки сте­ка. Если его очи­щает вызыва­емая фун­кция, мы име­ем дело c cdecl, в про­тив­ном слу­чае это либо stdcall, либо PASCAL. Такая неоп­ределен­ность в отож­дест­вле­нии выз­вана тем, что под­линный про­тотип фун­кции неиз­вестен и, ста­ло быть, порядок занесе­ния аргу­мен­тов в стек опре­делить невоз­можно. Единс­твен­ная зацеп­ка: зная ком­пилятор и пред­полагая, что прог­раммист исполь­зовал тип вызовов по умол­чанию, мож­но уточ­нить тип вызова фун­кции. Одна­ко в прог­раммах под Windows широко исполь­зуют­ся оба типа вызовов: и PASCAL (он же WINAPI), и stdcall, поэто­му неоп­ределен­ность по‑преж­нему оста­ется.


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


Дру­гое дело — биб­лиотеч­ные фун­кции, про­тотип которых известен. Зная порядок занесе­ния аргу­мен­тов в стек, по про­тоти­пу мож­но авто­мати­чес­ки вос­ста­новить тип и наз­начение аргу­мен­тов!


 

Определение количества и типа передачи аргументов


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


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


Ес­ли фун­кция сле­дует сог­лашению stdcall (или PASCAL), она навер­няка очи­щает стек коман­дой RET n, где n и есть иско­мое зна­чение в бай­тах. Хуже с cdecl-фун­кци­ями. В общем слу­чае за их вызовом сле­дует инс­трук­ция ADD RSP, n, где n — иско­мое зна­чение в бай­тах, но воз­можны и вари­ации: отло­жен­ная очис­тка сте­ка или вытал­кивание аргу­мен­тов в какой‑нибудь сво­бод­ный регистр. Впро­чем, отло­жим голово­лом­ки опти­миза­ции на потом, а пока огра­ничим­ся лишь кру­гом неоп­тимизи­рующих ком­пилято­ров.


Ло­гич­но пред­положить, что количес­тво занесен­ных в стек бай­тов рав­но количес­тву вытал­кива­емых, ина­че пос­ле завер­шения фун­кции стек ока­жет­ся нес­балан­сирован­ным и прог­рамма рух­нет (о том, что опти­мизи­рующие ком­пилято­ры допус­кают дис­баланс сте­ка на некото­ром учас­тке, мы пом­ним, но погово­рим об этом потом). Отсю­да сле­дует: количес­тво аргу­мен­тов рав­но количес­тву передан­ных бай­тов, делен­ному на раз­мер машин­ного сло­ва. Под машин­ным сло­вом понима­ется не толь­ко два бай­та, но и раз­мер опе­ран­дов по умол­чанию, в 32-раз­рядном режиме машин­ное сло­во рав­но четырем бай­там (двой­ное сло­во), в 64-раз­рядном режиме машин­ное сло­во — это учет­верен­ное сло­во (восемь бай­тов).


Вер­но ли это? Нет! Далеко не вся­кий аргу­мент занима­ет ров­но один эле­мент сте­ка. Взять тот же тип int, отъ­еда­ющий толь­ко полови­ну. Или сим­воль­ную стро­ку, передан­ную не по ссыл­ке, а по непос­редс­твен­ному зна­чению: она «ску­шает» столь­ко бай­тов, сколь­ко захочет. К тому же стро­ка может засылать­ся в стек (как и струк­тура дан­ных, мас­сив, объ­ект) не коман­дой PUSH, а с помощью MOVS! Кста­ти, наличие MOVS — явное сви­детель­ство переда­чи аргу­мен­та по зна­чению.


Ес­ли я успел окон­чатель­но тебя запутать, то поп­робу­ем раз­ложить по полоч­кам тот кавар­дак, что обра­зовал­ся в тво­ей голове. Итак, ана­лизом кода вызыва­ющей фун­кции уста­новить количес­тво передан­ных через стек аргу­мен­тов невоз­можно. Даже количес­тво передан­ных бай­тов опре­деля­ется весь­ма неуве­рен­но. С типом переда­чи пол­ный мрак. Поз­же мы к это­му еще вер­немся, а пока вот при­мер. PUSH 0x404040 / CALL MyFunc — эле­мент 0x404040 — что это: аргу­мент, переда­ваемый по зна­чению (то есть кон­стан­та 0x404040), или ука­затель на неч­то, рас­положен­ное по сме­щению 0x404040, и тог­да, ста­ло быть, переда­ча про­исхо­дит по ссыл­ке? С ходу опре­делить это невоз­можно, не прав­да ли?


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


#include <string.h>struct XT {
char s0[20];
int x;};void MyFunc(double a, struct XT xt) {
printf("%f,%x,%sn", a, xt.x, &xt.s0[0]);}int main() {
XT xt;
strcpy_s(&xt.s0[0], 13, "Hello,World!");
xt.x = 0x777;
MyFunc(6.66, xt);}
Фундаментальные основы хакерства. Учимся идентифицировать аргументы функций - «Новости»
Вы­вод нашего при­ложе­ния

Ре­зуль­тат его ком­пиляции ком­пилято­ром Microsoft Visual C++ с вклю­чен­ной под­дер­жкой плат­формы x64 и в релиз­ном режиме, но с вык­лючен­ной опти­миза­цией (/Od) выг­лядит так:


main proc near
var_58 = byte ptr -58h
Dst= byte ptr -38h
var_24 = dword ptr -24h
var_20 = qword ptr -20h
; Инициализируем стек
push rsi
push rdi


Перейти обратно к новости