RTP DesportoTriatlo/Mundiais: Letícia Magalhães e Catarina Santos concluem prova de juniores em 29.º e 30.ºBollywood HungamaSCOOP: Salman Khan’s Monster lands a MONSTROUS Rs. 100 crore deal; PVR INOX bags All India distribution rightsPunchPDP chieftain hails Oyebanji for donating 100 vehicles to security agenciesESPNWhat to do with seven start-sit decisions you might be dreadingDaily MaverickOUR CITY NEWS: R10bn urgently needed to fix Joburg’s crumbling roadsInquirerIloilo school cancels classes over ‘security concern’CNN TürkDİLAY ÖZDEMİR KİMDİR, KAÇ YAŞINDA, NERELİ? Voleybolcu Dilay Özdemir Hangi Takımlarda Forma Giydi? Filenin Sultanları'nın Genç YıldızıESPN DeportesCheco Pérez, con buena primera práctica en GP de Azerbaiyán de F1The Jerusalem PostIsraeli envoy to US's son fighting for life in hospital after car-ramming attack, Leiter saysVanguardFour suspected Boko Haram terrorists arrested in Yoben-tvWo beginnt Antisemitismus?: Was die Debatte um die Berliner Linke so kompliziert machtCBS NewsJudge blocks Trump's ban on CNN, MS NOW and Politico's White House access for now
The Daily Newsstand · Free, Always
Thursday, September 24, 2026

Как украсть «невидимое»: извлекаем прошивку из Execution-Only Memory

Translate

Во время исследования контроллеров Realtek я столкнулся с таким любопытным способом защиты прошивки от реверс-инжиниринга, как XOM (eXecution Only Memory). Эта технология разработана ARM уже около 10 лет назад. Опираясь на свой опыт, назвать ее очень популярной не могу. Однако, в Realtek решили использовать XOM в ряде своих контроллеров, которые попали к нам на исследование.

Если коротко, XOM – это аппаратно-программная технология, формирующая execute-only область памяти. Суть XOM состоит в аппаратной фильтрации транзакций на шине AXI (AHB). Если транзакция в защищаемую область запрашивает исполнение инструкции - доступ разрешен, если чтение/запись данных – доступ запрещен. Таким образом, прочитать или перезаписать прошивку в XOM просто так не получится.

Данная статья описывает подход к восстановлению спрятанной в XOM прошивки, основанный на анализе изменений состояния памяти и регистров процессора, возникающих вследствие выполнения инструкций процессора.

Проблемы, которые доставляет XOM ревёрсеру

Архитектура XOM накладывает ограничения на размещаемый в ней программный код. Такой код не может содержать литералы. Для формирования программного кода, отвечающего такому требованию, используются специальные настройки компилятора. В результате в eXecution Only Memory не может быть такого кода:

Фрагмент прошивки с литералами

Фрагмент прошивки с литералами

а такой, может:

Фрагмент прошивки без литералов

Фрагмент прошивки без литералов

Как правило, в XOM записывают то, что нужно спрятать. Вот и Realtek положили в XOM ключевой фрагмент ROM-кода, который отвечает за настройки безопасности чипа и Secure Boot. И возникает очень распространенная в embedded-исследованиях проблема – сложно разреверсить прошивку, но получить её бывает еще сложнее. Причем ситуация с XOM серьезно отличается от ситуации с RDP (Readout Protection). Когда имеешь дело с RDP, можно попытаться воспользоваться Fault Injection в загрузчике, вызвать сбой и тем самым нарушить логику активации защиты. Конечно, это не просто, но так делают, в том числе и мы в Positive Labs! А тут ситуация совсем другая, защита от считывания конкретного диапазона адресов реализована на уровне дизайна микросхемы. Посмотрите еще раз на рисунок выше - исполняемый код даже сам себя читать не может. А раз так, то нужен метод чтобы извлекать и такие прошивки.

Идея метода восстановления прошивки

