Наши продукты

Все выпуски в PDF →
  • Годовая подписка на Xakep.ru

    Все статьи, PDF-архив, скидки и другие бонусы

    6000₽ в год

    Подписаться
  • Ежеквартальный «Хакер» #4

    Печатный выпуск за август–ноябрь 2026 года

    2000₽

    Заказать
  • Ежеквартальный «Хакер» #3

    Печатный выпуск за май–июль 2026 года

    2000₽

    Заказать
  • Спецвыпуск «Хакера» #4

    Избранные материалы 2021–2022 годов на бумаге

    2000₽

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

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

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

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

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

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

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

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

arg_offset = N * size_element + size_return_address

Здесь:

  • N — номер аргу­мен­та, счи­тая от вер­шины сте­ка, начиная с нуля;
  • size_element — раз­мер одно­го эле­мен­та сте­ка, в общем слу­чае рав­ный раз­ряднос­ти сег­мента (под Windows NT — четыре бай­та);
  • size_return_address — раз­мер в бай­тах, занима­емый адре­сом воз­вра­та (под Windows NT — обыч­но четыре бай­та).

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

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

С точ­ки зре­ния хакера, глав­ное дос­тоинс­тво такой адре­сации аргу­мен­тов в том, что, уви­дев где‑то в середи­не кода инс­трук­цию типа MOV RAX, [RBP+0x10], мож­но мгно­вен­но вычис­лить, к какому имен­но аргу­мен­ту про­исхо­дит обра­щение. Одна­ко опти­мизи­рующие ком­пилято­ры для эко­номии регис­тра RBP адре­суют аргу­мен­ты непос­редс­твен­но через RSP.

Раз­ница прин­ципи­аль­на! Зна­чение RSP не оста­ется пос­тоян­ным на про­тяже­нии выпол­нения фун­кции и изме­няет­ся вся­кий раз при занесе­нии и сня­тии дан­ных из сте­ка, сле­дова­тель­но, не оста­ется пос­тоян­ным и сме­щение аргу­мен­тов отно­ситель­но RSP. Теперь, что­бы опре­делить, к какому имен­но аргу­мен­ту про­исхо­дит обра­щение, необ­ходимо знать, чему равен RSP в дан­ной точ­ке прог­раммы, а для выяс­нения это­го все его изме­нения при­ходит­ся отсле­живать от самого начала фун­кции!

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

void MyFunc(double, struct XT) proc near
arg_0 = qword ptr 8
arg_8 = qword ptr 10h

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

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

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

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

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

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

sub rsp, 28h
mov eax, 1
imul rax, 0
mov rcx, [rsp+28h+arg_8]
add rcx, rax
mov rax, rcx
mov r9, rax
mov rax, [rsp+28h+arg_8]
mov r8d, [rax+14h]
movsd xmm1, [rsp+28h+arg_0]
movq rdx, xmm1
lea rcx, _Format ; "%f,%x,%s\n"
call printf

Об­рати вни­мание, перед вызовом printf прог­рамма в трех регис­трах раз­меща­ет зна­чения парамет­ров для переда­чи, а в чет­вертом регис­тре RCX (так же для переда­чи) помеща­ет ука­затель на фор­матную стро­ку спе­цифи­като­ров вывода: %f,%x,%s\n. Фун­кция printf, как извес­тно, име­ет перемен­ное чис­ло аргу­мен­тов, тип и количес­тво которых как раз и зада­ют спе­цифи­като­ры. Вспом­ним: спер­ва в стек мы заноси­ли ука­затель на стро­ку, и дей­стви­тель­но, край­ний пра­вый спе­цифи­катор %s обоз­нача­ет вывод стро­ки. Затем в стек заноси­лась перемен­ная типа int и вто­рой спра­ва спе­цифи­катор, то есть %х – вывод целого чис­ла в шес­тнад­цатерич­ной фор­ме.

А вот затем идет пос­ледний спе­цифи­катор %f. Заг­лянув в руководс­тво прог­раммис­та по Visual C++, мы проч­тем, что спе­цифи­катор %f выводит вещес­твен­ное зна­чение, которое в зависи­мос­ти от типа может занимать и четыре бай­та (float), и восемь (double). В нашем слу­чае оно явно занима­ет восемь байт, сле­дова­тель­но, это double. Таким обра­зом, мы вос­ста­нови­ли про­тотип нашей фун­кции, вот он:

cdecl MyFunc(double a, struct B b)

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

Мо­жет показать­ся, что если прог­рамму собира­ли в C++Builder, то мы прос­то изме­няем порядок аргу­мен­тов на обратный, вот и все. Увы, это не так прос­то. Если име­ло мес­то явное пре­обра­зова­ние типа фун­кции в cdecl, то C++Builder без лиш­ней самоде­ятель­нос­ти пос­тупил бы так, как ему велели, и тог­да бы обра­щение поряд­ка аргу­мен­тов дало бы невер­ный резуль­тат!

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

info

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

Да­лее деини­циали­зиру­ем стек и зак­ругля­емся.

add rsp, 28h
retn
 

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

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

 

