Тайна прошивок

Авторы: к.ф.-м.н. Чернов А.В. (monsieur_cher) и к.ф.-м.н. Трошина К.Н.

Как с помощью самых общих предположений, основанных на знании современных процессорных архитектур, можно восстановить структуру программы из бинарного образа неизвестной архитектуры, и дальше восстановить алгоритмы и многое другое?

В этой статье мы расскажем об одной интересной задаче, которая была поставлена перед нами несколько лет назад. Заказчик попросил разобраться с бинарной прошивкой устройства, которое выполняло управление неким физическим процессом. Ему требовался алгоритм управления в виде компилируемой С-программы, а также формулы с объяснением, как они устроены и почему именно так. По словам Заказчика, это было необходимо для обеспечения совместимости со «старым» оборудованием в новой системе. То, как мы в итоге разбирались с физикой, в рамках данного цикла статей мы опустим, а вот процесс восстановления алгоритма рассмотрим подробно.

Практически повсеместное использование в массовых устройствах программируемых микроконтроллеров (концепции интернета вещей IOT или умного дома SmartHome) требует обратить внимание на бинарный анализ встраиваемого кода, или, другими словами, бинарный анализ прошивок устройств.

Бинарный анализ прошивок устройств может иметь следующие цели:

  • Анализ кода на наличие уязвимостей, позволяющих получить несанкционированный доступ к устройству или к данным передаваемым или обрабатываемым этим устройством.
  • Анализ кода на наличие недокументированных возможностей, приводящих, например, к утечке информации.
  • Анализ кода для восстановления протоколов и интерфейсов взаимодействия с устройствами для обеспечения совместимости данного устройства с другими.


Поставленная выше задача анализа бинарного кода может рассматриваться как частный случай задачи анализа бинарника для обеспечения совместимости устройств.
Анализ формата бинарного файла

Если в мире «настоящих» операционных систем форматы исполняемых файлов стандартизированы, то в мире встраиваемых программ каждый вендор может использовать свое проприетарное решение. Следовательно, анализ бинарного файла прошивки приходится начинать с анализа формата бинарного файла.

Если в мире «настоящих» операционных систем форматы исполняемых файлов стандартизированы для каждой операционной системы, и эти форматы включают в себя информацию об ABI операционной системы, процессоре, битности, порядке байт в слове, в мире встраиваемых программ вариантов форматов файлов намного больше. Собственно, каждый вендор может использовать что-то свое. И анализ бинарного файла прошивки приходится начинать с анализа формата бинарного файла.

В начале работы ситуация для нас выглядела следующим образом: мы получили некоторый файл с прошивкой без какой-либо сопроводительной документации. Не было информации ни о формате файла прошивки, ни об архитектуре микроконтроллера.

Файл с прошивкой оказался текстовым файлом. Он содержал строки примерно следующего вида:

:04013000260F970CF8
:10020000004D000B043F000B34AD010C002FFE4D30
:02023000FD0BC1
:1004000018001A0000001E0008005E000200190052

Внимательно посмотрев на набор этих строк, мы сообразили, что это — вполне стандартный для микроконтроллеров формат Intel HEX. Файл состоит из записей, в каждой из которой указывается ее тип, адрес размещения в памяти, данные и контрольная сумма. Само по себе использование формата Intel Hex предполагает, что файл, скорее всего, не зашифрован и представляет собой образ программы, размещаемой в памяти.

Хотя формат Intel Hex поддерживает вплоть до 32-битной адресации памяти, в нашем файле присутствовали записи только с 16-битными адресами в памяти. Поэтому по текстовому файлу легко создать бинарный файл образа памяти, в котором записи из исходного тестового файла уже будут размещены по заданным адресам. Такой бинарный файл удобнее инспектировать с помощью утилит анализа бинарного файла, да и писать свои утилиты для него проще, чем для Intel HEX. Бинарный файл образа памяти подтвердил, что файл не зашифрован, так как разнообразные осмысленные строки были обнаружены разбросанными в разных местах кода.
Однако это не дало ответ на вопрос, для какой архитектуры этот файл.

»256acqp6bgydpoj5ju-vwvc97gq.jpeg»

Мы получили файл с образом памяти какого-то 16-битного или 8-битного микроконтроллера. И что это за микроконтроллер — непонятно. Мы взяли IDA Pro и попробовали дизассемблировать файл всеми возможными вариантами поддерживаемых процессоров. И ничего. Ни один процессор из поддерживаемых IDA Pro не подошел: листинг либо не сгенерировался, либо содержал явную бессмыслицу. Возможно, это была программа для одного из поддерживаемых IDA Pro процессоров, но мы что-то делали не так. Например, нужна была просто дополнительная обработка файла образа. В любом случае, здесь можно было приостановить работы и запросить дополнительную информацию о бинарном файле.