Идея, как восстановить инструкции в XOM, лежит на поверхности – выполнять инструкции step-by-step, анализировать результаты их выполнения по изменению значений регистров и состояния памяти. То есть нужно иметь возможность пошагового выполнения кода. В случае с Realtek, есть небольшое затруднение – контроллеры этого производителя имеют несколько опций защиты, которые настраиваются через программирование eFUSE (однократно программируемая память). Одна из опций – это заблокировать отладку по SWD, поэтому на микроконтроллере, взятом из некоторого готового устройства, SWD будет почти наверняка заблокирован. Но на таком же «чистом» контроллере отладка разблокирована, а в XOM у него лежит та же часть загрузчика.

Похожую идею, восстанавливать инструкции по результатам их выполнения, описали в своей работе «Taking a Look into Execute-Only Memory» М. Шринк и Й. Обермайер. Однако, они решили не публиковать созданный ими инструмент. Написанный мной фреймворк восстановления инструкций (XOM Extractor) – это то, как я вижу решение данной задачи, и он доступен на Github. Его исходный код можно дописывать и переписывать под ваши нужды, как вам захочется. Итак, начнем описание…

Для восстановления данных из XOM нужно подключиться к исследуемому микроконтроллеру через отладчик, например, JLINK+OpenOCD. Подключение подразумевает возможность пошагового выполнения инструкций, чтение/запись регистров и RAM-памяти. Память особенно важна, поскольку для корректного анализа инструкций чтения/записи необходимо контролировать её состояние. 

Первым делом нужно определить, сколько RAM-памяти доступно для чтения/записи. Эту информацию можно найти в документации, а если документации нет, то поможет OpenOCD:

  1. Попробовать использовать адрес вершины стека. После команды OpenOCD «reset halt» в регистре SP лежит указатель верхушки стека, это значение можно попробовать считать старшими адресами доступной на чтение/запись RAM, а младшие адреса можно узнать перебором. Если под стек используется какая-то специальная область памяти, то этот способ может не сработать. 

  2. Перебирать адреса с помощью OpenOCD. 

> reset halt
[rtl8762c.cpu.km0] Only resetting the Cortex-M core, use a reset-init event handler to reset any peripherals or configure hardware srst support.
[rtl8762c.cpu.km0] halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x0000107c msp: 0x00203800
>

Повезет, если в микроконтроллере окажется более 60 кБ RAM-памяти. Почему именно столько расскажу чуть позже. 

Фреймворк восстанавливает инструкции по одной, его не заботит реальный поток выполнения программы в XOM. Он как угодно меняет контекст выполнения конкретной инструкции, может выполнять ее по нескольку раз, пока не вынесет вердикт - смог он распознать данную инструкцию или нет.

В текущем состоянии XOM Extractor может восстанавливать следующие инструкции ARM Thumb:

  • ADD, ADC;

  • AND, ORR, EOR, BIC;

  • ASR, LSL, LSR;

  • MOV, MVN, MOVT;

  • BFI, UBFX;

  • UXTB, UXTH;

  • SUB, SBC, RSB;

  • CMP, TST;

  • BL, BX, BLX, Bcc;

  • LDRB, LDRH, LDR, LDRD, LDM;

  • STRB, STRH, STR, STRD, STM;

  • MUL, UMULL, UMAAL, UMLAL;

  • PUSH, POP.

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

Шаги выполняемые XOM Extractor

