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

Фундаментальные основы хакерства

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен Windows 2000 и Visual Studio 6.0 во вре­мена Windows 10 и Visual Studio 2019.

Ссыл­ки на дру­гие статьи из это­го цик­ла ищи на стра­нице авто­ра.

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

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

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

 

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

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

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

Каж­дый механизм име­ет свои дос­тоинс­тва и недос­татки и, что еще хуже, тес­но свя­зан с самим язы­ком. В час­тнос­ти, «сиш­ные» воль­нос­ти с соб­людени­ем про­тоти­пов фун­кций воз­можны имен­но потому, что аргу­мен­ты из сте­ка вытал­кива­ет не вызыва­емая, а вызыва­ющая фун­кция, которая навер­няка пом­нит, что она переда­вала. Нап­ример, фун­кции 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 <stdio.h>
#include <string.h>
struct XT {
char s0[20];
int x;
};
void MyFunc(double a, struct XT xt) {
printf("%f,%x,%s\n", 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

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

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

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

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

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


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

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

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

Юрий Язев

Юрий Язев

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

Check Also

Криптобиржу BitMart взломали, похищено 150 млн долларов

Криптовалютная биржа BitMart заявила, что в минувшие выходные ее взломали и похитили 150 м…

Подписаться
Уведомить о
0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии