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

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

Пе­ред тобой уже во вто­рой раз обновлен­ная вер­сия цик­ла «Фун­дамен­таль­ные осно­вы хакерс­тва». В 2018 году Юрий Язев изме­нил текст Кри­са Кас­пер­ски для соот­ветс­твия новым вер­сиям Windows и Visual Studio, а теперь внес прав­ки с уче­том отладки прог­рамм для 64-раз­рядной архи­тек­туры.

Чи­тай так­же улуч­шенные вер­сии прош­лых ста­тей цик­ла:

  1. Учим­ся ана­лизи­ровать прог­раммы для x86-64 с нуля
  2. Ис­поль­зуем отладчик для ана­лиза 64-раз­рядных прог­рамм в Windows
  3. На­ходим реаль­ные адре­са инс­трук­ций в исполня­емых фай­лах x86-64
  4. Ос­ваиваем раз­ные спо­собы поис­ка защит в прог­раммах для x86-64
  5. Мас­тер‑класс по ана­лизу исполня­емых фай­лов в IDA Pro

Все новые вер­сии ста­тей дос­тупны без плат­ной под­писки.

Цикл «Фун­дамен­таль­ные осно­вы хакерс­тва» со все­ми обновле­ниями опуб­ликован в виде кни­ги, ку­пить ее по выгод­ной цене ты можешь на сай­те изда­тель­ства «Солон‑пресс».

Сов­ремен­ные дизас­сем­бле­ры дос­таточ­но интеллек­туаль­ны и льви­ную долю рас­позна­вания клю­чевых струк­тур берут на себя. В час­тнос­ти, IDA Pro успешно справ­ляет­ся с иден­тифика­цией стан­дар­тных биб­лиотеч­ных фун­кций, локаль­ных перемен­ных, адре­суемых через регистр RSP, case-вет­вле­ний и про­чего. Одна­ко порой IDA оши­бает­ся, вво­дя иссле­дова­теля в заб­лужде­ние, к тому же высокая сто­имость IDA Pro не всег­да оправды­вает при­мене­ние. Нап­ример, сту­ден­там, изу­чающим ассем­блер (а луч­шее средс­тво изу­чения ассем­бле­ра — дизас­сем­бли­рова­ние чужих прог­рамм), «Ида» едва ли по кар­ману.

Ра­зуме­ется, на IDA свет кли­ном не сошел­ся, сущес­тву­ют и дру­гие дизас­сем­бле­ры — ска­жем, тот же DUMPBIN, вхо­дящий в штат­ную пос­тавку SDK. Почему бы на худой конец не вос­поль­зовать­ся им? Конеч­но, если под рукой нет ничего луч­шего, сой­дет и DUMPBIN, но в этом слу­чае об интеллек­туаль­нос­ти дизас­сем­бле­ра при­дет­ся забыть и поль­зовать­ся исклю­читель­но сво­ей головой.

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

 

Идентификация функций

Фун­кция (так­же называ­емая про­цеду­рой или под­прог­раммой) — основная струк­турная еди­ница про­цедур­ных и объ­ектно ори­енти­рован­ных язы­ков, поэто­му дизас­сем­бли­рова­ние кода обыч­но начина­ется с отож­дест­вле­ния фун­кций и иден­тифика­ции переда­ваемых им аргу­мен­тов. Стро­го говоря, тер­мин «фун­кция» при­сутс­тву­ет не во всех язы­ках, но даже там, где он при­сутс­тву­ет, его опре­деле­ние варь­иру­ется от язы­ка к язы­ку.

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

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

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

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

Та­ким обра­зом, рас­познать фун­кцию мож­но дво­яко: по перек­рес­тным ссыл­кам, ведущим к машин­ной инс­трук­ции CALL, и по ее эпи­логу, завер­шающе­муся инс­трук­цией RET. Перек­рес­тные ссыл­ки и эпи­лог в совокуп­ности поз­воля­ют опре­делить адре­са начала и кон­ца фун­кции. Нем­ного забегая впе­ред, заметим, что в начале мно­гих фун­кций при­сутс­тву­ет харак­терная пос­ледова­тель­ность команд, называ­емая про­логом, которая так­же при­год­на и для иден­тифика­ции фун­кций. А теперь рас­смот­рим все эти темы попод­робнее.

 

Непосредственный вызов функции

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

void func();
int main()
{
int a;
func();
a=0x666;
func();
}
void func()
{
int a;
a++;
}

Что­бы откомпи­лиро­вать при­мер для 64-бит­ной плат­формы, надо открыть соот­ветс­тву­ющую кон­соль — x64 Native Tools Command Prompt for VS — и уже в ней выпол­нить коман­ду

cl.exe main.cpp /EHcs

Ре­зуль­тат ком­пиляции в IDA Pro дол­жен выг­лядеть приб­лизитель­но так:

.text:0000000140001020 main proc near
.text:0000000140001020 var_18 = dword ptr -18h
.text:0000000140001020
.text:0000000140001020 sub rsp, 38h

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

.text:0000000140001024 call sub_140001000
.text:0000000140001029 mov [rsp+38h+var_18], 666h
.text:0000000140001031 call sub_140001000
.text:0000000140001036 xor eax, eax
.text:0000000140001038 add rsp, 38h

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

.text:000000014000103C retn
.text:000000014000103C main endp
.text:0000000140001040 sub_140001040 proc near
.text:0000000140001040 push rbx
.text:0000000140001042 sub rsp, 20h
.text:0000000140001046 mov ecx, 1
.........

Су­дя по адре­сам, «наша фун­кция» в лис­тинге рас­положе­на выше фун­кции main:

.text:0000000140001000 sub_140001000 proc near
.text:0000000140001000 var_18 = dword ptr -18h

На эту стро­ку ссы­лают­ся опе­ран­ды нес­коль­ких инс­трук­ций call.Сле­дова­тель­но, это адрес начала «нашей фун­кции».

.text:0000000140001000 sub rsp, 18h
.text:0000000140001004 mov eax, [rsp+18h+var_18]
.text:0000000140001007 inc eax
.text:0000000140001009 mov [rsp+18h+var_18], eax
.text:000000014000100C add rsp, 18h
.text:0000000140001010 retn
.text:0000000140001010 sub_140001000 endp

Как видишь, все очень прос­то.

 

Вызов функции по указателю

Од­нако задача замет­но усложня­ется, если прог­раммист (или ком­пилятор) исполь­зует кос­венные вызовы фун­кций, переда­вая их адрес в регис­тре и динами­чес­ки вычис­ляя его (адрес, а не регистр!) на ста­дии выпол­нения прог­раммы. Имен­но так, в час­тнос­ти, реали­зова­на работа с вир­туаль­ными фун­кци­ями, одна­ко в любом слу­чае ком­пилятор дол­жен каким‑то обра­зом сох­ранить адрес фун­кции в коде. Зна­чит, его мож­но най­ти и вычис­лить! Еще про­ще заг­рузить иссле­дуемое при­ложе­ние в отладчик, уста­новить на «под­следс­твен­ную» инс­трук­цию CALL точ­ку оста­нова и, дож­давшись всплы­тия отладчи­ка, пос­мотреть, по какому адре­су она передаст управле­ние. Рас­смот­рим сле­дующий при­мер (Listing2):

int func()
{
return 0;
}
int main()
{
int (*a)();
a = func;
a();
}

Ре­зуль­тат его ком­пиляции дол­жен в общем слу­чае выг­лядеть так (фун­кция main):

.text:0000000140001000 loc_140001000:
.text:0000000140001000 xor eax, eax
.text:0000000140001002 retn
.text:0000000140001002 ; -------------------------------------------
.text:0000000140001010 main proc near
.text:0000000140001010
.text:0000000140001010 var_18 = qword ptr -18h
.text:0000000140001010
.text:0000000140001010 sub rsp, 38h
.text:0000000140001014 lea rax, loc_140001000
.text:000000014000101B mov [rsp+38h+var_18], rax

Вот инс­трук­ция CALL, осу­щест­вля­ющая кос­венный вызов фун­кции по адре­су, содер­жащему­ся в ячей­ке [rsp+38h+var_18]. Как узнать, что же там содер­жится? Под­нимем глаз­ки строч­кой выше и обна­ружим: lea rax, loc_140001000. Ага! Зна­чит, управле­ние переда­ется по сме­щению loc_140001000, где рас­полага­ется адрес начала фун­кции! Теперь оста­лось толь­ко дать фун­кции осмыслен­ное имя.

.text:0000000140001020 call [rsp+38h+var_18]
.text:0000000140001024 xor eax, eax
.text:0000000140001026 add rsp, 38h
.text:000000014000102A retn
.text:000000014000102A main endp
 

Вызов функции по указателю с комплексным вычислением целевого адреса

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

int func_1()
{
return 0;
}
int func_2()
{
return 0;
}
int func_3()
{
return 0;
}
int main()
{
int x;
int a[3]={(int) func_1,(int) func_2, (int) func_3};
int (*f)();
for (x=0;x < 3;x++)
{
f=(int (*)()) a[x]; f();
}
}

Ре­зуль­тат дизас­сем­бли­рова­ния это­го кода в общем слу­чае дол­жен выг­лядеть так:

.text:0000000140001030 main proc near
.text:0000000140001030
.text:0000000140001030 var_38 = dword ptr -38h
.text:0000000140001030 var_30 = qword ptr -30h
.text:0000000140001030 var_28 = dword ptr -28h
.text:0000000140001030 var_24 = dword ptr -24h
.text:0000000140001030 var_20 = dword ptr -20h
.text:0000000140001030 var_18 = qword ptr -18h
.text:0000000140001030
.text:0000000140001030 sub rsp, 58h
.text:0000000140001034 mov rax, cs:__security_cookie
.text:000000014000103B xor rax, rsp
.text:000000014000103E mov [rsp+58h+var_18], rax
.text:0000000140001043 lea rax, loc_140001000
.text:000000014000104A mov [rsp+58h+var_28], eax
.text:000000014000104E lea rax, sub_140001010
.text:0000000140001055 mov [rsp+58h+var_24], eax
.text:0000000140001059 lea rax, sub_140001020
.text:0000000140001060 mov [rsp+58h+var_20], eax
.text:0000000140001064 mov [rsp+58h+var_38], 0
.text:000000014000106C jmp short loc_140001078
.text:000000014000106E ; ----------------------------------------------
.text:000000014000106E
.text:000000014000106E loc_14000106E: ; CODE XREF: main+62↓j
.text:000000014000106E mov eax, [rsp+58h+var_38]
.text:0000000140001072 inc eax
.text:0000000140001074 mov [rsp+58h+var_38], eax
.text:0000000140001078
.text:0000000140001078 loc_140001078: ; CODE XREF: main+3C↑j
.text:0000000140001078 cmp [rsp+58h+var_38], 3
.text:000000014000107D jge short loc_140001094
.text:000000014000107F movsxd rax, [rsp+58h+var_38]
.text:0000000140001084 movsxd rax, [rsp+rax*4+58h+var_28]
.text:0000000140001089 mov [rsp+58h+var_30], rax
.text:000000014000108E call [rsp+58h+var_30]
.text:0000000140001092 jmp short loc_14000106E
.text:0000000140001094 ; ---------------------------------------------
.text:0000000140001094
.text:0000000140001094 loc_140001094: ; CODE XREF: main+4D↑j
.text:0000000140001094 xor eax, eax
.text:0000000140001096 mov rcx, [rsp+58h+var_18]
.text:000000014000109B xor rcx, rsp
.text:000000014000109E call __security_check_cookie
.text:00000001400010A3 add rsp, 58h
.text:00000001400010A7 retn

В стро­ке call [rsp+58h+var_30] про­исхо­дит кос­венный вызов фун­кции. А что у нас в [rsp+58h+var_30]? Под­нима­ем гла­за на одну стро­ку вверх — в [rsp+58h+var_30] у нас зна­чение rax. А чему же равен сам rax? Прок­ручива­ем еще одну стро­ку вверх — rax равен содер­жимому ячей­ки [rsp+rax*4+58h+var_28]. Вот дела! Мало того, что нам надо узнать содер­жимое этой ячей­ки, так еще и пред­сто­ит вычис­лить ее адрес!

Че­му равен RAX в этом выраже­нии? Содер­жимому [rsp+58h+var_38]. А оно чему рав­но? «Сей­час выяс­ним...» — бор­мочем мы себе под нос, прок­ручивая экран дизас­сем­бле­ра вверх. Ага, наш­ли: в стро­ке 0x140001074 в него заг­ружа­ется содер­жимое EAX! Какая радость! И дол­го мы будем так блуж­дать по коду?

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

Го­раз­до быс­трее и надеж­нее заг­рузить иссле­дуемую прог­рамму в отладчик, уста­новить бряк на стро­ку .text:000000014000108E и, дож­давшись всплы­тия окна отладчи­ка, пос­мотреть, что у нас рас­положе­но в ячей­ке [rsp+58h+var_30]. Отладчик будет всплы­вать триж­ды, при­чем каж­дый раз показы­вать новый адрес! Заметим, что опре­делить этот факт в дизас­сем­бле­ре мож­но толь­ко пос­ле пол­ной реконс­трук­ции алго­рит­ма.

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

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

.text:000000014000103E mov [rsp+58h+var_18], rax
.text:0000000140001043 lea rax, loc_140001000
.text:000000014000104A mov [rsp+58h+var_28], eax
.text:000000014000104E lea rax, sub_140001010
.text:0000000140001055 mov [rsp+58h+var_24], eax
.text:0000000140001059 lea rax, sub_140001020

Вос­поль­зуем­ся средс­тва­ми IDA и пос­мотрим, что заг­ружа­ется в ячей­ки памяти [rsp+…]. А это как раз адре­са трех наших фун­кций, пос­ледова­тель­но раз­мещен­ных ком­пилято­ром друг за друж­кой:

.text:0000000140001000 loc_140001000:
.text:0000000140001000 xor eax, eax
.text:0000000140001002 retn
.text:0000000140001010 sub_140001010 proc near
.text:0000000140001010 xor eax, eax
.text:0000000140001012 retn
.text:0000000140001012 sub_140001010 endp
.text:0000000140001020 sub_140001020 proc near
.text:0000000140001020 xor eax, eax
.text:0000000140001022 retn
.text:0000000140001022 sub_140001020 endp
 

«Ручной» вызов функции инструкцией JMP

Са­мый тяжелый слу­чай пред­став­ляют собой «руч­ные» вызовы фун­кции коман­дой JMP с пред­варитель­ной засыл­кой в стек адре­са воз­вра­та. Вызов через JMP в общем слу­чае выг­лядит так: PUSH ret_addrr / JMP func_addr, где ret_addrr и func_addr — непос­редс­твен­ные или кос­венные адре­са воз­вра­та и начала фун­кции соот­ветс­твен­но. Кста­ти, заметим, что коман­ды PUSH и JMP не всег­да сле­дуют одна за дру­гой и порой быва­ют раз­делены дру­гими коман­дами.

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

Иден­тифици­ровать такие фун­кции очень слож­но — кон­текс­тный поиск ничего не дает, пос­коль­ку команд JMP, исполь­зующих­ся для локаль­ных перехо­дов, в теле любой прог­раммы очень и очень мно­го — поп­робуй‑ка про­ана­лизи­руй их все! Если же это­го не сде­лать, из поля зре­ния выпадут сра­зу две фун­кции — вызыва­емая фун­кция и фун­кция, на которую переда­ется управле­ние пос­ле воз­вра­та. К сожале­нию, быс­трых решений этой проб­лемы не сущес­тву­ет, единс­твен­ная зацеп­ка — вызыва­ющий JMP прак­тичес­ки всег­да выходит за гра­ницы фун­кции, в теле которой он рас­положен. Опре­делить же гра­ницы фун­кции мож­но по эпи­логу. Рас­смот­рим сле­дующий при­мер (Listing4):

int funct()
{
return 0;
}
int main()
{
__asm
{
LEA ESI, return_addr
PUSH ESI
JMP funct
return_addr:
}
}

Пос­коль­ку при­сутс­тву­ющее в этом коде клю­чевое сло­во asm плат­формен­но зависи­мое и под­держи­вает­ся толь­ко на x86, ском­пилиру­ем этот при­мер 32-бит­ным ком­пилято­ром. Резуль­тат ком­пиляции в общем слу­чае дол­жен выг­лядеть так:

.text:00401010 _main proc near
.text:00401010
.text:00401010 argc = dword ptr 8
.text:00401010 argv = dword ptr 0Ch
.text:00401010 envp = dword ptr 10h
.text:00401010
.text:00401010 push ebp
.text:00401011 mov ebp, esp
.text:00401013 push esi
.text:00401014 lea esi, loc_401020
.text:0040101A push esi
.text:0040101B jmp sub_401000
...

Смот­ри, казалось бы, три­виаль­ный безус­ловный переход, что в нем такого? Ан нет! Это не прос­той переход, это замас­кирован­ный вызов фун­кции! Отку­да он сле­дует? Давай‑ка перей­дем по сме­щению sub_401000 и пос­мотрим:

.text:00401000 sub_401000 proc near
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 xor eax, eax
.text:00401005 pop ebp
.text:00401006 retn
.text:00401006 sub_401000 endp

Как ты дума­ешь, куда этот ret воз­вра­щает управле­ние? Естес­твен­но, по адре­су, лежаще­му на вер­хушке сте­ка. А что у нас лежит на сте­ке? PUSH EBP из стро­ки 0x401000, обратно вытал­кива­ется инс­трук­цией POP из стро­ки 0x401005... Воз­вра­щаем­ся назад, к мес­ту безус­ловно­го перехо­да, и начина­ем мед­ленно прок­ручивать экран дизас­сем­бле­ра вверх, отсле­живая все обра­щения к сте­ку. Ага, попалась птич­ка!

Инс­трук­ция PUSH ESI из стро­ки 40101A закиды­вает на вер­шину сте­ка содер­жимое регис­тра ESI, а он сам, в свою оче­редь, стро­кой выше при­нима­ет «на грудь» зна­чение loc_401020 — это и есть адрес начала фун­кции, вызыва­емой коман­дой JMP (вер­нее, не адрес, а сме­щение, но это не прин­ципи­аль­но важ­но):

.text:00401020 loc_401020:
.text:00401020 pop esi
.text:00401021 pop ebp
.text:00401022 retn
.text:00401022 _main endp
 

Автоматическая идентификация функций посредством IDA Pro

Ди­зас­сем­блер IDA Pro спо­собен ана­лизи­ровать опе­ран­ды инс­трук­ций CALL, что поз­воля­ет ему авто­мати­чес­ки раз­бивать прог­рамму на фун­кции. При­чем IDA впол­не успешно справ­ляет­ся с боль­шинс­твом кос­венных вызовов. Меж­ду тем сов­ремен­ные вер­сии дизас­сем­бле­ра на раз‑два справ­ляют­ся с ком­плексны­ми и «руч­ными» вызова­ми фун­кций коман­дой JMP.

«Ида» успешно распознала «ручной» вызов функции
«Ида» успешно рас­позна­ла «руч­ной» вызов фун­кции
 

Пролог

