Исследование алгоритма работы программ, написанных на языках высокого уровня, традиционно начинается с реконструкции ключевых структур исходного языка — функций, локальных и глобальных переменных, ветвлений, циклов и так далее. Это делает дизассемблированный листинг более наглядным и значительно упрощает его анализ.
- Исследование алгоритма работы программ
- Идентификация функций
- Непосредственный вызов функции
- Вызов функции по указателю
- Вызов функции по указателю с комплексным вычислением целевого адреса
- «Ручной» вызов функции инструкцией JMP
- Автоматическая идентификация функций посредством IDA Pro
- Пролог
- Эпилог
- Специальное замечание
- «Голые» (naked) функции
- Идентификация встраиваемых (inline) функций
- Заключение
Исследование алгоритма работы программ
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Мы попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2017.
Современные дизассемблеры достаточно интеллектуальны и львиную долю распознавания ключевых структур берут на себя. В частности, IDA Pro успешно справляется с идентификацией стандартных библиотечных функций, локальных переменных, адресуемых через регистр ESP, case-ветвлений и прочего. Однако порой она ошибается, вводя исследователя в заблуждение, к тому же ее высокая стоимость не всегда оправдывает применение. Например, студентам, изучающим ассемблер (а лучшее средство изучения ассемблера — дизассемблирование чужих программ), она едва ли по карману.
РЕКОМЕНДУЕМ:
Лучший редактор бинарных файлов для Windows
Разумеется, на IDA свет клином не сошелся, существуют и другие дизассемблеры — скажем, тот же DUMPBIN, входящий в штатную поставку SDK. Почему бы на худой конец не воспользоваться им? Конечно, если под рукой нет ничего лучшего, сойдет и DUMPBIN, но в этом случае об интеллектуальности дизассемблера придется забыть и пользоваться исключительно своей головой.
Первым делом мы познакомимся с неоптимизирующими компиляторами — анализ их кода относительно прост и вполне доступен для понимания даже новичкам в программировании. Затем же, освоившись с дизассемблером, перейдем к вещам более сложным — оптимизирующим компиляторам, генерирующим очень хитрый, запутанный и витиеватый код.
Поставь любимую музыку, выбери любимый напиток и погрузись в глубины дизассемблерных листингов.
Неплохой сборник, как раз для продолжительной работы
Идентификация функций
Функция (также называемая процедурой или подпрограммой) — основная структурная единица процедурных и объектно ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов.
Строго говоря, термин «функция» присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку. Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать — это уже не суть важно. Ключевое свойство функции — возвращение управления на место ее вызова, а ее характерный признак — множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).
Откуда функция знает, куда следует возвратить управление? Очевидно, вызывающий код должен предварительно сохранить адрес возврата и вместе с прочими аргументами передать его вызываемой функции. Существует множество способов решения этой проблемы: можно, например, перед вызовом функции поместить в ее конец безусловный переход на адрес возврата, можно сохранить адрес возврата в специальной переменной и после завершения функции выполнить косвенный переход, используя эту переменную как операнд инструкции jump… Не останавливаясь на обсуждении сильных и слабых сторон каждого метода, отметим, что компиляторы в подавляющем большинстве случаев используют специальные машинные команды CALL и RET, соответственно предназначенные для вызова функций и возврата из них.
Инструкция CALL закидывает адрес следующей за ней инструкции на вершину стека, а RET стягивает и передает на него управление. Тот адрес, на который указывает инструкция CALL, и есть адрес начала функции. А замыкает функцию инструкция RET (но внимание: не всякий RET обозначает конец функции!).
Таким образом, распознать функцию можно двояко: по перекрестным ссылкам, ведущим к машинной инструкции CALL, и по ее эпилогу, завершающемуся инструкцией RET. Перекрестные ссылки и эпилог в совокупности позволяют определить адреса начала и конца функции. Немного забегая вперед, заметим, что в начале многих функций присутствует характерная последовательность команд, называемая прологом, которая также пригодна и для идентификации функций. А теперь рассмотрим все эти темы поподробнее.
Непосредственный вызов функции
Просматривая дизассемблерный код, находим все инструкции CALL — содержимое их операнда и будет искомым адресом начала функции. Адрес невиртуальных функций, вызываемых по имени, вычисляется еще на стадии компиляции, и операнд инструкции CALL в таких случаях представляет собой непосредственное значение. Благодаря этому адрес начала функции выявляется простым синтаксическим анализом: ищем контекстным поиском все подстроки CALL и запоминаем (записываем) непосредственные операнды.
Рассмотрим следующий пример (Listing1 в материалах к статье):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void func(); int main(){ int a; func(); a=0x666; func(); } void func(){ int a; a++; } |
Компилируем привычным образом:
1 |
cl.exe main.cpp /EHcs |
Результат компиляции в IDA Pro должен выглядеть приблизительно так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
.text:00401020 push ebp .text:00401021 mov ebp, esp .text:00401023 push ecx .text:00401024 call sub_401000 .text:00401024 ; Вот мы выловили инструкцию call c непосредственным операндом, .text:00401024 ; представляющим собой адрес начала функции. Точнее — ее смещение .text:00401024 ; в кодовом сегменте (в данном случае в сегменте .text). .text:00401024 ; Теперь можно перейти к строке .text:00401000 и, дав функции .text:00401024 ; собственное имя, заменить операнд инструкции call на конструкцию .text:00401024 ; «call offset Имя моей функции». .text:00401024 ; .text:00401029 mov [ebp+var_4], 666h .text:00401029 ; А вот наше знакомое число 0x666, присваиваемое переменной .text:00401030 call sub_401000 .text:00401030 ; А вот еще один вызов функции! Обратившись к строке .text:401000, .text:00401030 ; мы увидим, что эта совокупность инструкций уже определена как функция, .text:00401030 ; и все, что потребуется сделать, — заменить call 401000 на .text:00401030 ; «call offset Имя моей функции». .text:00401030 ; .text:00401035 xor eax, eax .text:00401037 mov esp, ebp .text:00401039 pop ebp .text:0040103A retn .text:0040103A ; Вот нам встретилась инструкция возврата из функции, однако не факт, .text:0040103A ; что это действительно конец функции, — ведь функция может иметь .text:0040103A ; и несколько точек выхода. Однако смотри: следом за ret расположено .text:0040103A ; начало следующей функции. Поскольку функции не могут перекрываться, .text:0040103A ; выходит, что данный ret — конец функции! .text:0040103A sub_401020 endp .text:0040103B sub_40103B proc near ; DATA XREF: .rdata:0040D11C?o .text:0040103B push esi .text:0040103C push 1 ... |
Судя по адресам, «наша функция» в листинге расположена выше функции main:
1 2 3 4 5 6 7 8 9 10 11 |
.text:00401000 push ebp .text:00401000 ; На эту строку ссылаются операнды нескольких инструкций call. .text:00401000 ; Следовательно, это адрес начала «нашей функции». .text:00401001 mov ebp, esp ; <- .text:00401003 push ecx ; <- .text:00401004 mov eax, [ebp+var_4] ; <- .text:00401007 add eax, 1 ; <- тело «нашей функции» .text:0040100A mov [ebp+var_4], eax ; <- .text:0040100D mov esp, ebp ; <- .text:0040100F pop ebp ; <- .text:00401010 retn ; <- |
Как видишь, все очень просто.
Вызов функции по указателю
Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, а не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями, однако в любом случае компилятор должен каким-то образом сохранить адрес функции в коде. Значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на «подследственную» инструкцию CALL точку останова и, дождавшись всплытия отладчика, посмотреть, по какому адресу она передаст управление. Рассмотрим следующий пример (Listing2):
1 2 3 4 5 6 7 8 9 |
int func(){ return 0; } int main(){ int (*a)(); a = func; a(); } |
Результат его компиляции должен в общем случае выглядеть так (функция main):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
.text:00401010 push ebp .text:00401011 mov ebp, esp .text:00401013 push ecx .text:00401014 mov [ebp+var_4], offset sub_401000 .text:0040101B call [ebp+var_4] .text:0040101B ; Вот инструкция CALL, выполняющая косвенный вызов функции .text:0040101B ; по адресу, содержащемуся в ячейке [ebp+var_4]. .text:0040101B ; Как узнать, что же там содержится? Поднимем глазки строчкой выше .text:0040101B ; и обнаружим: mov [ebp+var_4], offset sub_401000. Ага! .text:0040101B ; Значит, управление передается по смещению sub_401000, .text:0040101B ; где располагается адрес начала функции! Теперь осталось только .text:0040101B ; дать функции осмысленное имя. .text:0040101E xor eax, eax .text:00401020 mov esp, ebp .text:00401022 pop ebp .text:00401023 retn |
Вызов функции по указателю с комплексным вычислением целевого адреса
В некоторых, достаточно немногочисленных программах встречается и косвенный вызов функции с комплексным вычислением ее адреса. Рассмотрим следующий пример (Listing3):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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(); } } |
Результат дизассемблирования этого кода в общем случае должен выглядеть так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
.text:00401030 push ebp .text:00401031 mov ebp, esp .text:00401033 sub esp, 18h .text:00401036 mov eax, ___security_cookie .text:0040103B xor eax, ebp .text:0040103D mov [ebp+var_4], eax .text:00401040 mov [ebp+var_10], offset sub_401000 .text:00401047 mov [ebp+var_C], offset sub_401010 .text:0040104E mov [ebp+var_8], offset sub_401020 .text:00401055 mov [ebp+var_14], 0 .text:0040105C jmp short loc_401067 .text:0040105E ; -------------------------------------- .text:0040105E .text:0040105E loc_40105E: ; CODE XREF: sub_401030+4A?j .text:0040105E mov eax, [ebp+var_14] .text:00401061 add eax, 1 .text:00401064 mov [ebp+var_14], eax .text:00401067 .text:00401067 loc_401067: ; CODE XREF: sub_401030+2C?j .text:00401067 cmp [ebp+var_14], 3 .text:0040106B jge short loc_40107C .text:0040106D mov ecx, [ebp+var_14] .text:00401070 mov edx, [ebp+ecx*4+var_10] .text:00401074 mov [ebp+var_18], edx .text:00401077 call [ebp+var_18] .text:0040107A jmp short loc_40105E .text:0040107C ; -------------------------------------- .text:0040107C .text:0040107C loc_40107C: ; CODE XREF: sub_401030+3B?j .text:0040107C xor eax, eax .text:0040107E mov ecx, [ebp+var_4] .text:00401081 xor ecx, ebp .text:00401083 call @__security_check_cookie@4 ; __security_check_cookie(x) .text:00401088 mov esp, ebp .text:0040108A pop ebp .text:0040108B retn |
В строке call [ebp+var_18] происходит косвенный вызов функции. А что у нас в [ebp+var_18]? Поднимаем глаза на строку вверх — в [ebp+var_18] у нас значение edx. А чему равен сам edx? Прокручиваем еще одну строку вверх — edx равен содержимому ячейки [ebp+ecx*4+var_10]. Вот дела! Мало того что нам надо узнать содержимое этой ячейки, так еще и предстоит вычислить ее адрес!
Чему равен ECX? Содержимому [ebp+var_14]. А оно чему равно? «Сейчас выясним…» — бормочем мы себе под нос, прокручивая экран дизассемблера вверх. Ага, нашли: в строке 0x401064 в него загружается содержимое EAX! Какая радость! И долго мы будем так блуждать по коду?
Конечно, можно, затратив неопределенное количество времени, усилий и бодрящего напитка, реконструировать весь ключевой алгоритм целиком (тем более что мы практически подошли к концу анализа), но где гарантия, что при этом не будут допущены ошибки?
РЕКОМЕНДУЕМ:
Взлом приложений для Андроид с помощью отладчика
Гораздо быстрее и надежнее загрузить исследуемую программу в отладчик, установить бряк на строку text:00401077 и, дождавшись всплытия окна отладчика, посмотреть, что у нас расположено в ячейке [ebp+var_18]. Отладчик будет всплывать трижды, причем каждый раз показывать новый адрес! Заметим, что определить этот факт в дизассемблере можно только после полной реконструкции алгоритма.
Однако не стоит питать излишних иллюзий о мощи отладчика. Программа может тысячу раз вызывать одну и ту же функцию, а на тысяча первый — вызвать совсем другую. Отладчик бессилен это определить. Ведь вызов такой функции может произойти в непредсказуемый момент, например при определенном сочетании времени, обрабатываемых программой данных и текущей фазы Луны. Ну не будем же мы целую вечность гонять программу под отладчиком?
Дизассемблер — дело другое. Полная реконструкция алгоритма позволит однозначно и гарантированно отследить все адреса косвенных вызовов. Вот потому дизассемблер и отладчик должны скакать в одной упряжке!
Напоследок предлагаю взглянуть на такой участок дизассемблированного листинга:
1 2 3 4 5 |
.text:0040103D mov [ebp+var_4], eax .text:00401040 mov [ebp+var_10], offset sub_401000 .text:00401047 mov [ebp+var_C], offset sub_401010 .text:0040104E mov [ebp+var_8], offset sub_401020 .text:00401055 mov [ebp+var_14], 0 |
Воспользуемся средствами IDA и посмотрим, что загружается в ячейки памяти [ebp+…]. А это как раз адреса трех наших функций, последовательно размещенных компилятором друг за дружкой:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
.text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 xor eax, eax .text:00401005 pop ebp .text:00401006 retn .text:00401010 push ebp .text:00401011 mov ebp, esp .text:00401013 xor eax, eax .text:00401015 pop ebp .text:00401016 retn .text:00401020 push ebp .text:00401021 mov ebp, esp .text:00401023 xor eax, eax .text:00401025 pop ebp .text:00401026 retn |
«Ручной» вызов функции инструкцией JMP
Самый тяжелый случай представляют «ручные» вызовы функции командой JMP с предварительной засылкой в стек адреса возврата. Вызов через JMP в общем случае выглядит так: PUSH ret_addrr/JMP func_addr, где ret_addrr и func_addr — непосредственные или косвенные адреса возврата и начала функции соответственно. Кстати, заметим, что команды PUSH и JMP не всегда следуют одна за другой и порой бывают разделены другими командами.
Возникает резонный вопрос: чем же так плох CALL и зачем прибегать к JMP? Дело в том, что функция, вызванная по CALL, после возврата управления материнской функции всегда передает управление команде, следующей за CALL. В ряде случаев (например, при структурной обработке исключений) возникает необходимость после возврата из функции продолжать выполнение не со следующей за CALL командой, а совсем с другой ветки программы. Тогда-то и приходится вручную заносить требуемый адрес возврата и вызывать дочернюю функцию через JMP.
Идентифицировать такие функции очень сложно — контекстный поиск ничего не дает, поскольку команд JMP, использующихся для локальных переходов, в теле любой программы очень и очень много — попробуй-ка проанализируй их все! Если же этого не сделать, из поля зрения выпадут сразу две функции — вызываемая функция и функция, на которую передается управление после возврата. К сожалению, быстрых решений этой проблемы не существует, единственная зацепка — вызывающий JMP практически всегда выходит за границы функции, в теле которой он расположен. Определить же границы функции можно по эпилогу.
Рассмотрим следующий пример (Listing4):
1 2 3 4 5 6 7 8 9 10 11 12 |
int funct(){ return 0; } int main(){ __asm{ LEA ESI, return_addr PUSH ESI JMP funct return_addr: } } |
Результат его компиляции в общем случае должен выглядеть так:
1 2 3 4 5 6 7 |
.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 и посмотрим:
1 2 3 4 5 |
.text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 xor eax, eax .text:00401005 pop ebp .text:00401006 retn |
Как ты думаешь, куда этот retn возвращает управление? Естественно, по адресу, лежащему на верхушке стека. А что у нас лежит на стеке? PUSH EBP из строки 401000, обратно выталкивается инструкцией POP из строки 401005. Возвращаемся назад, к месту безусловного перехода, и начинаем медленно прокручивать экран дизассемблера вверх, отслеживая все обращения к стеку. Ага, попалась птичка! Инструкция PUSH ESI из строки 40101A закидывает на вершину стека содержимое регистра ESI, а он сам, в свою очередь, строкой выше принимает «на грудь» значение loc_401020 — это и есть адрес начала функции, вызываемой командой JMP (вернее, не адрес, а смещение, но это не принципиально важно):
1 2 3 |
.text:00401020 pop esi .text:00401021 pop ebp .text:00401022 retn |
Автоматическая идентификация функций посредством IDA Pro
Дизассемблер IDA Pro способен анализировать операнды инструкций CALL, что позволяет ему автоматически разбивать программу на функции. Причем IDA вполне успешно справляется с большинством косвенных вызовов. Между тем современные версии дизассемблера на раз-два справляются с комплексными и «ручными» вызовами функций командой JMP.
IDA успешно распознала «ручной» вызов функции
Пролог
Большинство неоптимизирующих компиляторов помещают в начало функции следующий код, называемый прологом:
1 2 3 |
push ebp mov ebp, esp sub esp, xx |
В общих чертах назначение пролога сводится к следующему: если регистр EBP используется для адресации локальных переменных (как часто и бывает), то перед его использованием он должен быть сохранен в стеке (иначе вызываемая функция «сорвет крышу» материнской), затем в EBP копируется текущее значение регистра указателя вершины стека ( ESP) — происходит так называемое открытие кадра стека, и значение ESP уменьшается на размер области памяти, выделенной под локальные переменные.
Последовательность PUSH EBP/MOV EBP,ESP/SUB ESP,xx может служить хорошей сигнатурой для нахождения всех функций в исследуемом файле, включая и те, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDA Pro, однако оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESP и используют EBP как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUB ESP, xxx — последовательность слишком короткая для использования ее в качестве сигнатуры функции, увы. Более подробный рассказ об эпилогах функций нас ждет впереди.
Эпилог
В конце своей жизни функция закрывает кадр стека, перемещая указатель вершины стека «вниз», и восстанавливает прежнее значение EBP (если только оптимизирующий компилятор не адресовал локальные переменные через ESP, используя EBP как обычный регистр общего назначения). Эпилог функции может выглядеть двояко: либо ESP увеличивается на нужное значение командой ADD, либо в него копируется значение EBP, указывающее на низ кадра стека.
Обобщенный код эпилога функции выглядит так. Эпилог 1:
1 2 3 |
pop ebp add esp, 64h retn |
Эпилог 2:
1 2 3 |
mov esp, ebp pop ebp retn |
Важно отметить: между командами POP EBP/ADD ESP, xxx и MOV ESP,EBP/POP EBP могут находиться и другие команды — они необязательно должны следовать вплотную друг к другу. Поэтому для поиска эпилогов контекстный поиск непригоден — требуется применять поиск по маске.
Если функция написана с учетом соглашения PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это делается инструкцией RET n, где n — количество байтов, снимаемых из стека после возврата. Функции же, соблюдающие С-соглашение, предоставляют очистку стека вызывающему их коду и всегда оканчиваются командой RET. API-функции Windows представляют собой комбинацию соглашений С и PASCAL — аргументы заносятся в стек справа налево, но очищает стек сама функция.
Таким образом, RET может служить достаточным признаком эпилога функции, но не всякий эпилог — это конец. Если функция имеет в своем теле несколько операторов return (как часто и бывает), компилятор в общем случае генерирует для каждого из них свой собственный эпилог. Необходимо обратить внимание, находится ли за концом эпилога новый пролог или продолжается код старой функции. Также нельзя забывать и о том, что компиляторы обычно (но не всегда!) не помещают в исполняемый файл код, никогда не получающий управления. Иначе говоря, у функции будет всего один эпилог, а все находящееся после первого return будет выброшено как ненужное.
Между тем не стоит спешить вперед паровоза. Откомпилируем с параметрами по умолчанию следующий пример (Listing5):
1 2 3 4 5 6 7 8 9 |
int func(int a){ return a++; a=1/a; return a; } int main(){ func(1); } |
Откомпилированный результат будет выглядеть так (приведен код только функции func):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
.text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push ecx .text:00401004 mov eax, [ebp+arg_0] .text:00401007 mov [ebp+var_4], eax .text:0040100A mov ecx, [ebp+arg_0] .text:0040100D add ecx, 1 ; Производим сложение .text:00401010 mov [ebp+arg_0], ecx .text:00401013 mov eax, [ebp+var_4] .text:00401016 jmp short loc_401027 ; Выполняем безусловный переход .text:00401016 ; на эпилог функции .text:00401018 ; -------------------------------------- .text:00401018 mov eax, 1 .text:0040101D cdq .text:0040101E idiv [ebp+arg_0] ; Код деления единицы на параметр .text:00401021 mov [ebp+arg_0], eax ; остался .text:00401024 mov eax, [ebp+arg_0] ; Компилятор не посчитал нужным его убрать .text:00401027 .text:00401027 loc_401027: ; CODE XREF: sub_401000+16?j .text:00401027 mov esp, ebp ; При этом эпилог только один .text:00401029 pop ebp ; .text:0040102A retn |
Теперь посмотрим, какой код сгенерирует компилятор, когда внеплановый выход из функции происходит при срабатывании некоторого условия (Listing6):
1 2 3 4 5 6 7 8 9 |
int func(int a){ if (a != 0) return a++; return 1/a; } int main(){ func(1); } |
Результат компиляции (только func):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
.text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push ecx .text:00401004 cmp [ebp+arg_0], 0 ; Сравниваем аргумент функции с нулем .text:00401008 jz short loc_40101E ; Если они равны, переходим на метку и .text:00401008 ; выполняем команду деления .text:0040100A mov eax, [ebp+arg_0] ; Если же .text:0040100D mov [ebp+var_4], eax ; не равны, .text:00401010 mov ecx, [ebp+arg_0] ; то выполняем .text:00401013 add ecx, 1 ; инкремент .text:00401016 mov [ebp+arg_0], ecx .text:00401019 mov eax, [ebp+var_4] .text:0040101C jmp short loc_401027 .text:0040101E ; -------------------------------------- .text:0040101E .text:0040101E loc_40101E: ; CODE XREF: sub_401000+8?j .text:0040101E mov eax, 1 .text:00401023 cdq .text:00401024 idiv [ebp+arg_0] ; Деление 1 на аргумент .text:00401027 .text:00401027 loc_401027: ; CODE XREF: sub_401000+1C?j .text:00401027 mov esp, ebp ; <-- Это явно эпилог .text:00401029 pop ebp ; <-- .text:0040102A retn ; <-- |
Как и в предыдущем случае, компилятор создал только один эпилог. Обрати внимание: в начале функции в строке 00401004 аргумент сравнивается с нулем, если условие выполняется, происходит переход на метку loc_40101E, где выполняется деление, за которым сразу следует эпилог. Если же условие в строке 00401004 не выполняется, выполняется сложение и происходит безусловный прыжок на эпилог.
Специальное замечание
Начиная с процессора 80286 в наборе команд появились две инструкции ENTER и LEAVE, предназначенные специально для открытия и закрытия кадра стека. Однако они практически никогда не используются современными компиляторами. Почему?
Причина в том, что ENTER и LEAVE очень медлительны, намного медлительнее PUSH EBP/MOV EBP,ESP/SUB ESB, xxx и MOV ESP,EBP/POP EBP. Так, на старом добром Pentium ENTER выполняется за десять тактов, а приведенная последовательность команд — за семь. Аналогично LEAVE требует пять тактов, хотя ту же операцию можно выполнить за два (и даже быстрее, если разделить MOV ESP,EBP/POP EBP какой-нибудь командой).
РЕКОМЕНДУЕМ:
Реверс Андроид приложений
Поэтому современный исследователь никогда не столкнется ни с ENTER, ни с LEAVE. Хотя помнить об их назначении будет нелишне (мало ли, вдруг придется дизассемблировать древние программы или программы, написанные на ассемблере, — не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их «ручная оптимизация» заметно уступает компилятору по производительности).
«Голые» (naked) функции
Компилятор Microsoft Visual C++ поддерживает нестандартный квалификатор naked, позволяющий программистам создавать функции без пролога и эпилога. Компилятор даже не помещает в конце функции RET, и это приходится делать «вручную», прибегая к ассемблерной вставке __asm{ret} (использование return не приводит к желаемому результату).
Вообще-то поддержка naked-функций задумывалась исключительно для написания драйверов на чистом С (с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность «ручного» создания функций, не беспокоясь, что их непредсказуемым образом «изуродует» компилятор.
Для нас же, кодокопателей, в первом приближении это означает, что в программе может встретиться одна или несколько функций, не содержащих ни пролога, ни эпилога. Ну и что в этом страшного? Оптимизирующие компиляторы так же выкидывают пролог, а от эпилога оставляют один лишь RET, но функции элементарно идентифицируются по вызывающей их инструкции CALL.
Идентификация встраиваемых (inline) функций
Самый эффективный способ избавиться от накладных расходов на вызов функций — не вызывать их. В самом деле, почему бы не встроить код функции непосредственно в саму вызывающую функцию? Конечно, это ощутимо увеличит размер (и тем ощутимее, чем из больших мест функция вызывается), но зато значительно увеличит скорость выполнения программы (и тем значительнее, чем чаще развернутая функция вызывается).
Чем плоха развертка функций для исследования программы? Прежде всего, она увеличивает размер материнской функции и делает ее код менее наглядным — вместо CALL\TEST EAX,EAX\JZ xxx с бросающимся в глаза условным переходом мы видим кучу ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться.
Встроенные функции не имеют ни собственного пролога, ни эпилога, их код и локальные переменные (если таковые имеются) полностью вживлены в вызывающую функцию, результат компиляции выглядит в точности так, как будто бы никакого вызова функции и не было. Единственная зацепка — встраивание функции неизбежно приводит к дублированию ее кода во всех местах вызова, а это хоть и с трудом, но можно обнаружить. С трудом потому, что встраиваемая функция, становясь частью вызывающей функции, всквозную оптимизируется в контексте последней, что приводит к значительным вариациям кода.
Рассмотрим следующий пример, чтобы увидеть, как компилятор оптимизирует встраиваемую функцию (Listing7):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#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):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
.text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push 777h ; Подготавливаем два .text:00401008 push 666h ; аргумента функции .text:0040100D call sub_401070 ; Вызов сравнивающей функции max .text:00401012 add esp, 8 .text:00401015 push eax .text:00401016 push offset unk_412160 ; Добавляем параметр %x .text:0040101B call sub_4010D0 ; Вызов printf .text:00401020 add esp, 8 .text:00401023 mov eax, [ebp+arg_0] ; Берем аргумент argc — количество аргументов .text:00401026 push eax .text:00401027 push 666h ; Запихиваем константу .text:0040102C call sub_401070 ; Вызов функции max .text:00401031 add esp, 8 .text:00401034 push eax .text:00401035 push offset unk_412164 ; Добавляем параметр ‘%x’ .text:0040103A call sub_4010D0 ; Вызов printf .text:0040103F add esp, 8 .text:00401042 mov ecx, [ebp+arg_0] ; <-- .text:00401045 push ecx ; <-- .text:00401046 push 666h ; <-- .text:0040104B call sub_401070 ; <-- Аналогичная .text:00401050 add esp, 8 ; <-- последовательность .text:00401053 push eax ; <-- действий .text:00401054 push offset unk_412168 ; <-- .text:00401059 call sub_4010D0 ; <-- .text:0040105E add esp, 8 .text:00401061 xor eax, eax .text:00401063 pop ebp .text:00401064 retn |
«Так-так», — шепчем себе под нос. И что же он тут накомпилировал? Встраиваемую функцию представил в виде обычной! Вот дела! Компилятор забил на наше желание сделать функцию встраиваемой (мы ведь написали модификатор inline). Ситуацию не исправляет даже использование параметров компилятора: /Od или /Oi. Первый служит для отключения оптимизации, второй — для создания встраиваемых функций. Такими темпами компилятор вскоре будет генерировать код, угодный собственным предпочтениям или предпочтениям его разработчика, а не программиста, его использующего! Остальное ты можешь увидеть в комментариях к дизассемблированному листингу.
Сравнивающая функция max в дизассемблированном виде будет выглядеть так:
1 2 3 4 5 6 7 8 9 10 11 12 |
.text:00401070 push ebp .text:00401071 mov ebp, esp .text:00401073 mov eax, [ebp+arg_0] .text:00401076 cmp eax, [ebp+arg_4] ; Сравниваем и в зависимости .text:00401079 jle short loc_401080 ; от результата — переходим .text:0040107B mov eax, [ebp+arg_0] .text:0040107E jmp short loc_401083 ; Безусловный переход на эпилог .text:00401080 loc_401080: ; CODE XREF: sub_401070+9?j .text:00401080 mov eax, [ebp+arg_4] .text:00401083 loc_401083: ; CODE XREF: sub_401070+E?j .text:00401083 pop ebp .text:00401084 retn |
Здесь тоже все важные фрагменты прокомментированы.
Напоследок предлагаю откомпилировать и рассмотреть следующий пример (Listing8). Он немного усложнен по сравнению с предыдущим, в нем в качестве одного из значений для сравнения используется аргумент командной строки, который преобразуется из строки в число и при выводе обратно.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> #include <sstream> #include <string> using namespace std; inline string max(int a, int b){ // Встраиваемая функция нахождения максимума int val = (a > b) ? a : b; stringstream stream; stream << "0x" << hex << val; // Преобразуем значение в hex-число string res = stream.str(); return res; } int main(int argc, char **argv){ cout << max(0x666, 0x777) << endl; string par = argv[1]; int val; if (par.substr(0, 2) == "0x") // Если впереди параметра есть символы '0x', val = stoi(argv[1], nullptr, 16); // тогда это hex-число, else val = stoi(argv[1], nullptr, 10); // в ином случае — dec-число cout << max(0x666, val) << endl; cout << max(0x666, val) << endl; return 0; } |
VS Code
Сразу в начале своего выполнения программа вызывает встраиваемую функцию, передавая ей два шестнадцатеричных числа. В качестве результата функция возвращает большее из них, преобразованное в шестнадцатеричный формат. После чего основная функция выводит его в консоль.
Следующим действием программа берет параметр командной строки. Она различает числа двух форматов: десятичные и шестнадцатеричные, определяя их по отсутствию или наличию префикса
0x. Два последующих оператора идентичны, в них происходят вызовы функции
max, которой оба раза передаются одинаковые параметры:
0x666 и параметр командной строки, преобразованный из строки в число. Эти два последовательных оператора, как и в прошлый раз, позволят нам проследить вызовы функции.
Вывод приложения
Вместе с дополнительной функциональностью соответственно увеличился дизассемблерный листинг. Тем не менее суть происходящего не изменилась. Чтобы не приводить его здесь (он занимает реально много места), предлагаю тебе разобраться с ним самостоятельно.
Заключение
Тема «Идентификация ключевых структур» очень важна, хотя бы потому, что в современных языках программирования этих структур великое множество. И в сегодняшней статье мы только начали рассматривать функции. Ведь, кроме приведенных выше функций (обычных, голых, встраиваемых) и способов их вызова (непосредственный вызов, по указателю, с комплексным вычислением адреса), существуют также виртуальные, библиотечные. Кроме того, к функциям можно отнести конструкторы и деструкторы. Но не будем забегать вперед.
РЕКОМЕНДУЕМ:
Как обмануть нейронную сеть
Прежде чем переходить к методам объектов, статическим и виртуальным функциям, надо научиться идентифицировать стартовые функции, которые могут занимать значительную часть дизассемблерного листинга, но анализировать которые нет необходимости (за небольшими исключениями). Поэтому, дорогой друг, напиши в комментах к статье, что ты думаешь о теме идентификации и какие конструкции тебе интересны для анализа.