Все процессоры примерно одинаковы

Но нам стало интересно, а что мы можем понять по бинарной программе, даже если процессор, для которой она скомпилирована — неизвестен. Ответ «ничего» — неинтересен, даже если мы сможем понять чуть-чуть, это лучше, чем ничего. Очевидно, что текстовые строки могут дать информацию о программе, но мы целим в большее — понять что-то из структуры программы.
Различных процессорных архитектур — большое количество. Эволюция вычислительной техники порождала даже самые необычные архитектуры типа троичных компьютеров. Однако микропроцессоры и микроконтроллеры, существующие в настоящее время, по крайней мере массовые, удивительно похожи друг на друга.

Ниже мы перечислим базовые принципы, общие для современных микропроцессоров.

Последовательное исполнение инструкций. Процессор исполняет инструкции, расположенные последовательно в памяти. Существуют специальные инструкции условного и безусловного перехода и вызова и возврата из подпрограммы, которые позволяют прервать последовательную выборку инструкций из памяти и перейти к выполнению другой инструкции. Однако остальные инструкции предполагают последовательное выполнение, и поэтому не содержат адреса следующей инструкции.

Бинарное кодирование. Помимо того, что процессор обрабатывает данные в бинарном виде, инструкции процессора, составляющие исполняемую программу, закодированы в бинарном формате, то есть поля, в которых хранятся параметры инструкции, например, смещения или номера регистров, занимают целое число бит. Можно теоретически представить, что, несмотря на двоичное кодирование данных и программы, они будут обрабатываться в процессоре в какой-то другой системе счисления, но нам такая экзотика неизвестна.

Следующие принципы, вообще говоря, не являются базовыми архитектурными принципами, но практически повсеместно выполняются, в особенности для машинного кода, не написанного вручную на языке ассемблера, а сгенерированного компилятором языка высокого уровня.

Процедурное программирование. Программа разбивается на структурные единицы, которые могут называться по-разному: процедуры, функции, подпрограммы и т. п. Подпрограммы могут вызывать другие подпрограммы, передавая им параметры и получая обратно результат выполнения. Важно, что у подпрограммы одна точка входа, то есть все подпрограммы, вызывающие данную, переходят на один и тот же адрес точки входа.
Как правило, подпрограммы имеют одну точку выхода, возвращающую управление в точку вызова, но это менее существенно, чем требование одной точки входа для каждой подпрограммы. Такой код обычно получается в результате компиляции программы. Оптимизатор периода компоновки (link-time optimizer) может частично разрушить эту структуру для уменьшения размера программы, а размер программы — критичен для встраиваемых систем. Тем более эту структуру может разрушить обфускатор кода.
Вложенность вызовов подпрограмм может организовываться с помощью стека, который еще можно использовать для передачи аргументов в подпрограмму и хранения локальных переменных, но на текущем уровне проработки архитектуры эта информация преждевременна.

Как можно применить эти принципы к первичному анализу бинарного кода?
Сделаем базовое предположение, что в системе команд процессора есть инструкция RET (возврат из подпрограммы). Эта инструкция имеет какое-то фиксированное бинарное представление, которое мы и будем искать. Если RET не единственный, как в x86, где у RET есть аргумент — размер области параметров подпрограммы, или если RET является побочным эффектом более сложной операции, как в ARMv7, где значение PC можно извлекать из стека одновременно со значениями других регистров (ldmfd sp!, { fp, pc }), тогда, скорее всего, наш эвристический поиск не даст результатов.
Еще нам нужно сразу же сделать разумные предположения о принципе кодирования инструкций исследуемого процессора. В существующих процессорах используется несколько принципов кодирования инструкций:

  • Поток байт, из которого формируются инструкции, причем разные инструкции кодируются разным числом байт. В этой категории самый известный представитель — семейство x86, начиная от первых процессоров 8080 и до самых современных 64-битных процессоров. Одна инструкция процессора x86_64 может кодироваться последовательностью от 1 до 16 байт. К этому же семейству процессоров с переменной длиной инструкции относится 8051, используемый в микроконтроллерах.
  • Поток 16-битных значений. При этом каждая инструкция имеет фиксированный размер — 16 бит.
  • Поток 16-битных значений, при этом инструкции имеют переменный размер. Один из представителей этого семейства — архитектура PDP-11, в которой непосредственно команда занимает первые 16 бит, а за ней могут идти либо непосредственные значения, либо адреса в памяти для прямой адресации. Сюда же можно отнести кодирование THUMB, а архитектуре ARM.
  • Поток 32-битных значений, каждая инструкция имеет фиксированный размер — 32 бита. Это — большинство 32- и 64-битных RISC-процессоров: ARMv7, ARMv8, MIPS.


