May 31, 2021

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

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

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

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен 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: XMM0XMM3;
  • вы­зыва­ющая фун­кция резер­виру­ет в сте­ке прос­транс­тво для аргу­мен­тов, переда­ющих­ся в регис­трах. Вызыва­емая фун­кция может исполь­зовать это прос­транс­тво для раз­мещения содер­жимого регис­тров в сте­ке;
  • лю­бые допол­нитель­ные парамет­ры переда­ются в сте­ке;
  • ука­затель или целочис­ленный аргу­мент воз­вра­щает­ся в регис­тре 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

От­сутс­твие явной ини­циали­зации регис­тров говорит о том, что, ско­рее все­го, они прос­то сох­раня­ются в сте­ке, а не переда­ются как аргу­мен­ты. К тому же, как мы пом­ним, на плат­форме x64 пер­вые четыре парамет­ра целочис­ленно­го типа или ука­зате­ли переда­ются в регис­трах про­цес­сора: RCX, RDX, R8, R9. Если при­сутс­тву­ют допол­нитель­ные аргу­мен­ты, то они переда­ются по ста­рин­ке — через стек. Меж­ду тем если аргу­мен­ты переда­вались дан­ной фун­кции через регис­тры RSI и RDI, то их засыл­ка в стек впол­не может прес­ледовать цель переда­чи аргу­мен­тов сле­дующей фун­кции.

sub rsp, 68h

mov rax, cs:__security_cookie

; Инвертируем значение вершины стека, результат сохраняем в RAX

xor rax, rsp

; Далее это значение записываем в переменную

mov [rsp+78h+var_20], rax

mov eax, 1

imul rax, 0

; В регистр RAX помещаем указатель на область памяти:

lea rax, [rsp+rax+78h+Dst]

IDA нам под­ска­зала, что в регистр R8, слу­жащий для переда­чи парамет­ров, помеща­ется стро­ка "Hello,World!" из кон­стан­ты Src, которая находит­ся в сег­менте дан­ных, пред­назна­чен­ном толь­ко для чте­ния, .rdata:

lea r8, Src ; "Hello,World!"

В 32-бит­ный регистр EDX, так­же пред­назна­чен­ный для переда­чи парамет­ров, записы­ваем чис­ло бай­тов:

mov edx, 0Dh ; SizeInBytes

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

mov rcx, rax ; Dst

На­ше пред­положе­ние оправда­лось, сле­дующей коман­дой осу­щест­вля­ется вызов фун­кции для копиро­вания мас­сива сим­волов:

call cs:__imp_strcpy_s

Про­тотип безопас­ной фун­кции errno_t strcpy_s(char *dest rsize_t dest_size, const char *src);, где rsize_t поп­росту явля­ется синони­мом size_t, не поз­воля­ет опре­делить порядок занесе­ния аргу­мен­тов, одна­ко пос­коль­ку все биб­лиотеч­ные С‑фун­кции сле­дуют сог­лашению cdecl, то аргу­мен­ты заносят­ся спра­ва налево. Из это­го сле­дует, что исходный код выг­лядит так: strcpy_s(&buff[0], 13,"Hello,World!");.

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

За­носим в локаль­ную перемен­ную кон­стан­ту 0x777. Это явно кон­стан­та, а не ука­затель, так как у Windows в этой области памяти не могут хра­нить­ся никакие поль­зователь­ские дан­ные:

mov [rsp+78h+var_24], 777h

Го­товим парамет­ры для выпол­нения цик­личес­кой опе­рации, раз­мещая их в регис­трах:

lea rax, [rsp+78h+var_58] ; Указатель на начало области памяти приемника

lea rcx, [rsp+78h+Dst] ; Указатель на начало области памяти источника

mov rdi, rax ; Приемник

mov rsi, rcx ; Источник

; Теперь RSI и RDI содержат, соответственно, адреса источника и приемника

mov ecx, 18h

В пос­ледней стро­ке пре­дыду­щего лис­тинга в регистр ECX, занима­ющий ниж­ние 32 бита регис­тра RCX, помеща­ем количес­тво бай­тов для копиро­вания.

Вот она, переда­ча стро­ки по зна­чению, дру­гими сло­вами — переда­ча цепоч­ки бай­тов:

rep movsb

Вни­матель­но рас­смот­рим дей­ствие этой коман­ды. MOVSB копиру­ет один байт, она отно­сит­ся к семей­ству команд переда­чи дан­ных MOVS, где зак­лючитель­ная бук­ва опре­деля­ет раз­мер дан­ных: B — байт, W — сло­во, D — двой­ное сло­во и так далее. Для задания парамет­ров коман­ды на плат­форме x64 исполь­зуют­ся регис­тры RSI и RDI, в пер­вый помеща­ется адрес источни­ка в сег­менте дан­ных, во вто­рой — адрес при­емни­ка в допол­нитель­ном сег­менте. Опре­деле­ние парамет­ров хорошо прос­лежива­ется в пре­дыду­щем лис­тинге.

