Содержание статьи
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен 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...
— изменяемые, тогда как RBX
, RBP
, RDI
, RSI
, R12...
— неизменяемые. Что это значит? Это свойство было добавлено в архитектуру x64, оно означает, что значения первых могут быть изменены непосредственно в вызываемой функции, тогда как значения вторых должны быть сохранены в памяти в начале вызываемой функции, а в ее конце, перед возвращением, — восстановлены.
Цели и задачи
При исследовании функции перед нами стоят следующие задачи: определить, какое соглашение используется для вызова, подсчитать количество аргументов, передаваемых функции (и/или используемых функцией), и, наконец, выяснить тип и назначение самих аргументов. Начнем?
Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция, мы имеем дело c cdecl, в противном случае это либо stdcall, либо PASCAL. Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI), и stdcall, поэтому неопределенность по‑прежнему остается.
Впрочем, порядок передачи аргументов ничего не меняет: имея в наличии и вызывающую, и вызываемую функции, между передаваемыми и принимаемыми аргументами всегда можно установить взаимную однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен, см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему.
Другое дело — библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов!
Определение количества и типа передачи аргументов
Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а также неявно через глобальные переменные.
Если бы стек был задействован только для передачи аргументов, подсчитать их количество было бы относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию «заталкивания» PUSH
, не торопись идентифицировать ее как аргумент. Узнать количество байтов, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байтов, выталкиваемых из стека после завершения функции!
Если функция следует соглашению stdcall (или PASCAL), она наверняка очищает стек командой RET
, где n
и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция ADD
, где n
— искомое значение в байтах, но возможны и вариации: отложенная очистка стека или выталкивание аргументов в какой‑нибудь свободный регистр. Впрочем, отложим головоломки оптимизации на потом, а пока ограничимся лишь кругом неоптимизирующих компиляторов.
Логично предположить, что количество занесенных в стек байтов равно количеству выталкиваемых, иначе после завершения функции стек окажется несбалансированным и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда следует: количество аргументов равно количеству переданных байтов, деленному на размер машинного слова. Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам (двойное слово), в 64-разрядном режиме машинное слово — это учетверенное слово (восемь байтов).
Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип int, отъедающий только половину. Или символьную строку, переданную не по ссылке, а по непосредственному значению: она «скушает» столько байтов, сколько захочет. К тому же строка может засылаться в стек (как и структура данных, массив, объект) не командой PUSH
, а с помощью MOVS
! Кстати, наличие MOVS
— явное свидетельство передачи аргумента по значению.
Если я успел окончательно тебя запутать, то попробуем разложить по полочкам тот кавардак, что образовался в твоей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байтов определяется весьма неуверенно. С типом передачи полный мрак. Позже мы к этому еще вернемся, а пока вот пример. PUSH
— элемент 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 nearvar_58 = byte ptr -58hDst = byte ptr -38hvar_24 = dword ptr -24hvar_20 = qword ptr -20h; Инициализируем стекpush rsipush rdi
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»