Вы когда-нибудь задумывались, как много вокруг умной электроники? Куда ни глянь, натыкаешься на устройство, в котором есть микроконтроллер с собственной прошивкой. Фотоаппарат, микроволновка, фонарик… Да даже некоторые USB Type C кабели имеют прошивку! И всё это в теории можно перепрограммировать, переделать, доработать. Вот только как это сделать без документации и исходников? Конечно же реверс-инжинирингом! А давайте-ка очень подробно разберём этот самый процесс реверса, от самой идеи до конечного результата, на каком-нибудь небольшом, но интересном примере!
Идея
Меня давно манила шина PCI Express. Сами посудите — высокие скорости, DMA доступ к памяти компьютера, множество разнообразных устройств и производителей, тонна стандартов и реализаций. Что, если взять некоторое PCI-E устройство и переделать прошивку так, чтобы в процессе работы параллельно ещё и читать/писать ОЗУ компьютера по своему желанию?
Вы скажете — но есть же готовые решения, например, PCI Leech и Thunderclap. Но PCI Leech не умеет работать как «обычное» устройство, а Thunderclap, хоть и имитирует сетевую карту, но базируется на громоздкой и дорогущей FPGA девборде. По моей же задумке, устройство должно корректно определяться драйверами в любой системе и при этом недорого стоить.
Такое устройство можно использовать, к примеру, в качестве аппаратного отладчика x86 — имея возможность чтения и записи ОЗУ, разрабатывать драйвера для BIOS «на коленке» гораздо проще.
Выбираем подопытного
К различным PCI-E устройствам я начал присматриваться ещё несколько лет назад, но большинство из встреченных мной девайсов либо были слишком просты и имели намертво зафиксированную функциональность, и очень небольшие возможности со стороны прошивки:
Либо выглядели настолько сложно, что перспектива реверса такого монстра просто пугала:
Мне же требовалось устройство, которое имеет:
-
встроенный мощный микроконтроллер
-
легко перепрограммируемое ПЗУ
-
отладочные интерфейсы (UART, JTAG)
-
встроенную прошивку (а не загружаемую драйвером)
Ну и в добавок, это должно быть широко поддерживаемым всеми системами устройством, например, SATA или USB контроллером. И, как вы уже догадались из названия статьи, недавно я наткнулся на вот такой SATA контроллер:
А именно, меня привлекла крайняя простота контроллера (только проц, да ПЗУ) и радиатор на нём (а значит внутри мощный CPU!). Быстрый поиск по названию чипа ещё больше подогрел к нему интерес. Найденная прошивка для него весила аж 500 КБ, имела признаки ARM кода, не была пожата, и имела достаточно текстовых отладочных строк:
И на него была некоторая документация, в которой очень много выводов было не подписано, что давало шанс на наличие отладочных интерфейсов:
В скором времени контроллер был куплен, и я взглянул на него повнимательнее:
Контроллер действительно выглядел очень просто и перспективно. Но более подробное изучение даташитов показало, что NC пины — на самом деле NC, и отладки на нём ждать не стоит. Более продвинутый контроллер с поддержкой как SATA, так и IDE, ровно в таком же корпусе имел на этих выводах дополнительные сигналы:
Но зато я нашёл даташиты на другие очень похожие контроллеры, с явным упоминанием отладки:
То, что производитель просит не подключать TST2-TST6 ну очень намекает на наличие JTAG, а прямое указание UART на TST0 и TST1 (в другом даташите) это уже джекпот. Засим было решено купить 88SE9215 как самый недорогой из доступных, и издеваться уже над ним:
Проверяем работоспособность
И вот объект изучения у нас в руках, первым делом проверяем, что он работает. Это важный момент, именно тут мы устраняем возможные будущие вопросы «Это я сломал или оно и было нерабочим??»
Для этого мне пришлось купить M2 райзер, поскольку единственный PCI Express слот моего ПК занят видеокартой:
В общем, тест прошёл успешно, я даже установил систему на HDD, подключенный к этому контроллеру, всё подцепилось стандартными драйверами:
При запуске ПК мелькает информация о состоянии контроллера, это PCI Option ROM и по идее из этого меню можно что-то настраивать, но мне никак не удалось зайти в настройки:
Анализируем компоненты
Теперь нужно определить, что с чем соединено, где процессор, а где ПЗУ, и какие компоненты за что отвечают. В нашем случае анализ крайне простой, особенно учитывая наличие даташитов:
Исследователям на заметку
Для быстрого осмотра плат я применяю дурацкий, но зачастую действенный подход — если микросхема квардатная и с множеством выводов, то это исполнительный элемент (процессор, FPGA, DSP), а если прямоугольный, то это память. Некоторые производители дополнительно отмечают краской компоненты, содержащие прошивку внутри (очень пригождается, когда ПЗУ находится в микроконтроллере и метка явно об этом говорит).
Точное назначение и тип компонентов узнаём по маркировке и даташитам. В нашем случае список компонентов довольно небольшой:
Маркировка |
Назначение |
Параметры |
88SE9215-NAA2 |
Центральный контроллер |
SATA III x4 / PCI-E 2.0 x1 |
25Q40H |
ПЗУ с прошивкой |
SPI, 512 КБ |
Получаем прошивку
Вот мы убедились, что устройство работает, проанализировали, из каких компонентов оно состоит, теперь следует его «забэкапить», по максимуму извлечь из него данные, чтобы в будущем можно было его к этому же состоянию и вернуть. Прошивка может находиться как во внешнем ПЗУ, так и в самом контроллере, это тоже следует учитывать и внимательно смотреть документацию.
Обычно прошивку можно получить тремя способами, это:
-
обновления от производителя
-
программатором из ПЗУ / контроллера
-
по отладочным интерфейсам из устройства
В сети прошивка для моего контроллера нашлась на сайте station-drivers, впрочем, оттуда же я брал прошивку и для предыдущего купленного контроллера. Несмотря на то, что это .exe файл, 7-zip его прожевал, и внутри обнаружились .bin файлы самой прошивки:
Я скачал всё прошивки, что только смог найти в сети, в том числе и для похожих контроллеров, и это тоже дало результаты — в одном из архивов обнаружился Readme с описанием чтения ПЗУ:
Но для этого нужно было готовить загрузочную DOS флешку, поэтому я просто считал ПЗУ программатором, благо здесь стоит типичная SPI Flash. Самый простой способ — клипсой. Цепляемся к ПЗУ и пытаемся читать:
И в два клика ПЗУ определилось и прочиталось программатором:
Часто контроллер мешает считыванию и приходится выпаивать микросхему или хотя бы подавать питание на устройство. В этом случае повезло, но на всякий случай считываем несколько раз и сравниваем содержимое, чтобы убедиться, что считалось корректно.
Анализируем прошивку
В качестве объекта для анализа я взял скачанную с сайта прошивку (чтобы начать исследование ещё до того, как купленный контроллер приедет ко мне). Первым делом нужно определить структуру образа прошивки. При беглом просмотре сразу видно, что большую часть образа занимает пустое место, а полезные данные начинаются на некоторых адресах, кратных 0x1000. И по адресу 0x2000 видим достаточно интересный набор данных:
Ну вот, за нас даже структуру прошивки расписали! Итак, согласно описанию, в образе мы имеем:
Смещение |
Размер |
Название |
Назначение |
0x00000 |
0x000A0 |
Autoload |
?? |
0x0C000 |
0x00834 |
Loader |
Загрузчик |
0x20000 |
0x07800 |
BIOS |
PCI-E Option ROM |
0x30000 |
0x74558 |
Firmware |
Прошивка контроллера |
Мне повезло, что в образе уже было описание разделов. В общем случае, столкнувшись с неизвестным образом, желательно прогнать его через binwalk, эта утилита сразу покажет известные форматы файловых систем и упакованных данных. В этом конкретном случае он нашёл только таблицы коэффициентов для CRC32:
Также полезно разбить прошивку на части и прогнать их через cpu_rec, чтобы знать, с какой программной архитектурой имеем дело. Ещё в начале статьи я предположил, что контроллер должен иметь архитектуру ARM, ну а Option ROM должен быть архитектуры x86, поскольку исполняется он на хосте. Проверим это:
А вот теперь возьмёмся за реверс. Начнем с Loader — он ARM архитектуры и имеет небольшой размер. Скорее всего с него контроллер начинает загрузку. Попробуем его загрузить в дизассемблер:
И видим, что первые инструкции выполняют прыжки на адреса 0xFFFF00**, а это значит, что либо контроллер первым делом при старте прыгает в Mask ROM по адресу 0xFFFF0000 (что сомнительно), либо код Loader сам грузится в этот адрес. Перезагружаем код в дизассемблер по 0xFFFF0000 и действительно, всё корректно парсится:
Функций здесь очень немного (целых четыре), и выяснить что делает код не представляет труда:
-
По адресу 0xF8064000 код обращается к содержимому ПЗУ
-
В ПЗУ происходит поиск сигнатуры «MAGIIMGF»
-
Блок данных с этой сигнатурой парсится и раскидывается по ОЗУ
-
Происходит запуск основной системы прыжком по адресу 0
И да, именно с сигнатуры «MAGIIMGF» начинается Firmware, который мы вырезали ранее! Немного проанализировав загрузчик, получаем вот такой формат блока прошивки:
И теперь мы можем распарсить Firmware и правильно прогрузить его в дизассемблер! Ожидаемо всё идеально прогружается и можно начать анализ основной системы:
Основная задача реверс-инжиниринга — дать имена функциям и понять, что происходит в коде. В этом нам помогут:
-
часто вызываемые стандартные функции (malloc, memset, memcpy)
-
текстовая отладочная информация из прошивки
-
различные уникальные константы
Очень полезно найти функцию Assert, которая выводит в отладочную консоль наименование файла исходников и номер строки с возникшей ошибкой. Например, здесь название файла намекает, что эта функция занимается выделением памяти:
А здесь явно происходит инициализация последовательного порта:
Кстати, немного проанализировав задачи инициализации системы, можно наткнуться на функцию, которая принимает указатель на другую функцию и некоторое имя. Очень похоже на запуск новой задачи! Так и назовём:
В процессе изучения кода задач натыкаемся на интересную функцию, которой часто передаются красивые десятичные значения (100, 1000)… И это очень похоже на функцию задержки исполнения, sleep:
Судя по числам, наш процессор работает на частоте 300 MHz — неплохо так. А ещё из этой функции получаем очень важную информацию — по адресу 0xD0020314 расположен системный таймер. Попытки поискать этот адрес в сети привели к очередному успеху — PDF с детальным описанием другого процессора Marvell:
И вот у нас уже есть какая-никакая, но документация, с помощью которой мы находим то, что искали изначально, а именно функцию отображения адресного пространства ПК в адреса самого контроллера:
Этой функцией контроллер взаимодействует с ПК, к которому он подключен. Достаточно задать нужный адрес в аппаратных регистрах транслятора, и чтение/запись в пределах заданного «окна» по адресу 0x40000000 автоматически приведут к чтению/записи физической памяти ПК!
Теперь осталось найти, как взаимодействовать с прошивкой и подавать команды извне, внедрить в прошивку свой код, который будет лезть в ОЗУ компьютера и … готово?
Ищем отладочные интерфейсы
Надоело смотреть на скриншоты ассемблерного кода? Возвращаемся к железякам! У нас есть набор тестовых контактов, но мы не знаем, где на них какие отладочные интерфейсы (и есть ли они там вообще). Сначала нужно что? Правильно, подпаяться к ним и вывести на гребёнку:
Производитель совсем не заботится о глазах реверс-инженеров, нет бы хоть пятаки сделать!
А дальше — подключаем прибор для поиска JTAG, подрубаем питание и вперед!
Ииии ничего не нашлось:
Ожидаемо, подумал я, и вспомнил про пин «TESTMODE» из даташита. Вероятно, его нужно задействовать и тогда…. Что-ж, паяем ещё проводков, ставим подтяжку на TESTMODE:
Иии снова ничего не нашлось, правда, картина немного иная, почему-то всё стало Input:
Да что ж такое, ну ладно JTAG, хотя бы UART найду, подумал я. Подключаем логический анализатор…
И не видим признаков UART ни на одном выводе…
А с поднятым TESTMODE, тест выводы и вовсе колбасит по-черному, и это точно не UART:
Ну, думаю, он просто выключен в прошивке. Нужно его включить! Вношу небольшие изменения и сталкиваюсь с тем, что через клипсу прошивка не хочет записываться. Контроллер питается от программатора и мешает записи. Да что за день-то такой?! Психанул, сделал ПЗУ съёмной:
Но никакие мои попытки включить UART не привели к результатам. Вообще никак, хоть убей. И тут я вспомнил один факт, на который я поначалу не обратил внимания. В прошивке с сайта ARM код был, а в прошивке, что я считал программатором — был только код BIOS. Я посчитал, что добавления ARM кода хватит для того, чтобы контроллер его подхватил и запустился. В общем, возник вопрос, а грузится ли прошивка вообще??
Для этого я подключил логический анализатор прямо к SPI микросхеме:
И проанализировал чтение флешки по SPI:
И да, читался только неведомый Autoload (причём трижды) и BIOS! Загрузчик и прошивка и не думали грузиться! Я наконец понял, что мой контроллер имеет только функциональность SATA, а прошивка нужна для RAID, который у меня не поддерживается..
В отчаянии, используя SPI логи, я разреверсил формат Autoload, он оказался очень простой:
Сначала сигнатура (0xA5A5A5A5), дальше пары адрес/значение, в конце сигнатура окончания (0xFFFFFFFF). Я подумал, а вдруг эти данные загружаются встроенным микроконтроллером. Тогда, испортив ему стек и адрес возврата из функции, мы перехватим управление и прыгнем на внешний загрузчик. И как только процессор прыгнет на адрес ПЗУ — мы это увидим в логе SPI, произойдёт чтение!
Я собрал Autoload, в котором во все возможные адреса стека записывался адрес ПЗУ (0xF8064000), запихнул на флешку и… Эта зараза прожевала все 128 КБ и не подавилась, дважды!! (первый раз подавилась, похоже, по таймауту)
Короче, я сдался. Похоже, нет внутри 88se9215 чипа ARM контроллера, и мне нужен 88se9230 чип. Нет слов. Купил 9230 и … поехали всё заново, что поделать!
В общем, да. На 9230 JTAG нашёлся с пол-пинка и даже без TESTMODE пина:
Вывод |
Назначение |
TST2 |
TRST |
TST3 |
TMS |
TST4 |
TDI |
TST5 |
TCK |
TST6 |
TDO |
Кстати, в этом самом TESTMODE неслабо колбасит вообще все пины (как TST, так и GPIO), чип выводит на каждый из них некоторую частоту:
Ладно, у нас теперь хотя бы JTAG есть. Продолжаем эпопею!
Играемся с JTAG
JTAG штука хорошая, с JTAG можно ставить брейкпоинты, читать и писать RAM, в общем, производить полную отладку.
Подключаем JTAG программатор к только что найденным пинам…
Пишем небольшой скрипт для OpenOCD с одним ARM ядром
interface usb-s
reset_config trst_pulls_srst trst_push_pull
adapter_khz 2000
telnet_port 4444
gdb_port 3333
jtag newtap marvell cpu -irlen 4 -ircapture 0x1 -irmask 0xf -expected-id 0x140003d3
target create core feroceon -chain-position marvell.cpu
И подключаемся к контроллеру:
Наконец можно проверить, работает ли маппинг памяти! Запускаем UEFI Shell с флешки, читаем адрес 0x100000000:
Теперь по JTAG из контроллера мапим этот же адрес и перезаписываем содержимое:
И проверяем из UEFI Shell:
Оно работает! Мы действительно можем перезаписывать оперативную память ПК из SATA контроллера! Теперь нужно включить UART, чтобы потом по нему принимать управляющие команды. Из реверса я нашёл, что чтобы вывести символ в UART, нужно записать в регистр 0xD0072000. Но как я ни записывал в него, на анализаторе ничего не происходило.
С помощью отладки по JTAG выяснилось, что до инициализации UART дело не доходит:
Регистр 0xD0071054 имеет значение 0x3F, а для инициализации UART нужно значение 0x2F:
Банальное повторение всего того, что делает процедура serial_init, не помогло, UART не завёлся. Пришлось исследовать более детально. На тот же самый регистр 0xD0071054, по которому код решал, включать UART или нет, ссылалась и другая процедура, i2c_init, только она уже реагировала на значение 1:
Но подождите-ка, на нашей плате пины TST0 и TST1 идут как раз к посадочному месту будто бы под i2c флешку:
А значит, должен быть способ переключить регистр 0xD0071000 (точнее его биты 4-5, которые проверяются в этих функциях) хотя бы в положение «1». И на плате, похоже, для этого предусмотрительно оставлены места под резисторы «на землю» возле некоторых GPIO. А ну-ка подтянем GPIO4 на землю (чтобы в 0x3F обнулить бит №4), не зря же мы все GPIO распаяли:
Читаем снова по JTAG и видим, что изменился регистр, правда не тот, но обнулился нужный бит!
Уже хоть что-то! Попадание совсем рядом. Значит это действительно GPIO. А что если включить контроллер с уже замкнутым GPIO? Может, регистр 0x54 показывает состояние GPIO на момент начального включения? Перезагружаем контроллер:
И да, так и оказалось, эти регистры отвечают за конфигурацию и состояние GPIO. Теперь у нас должен быть инициализирован UART, записываем в 0xD0072000 значение 0x30 иии:
Есть UART! RxD на TST0 и TxD на TST1
Более того, подключаем USB-UART адаптер и видим:
Лог загрузки контроллера из UART
FW version 2.3.0.1041 Built: 18:55:01 Jul 4 2012
Interrupt initialization.
PUNIT initialization.
Scratchpad RAM initialization.
SPD RAM @ 0x30060280 with 130432 bytes.
Entering dma_init.
Leaving dma_init.
ide_fifo_init_module
Magni device id: 9230, rev: 60
ide_host_init.
IDE host allocates 16896.
ahci_host_init.
Gigabyte early post spd: 20
Console initialization.
CORE initialization.
Backend CCCS not supported.
Core allocated 26624 bytes.
No supported I2C(2).
spi_init.
SPI device: mxic 25l4005 detected.
SPI size:80000
SPI block size:1000
mv_init_phy_tuning.
No phy_tuning this page. Using default value.
mv_init_modifing_vdname.
No modifying_vdname this page.
mv_init_aes_page.
raid_init
hd_info.flag = 0
init req_ct_pool_buf(dtcm), size : 1280
init req_ext_ct_pool_buf(sram), size : 5376
Free 2080 size, initialization done!
Front thread started.
Backend thread started.
Starting AHCI controller.
AHCI controller started.
Set BGA Buffer to SRAM memory, 0x1000 bytes
raid_init_work_queue, raid_bga_next_handler
port 7 Auto-fetch enable
console device: 12 mapping to vportid: 7
Теперь осталось запрограммировать команды и реакцию на них
Расширяем функциональность прошивки
Для начала, напишем довольно простой код, который выдаёт обратно всё, что мы ввели в терминал. В качестве буфера используем верхнюю половину буфера, что прошивка использует под sprintf.
В самом начале кода обязательно добавим прыжок на основную функцию, чтобы потом не заморачиваться с поиском адреса основной функции, а просто вписать адрес, куда код положим. На встроенные системные функции просто будем прыгать по их адресу:
Небольшой цикл обработки UART
asm("B mainCycle");
asm(
"get_char:nt"
"LDR R12, =0x30042695nt"
"BX R12nt"
);
asm(
"put_s:nt"
"LDR R12, =0x300426A9nt"
"BX R12nt"
);
void mainCycle ()
{
char c;
char * in_buffer = (char *)0x30059CB8;
int in_max_size = 0x80;
int buf_ptr = 0;
while(1)
{
c = get_char();
if (c == 0x0A || c == 0x00)
continue;
if (c != 0x0D && buf_ptr >= in_max_size)
continue;
in_buffer[buf_ptr++] = c;
if (c == 0x0D)
{
put_s(in_buffer);
buf_ptr = 0;
}
}
}
Чтобы скомпилировать код под ARM, нам понадобится GCC. Возьмём его из ARM GNU Toolchain
Поскольку нужно скомпилировать совсем сырой бинарь, добавляем опцию -nostdlib, а после сборки преобразуем полученный файл в binary формат:
arm-none-eabi-gcc.exe echo.c -nostdlib -O2 -o echo.out
arm-none-eabi-objcopy.exe -O binary echo.out echo.bin
К счастью, ARM код (по большей части) использует относительные адреса, поэтому с линковкой по нужному адресу не заморачиваемся. По-хорошему нужно явно указать компилятору, по какому адресу будет расположен код директивой -Wl,—section-start=.text=0x30400000 (последнее — требуемый адрес)
Теперь нужно вставить наш код в прошивку. И снова можно пойти правильным путём — расширить секцию кода, поменять размер в заголовках, добавить код в самый конец файла… А можно поступить проще, найти неиспользуемый участок кода в прошивке и перезаписать его. Помните, мы обнаружили инициализацию I2C, которая теперь точно не нужна?
Нам пока хватит, если что, поищем другое место. Вставляем код по адресу 0x30054EB0 и меняем адрес Idle задачи на этот:
А вот чтобы перешивать контроллер программатором (не делать же снова «внешнее ПЗУ») делаем аппаратную хитрость — «изолируем» питание ПЗУ диодами так, чтобы при подключении клипсы питание не уходило на контроллер:
Проверяем и вроде бы работает, после ввода строки и нажатия Enter, в терминал выводится наша введенная строка:
Наконец, запрограммируем чтение ОЗУ компьютера! Заодно поправим недостатки, а именно, будем выводить символы, что получили, а ещё избавимся от ассемблерных вставок:
Spoiler
asm("B mainCycle");
typedef unsigned int uint32;
typedef int (*get_char_func)();
typedef void (*put_char_func)(char symbol);
typedef void (*hex_dump_func)(char * bufptr, uint32 size);
typedef void (*hex_to_int_func)(char * bufptr, uint32 * out_val);
typedef char * (*dma_map_func)(uint32 low, uint32 hi, int size, int map_id);
typedef void (*dma_unmap_func)(int map_id);
void mainCycle ()
{
get_char_func get_char = (get_char_func)0x30042695;
put_char_func put_char = (put_char_func)0x30042671;
hex_to_int_func hextoint = (hex_to_int_func)0x3007EECD;
hex_dump_func hexdump = (hex_dump_func)0x30041FFC;
dma_map_func mapdma = (dma_map_func)0x1830;
dma_unmap_func unmapdma = (dma_unmap_func)0x18DC;
char c;
char * in_buffer = (char *)0x30059CB8;
int in_max_size = 0x80;
int buf_ptr = 0;
unsigned int low_addr = 0;
unsigned int hi_addr = 0;
while(1)
{
c = get_char();
if (c == 0x00)
continue;
if (c != 0x0D && buf_ptr >= in_max_size)
continue;
in_buffer[buf_ptr++] = c;
if (c == 0x0D)
c = 0x0A;
put_char(c);
if (c == 0x0A && buf_ptr == 18)
{
hextoint(in_buffer, &hi_addr);
hextoint(in_buffer+9, &low_addr);
hexdump(mapdma(low_addr, hi_addr, 0x40, 1), 0x40);
unmapdma(1);
}
if (c == 0x0A)
buf_ptr = 0;
}
}
Теперь при вводе в терминал адреса, контроллер считает нам физическую память ПК:
И это полный успех, можно наконец-то передохнуть!
Заключение
Итак, цель достигнута, мы модифицировали прошивку и можем командами по UART читать физическую память ПК. Конечно, этот пример крайне простой, но при желании можно реализовать абсолютно любую функциональность, ведь внутри стоит мощный и энергоэффективный ARM контроллер на 300 MHz. Например, никто не мешает написать код, который за несколько секунд сдампит всю ОЗУ компьютера на подключенный к контроллеру SSD диск (да да, там есть функции читать/писать диск). Или добавить на плату WiFi модуль, чтобы подавать команды удалённо — на что хватит фантазии!