Сделать выбор между байтовым потоком переменной длины и потоком 16-битных слов поможет просмотр образа памяти «на глаз». Как бы не кодировались инструкции процессора, в программе достаточной длины они неизбежно будут повторяться. Например, на x86 инструкция

add    %ebx,%eax


которая складывает значения регистров eax и ebx и помещает результат в eax, кодируется двумя байтами:

01 d8.

На ARMv7 инструкция

add      r0, r0, r1


которая складывает значения регистров r0 и r1 и помещает результат в r0, кодируется 32-битным значением e0800001.

В достаточно большой программе подобные инструкции будут повторяться не один раз. Если интересующая нас последовательность байт (например, 01 d8) встречается по произвольному невыровненному адресу, можно сделать предположение, что у процессора инструкции кодируются потоком байт переменного размера. Если же значение, например, e0800001 встречается только по адресам, кратным 4, можно сделать предположение о фиксированном размере инструкций процессора. Конечно же, здесь возможна ошибка, что мы приняли байты данных за инструкцию, или так случайно получилось, что некоторая инструкция всегда оказалась выровнена. Однако, влияние такого «шума» на программу достаточного размера будет невелико.

Когда мы посмотрели на анализируемую прошивку под таким углом, стало понятно, что скорее всего у рассматриваемого процессора инструкции кодируются 16-битными значениями.
Итак, исходя из предположения, что кодирование инструкции RET — это некоторое фиксированное 16-битное значение, попробуем найти его. Найдем в образе программы наиболее часто встречающиеся 16-битные значения. В нашем случае получилась следующее:

Значение (hex) Количество Частота
0b01           854        5.1%
0800           473        2.8%
8c0d           432        2.6%
2b00           401        2.4%
4e1c           365        2.2%
0801           277        1.6%

Инструкцию RET будем искать среди этих наиболее часто встречающихся в коде 16-битных значений. Сразу же мы будем искать инструкцию CALL — парную к инструкции RET. Инструкция CALL имеет по крайней мере один параметр — адрес перехода, поэтому фиксированными значениями не обойтись.

Сделаем предположение, что во многих случаях немедленное после конца одной подпрограммы, то есть после инструкции RET, начинается другая подпрограмма, и эта подпрограмма вызывается инструкцией CALL из другой точки программы. Большое количество переходов на адрес, непосредственно следующий за RET, будет одним из признаков, отличающих инструкцию CALL. Конечно же, это правило не универсальное, так как на некоторых платформах, в частности, ARMv7, непосредственно после завершения из подпрограммы обычно располагается пул констант. В этом случае мы можем рассматривать некоторый разумный диапазон адресов непосредственно после RET в качестве точек перехода инструкции RET.
В случае инструкции CALL для перехода к подпрограмме вариантов ее кодирования может быть достаточно много. Во-первых, процессор может использовать разный порядок байт в слове: little-endian, как на большинстве современных процессоров, когда многобайтное целое число записывается в памяти, начиная с младшего байта, и big-endian, когда многобайтное целое число записывается в памяти, начиная со старшего байта. Практически все современные процессоры работают в режиме little-endian, но отбрасывать другие возможные порядки байт в слове не стоит.
Во-вторых, инструкция CALL может использовать либо абсолютную адресацию точки перехода, либо адресацию относительно текущего адреса. В случае абсолютной адресации закодированная инструкция содержит адрес, на который требуется перейти в каких-то битах закодированной инструкции. Чтобы обеспечить вызов подпрограммы из любой точки 16-битного адресного пространства в любую другую точку по абсолютному адресу 16-битного слова закодированной инструкции не хватит, так как кроме адреса перехода нужно еще где-то хранить биты кода операции. Поэтому имеет смысл рассматривать два 16-битных слова подряд и пробовать разные варианты разбиения адреса перехода между этими словами.
Альтернатива абсолютному кодированию адреса подпрограммы — относительное кодирование. В закодированную инструкцию мы записываем разность адреса подпрограммы и текущей точки. Относительное кодирование обычно предпочтительнее абсолютного, потому что, во-первых, программа с относительными переходами позиционно-независима, то есть может размещаться в памяти с любого адреса без каких-либо изменений в бинарном коде. Во-вторых, для кодирования смещения можно резервировать меньше бит, чем размерность адресного пространства, исходя из того, что во многих случаях вызываемая подпрограмма находиться не так далеко от вызывающей. Однако, если смещение для вызова выходит за диапазон представимых значений, придется вставлять специальные инструкции-«трамплины».
Относительное кодирование адреса подпрограммы может выполняться с некоторыми вариациями: во-первых, адрес текущей точки программы может браться либо как адрес текущей инструкции, либо как адрес следующей инструкции, как в процессорах x86, либо адрес еще какой-то инструкции около текущей точки. Например, в процессорах ARM за точку отсчета берется адрес текущей инструкции +8 (то есть не адрес следующей после CALL инструкции, а адрес следующей за следующей инструкции). Кроме того, поскольку в нашем случае программа записывается в виде потока 16-битных слов, логично ожидать, что и смещение будет выражено в словах. То есть, чтобы получить адрес вызываемой подпрограммы, смещение потребуется умножить на 2.
С учетом всего вышеописанного получаем следующее пространство перебора для поиска пары CALL/RET в бинарном коде.