На плат­форме IA-32 боль­шинс­тво неоп­тимизи­рующих ком­пилято­ров помеща­ют в начало фун­кции сле­дующий код, называ­емый про­логом:

push ebp
mov ebp, esp
sub esp, xx

К сожале­нию, на x64 нет такой строй­ной пос­ледова­тель­нос­ти инс­трук­ций. У каж­дой фун­кции про­лог сущес­твен­но отли­чает­ся. Поэто­му рас­смот­рим про­лог фун­кций для x86.

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

Пос­ледова­тель­ность PUSH EBP / MOV EBP,ESP / SUB ESP,xx может слу­жить хорошей сиг­натурой, что­бы най­ти все фун­кции в иссле­дуемом фай­ле, вклю­чая и те, на которые нет пря­мых ссы­лок. Такой при­ем, в час­тнос­ти, исполь­зует в сво­ей работе IDA Pro, одна­ко опти­мизи­рующие ком­пилято­ры уме­ют адре­совать локаль­ные перемен­ные через регистр ESP и исполь­зуют EBP, как и любой дру­гой регистр обще­го наз­начения. Про­лог опти­мизи­рован­ных фун­кций сос­тоит из одной лишь коман­ды SUB ESP, xxx — пос­ледова­тель­ность слиш­ком корот­кая для исполь­зования ее в качес­тве сиг­натуры фун­кции, увы. Более под­робный рас­сказ об эпи­логах фун­кций нас ждет впе­реди.

 

Эпилог

С эпи­логом такая же ситу­ация, на x64 отсутс­тву­ет пос­тоян­ная пос­ледова­тель­ность инс­трук­ций. Одна­ко, пос­коль­ку 32-бит­ных при­ложе­ний так мно­го, что их при­дет­ся ана­лизи­ровать еще веч­ность, нам необ­ходимо знать, как выг­лядит эпи­лог в прог­раммах на x86.

В кон­це сво­ей жиз­ни фун­кция зак­рыва­ет кадр сте­ка, переме­щая ука­затель вер­шины сте­ка «вниз», и вос­ста­нав­лива­ет преж­нее зна­чение EBP (если толь­ко опти­мизи­рующий ком­пилятор не адре­совал локаль­ные перемен­ные через ESP, исполь­зуя EBP как обыч­ный регистр обще­го наз­начения). Эпи­лог фун­кции может выг­лядеть дво­яко: либо ESP уве­личи­вает­ся на нуж­ное зна­чение коман­дой ADD, либо в него копиру­ется зна­чение EBP, ука­зыва­ющее на низ кад­ра сте­ка. Обоб­щенный код эпи­лога фун­кции выг­лядит так.

Эпи­лог 1:

pop ebp
add esp, 64h
retn

Эпи­лог 2:

mov esp, ebp
pop ebp
retn

Важ­но отме­тить: меж­ду коман­дами POP EBP / ADD ESP, xxx и MOV ESP,EBP / POP EBP могут находить­ся и дру­гие коман­ды — они не обя­затель­но дол­жны сле­довать вплот­ную друг к дру­гу. Поэто­му для поис­ка эпи­логов кон­текс­тный поиск неп­ригоден — тре­бует­ся при­менять поиск по мас­ке.

Ес­ли фун­кция написа­на с уче­том сог­лашения PASCAL, то ей при­ходит­ся самос­тоятель­но очи­щать стек от аргу­мен­тов. В подав­ляющем боль­шинс­тве слу­чаев это дела­ется инс­трук­цией RET n, где n — количес­тво бай­тов, сни­маемых из сте­ка пос­ле воз­вра­та. Фун­кции же, соб­люда­ющие С‑сог­лашение, пре­дос­тавля­ют очис­тку сте­ка вызыва­юще­му их коду и всег­да окан­чива­ются коман­дой RET. API-фун­кции Windows пред­став­ляют собой ком­бинацию сог­лашений С и Pascal — аргу­мен­ты заносят­ся в стек спра­ва налево, но очи­щает стек сама фун­кция.

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

Так­же нель­зя забывать и о том, что ком­пилято­ры обыч­но (но не всег­да!) не помеща­ют в исполня­емый файл код, никог­да не получа­ющий управле­ния. Ина­че говоря, у фун­кции будет все­го один эпи­лог, а все находя­щееся пос­ле пер­вого return будет выб­рошено как ненуж­ное. Меж­ду тем не сто­ит спе­шить впе­ред парово­за. Откомпи­лиру­ем с парамет­рами по умол­чанию сле­дующий при­мер (Listing5):

int func(int a)
{
return a++;
a=1/a;
return a;
}
int main()
{
func(1);
}

От­компи­лиро­ван­ный резуль­тат будет выг­лядеть так (при­веден код толь­ко фун­кции func):

