Фундаментальные основы хакерства. Учимся идентифицировать аргументы функций
Как известно, аргументы функций передаются в функцию при ее вызове. Определив их, можно разобраться в том, как эта функция работает. Однако обнаружить аргументы в дизассемблированном коде — непростая задача. Сегодняшняя статья поможет тебе эту задачу решить.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Идентификация аргументов функций — ключевое звено в исследовании дизассемблированных листингов. Поэтому приготовь чай и печеньки, разговор будет долгим. В сегодняшней статье мы рассмотрим список соглашений о передаче параметров, используемых в разных языках программирования и компиляторах. В довесок мы рассмотрим приложение, в котором можно отследить передачу параметров, а также определить их количество и тип. Это может быть весьма нетривиальной задачей, особенно если один из параметров — структура.
Существует три способа передать аргументы функции: через стек, регистры и комбинированный — через стек и регистры одновременно. К этому списку вплотную примыкает и неявная передача аргументов через глобальные переменные.
Сами же аргументы могут передаваться либо по значению, либо по ссылке. В первом случае функции передается копия соответствующей переменной, а во втором — указатель на саму переменную.
СОГЛАШЕНИЯ О ПЕРЕДАЧЕ ПАРАМЕТРОВ
Для успешной совместной работы вызывающая функция должна не только знать прототип вызываемой, но и «договориться» с ней о способе передачи аргументов: по ссылке или по значению, через регистры или через стек. Если через регистры — оговорить, какой аргумент в какой регистр помещен, а если через стек — определить порядок занесения аргументов и выбрать «ответственного» за очистку стека от аргументов после завершения вызываемой функции.
Неоднозначность механизма передачи аргументов — одна из причин несовместимости различных компиляторов. Кажется, почему бы не заставить всех производителей компиляторов придерживаться какой‑то одной схемы? Увы, это принесет больше проблем, чем решит.
Каждый механизм имеет свои достоинства и недостатки и, что еще хуже, тесно связан с самим языком. В частности, «сишные» вольности с соблюдением прототипов функций возможны именно потому, что аргументы из стека выталкивает не вызываемая, а вызывающая функция, которая наверняка помнит, что она передавала. Например, функции main
передаются два аргумента — количество ключей командной строки и указатель на содержащий их массив. Однако, если программа не работает с командной строкой (или получает ключ каким‑то иным путем), прототип main
может быть объявлен и так: main()
.
На паскале подобная выходка привела бы либо к ошибке компиляции, либо к краху программы, так как в нем стек очищает непосредственно вызываемая функция. Если она этого не сделает (или сделает неправильно, вытолкнув не то же самое количество машинных слов, которое ей было передано), стек окажется несбалансированным и все рухнет. Точнее, у материнской функции «слетит» вся адресация локальных переменных, а вместо адреса возврата в стеке окажется, что глюк на душу положит.
Минусом «сишного» решения является незначительное увеличение размера генерируемого кода, ведь после каждого вызова функции приходится вставлять машинную команду (и порой не одну) для выталкивания аргументов из стека, а у паскаля эта команда внесена непосредственно в саму функцию и потому встречается в программе один‑единственный раз.
Не найдя золотой середины, разработчики компиляторов решили использовать все доступные механизмы передачи данных, а чтобы справиться с проблемой совместимости, стандартизировали каждый из механизмов, введя ряд соглашений.
- С‑соглашение (обозначаемое cdecl) предписывает засылать аргументы в стек справа налево в порядке их объявления, а очистку стека возлагает на плечи вызывающей функции. Имена функций, следующих С‑соглашению, предваряются символом подчеркивания _, автоматически вставляемого компилятором. Указатель
this
(в программах, написанных на C++) передается через стек последним по счету аргументом. - Паскаль‑соглашение (обозначаемое PASCAL) предписывает засылать аргументы в стек слева направо в порядке их объявления и возлагает очистку стека на саму вызывающую функцию. Обрати внимание: в настоящее время ключевое слово PASCAL считается устаревшим и выходит из употребления, вместо него можно использовать аналогичное соглашение WINAPI.
- Стандартное соглашение (обозначаемое stdcall) является гибридом С- и паскаль‑соглашений. Аргументы засылаются в стек справа налево, но очищает стек сама вызываемая функция. Имена функций, следующих стандартному соглашению, предваряются символом подчеркивания _, а заканчиваются суффиксом @, за которым следует количество байтов, передаваемых функции. Указатель
this
передается через стек последним по счету аргументом. - Соглашение быстрого вызова предписывает передавать аргументы через регистры. Компиляторы от Microsoft и Embarcadero поддерживают ключевое слово
fastcall
, но интерпретируют его по‑разному. Имена функций, следующих соглашениюfastcall
, предваряются символом@
, автоматически вставляемым компилятором. - Соглашение по умолчанию. Если явное объявление типа вызова отсутствует, компилятор обычно использует собственные соглашения, выбирая их по своему усмотрению. Наибольшему влиянию подвергается указатель
this
, большинство компиляторов при вызове по умолчанию передают его через регистр. У Microsoft этоRCX
, у Embarcadero —RAX
. Остальные аргументы также могут передаться через регистры, если оптимизатор посчитает, что так будет лучше. Механизм передачи и логика выборки аргументов у всех разная и наперед непредсказуемая, разбирайся по ситуации.
x64
Вместе с появлением архитектуры x64 для нее было изобретено только одно новое соглашение вызова, заменившее собой все остальные:
- первые четыре целочисленных параметра, в том числе указатели, передаются в регистрах
RCX
,RDX
,R8
,R9
; - первые четыре значения с плавающей запятой передаются в первых четырех регистрах расширения SSE:
XMM0
—XMM3
; - вызывающая функция резервирует в стеке пространство для аргументов, передающихся в регистрах. Вызываемая функция может использовать это пространство для размещения содержимого регистров в стеке;
- любые дополнительные параметры передаются в стеке;
- указатель или целочисленный аргумент возвращается в регистре
RAX
. Значение с плавающей запятой возвращается в регистреXMM0
.
Однако благодаря обратной совместимости с x86 современные процессоры на базе x86_64 также поддерживают все перечисленные способы передачи параметров.
Стоит отметить, что регистры RAX
, RCX
, RDX
, а также R8...R11
— изменяемые, тогда как RBX
, RBP
, RDI
, RSI
, R12...R15
— неизменяемые. Что это значит? Это свойство было добавлено в архитектуру x64, оно означает, что значения первых могут быть изменены непосредственно в вызываемой функции, тогда как значения вторых должны быть сохранены в памяти в начале вызываемой функции, а в ее конце, перед возвращением, — восстановлены.
ЦЕЛИ И ЗАДАЧИ
При исследовании функции перед нами стоят следующие задачи: определить, какое соглашение используется для вызова, подсчитать количество аргументов, передаваемых функции (и/или используемых функцией), и, наконец, выяснить тип и назначение самих аргументов. Начнем?
Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция, мы имеем дело c cdecl, в противном случае это либо stdcall, либо PASCAL. Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI), и stdcall, поэтому неопределенность по‑прежнему остается.
Впрочем, порядок передачи аргументов ничего не меняет: имея в наличии и вызывающую, и вызываемую функции, между передаваемыми и принимаемыми аргументами всегда можно установить взаимную однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен, см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему.
Другое дело — библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов!
ОПРЕДЕЛЕНИЕ КОЛИЧЕСТВА И ТИПА ПЕРЕДАЧИ АРГУМЕНТОВ
Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а также неявно через глобальные переменные.
Если бы стек был задействован только для передачи аргументов, подсчитать их количество было бы относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию «заталкивания» PUSH
, не торопись идентифицировать ее как аргумент. Узнать количество байтов, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байтов, выталкиваемых из стека после завершения функции!
Если функция следует соглашению stdcall (или PASCAL), она наверняка очищает стек командой RET n
, где n
и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция ADD RSP, n
, где n
— искомое значение в байтах, но возможны и вариации: отложенная очистка стека или выталкивание аргументов в какой‑нибудь свободный регистр. Впрочем, отложим головоломки оптимизации на потом, а пока ограничимся лишь кругом неоптимизирующих компиляторов.
Логично предположить, что количество занесенных в стек байтов равно количеству выталкиваемых, иначе после завершения функции стек окажется несбалансированным и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда следует: количество аргументов равно количеству переданных байтов, деленному на размер машинного слова. Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам (двойное слово), в 64-разрядном режиме машинное слово — это учетверенное слово (восемь байтов).
Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип int, отъедающий только половину. Или символьную строку, переданную не по ссылке, а по непосредственному значению: она «скушает» столько байтов, сколько захочет. К тому же строка может засылаться в стек (как и структура данных, массив, объект) не командой PUSH
, а с помощью MOVS
! Кстати, наличие MOVS
— явное свидетельство передачи аргумента по значению.
Если я успел окончательно тебя запутать, то попробуем разложить по полочкам тот кавардак, что образовался в твоей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байтов определяется весьма неуверенно. С типом передачи полный мрак. Позже мы к этому еще вернемся, а пока вот пример. PUSH 0x404040 / CALL MyFunc
— элемент 0x404040
— что это: аргумент, передаваемый по значению (то есть константа 0x404040
), или указатель на нечто, расположенное по смещению 0x404040
, и тогда, стало быть, передача происходит по ссылке? С ходу определить это невозможно, не правда ли?
Но не волнуйся, нам не пришли кранты — мы еще повоюем! Большую часть проблем решает анализ вызываемой функции. Выяснив, как она манипулирует переданными ей аргументами, мы установим и их тип, и количество! Для этого нам придется познакомиться с адресацией аргументов в стеке, но, прежде чем приступить к работе, рассмотрим в качестве небольшой разминки следующий пример:
#include <stdio.h>
#include <string.h>
struct XT {
char s0[20];
int x;
};
void MyFunc(double a, struct XT xt) {
printf("%f,%x,%s\n", a, xt.x, &xt.s0[0]);
}
int main() {
XT xt;
strcpy_s(&xt.s0[0], 13, "Hello,World!");
xt.x = 0x777;
MyFunc(6.66, xt);
}
Результат его компиляции компилятором Microsoft Visual C++ с включенной поддержкой платформы x64 и в релизном режиме, но с выключенной оптимизацией (/Od) выглядит так:
main proc near
var_58 = byte ptr -58h
Dst = byte ptr -38h
var_24 = dword ptr -24h
var_20 = qword ptr -20h
; Инициализируем стек
push rsi
push rdi
Отсутствие явной инициализации регистров говорит о том, что, скорее всего, они просто сохраняются в стеке, а не передаются как аргументы. К тому же, как мы помним, на платформе x64 первые четыре параметра целочисленного типа или указатели передаются в регистрах процессора: RCX
, RDX
, R8
, R9
. Если присутствуют дополнительные аргументы, то они передаются по старинке — через стек. Между тем если аргументы передавались данной функции через регистры RSI
и RDI
, то их засылка в стек вполне может преследовать цель передачи аргументов следующей функции.
sub rsp, 68h
mov rax, cs:__security_cookie
; Инвертируем значение вершины стека, результат сохраняем в RAX
xor rax, rsp
; Далее это значение записываем в переменную
mov [rsp+78h+var_20], rax
mov eax, 1
imul rax, 0
; В регистр RAX помещаем указатель на область памяти:
lea rax, [rsp+rax+78h+Dst]
IDA нам подсказала, что в регистр R8
, служащий для передачи параметров, помещается строка "Hello,World!" из константы Src
, которая находится в сегменте данных, предназначенном только для чтения, .rdata
:
lea r8, Src ; "Hello,World!"
В 32-битный регистр EDX
, также предназначенный для передачи параметров, записываем число байтов:
mov edx, 0Dh ; SizeInBytes
В регистр RCX
попадает указатель на область памяти, заданную выше, теперь можно предположить, что это целевой буфер для копирования данных:
mov rcx, rax ; Dst
Наше предположение оправдалось, следующей командой осуществляется вызов функции для копирования массива символов:
call cs:__imp_strcpy_s
Прототип безопасной функции errno_t strcpy_s(char *dest rsize_t dest_size, const char *src);
, где rsize_t
попросту является синонимом size_t
, не позволяет определить порядок занесения аргументов, однако поскольку все библиотечные С‑функции следуют соглашению cdecl, то аргументы заносятся справа налево. Из этого следует, что исходный код выглядит так: strcpy_s(&buff[0], 13,"Hello,World!");
.
Но, может быть, программист использовал преобразование, скажем, в stdcall? Крайне маловероятно: для этого пришлось бы перекомпилировать и саму strcpy_s
— иначе откуда бы она узнала, что порядок занесения аргументов изменился? Хотя обычно стандартные библиотеки поставляются с исходными текстами, их перекомпиляцией практически никто и никогда не занимается.
Заносим в локальную переменную константу 0x777
. Это явно константа, а не указатель, так как у Windows в этой области памяти не могут храниться никакие пользовательские данные:
mov [rsp+78h+var_24], 777h
Готовим параметры для выполнения циклической операции, размещая их в регистрах:
lea rax, [rsp+78h+var_58] ; Указатель на начало области памяти приемника
lea rcx, [rsp+78h+Dst] ; Указатель на начало области памяти источника
mov rdi, rax ; Приемник
mov rsi, rcx ; Источник
; Теперь RSI и RDI содержат, соответственно, адреса источника и приемника
mov ecx, 18h
В последней строке предыдущего листинга в регистр ECX
, занимающий нижние 32 бита регистра RCX
, помещаем количество байтов для копирования.
Вот она, передача строки по значению, другими словами — передача цепочки байтов:
rep movsb
Внимательно рассмотрим действие этой команды. MOVSB
копирует один байт, она относится к семейству команд передачи данных MOVS
, где заключительная буква определяет размер данных: B
— байт, W
— слово, D
— двойное слово и так далее. Для задания параметров команды на платформе x64 используются регистры RSI
и RDI
, в первый помещается адрес источника в сегменте данных, во второй — адрес приемника в дополнительном сегменте. Определение параметров хорошо прослеживается в предыдущем листинге.
Чтобы повторить действие команды MOVSB
, перед ней указывается префикс REP
. Кроме того, чтобы процессор знал, сколько раз надо повторить выполнение команды, перед ее вызовом необходимо поместить это значение в регистр RCX
(или ECX
). Таким образом, после выполнения команды MOVSB
значение в регистре уменьшается на единицу. И цикл выполнения команды продолжается, пока в RCX
(ECX
) не появится ноль. Строчкой выше в нашем дизассемблированном листинге как раз и осуществляется эта операция: mov ecx, 18h
. Переведем число в десятичную форму, получим 24 повторения.
INFO
Наверняка ты заметил, что на старших моделях процессора x86 программист может обращаться только к нижней половине регистров младшей модели. Таким образом, на 64-битном процессоре мы можем обращаться только к нижней половине 32-битных регистров. Но так было не всегда, на 16-битных Intel-совместимых процессорах программист мог обращаться также к старшей половине 8-битных регистров. С выходом 80386 Intel подзабила на это, запретив обращаться к старшей половине регистров.
Из этого можно сделать вывод, что программа копирует 24 байта. Давай разбираться, почему байтов 24, когда несколькими строками выше для копирования строки нам хватило только 13 байт? Как обычно, не подсматривая исходник, займем позицию хакера.
После инициализации стека значение регистра RSP
указывает на вершину стека, которая находится по самому младшему адресу, тогда как дно располагается по самому старшему адресу, поскольку стек растет сверху вниз. Указатель на начало целевого буфера памяти выглядит так: [rsp+78h+var_58]
. Взглянув в начало листинга рассматриваемой функции, выясним, что «динамическая переменная» var_58
равна -58h
. «Динамическая переменная» в том смысле, что значение представляет собой не переменную, а только смещение относительно вершины стека. Тогда как значение Dst
из выражения [rsp+78h+Dst]
, указывающее на буфер памяти источника, равно -38h
.
Для упрощения вычислений примем RSP = 0
: адрес источника [78h – 38h] = 40h
, адрес приемника — [78h – 58h] = 20h
. Все равно картина не складывается. Обрати внимание на строчку mov [rsp+78h+var_24], 777h
. Адрес переменной равен 54h
. Это уже нам о чем‑то говорит. Тип int
занимает 4 байта — 54h + 4h
. Отсюда имеем: 54h – 40h = 14h
; 14h + 4h = 18h = 24
в десятичной системе. Таким образом, нам удалось воссоздать структуру, которую задумал программист при написании своего приложения.
struct XT {
char s0[20];
int x;
};
Сначала следует 20 однобайтовых символов типа char, а затем четырехбайтовый int. Далее в RDX
помещаем указатель на буфер, куда на предыдущем шаге была скопирована структура. Поскольку RDX
используется для передачи аргументов, отметим про себя этот момент.
lea rdx, [rsp+78h+var_58]
movsd xmm0, cs:__real@401aa3d70a3d70a4 ; a
Заносим в XMM0
значение константы __real@401aa3d70a3d70a4
. Прокрутим листинг в дизассемблере, чтобы увидеть, чему равно ее значение:
; .rdata:0000000140002270 ; long double DOUBLE_6_66
.rdata:0000000140002270 __real@401aa3d70a3d70a4 dq 6.66
Как мы помним, на платформе x64 при передаче первых четырех значений с плавающей запятой используются первые четыре регистра XMM0 — XMM3
из процессорного расширения SSE.
call MyFunc(double,XT)
IDA правильно распознала прототип вызываемой функции. Причем в обратном порядке: сначала в стек была скопирована структура, затем в регистр XMM0
— значение с плавающей запятой. Таким образом, параметры передаются одновременно и в регистре процессора, и в стеке.
; Убираем за собой, деинициализируем стек
xor eax, eax
mov rcx, [rsp+78h+var_20] ; Значение вершины стека из области памяти
; помещаем в регистр RCX
xor rcx, rsp ; StackCookie
call __security_check_cookie
add rsp, 68h
; Извлекаем из стека значения ранее сохраненных регистров
pop rdi
pop rsi
retn
EMBARCADERO C++BUILDER
Теперь посмотрим, какой код сгенерирует компилятор Embarcadero C++Builder:
main proc near
var_44 = dword ptr -44h
var_40 = qword ptr -40h
var_38 = qword ptr -38h
var_30 = qword ptr -30h
var_28 = qword ptr -28h
var_20 = qword ptr -20h
var_14 = dword ptr -14h
var_10 = qword ptr -10h
var_8 = dword ptr -8
var_4 = dword ptr -4
sub rsp, 68h ; Инициализируем стек
; Заполняем регистры
mov eax, 0Dh ; Число 13
mov r8d, eax ; Два 32-битных регистра
lea r9, aHelloWorld ; "Hello,World!"
lea r10, [rsp+68h+var_28] ; Указатель на пустую область памяти
mov [rsp+68h+var_4], 0
mov [rsp+68h+var_8], ecx
mov [rsp+68h+var_10], rdx
Готовим регистры для передачи параметров функции:
mov rcx, r10 ; Указатель на целевую область памяти для копирования
mov rdx, r8 ; Src — число 13 (символов для копирования)
mov r8, r9 ; "Hello,World!"
Теперь вызываем функцию, копирующую строку, используя заданные ранее параметры:
call strcpy_s
movsd xmm0, cs:qword_44A000
В строке выше помещаем в регистр XMM0
двойное слово из константы — число с плавающей запятой. Далее помещаем в RDX
указатель на начало пустого буфера:
lea rdx, [rsp+68h+var_40]
mov [rsp+68h+var_14], 777h
Выше в переменную помещаем константу 0x777
. Это важный момент, обратим на него внимание. Далее в регистр RCX
помещается скопированная строка, она находится аккурат перед числом: -28h + 14h = -14h
, далее, вплоть до вызова функции, эта область памяти не перезаписывается:
mov rcx, [rsp+68h+var_28]
mov [rsp+68h+var_40], rcx
mov rcx, [rsp+68h+var_20]
mov [rsp+68h+var_38], rcx
mov rcx, [rsp+50h]
Здесь мы видим новое переупорядочивание данных в памяти: сначала в стек копируется строка, состоящая из 14h байт:
mov [rsp+68h+var_30], rcx
Затем значение типа int — 4h байта:
mov [rsp+68h+var_44], eax
call MyFunc(double,XT)
После вызова функции нет очистки стека. Это последняя вызываемая функция, и очистки стека не требуется — C++Builder ее и не выполняет...
Какой странный способ обнуления регистра EAX
! Visual C++ в этом месте пошел проторенной дорогой: xor eax, eax
, что в разы быстрее.
mov [rsp+68h+var_4], 0
mov eax, [rsp+68h+var_4]
Обнуление EAX
нужно, чтобы функция вернула 0, даже если она фактически ничего не возвращает. Восстанавливаем RSP
— вот почему стек не очищался после вызова последней функции!
add rsp, 68h
retn
main endp
Обрати внимание: по умолчанию Visual C++ передает аргументы справа налево:
...
lea rdx, [rsp+78h+var_58]
movsd xmm0, cs:__real@401aa3d70a3d70a4
...
В то же время C++Builder — слева направо:
...
movsd xmm0, cs:qword_44A000
lea rdx, [rsp+68h+var_40]
...
Среди стандартных типов вызова нет такого, который, передавая аргументы слева направо, поручал бы очистку стека вызывающей функции! Выходит, C++Builder использует свой собственный, ни с чем не совместимый тип вызова!
Второе отличие, которое бросается в глаза, — это отсутствие непосредственного копирования строки. Однако после копирования строки с помощью функции strcpy_s
в начале программы мы видим следующее:
...
lea r9, aHelloWorld ; "Hello,World!"
lea r10, [rsp+68h+var_28] ; Указатель на пустую область памяти
...
mov rcx, r10 ; Указатель на целевую область памяти для копирования
mov rdx, r8 ; Src — 13
mov r8, r9 ; "Hello,World!"
call strcpy_s ; Копируем строку
...
Вместо второго копирования здесь присутствует запутанная манипуляция адресами. В остальном же оба дизассемблированных листинга похожи, но в них ощущается характерный почерк компилятора.
ЗАКЛЮЧЕНИЕ
В сегодняшней статье мы только начали разбираться в вопросах идентификации аргументов функций. Далее нас ждет много интересного из жизни программ. Например, в дальнейшем мы рассмотрим адресацию аргументов в стеке.