Что­бы пов­торить дей­ствие коман­ды MOVSB, перед ней ука­зыва­ется пре­фикс REP. Кро­ме того, что­бы про­цес­сор знал, сколь­ко раз надо пов­торить выпол­нение коман­ды, перед ее вызовом необ­ходимо помес­тить это зна­чение в регистр RCX (или ECX). Таким обра­зом, пос­ле выпол­нения коман­ды MOVSB зна­чение в регис­тре умень­шает­ся на еди­ницу. И цикл выпол­нения коман­ды про­дол­жает­ся, пока в RCX (ECX) не появит­ся ноль. Строч­кой выше в нашем дизас­сем­бли­рован­ном лис­тинге как раз и осу­щест­вля­ется эта опе­рация: mov ecx, 18h. Переве­дем чис­ло в десятич­ную фор­му, получим 24 пов­торения.

INFO

На­вер­няка ты заметил, что на стар­ших моделях про­цес­сора x86 прог­раммист может обра­щать­ся толь­ко к ниж­ней полови­не регис­тров млад­шей модели. Таким обра­зом, на 64-бит­ном про­цес­соре мы можем обра­щать­ся толь­ко к ниж­ней полови­не 32-бит­ных регис­тров. Но так было не всег­да, на 16-бит­ных Intel-сов­мести­мых про­цес­сорах прог­раммист мог обра­щать­ся так­же к стар­шей полови­не 8-бит­ных регис­тров. С выходом 80386 Intel под­забила на это, зап­ретив обра­щать­ся к стар­шей полови­не регис­тров.

Из это­го мож­но сде­лать вывод, что прог­рамма копиру­ет 24 бай­та. Давай раз­бирать­ся, почему бай­тов 24, ког­да нес­коль­кими стро­ками выше для копиро­вания стро­ки нам хва­тило толь­ко 13 байт? Как обыч­но, не под­смат­ривая исходник, зай­мем позицию хакера.

Пос­ле ини­циали­зации сте­ка зна­чение регис­тра RSP ука­зыва­ет на вер­шину сте­ка, которая находит­ся по самому млад­шему адре­су, тог­да как дно рас­полага­ется по самому стар­шему адре­су, пос­коль­ку стек рас­тет свер­ху вниз. Ука­затель на начало целево­го буфера памяти выг­лядит так: [rsp+78h+var_58]. Взгля­нув в начало лис­тинга рас­смат­рива­емой фун­кции, выяс­ним, что «динами­чес­кая перемен­ная» var_58 рав­на -58h. «Динами­чес­кая перемен­ная» в том смыс­ле, что зна­чение пред­став­ляет собой не перемен­ную, а толь­ко сме­щение отно­ситель­но вер­шины сте­ка. Тог­да как зна­чение Dst из выраже­ния [rsp+78h+Dst], ука­зыва­ющее на буфер памяти источни­ка, рав­но -38h.

Для упро­щения вычис­лений при­мем RSP = 0: адрес источни­ка [78h – 38h] = 40h, адрес при­емни­ка — [78h – 58h] = 20h. Все рав­но кар­тина не скла­дыва­ется. Обра­ти вни­мание на строч­ку mov [rsp+78h+var_24], 777h. Адрес перемен­ной равен 54h. Это уже нам о чем‑то говорит. Тип int занима­ет 4 бай­та — 54h + 4h. Отсю­да име­ем: 54h – 40h = 14h; 14h + 4h = 18h = 24 в десятич­ной сис­теме. Таким обра­зом, нам уда­лось вос­создать струк­туру, которую задумал прог­раммист при написа­нии сво­его при­ложе­ния.

struct XT {

char s0[20];

int x;

};

Сна­чала сле­дует 20 одно­бай­товых сим­волов типа char, а затем четырех­бай­товый int. Далее в RDX помеща­ем ука­затель на буфер, куда на пре­дыду­щем шаге была ско­пиро­вана струк­тура. Пос­коль­ку RDX исполь­зует­ся для переда­чи аргу­мен­тов, отме­тим про себя этот момент.

lea rdx, [rsp+78h+var_58]

movsd xmm0, cs:__real@401aa3d70a3d70a4 ; a

За­носим в XMM0 зна­чение кон­стан­ты __real@401aa3d70a3d70a4. Прок­рутим лис­тинг в дизас­сем­бле­ре, что­бы уви­деть, чему рав­но ее зна­чение:

; .rdata:0000000140002270 ; long double DOUBLE_6_66

.rdata:0000000140002270 __real@401aa3d70a3d70a4 dq 6.66

Как мы пом­ним, на плат­форме x64 при переда­че пер­вых четырех зна­чений с пла­вающей запятой исполь­зуют­ся пер­вые четыре регис­тра XMM0 — XMM3 из про­цес­сорно­го рас­ширения SSE.

