Содержание статьи
Адресация аргументов в стеке
Базовая концепция стека включает в себя лишь две операции — занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу — это что‑то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы. Если нужен, скажем, третий по счету элемент, почему бы не вытащить его из стека напрямую, не снимая первые два?
Стек — это не только «стопка», как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажете класть очередной элемент?) и размер элементов, мы сможем вычислить смещение любого из элементов, после чего не составит никакого труда его прочитать.
Попутно отметим один из недостатков стека: как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например двойные слова. Если же требуется занести один байт (скажем, аргумент типа 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 , можно мгновенно вычислить, к какому именно аргументу происходит обращение. Однако оптимизирующие компиляторы для экономии регистра RBP адресуют аргументы непосредственно через RSP.
Разница принципиальна! Значение RSP не остается постоянным на протяжении выполнения функции и изменяется всякий раз при занесении и снятии данных из стека, следовательно, не остается постоянным и смещение аргументов относительно RSP. Теперь, чтобы определить, к какому именно аргументу происходит обращение, необходимо знать, чему равен RSP в данной точке программы, а для выяснения этого все его изменения приходится отслеживать от самого начала функции!
Подробнее о такой «хитрой» адресации мы поговорим потом, а для начала вернемся к предыдущему примеру (надо ж его «добить») и разберем вызываемую функцию (исходник к ней смотри в предыдущей статье — пример на Visual C++):
void MyFunc(double, struct XT) proc neararg_0 = qword ptr 8arg_8 = qword ptr 10hIDA распознала два аргумента, передаваемых функции. Однако не стоит безоговорочно этому доверять, если один аргумент (например, int64) передается в нескольких машинных словах, то IDA ошибочно примет его не за один, а за несколько аргументов! Поэтому результат, полученный IDA, надо трактовать так: функции передается не менее двух аргументов.
Впрочем, и здесь не все гладко! Ведь никто не мешает вызываемой функции залезать в стек материнской так далеко, как она захочет! Может быть, нам не передавали никаких аргументов вовсе, а мы самовольно полезли в стек и стянули что‑то оттуда. Хотя это случается в основном вследствие программистских ошибок из‑за путаницы с прототипами, считаться с такой возможностью необходимо. Когда‑нибудь ты все равно с этим встретишься, так что будь начеку.
Число, стоящее после arg, выражает смещение аргумента относительно начала кадра стека.
Извлекаем переданные аргументы из регистров процессора и размещаем их в памяти (при этом вспоминаем, что передавали из вызывающей функции):
mov [rsp+arg_8], rdx ; указатель на буферmovsd [rsp+arg_0], xmm0 ; значение с плавающей запятойДалее инициализируем стек, подготавливаем регистры к работе, производим необходимые вычисления, затем кладем в регистры значения для передачи параметров функции printf:
sub rsp, 28hmov eax, 1imul rax, 0mov rcx, [rsp+28h+arg_8]add rcx, raxmov rax, rcxmov r9, raxmov rax, [rsp+28h+arg_8]mov r8d, [rax+14h]movsd xmm1, [rsp+28h+arg_0]movq rdx, xmm1lea rcx, _Format ; "%f,%x,%s\n"call printfОбрати внимание, перед вызовом printf программа в трех регистрах размещает значения параметров для передачи, а в четвертом регистре RCX (так же для передачи) помещает указатель на форматную строку спецификаторов вывода: %f,. Функция 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, 28hretnСоглашения о вызовах
Кое‑какие продвижения уже есть — мы уверенно восстановили прототип нашей первой функции. Но это только начало. Еще много миль предстоит пройти… Если устал — передохни, тяпни кваса, поболтай с кем‑нибудь и продолжим на свежую голову. Мы приступаем еще к одной очень важной теме — сравнительному анализу разных типов вызовов функций и их реализации в разных компиляторах.
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++ с отключенной оптимизацией, то есть ключом / (в ином случае компилятор так заоптимизирует код, что исчезнет всякая познавательная составляющая):
main proc nearsub rsp, 28h```IDA хорошо нам подсказывает, что константа `c` содержит строку `Hello,World!`. Указатель на нее помещается в регистр `R8`, предназначенный для передачи целочисленных параметров или собственно указателей. Первым по порядку передается указатель на строку, заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что это самый правый аргумент, передаваемый функции. Следовательно, перед нами вызов типа stdcall или cdecl, но не PASCAL.````nasmlea r8, c ; "Hello,World!"mov edx, 777h ; bmov ecx, 666h ; a```Следом помещаем в два 32-битных регистра EDX и ECX значения двух переменных `0x777` и `0x666` соответственно. Последнее оказалось самым левым аргументом. Что показывает нам правильно восстановленный прототип функции. Но так бывает не всегда, IDA иногда ошибается.```nasmcall MyFunc(int,int,char const *)```Обрати внимание, после вызова функции отсутствуют команды очистки стека от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул к отложенной очистке, то, скорее всего, стек очищает сама вызываемая функция, значит, тип вызова — stdcall (что, собственно, и требовалось доказать).```nasmmov edx, eax```Теперь, передаем возвращенное функцией значение следующей функции как аргумент.```nasmlea rcx, _Format ; "%x\n"```Эта следующая функция `printf` и строка спецификаторов показывают, что переданный аргумент имеет тип int.```nasmcall printfxor eax, eaxadd rsp, 28hretnmain 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 первые четыре параметра всегда передаются через регистры процессора, независимо от указанного типа вызова.```nasmvar_18 = qword ptr -18hvar_10 = qword ptr -10harg_0 = dword ptr 8arg_8 = dword ptr 10harg_10 = qword ptr 18h```Переданные аргументы из регистров помещаются в память, затем после инициализации стека числовые значения размещаются в регистрах, где происходит их сложение: `add ecx, eax`.```nasmmov [rsp+arg_10], r8mov [rsp+arg_8], edxmov [rsp+arg_0], ecxsub rsp, 18hmov eax, [rsp+18h+arg_8]mov ecx, [rsp+18h+arg_0]add ecx, eaxmov eax, ecx```Преобразование двойного слова (EAX) в учетверенное (RAX):```nasmcdqe```Копирование из стека указателя в строку, в регистр RCX и в переменную `var_10`. Далее инициализируем переменную `var_18` значением `-1`, очевидно, она будет счетчиком.```nasmmov rcx, [rsp+18h+arg_10]mov [rsp+18h+var_10], rcxmov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFhloc_140001111:```И действительно, на следующем шаге она увеличивается на единицу. К тому же здесь мы видим метку безусловного перехода. Похоже, вместо того, чтобы узнать длину строки посредством вызова библиотечной функции `strlen`, компилятор решил самостоятельно сгенерировать код. Что ж, флаг ему в руки!```nasminc [rsp+18h+var_18]```Значения переменных вновь копируются в регистры для проведения операций над ними.```nasmmov rcx, [rsp+18h+var_10]mov rdx, [rsp+18h+var_18]cmp byte ptr [rcx+rdx], 0```Значения регистров RCX и RDX складываются, а сумма сравнивается с нулем. В случае если выражение тождественно, то флаг ZF устанавливается в единицу, в обратном случае — в ноль. Инструкция `JNZ` проверяет флаг Z. Если он равен нулю, тогда происходит переход на метку `loc_140001111`, откуда блок кода начинает выполняться по новой.```nasmjnz short loc_140001111```Когда флаг ZF равен единице, осуществляется выход из цикла и переход на следующую за ним инструкцию, которая накопленное в переменной-счетчике число записывает в регистр RCX. После этого происходит сложение значений в регистрах RCX и RAX, как помним, в последнем содержится сумма двух переданных числовых аргументов.```nasmmov rcx, [rsp+18h+var_18]add rax, rcx```В завершении функции происходит деинициализация стека:```nasmadd rsp, 18hretnint MyFunc(int, int, char const *) endp```Возвращение целочисленного аргумента на платформе x64 предусмотрено в регистре RAX.На вывод программа печатает `de9`.Легко проверить: `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 nearvar_18 = qword ptr -18hvar_10 = qword ptr -10harg_0 = dword ptr 8arg_8 = dword ptr 10harg_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], 0FFFFFFFFFFFFFFFFhloc_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 retnint 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 retnmain 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, если они его поддерживают#endifint// Подобный прием программирования, может, и делает листинг// менее читабельным, но зато позволяет компилировать его// не только одним компилятором!#if defined(_MSC_VER)WINAPI#elsePASCAL//__pascal – для C++Builder#endifMyFunc(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.Для разнообразия откомпилируем его в C++Builder:Первое, что бросается в глаза, — современный C++Builder тоже не поддерживает соглашение о вызовах PASCAL!Посмотрим, что же он накомпилировал:```nasm; int __cdecl main(int argc, const char **argv, const char **envp) public mainmain proc nearvar_14 = dword ptr -14hvar_10 = qword ptr -10hvar_8 = dword ptr -8var_4 = dword ptr -4; инициализация стека sub rsp, 38h```Передаем функции аргументы. Заглянув в исходный текст, замечаем, что аргументы передаются слева направо. Однако, если исходников нет, установить этот факт невозможно! К счастью, подлинный прототип функции не важен.```nasmmov eax, 666hmov r8d, 777hlea r9, aHelloWorld ; "Hello,World!"mov [rsp+38h+var_4], 0mov [rsp+38h+var_8], ecxmov [rsp+38h+var_10], rdxmov ecx, eaxmov edx, r8d ; char *mov r8, r9 ; теперь строка в R8call MyFunc(int,int,char const*)```По определенному IDA прототипу можно утверждать, что тип вызова либо PASCAL, либо stdcall. Но так как PASCAL уже вышел из употребления, будем считать, что имеем дело с stdcall.```nasmlea rcx, unk_44A000mov edx, eaxcall printfmov [rsp+38h+var_4], 0mov [rsp+38h+var_14], eaxmov eax, [rsp+38h+var_4]add rsp, 38hretnmain endp; __int64 __fastcall MyFunc(int, int, const char *)```Ага! IDA вновь дала неправильный результат! Тип вызова явно не `fastcall`! Однако в остальном прототип функции верен. Точнее, не то чтобы (на самом деле порядок аргументов обратный), но для использования пригоден.```nasmpublic 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 — строка:```nasmmov [rsp+48h+var_4], ecxmov [rsp+48h+var_8], edxmov [rsp+48h+var_10], r8```Переносим в регистр ECX аргумент `var_4` типа int, затем складываем его с аргументом `var_8`. Сумма временно хранится в ECX.```nasmmov ecx, [rsp+48h+var_4]add ecx, [rsp+48h+var_8]```После сложения для расширения 32-битного операнда до 64-битного с учетом знака компилятор воспользовался инструкцией `movsxd`, которая появилась в наборе инструкций x86-64. Сумма хранится в R8. Строка теперь находится в RCX.```nasmmovsxd r8, ecxmov rcx, [rsp+48h+var_10]mov [rsp+48h+var_18], r8 ; сумма снова помещается в var_18```В отличие от Visual C++ для нахождения длины строки C++Builder использует библиотечную функцию `strlen`, строка передается ей параметром в регистре RCX:```nasmcall 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 retnMyFunc(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:```bashcl pascal.callback.c USER32.lib```И посмотрим, что из этого получилось.```nasm; int __cdecl main(int argc, const char **argv, const char **envp)main proc near```IDA распознала одну локальную переменную и даже восстановила ее тип, что не может не радовать. Между тем, что особо интересно, во время трансляции компилятор бессовестно переименовал функцию `TimerProc` в `TimerFunc`! Поэтому будь внимателен.```nasmMsg = tagMSG ptr -38hsub rsp, 58hlea r9, TimerFunc ; lpTimerFunc - передаем указатель на функцию TimerProcmov r8d, 3E8h ; uElapse - передаем время задержки таймераxor edx, edx ; nIDEvent - в консольных приложениях аргумент nIDEvent всегда игнорируетсяxor ecx, ecx ; hWnd - окон нет, передаем NULL```Булева операция XOR быстрее осуществляет обнуление регистра, переданного ей в обоих параметрах, чем простое обнуление этого регистра.Win32 API-функции вызываются по соглашению stdcall — это дает возможность, зная их прототип (а он описан в SDK), восстановить тип и назначение аргументов. В данном случае исходный текст выглядит так: `SetTimer(NULL, NULL, 1000, TimerProc);`.```nasmcall cs:SetTimerloc_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 retnmain endp; void __stdcall TimerFunc(HWND, UINT, UINT_PTR, DWORD)TimerFunc proc near```К нашему удовлетворению, IDA распознала прототип функции! Хотя раньше дела обстояли хуже. Поскольку `TimerProc` неявно вызывается операционной системой, в старых версиях IDA не могла автоматически восстановить прототип, и нам приходилось заниматься этим самим. Но тогда достаточно было заглянуть в описание функции `SetTimer`, которой передается `TimerProc`. В любом случае ее прототип найти не составит труда: он такой же, как использованный нами при написании оригинальной программы на C++.```cppVOID 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. Значит, аргументы идут слева направо, а стек чистит вызываемая функция.```nasmarg_0 = qword ptr 8arg_8 = dword ptr 10harg_10 = dword ptr 18harg_18 = dword ptr 20h```И опять, к нашему счастью, IDA обнаружила все четыре аргумента! Стоит отметить, что раньше IDA определяла только один аргумент, поскольку функция в работе использует всего один, а к остальным просто не обращается. Вот IDA и не может их восстановить.Тем не менее в нашем случае IDA не отметила ни один аргумент комментарием — разбирайся сам! А нам надо узнать тип хотя бы крайнего элемента, чтобы определить порядок передачи аргументов. Для этого нам надо разобраться в коде тела функции, благо мы можем его сравнить с исходником на C++.```nasm; вынимаем содержимое регистров, складируем их в памятьmov [rsp+arg_18], r9dmov [rsp+arg_10], r8dmov [rsp+arg_8], edxmov [rsp+arg_0], rcx; инициализируем стекsub rsp, 28hxor 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++ нам в помощь:```cppMessageBeep((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`, которая будет бренчать нарочно выбранным системным звуком:```nasmimul eax, 10hmov 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 ; Уходя, гасим за собой свет, прибираем стек! retnTimerFunc endp```[ INFOИтак, давай повторим для закрепления. **На Intel все виндовые функции следуют соглашению: аргументы заносятся справа налево, а стек вычищает вызываемая функция.**]## DelphiЗавершим знакомство с соглашением PASCAL на программе, написанной на языке из этого семейства. Для этого воспользуемся последней версией Embarcadero Delphi Community Edition, которую можно свободно скачать с сайта [разработчика](https://www.embarcadero.com/ru/products/delphi/starter). Создадим простенькую программу и дизассемблируем ее.```pasprogram 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-битную отладочную версию.В release-сборке код получится совсем неразборчивым. Вывод в консоль программы выглядит следующим образом.Сейчас посмотрим на результат компиляции, но сначала глянь, какую заковыристую схему построила IDA для этой программы.```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```Вызывается процедура, которая инициализирует системный модуль приложения. В том числе она получает экземпляр приложения и может принимать события и отвечать на них.```nasmcall _ZN7Sysinit8_InitExeEPv```В регистр размером в слово помещаем значение 0x666:```nasmmov cx, 666h```В регистр размером в байт помещаем 0x77:```nasmmov dl, 77h ; 'w'```В регистр R8 помещаем ссылку на строку символов:```nasmlea r8, aHelloSailor ; "Hello,Sailor!"```Обрати внимание: передача аргументов идет слева направо.А здесь происходит вызов нашей функции `MyProc`, которой передаются все подготовленные ранее параметры:```nasmcall _ZN11Delphi_call6MyProcEthN6System13UnicodeStringE```А вот эта функция проверяет наличие ошибок и завершает процессы, освобождает объекты памяти, тем самым завершает программу:```nasmcall _ZN6System6_Halt0Evjmp short loc_428730 ; безусловный переход на метку, ; если завершение прошло успешноdb 2 dup(90h)```В случае, когда происходит неуловимое исключение — системная ошибка, срабатывает обращение вот к этой функции, которая выталкивает системный сбой на верхний уровень, чтобы показать его пользователю.```nasm call _ZN6System19_UnhandledExceptionEv align 10hloc_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 30harg_8 = byte ptr 38harg_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!’```Вызываем функцию, которая добавляет ссылку на строку. В старых версиях компилятор паскаля создавал для этого локальный буфер и передавал строку по значению. Хорошо хоть сейчас научился пользоваться ссылками!```nasmcall _ZN6System11_UStrAddRefEPvnopmov 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.```nasmmovzx edx, [rbp+arg_0]movzx rax, [rbp+arg_8]add edx, eax```EDX — это меньшая часть 64-разрядного RDX, следовательно, данные передаются следующей функции для вывода символьного значения в промежуточный буфер:```nasmcall _ZN6System11_Write0LongERNS_8TTextRecEimov rcx, rax```Регистр DX — это 1/4 от регистра RDX (16 бит), а значит, он тоже участвует в передаче параметров на платформе x64. В него помещается значение 0x20, что соответствует пробелу. Затем сформированной компилятором процедурой этот одиночный символ подготавливается для вывода в консоль (копируется в кеш):```nasmmov dx, 20h ; ' 'call _ZN6System12_Write0WCharERNS_8TTextRecEwmov rcx, rax```На этот раз программе понадобился регистр целиком — все 64 бита. В него из памяти копируется строка `Hello,Sailor!`. Тем самым, как ты уже догадался, строка будет передана следующей функции, которая отправит ее в буфер:```nasmmov rdx, [rbp+arg_10]call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringEmov 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! Анализ откомпилированной в ней программы преподнес нам хороший урок.Надеюсь, никто не устал? Отхлебнем любимого напитка из походной фляжки — и вперед, в дебри машинного кода! Нас ждут потрясающие открытия и увлекательные приключения.