.text:00401000 sub_401000 proc near
.text:00401000
.text:00401000 var_4 = dword ptr -4
.text:00401000 arg_0 = dword ptr 8
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004; Копирование значения аргумента в регистр EAX.
.text:00401004 mov eax, [ebp+arg_0]
.text:00401007; Перекладываем его в переменную var_4.
.text:00401007 mov [ebp+var_4], eax
.text:0040100A; Значение аргумента в ECX.
.text:0040100A mov ecx, [ebp+arg_0]
.text:0040100D; Производим инкремент значения в регистре.
.text:0040100D add ecx, 1
.text:00401010; Инкрементированное значение пишем в аргумент,
.text:00401010; который служит переменной.
.text:00401010 mov [ebp+arg_0], ecx
.text:00401013; В EAX помещается начальное значение аргумента,
.text:00401013; оно и возвращается.
.text:00401013 mov eax, [ebp+var_4]
.text:00401016; Осуществляем безусловный переход на эпилог функции.
.text:00401016 jmp short loc_401027
.text:00401018; ------------------------------------------
.text:00401018; В EAX помещаем 1.
.text:00401018 mov eax, 1
.text:0040101D; Расширяем EAX до EDX:EAX (нужно для деления).
.text:0040101D cdq
.text:0040101E; Выполняем деление единицы на аргумент.
.text:0040101E idiv [ebp+arg_0]
.text:00401021; Частное помещаем в переменную.
.text:00401021 mov [ebp+arg_0], eax
.text:00401024; Возвращаем обратно в регистр.
.text:00401024 mov eax, [ebp+arg_0]
.text:00401024 ; Код для деления остался, компилятор не посчитал нужным
.text:00401024 ; его убрать, хотя он недостижим.
.text:00401027
.text:00401027 loc_401027: ; CODE XREF: sub_401000+16↑j
.text:00401027; При этом эпилог только один.
.text:00401027 mov esp, ebp
.text:00401029 pop ebp
.text:0040102A retn
.text:0040102A sub_401000 endp

Те­перь пос­мотрим, какой код сге­нери­рует ком­пилятор, ког­да внеп­лановый выход из фун­кции про­исхо­дит при сра­баты­вании некото­рого усло­вия (Listing6):

int func(int a)
{
if (a != 0)
return a++;
return 1/a;
}
int main()
{
func(1);
}

Ре­зуль­тат ком­пиляции (толь­ко func):

.text:00401000 sub_401000 proc near ; CODE XREF: _main+5↓p
.text:00401000
.text:00401000 var_4 = dword ptr -4
.text:00401000 arg_0 = dword ptr 8
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004; Сравниваем аргумент функции с нулем.
.text:00401004 cmp [ebp+arg_0], 0
.text:00401008; Если они равны, переходим на метку;
.text:00401008 jz short loc_40101E
.text:0040100A; Если же не равны, помещаем значение аргумента в регистр EAX.
.text:0040100A mov eax, [ebp+arg_0]
.text:0040100D; Оттуда в переменную var_4
.text:0040100D mov [ebp+var_4], eax
.text:00401010; Значение аргумента копируется в регистр ECX, а последний инкрементируем.
.text:00401010 mov ecx, [ebp+arg_0]
.text:00401013 add ecx, 1
.text:00401016; Инкрементированное значение помещаем в аргумент, выступающий переменной.
.text:00401016 mov [ebp+arg_0], ecx
.text:00401019; В EAX помещаем начальное значение аргумента, его и возвращаем.
.text:00401019 mov eax, [ebp+var_4]
.text:0040101C; Переход на эпилог.
.text:0040101C jmp short loc_401027
.text:0040101E ; ------------------------------------------
.text:0040101E
.text:0040101E loc_40101E: ; CODE XREF: sub_401000+8↑j
.text:0040101E; В EAX помещаем 1.
.text:0040101E mov eax, 1
.text:00401023; Расширяем EAX до EDX:EAX (нужно для деления).
.text:00401023 cdq
.text:00401024; Деление EDX:EAX, где находится 1, на аргумент, равный 0.
.text:00401024 idiv [ebp+arg_0]
.text:00401027
.text:00401027 loc_401027: ; CODE XREF: sub_401000+1C↑j
.text:00401027; Это явно эпилог.
.text:00401027 mov esp, ebp
.text:00401029 pop ebp
.text:0040102A retn
.text:0040102A sub_401000 endp

Как и в пре­дыду­щем слу­чае, ком­пилятор соз­дал толь­ко один эпи­лог. Обра­ти вни­мание: в начале фун­кции в стро­ке 0x401004 аргу­мент срав­нива­ется с нулем, если усло­вие выпол­няет­ся, про­исхо­дит переход на мет­ку loc_40101E, где выпол­няет­ся деление, за которым сра­зу сле­дует эпи­лог. Если же усло­вие в стро­ке 0x401004 не соб­людено, выпол­няет­ся сло­жение и про­исхо­дит безус­ловный пры­жок на эпи­лог.

 

Специальное замечание