Шаги выполняемые XOM Extractor

  1. Установить в значение регистра PC (Program Counter) адрес той инструкции, которую требуется восстановить.

  2. Загрузка данных в регистры. Какие регистры и какими значениями? В Cortex-M нам доступны регистры общего назначения (РОН) R0, R1,…, R12, SP и LR, а также регистр xPSR (объединяет APSR, IPSR и EPSR). А вот со значениями надо подумать… Представим, что мы установили PC на инструкцию LDR R0, [R5], но мы об этом пока не знаем, ведь мы не видим код. Эта инструкция возьмет из адреса, содержащегося в R5, 32-х битное значение и запишет его в R0. Но если R5 содержит какое-то произвольное значение, то после выполнения STEP можно получить Hard Fault, если значение в R5 указывало «не туда». Следовательно, значения в регистрах должны указывать на доступную область памяти, как минимум до того момента, пока мы не убедимся, что исследуемая инструкция это не LDR и не STR. Например, в исследуемом микроконтроллере доступный фрагмент RAM = [0x210000…0x220000], поэтому используем описанное ограничение для инициализации регистров следующим образом: R0 = 0x210000, R1 = 0x211000, R2 = 0x212000, …, SP = 0x21D000, LR = 0x21E000.

  3. Заполнение памяти. Помним, что РОН, SP и LR хранят указатели на RAM с шагом в 0x1000. Здесь вернемся к тому, почему 60 кБ RAM –  это хорошо. 60 кБ – это количество регистров R0, …, R12, SP и LR, умноженное на 0x1000. Почему именно на 0x1000? Потому что в ARM Thumb есть, например, инструкция LDR (immediate) следующего формата:

    где поле imm12 хранит в себе смещение именно в пределах 0x1000. Иначе инструкцию, например, LDR.W R0,[R1,0xFF0], определить будет сложно. Компиляторы сейчас умные и почти наверняка будут использовать маленькое отрицательное число в параметре смещения, но этого никто не обещал. Начальное заполнение памяти перед выполнением инструкции, пока нет гипотез относительно вида инструкции, может быть любым. Менять содержимое памяти будем позже, когда гипотезы появятся.

Описывать шаги «STEP» и «Get Registers» смысла нет, – просто выполнить одну инструкцию и получить новое состояние регистров.

После выполнения инструкции на вход анализатора поступают: состояние регистров до выполнения, после выполнения, разница в состоянии регистров (список изменений) и состояние памяти.

Схема анализатора инструкций

Схема анализатора инструкций

На рисунке, стрелками выходящими из «Analyzer»,  показаны «направления», в которых происходит анализ класса инструкций. На классы они поделены по следующим признакам:

  • инструкции ветвления;

  • стековые инструкции;

  • инструкции, которые изменили только состояние флагов;

  • инструкции, которые изменили 1 регистр;

  • инструкции, которые не изменили состояние регистров;

  • инструкции, которые изменили несколько регистров.

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

Допустим, выполненная инструкция не изменила состояние регистров (класс «Nothing»), проверяем, не была ли это STR (запись в память). Оказывается, что нет, а это был не сработавший условный переход, который относится к другому классу инструкций - «Branches».

Логично будет спросить, а зачем выдумывать какие-то классы? Перебирай все инструкции подряд и всё. Ответ - производительность. Проверка одной 4-х байтовой инструкции CMP вида CMP.W <Rn>,<Rm>{,<shift>} в худшем случае требует 5642 раза перезаполнить регистры и выполнить STEP только для одного вида сдвига (LSL или LSR). Дорогое удовольствие! Поэтому я попытался хоть как-то классифицировать инструкции и сделать их перебор чуть более адресным, опираясь также на частоту встречаемости инструкций в «стандартном» коде, но даже в этом случае восстановление 32кБ XOM занимает почти трое суток (частота JTAG 4 МГц).

Прежде чем переходить к анализу классов, разберемся с регистром PC. Первым делом надо удалить его из списка изменений, поскольку PC меняется всегда (кроме перехода «на себя»). Прежде, чем это сделать, вычислим и запомним разницу между состояниями PC, поскольку её можно считать размером выполненной инструкции (кроме инструкций ветвления). Эта разница может быть:

  • Ноль. Тогда это инструкция вечного цикла. И это неприятная история, потому что нельзя уверенно сказать, что это: 2-х байтовый B «на себя» (FE E7) или 4-х байтовый BL «на себя» (FF F7 FE FF). Неприятно это потому, что мы не знаем истинный размер инструкции (2 или 4 байта) и теперь неясно, на сколько байт надо смещаться на следующую инструкцию относительно этой;

  • Два. Тогда это почти наверняка 2-х байтовая инструкция;

  • Четыре. Тогда это 4-х байтовая инструкция или... выполнившийся 2-х байтовый условный или безусловный переход, который «перепрыгнул» одну 2-х байтовую инструкцию. Ниже классический пример такого перехода: 

  • Больше четырех или отрицательная. Тут уверенно можно сказать, что это инструкция перехода. Это может быть BL, BX, BLX или Bxx. 

