Содержание статьи
- Идентификация функций
- Непосредственный вызов функции
- Вызов функции по указателю
- Вызов функции по указателю с комплексным вычислением целевого адреса
- «Ручной» вызов функции инструкцией JMP
- Автоматическая идентификация функций посредством IDA Pro
- Пролог
- Эпилог
- Специальное замечание
- «Голые» (naked) функции
- Идентификация встраиваемых (inline) функций
- Выводы
«Фундаментальные основы хакерства»
Перед тобой уже во второй раз обновленная версия цикла «Фундаментальные основы хакерства». В 2018 году Юрий Язев изменил текст Криса Касперски для соответствия новым версиям Windows и Visual Studio, а теперь внес правки с учетом отладки программ для 64-разрядной архитектуры.
Читай также улучшенные версии прошлых статей цикла:
- Учимся анализировать программы для x86-64 с нуля
- Используем отладчик для анализа 64-разрядных программ в Windows
- Находим реальные адреса инструкций в исполняемых файлах x86-64
- Осваиваем разные способы поиска защит в программах для x86-64
- Мастер‑класс по анализу исполняемых файлов в IDA Pro
Все новые версии статей доступны без платной подписки.
Цикл «Фундаментальные основы хакерства» со всеми обновлениями опубликован в виде книги, купить ее по выгодной цене ты можешь на сайте издательства «Солон‑пресс».
Современные дизассемблеры достаточно интеллектуальны и львиную долю распознавания ключевых структур берут на себя. В частности, IDA Pro успешно справляется с идентификацией стандартных библиотечных функций, локальных переменных, адресуемых через регистр RSP, case-ветвлений и прочего. Однако порой IDA ошибается, вводя исследователя в заблуждение, к тому же высокая стоимость IDA Pro не всегда оправдывает применение. Например, студентам, изучающим ассемблер (а лучшее средство изучения ассемблера — дизассемблирование чужих программ), «Ида» едва ли по карману.
Разумеется, на IDA свет клином не сошелся, существуют и другие дизассемблеры — скажем, тот же DUMPBIN, входящий в штатную поставку SDK. Почему бы на худой конец не воспользоваться им? Конечно, если под рукой нет ничего лучшего, сойдет и DUMPBIN, но в этом случае об интеллектуальности дизассемблера придется забыть и пользоваться исключительно своей головой.
Первым делом мы познакомимся с результатами работы неоптимизирующих компиляторов — анализ их кода относительно прост и вполне доступен для понимания даже новичкам в программировании. Затем, освоившись с дизассемблером, перейдем к вещам более сложным — оптимизирующим компиляторам, которые генерируют очень хитрый, запутанный и витиеватый код.
Идентификация функций
Функция (также называемая процедурой или подпрограммой) — основная структурная единица процедурных и объектно ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов. Строго говоря, термин «функция» присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку.
Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать — это уже не суть важно. Ключевое свойство функции — возвращение управления на место ее вызова, а ее характерный признак — множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).
Откуда функция знает, куда следует возвратить управление? Очевидно, вызывающий код должен предварительно сохранить адрес возврата и вместе с прочими аргументами передать его вызываемой функции. Существует множество способов решения этой проблемы: можно, например, перед вызовом функции поместить в ее конец безусловный переход на адрес возврата, можно сохранить адрес возврата в специальной переменной и после завершения функции выполнить косвенный переход, используя эту переменную как операнд инструкции jump
...
Не останавливаясь на обсуждении сильных и слабых сторон каждого метода, отметим, что компиляторы в подавляющем большинстве случаев используют специальные машинные команды CALL
и RET
, соответственно предназначенные для вызова функций и возврата из них.
Инструкция CALL
закидывает адрес следующей за ней инструкции на вершину стека, а RET стягивает и передает на него управление. Тот адрес, на который указывает инструкция CALL
, и есть адрес начала функции. А замыкает функцию инструкция RET
(но внимание: не всякий RET обозначает конец функции!).
Таким образом, распознать функцию можно двояко: по перекрестным ссылкам, ведущим к машинной инструкции CALL
, и по ее эпилогу, завершающемуся инструкцией RET
. Перекрестные ссылки и эпилог в совокупности позволяют определить адреса начала и конца функции. Немного забегая вперед, заметим, что в начале многих функций присутствует характерная последовательность команд, называемая прологом, которая также пригодна и для идентификации функций. А теперь рассмотрим все эти темы поподробнее.
Непосредственный вызов функции
Просматривая дизассемблерный код, находим все инструкции CALL
— содержимое их операнда и будет искомым адресом начала функции. Адрес невиртуальных функций, вызываемых по имени, вычисляется еще на стадии компиляции, и операнд инструкции CALL
в таких случаях представляет собой непосредственное значение. Благодаря этому адрес начала функции выявляется простым синтаксическим анализом: ищем контекстным поиском все подстроки CALL
и запоминаем (записываем) непосредственные операнды. Рассмотрим следующий пример (Listing1):
void func();int main(){ int a; func(); a=0x666; func();}void func(){ int a; a++;}
Чтобы откомпилировать пример для 64-битной платформы, надо открыть соответствующую консоль — x64 Native Tools Command Prompt for VS — и уже в ней выполнить команду
cl.exe main.cpp /EHcs
Результат компиляции в IDA Pro должен выглядеть приблизительно так:
.text:0000000140001020 main proc near
.text:0000000140001020 var_18 = dword ptr -18h
.text:0000000140001020
.text:0000000140001020 sub rsp, 38h
Вот мы выловили инструкцию call с непосредственным операндом, представляющим собой адрес начала функции. Точнее, ее смещение в кодовом сегменте (в данном случае в сегменте .text). Теперь можно перейти к строке .
и, дав функции собственное имя, заменить операнд инструкции call конструкцией «call Имямоейфункции».
.text:0000000140001024 call sub_140001000
.text:0000000140001029 mov [rsp+38h+var_18], 666h
.text:0000000140001031 call sub_140001000
.text:0000000140001036 xor eax, eax
.text:0000000140001038 add rsp, 38h
Вот нам встретилась инструкция возврата из функции, однако не факт, что это действительно конец функции, ведь функция может иметь и несколько точек выхода. Однако смотри: следом за ret расположено начало следующей функции. Поскольку функции не могут перекрываться, выходит, что данный ret — конец функции!
.text:000000014000103C retn
.text:000000014000103C main endp
.text:0000000140001040 sub_140001040 proc near
.text:0000000140001040 push rbx
.text:0000000140001042 sub rsp, 20h
.text:0000000140001046 mov ecx, 1
.........
Судя по адресам, «наша функция» в листинге расположена выше функции main:
.text:0000000140001000 sub_140001000 proc near
.text:0000000140001000 var_18 = dword ptr -18h
На эту строку ссылаются операнды нескольких инструкций call.Следовательно, это адрес начала «нашей функции».
.text:0000000140001000 sub rsp, 18h
.text:0000000140001004 mov eax, [rsp+18h+var_18]
.text:0000000140001007 inc eax
.text:0000000140001009 mov [rsp+18h+var_18], eax
.text:000000014000100C add rsp, 18h
.text:0000000140001010 retn
.text:0000000140001010 sub_140001000 endp
Как видишь, все очень просто.
Вызов функции по указателю
Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, а не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями, однако в любом случае компилятор должен каким‑то образом сохранить адрес функции в коде. Значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на «подследственную» инструкцию CALL
точку останова и, дождавшись всплытия отладчика, посмотреть, по какому адресу она передаст управление. Рассмотрим следующий пример (Listing2):
int func(){ return 0;}int main(){ int (*a)(); a = func; a();}
Результат его компиляции должен в общем случае выглядеть так (функция main
):
.text:0000000140001000 loc_140001000:
.text:0000000140001000 xor eax, eax
.text:0000000140001002 retn
.text:0000000140001002 ; -------------------------------------------
.text:0000000140001010 main proc near
.text:0000000140001010
.text:0000000140001010 var_18 = qword ptr -18h
.text:0000000140001010
.text:0000000140001010 sub rsp, 38h
.text:0000000140001014 lea rax, loc_140001000
.text:000000014000101B mov [rsp+38h+var_18], rax
Вот инструкция CALL, осуществляющая косвенный вызов функции по адресу, содержащемуся в ячейке [rsp+38h+var_18]. Как узнать, что же там содержится? Поднимем глазки строчкой выше и обнаружим: lea
. Ага! Значит, управление передается по смещению loc_140001000
, где располагается адрес начала функции! Теперь осталось только дать функции осмысленное имя.
.text:0000000140001020 call [rsp+38h+var_18]
.text:0000000140001024 xor eax, eax
.text:0000000140001026 add rsp, 38h
.text:000000014000102A retn
.text:000000014000102A main endp
Вызов функции по указателю с комплексным вычислением целевого адреса
В некоторых достаточно немногочисленных программах встречается и косвенный вызов функции с комплексным вычислением ее адреса. Рассмотрим следующий пример (Listing3):
int func_1(){ return 0;}int func_2(){ return 0;}int func_3(){ return 0;}int main(){ int x; int a[3]={(int) func_1,(int) func_2, (int) func_3}; int (*f)(); for (x=0;x < 3;x++) { f=(int (*)()) a[x]; f(); }}
Результат дизассемблирования этого кода в общем случае должен выглядеть так:
.text:0000000140001030 main proc near
.text:0000000140001030
.text:0000000140001030 var_38 = dword ptr -38h
.text:0000000140001030 var_30 = qword ptr -30h
.text:0000000140001030 var_28 = dword ptr -28h
.text:0000000140001030 var_24 = dword ptr -24h
.text:0000000140001030 var_20 = dword ptr -20h
.text:0000000140001030 var_18 = qword ptr -18h
.text:0000000140001030
.text:0000000140001030 sub rsp, 58h
.text:0000000140001034 mov rax, cs:__security_cookie
.text:000000014000103B xor rax, rsp
.text:000000014000103E mov [rsp+58h+var_18], rax
.text:0000000140001043 lea rax, loc_140001000
.text:000000014000104A mov [rsp+58h+var_28], eax
.text:000000014000104E lea rax, sub_140001010
.text:0000000140001055 mov [rsp+58h+var_24], eax
.text:0000000140001059 lea rax, sub_140001020
.text:0000000140001060 mov [rsp+58h+var_20], eax
.text:0000000140001064 mov [rsp+58h+var_38], 0
.text:000000014000106C jmp short loc_140001078
.text:000000014000106E ; ----------------------------------------------
.text:000000014000106E
.text:000000014000106E loc_14000106E: ; CODE XREF: main+62↓j
.text:000000014000106E mov eax, [rsp+58h+var_38]
.text:0000000140001072 inc eax
.text:0000000140001074 mov [rsp+58h+var_38], eax
.text:0000000140001078
.text:0000000140001078 loc_140001078: ; CODE XREF: main+3C↑j
.text:0000000140001078 cmp [rsp+58h+var_38], 3
.text:000000014000107D jge short loc_140001094
.text:000000014000107F movsxd rax, [rsp+58h+var_38]
.text:0000000140001084 movsxd rax, [rsp+rax*4+58h+var_28]
.text:0000000140001089 mov [rsp+58h+var_30], rax
.text:000000014000108E call [rsp+58h+var_30]
.text:0000000140001092 jmp short loc_14000106E
.text:0000000140001094 ; ---------------------------------------------
.text:0000000140001094
.text:0000000140001094 loc_140001094: ; CODE XREF: main+4D↑j
.text:0000000140001094 xor eax, eax
.text:0000000140001096 mov rcx, [rsp+58h+var_18]
.text:000000014000109B xor rcx, rsp
.text:000000014000109E call __security_check_cookie
.text:00000001400010A3 add rsp, 58h
.text:00000001400010A7 retn
В строке call [
происходит косвенный вызов функции. А что у нас в [
? Поднимаем глаза на одну строку вверх — в [
у нас значение rax
. А чему же равен сам rax
? Прокручиваем еще одну строку вверх — rax
равен содержимому ячейки [
. Вот дела! Мало того, что нам надо узнать содержимое этой ячейки, так еще и предстоит вычислить ее адрес!
Чему равен RAX в этом выражении? Содержимому [
. А оно чему равно? «Сейчас выясним...» — бормочем мы себе под нос, прокручивая экран дизассемблера вверх. Ага, нашли: в строке 0x140001074
в него загружается содержимое EAX! Какая радость! И долго мы будем так блуждать по коду?
Конечно, можно, потратив неопределенное количество времени, усилий и бодрящего напитка, реконструировать весь ключевой алгоритм целиком (тем более что мы практически подошли к концу анализа), но где гарантия, что при этом не будут допущены ошибки?
Гораздо быстрее и надежнее загрузить исследуемую программу в отладчик, установить бряк на строку .
и, дождавшись всплытия окна отладчика, посмотреть, что у нас расположено в ячейке [
. Отладчик будет всплывать трижды, причем каждый раз показывать новый адрес! Заметим, что определить этот факт в дизассемблере можно только после полной реконструкции алгоритма.
Однако не стоит питать излишних иллюзий о мощи отладчика. Программа может тысячу раз вызывать одну и ту же функцию, а на тысяча первый — вызвать совсем другую. Отладчик бессилен это определить. Ведь вызов такой функции может произойти в непредсказуемый момент, например при определенном сочетании времени, обрабатываемых программой данных и текущей фазы Луны. Ну не будем же мы целую вечность гонять программу под отладчиком?
Дизассемблер — дело другое. Полная реконструкция алгоритма позволит однозначно и гарантированно отследить все адреса косвенных вызовов. Вот потому дизассемблер и отладчик должны скакать в одной упряжке! Напоследок предлагаю взглянуть на такой участок дизассемблированного листинга:
.text:000000014000103E mov [rsp+58h+var_18], rax
.text:0000000140001043 lea rax, loc_140001000
.text:000000014000104A mov [rsp+58h+var_28], eax
.text:000000014000104E lea rax, sub_140001010
.text:0000000140001055 mov [rsp+58h+var_24], eax
.text:0000000140001059 lea rax, sub_140001020
Воспользуемся средствами IDA и посмотрим, что загружается в ячейки памяти [
. А это как раз адреса трех наших функций, последовательно размещенных компилятором друг за дружкой:
.text:0000000140001000 loc_140001000:
.text:0000000140001000 xor eax, eax
.text:0000000140001002 retn
.text:0000000140001010 sub_140001010 proc near
.text:0000000140001010 xor eax, eax
.text:0000000140001012 retn
.text:0000000140001012 sub_140001010 endp
.text:0000000140001020 sub_140001020 proc near
.text:0000000140001020 xor eax, eax
.text:0000000140001022 retn
.text:0000000140001022 sub_140001020 endp
«Ручной» вызов функции инструкцией JMP
Самый тяжелый случай представляют собой «ручные» вызовы функции командой JMP
с предварительной засылкой в стек адреса возврата. Вызов через JMP
в общем случае выглядит так: PUSH
/ JMP
, где ret_addrr
и func_addr
— непосредственные или косвенные адреса возврата и начала функции соответственно. Кстати, заметим, что команды PUSH
и JMP
не всегда следуют одна за другой и порой бывают разделены другими командами.
Возникает резонный вопрос: чем же так плох CALL
и зачем прибегать к JMP
? Дело в том, что функция, вызванная по CALL
, после возврата управления материнской функции всегда передает управление команде, следующей за CALL
. В ряде случаев (например, при структурной обработке исключений) возникает необходимость после возврата из функции продолжать выполнение не со следующей за CALL
командой, а совсем с другой ветки программы. Тогда‑то и приходится вручную заносить требуемый адрес возврата и вызывать дочернюю функцию через JMP.
Идентифицировать такие функции очень сложно — контекстный поиск ничего не дает, поскольку команд JMP
, использующихся для локальных переходов, в теле любой программы очень и очень много — попробуй‑ка проанализируй их все! Если же этого не сделать, из поля зрения выпадут сразу две функции — вызываемая функция и функция, на которую передается управление после возврата. К сожалению, быстрых решений этой проблемы не существует, единственная зацепка — вызывающий JMP
практически всегда выходит за границы функции, в теле которой он расположен. Определить же границы функции можно по эпилогу. Рассмотрим следующий пример (Listing4):
int funct(){ return 0;}int main(){ __asm { LEA ESI, return_addr PUSH ESI JMP funct return_addr: }}
Поскольку присутствующее в этом коде ключевое слово asm
платформенно зависимое и поддерживается только на x86, скомпилируем этот пример 32-битным компилятором. Результат компиляции в общем случае должен выглядеть так:
.text:00401010 _main proc near
.text:00401010
.text:00401010 argc = dword ptr 8
.text:00401010 argv = dword ptr 0Ch
.text:00401010 envp = dword ptr 10h
.text:00401010
.text:00401010 push ebp
.text:00401011 mov ebp, esp
.text:00401013 push esi
.text:00401014 lea esi, loc_401020
.text:0040101A push esi
.text:0040101B jmp sub_401000
...
Смотри, казалось бы, тривиальный безусловный переход, что в нем такого? Ан нет! Это не простой переход, это замаскированный вызов функции! Откуда он следует? Давай‑ка перейдем по смещению sub_401000
и посмотрим:
.text:00401000 sub_401000 proc near
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 xor eax, eax
.text:00401005 pop ebp
.text:00401006 retn
.text:00401006 sub_401000 endp
Как ты думаешь, куда этот ret
возвращает управление? Естественно, по адресу, лежащему на верхушке стека. А что у нас лежит на стеке? PUSH
из строки 0x401000
, обратно выталкивается инструкцией POP из строки 0x401005
... Возвращаемся назад, к месту безусловного перехода, и начинаем медленно прокручивать экран дизассемблера вверх, отслеживая все обращения к стеку. Ага, попалась птичка!
Инструкция PUSH
из строки 40101A
закидывает на вершину стека содержимое регистра ESI
, а он сам, в свою очередь, строкой выше принимает «на грудь» значение loc_401020
— это и есть адрес начала функции, вызываемой командой JMP
(вернее, не адрес, а смещение, но это не принципиально важно):
.text:00401020 loc_401020:
.text:00401020 pop esi
.text:00401021 pop ebp
.text:00401022 retn
.text:00401022 _main endp
Автоматическая идентификация функций посредством IDA Pro
Дизассемблер IDA Pro способен анализировать операнды инструкций CALL
, что позволяет ему автоматически разбивать программу на функции. Причем IDA вполне успешно справляется с большинством косвенных вызовов. Между тем современные версии дизассемблера на раз‑два справляются с комплексными и «ручными» вызовами функций командой JMP
.
Пролог
На платформе IA-32 большинство неоптимизирующих компиляторов помещают в начало функции следующий код, называемый прологом:
push ebp
mov ebp, esp
sub esp, xx
К сожалению, на x64 нет такой стройной последовательности инструкций. У каждой функции пролог существенно отличается. Поэтому рассмотрим пролог функций для x86.
В общих чертах назначение пролога сводится к следующему: если регистр EBP
используется для адресации локальных переменных (как часто и бывает), то перед использованием он должен быть сохранен в стеке (иначе вызываемая функция «сорвет крышу» материнской), затем в EBP
копируется текущее значение регистра указателя вершины стека (ESP
) — происходит так называемое открытие кадра стека, и значение ESP
уменьшается на размер области памяти, выделенной под локальные переменные.
Последовательность PUSH
/ MOV
/ SUB
может служить хорошей сигнатурой, чтобы найти все функции в исследуемом файле, включая и те, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDA Pro, однако оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESP
и используют EBP
, как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUB
— последовательность слишком короткая для использования ее в качестве сигнатуры функции, увы. Более подробный рассказ об эпилогах функций нас ждет впереди.
Эпилог
С эпилогом такая же ситуация, на x64 отсутствует постоянная последовательность инструкций. Однако, поскольку 32-битных приложений так много, что их придется анализировать еще вечность, нам необходимо знать, как выглядит эпилог в программах на x86.
В конце своей жизни функция закрывает кадр стека, перемещая указатель вершины стека «вниз», и восстанавливает прежнее значение EBP (если только оптимизирующий компилятор не адресовал локальные переменные через ESP
, используя EBP
как обычный регистр общего назначения). Эпилог функции может выглядеть двояко: либо ESP
увеличивается на нужное значение командой ADD
, либо в него копируется значение EBP
, указывающее на низ кадра стека. Обобщенный код эпилога функции выглядит так.
Эпилог 1:
pop ebp
add esp, 64h
retn
Эпилог 2:
mov esp, ebp
pop ebp
retn
Важно отметить: между командами POP
/ ADD
и MOV
/ POP
могут находиться и другие команды — они не обязательно должны следовать вплотную друг к другу. Поэтому для поиска эпилогов контекстный поиск непригоден — требуется применять поиск по маске.
Если функция написана с учетом соглашения PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это делается инструкцией RET
, где n
— количество байтов, снимаемых из стека после возврата. Функции же, соблюдающие С‑соглашение, предоставляют очистку стека вызывающему их коду и всегда оканчиваются командой RET. API-функции Windows представляют собой комбинацию соглашений С и Pascal — аргументы заносятся в стек справа налево, но очищает стек сама функция.
Таким образом, RET
может служить достаточным признаком эпилога функции, но не всякий эпилог — это конец. Если функция имеет в своем теле несколько операторов return
(как часто и бывает), компилятор в общем случае генерирует для каждого из них свой собственный эпилог. Необходимо обратить внимание, находится ли за концом эпилога новый пролог, или продолжается код старой функции.
Также нельзя забывать и о том, что компиляторы обычно (но не всегда!) не помещают в исполняемый файл код, никогда не получающий управления. Иначе говоря, у функции будет всего один эпилог, а все находящееся после первого return будет выброшено как ненужное. Между тем не стоит спешить вперед паровоза. Откомпилируем с параметрами по умолчанию следующий пример (Listing5):
int func(int a){ return a++; a=1/a; return a;}int main(){ func(1);}
Откомпилированный результат будет выглядеть так (приведен код только функции func
):
.text:00401000 sub_401000 proc near
.text:00401000
.text:00401000 var_4 = dword ptr -4
.text:00401000 arg_0 = dword ptr 8
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004; Копирование значения аргумента в регистр EAX.
.text:00401004 mov eax, [ebp+arg_0]
.text:00401007; Перекладываем его в переменную var_4.
.text:00401007 mov [ebp+var_4], eax
.text:0040100A; Значение аргумента в ECX.
.text:0040100A mov ecx, [ebp+arg_0]
.text:0040100D; Производим инкремент значения в регистре.
.text:0040100D add ecx, 1
.text:00401010; Инкрементированное значение пишем в аргумент,
.text:00401010; который служит переменной.
.text:00401010 mov [ebp+arg_0], ecx
.text:00401013; В EAX помещается начальное значение аргумента,
.text:00401013; оно и возвращается.
.text:00401013 mov eax, [ebp+var_4]
.text:00401016; Осуществляем безусловный переход на эпилог функции.
.text:00401016 jmp short loc_401027
.text:00401018; ------------------------------------------
.text:00401018; В EAX помещаем 1.
.text:00401018 mov eax, 1
.text:0040101D; Расширяем EAX до EDX:EAX (нужно для деления).
.text:0040101D cdq
.text:0040101E; Выполняем деление единицы на аргумент.
.text:0040101E idiv [ebp+arg_0]
.text:00401021; Частное помещаем в переменную.
.text:00401021 mov [ebp+arg_0], eax
.text:00401024; Возвращаем обратно в регистр.
.text:00401024 mov eax, [ebp+arg_0]
.text:00401024 ; Код для деления остался, компилятор не посчитал нужным
.text:00401024 ; его убрать, хотя он недостижим.
.text:00401027
.text:00401027 loc_401027: ; CODE XREF: sub_401000+16↑j
.text:00401027; При этом эпилог только один.
.text:00401027 mov esp, ebp
.text:00401029 pop ebp
.text:0040102A retn
.text:0040102A sub_401000 endp
Теперь посмотрим, какой код сгенерирует компилятор, когда внеплановый выход из функции происходит при срабатывании некоторого условия (Listing6):
int func(int a){ if (a != 0) return a++; return 1/a;}int main(){ func(1);}
Результат компиляции (только func
):
.text:00401000 sub_401000 proc near ; CODE XREF: _main+5↓p
.text:00401000
.text:00401000 var_4 = dword ptr -4
.text:00401000 arg_0 = dword ptr 8
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004; Сравниваем аргумент функции с нулем.
.text:00401004 cmp [ebp+arg_0], 0
.text:00401008; Если они равны, переходим на метку;
.text:00401008 jz short loc_40101E
.text:0040100A; Если же не равны, помещаем значение аргумента в регистр EAX.
.text:0040100A mov eax, [ebp+arg_0]
.text:0040100D; Оттуда — в переменную var_4
.text:0040100D mov [ebp+var_4], eax
.text:00401010; Значение аргумента копируется в регистр ECX, а последний инкрементируем.
.text:00401010 mov ecx, [ebp+arg_0]
.text:00401013 add ecx, 1
.text:00401016; Инкрементированное значение помещаем в аргумент, выступающий переменной.
.text:00401016 mov [ebp+arg_0], ecx
.text:00401019; В EAX помещаем начальное значение аргумента, его и возвращаем.
.text:00401019 mov eax, [ebp+var_4]
.text:0040101C; Переход на эпилог.
.text:0040101C jmp short loc_401027
.text:0040101E ; ------------------------------------------
.text:0040101E
.text:0040101E loc_40101E: ; CODE XREF: sub_401000+8↑j
.text:0040101E; В EAX помещаем 1.
.text:0040101E mov eax, 1
.text:00401023; Расширяем EAX до EDX:EAX (нужно для деления).
.text:00401023 cdq
.text:00401024; Деление EDX:EAX, где находится 1, на аргумент, равный 0.
.text:00401024 idiv [ebp+arg_0]
.text:00401027
.text:00401027 loc_401027: ; CODE XREF: sub_401000+1C↑j
.text:00401027; Это явно эпилог.
.text:00401027 mov esp, ebp
.text:00401029 pop ebp
.text:0040102A retn
.text:0040102A sub_401000 endp
Как и в предыдущем случае, компилятор создал только один эпилог. Обрати внимание: в начале функции в строке 0x401004
аргумент сравнивается с нулем, если условие выполняется, происходит переход на метку loc_40101E
, где выполняется деление, за которым сразу следует эпилог. Если же условие в строке 0x401004
не соблюдено, выполняется сложение и происходит безусловный прыжок на эпилог.
Специальное замечание
Начиная с процессора 80286, в наборе команд появились две инструкции — ENTER
и LEAVE
, предназначенные специально для открытия и закрытия кадра стека. Однако они практически никогда не используются современными компиляторами. Почему?
Причина в том, что ENTER
и LEAVE
очень медлительны, намного медлительнее PUSH
/ MOV
/ SUB
и MOV
/ POP
. Так, на старом добром Pentium ENTER
выполняется за десять тактов, а приведенная последовательность команд — за семь. Аналогично LEAVE
требует пять тактов, хотя ту же операцию можно выполнить за два (и даже быстрее, если разделить MOV
/ POP
какой‑нибудь командой).
Поэтому современный исследователь никогда не столкнется ни с ENTER
, ни с LEAVE
. Хотя помнить об их назначении будет нелишне. Мало ли, вдруг придется дизассемблировать древние программы или программы, написанные на ассемблере, — не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их «ручная оптимизация» заметно уступает компилятору по производительности.
«Голые» (naked) функции
Компилятор Microsoft Visual C++ поддерживает нестандартный квалификатор naked
, позволяющий программистам создавать функции без пролога и эпилога. Компилятор даже не помещает в конце функции RET
, и это приходится делать «вручную», прибегая к ассемблерной вставке __asm{
(использование return
не приводит к желаемому результату).
Вообще‑то поддержка naked-функций задумывалась исключительно для написания драйверов на чистом С (с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность «ручного» создания функций и не беспокоиться, что их непредсказуемым образом «изуродует» компилятор.
Для нас же, кодокопателей, в первом приближении это означает, что в программе может встретиться одна или несколько функций, не содержащих ни пролога, ни эпилога. Ну и что в этом страшного? Оптимизирующие компиляторы так же выкидывают пролог, а от эпилога оставляют один лишь RET
, но функции элементарно идентифицируются по вызывающей их инструкции CALL
.
Идентификация встраиваемых (inline) функций
Самый эффективный способ избавиться от накладных расходов на вызов функций — не вызывать их. В самом деле, почему бы не встроить код функции непосредственно в саму вызывающую функцию? Конечно, это ощутимо увеличит размер (и тем ощутимее, чем из больших мест функция вызывается), но зато значительно увеличит скорость выполнения программы (и тем значительнее, чем чаще развернутая функция вызывается).
Чем плоха развертка функций для исследования программы? Прежде всего, она увеличивает размер материнской функции и делает ее код менее наглядным — вместо CALL
/ TEST
/ JZ
с бросающимся в глаза условным переходом мы видим кучу ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться.
Встроенные функции не имеют ни собственного пролога, ни эпилога, их код и локальные переменные (если таковые имеются) полностью вживлены в вызывающую функцию, результат компиляции выглядит в точности так, как будто бы никакого вызова функции и не было. Единственная зацепка — встраивание функции неизбежно приводит к дублированию ее кода во всех местах вызова, а это хоть и с трудом, но можно обнаружить. С трудом — потому, что встраиваемая функция, становясь частью вызывающей функции, всквозную оптимизируется в контексте последней, что приводит к значительным вариациям кода.
Рассмотрим следующий пример, чтобы увидеть, как компилятор оптимизирует встраиваемую функцию (Listing7):
#include <stdio.h>inline int max(int a, int b){ if(a > b) return a; return b;}int main(int argc, char **argv){ printf("%x\n",max(0x666,0x777)); printf("%x\n",max(0x666,argc)); printf("%x\n",max(0x666,argc)); return 0;}
Результат его компиляции будет иметь следующий вид (функция main
):
.text:0000000140001000 main proc near
.text:0000000140001000
.text:0000000140001000 arg_0 = dword ptr 8
.text:0000000140001000 arg_8 = qword ptr 10h
.text:0000000140001000; Полученные аргументы помещаются
.text:0000000140001000; в локальные переменные.
.text:0000000140001000 mov [rsp+arg_8], rdx
.text:0000000140001005 mov [rsp+arg_0], ecx
.text:0000000140001009 sub rsp, 28h
.text:0000000140001009; Аргументы помещаются в регистры EDX, ECX,
.text:0000000140001009; что говорит нам об их подготовке
.text:0000000140001009; к передаче в качестве параметров другой функции.
.text:000000014000100D mov edx, 777h
.text:0000000140001012 mov ecx, 666h
.text:0000000140001012; Вызов сравнивающей функции.
.text:0000000140001017 call sub_140001070
.text:000000014000101C mov edx, eax
.text:000000014000101C; Возвращенный предыдущей функцией результат
.text:000000014000101C; передаем функции printf вместе с форматной
.text:000000014000101C; строкой.
.text:000000014000101E lea rcx, Format ; "%x\n"
.text:000000014000101E; Вызов функции вывода значений на экран.
.text:0000000140001025 call printf
.text:0000000140001025; История повторяется, происходит подготовка
.text:0000000140001025; параметров для вызова функции.
.text:000000014000102A mov edx, [rsp+28h+arg_0]
.text:000000014000102E mov ecx, 666h
.text:000000014000102E; Вызов «встраиваемой» функции max,
.text:000000014000102E; но, как мы видим, встраиваемой она не стала.
.text:0000000140001033 call sub_140001070
.text:0000000140001033; Первый параметр для printf — возвращенное
.text:0000000140001033; max число, второй параметр — форматная
.text:0000000140001033; строка.
.text:0000000140001038 mov edx, eax
.text:000000014000103A lea rcx, asc_140016324 ; "%x\n"
.text:000000014000103A; Выводим параметры на экран посредством printf.
.text:0000000140001041 call printf
.text:0000000140001041; Подготовка параметров для вызова функции max.
.text:0000000140001046 mov edx, [rsp+28h+arg_0]
.text:000000014000104A mov ecx, 666h
.text:000000014000104A; Вызов функции max.
.text:000000014000104F call sub_140001070
.text:0000000140001054 mov edx, eax
.text:0000000140001056 lea rcx, asc_140016328 ; "%x\n"
.text:0000000140001056; Вывод результата на экран.
.text:000000014000105D call printf
.text:0000000140001062 xor eax, eax
.text:0000000140001064 add rsp, 28h
.text:0000000140001068 retn
.text:0000000140001068 main endp
«Так‑так», — шепчем себе под нос. И что же он тут накомпилировал? Встраиваемую функцию представил в виде обычной! Вот дела! Компилятор забил на наше желание сделать функцию встраиваемой (мы ведь написали модификатор inline
).
Ситуацию не исправляет даже использование параметров компилятора: /
или /
. Первый служит для отключения оптимизации, второй — для создания встраиваемых функций. Такими темпами компилятор вскоре будет генерировать код, угодный собственным предпочтениям или предпочтениям его разработчика, а не программиста, его использующего!
Остальное ты можешь увидеть в комментариях к дизассемблированному листингу. Сравнивающая функция max в дизассемблированном виде будет выглядеть так:
.text:0000000140001070 sub_140001070 proc near
.text:0000000140001070
.text:0000000140001070 arg_0 = dword ptr 8
.text:0000000140001070 arg_8 = dword ptr 10h
.text:0000000140001070
.text:0000000140001070 mov [rsp+arg_8], edx
.text:0000000140001074 mov [rsp+arg_0], ecx
.text:0000000140001078 mov eax, [rsp+arg_8]
.text:0000000140001078; Сравнение значений, переданных в параметрах.
.text:000000014000107C cmp [rsp+arg_0], eax
.text:000000014000107C; Если первый операнд меньше второго или равен ему,
.text:000000014000107C; переходим на метку, где возвращается второй
.text:000000014000107C; операнд.
.text:0000000140001080 jle short loc_140001088
.text:0000000140001080; В обратном случае возвращаем первый операнд.
.text:0000000140001082 mov eax, [rsp+arg_0]
.text:0000000140001086 jmp short locret_14000108C
.text:0000000140001088 ; --------------------------------------------
.text:0000000140001088
.text:0000000140001088 loc_140001088:
.text:0000000140001088 mov eax, [rsp+arg_8]
.text:000000014000108C
.text:000000014000108C locret_14000108C:
.text:000000014000108C retn
.text:000000014000108C sub_140001070 endp
Здесь тоже все важные фрагменты прокомментированы.
Напоследок предлагаю откомпилировать и рассмотреть следующий пример (Listing8). Он немного усложнен по сравнению с предыдущим, в нем в качестве одного из значений для сравнения используется аргумент командной строки, который преобразуется из строки в число и при выводе обратно.
#include <iostream>#include <sstream>#include <string>using namespace std;// Встраиваемая функция нахождения максимума.inline string max(int a, int b){ int val = (a > b) ? a : b; stringstream stream; // Преобразуем значение в hex-число. stream << "0x" << hex << val; string res = stream.str(); return res;}int main(int argc, char **argv){cout << max(0x666, 0x777) << endl;string par = argv[1];int val;// Если впереди параметра есть символы '0x',if (par.substr(0, 2) == "0x")// тогда это hex-число.val = stoi(argv[1], nullptr, 16);else// В ином случае это dec-число.val = stoi(argv[1], nullptr, 10);cout << max(0x666, val) << endl;cout << max(0x666, val) << endl;return 0;}
Сразу в начале своего выполнения программа вызывает встраиваемую функцию, передавая ей два шестнадцатеричных числа. В качестве результата функция возвращает большее из них, преобразованное в шестнадцатеричный формат. После чего основная функция выводит его в консоль.
Следующим действием программа берет параметр командной строки. Она различает числа двух форматов: десятичные и шестнадцатеричные, определяя их по отсутствию или наличию префикса 0x
. Два последующих оператора идентичны, в них происходят вызовы функции max, которой оба раза передаются одинаковые параметры: 0x666
и параметр командной строки, преобразованный из строки в число. Эти два последовательных оператора, как и в прошлый раз, позволят нам проследить вызовы функции.
Вместе с дополнительной функциональностью соответственно увеличился дизассемблерный листинг. Тем не менее суть происходящего не изменилась. Чтобы не приводить его здесь (он занимает реально много места), предлагаю тебе разобраться с ним самостоятельно.
Выводы
Тема идентификации ключевых структур очень важна, хотя бы потому, что в современных языках программирования этих структур великое множество. И в сегодняшней статье мы только начали рассматривать функции. Ведь, кроме приведенных выше функций (обычных, голых, встраиваемых) и способов их вызова (непосредственный вызов, по указателю, с комплексным вычислением адреса), существуют также виртуальные, библиотечные. Кроме того, к функциям можно отнести конструкторы и деструкторы. Но не будем забегать вперед.
Прежде чем переходить к методам объектов, статическим и виртуальным функциям, надо научиться идентифицировать стартовые функции, которые могут занимать значительную часть дизассемблерного листинга, но анализировать которые нет необходимости (за небольшими исключениями).