Содержание статьи
Адресация аргументов в стеке
Базовая концепция стека включает в себя лишь две операции — занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу — это что‑то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы. Если нужен, скажем, третий по счету элемент, почему бы не вытащить его из стека напрямую, не снимая первые два?
Стек — это не только «стопка», как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажете класть очередной элемент?) и размер элементов, мы сможем вычислить смещение любого из элементов, после чего не составит никакого труда его прочитать.
Попутно отметим один из недостатков стека: как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например двойные слова. Если же требуется занести один байт (скажем, аргумент типа 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 10h
IDA распознала два аргумента, передаваемых функции. Однако не стоит безоговорочно этому доверять, если один аргумент (например, 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,
. Указатель на нее помещается в регистр R8
, предназначенный для передачи целочисленных параметров или собственно указателей. Первым по порядку передается указатель на строку, заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что это самый правый аргумент, передаваемый функции. Следовательно, перед нами вызов типа stdcall или cdecl, но не PASCAL.
lea r8, c ; "Hello,World!"mov edx, 777h ; bmov ecx, 666h ; a
Следом помещаем в два 32-битных регистра EDX и ECX значения двух переменных 0x777
и 0x666
соответственно. Последнее оказалось самым левым аргументом. Что показывает нам правильно восстановленный прототип функции. Но так бывает не всегда, IDA иногда ошибается.
call MyFunc(int,int,char const *)
Обрати внимание, после вызова функции отсутствуют команды очистки стека от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул к отложенной очистке, то, скорее всего, стек очищает сама вызываемая функция, значит, тип вызова — stdcall (что, собственно, и требовалось доказать).
mov edx, eax
Теперь, передаем возвращенное функцией значение следующей функции как аргумент.
lea rcx, _Format ; "%x\n"
Эта следующая функция printf
и строка спецификаторов показывают, что переданный аргумент имеет тип int.
call printfxor eax, eaxadd rsp, 28hretnmain endp
Теперь рассмотрим функцию MyFunc
:
; int __fastcall MyFunc(int a, int b, const char *c)int MyFunc(int, int, char const *) proc near
IDA пытается самостоятельно восстановить прототип функции и… обламывается. Иными словами, делает это не всегда успешно. Например, «Ида» ошибочно предположила тип вызова fastcall
, хотя на самом деле — stdcall. Вспомним: fastcall
на 32-битной платформе предполагает передачу параметров через регистры процессора, тогда как на платформе x64 первые четыре параметра всегда передаются через регистры процессора, независимо от указанного типа вызова.
var_18 = qword ptr -18hvar_10 = qword ptr -10harg_0 = dword ptr 8arg_8 = dword ptr 10harg_10 = qword ptr 18h
Переданные аргументы из регистров помещаются в память, затем после инициализации стека числовые значения размещаются в регистрах, где происходит их сложение: add
.
mov [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):
cdqe
Копирование из стека указателя в строку, в регистр RCX и в переменную var_10
. Далее инициализируем переменную var_18
значением -1
, очевидно, она будет счетчиком.
mov rcx, [rsp+18h+arg_10]mov [rsp+18h+var_10], rcxmov [rsp+18h+var_18], 0FFFFFFFFFFFFFFFFhloc_140001111:
И действительно, на следующем шаге она увеличивается на единицу. К тому же здесь мы видим метку безусловного перехода. Похоже, вместо того, чтобы узнать длину строки посредством вызова библиотечной функции strlen
, компилятор решил самостоятельно сгенерировать код. Что ж, флаг ему в руки!
inc [rsp+18h+var_18]
Значения переменных вновь копируются в регистры для проведения операций над ними.
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
, откуда блок кода начинает выполняться по новой.
jnz short loc_140001111
Когда флаг ZF равен единице, осуществляется выход из цикла и переход на следующую за ним инструкцию, которая накопленное в переменной‑счетчике число записывает в регистр RCX. После этого происходит сложение значений в регистрах RCX и RAX, как помним, в последнем содержится сумма двух переданных числовых аргументов.
mov rcx, [rsp+18h+var_18]add rax, rcx
В завершении функции происходит деинициализация стека:
add rsp, 18hretnint MyFunc(int, int, char const *) endp
Возвращение целочисленного аргумента на платформе x64 предусмотрено в регистре RAX.
На вывод программа печатает de9
.
Легко проверить: Hello,
— 12 символов, то есть 0xC. Открой калькулятор в Windows:
0x666 + 0x777 + 0xC = 0xDE9
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»