Итак, переходим к классам.

Класс «Branches»

Если разница в значениях PC отрицательная или больше 4, и при этом РОН и SP не изменились, то это переход. Какой именно? Давайте разбираться.

Если в списке изменений есть LR, то это либо BL, либо BLX. Отличить их просто: если значение PC после выполнения инструкции равно значению какого-либо РОН до выполнения, то это BLX, иначе это BL. Если LR не изменился, но значение PC после выполнения инструкции равно значению какого-либо РОН, то это инструкция BX.

Если LR не изменился, то это также могут быть инструкции CBZ или CBNZ. Чтобы это проверить, надо выполнить инструкцию еще 2 раза: первый раз заполнив все РОН нулевыми значениями, а второй раз – ненулевыми. Если длина перехода в первом и втором случаях не совпадают, то проверяем, в каком случае значение перехода было больше (либо отрицательным) и выясняем какой именно регистр вызывает выполнение CBZ (CBNZ). Поясню на простом примере опознания инструкции по адресу 0х578:

Определение инструкций CBZ/CBNZ в коде

Определение инструкций CBZ/CBNZ в коде

 Чтобы понять, что перед нами именно CBZ или CBNZ, заполним все регистры нулями и выполним инструкцию по адресу 0x578. Получим переход на 0х586, потом заполним все «ненулями» и выполним еще раз, получим переход на 0x57A. Поскольку переход сработал тогда, когда значения регистров были нулевыми, стало понятно, что это CBZ. После этого в цикле заполняем по очереди один регистр нулем, а все остальные – «ненулями», выполняем инструкцию и так до тех пор пока не выясним, какой регистр проверяется.

Допустим, это оказались не BL, не BX, не BLX, не CBZ и не CBNZ, а какой-то Bxx. В ARM Instruction Set есть таблица, которая описывает флаги по которым происходят условные переходы.

Таблица флагов условных переходов из ARM Instruction Set

Таблица флагов условных переходов из ARM Instruction Set

Опираясь на таблицу и зная, что биты флагов в регистре XPSR имеют смещения
N(negative) – 31-й, Z(zero) – 30-й, C(Carry) – 29-й и V(Overflow) – 28-й, составим свой список условий, который и будем проверять на исследуемой инструкции. 

Имя условия

NZCV

Возможные комбинации флагов

eq

x1xx

0100 0101 0110 0111 1100 1101 1110 1111

lt

0xx1 или 1xx0

0001 0011 0101 0111 1000 1010 1100 1110

cs

xx1x

0010 0011 0110 0111 1010 1011 1110 1111

mi

1xxx

1000 1001 1010 1011 1100 1101 1110 1111

pl

0xxx

0000 0001 0010 0011 0100 0101 0110 0111

vs

xxx1

0001 0011 0101 0111 1001 1011 1101 1111

ne

x0xx

0000 0001 0010 0011 1000 1001 1010 1011

cc

xx0x

0000 0001 0100 0101 1000 1001 1100 1101

vc

xxx0

0000 0010 0100 0110 1000 1010 1100 1110

ge

1xx1 или 0xx0

0000 0010 0100 0110 1001 1011 1101 1111

ls

x10x

0100 0101 1100 1101

le

11x0 01x1

1100 1110 0101 0111

hi

x01x

0010 0011 1010 1011

gt

10x1 00x0

1001 1011 0000 0010

Таблица флагов условных переходов и их возможных комбинаций

