Главная » Хабрахабр » Тайна прошивок

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Как можно применить эти принципы к первичному анализу бинарного кода?

Эта инструкция имеет какое-то фиксированное бинарное представление, которое мы и будем искать. Сделаем базовое предположение, что в системе команд процессора есть инструкция RET (возврат из подпрограммы). Если RET не единственный, как в x86, где у RET есть аргумент – размер области параметров подпрограммы, или если RET является побочным эффектом более сложной операции, как в ARMv7, где значение PC можно извлекать из стека одновременно со значениями других регистров(ldmfd sp!, ), тогда, скорее всего, наш эвристический поиск не даст результатов.

В существующих процессорах используется несколько принципов кодирования инструкций: Еще нам нужно сразу же сделать разумные предположения о принципе кодирования инструкций исследуемого процессора.

  • Поток байт, из которого формируются инструкции, причем разные инструкции кодируются разным числом байт. В этой категории самый известный представитель – семейство 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-битными значениями.

Найдем в образе программы наиболее часто встречающиеся 16-битные значения. Итак, исходя из предположения, что кодирование инструкции RET – это некоторое фиксированное 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 из другой точки программы. В этом случае мы можем рассматривать некоторый разумный диапазон адресов непосредственно после RET в качестве точек перехода инструкции RET. Конечно же, это правило не универсальное, так как на некоторых платформах, в частности, ARMv7, непосредственно после завершения из подпрограммы обычно располагается пул констант.

Во-первых, процессор может использовать разный порядок байт в слове: little-endian, как на большинстве современных процессоров, когда многобайтное целое число записывается в памяти, начиная с младшего байта, и big-endian, когда многобайтное целое число записывается в памяти, начиная со старшего байта. В случае инструкции CALL для перехода к подпрограмме вариантов ее кодирования может быть достаточно много. В случае абсолютной адресации закодированная инструкция содержит адрес, на который требуется перейти в каких-то битах закодированной инструкции. Практически все современные процессоры работают в режиме little-endian, но отбрасывать другие возможные порядки байт в слове не стоит.
Во-вторых, инструкция CALL может использовать либо абсолютную адресацию точки перехода, либо адресацию относительно текущего адреса. Поэтому имеет смысл рассматривать два 16-битных слова подряд и пробовать разные варианты разбиения адреса перехода между этими словами. Чтобы обеспечить вызов подпрограммы из любой точки 16-битного адресного пространства в любую другую точку по абсолютному адресу 16-битного слова закодированной инструкции не хватит, так как кроме адреса перехода нужно еще где-то хранить биты кода операции.

В закодированную инструкцию мы записываем разность адреса подпрограммы и текущей точки. Альтернатива абсолютному кодированию адреса подпрограммы – относительное кодирование. Во-вторых, для кодирования смещения можно резервировать меньше бит, чем размерность адресного пространства, исходя из того, что во многих случаях вызываемая подпрограмма находиться не так далеко от вызывающей. Относительное кодирование обычно предпочтительнее абсолютного, потому что, во-первых, программа с относительными переходами позиционно-независима, то есть может размещаться в памяти с любого адреса без каких-либо изменений в бинарном коде. Однако, если смещение для вызова выходит за диапазон представимых значений, придется вставлять специальные инструкции-«трамплины».

Например, в процессорах ARM за точку отсчета берется адрес текущей инструкции +8 (то есть не адрес следующей после CALL инструкции, а адрес следующей за следующей инструкции). Относительное кодирование адреса подпрограммы может выполняться с некоторыми вариациями: во-первых, адрес текущей точки программы может браться либо как адрес текущей инструкции, либо как адрес следующей инструкции, как в процессорах x86, либо адрес еще какой-то инструкции около текущей точки. То есть, чтобы получить адрес вызываемой подпрограммы, смещение потребуется умножить на 2. Кроме того, поскольку в нашем случае программа записывается в виде потока 16-битных слов, логично ожидать, что и смещение будет выражено в словах.

С учетом всего вышеописанного получаем следующее пространство перебора для поиска пары CALL/RET в бинарном коде.

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

  • 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, мы можем более точно разметить области кода и данных программы, что будет важно при дальнейшем анализе.

Продолжение следует…

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Анализ кода CUBA Platform с помощью PVS-Studio

Для Java программистов существуют полезные инструменты, помогающие писать качественный код, например, мощная среда разработки IntelliJ IDEA, бесплатные анализаторы SpotBugs, PMD и другие. Всё это уже используется в разработке проекта CUBA Platform, и в этом обзоре найденных дефектов кода я расскажу, ...

[Из песочницы] Подготовка к промышленному производству ДО-РА

1. Транспортировка образцов после ядерной катастрофы на АЭС Фукусима в Японии и задумывался в виде гаджета – персонального дозиметра-радиометра работающего с одноименным ПО – DO-RA. Проект DO-RA DO-RA.com был рождён в марте 2011 г. Soft на любом смартфоне под мобильные ...