Вам может быть интересно, чего ожидать от книги, посвященной IDA Pro. Хотя эта книга явно ориентирована на IDA, она не предназначена для того, чтобы воспринимать её как Руководство пользователя IDA Pro. Вместо этого мы намерены использовать IDA в качестве вспомогательного инструмента для обсуждения методов реверс инжиниринга, которые вы найдете полезными при анализе широкого спектра программного обеспечения, от уязвимых приложений до вредоносных программ. При необходимости мы предоставим подробные инструкции, которым необходимо следовать в IDA для выполнения конкретных действий, связанных с поставленной задачей. В результате мы сделаем довольно окольный обход возможностей IDA, начиная с основных задач, которые вы захотите выполнить при первоначальном исследовании файла, и заканчивая расширенным использованием и настройкой IDA для решения более сложных задач обратного проектирования. Мы не пытаемся охватить все возможности IDA. Тем не менее, мы рассматриваем функции, которые будут наиболее полезными для решения ваших задач реверс инжиниринга. Эта книга поможет сделать IDA самым мощным оружием в вашем арсенале инструментов.
Прежде чем углубляться в какие-либо особенности IDA, будет полезно осветить некоторые основы процесса дизассемблирования, а также рассмотреть некоторые другие инструменты, доступные для обратного проектирования скомпилированного кода. Хотя ни один из этих инструментов не предлагает полный спектр возможностей IDA, каждый из них обращается к определенным подмножествам функций IDA и предлагает ценную информацию о конкретных функциях IDA. Остальная часть этой главы посвящена пониманию процессу дизассембилрования.
Теория дизассемблирования
Любой, кто хоть сколько-нибудь изучал языки программирования, вероятно, уже знал о различных поколениях языков, но они приведены здесь для тех, кто, возможно, спал.
Языки первого поколения
Это низшая форма языка, обычно состоящая из единиц и нулей или некоторой сокращенной формы, такой как шестнадцатеричная, и читаемая только двоичными ниндзя. На этом уровне все сбивает с толку, потому что часто бывает трудно отличить данные от инструкций, поскольку все выглядит примерно одинаково. Языки первого поколения также могут называться машинными языками и в некоторых случаях байтовым кодом, в то время как программы на машинном языке часто называются двоичными.
Языки второго поколения
Языки второго поколения, также называемые языками ассемблера, представляют собой простой поиск по таблицам, отличные от машинного языка, и обычно отображают определенные битовые шаблоны или коды операций (опкоды) в короткие, но запоминающиеся последовательности символов, называемые мнемоникой. Иногда эти мнемоники на самом деле помогают программистам запомнить инструкции, с которыми они связаны. Ассемблер - это инструмент, используемый программистами для перевода своих программ на ассемблере на машинный язык, пригодный для выполнения.
Языки третьего поколения
Эти языки делают еще один шаг к выразительной способности естественных языков, вводя ключевые слова и конструкции, которые программисты используют в качестве строительных блоков для своих программ. Языки третьего поколения обычно не зависят от платформы, хотя программы, написанные с их помощью, могут зависеть от платформы в результате использования функций, уникальных для конкретной операционной системы. Часто цитируемые примеры включают FORTRAN, COBOL, C и Java. Программисты обычно используют компиляторы для перевода своих программ на ассемблер или полностью на машинный язык (или какой-то грубый эквивалент, такой как байтовый код).
Языки четвертого поколения
Они существуют, но не имеют отношения к этой книге и не будут обсуждаться.
Что такое дизассембилрование
В традиционной модели разработки программного обеспечения компиляторы, ассемблеры и компоновщики используются сами по себе или в комбинации для создания исполняемых программ. Чтобы работать в обратном направлении (или реверсировать программы), мы используем инструменты для отмены процессов ассемблирования и компиляции. Неудивительно, что такие инструменты называются дизассемблерами и декомпиляторами, и они делают в значительной степени то, что подразумевают их названия. Дизассемблер отменяет процесс ассемблирования, поэтому мы должны ожидать язык ассемблера в качестве выходных данных (и, следовательно, машинный язык в качестве входных). Декомпиляторы стремятся производить вывод на языке высокого уровня, когда в качестве входных данных используется ассемблер или даже машинный язык.
Обещание “восстановления исходного кода” всегда будет привлекательным на конкурентном рынке программного обеспечения, и поэтому разработка пригодных для использования декомпиляторов остается активной областью исследований в области компьютерных наук. Ниже приведены лишь некоторые из причин, по которым декомпиляция затруднена:
Процесс компиляции это процесс с потерями.
На уровне машинного языка нет имен переменных или функций, и информацию о типе переменных можно определить только по тому, как используются данные, а не по явным объявлениям типов. Когда вы видите, что передаются 32 бита данных, вам нужно будет провести некоторую исследовательскую работу, чтобы определить, представляют ли эти 32 бита целое число, 32-битное значение с плавающей запятой или 32-битный указатель.
Компиляция - это операция "многие ко многим".
Это означает, что исходная программа может быть переведена на язык ассемблера множеством различных способов, а машинный язык может быть переведен обратно в исходный код множеством различных способов. В результате довольно часто при компиляции файла и его немедленной декомпиляции исходный файл может сильно отличаться от того, который был введен.
Декомпиляторы очень зависят от языка и библиотеки.
Обработка двоичного файла, созданного компилятором Delphi, декомпилятором, предназначенным для генерации кода C, может дать очень странные результаты. Точно так же загрузка скомпилированного двоичного файла Windows через декомпилятор, который не знает API программирования Windows, может не дать ничего полезного.
Для точной декомпиляции двоичного файла требуется почти идеальная возможность дизассемблирования.
Любые ошибки или упущения на этапе дизассемблирования почти наверняка распространятся на декомпилированный код.
Hex-Rays, самый сложный декомпилятор на рынке сегодня, будет рассмотрен в главе 23.
Почему дизассемблирование
Инструменты дизассемблирования часто используются для облегчения понимания программ, когда исходный код недоступен.Общие ситуации, в которых используется дизассемблирование, включают следующие:
-Анализ вредоносных программ
-Анализ ПО с закрытым исходным кодом на наличие уязвимостей
-Анализ программного обеспечения с закрытым исходным кодом на предмет взаимодействия
-Анализ кода, созданного компилятором для проверки производительности компилятора/правильности компилятора
-Отображение инструкций программы во время отладки
В последующих разделах каждая ситуация объясняется более подробно.
Анализ вредоносного ПО
Если вы не имеете дело с червем на основе сценариев, авторы вредоносных программ редко предоставляют вам исходный код своих созданий. Не имея исходного кода, вы сталкиваетесь с очень ограниченным набором возможностей для точного определения того, как ведет себя вредоносная программа. Двумя основными методами анализа вредоносных программ являются динамический анализ и статический анализ. Динамический анализ предполагает выполнение вредоносной программы в тщательно контролируемой среде (песочнице) с одновременной записью всех наблюдаемых аспектов ее поведения с использованием любого количества системных инструментальных утилит. Напротив, статический анализ пытается понять поведение программы, просто читая программный код, который в случае вредоносного ПО обычно состоит из списка дизассемблирования.
Анализ уязвимости
Для упрощения давайте разделим весь процесс аудита безопасности на три этапа: обнаружение уязвимостей, анализ уязвимостей и разработка эксплойтов. Те же шаги применяются независимо от того, есть у вас есть исходный код или нет; однако уровень усилий существенно возрастает, когда все, что у вас есть, является двоичным. Первым шагом в этом процессе является обнаружение потенциально уязвимого состояния в программе. Это часто достигается с помощью динамических методов, таких как фаззинг, но это также может быть выполнено (обычно с гораздо большими усилиями) с помощью статического анализа. После того, как проблема обнаружена, часто требуется дальнейший анализ, чтобы определить, можно ли вообще использовать проблему, и если да, то при каких условиях.
Листинги дизассемблирования обеспечивают уровень детализации, необходимый для точного понимания того, как компилятор выбрал размещение переменных программы. Например, может быть полезно знать, что 70-байтовый массив символов, объявленный программистом, был округлен до 80 байтов при выделении компилятором. Листинги дизассемблирования также предоставляют единственное средство точно определить, как компилятор выбрал порядок всех переменных, объявленных глобально или внутри функций. Понимание пространственных отношений между переменными часто бывает необходимо при попытке разработать эксплойты. В конечном итоге, используя дизассемблер и отладчик вместе, можно разработать эксплойт.
Совместимость программного обеспечения
Когда программное обеспечение выпускается только в двоичной форме, конкурентам очень трудно создать программное обеспечение, которое могло бы взаимодействовать с ним, или предоставить замену плагинов для этого программного обеспечения. Типичным примером является выпуск кода драйвера для оборудования, которое поддерживается только на одной платформе. Когда поставщик медленно поддерживает или, что еще хуже, отказывается поддерживать использование своего оборудования с альтернативными платформами, могут потребоваться значительные усилия по реверс инжинирингу для разработки драйверов программного обеспечения для поддержки оборудования. В этих случаях статический анализ кода - почти единственное средство, и часто для понимания встроенного микропрограммного обеспечения необходимо выходить за рамки программного драйвера.
Проверка компилятора
Поскольку целью компилятора (или ассемблера) является создание машинного языка, часто требуются хорошие инструменты дизассемблирования, чтобы убедиться, что компилятор выполняет свою работу в соответствии с любыми проектными спецификациями. Аналитики также могут быть заинтересованы в поиске дополнительных возможностей для оптимизации вывода компилятора и, с точки зрения безопасности, в определении того, был ли скомпрометирован сам компилятор до такой степени, что он может вставлять лазейки в сгенерированный код.
Debugging Displays
Возможно, наиболее распространенное использование дизассемблеров - создание листингов в отладчиках. К сожалению, дизассемблеры, встроенные в отладчики, как правило, довольно просты. Обычно они неспособны к пакетному дизассемблированию и иногда отказываются от дизассемблирования, когда не могут определить границы функции. Это одна из причин, по которой лучше всего использовать отладчик в сочетании с высококачественным дизассемблером, чтобы обеспечить лучшую ситуационную осведомленность и контекст во время отладки.
Как дизассемблировать
Теперь, когда вы хорошо разбираетесь в целях дизассемблирования, пора перейти к тому, как этот процесс работает на самом деле. Рассмотрим типичную сложную задачу, с которой сталкивается дизассемблер: возьмите эти 100 КБ, отделите код от данных, преобразуйте код на язык ассемблера для отображения пользователю и, пожалуйста, не упустите ничего в процессе. В конце этого мы могли бы выполнить любое количество специальных запросов, например, попросить дизассемблер найти функции, распознать таблицы переходов и идентифицировать локальные переменные, что значительно усложняет работу дизассемблера.
Чтобы удовлетворить все наши требования, любой дизассемблер должен будет выбирать из множества алгоритмов при навигации по файлам, которые мы ему загружаем. Качество созданного листинга дизассемблирования будет напрямую зависеть от качества используемых алгоритмов и того, насколько хорошо они были реализованы. В этом разделе мы обсудим два основных алгоритма, используемых сегодня для дизассемблирования машинного кода. Представляя эти алгоритмы, мы также будем указывать на их недостатки, чтобы подготовить вас к ситуациям, в которых ваш дизассемблер не работает. Понимая ограничения дизассемблера, вы сможете вручную вмешаться, чтобы улучшить общее качество вывода дизассемблера.
Базовый алгоритм дизассемблирования
Для начала давайте разработаем простой алгоритм для принятия машинного языка в качестве ввода и создания языка ассемблера в качестве вывода. Поступая таким образом, мы получим понимание проблем, предположений и компромиссов, лежащих в основе автоматизированного процесса дизассемблирования.
Шаг 1
Первым шагом в процессе дизассемблирования является определение области кода для дизассемблирования. Это не обязательно так просто, как может показаться. Инструкции обычно смешиваются с данными, и важно различать их. В наиболее распространенном случае, дизассемблировании исполняемого файла, файл будет соответствовать общему формату для исполняемых файлов, такому как формат Portable Executable (PE), используемый в Windows, или Executable and Linking Format (ELF), распространенный во многих Unix системах. Эти форматы обычно содержат механизмы (часто в форме иерархических заголовков файлов) для поиска разделов файла, содержащих код и точки входа в этот код.
Шаг 2
Учитывая начальный адрес инструкции, следующим шагом будет считывание значения, содержащегося по этому адресу (или смещение файла), и выполнение поиска в таблице для сопоставления значения двоичного кода операции с мнемоникой языка ассемблера. В зависимости от сложности дизассемблируемого набора команд это может быть тривиальный процесс или он может включать в себя несколько дополнительных операций, таких как понимание любых префиксов, которые могут изменять поведение инструкции, и определение любых операндов, требуемых инструкцией. Для наборов инструкций с инструкциями переменной длины, таких как Intel x86, может потребоваться получение дополнительных байтов инструкций, чтобы полностью разобрать одну инструкцию.
Шаг 3
После получения инструкции и декодирования всех необходимых операндов ее эквивалент на языке ассемблера форматируется и выводится как часть списка дизассемблирования. Можно выбрать более одного синтаксиса вывода на языке ассемблера. Например, два преобладающих формата для языка ассемблера x86 - это формат Intel и формат AT&T.
СИНТАКСИС АССЕМБЛЕРА X86: AT&T VS. INTEL
Для исходного кода используются два основных синтаксиса: AT&T и Intel. Несмотря на то, что это языки второго поколения, эти два языка сильно различаются по синтаксису: от доступа к переменным, константам и регистрам до переопределений размера сегмента и инструкции до косвенного обращения и смещений. Синтаксис AT&T отличается использованием символа % для префикса всех имен регистров, использованием $ в качестве префикса для литеральных констант (также называемых непосредственными операндами) и упорядочением его операндов, в котором исходный операнд отображается как левый операнд и операнд-адресат появляются справа. Используя синтаксис AT&T, инструкция по добавлению значения 4 в регистр EAX будет выглядеть так: add $ 0x4, % eax. GNU Assembler (Gas) и многие другие инструменты GNU, включая gcc и gdb, используют синтаксис AT&T.
Синтаксис Intel отличается от AT&T тем, что не требует регистров или буквенных префиксов, а порядок операндов меняется на противоположный, так что исходный операнд отображается справа, а место назначения отображается слева. Та же самая инструкция добавления с использованием синтаксиса Intel будет выглядеть так: add eax, 0x4.Ассемблеры, использующие синтаксис Intel, включают в себя Microsoft Assembler (MASM), Borland's Turbo Assembler (TASM) и Netwide Assembler (NASM).
Шаг 4
После вывода инструкции нам нужно перейти к следующей инструкции и повторять предыдущий процесс до тех пор, пока мы не дизассемблируем все инструкции в файле. Существуют различные алгоритмы для определения, где начать дизассемблирование, как выбрать следующую инструкцию для дизассемблирования, как отличить код от данных и как определить, когда была дизассемблирована последняя инструкция. Два преобладающих алгоритма дизассемблирования - это линейная развертка и рекурсивный спуск.
Линейная развертка
Алгоритм дизассемблирования линейной развертки использует очень простой подход к поиску инструкций для дизассемблирования: там, где заканчивается одна инструкция, начинается другая. В результате самое трудное решение - с чего начать. Обычное решение состоит в том, чтобы предположить, что все, что содержится в разделах программы, помеченных как код (обычно указываемых в заголовках файлов программы), представляет собой инструкции машинного языка. Дизассемблирование начинается с первого байта в секции кода и линейно перемещается по секции, дизассемблируя одну инструкцию за другой, пока не будет достигнут конец секции. Не предпринимается никаких усилий, чтобы понять поток управления программой через распознавание нелинейных инструкций, таких как переходы.
Во время процесса дизассемблирования можно удерживать указатель, чтобы отметить начало инструкции, которая в настоящее время дизассемблируется. Как часть процесса дизассемблирования, длина каждой инструкции вычисляется и используется для определения местоположения следующей инструкции, которая будет дизассемблирована. Наборы инструкций с инструкциями фиксированной длины (например, MIPS) несколько легче разбирать, поскольку найти последующие инструкции несложно.
Основное преимущество алгоритма линейной развертки заключается в том, что он обеспечивает полное покрытие участков кода программы. Одним из основных недостатков метода линейной развертки является то, что он не учитывает тот факт, что данные могут быть объединены с кодом. Это очевидно в листинге 1-1, где показан вывод функции, разобранной дизассемблером с линейной разверткой. Эта функция содержит оператор switch, и используемый в этом случае компилятор решил реализовать switch с помощью таблицы переходов. Более того, компилятор решил встроить таблицу переходов в саму функцию. Оператор jmp по адресу 401250, ссылается на таблицу адресов, начиная с адреса 401257. К сожалению, дизассемблер обрабатывает это как инструкцию и неправильно генерирует соответствующее представление на языке ассемблера:
Если мы рассмотрим последовательные 4-байтовые группы как значения little-endian, начинающиеся с [2], мы увидим, что каждая из них представляет собой указатель на ближайший адрес, который фактически является местом назначения для одного из различных переходов (004012e0, 0040128b, 00401290, ...) .Таким образом, инструкция loopne в [2] вообще не является инструкцией. Вместо этого это указывает на неспособность алгоритма линейной развертки правильно отличить встроенные данные от кода.
Линейная развертка используется механизмами дизассемблирования, содержащимися в отладчике GNU (gdb), отладчике Microsoft WinDbg и утилите objdump.
Рекурсивный спуск
Рекурсивный спуск использует другой подход к поиску инструкций. Рекурсивный спуск фокусируется на концепции потока управления, который определяет, следует ли дизассемблировать инструкцию, в зависимости от того, ссылается ли на нее другая инструкция. Чтобы понять рекурсивный спуск, полезно классифицировать инструкции в соответствии с тем, как они влияют на указатель инструкций ЦП.
Инструкции по последовательному потоку
Команды с последовательным потоком передают выполнение инструкции, которая следует сразу за ними. Примеры инструкций последовательного выполнения включают простые арифметические инструкции, такие как сложение; инструкции передачи из регистра в память, такие как mov; и операции управления стеком, такие как push и pop. Для таких инструкций дизассемблирование происходит как при линейной развертке.
Условные инструкции ветвления
Инструкции условного перехода, такие как x86 jnz, предлагают два возможных пути выполнения. Если условие истинно, выполняется переход, и указатель инструкции должен быть изменен, чтобы отразить цель перехода. Однако, если условие ложно, выполнение продолжается в линейном режиме, и для дизассемблирования следующей инструкции может использоваться методология линейной развертки. Поскольку в статическом контексте обычно невозможно определить результат условного теста, алгоритм рекурсивного спуска дизассемблирует оба пути, откладывая дизассемблирование целевой инструкции ветвления, добавляя адрес целевой инструкции в список адресов, которые нужно дизассемблировать позже.
Инструкции безусловного ветвления
Безусловные переходы не следуют модели линейного потока и, следовательно, по-разному обрабатываются алгоритмом рекурсивного спуска. Как и в случае инструкций с последовательным потоком, выполнение может переходить только к одной инструкции; однако эта инструкция не обязательно должна следовать сразу за инструкцией ветвления. Фактически, как видно из Листинга 1-1, нет никакого требования, чтобы инструкция сразу следовала за безусловным переходом. Следовательно, нет причин разбирать байты, следующие за безусловным переходом.
Дизассемблер с рекурсивным спуском попытается определить цель безусловного перехода и добавить адрес назначения в список адресов, которые еще предстоит изучить. К сожалению, некоторые безусловные переходы могут вызвать проблемы для дизассемблеров с рекурсивным спуском. Когда цель инструкции перехода зависит от значения времени выполнения, может оказаться невозможным определить назначение перехода с помощью статического анализа. Инструкция jmp eax для x86 демонстрирует эту проблему. Регистр eax содержит значение только тогда, когда программа действительно работает. Поскольку регистр не содержит значения во время статического анализа, у нас нет возможности определить цель инструкции перехода, и, следовательно, у нас нет способа определить, где продолжить процесс дизассемблирования.
Инструкции вызова функций
Инструкции вызова функции работают аналогично инструкциям безусловного перехода (включая неспособность дизассемблера определить цель инструкций, таких как call eax), с дополнительным ожиданием того, что выполнение обычно возвращается к инструкции, следующей сразу за инструкцией вызова, как только функция завершается. В этом отношении они похожи на инструкции условного перехода в том, что они генерируют два пути выполнения. Целевой адрес инструкции вызова добавляется в список для отложенного дизассемблирования, в то время как инструкция, следующая сразу за вызовом, дизассемблируется аналогично линейной развертке.
Рекурсивный спуск может завершиться ошибкой, если программы не ведут себя должным образом при возврате из вызываемых функций. Например, код функции может намеренно манипулировать адресом возврата этой функции, чтобы после завершения управление возвращалось в место, отличное от того, которое ожидает дизассемблер. Простой пример показан в следующем неверном листинге, где функция foo просто добавляет 1 к адресу возврата перед возвратом к инструкции call.
В результате управление фактически не передается инструкции добавления в [1] после вызова foo. Ниже представлена правильный листинг:
Этот листинг более ясно показывает реальный поток программы, в котором функция foo фактически возвращается к инструкции mov в [2]. Важно понимать, что дизассемблер с линейной разверткой также не сможет правильно дизассемблировать этот код, хотя и по несколько другим причинам.
Инструкции возврата
В некоторых случаях алгоритм рекурсивного спуска уходить от нужных путей. Команда возврата в функции (например, x86 ret) не предлагает информации о том, какая инструкция будет выполнена следующей. Если бы программа действительно выполнялась, адрес был бы взят из вершины стека времени выполнения, и выполнение возобновилось бы с этого адреса. Дизассемблеры не имеют доступа к стеку. Вместо этого дизассемблер резко останавливается. Именно в этот момент дизассемблер с рекурсивным спуском обращается к списку адресов, которые он оставил для отложенного дизассемблирования. Адрес удаляется из этого списка, и процесс дизассемблирования продолжается с этого адреса. Это рекурсивный процесс, который дает название алгоритму дизассемблирования.
Одним из основных преимуществ алгоритма рекурсивного спуска является его превосходная способность отличать код от данных. Как алгоритм, основанный на потоке управления, гораздо менее вероятно, что он неправильно дизассемблирует значения данных в виде кода. Основным недостатком рекурсивного спуска является невозможность следовать косвенным путям кода, таким как переходы или вызовы, которые используют таблицы указателей для поиска целевого адреса. Однако с добавлением некоторых эвристик для идентификации указателей на код дизассемблеры с рекурсивным спуском могут обеспечить очень полное покрытие кода и отличное распознавание кода по сравнению с данными. В листинге 1-2 показаны выходные данные дизассемблера с рекурсивным спуском, используемого в том же операторе switch, показанном ранее в листинге 1-1.
Обратите внимание, что таблица пунктов назначения перехода была распознана и отформатирована соответствующим образом. IDA Pro - наиболее яркий пример дизассемблера с рекурсивным спуском. Понимание процесса рекурсивного спуска поможет нам распознать ситуации, в которых IDA может производить неоптимальные списки, и позволит нам разработать стратегии для улучшения результатов IDA.
Резюме
Необходимо ли глубокое понимание алгоритмов дизассемблирования при использовании дизассемблера? Нет. Это полезно? Да! Сражение с вашими инструментами - последнее, на что вы хотите тратить время при реверс инжиниринге. Одним из многих преимуществ IDA является то, что, в отличие от большинства других дизассемблеров, он предлагает множество возможностей направлять и отменять решения. Конечный результат состоит в том, что готовый продукт, будет намного лучше всего остального.
В следующей главе мы рассмотрим множество существующих инструментов, которые могут оказаться полезными во многих ситуациях реверси инжиниринга. Хотя они не имеют прямого отношения к IDA, многие из этих инструментов находились под влиянием IDA.
THE IDA PRO BOOK 2 ИЗДАНИЕ - Неофициальное руководство по самому популярному дизассемблеру в мире
Дата публикации 8 ноя 2020
| Редактировалось 18 ноя 2022