call MyFunc(double,XT)

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

; Убираем за собой, деинициализируем стек

xor eax, eax

mov rcx, [rsp+78h+var_20] ; Значение вершины стека из области памяти

; помещаем в регистр RCX

xor rcx, rsp ; StackCookie

call __security_check_cookie

add rsp, 68h

; Извлекаем из стека значения ранее сохраненных регистров

pop rdi

pop rsi

retn

Рас­положе­ние в сте­ке парамет­ров перед вызовом фун­кции MyFunc

EMBARCADERO C++BUILDER

Те­перь пос­мотрим, какой код сге­нери­рует ком­пилятор Embarcadero C++Builder:

Нас­трой­ки про­екта в C++Builder

main proc near

var_44 = dword ptr -44h

var_40 = qword ptr -40h

var_38 = qword ptr -38h

var_30 = qword ptr -30h

var_28 = qword ptr -28h

var_20 = qword ptr -20h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

sub rsp, 68h ; Инициализируем стек

; Заполняем регистры

mov eax, 0Dh ; Число 13

mov r8d, eax ; Два 32-битных регистра

lea r9, aHelloWorld ; "Hello,World!"

lea r10, [rsp+68h+var_28] ; Указатель на пустую область памяти

mov [rsp+68h+var_4], 0

mov [rsp+68h+var_8], ecx

mov [rsp+68h+var_10], rdx

Го­товим регис­тры для переда­чи парамет­ров фун­кции:

mov rcx, r10 ; Указатель на целевую область памяти для копирования

mov rdx, r8 ; Src — число 13 (символов для копирования)

mov r8, r9 ; "Hello,World!"

Те­перь вызыва­ем фун­кцию, копиру­ющую стро­ку, исполь­зуя задан­ные ранее парамет­ры:

call strcpy_s

movsd xmm0, cs:qword_44A000

В стро­ке выше помеща­ем в регистр XMM0 двой­ное сло­во из кон­стан­ты — чис­ло с пла­вающей запятой. Далее помеща­ем в RDX ука­затель на начало пус­того буфера:

lea rdx, [rsp+68h+var_40]

mov [rsp+68h+var_14], 777h

Вы­ше в перемен­ную помеща­ем кон­стан­ту 0x777. Это важ­ный момент, обра­тим на него вни­мание. Далее в регистр RCX помеща­ется ско­пиро­ван­ная стро­ка, она находит­ся акку­рат перед чис­лом: -28h + 14h = -14h, далее, вплоть до вызова фун­кции, эта область памяти не переза­писы­вает­ся:

mov rcx, [rsp+68h+var_28]

mov [rsp+68h+var_40], rcx

mov rcx, [rsp+68h+var_20]

mov [rsp+68h+var_38], rcx

mov rcx, [rsp+50h]

Здесь мы видим новое пере­упо­рядо­чива­ние дан­ных в памяти: сна­чала в стек копиру­ется стро­ка, сос­тоящая из 14h байт:

mov [rsp+68h+var_30], rcx

За­тем зна­чение типа int — 4h бай­та:

mov [rsp+68h+var_44], eax

call MyFunc(double,XT)

Пос­ле вызова фун­кции нет очис­тки сте­ка. Это пос­ледняя вызыва­емая фун­кция, и очис­тки сте­ка не тре­бует­ся — C++Builder ее и не выпол­няет...

Ка­кой стран­ный спо­соб обну­ления регис­тра EAX! Visual C++ в этом мес­те пошел про­торен­ной дорогой: xor eax, eax, что в разы быс­трее.

mov [rsp+68h+var_4], 0

mov eax, [rsp+68h+var_4]

Об­нуление EAX нуж­но, что­бы фун­кция вер­нула 0, даже если она фак­тичес­ки ничего не воз­вра­щает. Вос­ста­нав­лива­ем RSP — вот почему стек не очи­щал­ся пос­ле вызова пос­ледней фун­кции!

add rsp, 68h

retn

main endp

Об­рати вни­мание: по умол­чанию Visual C++ переда­ет аргу­мен­ты спра­ва налево:

...

lea rdx, [rsp+78h+var_58]

movsd xmm0, cs:__real@401aa3d70a3d70a4

...

В то же вре­мя C++Builder — сле­ва нап­раво:

...

movsd xmm0, cs:qword_44A000

lea rdx, [rsp+68h+var_40]

...

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

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

...

lea r9, aHelloWorld ; "Hello,World!"

lea r10, [rsp+68h+var_28] ; Указатель на пустую область памяти

...

mov rcx, r10 ; Указатель на целевую область памяти для копирования

mov rdx, r8 ; Src — 13

mov r8, r9 ; "Hello,World!"

call strcpy_s ; Копируем строку

...

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

ЗАКЛЮЧЕНИЕ

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