Далее берем исследуемую инструкцию и выполняем ее для каждого условия столько раз, сколько есть для нее комбинаций флагов, то есть либо 4, либо 8 раз, и запоминаем длины получившихся переходов. Если в результате выполнения получилось, что у всех инструкций из таблицы все длины переходов одинаковые (но длина не равна 2!), то это безусловный переход. Иначе ищем, у какой инструкции длина перехода одинакова для всех комбинаций флагов. Если таковая нашлась, то значит это и есть тот самый условный переход... Ну а если ничего не сработало, то эта инструкция – не переход.

Класс «Stack»

Инструкции работы со стеком одни из самых однозначно трактуемых. Если изменился регистр SP, то это либо PUSH, либо POP. Может быть еще ADD/SUB, но это легко детектируется. PUSH от POP отличить легко – если значение SP уменьшилось, то это PUSH, увеличилось – POP. PUSH{r3-r5,lr} от PUSH{r4,lr} также несложно отличить, в регистры записаны контрольные значения перед выполнением инструкции, значит эти же самые значения должны оказаться в памяти, на которую указывал SP до выполнения инструкции. По содержимому памяти реконструируется цепочка регистров, помещенных в стек. С инструкцией POP все делается наоборот... Однако POP имеет одну неприятную особенность – размер инструкции. Дело в том, что POP часто стоит на выходе из функции и выполняет возврат управления по адресу, записанному в регистр LR при входе в функцию, и проблема здесь в том, что снова нельзя однозначно сказать, является ли эта инструкция 2-х байтовым POP или 4-х байтовым POP.W. В большинстве случаев такая проблема решается запоминанием того, какой до этого был PUSH (2-х или 4-х байтовый) выше по коду, но это срабатывает не всегда. 

Неоднозначность размера инструкции POP

Неоднозначность размера инструкции POP

Почему я на этом заостряю внимание? Потому что, если ошибочно будет распознана 4-х байтовая инструкция, то следующая за ней валидная 2-х байтовая будет пропущена. Если ошибочно будет распознана 2-х байтовая инструкция, то следующая за ней может оказаться 4-х байтовой «ложной» инструкцией и она может потянуть за собой цепочку «ложных» инструкций (длина такой цепочки будет невелика, но все равно неприятно). Как мне подсказал мой коллега, в таких «сомнительных» участках кода можно использовать двухпроходный перебор, считая сначала все инструкции 2-х байтовыми, а потом проходиться по этим инструкциям еще раз, заменяя те инструкции, которые оказались точно 4-х байтовыми. Это пока не реализовано, но может помочь решить обозначенную выше проблему.

Класс «1 Register»