Сначала в качестве кандидатов для инструкции RET берем 16-битные слова из списка наиболее часто встречающихся значений в коде. Дальше для поиска инструкции CALL перебираем:

  • Big-endian и little-endian порядок байт в слове
  • Абсолютное и относительное кодирование адреса подпрограммы в инструкции.

Для абсолютного кодирования рассматриваем два 16-битных значения подряд, а именно, перебираем различные варианты размещения битового поля, хранящего абсолютный адрес, в 32-битном слове, а также для относительного кодирования рассматриваем и 16-битные значения, и два 16-битных слова подряд. Дальше перебираем различные варианты размещения битового поля, хранящего смещения. Проверяем, выражается ли смещение в байтах или в 16-битных словах, то есть нужно ли умножать смещение на 2, проверяем разные варианты точки отсчета: адрес текущей инструкции, адрес следующей инструкции.

Для каждого из вариантов в описанном выше пространстве перебора вычисляем статистики:

  • Сколько предположительных адресов начала подпрограмм не являются очевидно корректными, то есть расположены там, где нет ничего, либо где заведомо находятся данные (явные строки, или явные таблицы значений). Даже для значения, соответствующего правильному кодированию инструкции CALL, вполне возможно некоторое небольшое количество некорректных адресов начала подпрограммы, если значение, соответствующее инструкции CALL, случайно встретилось в данных.
  • Сколько предположительных адресов начала подпрограмм находится непосредственно после предположительной инструкции RET.
  • Сколько предположительных адресов начала подпрограмм используется более одного раза.

Если наши предположения о паре инструкций CALL/RET верны, то она должна найтись в описанном пространстве перебора. Но возможно будут еще и ложные срабатывания. Ну хорошо, запускаем перебор.

И находим только один возможный вариант!

Trying 8c0d as RET
After-ret-addr-set-size: 430
Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66%), misses: 432 (33%), coverage: 76%

Итак, 16-битное слово 8c0d подходит в качестве кандидатуры для инструкции RET. Всего в прошивке 430 позиций программных адресов непосредственно после этой инструкции. Рассматривались 32-битные значения (два 16-битных слова подряд), при значении маски адреса, равном ff 00 ff 00 обнаружена инструкция с кодом 00 0b 00 3d. Таких инструкций всего 1275, из них 843 (то есть 66%) передают управление в точку, непосредственно следующую за кандидатом в RET. Таким образом, выявлены две инструкции:

  • RET: 8c0d (16-bit Little-Endian)
  • CALL HHLL: 0bHH 3dLL (2 16-bit Little-Endian)

Инструкция CALL использует абсолютную адресацию, причем при записи адреса перехода сначала записывается старший байт, затем записывается младший байт. Вполне возможно, что это в реальности две инструкции процессора, каждая из которых загружает по половине адреса перехода, но с точки зрения анализа программы это и не важно. Зная инструкции CALL и RET, мы можем более точно разметить области кода и данных программы, что будет важно при дальнейшем анализе.

Продолжение следует…
Дальше мы расскажем, как восстанавливались условные и безусловные переходы и некоторые арифметические и логические операции.

© Habrahabr.ru