То, что пугает зверя, не пугает человека.
Фрэнк Херберт "Ловец душ"
Идентификация аргументов функций – ключевое звено в исследовании дизассемблерных программ, поэтому, приготовьтесь, что эта глава будет длинной и, возможно, скучной, но ничего не поделаешь – хакерство требует жертв!
Существует три способа передачи аргументов функции: через стек, через регистры и комбинированный
(через стек и регистры одновременно). К этому списку вплотную примыкает и неявная передача аргументов через глобальные переменные, описание которой вынесено в отдельную главу "Идентификация глобальных переменных".
Сами же аргументы могут передаваться либо по значению, либо по ссылке. В первом случае функции передается копия
соответствующей переменной, а во втором – указатель
на саму переменную.
::соглашения о передаче параметров. Для успешной совместной работы вызывающая функция должна не только знать прототип вызываемой, но и "договориться" с ней о способе передачи аргументов: по ссылке или значению, через регистры или через стек? Если через регистры – оговорить какой аргумент в какой регистр помещен, а если через стек – определить порядок занесения аргументов и выбрать "ответственного" за очистку стека от аргументов после завершения вызываемой функции.
Неоднозначность механизма передачи аргументов – одна из причин несовместимости различных компиляторов. Казалось, почему бы ни заставить всех производителей компиляторов придерживаться какой-то одной схемы? Увы, это решение принесет больше проблем, чем решит.
Каждый механизм имеет свои достоинства и недостатки и, что еще хуже, тесно связан с самим языком. В частности, "Сишные" вольности в отношении соблюдения прототипов функцией возможны именно потому, что аргументы из стека выталкивает не вызываемая, а вызывающая функция, которая наверняка "помнит", что она передавала. Например, функции main передаются два аргумента – количество ключей командной строки и указатель на содержащий их массив.
Однако если программа не работает с командной строкой (или получает ключ каким-то иным путем), прототип main может быть объявлен и так: main().
На Паскале бы подобная выходка привела бы либо к ошибке компиляции, либо к краху программы, т.к. в нем стек очищает непосредственно вызываемая функция и, если она этого не сделает (или сделает неправильно, вытолкнув не то же самое количество машинных слов, которое ей было передано), стек окажется не сбалансированным и все рухнет. (Точнее, у материнской функции "слетит" вся адресация локальных переменных, а вместо адреса возврата в стеке окажется, что глюк на душу положит).
Минусом "Сишного" решения является незначительное увеличении размера генерируемого кода, ведь после каждого вызова функции приходится вставлять машинную команду (и порой не одну) для выталкивания аргументов из стека, а у Паскаля эта команда внесена непосредственно в саму функцию и потому встречается в программе один единственный раз.
Не найдя "золотой середины", разработчики компиляторов решили использовать все возможные механизмы передачи данных, а, чтобы справится с проблемой совместимости, стандартизировали каждый из механизмов, введя ряд соглашений.
Си-соглашение (обозначаемое __cdecl) предписывает засылать аргументы в стек справа налево в порядке их объявления, а очистку стека возлагает на плечи вызывающей функции. Имена функций, следующих Си-соглашению, предваряются символом прочерка "_", автоматически вставляемого компилятором. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.
Паскаль-соглашение
(обозначаемое PASCAL) { >>> сноска В настоящее время ключевое слово PASCAL считается устаревшим и выходит из употребления, вместо него можно использовать аналогичное соглашение WINAPI} предписывает засылать аргументы в стек слева направо в порядке их объявления, и возлагает очистку стека на саму вызывающую функцию.
Стандартное соглашение (обозначаемое __stdcall) является гибридом Си- и Паскаль- соглашений.
Аргументы засылаются в стек справа налево, но очищает стек сама вызываемая функция. Имена функций, следующих стандартному соглашению, предваряются символом прочерка "_", а заканчиваются суффиксом "@", за которым следует количество байт передаваемых функции. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.
Соглашения быстрого вызова: Предписывает передавать аргументы через регистры. Компиляторы от Microsoft и Borland поддерживают ключевое слово __fastcall, но интерпретируют его по-разному, а WATCOM С++ вообще не понимает ключевого слова __fastcall, но имеет в "арсенале" своего лексикона специальную прагму "aux", позволяющую вручную выбрать регистры для передачи аргументов (подробнее см. "соглашения о быстрых вызовах – fastcall"). Имена функций, следующих соглашению __fastcall, предваряются символом "@", автоматически вставляемым компилятором.
Соглашение по умолчанию: Если явное объявление типа вызова отсутствует, компилятор обычно использует собственные соглашения, выбирая их по своему усмотрению. Наибольшему влиянию подвергается указатель this, - большинство компиляторов при вызове по умолчанию передают его через регистр. У Microsoft это – ECX, у Borland – EAX, у WATCOM – либо EAX, либо EDX, либо и то, и другое разом. Остальные аргументы так же могут передаться через регистры, если оптимизатор посчитает, что так будет лучше. Механизм передачи и логика выборки аргументов у всех разная и наперед непредсказуемая, - разбирайтесь по ситуации.
::цели и задачи.
При исследовании функции перед исследователем стоят следующее задачи: определить, какое соглашение используется для вызова; подсчитать количество аргументов, передаваемых функции (и/или используемых функцией); наконец, выяснить тип и назначение самих аргументов. Начнем?
Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция - мы имеем c cdecl, в противном случае – либо с stdcall, либо с PASCAL.
Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI) и stdcall, поэтому, неопределенность по-прежнему остается. Впрочем, порядок передачи аргументов ничего не меняет – имея в наличии и вызывающую, и вызываемую функцию между передаваемыми и принимаемыми аргументами всегда можно установить взаимно однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен - см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему.
Другое дело – библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов!
::определение количества и типа передачи аргументов. Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а так же – неявно через глобальные переменные.
Если бы стек использовался только для передачи аргументов, подсчитать их количество было относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию заталкивания PUSH, не торопитесь идентифицировать ее как аргумент. Узнать количество байт, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байт, выталкиваемых из стека после завершения функции!
Если функция следует соглашению stdcall (или PASCAL) она наверняка очищает стек командой RET n, где n и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция "ADD ESP,n" – где n искомое значение в байтах, но возможны и вариации – отложенная очистка стека
или выталкивание аргументов в какой-нибудь свободный регистр (подробнее об этом рассказано в главе "Коварства оптимизирующих компиляторов"). Впрочем, отложим головоломки оптимизации на потом, пока ограничившись лишь кругом не оптимизирующих компиляторов.
Логично предположить, что количество занесенных в стек байт равно количеству выталкиваемых – иначе после завершения функции стек окажется несбалансированным, и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда: количество аргументов равно количеству переданных байт, деленному на размер машинного слова { >>> сноска Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам} Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип double, отъедающий восемь байт, или символьную строку, переданную не по ссылке, а по непосредственному значению, - она "скушает" столько байт, сколько захочет… К тому же засылаться в стек строка (как и структура данных, массив, объект) может не командой PUSH, а с помощью MOVS! (Кстати, наличие MOVS – явное свидетельство передачи аргумента по значению)
Если я не успел окончательно вас запутать, то попробуем разложить по полочкам тот кавардак, что образовался в нашей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байт определяется весьма неуверенно. С типом передачи полный мрак. Позже (см. "Идентификация констант и смещений") мы к этому еще вернемся, а пока вот пример: PUSH 0x40404040/CALL MyFuct: 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]);
}
main()
{
struct XT xt;
strcpy(&xt.s0[0],"Hello,World!");
xt.x=0x777;
MyFunc(6.66,xt);
}
Листинг 56 Демонстрация механизма передачи аргументов
Результат его компиляции компилятором Microsoft Visual C++ с настройками по умолчанию выглядит так:
main proc near ; CODE XREF: start+AFp
var_18 = byte ptr -18h
var_4 = dword ptr -4
push ebp
mov ebp, esp
sub esp, 18h
; Первый PUSH явно относится к прологу функции, а не к передаваемым аргументам
push esi
push edi
; Отсутствие явной инициализации регистров говорит о том, что, скорее всего,
; они просто сохраняются в стеке, а не передаются как аргументы,
; однако если данной функции аргументы передавались не только через стек,
; но и через регистры ESI и EDI, то их засылка в стек вполне может
; преследовать цель передачи аргументов следующей функции
push offset aHelloWorld ; "Hello,World!"
; Ага, а вот здесь явно имеет место передача аргумента – указателя на строку
; (строго говоря, предположительно
имеет место, - см. "Идентификация констант")
; Хотя теоретически возможно временное сохранение константы в стеке для ее
; последующего выталкивания в какой-нибудь регистр, или же непосредственному
; обращению к стеку, ни один из известных мне компиляторов не способен на такие
; хитрости и засылка константы в стек всегда является передаваемым аргументом
lea eax, [ebp+var_18]
; в EAX заносится указатель на локальный буфер
push eax
; EAX
( указатель на локальный буфер) сохраняется в стеке.
; Поскольку, ряд аргументов непрерывен, то после распознания первого аргумента
; можно не сомневаться, что все последующие заносы чего бы то ни было в стек –
; так же аргументы
call strcpy
; Прототип функции strcpy(char
*, char
*) не позволяет определить порядок
; занесения аргументов, однако, поскольку все библиотечные Си-функции
; следует соглашению cdecl, то аргументы заносятся справа налево
; и исходный код выглядел так: strcpy(&buff[0],"Hello,World!")
; Но, может быть, программист использовал преобразование, скажем, в stdcall?
; Крайне маловероятно, – для этого пришлось бы перекомпилировать и саму
; strcpy
– иначе откуда она бы узнала, что порядок занесения аргументов
; изменился? Хотя обычно стандартные библиотеки поставляются с исходными
; текстами их перекомпиляцией практически никто и никогда не занимается
add esp, 8
; Выталкиваем 8 байт из стека. Из этого мы заключаем, что функции передавалось
; два машинных слова аргументов и, следовательно, PUSH ESI
и PUSH EDI
не были
; аргументами функции!
mov [ebp+var_4], 777h
; Заносим в локальную переменную константу 0x777. Это явно константа, а не
; указатель, т.к. у Windows в этой области памяти не могут храниться никакие
; пользовательские данные
sub esp, 18h
; Резервирование памяти для временной переменной. Временные переменные
; в частности создаются при передаче аргументов по значению, поэтому,
; будем готовы к тому, что следующий "товарищ" – аргумент
; (см. "Идентификация регистровых и временных переменных")
mov ecx, 6
; Заносим в ECX константу 0х6. Пока еще не известно зачем.
lea esi, [ebp+var_18]
; Загружаем в ESI указатель на локальный буффер, содержащий скопированную
; строку "Hello, World!"
mov edi, esp
; Копируем в EDI указатель на вершину стека
repe movsd
; вот она – передача строки по значению.
Строка целиком копируется в стек,
; отъедая от него 6*4 байт.
; (6 – значение счетчика ECX, а 4 – размер двойного слова – movsD)
; следовательно, этот аргумент занимает 20 (0x14) байт стекового пространства –
; эта цифра нам пригодится при определении количества аргументов по количеству
; выталкиваемых байт.
; В стек копируются данные с [ebp+var_18], до [ebp+var_18-0x14], т.е.
; с var_18 до var_4. Но ведь в var_4 содержится константа 0x777!
; следовательно, она будет передана функции вместе со строкой.
; Это позволяет нам воссоздать исходную структуру:
; struct x{
; char s0[20]
; int x
; }
; да, функции, выходит, передается структура, а не одна строка!
push 401AA3D7h
push 0A3D70A4h
; Заносим в стек еще два аргумента. Впрочем, почему именно два?
; Это вполне может быть и один аргумент типа int64 или double
; Определить – какой именно по коду вызывающей функции не представляется
; возможным
call MyFunc
; Вызов MyFunc. Прототип функции установить, увы, не удается... Ясно только,
; что первый (слева? справа?) аргумент – структура, а за ним идут либо два int
; либо один int64 или double
; Уточнить ситуацию позволяет анализ вызываемой функции, но мы это отложим
; на потом, - до того как изучим адресацию аргументов в стеке
; Пока же придется прибывать в полной неопределенности
add esp, 20h
; выталкиваем 0x20 байт. Поскольку, 20 байт (0x14) приходится на структуру
; и 8 байт – на два следующих аргумента, получаем 0x14+0x8=0x20, что
; и требовалось доказать.
pop edi
pop esi
mov esp, ebp
pop ebp
retn
sub_401022 endp
aHelloWorld db 'Hello,World!',0 ; DATA XREF: sub_401022+8o
align 4
Листинг 57
Результат компиляции компилятором Borland C++ будет несколько иным и довольно поучительным. Рассмотрим и его:
_main proc near ; DATA XREF: DATA:00407044o
var_18 = byte ptr -18h
var_4 = dword ptr -4
push ebp
mov ebp, esp
add esp, 0FFFFFFE8h
; Ага! Это сложение со знаком минус. Жмем в IDA
<-> и получаем ADD ESP,-18h
push esi
push edi
; Пока все идет как в предыдущем случае
mov esi, offset aHelloWorld ; "Hello,World!"
; А вот тут начинаются различия! Вызов strcpy
как корова языком слизала –
; нету его и все! Причем, компилятор даже не развернул функцию,
; подставляя ее на место вызова, а просто исключил сам вызов!
lea edi, [ebp+var_18]
; Заносим в EDI указатель на локальный буфер
mov eax, edi
; Заносим тот же самый указатель в EAX
mov ecx, 3
repe movsd
movsb
; Обратите внимание: копируется 4*3+1=13 байт. Тринадцать, а вовсе не
; двадцать, как следует из объявления структуры. Это компилятор так
; оптимизировал код, копируя в буфер лишь саму строку, и игнорируя ее
; не инициализированный "хвост"
mov [ebp+var_4], 777h
; Заносим в локальную переменную константу 0x777
push 401AA3D7h
push 0A3D70A4h
; Аналогично. Мы не может определить: чем являются эти два числа –
; одним или двумя аргументами.
lea ecx, [ebp+var_18]
; Заносим в ECX указатель на начало строки
mov edx, 5
; Заносим в EDX константу 5 (пока не понятно зачем)
loc_4010D3: ; CODE XREF: _main+37j
push dword ptr [ecx+edx*4]
; Ой, что это за кошмарный код? Давайте подумаем, начав раскручивать его
; с самого конца. Прежде всего – чему равно ECX+EDX*4? ECX – указатель на
; буфер и с этим все ясно, а вот EDX*4 == 5*4 == 20.
; Ага, значит, мы получаем указатель не на начало строки, а на конец, вернее
; даже не на конец, а на переменную ebp+var_4 (0x18-0x14=0x4).
; Подумаем – если это указатель на саму var_4, то зачем его вычислять таким
; закрученным макаром? Вероятнее всего мы имеем дело со структурой...
; Далее – смотрите, команда push засылает в стек двойное слово,
; хранящееся по этому указателю
dec edx
; Уменьшаем EDX... Вы уже почувствовали, что мы имеем дело с циклом?
jns short loc_4010D3
; вот – этот переход, срабатывающий пока EDX
не отрицательное число,
; подтверждает наше предположение о цикле.
; Да, такой вот извращенной конструкций Borland
передает аргумент - структуру
; функции по значению!
call MyFunc
; Вызов функции... смотрите – нет очистки стека! Да, это последняя вызываемая
; функция и очистки стека не требуется – Borland
ее и не выполняет...
xor eax, eax
; Обнуление результата, возращенного функцией. Borland
так поступает с void
; функциями – они у него всегда возвращают ноль,
; точнее: не они возвращают, а помещенный за их вызовом код, обнуления EAX
pop edi
pop esi
; Восстанавливаем ранее сохраненные регистры EDI
и ESI
mov esp, ebp
; восстанавливаем ESI, - вот почему стек не очищался после вызова последней
; функции!
pop ebp
retn
_main endp
Листинг 58
Обратите внимание – по умолчанию Microsoft C++ передает аргументы справа налево, а Borland C++ - слева направо! Среди стандартных типов вызов нет такого, который, передавая аргументы слева направо, поручал бы очистку стека вызывающей функции! Выходит, что Borland C++ использует свой собственный, ни с чем не совместимый тип вызова!
::адресация аргументов в стеке. Базовая концепция стека включает лишь две операции – занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу – это что-то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы – если нужен, скажем, третий по счету элемент, почему бы ни вытащить из стека напрямую, не снимая первые два? Стек это не только "стопка", как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажите класть очередной элемент?), и размер элементов, мы сможем вычислить смещению любого из элементов, после чего не составит никакого труда его прочитать.
Попутно отметим один из недостатков стека – как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например, двойные слова. Если же требуется занести один байт (скажем, аргумент типа char), то приходится расширять его до двойного слова и заносить его целиком. Аналогично, если аргумент занимает четыре слова (double, int64) на его передачу расходуется два стековых элемента!
Помимо передачи аргументов стек используется и для сохранения адреса возврата из функции, что требует в зависимости от типа вызова функции (ближнего или дальнего) от одного до двух элементов. Ближний (near) вызов действует в рамках одного сегмента, - в этом случае достаточно сохранить лишь смещение команды, следующей за инструкций CALL. Если же вызывающая функция находится в одном сегменте, а вызываемая в другом, то помимо смещения приходится запоминать и сам сегмент, чтобы знать в какое место вернуться. Поскольку адрес возврата заносится после аргументов, то относительно вершины стека аргументы оказываются "за" ним и их смещение варьируется в зависимости от того: один элемент занимает адрес возврата или два. К счастью, плоская модель памяти Windows NT\9x позволяет забыть о моделях памяти как о страшном сне и всюду использовать только ближние вызовы.
Не оптимизирующие компиляторы используют для адресации аргументов специальный регистр (как правило, EBP), копируя в него значение регистра-указателя вершины стека в самом начале функции. Поскольку стек растет снизу вверх, т.е. от старших адресов к младшим, смещение всех аргументов (включая адрес возврата) положительны, а смещение N-ого по счету аргумента вычисляется по следующей формуле:
arg_offset = N*size_element+size_return_address
где N
– номер аргумента, считая от вершины стека, начиная с нуля, size_element – размер одного элемента стека, в общем случае равный разрядности сегмента (под Windows NT\9x – четыре байта), size_return_address – размер в байтах, занимаемый адресом возврата (под Windows NT\9x – обычно четыре байта).
Часто приходится решать и обратную задачу: зная смещение элемента, определять к какому по счету аргументу происходит обращение. В этом нам поможет следующая формула, элементарно выводящаяся из предыдущей:
Поскольку, перед копированием в EBP текущего значения ESP, старое значение EBP приходится сохранять в том же самом стеке, в приведенную формулу приходится вносить поправку, добавляя к размеру адреса возврата еще и размер регистра EBP (BP в 16-разрядном режиме, который все еще жив на сегодняшний день).
С точки зрения хакера главное достоинства такой адресации аргументов в том, что, увидев где-то в середине кода инструкцию типа "MOV EAX,[EBP+0x10]", можно мгновенно вычислить к какому именно аргументу происходит обращение. Однако оптимизирующие компиляторы для экономии регистра EBP адресуют аргументы непосредственно через ESP. Разница принципиальна! Значение ESP не остается постоянным на протяжении выполнения функции и изменяется всякий раз при занесении и снятии данных из стека, следовательно, не остается постоянным и смещение аргументов относительно ESP. Теперь, чтобы определить к какому именно аргументу происходит обращение, необходимо знать: чему равен ESP в данной точке программы, а для выяснения этого все его изменения приходится отслеживать от самого начала функции! Подробнее о такой "хитрой" адресации мы поговорим потом (см. "Идентификация локальных стековых переменных"), а для начала вернемся к предыдущему примеру (надо ж его "добить") и разберем вызываемую функцию:
MyFunc proc near ; CODE XREF: main+39p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = byte ptr 10h
arg_1C = dword ptr 24h
; IDA
распознала четыре аргумента, передаваемых функции. Однако,
; не стоит безоговорочно этому доверять, – если один аргумент (например, int64)
; передается в нескольких машинных словах, то IDA
ошибочно примет его не за один,
; а за несколько аргументов!
; Поэтому, результат, полученный IDA, надо трактовать так: функции передается не менее
; четырех аргументов. Впрочем, и здесь не все гладко! Ведь никто не мешает вызываемой
; функции залезать в стек материнской так далеко, как она захочет! Может быть,
; нам не передавали никаких аргументов вовсе, а мы самовольно полезли в стек и
; стянули что-то оттуда. Хотя это случается в основном вследствие программистских
; ошибок из-за путаницы с прототипами, считаться с такой возможностью необходимо.
; (Когда ни будь вы все равно с этим встретитесь, так что будьте готовы)
; Число, стоящее после 'arg', выражает смещение аргумента относительно начала
; кадра стека.
; Обратите внимание: сам кадр стека смещен на восемь байт относительно EBP
-
; четыре байта занимает сохраненный адрес возврата, и еще четыре уходят на сохранение
; регистра EBP.
push ebp
mov ebp, esp
lea eax, [ebp+arg_8]
; получение указателя на аргумент.
; Внимание: именно указателя на аргумент, а не изволение аргумента-указателя!
; Теперь разберемся – на какой именно аргумент мы получаем указатель.
; IDA
уже вычислила, что этот аргумент смещен на восемь байт относительно
; начала кадра стека. В оригинале выражение, заключенное в скобках выглядело
; как ebp+0x10 – так его и отображает большинство дизассемблеров. Не будь IDA
; такой умной, нам бы пришлось постоянно вручную отнимать по восемь байт от
; каждого такого адресного выражения (впрочем, с этим мы еще поупражняемся)
;
; Логично: на вершине то, что мы клали в стек в последнею очередь.
; Смотрим вызывающую функцию – что ж мы клали-то?
; (см. вариант, откомпилированный Microsoft Visual C++)
; Ага, последними были те два непонятные аргумента, а перед ними в стек
; засылалась структура, состоящая из строки и переменной типа int
; Таким образом, EBP+ARG_8 указывает на строку
push eax
; Засылаем в стек полученный указатель.
; Похоже, что он передается очередной функции.
mov ecx, [ebp+arg_1C]
; Заносим в ECX содержимое аргумента EBP+ARG_1C.
На что он указывает?
; Вспомним, что тип int находится в структуре по смещению 0x14 байт от начала,
; а ARG_8 – и есть ее начало. Тогда, 0x8+0x14 == 0x1C.
; Т.е. в ECX заносится значение переменной типа int, члена структуры
push ecx
; Заносим полученную переменную в стек, передавая ее по значению
; (по значению – потому что ECX хранит значение, а не указатель)
mov edx, [ebp+arg_4]
; Берем один их тех двух непонятных аргументов, занесенных последними в стек
push edx
; ...и, вновь заталкиваем в стек, передавая его очередной функции.
mov eax, [ebp+arg_0]
push eax
; Берем второй непонятный аргумент и пихаем его в стек.
push offset aFXS ; "%f,%x,%s\n"
call _printf
; Опа! Вызов printf с передачей строкой спецификаторов! Функция, printf,
; как известно, имеет переменное число аргументов, тип и количество которых
; как раз и задают спецификаторы.
; Вспомним, – сперва в стек мы заносили указатель на строку, и действительно,
; крайний правый спецификатор "%s" обозначает вывод строки.
; Затем в стек заносилась переменная типа int
и второй справа спецификатор
; есть %x – вывод целого в шестнадцатеричной форме.
; А вот затем... затем идет последний спецификатор %f, в то время как в стек
; заносились два аргумента.
; Заглянув в руководство программиста по Microsoft Visual C++, мы прочтем,
; что спецификатор %f выводит вещественное значение, которое в зависимости от
; типа может занимать и четыре байта (float), и восемь (double).
; В нашем случае оно явно занимает восемь байт, следовательно, это double
; Таким образом, мы восстановили прототип нашей функции, вот он:
; cdecl MyFunc(double a, struct B b)
; Тип вызова cdecl – т.е. стек вычищала вызывающая функция. Вот только, увы,
; подлинный порядок передачи аргументов восстановить невозможно. Вспомним,
; Borland C++ так же вычищал стек вызывающей функцией, но самвовольно изменил
; порядок передачи параметров.
; Кажется, если программа компилилась Borland C++, то мы просто изменяем
; порядк арументов на обратный – вот и все. Увы, это не так просто. Если имело
; место явное преобразование типа функции в cdecl, то Borland C++ без лишней
; самодеятельности поступил бы так, как ему велели и тогда бы обращение
; порядка аргументов дало бы неверный резлуьтат!
; Впрочем, подлинный порядок следования аргументов в прототипе функции
; не играет никакой роли. Важно лишь связать передаваемые и принимаемые
; аргументы, что мы и сделали.
; Обратите внимание: это стало возможно лишь при совместом анализе и вызываемой
; и вызывающей функуий! Анализ лишь одной из них ничего бы не дал!
; Примечание: никогда не следует безоговорочно полагаться на достоверность
; строки спецификаторов. Посколкьу, спецификаторы формируются вручную самим
; программистом, тут возможны ошибки, под час весьма трудноуловимые и дающие
; после компиляции чрезвычайно загадочный код!
; Подробнее об этом рассказывается в статье
; "неизвестная уявзимость ошибка printf", помещенный в главу "Приложения"
add esp, 14h
pop ebp
retn
MyFunc endp
Листинг 59
Так, кое-какие продвижения уже есть – мы уверенно восстановили прототип нашей первой функции. Но это только начало… Еще много миль предстоит пройти, прежде чем будет достигнут конец главы. Если вы устали – передохните. Тяпните пивка (колы), позвоните своей любимой девушке (а, что, у хакеров и любимые девушки есть?), словом, как хотите, но обеспечьте свежую голову. Мы приступаем к еще одной нудной, но важной теме – сравнительному анализу различных типов вызовов функций и их реализации в популярных компиляторах.
Начнем с изучения стандартного соглашения о вызове – stdcall. Рассмотрим следующий пример:
#include <stdio.h>
#include <string.h>
__stdcall MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}
main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 60 Демонстрация stdcall
Результат его компиляции Microsoft Visual C++ с настройками по умолчанию должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp
push offset aHelloWorld ; const char *
; Заносим в стек указатель на строку aHelloWorld.
; Заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что
; это – самый правый аргумент, передаваемый функции. Следовательно,
; перед нами вызов типа stdcall или cdecl, но не PASCAL.
; Обратите внимание – строка передается по ссылке, но не по знаниючению.
push 777h ; int
; Заносим в стек еще один аргумент - константу типа int.
; (IDA начиная с версии 4.17 автоматически определяет ее тип).
push 666h ; int
; Передаем функции последний, самый левый аргумент, – константу типа int
call MyFunc
; Обратите внимание – после вызова функции отсутствуют команды очистки стека
; от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул
; к отложенной очистке, то скорее всего, стек очищает сама вызываемая функция,
; значит, тип вызова – stdcall (что, собственно, и требовалось доказать)
push eax
; Передаем возвращенное функцией значение следующей функции как аргумент
push offset asc_406040 ; "%x\n"
call _printf
; ОК, эта следующая функция printf, и строка спецификаторов показывает,
; что переданный аргумент имеет тип int
add esp, 8
; Выталкивание восьми байт из стека – четыре приходятся на аргумент типа int
; остальные четыре – на указатель на строку спецификаторов
pop ebp
retn
main endp
; int __cdecl MyFunc(int,int,const char *)
MyFunc proc near ; CODE XREF: sub_40101D+12p
; С версии 4.17 IDA автоматически восстанавливает прототипы функций, но делает это
; не всегда правильно. В данном случае она допустила грубую ошибку – тип вызова
; никак не может иметь тип cdecl, т.к. стек вычищает вызываемая функция! Сдается, что
; вообще не предпринимает никаких попыток анализа типа вызова, а берет его из настроек
; распознанного компилятора по умолчанию.
; В общем, как бы там ни было, но с результатами работы IDA
следует обращаться
; очень осторожно.
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
push ebp
mov ebp, esp
push esi
; Это, как видно, сохранение регистра в стеке, а не передача его функции, т.к.
; регистр явным образом не инициализировался ни вызывающей, ни вызываемой
; функцией.
mov esi, [ebp+arg_0]
; Заносим в регистр ESI последней занесенный в стек аргумент
add esi, [ebp+arg_4]
; Складываем содержимое ESI с предпоследним занесенным в стек аргументом
mov eax, [ebp+arg_8]
; Заносим в в EAX пред- предпоследний аргумент и…
push eax ; const char *
; …засылаем его в стек.
call _strlen
; Поскольку strlen ожидает указателя на строку, можно с уверенностью
; заключить, что пред- предпоследний аргумент – строка, переданная по ссылке.
add esp, 4
; Вычистка последнего аргумента из стека
add eax, esi
; Как мы помним, в ESI хранится сумма двух первых аргументов,
; а в EAX – возвращенная длина строки. Таким образом, функция суммирует
; два своих аргумента с длиной строки.
pop esi
pop ebp
retn 0Ch
; Стек чистит вызываемая функция, следовательно, тип вызова stdcall
или PASCAL.
; Будем считать, что это stdcall, тогда прототип функции выглядит так:
; int MyFunc(int a, int b, char *c)
;
; Порядок аргументов вытекает из того, что на вершине стека были две
; переменные типа int, а под ними строка. Поскольку на верху стека лежит
; всегда то, что заносилось в него в последнюю очередь, а по stdcall
; аргументы заносятся справа налево, мы получаем именно такой порядок
; следования аргументов
MyFunc endp
Листинг 61
А теперь рассмотрим, как происходит вызов cdecl функции. Изменим в предыдущем примере ключевое слово stdcall на cdecl:
#include <stdio.h>
#include <string.h>
__cdecl MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}
main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 62 Демонстрация cdecl
Результат компиляции должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp
push offset aHelloWorld ; const char *
push 777h ; int
push 666h ; int
; Передаем функции аргументы через стек
call MyFunc
add esp, 0Ch
; Смотрите: стек вычищает вызывающая функция. Значит, тип вызова cdecl,
; поскольку, все остальные предписывают вычищать стек вызываемой функции.
push eax
push offset asc_406040 ; "%x\n"
call _printf
add esp, 8
pop ebp
retn
main endp
; int __cdecl MyFunc(int,int,const char *)
; А вот сейчас IDA правильно определила тип вызова. Однако, как уже показывалось выше,
; она могла и ошибиться, поэтому полагаться на нее не стоит.
MyFunc proc near ; CODE XREF: main+12p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
; Поскольку, как мы уже выяснили, функция имеет тип cdecl, аргументы передаются
; справа налево и ее прототип выглядит так: MyFunc(int arg_0, int arg_4, char *arg_8)
push ebp
mov ebp, esp
push esi
; Сохраняем ESI в стеке
mov esi, [ebp+arg_0]
; Заносим в ESI аргумент arg_0 типа int
add esi, [ebp+arg_4]
; Складываем его с arg_4
mov eax, [ebp+arg_8]
; Заносим в EAX указатель на строку
push eax ; const char *
; Передаем его функции strlen через стек
call _strlen
add esp, 4
add eax, esi
; Добавляем к сумме arg_0 и arg_4 длину строки arg_8
pop esi
pop ebp
retn
MyFunc endp
Листинг 63
Прежде, чем перейти к вещам по настоящему серьезным, рассмотрим на закуску последний стандартный тип – PASCAL:
#include <stdio.h>
#include <string.h>
// Внимание! Microsoft Visual C++ уже не поддерживает тип вызова PASCAL
// вместо этого используйте аналогичный ему тип вызова WINAPI, определенный в файле
// <windows.h>.
#if defined(_MSC_VER)
#include <windows.h>
// включать windows.h только если мы компилируется Microsoft Visual C++
// для остальных компиляторов более эффективное решение – использование ключевого
// слова PASACAL, если они, конечно, его поддерживают. (Borland
поддерживает)
#endif
// Подобный примем программирования может и делает листинг менее читабельным,
// но зато позволяет компилировать его не только одним компилятором!
#if defined(_MSC_VER)
WINAPI
#else
__pascal
#endif
MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}
main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 64 Демонстрация вызова PASCAL
Результат компиляции Borland C++ должен выглядеть так:
; int __cdecl main(int argc,const char **argv,const char *envp)
_main proc near ; DATA XREF: DATA:00407044o
push ebp
mov ebp, esp
push 666h ; int
push 777h ; int
push offset aHelloWorld ; s
; Передаем функции аргументы. Заглянув в исходный текст, мы заметим, что
; аргументы передаются слева направо. Однако если исходных текстов нет,
; установить этот факт невозможно! К счастью, подлинный прототип функции
; не важен.
call MyFunc
; Функция не вычищает за собой стек! Если это не результат оптимизации –
; ее тип вызова либо PASCAL, либо stdcall. Ввиду того, что PASACAL уже вышел
; из употребления, будем считать, что имеем дело с stdcall
push eax
push offset unk_407074 ; format
call _printf
add esp, 8
xor eax, eax
pop ebp
retn
_main endp
; int __cdecl MyFunc(const char *s,int,int)
; Ага! IDA вновь дала неправильный результат! Тип вызова явно не cdecl!
; Однако, в остальном прототип функции верен, вернее, не то что бы он верен
; (на самом деле порядок аргументов обратный), но для использования – пригоден
MyFunc proc near ; CODE XREF: _main+12p
s = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+s]
; Заносим в EAX указатель на строку
push eax ; s
call _strlen
; Передаем его функции strlen
pop ecx
; Очищаем стек от одного аргумента, выталкивая его в неиспользуемый регистр
mov edx, [ebp+arg_8]
; Заносим в EDX аргумент arg_8 типа int
add edx, [ebp+arg_4]
; Складываем его с аргументом arg_4
add eax, edx
; Складываем сумму arg_8 и arg_4 с длиной строки
pop ebp
retn 0Ch
; Стек чистит вызываемая функция. Значит, ее тип PASCAL
или stdcall
MyFunc endp
Листинг 65
Как мы видим, идентификация базовых типов вызов и восстановление прототипов функции – занятие несложное. Единственное, что портит настроение – путаница с PASCAL и stdcall, но порядок занесения аргументов в стек не имеет никакого значения, разве что в особых случаях, один из которых перед вами:
#include <stdio.h>
#include <windows.h>
#include <winuser.h>
// CALLBACK процедура для приема сообщений от таймера
VOID CALLBACK TimerProc(
HWND hwnd, // handle of window 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);
}
main()
// Да, это консольное приложение, но оно так же может иметь цикл выборки сообщений
// и устанавливать таймер!
{
int a;
MSG msg;
// Устанавливаем таймер, передавая ему адрес процедуры TimerProc
SetTimer(0,0,1000,TimerProc);
// Цикл выборки сообщений. Когда надоест – жмем Ctrl-Break и прерываем его
while (GetMessage(&msg, (HWND) NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
Листинг 66 Пример, демонстрирующий тот случай, когда требуется точно отличать PASCAL от stdcall
Откомпилируем этот пример так: "cl pascal.callback.c USER32.lib" и посмотрим, что из этого получилось:
main proc near ; CODE XREF: start+AFp
; На сей раз IDA не определила прототип функции. Ну и ладно...
Msg = MSG ptr -20h
; IDA
распознала одну локальную переменную и даже восстановила ее тип, что радует
push ebp
mov ebp, esp
sub esp, 20h
push offset TimerProc ; lpTimerFunc
; Передаем указатель на функцию TimerProc
push 1000 ; uElapse
; Передаем время задержки таймера
push 0 ; nIDEvent
; В консольных приложениях аргумент nIDEvent
всегда игнорируется
push 0 ; hWnd
; Окон нет, передаем NULL
call ds:SetTimer
; Win32 API
функции вызываются по соглашению stdcall
– это дает возможность,
; зная их прототип,(а он описан в SDK) восстановить тип и назначение аргументов
; в данном случае исходный текст выглядел так:
; SetTimer(NULL, BULL, 1000, TimerProc);
loc_401051: ; CODE XREF: main+42j
push 0 ; wMsgFilterMax
; NULL – нет
фильтра
push 0 ; wMsgFilterMin
; NULL – нет
фильтра
push 0 ; hWnd
; NULL
– нет окон в консольном приложении
lea eax, [ebp+Msg]
; Получаем указатель на локальную переменную msg
-
; тип этой переменной определяется, кстати, только на основе прототипа
; функции GetMessageA
push eax ; lpMsg
; Передаем указатель на msg
call ds:GetMessageA
; Вызываем
функцию GetMessageA(&msg, NULL, NULL, NULL);
test eax, eax
jz short loc_40107B
; Проверка на получение WM_QUIT
lea ecx, [ebp+Msg]
; В ECX – указатель на заполненную структуру MSG…
push ecx ; lpMsg
; …передаем
его функции TranslateMessage
call ds:TranslateMessage
; Вызываем
функцию TranslateMessage(&msg);
lea edx, [ebp+Msg]
; В EDX – указатель на msg…
push edx ; lpMsg
; …передаем его функции DispatchMessageA
call ds:DispatchMessageA
; Вызов
функции DispatchMessageA
jmp short loc_401051
; Цикл
выборки сообщений
loc_40107B: ; CODE XREF: main+2Cj
; Выход
mov esp, ebp
pop ebp
retn
main endp
TimerProc proc near ; DATA XREF: main+6o
; Прототип TimerProc в следствие ее неявного вызова операционной системой
; не был автоматически восстановлен IDA, - этим придется заниматься нам
; Мы знаем, что TimerProc передается функции SetTimer.
; Заглянув в описание SetTimer (SDK
всегда должен быть под рукой!) мы найдем
; ее
прототип:
;
;VOID CALLBACK TimerProc(
; HWND hwnd, // handle of window for timer messages
; UINT uMsg, // WM_TIMER message
; UINT idEvent, // timer identifier
; DWORD dwTime // current system time
;)
;
; Остается разобраться с типом вызова. На сей раз он приниципиален, т.к. не имеея
; кода вызывающей функции (он расположен глубоко в недрах операционной системы),
; мы разберемся с типами аргументов только в том случае, если будет знать их
; порядок передачи.
; Выше уже говорилось, что все CALLBACK
функции следуют соглашению PASCAL.
; Не путайте CALLBACK-функции с Win32 API-функциями! Первые вызывает сама
; операционная система, а вторые – прикладная программа.
;
; ОК, тип вызова этой функции – PASCAL. Значит, аргументы заносятся слевно направо,
; а стек чистит вызываемая функция (убедитесь, что это действительно так).
arg_C = dword ptr 14h
; IDA
обнаружила только один аргумент, хотя, судя по прототипу, их передается четыре.
; Почему? Очень просто – функция использовала всего один аргумент, а к остальным и
; не обращалась. Вот IDA и не смогла их восстановить!
; Кстати, что это за аргумент? Смотрим: его смещение равно 0xC. А на вершине стека то,
; что в него заталкивалось в последнюю очередь. Внизу, соответственно, наоборот.
; Постой, постой, что за чертовщина?! Выходит, dwTime
был занесен в стек в первую
; очередь?! (Мы-то, имея исходный текст, знаем, что arg_C – наверняка dwTime).
; Но ведь соглашение PASCAL диктует противоположный порядок занесения аргументов!
; Что-то здесь не так... но ведь программа работает (запустите ее, чтобы проверить)
; А в SDK написано, что CALLBACK – аналог FAR PASACAL. С FAR-ом понятно, в Win9x\NT
; все вызовы ближние, но вот как объяснить инверсию засылки аргументов?!
; Сдаетесь?(Нет, не сдавайтесь, попытайтесь найти решение сами – иначе какой интерес?)
; Тогда загляните в <windef.h> и посмотрите, как там определен тип PASCAL
;
; #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-файлов могут приносить пользу)
; Кстати, это извращения с перекрытием типов создают большую проблему при подключении
; к Си-проекту модулей, написанных в среде, поддерживающей PASACAL-соглашения о вызове
; функций. Поскольку в Windows PASCAL никакой не PASCAL, а stdcall – ничего работать
; соответственно не будет! Правда, есть еще ключевое слово __pascal, которое не
; перекрывается, но и не поддерживается последними версиями Microsoft Visual C++.
; Выход состоит в использовании ассемблерных вставок или переходе на Borland C++
; он, как и многие другие компиляторы, соглашение PASACAL
до сих пор исправно
; поддерживает.
;
; Итак, мы выяснили, что аргументы CALLBACL-функциям передаются справа налево, но
; стек вычищает сама вызываемая функция, как и положено по stdcall
соглашению.
push ebp
mov ebp, esp
mov eax, [ebp+arg_C]
; заносим в EAX аргумент dwTime.
; Как мы получили его? Смотрим – перед ним в стеке лежат три аргумента
; каждый из которых размеров в 4 байта, тогда 4*3=0xC
xor edx, edx
; Обнуляем EDX
mov ecx, 5
; Присваиваем ECX значение 5
div ecx
; Делим dwTime (он в EAX) на 5
shl edx, 4
; В EDX – остаток от деления, циклическим сдвигом умножаем его на 0x10
; точнее, умножаем его на 24
push edx ; uType
; Передаем полученный результат функции MessageBeep.
; Заглянув в SDK, мы найдем, что MessageBeep принимает одну из констант:
; NB_OK, MB_ICONASTERISK, MB_ICONHAND и т.д., но там ничего не сказано о том,
; какое непосредственное значение каждое из них принимает.
; Зато сообщается, что MessageBeep описана в файле <WINUSER.h>
; Открываем его и ищем контекстным поиском MB_OK:
;
; #define MB_OK 0x00000000L
; #define MB_OKCANCEL 0x00000001L
; #define MB_ABORTRETRYIGNORE 0x00000002L
; #define MB_YESNOCANCEL 0x00000003L
; #define MB_YESNO 0x00000004L
; #define MB_RETRYCANCEL 0x00000005L
;
; #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, 0x0x10 – 0x40.
call ds:MessageBeep
; Бибикаем на все лады
mov eax, [ebp+arg_C]
; Заносим в EAX dwTime
xor edx, edx
; Обнуляем EDX
mov ecx, 3E8h
; В десятичном 0x3E8 равно 1000
div ecx
; Делим dwTime на 1000 – т.е. переводим миллисекунды в секунды и…
push eax
; …передаем его функции printf
push offset aD ; "\r:=%d"
call _printf
add esp, 8
; printf("\r:=%d")
pop ebp
retn 10h
; Выходя – гасите свет, т.е. чистите за собой стек!
TimerProc endp
Листинг 67
Важное замечание о типах, определенных в <WINDOWS.H>! Хотя об этом уже говорилось в комментариях к предыдущему листингу, повторение не будет лишним, хотя бы уже потому, что не все читатели вчитываются в разборы дизассемблерных текстов.
Итак, CALLBACK и WINAPI функции следуют соглашению о вызовах PASCAL, но сам PASACAL определен в <WINDEF.H> как stdcall (а на некоторых платформах и как cdecl). Таким образом, на платформе INTEL все Windows-функции следуют соглашению: аргументы заносятся справа налево, а стек вычищает вызываемая функция.
Давайте для знакомства в PASCAL-соглашением создадим простенькую PASCAL программу и дизассемблируем ее (это, не обозначает, что PASCAL-вызовы встречаются только в PASCAL-программах, но так будет справедливо):
USES WINCRT;
Procedure MyProc(a:Word; b:Byte; c:String);
begin
WriteLn(a+b,' ',c);
end;
BEGIN
MyProc($666,$77,'Hello,Sailor!');
END.
Листинг 68 Демонстрация PASCAL-вызова
Результат компиляции компилятором "Turbo Pascal for Windows" должен выглядеть так:
PROGRAM proc near
call INITTASK
; Вызов INITTASK из KRNL386.EXE для инициализации 16-разрядной задачи
call @__SystemInit$qv ; __SystemInit(void)
; Инициализация модуля SYSTEM
call @__WINCRTInit$qv
; __WINCRTInit(void)
; Инициализация модуля WinCRT
push bp
mov bp, sp
; Пролог функции в середине функции!
; Вот такой он, Turbo-PASCAL!
xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверка стека на переполнение
push 666h
; Обратите внимание – передача аргументов идет слева направо
push 77h ; 'w'
mov di, offset aHelloSailor ; "Hello,Sailor!"
; В DI – указатель на строку "Hello, Sailor"
push ds
push di
; Смотрите: передается не ближний (NEAR), а дальний (FAR) указатель –
; т.е. и сегмент, и смещение строки.
call MyProc
; Стек чистит вызываемая функция.
leave
; Эпилог функции – закрытие кадра стека.
xor ax, ax
call @Halt$q4Word ; Halt(Word)
; Конец программы!
PROGRAM endp
MyProc proc near ; CODE XREF: PROGRAM+23p
; IDA
не определила прототип функции. Что ж, сделаем это сами!
var_100 = byte ptr -100h
; Локальная переменная. Судя по тому, что она находится на 0x100 байт выше кадра
; стека, сдается, что это массив их 0x100 байт. Поскольку, максимальная длина строки
; в PASACAL как раз и равна 0xFF байтам. Похоже, это буфер, зарезервированный под
; строку.
arg_0 = dword ptr 4
arg_4 = byte ptr 8
arg_6 = word ptr 0Ah
; Функция принимает три аргумента
push bp
mov bp, sp
; Открываем кадр стека
mov ax, 100h
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверяем – если ли в стеке необходимые нам 100 байт для локальных переменных
sub sp, 100h
; Резервируем пространство под локальные переменные
les di, [bp+arg_0]
; получаем указатель на самый правый аргумент
push es
push di
; Смотрите – передаем дальний указатель на аргумент arg_0, причем его
; сегмент из стека даже не извлекался!
lea di, [bp+var_100]
; Получаем указатель на локальный буфер
push ss
; Заносим его сегмент в стек
push di
; Заносим смещение буфера в стек
push 0FFh
; Заносим макс. длину строки
call @$basg$qm6Stringt14Byte ; Store string
; Копируем строку в локальный буфер (значит, arg_0 – это строка).
; Правда, совершенно непонятно зачем. Неужто нельзя пользоваться ссылкой?
; Дурной-дурной этот Turbo-Pascal!
; Да что делать – в самом Паскале строки передаются по значению :-(
mov di, offset unk_1E18
; Получаем указатель на буфер вывода
; Тут надобно познакомимся с системой вывода Паскаля – она весьма разительно
; отличается от Си.
; Во-первых, левосторонний порядок засылки аргументов в стек не позволяет
; организовать поддержку процедур с переменным числом аргументов
; (во всяком случае, без дополнительных ухищрений)
; Но ведь WriteLn и есть процедура с переменным числом параметров. Разве нет?!
; Вот именно, что нет!!! Никакая это не процедура, а оператор!
; Компилятор еще на стадии компиляции разбивает ее на множество вызовов
; процедур для вывода каждого аргумента по отдельности. Поэтому,
; в откомпилированном коде каждая процедура примет фиксированное количество
; аргументов. В нашем случае их будет три: первая для вывода суммы двух
; чисел – этим занимается процедура WriteLongint, вторая – для вывода символа
; пробела в символьной форме – этим занимается WriteChar
и, наконец, последняя
; для вывода строки – WriteSting
; Размышляем далее – под Windows непосредственно вывести строку в окно и тут же
; забыть о ней нельзя, т.к. окно в любой момент может потребовать перерисовки –
; операционная система не сохраняет его содержимого – в графической среде
; при высоком разрешении это привело бы к большим затратам памяти.
; Код, выводящий строку, должен уметь повторять свой вывод по запросу.
; Каждый, кто хоть раз программировал под Windows, наверняка помнит, что весь
; вывод приходилось помещать в обработчик сообщения WM_PAINT.
; Turbo Pascal
же позволяет обращаться к Windows-окном точно так,
; как с консолью. А раз так – он должен где-то хранить все, ранее выведенное
; на экран. Поскольку, локальный переменные умирают вместе с завершением
; их процедуры, то для хранения буфера они не годятся. Остается либо куча, либо
; сегмент данных. Pascal использует последнее – указатель на такой буфер мы
; только что получили.
; Далее, для повышения производительности вывода Turbo-Pascal реализует
; простейший
кэш. Функции WriteLingint, WriteChar, WriteString сливают
; результат своей деятельности в символьном виде в этом самый буфер, а в конце
; следует вызов WriteLn, выводящий содержимое буфера в окно.
; Run-time systems следит за его перерисовками и при необходимости повторяет
; вывод уже без участия программиста.
push ds
push di
; Заносим адрес буфера в стек
mov al, [bp+arg_4]
; Тип аргумента arg_4 - Byte
xor ah, ah
; Обнуляем старший байт регистра ah
add ax, [bp+arg_6]
; Складываем arg_4 с arg_6. Поскольку, al было предварительно расширено до AX
; то arg_6 имеет тип Word, т.к. при сложении двух чисел разного типа PASCAL
; расширяет их до большего из них.
; Кроме того, вызывающая процедура передает с этим аргументом значение 0x666,
; что явно не влезло бы в Byte.
xor dx, dx
; Обнуляем DX…
push dx
; …и заносим его в стек.
push ax
; Заносим в стек сумму двух левых аргументов
push 0
; Еще один ноль!
call @Write$qm4Text7Longint4Word ; Write(var f; v: Longint; width: Word)
; Функция WriteLongint имеет следующий прототип
; WriteLongint(Text far &, a: Longint, count:Word); где -
; Text far & - указатель на буфер вывода
; a - выводимое длинное целое
; count - сколько переменных выводить (ноль – одна переменная)
;
; Значит, в нашем случае мы выводим одну переменную – сумму двух аргументов.
; Маленькое дополнение – функция WriteLongint
не следует соглашению PASCAL
; т.к. не до конца чистит за собой стек, оставляя указать на буфер в стеке.
; На этот шаг разработчики компилятора пошли для увеличения производительности:
; раз указатель на буфер будет нужен и другим функциям
;(по крайней мере одной из них – WriteLn), зачем его то стягивать, то опять
; лихорадочно запихивать?
; Если вы загляните в конец функции WriteLongint, вы обнаружите там RET
6,
; т.е. функция выпихивает два аргумента – два машинных слова на Longint
и один
; Word на count.
; Вот такая милая маленькая техническая деталь. Маленькая-то она, маленькая,
; но как сбивает с толку!
; (особенно, если исследователь не знаком с системой ввода-вывода Паскаля)
push 20h ; ' '
; Заносим в стек следующий аргумент, передаваемый функции WriteLn
; (указатель на буфер все еще находится в стеке).
push 0
; Нам надо вывести только одни символ
call @Write$qm4Text4Char4Word ; Write(var f;c: Char; width:Word)
lea di, [bp+var_100]
; Получаем указатель на локальную копию переданной функции строки
push ss
push di
; Заносим ее адрес в стек
push 0
; Выводить только одну строку!
call @Write$qm4Textm6String4Word ; Write(var f; s: String; width: Word)
call @WriteLn$qm4Text ; WriteLn(var f: Text)
; Кажется, функции не передаются никакие параметры, но на самом деле на вершине
; стека лежит указатель на буфер и ждет своего "звездного часа"
; после завершения WriteLn он будет снят со стека
call @__IOCheck$qv ; Exit if error
; Проверка операции вывода на успешность
leave
; Закрываем кадр стека
retn 8
; Выталкиваем восемь байт со стека. ОК, теперь мы знаем все необходимое для
; восстановления прототипа нашей процедуры. Он выглядит так:
; MyProc(a:Byte, b:Word, c:String);
MyProc endp
Листинг 69
Да, хитрым оказался Turbo-PASCAL! Анализ откомпилированной с его помощью программы преподнес нам один очень важный урок – никогда нельзя быть уверенным, что функция выталкивает все переданные ей аргументы из стека, и уж тем более нельзя определять количество аргументов по числу снимаемых из стека машинных слов!
::соглашения о быстрых вызовах – fastcall. Какой бы непроизводительной передача аргументов через стек ни была, а типы вызовы stdcall и cdecl стандартизированы и хочешь – не хочешь, а их надо соблюдать. Иначе, модули, скомпилированные один компилятором (например, библиотеки), окажутся не совместимы с модулями, скомпилированными другими компиляторами. Впрочем, если вызываемая функция компилируется тем же самым компилятором, что и вызывающая, - придерживаться типовых соглашений ни к чему и можно воспользоваться более эффективной передачей аргументов через регистры.
Многие начинающие программисты удивляются: а почему передача аргументов через регистры до сих пор не стандартизирована и вряд ли когда будет стандартизирована вообще? Ответ: кем бы она могла быть стандартизирована? Комитетами по стандартизации Си и Си++? Нет, конечно! – все платформенно – зависимые решения оставляются на откуп разработчикам компиляторов – каждый из них волен реализовывать их по-своему или не реализовывать вообще. "Хорошо, уговорили", - не согласится иной читатель, "но что мешает разработчикам компиляторов одной конкретной платформы договориться об общих соглашениях. Ведь договорились же они передавать возвращенное функцией значение через [E]AX:[[E]DX], хотя стандарт о конкретных регистрах вообще никакого понятия не имеет".
Ну, отчасти разработчики и договорись: большинство 16-разрядных компиляторов придерживалось общих соглашений (хотя об этом не сильно трубилось вслух), но без претензий на совместимость друг с другом. Быстрый вызов – он на то и называется быстрым, чтобы обеспечить максимальную производительность. Техника же оптимизации не стоит на месте и вводить стандарт – это все равно, что привязывать гирю к ноге. С другой стороны, средний выигрыш от передачи аргументов через регистры составляет единичные проценты, – вот многие разработчики компиляторов отказываются от быстроты в пользу простоты (реализации). К тому же, если так критична производительность – используйте встраиваемые функции.
Впрочем, все эти рассуждения интересны в первую очередь программистам, исследователей же программ волнует не эффективность, а восстановление прототипов функций. Можно ли узнать какие аргументы принимает fastcall - функция, не анализируя ее код (т.е. смотря только на вызывающую функцию). Чрезвычайно популярный ответ "Нет, это невозможно, поскольку компилятор передает аргументы в наиболее "удобных" регистрах" неправилен, и говорящий наглядно демонстрирует свое полное незнание техники компиляции.
Существует такой термин как "единица трансляции", - в зависимости от реализации компилятор может либо транслировать весь текст программы целиком (что весьма накладно, т.к. придется хранить в памяти все дерево синтаксического разбора), либо транслировать каждую функцию по отдельности, сохраняя в памяти лишь ее имя и ссылку на сгенерированный для нее код. Компиляторы первого типа крайне редки, во всяком случае для ОС Windows я не встречал ни одного такого Си\Cи++ компилятора (хотя и слышал о таких). Компиляторы второго типа более производительны, требуют меньше памяти, проще в реализации, словом, всем хороши, за исключением органической неспособности к "сквозной" оптимизации, - каждая функция оптимизируется "персонально" и независимо от другой. Поэтому, подобрать оптимальные регистры для передачи аргументов компилятор не может, поскольку он не знает, как с ними манипулирует вызываемая функция. Поскольку, функции транслируются независимо, им приходится придерживаться общих соглашений, даже если это и невыгодно.
Таким образом, зная "почерк" конкретного компилятора, восстановить прототип функции можно без труда.
::Borland C++ 3.x – передача аргументов осуществляется через регистры: AX (AL), DX (DL), BX (BL), а, когда регистры кончаются, аргументы начинают засылаться в стек, заносясь в него слева направо и выталкиваясь самой вызываемой функцией (a la stdcall).
Схема передачи аргументов довольно интересна – компилятор не закрепляет за каждым аргументом "своих" регистров, вместо этого он предоставляет свободный доступ каждому из них к "стопке" кандидатов, уложенных в порядке предпочтения.
Каждый аргумент снимает со стопки столько регистров, сколько ему нужно, а когда стопка исчерпается – тогда придется отправляться в стек. Исключение составляет тип long int, всегда передаваемый через DX:AX (причем, в DX передается старшее слово), а если это невозможно – то через стек.
Если каждый аргумент занимает не более 16-ти бит (как обычно и происходит), то первый слева аргумент помещается в AX (AL), второй – в DX (DL), третий – в BX (BL). Если же первый слева аргумент представляет тип long int, он снимает со стопки сразу два регистра – DX:AX, тогда второму аргументу остается регистр BX (BL), а третьему – и вовсе ничего (и тогда он передается через стек). Когда же long int передается вторым аргументом, он отправляется в стек, т.к. необходимый ему регистр AX уже занят первым аргументом, третий же аргумент передается через DX. Наконец, будучи третьим слева аргументом, long int идет в стек, а первые два аргумента передаются через AX (AL) и DX (DL) соответственно.
Передача дальних указателей и вещественных значений всегда осуществляется через основной стек (а не стек сопроцессора, как иногда приходится слышать, и как подсказывает здравый смысл).
|
тип |
предпочтения |
||
|
1й |
2й |
3й |
|
|
char |
AL |
DL |
BL |
|
int |
AX |
DX |
BX |
|
long int |
DX:AX |
||
|
ближний указатель |
AX |
DX |
BX |
|
дальний указатель |
stack |
||
|
float |
stack |
||
|
double |
stack |
|
тип |
предпочтения |
||
|
1й |
2й |
3й |
|
|
char |
AL |
DL |
BL |
|
int |
AX |
DX |
BX |
|
long int |
DX:AX |
||
|
ближний указатель |
BX |
AX |
DX |
|
дальний указатель |
stack |
||
|
float |
stack |
||
|
double |
stack |
|
тип |
предпочтения |
||
|
1й |
2й |
3й |
|
|
char |
AL |
DL |
CL |
|
int |
EAX |
EDX |
ECX |
|
long int |
EAX |
EDX |
ECX |
|
ближний указатель |
EAX |
EDX |
ECX |
|
дальний указатель |
stack |
||
|
float |
stack |
||
|
double |
stack |
|
тип |
предпочтения |
||
|
1й |
2й |
3й |
|
|
char |
CL |
DL |
-- |
|
int |
ECX |
EDX |
-- |
|
__int64 |
stack |
||
|
long int |
ECX |
-- |
|
|
ближний указатель |
ECX |
EDX |
-- |
|
дальний указатель |
stack |
-- |
|
|
float |
stack |
-- |
|
|
double |
stack |
-- |
|
тип |
аргумент |
|||
|
1й |
2й |
3й |
4й |
|
|
char |
AL |
DL |
BL |
CL |
|
int |
EAX |
EDX |
EBX |
ECX |
|
long int |
EAX |
EDX |
EBX |
ECX |
|
ближний указатель |
ECX |
EDX |
EBX |
ECX |
|
дальний указатель |
stack |
stack |
stack |
stack |
|
float |
stack CPU |
stack CPU |
stack CPU |
stack CPU |
|
stack FPU |
stack FPU |
stack FPU |
stack FPU |
|
|
double |
stack CPU |
stack CPU |
stack CPU |
stack CPU |
|
stack FPU |
stack FPU |
stack FPU |
stack FPU |
|
тип |
допустимые регистры |
|||||
|
char |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
|
int |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
|
long int |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
|
ближний указатель |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
|
дальний указатель |
DX:EAX |
CX:EBX |
CX:EAX |
CX:ESI |
DX:EBX |
DI:EAX |
|
CX:EDI |
DX:ESI |
DI:EBX |
SI:EAX |
CX:EDX |
DX:EDI |
|
|
DI:ESI |
SI:EBX |
BX:EAX |
FS:ECX |
FS:EDX |
FS:EDI |
|
|
FS:ESI |
FS:EBX |
FS:EAX |
GS:ECX |
GS:EDX |
GS:EDI |
|
|
GS:ESI |
GS:EBX |
GS:EAX |
DS:ECX |
DS:EDX |
DS:EDI |
|
|
DS:ESI |
DS:EBX |
DS:EAX |
ES:ECX |
ES:EDX |
ES:EDI |
|
|
ES:ESI |
ES:EBX |
ES:EAX |
||||
|
float |
8087 |
??? |
??? |
??? |
??? |
??? |
|
double |
8087 |
EDX:EAX |
ECX:EBX |
ECX:EAX |
ECX:ESI |
EDX:EBX |
|
EDI:EAX |
ECX:EDI |
EDX:ESI |
EDI:EBX |
ESI:EAX |
ECX:EDX |
|
|
EDX:EDI |
EDI:ESI |
ESI:EBX |
EBX:EAX |
|
тип |
размер |
диапазон значений |
предпочтительные типы передачи |
|
float |
4 байта |
10-38...10+38 |
регистры CPU, стек CPU, стек FPU |
|
double |
8 байт |
10-308...10+308 |
регистры CPU, стек CPU, стек FPU |
|
long double |
10 байт |
10-4932...10+4932 |
стек CPU, стек FPU |
|
real[3] |
6 байт |
2.9*10-39...1.7*10+38 |
регистры CPU, стек CPU, стек FPU |
|
Команда |
Назначение |
|
FLD источник |
Заталкивает вещественное число из источника на вершину стека сопроцессора |
|
FSTP приемник |
Выталкивает вещественное число из вершины стека сопроцессора в приемник |
|
FST приемник |
Копирует вещественное число из вершины стека сопроцессора в приемник |
|
FLDZ |
Заталкивает ноль на вершину стека сопроцессора |
|
FLD1 |
Заталкивает единицу на вершину стека сопроцессора |
|
FLDPI |
Заталкивает на вершину стека сопроцессора число ? |
|
FLDL2T |
Заталкивает на вершину стека сопроцессора двоичный логарифм десяти |
|
FLDL2E |
Заталкивает на вершину стека сопроцессора двоичный логарифм числа e |
|
FLDLG2 |
Заталкивает на вершину стека сопроцессора десятичный логарифм двух |
|
FLDLN2 |
Заталкивает на вершину стека сопроцессора натуральный логарифм двух |
|
FILD источник |
Заталкивает целое число из источника на вершину стека сопроцессора |
|
FIST приемник |
Копирует целое число с вершины стека сопроцессора в приемник |
|
FISTP приемник |
Выталкивает целое число с вершины стека сопроцессора в приемник |
|
FBLD источник |
Заталкивает десятичное число из приемника на вершину стека сопроцессора |
|
FBSTP приемник |
Копирует десятичное число с вершины стека сопроцессора в приемник |
|
FXCH ST(индекс) |
Обмен значениями между вершиной стека сопроцессора и регистром ST(индекс) |
|
Команда |
Тип |
||
|
короткий (float) |
длинный (double) |
расширенный (long double) |
|
|
FLD |
0xD9 ??000??? |
0xDD ??000??? |
0xDB ??101??? |
|
FSTP |
0xD9 ??011??? |
0xDD ??011??? |
0xDB ??111??? |
|
FST |
0xD9 ??010??? |
0xDD ??010??? |
нет |
|
FADD |
0xD8 ??000??? |
0xDC ??000??? |
нет |
|
FADDP |
0xDE ??000??? |
0xDA ??000??? |
нет |
|
FSUB |
0xD8 ??100??? |
0xDC ??100??? |
нет |
|
FDIV |
0xD8 ??110??? |
0xDC ??110??? |
нет |
|
FMUL |
0xD* ??001??? |
0xDC ??001??? |
нет |
|
FCOM |
0xD8 ??010??? |
0xDC ??010??? |
нет |
|
FCOMP |
0xD8 ??011??? |
0xDC ??011??? |
нет |