Рассмотрим инструкции, которые приводят к изменению одного РОН и, возможно, регистра флагов. Это могут быть инструкции MOV, инструкции арифметических действий (ADD, SUB и т. д.), логические битовые инструкции (AND, ORR и т. д.), сдвиговые инструкции, вставка и извлечение бит (BFI, UBFX и т. д.), знаковое и беззнаковое расширение числа и некоторые другие. Не стоит забывать про инструкции LDR и, как ни странно, STR. Почему STR? Потому что инструкции с инкрементом, вроде STR.W R0,[R3,#8]! и STRB.W R2,[R4],#1, изменяют значения регистров. Подробнее про восстановление инструкции STR будет сказано позже.

Начинать проверку такого класса инструкций стоит с тех, которые работают с памятью, то есть LDR и STR, поскольку Hard Fault никто не отменял. Начнем с LDR. Прежде чем начинать исследовать инструкцию необходимо «правильно» заполнить память. Я решил заполнять память такими паттернами:

Заполнение памяти для восстановления LDR

Заполнение памяти для восстановления LDR

То есть память, на которую указывают РОН, LR и SP заполнена так, что в 16-ти битных паттернах содержится указание на то, относительно какого регистра произошла загрузка значения (старшие 4 бита) и по какому именно смещению (младшие 12 бит).

Предположим, что исследуемая инструкция – это LDR (держим в голове, что это может быть LDR, LDRH или LDRB). После выполнения инструкции в изменившемся регистре может быть:

  • 32-х битное значение, если инструкция была LDR. Тогда убеждаемся, что старшее и младшее полуслова отличаются на 1. То, относительно какого базового регистра происходила загрузка значения в целевой регистр, указано в старших четырех битах. Смещение вычисляем по младшим 12 битам по формуле: (offset-1)*2 и убеждаемся, что смещение кратно 4 (для LDR иначе быть не может);

  • 16-ти битное значение. Убеждаемся что индекс не больше 0x0E и смещение не больше 0x7FF. Далее, по формуле выше, вычисляем адрес памяти откуда взято получившееся значение, перезаписываем его некоторым контрольным значением (у меня 0x89EF) и выполняем эту инструкцию еще один раз. Если значение в изменившемся регистре совпадает с контрольным, значит это действительно LDRH;

  • 8-ми битное значение. Это самый неприятный случай, поскольку у нас уже нет информации о том, относительно какого регистра загружался этот байт. Чтобы это выяснить, необходимо снова переписать память (все страницы относительно всех наших 15-ти регистров), причем страница, на которую ссылается R0, должна быть записана одним только байтом 0x01, R10x02, R20x03 и т. д. Выполнив инструкцию еще раз, можно понять, относительно какого регистра была выполнена загрузка. Чтобы определить смещение, необходимо заполнить память, на которую ссылается найденный регистр, таблицами байт [0x00…0xFF], выполнить инструкцию, затем таблицами байт [0xFF…0x00] и опять выполнить инструкцию, так мы найдем индекс байта от 0 до 0xFF в пределах 0x100 байт. Допустим индекс получился 0x87. Чтобы узнать конкретное смещение (ведь оно может быть и 0x87, и 0x187, и 0x287 и т. д.), надо в каждой 0x100 байтовой таблице в пределах 0x1000 байт, которые адресуются найденным базовым регистром, еще раз перезаписать байт по этому индексу на, например, 0x11, 0x22, 0x33 и т. д. и тогда уже получить точное смещение.

 Инструкции типа LDR.W R1,[R2,R3,LSL#2] тоже можно успешно распознавать, причем достаточно быстро, немного манипуляций с заполнением памяти и все получается. Кстати, если инструкция изменила и значение РОН, и регистр флагов, то это не может быть инструкция LDR или STR, в таком случае существенную часть перебора инструкций, изменивших один регистр, можно отбросить.

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

Поясню на примере. Допустим, исследуем, является ли данная инструкция инструкцией ADD. Выполняем инструкцию и анализируем ее результат, перебирая в цикле суммы пар регистров РОН до выполнения инструкции (я оставляю значения соответствующие адресам RAM, которые описывал выше). Если никакая пара регистров не дает результата, получившегося в изменившемся регистре, то значит это не ADD. Если дает, то надо убедиться, что это не случайное срабатывание. Для этого в цикле (у меня от 10 до 15 раз) заполняем «подозреваемую» пару регистров псевдослучайными числами и выполняем инструкцию. Если все 10-15 раз подсчитанная сумма совпала со значением в изменившемся регистре, то значит мы правы и это действительно ADD. В этом же цикле нужно в регистре XPSR устанавливать в единичку флаг Carry до выполнения инструкции, это поможет нам отличить ADD от ADC. Понятно, что ADD имеет несколько видов выполнения , но ничего не поделаешь, надо перебирать все.

Все варианты инструкции ADD

Все варианты инструкции ADD

Описанным выше методом получается достаточно быстро распознать следующие инструкции: MOV, MOVT, ADD/ADC, AND, ASR, EOR, LSL, LSR, ORR, SUB, UBFX, UXTB, UHTX, MUL, MVN, BFI, BIC и RSB.

Класс «Only Flags»

Рассмотрим инструкции, исполнение которых привело только к изменению флагов в регистре XPSR. Самый классический пример таких инструкций – это CMP и TST. Обе меняют только флаги, но не содержимое регистров, CMP делает вычитание операндов, TST – логическое «И». При этом инструкция TST восстанавливается легко и быстро, а вот восстановление CMP – напротив, самое долгое (в худшем случае).

Начнем с TST. TST может иметь вид <регистр, значение> и <регистр, регистр>. Чтобы восстановить значение маски, которое используется в качестве второго операнда в инструкции вида <регистр, значение>, достаточно в цикле поочередно выставлять значения всех регистров в ноль, а в одном, который мы перебираем, «протягивать» единичку от младшего бита к старшему, каждый раз выполняя инструкцию. Когда позиция бита в регистре совпала с позицией бита в маске (второй операнд), значение флага Z становится нулевым. Таким образом, подобрав РОН, с которым делается TST, за 32 попытки восстанавливается 32-х битная маска. В случае <регистр, регистр> все еще проще – во все регистры записывается значение 0xFFFFFFFF, а в одну перебираемую пару – значения 0x55555555 и 0xAAAAAAAA. Когда пара регистров совпадает с той, что содержится в реальной инструкции, флаг Z примет единичное значение.

С инструкцией CMP сложнее, так как она имеет множество форматов:

  • CMP <Rn>,#<imm8> - сравнение с 8-ми битной константой;

  • CMP.W <Rn>,#<const> - сравнение с 32-х битной константой (константа не совсем честно 32-х битная, в том смысле, что не может быть любой, но тем не менее);

  • CMP <Rn>,<Rm> - сравнение двух регистров;

  • CMP.W <Rn>, <Rm> {,<shift>} - сравнение двух регистров, где регистр Rm может быть подвергнут операциям сдвига.

Опишу, как реализована проверка инструкции CMP. Воспользуемся тем, что если сравниваемые операнды равны, то флаг Z принимает единичное значение (напомню, что CMP делает вычитание операндов). Чтобы проверить, является ли сравнение сравнением с константой, воспользуемся бинарным поиском, ведь не перебирать же все числа (в худшем случае 32-х разрядные) для каждого РОН. Помогает то, что если проверяемое число, записанное в РОН, больше сравниваемого, то флаг N принимает нулевое значение, а если меньше – единичное. Поэтому для восстановления константы, с которой производится сравнение, необходимо всего 8 выполнений инструкции для каждого РОН, если константа 8-ми битная и 32 – если константа 32-х битная. В худшем случае получается 13 РОН + регистр LR = 14 регистров, и умножим это на 32, получается 448 попыток, на мой взгляд, неплохо.

Если инструкция выполняет сравнение регистра с регистром, то все еще проще. В каждой проверке заполняем одну пару регистров одинаковыми значениями, а остальные регистры псевдослучайными значениями. И так перебираем все пары. Даже если допустить, что в реальном коде бывает инструкция CMP Rn,Rn, то получается 14 умножить на 14 равно 196 проверок. Тоже немного.

Неприятно большой перебор получается, когда инструкция CMP выполняет сравнение со «сдвинутым» (LSL, LSR) значением второго регистра. В этом случае в перебор добавляется еще один множитель – значение сдвига, а он может быть от 1 до 31. Таким образом получается 14 умножить на 13 (все таки считаем, что, например, CMP Rn,Rn,LSR#10 не бывает) и умножить на 31 получается 5642 проверок! Это уже долго, а сдвигов в инструкции CMP может быть два вида – LSL и LSR. Получается сильно дорого! Теперь представьте, что перебираемая инструкция – это вообще не инструкция CMP... То есть вызывать тестирование инструкции на предмет, может ли она быть CMP, надо только когда это действительно необходимо.

Класс «Many Registers»

Класс инструкций, которые вызывают изменения нескольких регистров, не очень велик, но из-за одних только инструкций LDR с инкрементом вида LDR.W R1,[R0,#1]! его нельзя игнорировать. Инструкции LDR такого вида изменяют при выполнении два регистра: первый – регистр назначения и второй – инкрементируемый регистр. Отличить их от «обычного» LDR несложно именно по значению инкремента. Инструкция LDRD просто загружает в регистры два 32-х битных значения и изменяет при выполнении два регистра. Также важная и частая инструкция LDM (копирование блока регистров) тоже несложно детектируется, поскольку нам известно состояние регистров до выполнения инструкции и содержимое памяти после.

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

Класс «Nothing»

Рассмотрим заключительный и самый неоднозначный класс. Это инструкции, которые не меняют никаких регистров при выполнении. Не беда, если при выполнении поменялась хотя бы память, тогда мы понимаем, что почти наверняка это STR и разбираемся какой конкретно.

Сразу оговорюсь, что поместил STRSTM) в класс «Nothing», хоть эти инструкции и могут менять значения одного или нескольких регистров, потому что большинство STR, встреченных мной в восстанавливаемых прошивках, регистров не меняли. Процесс восстановления STR намного проще, чем LDR. Достаточно заполнить всю память, на которую ссылаются наши РОН, неким значением (я использую 0x55), выполнить инструкцию и посмотреть, где и что в памяти изменилось. Правда есть маленькая хитрость, помните мы заполняли регистры значениями, ссылающимися на RAM вот так: R0 – 0x210000, R1 – 0x211000, и т. д.? Так вот здесь это чуть-чуть изменим на: R0 – 0x210000, R1 – 0x211010, R2 – 0x212020 и т. д., потому что если этого не сделать, то мы не сможем распознать из какого регистра, произошла запись одного байта при выполнении инструкции STRB.

В контексте рассказа про STR расскажу еще про неожиданную сложность. По неведомой мне причине метод read_memory Python-класса openocd оказался довольно капризным и не стал копировать из памяти контроллера блоки данных длиной 0x1000, как раз те, на которые ссылаются регистры. А эту память обязательно надо посмотреть, иначе как анализировать изменения в памяти? Максимум, на что удалось его «уговорить» - это 0x100 байт, поэтому чтение памяти пришлось делать кусочками (кто будет смотреть исходники, не удивляйтесь)... Резюмируя про STR, инструкция восстанавливается почти во всех ее формах (не реализовано распознавание инструкции вида STR <Rt>,[<Rn>,<Rm>]), также восстанавливается STM, но выполнение анализа занимает достаточно длительное время из-за необходимости перезаписывать и считывать большие объемы памяти RAM.

К классу «Nothing» также отнесены инструкции, которые совсем ничего не изменили, при своем выполнении. Приведу некоторые примеры случаев с которыми можно «бороться»:

  • невыполнившийся условный переход;

  • битовые инструкции AND, ORR, EOR, BIC, которые не изменили ни флаги, ни состояние РОН (во втором операнде оказалась «неудачная» маска);

  • не изменившее значение флагов выполнение инструкций TST и CMP.

Как тестировать условные переходы, мы говорили выше, проверка выполняется достаточно быстро. Чтобы определить битовые инструкции достаточно несколько раз в цикле заполнять регистры псевдослучайными значениями и выполнять инструкцию, проверяя не изменились ли значения РОН. Данная проверка тоже выполняется достаточно быстро. А вот проверка инструкций TST и CMP (в основном CMP), это долго и я не нашел способа избежать его «холостого» использования, кроме как оставлять проверку этих инструкций на самую последнюю очередь, когда ничего до этого не дало результатов.

Заключение

С помощью разработанного фреймворка XOM Extractor мне удалось восстановить прошивки XOM от микроконтроллеров RTL8762D и RTL8713E. Это стало хорошим толчком в исследовании, которое мы с коллегой презентовали на OFFZONE 2026. Для RTL8762D на 8 кБ кода нераспознанными оказались 37 инструкций, а для 32 кБ RTL8713E, процент нераспознанных инструкций составил 3,3%. Говорить про инструкции сопроцессора, инструкции ожидания (WFI, WFE), барьеры (ISB, DSB) и NOP я не могу, потому что у меня нет идей, как их можно достоверно восстанавливать. Кстати, ISB и DSB, при наличии достаточного опыта, довольно неплохо определяются в восстановленном коде методом «пристального взгляда». Этим же методом видны возможные ошибки при обработке инструкции POP, про которые я писал выше.

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

Кстати, я подозреваю, что теоретически подобный инструмент можно сделать для любой RISC-архитектуры… Всем удачи!

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.