stdcall

Нач­нем с изу­чения стан­дар­тно­го сог­лашения о вызове — stdcall. Рас­смот­рим сле­дующий при­мер.

Исходник примера stdcall
Ис­ходник при­мера stdcall
#include <stdio.h>
#include <string.h>
int __stdcall MyFunc(int a, int b, const char* c){
return a + b + strlen(c);
}
int main(){
printf("%x\n", MyFunc(0x666, 0x777, "Hello,World!"));
}

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

main proc near
sub rsp, 28h
```
IDA хорошо нам подсказывает, что константа `c` содержит строку `Hello,World!`. Указатель на нее помещается в регистр `R8`, предназначенный для передачи целочисленных параметров или собственно указателей. Первым по порядку передается указатель на строку, заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что это самый правый аргумент, передаваемый функции. Следовательно, перед нами вызов типа stdcall или cdecl, но не PASCAL.
````nasm
lea r8, c ; "Hello,World!"
mov edx, 777h ; b
mov ecx, 666h ; a
```
Следом помещаем в два 32-битных регистра EDX и ECX значения двух переменных `0x777` и `0x666` соответственно. Последнее оказалось самым левым аргументом. Что показывает нам правильно восстановленный прототип функции. Но так бывает не всегда, IDA иногда ошибается.
```nasm
call MyFunc(int,int,char const *)
```
Обрати внимание, после вызова функции отсутствуют команды очистки стека от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул к отложенной очистке, то, скорее всего, стек очищает сама вызываемая функция, значит, тип вызова — stdcall (что, собственно, и требовалось доказать).
```nasm
mov edx, eax
```
Теперь, передаем возвращенное функцией значение следующей функции как аргумент.
```nasm
lea rcx, _Format ; "%x\n"
```
Эта следующая функция `printf` и строка спецификаторов показывают, что переданный аргумент имеет тип int.
```nasm
call printf
xor eax, eax
add rsp, 28h
retn
main endp
```
Теперь рассмотрим функцию `MyFunc`:
```cpp
; int __fastcall MyFunc(int a, int b, const char *c)
int MyFunc(int, int, char const *) proc near
```
IDA пытается самостоятельно восстановить прототип функции и… обламывается. Иными словами, делает это не всегда успешно. Например, «Ида» ошибочно предположила тип вызова `fastcall`, хотя на самом деле — stdcall. Вспомним: `fastcall` на 32-битной платформе предполагает передачу параметров через регистры процессора, тогда как на платформе x64 первые четыре параметра всегда передаются через регистры процессора, независимо от указанного типа вызова.
```nasm
var_18 = qword ptr -18h
var_10 = qword ptr -10h
arg_0 = dword ptr 8
arg_8 = dword ptr 10h
arg_10 = qword ptr 18h
```
Переданные аргументы из регистров помещаются в память, затем после инициализации стека числовые значения размещаются в регистрах, где происходит их сложение: `add ecx, eax`.
```nasm
mov [rsp+arg_10], r8
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
sub rsp, 18h
mov eax, [rsp+18h+arg_8]
mov ecx, [rsp+18h+arg_0]
add ecx, eax
mov eax, ecx
```
Преобразование двойного слова (EAX) в учетверенное (RAX):
```nasm
cdqe
```
Копирование из стека указателя в строку, в регистр RCX и в переменную `var_10`. Далее инициализируем переменную `var_18` значением `-1`, очевидно, она будет счетчиком.
```nasm
mov rcx, [rsp+18h+arg_10]
mov [rsp+18h+var_10], rcx
mov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFh
loc_140001111:
```
И действительно, на следующем шаге она увеличивается на единицу. К тому же здесь мы видим метку безусловного перехода. Похоже, вместо того, чтобы узнать длину строки посредством вызова библиотечной функции `strlen`, компилятор решил самостоятельно сгенерировать код. Что ж, флаг ему в руки!
```nasm
inc [rsp+18h+var_18]
```
Значения переменных вновь копируются в регистры для проведения операций над ними.
```nasm
mov rcx, [rsp+18h+var_10]
mov rdx, [rsp+18h+var_18]
cmp byte ptr [rcx+rdx], 0
```
Значения регистров RCX и RDX складываются, а сумма сравнивается с нулем. В случае если выражение тождественно, то флаг ZF устанавливается в единицу, в обратном случае — в ноль. Инструкция `JNZ` проверяет флаг Z. Если он равен нулю, тогда происходит переход на метку `loc_140001111`, откуда блок кода начинает выполняться по новой.
```nasm
jnz short loc_140001111
```
Когда флаг ZF равен единице, осуществляется выход из цикла и переход на следующую за ним инструкцию, которая накопленное в переменной-счетчике число записывает в регистр RCX. После этого происходит сложение значений в регистрах RCX и RAX, как помним, в последнем содержится сумма двух переданных числовых аргументов.
```nasm
mov rcx, [rsp+18h+var_18]
add rax, rcx
```
В завершении функции происходит деинициализация стека:
```nasm
add rsp, 18h
retn
int MyFunc(int, int, char const *) endp
```
Возвращение целочисленного аргумента на платформе x64 предусмотрено в регистре RAX.
На вывод программа печатает `de9`.
![Вывод программы stdcall](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20949/stdcall-vc.png)
Легко проверить: `Hello,World!` — 12 символов, то есть 0xC. Открой калькулятор в Windows:
> 0x666 + 0x777 + 0xC = 0xDE9
[xakepcut]
## cdecl
А теперь рассмотрим, как происходит вызов функции cdecl. Изменим в предыдущем примере ключевое слово stdcall на cdecl:
```cpp
#include <stdio.h>
#include <string.h>
int __cdecl MyFunc(int a, int b, const char* c){
return a + b + strlen(c);
}
int main(){
printf("%x\n", MyFunc(0x666, 0x777, "Hello,World!"));
}
```
Соответствующий результат компиляции:
```nasm
; int __fastcall MyFunc(int a, int b, const char *c)
int MyFunc(int, int, char const *) proc near
var_18 = qword ptr -18h
var_10 = qword ptr -10h
arg_0 = dword ptr 8
arg_8 = dword ptr 10h
arg_10 = qword ptr 18h
mov [rsp+arg_10], r8
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
sub rsp, 18h
mov eax, [rsp+18h+arg_8]
mov ecx, [rsp+18h+arg_0]
add ecx, eax
mov eax, ecx
cdqe
mov rcx, [rsp+18h+arg_10]
mov [rsp+18h+var_10], rcx
mov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFh
loc_140001111:
inc [rsp+18h+var_18]
mov rcx, [rsp+18h+var_10]
mov rdx, [rsp+18h+var_18]
cmp byte ptr [rcx+rdx], 0
jnz short loc_140001111
mov rcx, [rsp+18h+var_18]
add rax, rcx
add rsp, 18h
retn
int MyFunc(int, int, char const *) endp
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near
sub rsp, 28h
lea r8, c ; "Hello,World!"
mov edx, 777h ; b
mov ecx, 666h ; a
call MyFunc(int,int,char const *)
mov edx, eax
lea rcx, _Format ; "%x\n"
call printf
xor eax, eax
add rsp, 28h
retn
main endp
```
Я привел код без комментариев, потому что он байт в байт копирует предыдущий листинг, который я довольно подробно описал, в данном случае IDA наплевала на указания программиста о типе вызова, оставив все как есть. Секундочку, а может IDA тут ни при чем, а всему виной компилятор, которому показалось, что такой код будет работать лучше? Ну вот, а хакеру страдать в этих хитросплетениях…
## WINAPI
Прежде чем перейти к вещам по-настоящему серьезным, рассмотрим на закуску последний стандартный тип — PASCAL. Будь внимателен: Visual C++ уже давно не поддерживает тип вызова PASCAL! Вместо этого используй аналогичный ему тип вызова `WINAPI`, определенный в файле `windows.h`.
```cpp
#include <stdio.h>
#include <string.h>
#if defined(_MSC_VER)
#include <windows.h>
// Включать windows.h, только если мы компилируем Visual C++
// Для старых компиляторов эффективное решение — использование
// ключевого слова PASCAL, если они его поддерживают
#endif
int
// Подобный прием программирования, может, и делает листинг
// менее читабельным, но зато позволяет компилировать его
// не только одним компилятором!
#if defined(_MSC_VER)
WINAPI
#else
PASCAL
//__pascal – для C++Builder
#endif
MyFunc(int a, int b, const char* c){
return a + b + strlen(c);
}
int main(){
printf("%x\n", MyFunc(0x666, 0x777, "Hello,World!"));
}
```
Что за уродливый код мы нагородили? Если не указать тип возвращаемых функцией данных, компилятор языка C подразумевал использование int, следовательно, мы могли написать просто `main()`, и этого было бы достаточно для успешной компиляции. C++ так не делает, поэтому мы явно указываем ему тип.
Далее происходит выбор: если программа будет компилироваться в Visual C++, то ставится тип вызова `WINAPI`, если же любым другим компилятором, тогда — PASCAL. Между тем последние версии C++Builder не переваривают тип PASCAL, вместо него надо использовать `__pascal` — с двумя подчеркиваниями впереди. После этого код продолжается как обычно: заголовок функции, параметры, тело. Тип `__pascal` поддерживается и в VC++, то есть такой вариант в современных реалиях более универсален, чем PASCAL.
![Формирование функции](https://st768.s3.eu-central-1.amazonaws.com/0f46e63df13ce17b205c2c3fb53b9d38/20951/define.png)
Для разнообразия откомпилируем его в C++Builder:
![Настройки компиляции в C++Builder](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20947/pascal-cb-compile.png)
Первое, что бросается в глаза, — современный C++Builder тоже не поддерживает соглашение о вызовах PASCAL!
![C++Builder 10.3 Community Edition](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20946/pascal-cb-ide.png)
Посмотрим, что же он накомпилировал:
```nasm
; int __cdecl main(int argc, const char **argv, const char **envp)
public main
main proc near
var_14 = dword ptr -14h
var_10 = qword ptr -10h
var_8 = dword ptr -8
var_4 = dword ptr -4
; инициализация стека
sub rsp, 38h
```
Передаем функции аргументы. Заглянув в исходный текст, замечаем, что аргументы передаются слева направо. Однако, если исходников нет, установить этот факт невозможно! К счастью, подлинный прототип функции не важен.
```nasm
mov eax, 666h
mov r8d, 777h
lea r9, aHelloWorld ; "Hello,World!"
mov [rsp+38h+var_4], 0
mov [rsp+38h+var_8], ecx
mov [rsp+38h+var_10], rdx
mov ecx, eax
mov edx, r8d ; char *
mov r8, r9 ; теперь строка в R8
call MyFunc(int,int,char const*)
```
По определенному IDA прототипу можно утверждать, что тип вызова либо PASCAL, либо stdcall. Но так как PASCAL уже вышел из употребления, будем считать, что имеем дело с stdcall.
```nasm
lea rcx, unk_44A000
mov edx, eax
call printf
mov [rsp+38h+var_4], 0
mov [rsp+38h+var_14], eax
mov eax, [rsp+38h+var_4]
add rsp, 38h
retn
main endp
; __int64 __fastcall MyFunc(int, int, const char *)
```
Ага! IDA вновь дала неправильный результат! Тип вызова явно не `fastcall`! Однако в остальном прототип функции верен. Точнее, не то чтобы (на самом деле порядок аргументов обратный), но для использования пригоден.
```nasm
public MyFunc(int, int, char const*)
MyFunc(int, int, char const*) proc near
var_1C = dword ptr -1Ch
var_18 = qword ptr -18h
var_10 = qword ptr -10h
var_8 = dword ptr -8
var_4 = dword ptr -4
sub rsp, 48h
```
Помещаем переданные аргументы из регистров процессора в стек. В ECX и EDX значения типа int, в R8 — строка:
```nasm
mov [rsp+48h+var_4], ecx
mov [rsp+48h+var_8], edx
mov [rsp+48h+var_10], r8
```
Переносим в регистр ECX аргумент `var_4` типа int, затем складываем его с аргументом `var_8`. Сумма временно хранится в ECX.
```nasm
mov ecx, [rsp+48h+var_4]
add ecx, [rsp+48h+var_8]
```
После сложения для расширения 32-битного операнда до 64-битного с учетом знака компилятор воспользовался инструкцией `movsxd`, которая появилась в наборе инструкций x86-64. Сумма хранится в R8. Строка теперь находится в RCX.
```nasm
movsxd r8, ecx
mov rcx, [rsp+48h+var_10]
mov [rsp+48h+var_18], r8 ; сумма снова помещается в var_18
```
В отличие от Visual C++ для нахождения длины строки C++Builder использует библиотечную функцию `strlen`, строка передается ей параметром в регистре RCX:
```nasm
call strlen
```
Гляди-ка! После выполнения функции, как мы знаем, результат возвращается в регистре RAX, сумма от предыдущего сложения из переменной вновь попадает в регистр RCX! После чего происходит сложение RCX и RAX, помещение результата в область памяти, затем в EAX, деинициализация стека, возврат результата вызвавшей функции (в нашем случае — `main`).
```nasm
mov rcx, [rsp+48h+var_18]
add rcx, rax
mov edx, ecx
mov [rsp+48h+var_1C], edx
mov eax, [rsp+48h+var_1C]
add rsp, 48h
; Стек чистит вызываемая функция. Значит, ее тип —
; PASCAL или stdcall
retn
MyFunc(int, int, char const*) endp
```
Как мы видим, идентификация базовых типов вызова и восстановление прототипов функции — занятие несложное.
[ INFO
Если мы скомпилируем этот листинг при помощи VC++ и сравним результат с предыдущими примерами, то дизассемблерный листинг этой программы будет близко, но не побайтно похож. Причина в том, что в последнем примере между функциями `main` и `MyFunc` компилятор подсунул функцию `_vfprintf_l`, следовательно, адреса инструкций изменились.
]
## PASCAL VS stdcall
Единственное, что портит настроение, — путаница между PASCAL и stdcall, но порядок занесения аргументов в стек не имеет никакого значения, разве что в особых ситуациях. В следующем примере показан тот случай, когда требуется точно отличать PASCAL от stdcall:
```cpp
#include <stdio.h>
#include <windows.h>
#include <winuser.h>
// CALLBACK — процедура для приема сообщений от таймера
VOID CALLBACK TimerProc(HWND hwnd, // handle for timer messages
UINT uMsg, // WM_TIMER message
UINT idEvent, // timer identifier
DWORD dwTime // current system time
){
MessageBeep((dwTime % 5)*0x10);
// Бибикаем на все голоса
// Выводим время (в секундах) с момента пуска системы
printf("\r:=%d", dwTime / 1000);
}
int main(){
// Да, это консольное приложение, но оно также может иметь
// цикл выборки сообщений и устанавливать таймер!
int a;
MSG msg;
// Устанавливаем таймер, передавая ему адрес процедуры TimerProc
SetTimer(0, 0, 1000, TimerProc);
// Цикл выборки сообщений. Когда надоест, жмем Ctrl+C
while (GetMessage(&msg, (HWND) NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
```
Откомпилируем этот пример из командной строки при помощи VisualStudio 2019:
```bash
cl pascal.callback.c USER32.lib
```
И посмотрим, что из этого получилось.
![Вывод в консоль](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20945/command-prompt.png)
![Схема IDA](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20944/ida-pascal-callback.png)
```nasm
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near
```
IDA распознала одну локальную переменную и даже восстановила ее тип, что не может не радовать. Между тем, что особо интересно, во время трансляции компилятор бессовестно переименовал функцию `TimerProc` в `TimerFunc`! Поэтому будь внимателен.
```nasm
Msg = tagMSG ptr -38h
sub rsp, 58h
lea r9, TimerFunc ; lpTimerFunc - передаем указатель на функцию TimerProc
mov r8d, 3E8h ; uElapse - передаем время задержки таймера
xor edx, edx ; nIDEvent - в консольных приложениях аргумент nIDEvent всегда игнорируется
xor ecx, ecx ; hWnd - окон нет, передаем NULL
```
Булева операция XOR быстрее осуществляет обнуление регистра, переданного ей в обоих параметрах, чем простое обнуление этого регистра.
Win32 API-функции вызываются по соглашению stdcall — это дает возможность, зная их прототип (а он описан в SDK), восстановить тип и назначение аргументов. В данном случае исходный текст выглядит так: `SetTimer(NULL, NULL, 1000, TimerProc);`.
```nasm
call cs:SetTimer
loc_14000107B:
; готовимся выбирать сообщения
xor r9d, r9d ; wMsgFilterMax – NULL – нет фильтра
xor r8d, r8d ; wMsgFilterMin – NULL – нет фильтра
xor edx, edx ; hWnd – NULL – нет фильтра
```
Получаем указатель на локальную переменную `msg` — тип этой переменной определяется, кстати, только на основе прототипа функции `GetMessageA`.
```nasm
lea rcx, [rsp+58h+Msg] ; lpMsg
; вызываем функцию GetMessageA (&msg, NULL, NULL, NULL);
call cs:GetMessageA
test eax, eax
jz short loc_1400010AA ; Проверка на получение WM_QUIT, если, да, идем на выход
lea rcx, [rsp+58h+Msg] ; помещаем указатель на lpMsg в регистр RCX для передачи функции TranslateMessage
call cs:TranslateMessage ; вызываем функцию TranslateMessage
lea rcx, [rsp+58h+Msg] ; в RCX указатель на lpMsg
call cs:DispatchMessageA ; вызываем функцию DispatchMessageA, передавая ей lpMsg
jmp short loc_14000107B ; крутим цикл выборки сообщений
loc_1400010AA:
; выход
xor eax, eax
add rsp, 58h
retn
main endp
; void __stdcall TimerFunc(HWND, UINT, UINT_PTR, DWORD)
TimerFunc proc near
```
К нашему удовлетворению, IDA распознала прототип функции! Хотя раньше дела обстояли хуже. Поскольку `TimerProc` неявно вызывается операционной системой, в старых версиях IDA не могла автоматически восстановить прототип, и нам приходилось заниматься этим самим. Но тогда достаточно было заглянуть в описание функции `SetTimer`, которой передается `TimerProc`. В любом случае ее прототип найти не составит труда: он такой же, как использованный нами при написании оригинальной программы на C++.
```cpp
VOID CALLBACK TimerFunc(HWND hwnd, // handle for timer messages
UINT message, // WM_TIMER message
UINT idTimer, // timer identifier
DWORD dwTime // current system time
)
```
Остается разобраться с типом вызова. На сей раз он принципиален, так как, не имея кода вызывающей функции (он расположен глубоко в недрах операционной системы), мы сможем разобраться с типами аргументов, только если будем знать их порядок передачи. Выше уже говорилось, что все CALLBACK-функции следуют соглашению PASCAL. Не путай CALLBACK-функции с Win32 API-функциями! Первые вызывает сама операционная система, а вторые — прикладная программа.
Итак, тип вызова этой функции — PASCAL. Значит, аргументы идут слева направо, а стек чистит вызываемая функция.
```nasm
arg_0 = qword ptr 8
arg_8 = dword ptr 10h
arg_10 = dword ptr 18h
arg_18 = dword ptr 20h
```
И опять, к нашему счастью, IDA обнаружила все четыре аргумента! Стоит отметить, что раньше IDA определяла только один аргумент, поскольку функция в работе использует всего один, а к остальным просто не обращается. Вот IDA и не может их восстановить.
Тем не менее в нашем случае IDA не отметила ни один аргумент комментарием — разбирайся сам! А нам надо узнать тип хотя бы крайнего элемента, чтобы определить порядок передачи аргументов. Для этого нам надо разобраться в коде тела функции, благо мы можем его сравнить с исходником на C++.
```nasm
; вынимаем содержимое регистров, складируем их в память
mov [rsp+arg_18], r9d
mov [rsp+arg_10], r8d
mov [rsp+arg_8], edx
mov [rsp+arg_0], rcx
; инициализируем стек
sub rsp, 28h
xor edx, edx
```
О-па! Обращение к `arg_18`, который смещен аж на 0x18 байт, не считая смещения самого стека! Копируем из него 32-битное значение типа int в регистр EAX. Если скользнуть глазами на три строчки ниже, можно сделать вывод, что тем самым мы подготавливаем делимое.
```nasm
mov eax, [rsp+28h+arg_18]
; готовим делитель
mov ecx, 5
; рубим целочисленное деление…
div ecx
; …от которого нам нужен лишь остаток
mov eax, edx
```
Таким образом, нам удалось ответить на вопрос: от какой переменной программа получает остаток после деления на константу 5? Код на C++ нам в помощь:
```cpp
MessageBeep((dwTime % 5)*0x10);
```
И это аргумент `dwTime`! Следовательно, определился крайний элемент прототипа функции.
Постой-постой, что за чертовщина?! Выходит, `dwTime` был занесен в стек в первую очередь?! Но ведь соглашение PASCAL диктует противоположный порядок занесения аргументов! Что-то здесь не так... но ведь программа работает (запусти ее еще раз, чтобы проверить).
А в SDK написано, что CALLBACK — аналог FAR PASCAL. С FAR понятно: в WinNT и всех последующих все вызовы ближние, но вот как объяснить инверсию засылки аргументов?!
Сдаешься? Нет, не сдавайся, лучше попытайся найти решение сам, иначе какой интерес? Но если нужна подсказка, загляни в `minwindef.h` и посмотри, как там определен тип PASCAL:
```cpp
#elif (_MSC_VER >= 800) || defined(_STDCALL_SUPPORTED)
#define CALLBACK __stdcall
#define WINAPI __stdcall
#define WINAPIV __cdecl
#define APIENTRY WINAPI
#define APIPRIVATE __stdcall
#define PASCAL __stdcall
```
Кто бы мог подумать! Вызов, объявленный как PASCAL, на самом деле представляет собой stdcall! И CALLBACK тоже определен как stdcall.
Наконец-то все сошлось! Теперь, если тебе скажут, что CALLBACK — это PASCAL, ты сможешь усмехнуться и сказать, что еж тоже птица, правда, гордая — пока не пнешь, не полетит! Оказывается, копания в дебрях include-файлов могут приносить пользу. Кстати, эти извращения с перекрытием типов создают большую проблему при подключении к проекту на Си модулей, написанных в среде с поддержкой PASCAL-соглашений о вызове функций. Поскольку в Windows PASCAL — никакой не PASCAL, а stdcall, ничего работать не будет!
Итак, мы выяснили, что аргументы CALLBACK-функциям передаются справа налево, но стек вычищает сама вызываемая функция, как и положено по соглашению stdcall.
Продолжим разгребать нашу многострадальную `TimerFunc`.
Помещенный в EAX остаток от деления умножаем на 0x10 и передаем получившееся в регистре ECX произведение в качестве параметра функции `MessageBeep`, которая будет бренчать нарочно выбранным системным звуком:
```nasm
imul eax, 10h
mov ecx, eax ; uType
```
Однако не спеши, потому что звук этот не совсем случайный. Заглянув в Windows SDK, мы обнаружим, что `MessageBeep` принимает одну из констант: `MB_OK`, `MB_ICONASTERISK`, `MB_ICONHAND` и так далее, но там ничего не сказано о том, какое у этих констант значение. Зато сообщается, что `MessageBeep` описана в файле `WINUSER.h`. Открываем его и ищем `MB_OK`:
```cpp
#define MB_OK 0x00000000L
#define MB_OKCANCEL 0x00000001L
#define MB_ABORTRETRYIGNORE 0x00000002L
#define MB_YESNOCANCEL 0x00000003L
#define MB_YESNO 0x00000004L
#define MB_RETRYCANCEL 0x00000005L
#if(WINVER >= 0x0500)
#define MB_CANCELTRYCONTINUE 0x00000006L
#endif /* WINVER >= 0x0500 */
#define MB_ICONHAND 0x00000010L
#define MB_ICONQUESTION 0x00000020L
#define MB_ICONEXCLAMATION 0x00000030L
#define MB_ICONASTERISK 0x00000040L
```
Нашли! Интересующие нас константы равны 0x0, 0x10, 0x20, 0x30, 0x40. Теперь становится понятным смысл программы. Взяв остаток, полученный делением количества миллисекунд, прошедших с момента включения системы, на 5, мы получаем число в интервале от 0 до 4. Умножая его на 0x10, получаем: 0x0, 0x10, 0x20, 0x30, 0x40.
```nasm
; Бибикаем на все лады
call cs:MessageBeep
; обнуляем регистр EDX
xor edx, edx
; вновь берем значение аргумента dwTime (количество миллисекунд,
; прошедшее с момента старта операционной системы) – делимое
mov eax, [rsp+28h+arg_18]
; делитель – 1000 в десятичной системе
mov ecx, 3E8h
; делим миллисекунды на 1000, получаем секунды…
div ecx
mov edx, eax
lea rcx, Format ; "\r:=%d"
; …и выводим их на консоль
call printf
add rsp, 28h
; Уходя, гасим за собой свет, прибираем стек!
retn
TimerFunc endp
```
[ INFO
Итак, давай повторим для закрепления. **На Intel все виндовые функции следуют соглашению: аргументы заносятся справа налево, а стек вычищает вызываемая функция.**
]
## Delphi
Завершим знакомство с соглашением PASCAL на программе, написанной на языке из этого семейства. Для этого воспользуемся последней версией Embarcadero Delphi Community Edition, которую можно свободно скачать с сайта [разработчика](https://www.embarcadero.com/ru/products/delphi/starter). Создадим простенькую программу и дизассемблируем ее.
```pas
program delphi_call;
{$APPTYPE CONSOLE}
{$R *.res}
uses
System.SysUtils;
procedure MyProc(a:Word; b:Byte; c:String);
begin
writeLn(a+b, ' ' ,c);
end;
begin
MyProc($666, $77, 'Hello,Sailor!');
end.
```
Перед компиляцией не забудь настроить параметры проекта. На этот раз будем делать 64-битную отладочную версию.
![Delphi Community IDE](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20942/delphi-call.png)
В release-сборке код получится совсем неразборчивым. Вывод в консоль программы выглядит следующим образом.
![Консольный вывод](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20941/delphi-output.png)
Сейчас посмотрим на результат компиляции, но сначала глянь, какую заковыристую схему построила IDA для этой программы.
![IDA View-A](https://st768.s3.eu-central-1.amazonaws.com/622c2aba99f0be10add21db126aa30d3/20940/ida-delphi.png)
```nasm
_ZN11Delphi_call14initializationEv proc near
; DATA XREF: HEADER:0000000000400128↑o
; .pdata:0000000000446268↓o
; __unwind { // _ZN6System23_DelphiExceptionHandlerEPNS_16TExceptionRecordEyPvS2_
push rbp
sub rsp, 20h
mov rbp, rsp
mov rax, cs:off_430F18
mov byte ptr [rax], 1
nop
lea rcx, qword_428778
```
Вызывается процедура, которая инициализирует системный модуль приложения. В том числе она получает экземпляр приложения и может принимать события и отвечать на них.
```nasm
call _ZN7Sysinit8_InitExeEPv
```
В регистр размером в слово помещаем значение 0x666:
```nasm
mov cx, 666h
```
В регистр размером в байт помещаем 0x77:
```nasm
mov dl, 77h ; 'w'
```
В регистр R8 помещаем ссылку на строку символов:
```nasm
lea r8, aHelloSailor ; "Hello,Sailor!"
```
Обрати внимание: передача аргументов идет слева направо.
А здесь происходит вызов нашей функции `MyProc`, которой передаются все подготовленные ранее параметры:
```nasm
call _ZN11Delphi_call6MyProcEthN6System13UnicodeStringE
```
А вот эта функция проверяет наличие ошибок и завершает процессы, освобождает объекты памяти, тем самым завершает программу:
```nasm
call _ZN6System6_Halt0Ev
jmp short loc_428730 ; безусловный переход на метку,
; если завершение прошло успешно
db 2 dup(90h)
```
В случае, когда происходит неуловимое исключение — системная ошибка, срабатывает обращение вот к этой функции, которая выталкивает системный сбой на верхний уровень, чтобы показать его пользователю.
```nasm
call _ZN6System19_UnhandledExceptionEv
align 10h
loc_428730: ; метка, где находится эпилог функции
lea rsp, [rbp+20h] ; очищаем стек и выходим
pop rbp
retn
; } // starts at 4286F0
_ZN11Delphi_call14initializationEv endp
```
Добрались до анализа функции `MyProc`:
```nasm
_ZN11Delphi_call6MyProcEthN6System13UnicodeStringE proc near
; IDA не определила прототип функции. Что ж, сделаем это сами!
; CODE XREF: _ZN11Delphi_call14initializationEv+2C↓p
; DATA XREF: .pdata:0000000000446244↓o
; функция принимает три аргумента:
; слева направо: слово, байт, учетверенное слово
arg_0 = word ptr 30h
arg_8 = byte ptr 38h
arg_10 = qword ptr 40h
; __unwind { // _ZN6System23_DelphiExceptionHandlerEPNS_16TExceptionRecordEyPvS2_
; инициализируем стек, открывает фрейм стека
push rbp
sub rsp, 20h
mov rbp, rsp
; копируем значения аргументов из регистров процессора
mov [rbp+arg_0], cx
mov [rbp+arg_8], dl
mov [rbp+arg_10], r8
mov rcx, [rbp+arg_10] ; в регистре RCX указатель на строку ‘Hello,Sailor!’
```
Вызываем функцию, которая добавляет ссылку на строку. В старых версиях компилятор паскаля создавал для этого локальный буфер и передавал строку по значению. Хорошо хоть сейчас научился пользоваться ссылками!
```nasm
call _ZN6System11_UStrAddRefEPv
nop
mov rcx, cs:off_430D40 ; получаем указатель на буфер вывода
```
Тут нужно познакомить тебя с системой вывода Delphi — она весьма разительно отличается от C. Во-первых, левосторонний порядок отправки аргументов в стек не позволяет организовать поддержку процедур с переменным числом аргументов (во всяком случае, без дополнительных ухищрений). Но ведь `WriteLn` — это и есть процедура с переменным числом параметров. Разве нет? Вот именно, что нет! Никакая это не процедура, а оператор. Компилятор еще на стадии компиляции разбивает ее на множество вызовов процедур для вывода каждого аргумента по отдельности. Поэтому в откомпилированном коде каждая процедура примет фиксированное количество аргументов.
В нашем случае их будет три: первая — для вывода суммы двух чисел, этим занимается процедура `WriteLong`; вторая — для вывода символа пробела в символьной форме, этим занимается `WriteWChar`; и, наконец, последняя служит для вывода строки — `WriteString`.
Размышляем далее: в Windows вывести строку в окно и тут же забыть о ней нельзя, так как окно в любой момент может потребовать перерисовки. Операционная система не сохраняет его содержимое, поскольку при высоком разрешении это привело бы к большим затратам памяти.
Код, выводящий строку, должен уметь повторять свой вывод по запросу. Каждый, кто хоть раз писал программы для Windows, наверняка помнит, что весь вывод приходилось помещать в обработчик сообщения `WM_PAINT`.
Delphi же позволяет обращаться с окном Windows точно так, как с консолью. А раз так, значит, все, что выводилось на экран, должно где-то храниться. Поскольку локальные переменные умирают вместе с завершением их процедуры, то для хранения буфера они не годятся. Остается либо куча, либо сегмент данных. Delphi использует последнее — указатель на такой буфер мы только что получили.
Дальше для повышения производительности вывода Delphi реализует простейший кеш. Функции `WriteLong`, `WriteWChar` и `WriteString` сливают результат своей деятельности в символьном виде в этот самый буфер, а в конце следует вызов `WriteLn`, выводящий содержимое буфера в окно. Run-time systems следит за его перерисовками и при необходимости повторяет вывод уже без участия программиста.
В следующих двух инструкциях происходит копирование значений меньших размеров из памяти в регистры большего размера. При этом значения на лету дополняются нулевыми битами. Так, в первом случае из слова значение преобразуется в двойное слово, во втором — из байта в учетверенное слово. В третьей инструкции выполняется сложение значений регистров, а результат сохраняется в EDX.
```nasm
movzx edx, [rbp+arg_0]
movzx rax, [rbp+arg_8]
add edx, eax
```
EDX — это меньшая часть 64-разрядного RDX, следовательно, данные передаются следующей функции для вывода символьного значения в промежуточный буфер:
```nasm
call _ZN6System11_Write0LongERNS_8TTextRecEi
mov rcx, rax
```
Регистр DX — это 1/4 от регистра RDX (16 бит), а значит, он тоже участвует в передаче параметров на платформе x64. В него помещается значение 0x20, что соответствует пробелу. Затем сформированной компилятором процедурой этот одиночный символ подготавливается для вывода в консоль (копируется в кеш):
```nasm
mov dx, 20h ; ' '
call _ZN6System12_Write0WCharERNS_8TTextRecEw
mov rcx, rax
```
На этот раз программе понадобился регистр целиком — все 64 бита. В него из памяти копируется строка `Hello,Sailor!`. Тем самым, как ты уже догадался, строка будет передана следующей функции, которая отправит ее в буфер:
```nasm
mov rdx, [rbp+arg_10]
call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE
mov rcx, rax
```
Следующая функция не принимает никаких аргументов, в ее задачи входит завершить процедуру вывода текста. Она выталкивает его из буфера, печатает на консоли и, собственно, очищает кеш. Вместе с этим она заканчивает оператор `WriteLn`.
```nasm
call _ZN6System8_WriteLnERNS_8TTextRecE
; Проверка операции вывода на успешность
call _ZN6System8__IOTestEv
nop
lea rcx, [rbp+arg_10]
; Вызываем функцию для очистки памяти
call _ZN6System8_UStrClrEPv
lea rsp, [rbp+20h]
; Восстанавливаем стек и выходим из функции MyProc
pop rbp
retn
; } // starts at 428650
_ZN11Delphi_call6MyProcEthN6System13UnicodeStringE endp
```
С размерами параметров разобрались, поэтому можем установить их тип, а следовательно, восстановить прототип нашей процедуры: `MyProc(a: Byte; b: Word; c: String)`.
Да, хитрой оказалась Delphi! Анализ откомпилированной в ней программы преподнес нам хороший урок.
Надеюсь, никто не устал? Отхлебнем любимого напитка из походной фляжки — и вперед, в дебри машинного кода! Нас ждут потрясающие открытия и увлекательные приключения.

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

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

    Подписаться

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