На­чиная с про­цес­сора 80286, в наборе команд появи­лись две инс­трук­ции — ENTER и LEAVE, пред­назна­чен­ные спе­циаль­но для откры­тия и зак­рытия кад­ра сте­ка. Одна­ко они прак­тичес­ки никог­да не исполь­зуют­ся сов­ремен­ными ком­пилято­рами. Почему?

При­чина в том, что ENTER и LEAVE очень мед­литель­ны, нам­ного мед­литель­нее PUSH EBP / MOV EBP,ESP / SUB ESB, xxx и MOV ESP,EBP / POP EBP. Так, на ста­ром доб­ром Pentium ENTER выпол­няет­ся за десять так­тов, а при­веден­ная пос­ледова­тель­ность команд — за семь. Ана­логич­но LEAVE тре­бует пять так­тов, хотя ту же опе­рацию мож­но выпол­нить за два (и даже быс­трее, если раз­делить MOV ESP,EBP / POP EBP какой‑нибудь коман­дой).

По­это­му сов­ремен­ный иссле­дова­тель никог­да не стол­кнет­ся ни с ENTER, ни с LEAVE. Хотя пом­нить об их наз­начении будет нелиш­не. Мало ли, вдруг при­дет­ся дизас­сем­бли­ровать древ­ние прог­раммы или прог­раммы, написан­ные на ассем­бле­ре, — не сек­рет, что мно­гие пишущие на ассем­бле­ре очень пло­хо зна­ют тон­кости работы про­цес­сора и их «руч­ная опти­миза­ция» замет­но усту­пает ком­пилято­ру по про­изво­дитель­нос­ти.

 

«Голые» (naked) функции

Ком­пилятор Microsoft Visual C++ под­держи­вает нес­тандар­тный ква­лифи­катор naked, поз­воля­ющий прог­раммис­там соз­давать фун­кции без про­лога и эпи­лога. Ком­пилятор даже не помеща­ет в кон­це фун­кции RET, и это при­ходит­ся делать «вруч­ную», при­бегая к ассем­блер­ной встав­ке __asm{ret} (исполь­зование return не при­водит к жела­емо­му резуль­тату).

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

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

 

Идентификация встраиваемых (inline) функций

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

Чем пло­ха раз­вер­тка фун­кций для иссле­дова­ния прог­раммы? Преж­де все­го, она уве­личи­вает раз­мер материн­ской фун­кции и дела­ет ее код менее наг­лядным — вмес­то CALL / TEST EAX,EAX / JZ xxx с бро­сающим­ся в гла­за условным перехо­дом мы видим кучу ничего не напоми­нающих инс­трук­ций, в логике работы которых еще пред­сто­ит разоб­рать­ся.

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

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

#include <stdio.h>
inline int max(int a, int b)
{
if(a > b)
return a;
return b;
}
int main(int argc, char **argv)
{
printf("%x\n",max(0x666,0x777));
printf("%x\n",max(0x666,argc));
printf("%x\n",max(0x666,argc));
return 0;
}

Ре­зуль­тат его ком­пиляции будет иметь сле­дующий вид (фун­кция main):

.text:0000000140001000 main proc near
.text:0000000140001000
.text:0000000140001000 arg_0 = dword ptr 8
.text:0000000140001000 arg_8 = qword ptr 10h
.text:0000000140001000; Полученные аргументы помещаются
.text:0000000140001000; в локальные переменные.
.text:0000000140001000 mov [rsp+arg_8], rdx
.text:0000000140001005 mov [rsp+arg_0], ecx
.text:0000000140001009 sub rsp, 28h
.text:0000000140001009; Аргументы помещаются в регистры EDX, ECX,
.text:0000000140001009; что говорит нам об их подготовке
.text:0000000140001009; к передаче в качестве параметров другой функции.
.text:000000014000100D mov edx, 777h
.text:0000000140001012 mov ecx, 666h
.text:0000000140001012; Вызов сравнивающей функции.
.text:0000000140001017 call sub_140001070
.text:000000014000101C mov edx, eax
.text:000000014000101C; Возвращенный предыдущей функцией результат
.text:000000014000101C; передаем функции printf вместе с форматной
.text:000000014000101C; строкой.
.text:000000014000101E lea rcx, Format ; "%x\n"
.text:000000014000101E; Вызов функции вывода значений на экран.
.text:0000000140001025 call printf
.text:0000000140001025; История повторяется, происходит подготовка
.text:0000000140001025; параметров для вызова функции.
.text:000000014000102A mov edx, [rsp+28h+arg_0]
.text:000000014000102E mov ecx, 666h
.text:000000014000102E; Вызов «встраиваемой» функции max,
.text:000000014000102E; но, как мы видим, встраиваемой она не стала.
.text:0000000140001033 call sub_140001070
.text:0000000140001033; Первый параметр для printf — возвращенное
.text:0000000140001033; max число, второй параметр — форматная
.text:0000000140001033; строка.
.text:0000000140001038 mov edx, eax
.text:000000014000103A lea rcx, asc_140016324 ; "%x\n"
.text:000000014000103A; Выводим параметры на экран посредством printf.
.text:0000000140001041 call printf
.text:0000000140001041; Подготовка параметров для вызова функции max.
.text:0000000140001046 mov edx, [rsp+28h+arg_0]
.text:000000014000104A mov ecx, 666h
.text:000000014000104A; Вызов функции max.
.text:000000014000104F call sub_140001070
.text:0000000140001054 mov edx, eax
.text:0000000140001056 lea rcx, asc_140016328 ; "%x\n"
.text:0000000140001056; Вывод результата на экран.
.text:000000014000105D call printf
.text:0000000140001062 xor eax, eax
.text:0000000140001064 add rsp, 28h
.text:0000000140001068 retn
.text:0000000140001068 main endp

«Так‑так», — шеп­чем себе под нос. И что же он тут наком­пилиро­вал? Встра­иваемую фун­кцию пред­ста­вил в виде обыч­ной! Вот дела! Ком­пилятор забил на наше желание сде­лать фун­кцию встра­иваемой (мы ведь написа­ли модифи­катор inline).

Си­туацию не исправ­ляет даже исполь­зование парамет­ров ком­пилято­ра: /Od или /Oi. Пер­вый слу­жит для отклю­чения опти­миза­ции, вто­рой — для соз­дания встра­иваемых фун­кций. Такими тем­пами ком­пилятор вско­ре будет генери­ровать код, угод­ный собс­твен­ным пред­почте­ниям или пред­почте­ниям его раз­работ­чика, а не прог­раммис­та, его исполь­зующе­го!

Ос­таль­ное ты можешь уви­деть в ком­мента­риях к дизас­сем­бли­рован­ному лис­тингу. Срав­нива­ющая фун­кция max в дизас­сем­бли­рован­ном виде будет выг­лядеть так:

.text:0000000140001070 sub_140001070 proc near
.text:0000000140001070
.text:0000000140001070 arg_0 = dword ptr 8
.text:0000000140001070 arg_8 = dword ptr 10h
.text:0000000140001070
.text:0000000140001070 mov [rsp+arg_8], edx
.text:0000000140001074 mov [rsp+arg_0], ecx
.text:0000000140001078 mov eax, [rsp+arg_8]
.text:0000000140001078; Сравнение значений, переданных в параметрах.
.text:000000014000107C cmp [rsp+arg_0], eax
.text:000000014000107C; Если первый операнд меньше второго или равен ему,
.text:000000014000107C; переходим на метку, где возвращается второй
.text:000000014000107C; операнд.
.text:0000000140001080 jle short loc_140001088
.text:0000000140001080; В обратном случае возвращаем первый операнд.
.text:0000000140001082 mov eax, [rsp+arg_0]
.text:0000000140001086 jmp short locret_14000108C
.text:0000000140001088 ; --------------------------------------------
.text:0000000140001088
.text:0000000140001088 loc_140001088:
.text:0000000140001088 mov eax, [rsp+arg_8]
.text:000000014000108C
.text:000000014000108C locret_14000108C:
.text:000000014000108C retn
.text:000000014000108C sub_140001070 endp

Здесь тоже все важ­ные фраг­менты про­ком­менти­рова­ны.

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

#include <iostream>
#include <sstream>
#include <string>
using namespace std;
// Встраиваемая функция нахождения максимума.
inline string max(int a, int b)
{
int val = (a > b) ? a : b;
stringstream stream;
// Преобразуем значение в hex-число.
stream << "0x" << hex << val;
string res = stream.str();
return res;
}
int main(int argc, char **argv)
{
cout << max(0x666, 0x777) << endl;
string par = argv[1];
int val;
// Если впереди параметра есть символы '0x',
if (par.substr(0, 2) == "0x")
// тогда это hex-число.
val = stoi(argv[1], nullptr, 16);
else
// В ином случае это dec-число.
val = stoi(argv[1], nullptr, 10);
cout << max(0x666, val) << endl;
cout << max(0x666, val) << endl;
return 0;
}
VS Code
VS Code

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

Сле­дующим дей­стви­ем прог­рамма берет параметр коман­дной стро­ки. Она раз­лича­ет чис­ла двух фор­матов: десятич­ные и шес­тнад­цатерич­ные, опре­деляя их по отсутс­твию или наличию пре­фик­са 0x. Два пос­леду­ющих опе­рато­ра иден­тичны, в них про­исхо­дят вызовы фун­кции max, которой оба раза переда­ются оди­нако­вые парамет­ры: 0x666 и параметр коман­дной стро­ки, пре­обра­зован­ный из стро­ки в чис­ло. Эти два пос­ледова­тель­ных опе­рато­ра, как и в прош­лый раз, поз­волят нам прос­ледить вызовы фун­кции.

Вывод приложения
Вы­вод при­ложе­ния

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

 

Выводы

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

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

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

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

    Подписаться

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