KatWalk C2: ч.5: Учимся разбирать ARM в Ghidra (aka оверклокинг и багфиксинг)

от автора

«Что б они ни делали — не идут дела. Видимо в прошивке багов дофига». Как я напомнил в прошлой статье (где я подготовил утилиты для перепрошивки сенсоров) — я рассказываю про платформу для VR игр, как с ней интегрироваться и как добраться до ее сенсоров напрямую.

Её исходный ресивер обновляет сенсоры с частотой в 86Гц, тогда как технически возможно разогнать до 133 Гц, получив ощутимо ниже задержки, но связь была нестабильной.

Давайте начнём погружение в сенсоры — посмотрим, что за игра ghidra_11.0_PUBLIC установлена у меня в C:\Games, заглянем одним глазком в саму прошивку и поковыряемся там грязными патчиками, да исправим race condition плюс выкинем немного отладочных глюков. В общем, готовимся к погружению. В этот раз — всё серьёзно.

We need to go deeper... into Ghidra

We need to go deeper… into Ghidra

Гидра: как накормить дракона бинарником под ARM

Так как развлечения не позволяют покупать полноценную IDA с поддержкой ARM, а IDA Freeware только работает с x86, придётся пользоваться чем-нибудь другим. Этим чем-нибудь, разумеется, оказалась Ghidra — нашумевший несколько лет назад выложенный в опенсорс мощный дизассемблер, с системой скриптования, поддержкой множества архитектур и так далее.

Всё никак было не до него, а тут прекрасный повод подвернулся.

Скачиваем гидру куда-нибудь (C:\Games), распаковываем и запускаем через C:\Games\ghidra_11.0_PUBLIC\ghidraRun.bat. Создаём проект, через «File=>Import File» импортируем наш Bin файл. Гидра умеет разные форматы, но вот HEX не умеет, хорошо, что мы уже превратили в bin. Еще не определяет сама содержимое, хорошо, что мы уже знаем что это ARM, Cortex M3, little endian:

Выбор языка

Выбор языка

Гидра справшивает, не проанализировать ли файл, соглашаемся, и получаем ничего практически интересного, практически, чистый лист!

чистый лист

чистый лист

Регионы памяти

Не будем опускать руки, для начала, заполним карту памяти, которую я нашел где-то в документации у TI:

Карта памяти

Карта памяти

Идём в «Windows => Memory Map» и правим. Уже загруженный регион переименовываем во «flash» и убираем галочку «W» (это не записываемый регион). Через зеленый плюс в наборе инструментов в правом верхнем углу добавляем «rom» с адресом начинающимся с 10000000, длинною в 0x20000 (точнее в 0x1CC00 но это не важно), галочки ставим в R и X (убираем W). Еще добавляем регион «ram» с адреса 20000000, размером в 0x5000, R/W но не X.

С новой информацией мы понимаем, что в самом начале у нас адрес на стек, в RAM, 0x20004000, как и положено согласно карты памяти. Затем есть вектор, с которого начнётся исполнение, затем обработчики прерываний — все указывающие внутрь ROM, которого у нас нет, и в конце два каких-то кривых указателя. Не похожи на правду.

Но с картой памяти мы еще не закончили, еще есть «Peripherals» регион, через чтение-запись адресов в котором идёт доступ и настройка железа. Самое удобное, скачать CMSIS-SVD пакет, в котором собраны в машиночитаемом виде описание железа для большинства ARM чипов и модулей. А чтобы воспользоваться им, скачиваем SVD-Loader-Ghidra, после чего идём в «Window=>Script Manager», жмём на третью справа иконку (три таких полосочки, левее крестика и красного плюсика, называется «Manage Script Directories»), где через зелёный плюсик добавляем путь до скачанного плагина ($USER_HOME/Documents/GitHub/SVD-Loader-Ghidra в моём случае). Закрываем менеджер директорий, и в фильтр в менеджере скриптов вводим SVD, чтоб быстро найти его:

скритп в менеджере

скритп в менеджере

Двойным кликом запускаем его, он попросит выбрать требуемый SVD файл. Выбираем ...GitHub\cmsis-svd-data\data\TexasInstruments\CC26x0.svd.

Скрипт отработает и создаст требуемые регионы памяти в 0x40000000.

Разметка недоступного ROM

Когда код представляет собой сырую портянку, каждый бит полезной информации на вес золота. А потому, скачиваем SDK, распаковываем, и начинаем копаться. Мы знаем, что отладка возможна. То есть какие-то отладочные символы должны лежать. Осталось их найти 🙂

Проще всего начать с адреса прерываний: 1001c901, на который указывают все вектора. Возвращаемся в линукс, и:

$ cd /mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03 $ find . -name '*map' | xargs grep -i '1001c90'  ./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:                  1001c900    00000020     arm_m3_Hwi_asm_rom.obj (.text:ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I) ./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:1001c901  ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I                              ./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:1001c901  ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I       

Отлично, выглядит интересно! Надо только прогрузить. В плагинах с гидрой уже есть ImportSymbolsScript.py, но игры с ним оказались неудобными. Под свой случай я подправил его до ArmImportSymbolsScript.py. Основная модификация: если символ это ссылка на функцию, то адрес округляется до четного и на этом месте создаётся dword, чтобы адрес+1 указывал прямо на этот символ. Это позволило импортировать символы более удобно для автоанализа и чтения когда потом.

Впрочем, нам всё равно надо символы превратить в подходящий формат: <имя> <адрес> <тип f или нет>.

А еще, карта хоть и совпадает для символов, то, что ниже 1xxxxxxx — не всё правда. Например, по адресу 00001a25 main — совсем не main.

Самое простое — не разбираться с этой картой, а разобрать напрямую приложенный объектник:

$ cd /mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03 $ mkdir symbols $ objdump -t ./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/CC2640R2F_rtos_rom_syms.out | perl -ne '@a=split;print "$a[-1] $a[0] $a[2]\n" if $a[1] eq "g"' > symbols/rom.txt

И теперь мы можем его подгрузить двойным кликом по ArmImportSymbolsScript.py в Scripts Manager, выберем файл из C:\ti\simplelink...\scripts\rom.txt:

Уже какой-то смысл

Уже какой-то смысл
$ for k in source/ti/ble5stack/rom/ble_rom_releases/cc26xx_r2/Final_Release/*symbols; do     objdump -t $k | perl -ne '@a=split;print "$a[-1] $a[0] $a[2]\n" if $a[1] eq "g"' > symbols/${k##*/}.txt   done

Импортируем ble_r2.symbols.txt и common_r2.symbols.txt.

Разметка API SDK

Как мы видели в карте памяти, с адреса 0x1000 начинается «TI RTOS ROM Jump Table». Если сходим туда, увидим кучу указателей. Но не джампов… Впрочем, в TI системе есть еще одна огромная табличка переходов — «ROM_Flash_JT» — она расположена совсем не по 0x1000, но найти её легко: достаточно взять функцию из тех, что мы уже нашли в символах — я использую HCI_bm_alloc — и найти ссылку на неё:

Табличка

Табличка

Это и есть она. Переименовываем в «ROM_Flash_JT». Теперь мы можем сравнить табличку с содержимым в rom_init.c (полный путь
C:\ti\simplelink_cc2640r2_sdk_5_30_00_03\source\ti\blestack\rom\r2\rom_init.c), чтобы расставить еще кучу имён. Чтобы не делать это вручную, Я накидал скриптик ROM_Flash_JT — запускаем, выбираем rom_init.c, наслаждаемся:

Еще больше смысла!

Еще больше смысла!

Распаковка ROM2RAM

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

const char * str = "MyString"; char * str2 = "OtherString";

то str можно ставить как указатель на строку в ROM, то str2 должна быть скопирована в RAM, так как мы можем её менять. То же самое с константами в статических структурах и так далее. Чтобы это работало, компилятор перед запуском пользовательского кода производит распаковку констант, сохранённых в ROM. Насколько понимаю, разные компиляторы делают это по разному, но в случае с GCC использованным в TI SDK (как часть XDC Tools), таблица перекидывания прописана в самом начале кода. Скачиваем скрипт arm-romtotram, затем идём по адресу указанному в Reset:

Самое начало кода после старта

Самое начало кода после старта
unpackRomToRam

unpackRomToRam

Из неё нам требуется проставить три метки:

  • «ROMtoRAMtable» — начальное значение цикла,

  • «ROMtoRAMtableEnd» — финальное значение цикла (гидра может не позволить сразу поставить там метку, придётся дважды кликнуть на неё, создать там указатель кнопкой «p» и потом уже через «L» поставить метку «ROMtoRAMtableEnd»),

  • Указатель на массив обработчиков использованный внутри цикла — «ROMtoRAM_Processors».

Функция принимает вид:

void unpackRomToRam(void) {   byte **ppbVar1;   for (ppbVar1 = (byte **)&ROMtoRAMtable; ppbVar1 < &ROMtoRAMtableEnd; ppbVar1 = ppbVar1 + 2) {     (*(code *)(&ROMtoRAM_Processors)[**ppbVar1])(*ppbVar1 + 1,ppbVar1[1]);   }   xdc_runtime_Startup_exec__E();   return; }

Таблица процессоров содержит три функции: функция распаковки чего-то напоминающего LZ (переносим байт как есть, или копируем N байт начиная с M байт в прошлое), memcpy и обнуления региона. Сама таблица rom2ram содержит просто пары адресов — адрес в ROM аргумент для распаковщика и адрес в RAM для получателя.

Теперь, когда мы подписали все три метки, можно запустить «arm-romtoram» скрипт, и бульк — всё готово.

Анализ кода

Когда мы собрали всё, что может нам понять код, можем приступать. Для начала поправим непонятные ссылки, которые висят в SysTick и IRQ. Это не указатели, а уже начальный код прошивки, поэтому сбросим из через «c», переименуем SysTick в «Begin» и сделаем его кодом через F12, затем функцией через «f».

Код выглядит как инициализация чего-то:

  FUN_0000c9d0(&DAT_20001130,&DAT_20001150);   ti_sysbios_knl_Queue_construct(&DAT_2000118c,0);   DAT_20001154 = &DAT_2000118c;   FUN_0000cdfc(&DAT_2000120c,&LAB_000100e2+1,0,8);   FUN_0000cdfc(&DAT_20001230,&LAB_000100e2+1,3,4);   FUN_0000cdfc(&DAT_20001254,&LAB_000100e2+1,0,0xb);   FUN_0000cdfc(&DAT_200011e8,&LAB_000100e2+1,0,3);   DAT_20002038 = FUN_00009730(&DAT_20002040,&DAT_20002064);   if (DAT_20002038 == 0) {     do {                     /* WARNING: Do nothing block with infinite loop */     } while( true );   }   FUN_00005bf4(0x10,0xe165,0x1f,6);   uVar20 = 0;   local_5c = 9;   local_5e = 8;   local_53 = 0;   local_56 = 0;   local_5a = 100;   local_58 = 1000;   FUN_0000363c(0x306,2,&local_56);   FUN_0000363c(0x308,0x10,&DAT_200011a4);   FUN_0000363c(0x307,7,&DAT_20001174);   FUN_0000363c(0x310,1,&local_53);   FUN_0000363c(0x311,2,&local_5e);   FUN_0000363c(0x312,2,&local_5c);   FUN_0000363c(0x313,2,&local_5a);   FUN_0000363c(0x314,2,&local_58);   FUN_00005bf4(0x10,0xe165,6,0xa0);   FUN_00005bf4(0x10,0xe165,7,0xa0);   FUN_00005bf4(0x10,0xe165,8,0xa0);   FUN_00005bf4(0x10,0xe165,9,0xa0);   local_64 = 0;   local_50 = 0;   local_52 = 1;   local_51 = 1;   local_4f = 1;   FUN_00005bf4(0x10,&DAT_00003fb5,0x408,4,&local_64);   FUN_00005bf4(0x10,&DAT_00003fb5,0x400,1,&local_52);   FUN_00005bf4(0x10,&DAT_00003fb5,0x402,1,&local_51);   FUN_00005bf4(0x10,&DAT_00003fb5,0x403,1,&local_50);   FUN_00005bf4(0x10,&DAT_00003fb5,0x406,1,&local_4f);   FUN_00005bf4(0x10,&LAB_0000f3f8+1,&DAT_2000117c); 

Осталось понять чего. Для начала, сделаем поиск по константам (через Notepad++ или grep’ом):

datacompboy@NUUBOX:/mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03$ find . -name '*.h' | xargs grep 0x306 ./kernel/tirtos/packages/gnu/targets/arm/libs/install-native/arm-none-eabi/include/elf.h:#define NT_S390_LAST_BREAK0x306 ./source/ti/blestack/profiles/roles/cc26xx/broadcaster.h:#define GAPROLE_ADV_EVENT_TYPE      0x306  //!< Advertisement Type. Read/Write. Size is uint8_t.  Default is GAP_ADTYPE_ADV_IND (defined in GAP.h). ./source/ti/blestack/profiles/roles/cc26xx/multi.h:#define GAPROLE_ADVERT_OFF_TIME     0x306 ./source/ti/blestack/profiles/roles/cc26xx/peripheral.h:#define GAPROLE_ADVERT_OFF_TIME     0x306 ./source/ti/blestack/profiles/roles/peripheral_broadcaster.h:#define GAPROLE_ADVERT_OFF_TIME     0x306  //!< Advertising Off Time for Limited advertisements (in milliseconds). Read/Write. Size is uint16. Default is 30 seconds.

О, прекрасно, у нас же сенсор, значит из peripheral. Давайте подгрузим константы в гидру:

File=>Parse C source=>зеленый плюс=>»c:\ti\simplelink_cc2640r2_sdk_5_30_00_03\source\ti\blestack\profiles\roles\cc26xx\peripheral.h»=>Parse to program.

Ругнётся на что-то, но константы добавится. Тыкаем в «0x306», жмём «E» видим GAPROLE_ADVERT_OFF_TIME — двойным кликом применяем. Ниже видим 408/400/402… Можем повторить с ним, но лучше понять что за функции, и почему они не подписаны.

Поищем на тему примеров с GAPROLE_SCAN_RSP_DATA:

$ find . -name '*.c' | xargs grep GAPROLE_SCAN_RSP_DATA ./examples/rtos/CC2640R2_LAUNCHXL/blestack/multi_role/src/app/multi_role.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/project_zero/src/app/project_zero.c:  GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), scanRspData); ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_broadcaster/src/app/simple_broadcaster.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof (scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_np/src/app/simple_np_gap.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_np/src/app/simple_np_gap.c:        status = GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, len, pDataPtr); ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral/src/app/simple_peripheral.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral/src/app/simple_peripheral_dbg.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_offchip/src/app/simple_peripheral_oad_offchip.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_onchip/src/app/simple_peripheral_oad_onchip.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_onchip/src/persistent_app/oad_persistent_app.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), ./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_secure_fw/src/app/simple_peripheral_dbg.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), 

Что у нас там в simple_peripheral.c:

  // Setup the Peripheral GAPRole Profile. For more information see the User's   // Guide:   // http://software-dl.ti.com/lprf/sdg-latest/html/   {     // By setting this to zero, the device will go into the waiting state after     // being discoverable for 30.72 second, and will not being advertising again     // until re-enabled by the application     uint16_t advertOffTime = 0;      uint8_t enableUpdateRequest = DEFAULT_ENABLE_UPDATE_REQUEST;     uint16_t desiredMinInterval = DEFAULT_DESIRED_MIN_CONN_INTERVAL;     uint16_t desiredMaxInterval = DEFAULT_DESIRED_MAX_CONN_INTERVAL;     uint16_t desiredSlaveLatency = DEFAULT_DESIRED_SLAVE_LATENCY;     uint16_t desiredConnTimeout = DEFAULT_DESIRED_CONN_TIMEOUT;      GAPRole_SetParameter(GAPROLE_ADVERT_OFF_TIME, sizeof(uint16_t),                          &advertOffTime);      GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),                          scanRspData);     GAPRole_SetParameter(GAPROLE_ADVERT_DATA, sizeof(advertData), advertData);      GAPRole_SetParameter(GAPROLE_PARAM_UPDATE_ENABLE, sizeof(uint8_t),                          &enableUpdateRequest);     GAPRole_SetParameter(GAPROLE_MIN_CONN_INTERVAL, sizeof(uint16_t),                          &desiredMinInterval);     GAPRole_SetParameter(GAPROLE_MAX_CONN_INTERVAL, sizeof(uint16_t),                          &desiredMaxInterval);     GAPRole_SetParameter(GAPROLE_SLAVE_LATENCY, sizeof(uint16_t),                          &desiredSlaveLatency);     GAPRole_SetParameter(GAPROLE_TIMEOUT_MULTIPLIER, sizeof(uint16_t),                          &desiredConnTimeout);   }

Ха! 1-в-1: GAPROLE_ADVERT_OFF_TIME, GAPROLE_SCAN_RSP_DATA, GAPROLE_ADVERT_DATA, GAPROLE_PARAM_UPDATE_ENABLE…

Переименовываем FUN_0000363c => GAPRole_SetParameter, смотрим ниже:

  // Set the Device Name characteristic in the GAP GATT Service   // For more information, see the section in the User's Guide:   // http://software-dl.ti.com/lprf/sdg-latest/html   GGS_SetParameter(GGS_DEVICE_NAME_ATT, GAP_DEVICE_NAME_LEN, attDeviceName);    // Set GAP Parameters to set the advertising interval   // For more information, see the GAP section of the User's Guide:   // http://software-dl.ti.com/lprf/sdg-latest/html   {     // Use the same interval for general and limited advertising.     // Note that only general advertising will occur based on the above configuration     uint16_t advInt = DEFAULT_ADVERTISING_INTERVAL;      GAP_SetParamValue(TGAP_LIM_DISC_ADV_INT_MIN, advInt);     GAP_SetParamValue(TGAP_LIM_DISC_ADV_INT_MAX, advInt);     GAP_SetParamValue(TGAP_GEN_DISC_ADV_INT_MIN, advInt);     GAP_SetParamValue(TGAP_GEN_DISC_ADV_INT_MAX, advInt);   }

Хмм… У нас следом за пачкой GAPRole_SetParameter идет просто четыре вызова, ничего напоминающего вызов GGS_SetParameter нет. Кажется, начинаю понимать, почему нет таблицы параметров — её вырезали из примера. Но еще один непонятный момент — GAP_SetParamValue должен иметь два аргумента, а у нас 4:

  FUN_00005bf4(0x10,0xe165,6,0xa0);   FUN_00005bf4(0x10,0xe165,7,0xa0);   FUN_00005bf4(0x10,0xe165,8,0xa0);   FUN_00005bf4(0x10,0xe165,9,0xa0);

Давайте убедимся, что это они:

$ find . -name '*.h' | xargs grep TGAP_LIM_DISC_ADV_INT_MAX ./source/ti/blestack/inc/gap.h:#define TGAP_LIM_DISC_ADV_INT_MAX      7

Да, они…. А что такое GAP_SetParamValue, может, это макрос?

$ find . -name '*.h' | xargs grep GAP_SetParamValue ./source/ti/ble5stack/icall/inc/ble_dispatch_lite_idx.h:#define IDX_GAP_SetParamValue                            JT_INDEX(152) ./source/ti/ble5stack/icall/inc/icall_api_idx.h:#define IDX_GAP_SetParamValue                         GAP_SetParamValue ./source/ti/ble5stack/icall/inc/icall_ble_api.h:#define GAP_SetParamValue(...)                                                          (icall_directAPI(ICALL_SERVICE_CLASS_BLE, (uint32_t) IDX_GAP_SetParamValue , ##__VA_ARGS__)) ./source/ti/ble5stack/icall/inc/icall_ble_apimsg.h: * @see GAP_SetParamValue() ./source/ti/ble5stack/inc/gap.h: * Parameters set via @ref GAP_SetParamValue ./source/ti/ble5stack/inc/gap.h:extern bStatus_t GAP_SetParamValue(uint16_t paramID, uint16_t paramValue); ./source/ti/ble5stack/rom/map_direct.h:#define MAP_GAP_SetParamValue                                        GAP_SetParamValue

Вот оно что! В зависимости от флагов компиляции, вызов либо прямой, либо косвенный через таблицу джампов (с индексом 152), либо косвенный по прямому адресу. Убедимся, что ICALL_SERVICE_CLASS_BLE == 0x10:

$ find . -name '*.h' | xargs grep ICALL_SERVICE_CLASS_BLE ... ./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE            0x0010 ./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE_MSG        0x0050 ./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE_BOARD      0x0088 ...

переименуем FUN_00005bf4 в icall_directAPI, адрес 0xe165 в GAP_SetParamValue. Смотрим дальше пример:

    GAPBondMgr_SetParameter(GAPBOND_PAIRING_MODE, sizeof(uint8_t), &pairMode);     GAPBondMgr_SetParameter(GAPBOND_MITM_PROTECTION, sizeof(uint8_t), &mitm);     GAPBondMgr_SetParameter(GAPBOND_IO_CAPABILITIES, sizeof(uint8_t), &ioCap);     GAPBondMgr_SetParameter(GAPBOND_BONDING_ENABLED, sizeof(uint8_t), &bonding);     GAPBondMgr_SetParameter(GAPBOND_LRU_BOND_REPLACEMENT, sizeof(uint8_t), &replaceBonds);

как раз 408, 400, … переименовываем 0x3fb5 => GAPBondMgr_SetParameter.

Процесс дальше, впрочем не похож на инициализацию таблицы. Таки либо пример неверный, либо таки отдельные сервисы типа GSS вырезаны.

Заметки на полях: поиск полезных данных никогда не бывает лишним. Выше я показывал вывод strings, в котором были интересные вещи — «inputGyroRv» и «inputNormal». Поиск по ним на github дал сходу интересную вещь, что позволило еще разметить часть функций и структур, которыми пользуется сенсор направления. Для ног однако подобной фкусности не обнаружилось.

Тут можно бесконечно пытаться понять дальше, однако ж, давайте научимся менять код прошивки. Иначе зачем нам это всё?

Играемся с прошивкой

Для эксперимента, мы уже пропатчили прошивку, заменив «KATVR», которая используется для анонса данных, на «KAT-F» (типа «Ноги»). Но это не интересно. Каждый сенсор прошивается его типом — левый или правый — хорошо бы, чтобы он анонсировал себя «KAT-R» или «KAT-L»! Для этого надо найти где же хранится его тип (левый-правый).

Мы знаем, что спаривание висит на USB, и пакеты 0x55/0xAA. Простым пролистыванием вниз натыкаемся на кусок:

    case 0xc:         cVar6 = *(char *)(puVar23 + 1);         pcVar26 = *(char **)(puVar23 + 2);         FUN_0000af16(&DAT_200011c8,0,0x1f);         if ((*pcVar26 == 'U') && (pcVar26[1] == -0x56)) {         DAT_200011cb = 0;         DAT_200011c8 = 0x55;         DAT_200011c9 = 0xaa;         cVar4 = (char)local_48;         cVar7 = DAT_200011c0;         if (cVar6 == '\x01') { ....

который явственно обрабатывает событие «пакет по USB» и реагирует на него. WriteDeviceId это команда 0x04:

        if (cVar6 == '\x03') {             DAT_200011cc = '\x03';             cVar4 = DAT_200011cc;             cVar7 = DAT_200011c1;             goto LAB_000009b8;         }         if (cVar6 == '\x04') {             DAT_200011c1 = pcVar26[5];             PostMsg(7,0,0);             DAT_200011cd = 0;             DAT_200011cc = '\x04';             FUN_0000fa20();             FUN_0000f8ec();             break;         }

Вывод — DAT_200011c1 это искомый ID сенсора, он же использован как аргумент в ReadDeviceId (команда 0x03). Кросс-реферес по ссылкам на него находит интересную простую функцию:

void FUN_0000f8ec(void) {   if (ParamDeviceId == '\x03') {     DAT_20001132 = 5;   }   else {     DAT_20001132 = 4;   }   return; }

Которая вызывается дважды из Begin() — при инициализации (очевидно, после подгрузки параметров) и после WriteDeviceId. Идеальная точка врезки!

Изучим ассемблер:

                             *************************************************************                              *                           FUNCTION                                                        *************************************************************                              undefined  FUN_0000f8ec ()              undefined         r0:1           <RETURN>                              FUN_0000f8ec                                    XREF[2]:     0000038c (c) , 000009a4 (c)            0000f8ec 04  49           ldr        r1,[DAT_0000f900 ]                               = 20001130h         0000f8ee 91  f8  91  00    ldrb.w     r0,[r1,#0x91 ]=>ParamDeviceId         0000f8f2 03  28           cmp        r0,#0x3         0000f8f4 14  bf           ite        ne         0000f8f6 04  20           mov.ne     r0,#0x4         0000f8f8 05  20           mov.eq     r0,#0x5         0000f8fa 88  70           strb       r0,[r1,#0x2 ]=>DAT_20001132         0000f8fc 70  47           bx         lr         0000f8fe c0              ??         C0h         0000f8ff 46              ??         46h    F                              DAT_0000f900                                    XREF[1]:     FUN_0000f8ec:0000f8ec (R)            0000f900 30  11  00  20    undefine   20001130h                                        ?  ->  20001130 

Итак, функция грузит в r1 адрес объекта из неподалёку лежащей константы; затем грузит в r0 байт из объекта+смещение. Сравнивает этот байт с 0x3 (левая нога), и затем использует инструкцию «ite ne».

Очень красивая система условного выполнения без переходов. В полноценном ARM режиме у каждой команды просто приписано выполняется ли она при флагах, в thumb режиме всё задаётся командой — (I)f,(T)hen,(E)lse, которая может быть просто «IT» (if-then, выполнить инструкцию если совпадает условие), ITT (if-then-then, выполнить две). Управляется от 1 до 4 инструкций, и первая следующая всегда then.

Затем два mov в R0, первая выполнится если R0 != 3, вторая если R0 == 3.

Затем новое значение сохраняется в другой байт в структуре и идёт переход на адрес lr. Так как функция трогает только регистры r0 и r1 (они же — параметры функций), их сохранять похоже не обязательно.

Так как после возврата у нас есть еще два байта (выравнивание), мы можем заменить (bx lr + nop) на один длинный jmp куда-нибудь.

Сразу по окончании ROMtoRAM таблицы у нас как раз пустое место, всё нули. Для удобства отмотаем до круглого числа (0x12e10) и придумаем что мы хотим вписать. Превратим «KATVR» в «KAT-L» или «KAT-R» в зависимости от настройки. Соответственно, надо просто превратить R0 в L или R, и записать куда-надо. А куда? Строка «KATVR» нас дважды: один раз в ПЗУ части, один раз в ОЗУ в пакете для анонса:

                             DAT_200011a4                                    XREF[1]:     000000ee (*)            200011a4 06              ??         06h         200011a5 09              ??         09h         200011a6 4b              ??         4Bh    K         200011a7 41              ??         41h    A         200011a8 54              ??         54h    T         200011a9 56              ??         56h    V         200011aa 52              ??         52h    R         200011ab 05              ??         05h

Итого, нам надо вписать эквивалент:

  if(left) {     scanRsp[6] = 'L';   } else {     scanRsp[6] = 'R';   }   scanRsp[5] = '-'; // можно сделать патч в ROM, можно вписать тут

К моменту выхода из функции в R0 у нас 4 или 5 в зависимости от ноги, а в R1 у нас указатель на 20001130. Расстояние от 20001130 до 200011A9 — 0x79. В принципе, у нас еще и флаги уже стоят с прошлого сравнения. То есть можно сделать нечто вроде:

  ite ne   mov.ne r0,#'L'   mov.eq r0,#'R'   strb r0,[R1,#0x7A]   mov r0,#'-'   strb r0,[R1,#0x79]   bx lr

Чтобы править код в гидре нужно очистить область от режима (если там уже есть инструкции), затем через ctrl+shift+g включить ассемблер для текущей строки, она ругнётся что процессор не тестирован. Вписываем инструкцию и аргументы, воюем с ней так как не всегда понимает, что мы хотим, но вроде получается. Правда на «mov.ne r0,#’L’» она откаывается реагировать! Когда такое начиналось, я пользовался online assembler где вводил инстуркции и потом переносил байтики. «mov r0, #’L’» => «4f f0 4c 00″… Не не, надо 2хбайтную, «movs r0, #’L’» => «4c 20» — идеально. «movs r0, #’R’» => «52 20». И так далее…

Эм…. Что-то гидре поплохело:

                             MoveFeetNumNew         00012e10 14  bf           ite        ne         00012e12 4c  20           mov.ne     r0,#0x4c         00012e14 52  20           mov.eq     r0,#0x52         00012e16 81  f8  7a  00    strb.eq.w  r0,[r1,#0x7a ]         00012e1a 81  f8  79  00    strb.eq.w  r0,[r1,#0x79 ]         00012e1e 70  47           bx.eq      lr

Непонятно почему, но в дизассемблере залип отслеживатель флагов. Жаль, но, проигнорируем. Зальем патч… Вот только не сработало. 🙁

Надо не только сменить структуру, но еще и вызвать GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, 0x10, &scanRsp).

Хорошо, тогда нам нужен указатель на scanRsp в R2. Поправим код:

  ite ne   mov.ne r0,#'L'   mov.eq r0,#'R'   adds.w r2,r1,#0x74   strb r0,[R2,#6]   movw r0,#0x308   movs r1,#0x10   b.w GAPRole_SetParameter

Всё хорошо, но тут гидре сорвало крышу, декомпиляция совсем сошла с ума. Ткнём в «b.w» инструкцию правой кнопкой мыши и через «modify instruction flow» сменим её на «CALL_RETURN». Уже лучше. Чтобы вылечить залипшие «.eq», можно ткнуть на первую кривую инструкцию (adds.eq.w) правой, и выбрать «Clear Flow and Repair», после чего опять F12 — код исправится (более-менее):

                             MoveFeetNumNew                                  XREF[1]:     MoveFeetNumSmt:0000f8fc (j)            00012e10 14  bf           ite        ne         00012e12 4c  20           mov.ne     r0,#0x4c         00012e14 52  20           mov.eq     r0,#0x52         00012e16 11  f1  74  02    adds.w     r2,r1,#0x74         00012e1a 90  71           strb       r0,[r2,#0x6 ]         00012e1c 40  f2  08  30    movw       r0,#0x308         00012e20 10  21           movs       r1,#0x10         00012e22 f0  f7  0b  bc    b.w        GAPRole_SetParameter                             undefined GAPRole_SetParameter()                              -- Flow Override: CALL_RETURN (CALL_TERMINATOR)

Декомпиляция тоже станет лучше:

void MoveFeetNumSmt(void) {   if (DeviceId == '\x03') {     DAT_20001132 = 5;     UNK_200011aa = 0x52;   }   else {     DAT_20001132 = 4;     UNK_200011aa = 0x4c;   }   GAPRole_SetParameter(0x308,0x10);   return; }

Единственное, она потеряла аргумент к функции. Если нажать на ней правой кнопкой и сделать Edit Function, добавить три аргумента и выставить им простые типы:

Правим типы

Правим типы

Код окончательно примет нормальный вид:

void MoveFeetNumSmt(void) {   if (DeviceId == '\x03') {     DAT_20001132 = 5;     UNK_200011aa = 0x52;   }   else {     DAT_20001132 = 4;     UNK_200011aa = 0x4c;   }   GAPRole_SetParameter(0x308,0x10,&scanRspData);   return; }

Прекрасно, опять экспортируем патч (File=>Export Program), «fc.exe /b .\katvr_foot_orig.bin .\katvr_foot.bin» и так далее. Загружаем в сенсор. Урра, работает! 🙂 Видим «KAT-R» и «KAT-L» устройства плавающие вокруг. Красота.

Поиграли — пора и делом заняться.

Разгоняем ноги на KatWalk C2 до 133 Гц

Чтобы понять в чем проблема с сенсорами, надо думать как сенсоры. Итак, мы знаем, что сенсоры отдают данные через Notification. Пойдём искать, где же они используются. Слева в «Symbol tree» в поле поиска вводим Notifi и легко переходим на GATT_Noficiation. Ссылок не видно. Значит, используется косвенный вызов, делаем поиск по 0x10010045 (адрес+1, ибо код в Thumb режиме) и находим одну единственную функцию, где идёт подготовка, потом вызов:

  _DAT_20001186 = 0x14;   _DAT_20001188 = (undefined *)thunk_EXT_FUN_10018404(DAT_20001142,0x1b,0x14,0,in_r3);   cVar1 = DAT_20000521;   if (_DAT_20001188 != (undefined *)0x0) {     _DAT_20001184 = 0x2e;     if (_DAT_20001146 == 0) {   ...   cVar1 = icall_directAPI(0x10,(int)&GATT_Notification + 1,DAT_20001142,&DAT_20001184,0);

0x2E — это же наш handle, так что да, мы нашли то самое место.

Если упростить, код получается такой:

void KatSendNotification() {   out = malloc(...);   if (out) {     if (packetNo == 0) {         out->_type = 0; // status packet         fill_charge_levels(out);     } else {         out->_type = 1; // data packet         if (!DATA_READY || !DATA_OK) {             out->_x = 0;             out->_y = 0;         } else {             DATA_READY = false;             out->_x = DATA_X;             out->_y = DATA_Y;         }         out->status = STATUS;     }     if (something) {         out[0] = 0; out[1] = 1; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 1;     }     attHandleValueNoti_t notification = { 0x2e, 0x14, out };     if (!GATT_Notification(0x2E, &notification, 0)) { free(out); }     if (++packetNo == 500) {         packetNo = 0;     }   }

То есть, каждые 500 пакетов отправляется уровень заряда (плюс версия прошивки и ID сенсора), все остальные пакеты содержат данные. Причем если данные не готовы (или была ошибка связи с сенсором), то отсылаются нули. Еще, если стоит какой-то флаг, то начало пакета перетирается последовательностью 0-1-0-1-0-1.

Теперь нужно понять, как часто этот GATT_Nofication вызывается. Ссылок на «KatSendNotification» (как я назвал эту функцию) две, и обе из основного потока, обе внутри цикла обработки событий:

  while(!QueueEmpty(...)) {     Entry* event = QueueGet(...);     switch (event->id)     {         ...         case 4:             KatSendNotification();             ClockStop(...);             break;         case 6:             if (Flag1 == 1 || Flag2 == 1) {                 KatSendNotification();             }             break         ...     }   }

Событие №4 должно бы посылаться таймером, но таймер не запущен — и даже если будет запущен, сразу будет остановлен. Никаких других ссылок на этот таймер я не нашел.

Событие №6 генерируется по коллбэку на обработку события BLE, так что похоже на то, что события посылаются после того как два флага взведены (один из них — соединение установлено, второй не совсем понял сходу), причем следующее событие формируется после отправки предыдущего. Окей, это совпадает с наблюдением, что поток начинается после изменения параметра соединения.

Пока непонятно, но, вроде, сколько раз спросили — столько раз мы должны ответить. Что ж, посмотрим, на когда данные появляются? Кросс-референс на DATA_OK приводит нас к функции:

void ReadSensorData(...) {     do {         Semaphore_Pend(SensorSemaphore, -1);         Task_sleep(700);         GPIO_SET(..., 0);         SPI_Send(0x50);         Task_sleep(10);         char* out = &SensorData;         for (int i = 0x0C; i; --i) {             *(out++) = SPI_Recv();         }         GPIO_SET(..., 1);         Task_sleep(0.1);         DATA_OK = 0;         if ((SensorData[0] & 0x80 != 0) && (SensorData[0] & 0x20 != 0)) {             DATA_OK = 1;         }         DATA_READY = 1;         SensorReads++;         If (SensorReads > 99) {             // refresh something             SensorReads = 0;         }         if (SomeFlag == 0) {             Semaphore_Post(SensorSemaphore);         }     } while(true); }

Функция занимается обновлением данных с сенсора до тех пор, пока SomeFlag не будет взведён. Как показало дальнейшее расследование — этот флаг означает переход в режим сна. Семафор инициализируется на старте и сразу взводится, то есть датчик читается непрерывно пока мы не спим, с перерывами в 700+10+13байтSPI + обновление еще чего-то раз в 100 чтений. SPI настроен на 4 мегабод. Единицы сна в десятках микросекунд. То есть сенсор обновляется раз в ~7.11 миллисекунды, или около 140-141 Гц. Выглядит так, что не должно бы быть проблем с обновлением сенсора на 133Гц. Однако же они есть.

Наблюдаемое поведение — иногда мы видим нулевые пакеты (ведешь себе ровненько сенсор, а точка в гейтвее внезапно срывается в центр).

Как ни странно, это поведение вполне объяснимо: при обновлении данных каждые ~140Гц и чтении 133Гц, мы двигаемся рядом, и вероятность того, что пакет был обновлён вот-только-что, а мы обновляем данные — крайне высока. Мы наблюдаем состояние гонки, так как никакой синхронизации между отправкой, формированием и обновлением данных нет.

Решение, как ни странно, очевидно. Нам нужны новые данные для отправки, поэтому читать сенсор нужно только когда есть связь. Пакеты запрашиваются регулярно. А что если… Перенести вызов Semaphore_Post из ReadSensorData в KatSendNotification? Тогда получится идеальная связка: ReadSensorData() подготовит данные, KatSendNotification их заберёт — и запросит опять. Идеально. Единственное, что KatSendNotification вызывается после отправки прошлого пакета, то есть задержку изнутри ReadSensorData убирать нельзя. Я бы, пожалуй, чуток ее даже понизил, чтобы пакет точно был готов. Так как мы обновляем пакеты каждые 86..133Гц, момент замера скорости не так критичен, до тех пор, пока задержка одна и та же — 5 миллисекунд погоды не сделают.

Достанем блокнотик и спланируем наш патч. Во-1х снизим задержку:

        000079d0 41  f6  58  31    movw       r1,#7000  # The delay =>         000079d0 41  f2  88  31    movw       r1,#5000

Во-2х, замкнём цикл до вызова SensorSemaphore. Способов замкнуть цикл множество: можно заменить в IF условный jmp (bne) на безусловный, можно просто заNOPитьвызов функции, а можно тупо вписать переход вместо всего IFа:

                             LAB_00007ab0                                    XREF[1]:     00007a54 (j)            00007ab0 28  78           ldrb       r0,[r5,#0x0 ]=>SleepState                        = 52h         00007ab2 00  28           cmp        r0,#0x0         00007ab4 98  d1           bne        LAB_000079e8         00007ab6 68  68           ldr        r0,[r5,#0x4 ]=>SensorReadSem         00007ab8 f9  f7  7e  fa    bl         Semaphore_post                                   undefined Semaphore_post()         00007abc 94  e7           b          LAB_000079e8  =>                               LAB_00007ab0                                    XREF[1]:     00007a54 (j)            00007ab0 9a  e7           b          LAB_000079e8

И в-3их, поправить KatSendNotification. Действуем аналогично прошлому подходу с реакцией на лампочки — уходим на свободный кусочек в конце (после прошлого патча, я взял 00012e40). Соответственно, в конце функции меняем return на jmp:

                             LAB_00006b14                                    XREF[1]:     00006a08 (j)            00006b14 f8  bd           pop        {r3,r4,r5,r6,r7,pc}         00006b16 c0              ??         C0h         00006b17 46              ??         46h    F =>                              LAB_00006b14                                    XREF[1]:     00006a08 (j)            00006b14 0c  f0  94  b9    b.w        KatSendNotificationTail

И на новом месте формируем кусочек подобный вырезанному из ReadSensorData. Главная сложность, по сравнению с ReadSensorData, это спланировать сколько надо места перед хранением константы для загрузки указателя на семафор. Так же может понадобиться поправить переход, сбросить/вызвать Repair Flow после правок — но после заполнения всего кода и сброса, всё получается:

=>                              KatSendNotificationTail                         XREF[1]:     KatSendNotification:00006b14 (j)         00012e40 03  4d           ldr        r5,[->SleepState ]                               = 20001584         00012e42 28  78           ldrb       r0,[r5,#0x0 ]         00012e44 00  28           cmp        r0,#0x0         00012e46 02  d1           bne        LAB_00012e4e         00012e48 68  68           ldr        r0,[r5,#0x4 ]         00012e4a ee  f7  b5  f8    bl         Semaphore_post                                   undefined Semaphore_post()                              LAB_00012e4e                                    XREF[1]:     00012e46 (j)            00012e4e f8  bd           pop        {r3,r4,r5,r6,r7,pc}                              PTR_SleepState_00012e50                         XREF[1]:     00012e40 (R)            00012e50 84  15  00  20    addr       SleepState                                       = 52h
Пропатчилось, как надо

Пропатчилось, как надо

Заливка патча, на удивление, сработала с первого раза, ресивер на 133Гц получает пакеты стабильно. Если медленно вести, в гейтвее нет никаких проблем и не сбрасывается на ноль. Ура!

Исправляем баги (выкидываем забытый скальпель)

Я уже начал радоваться, но вот Utopia Machina (да-да, еще раз спасибо за кучу тестирования) жаловался на то, что сенсор направления периодически залипает и требует перезагрузки. «Залипает» это когда направление меняется только на пару градусов, еще и перестаёт показывать уровень заряда. Еще жаловался на то, что иногда пропадают данные с одной из ног. При этом на оригинальном ресивере практически не воспроизводится.

У меня не воспроизводилось… Но как-то выяснилось, что у меня не воспроизводится когда висит на зарядке, а у него без зарядки. Выкрутил сенсор, оставил на пару часов периодически пошевеливая чтоб убедиться что работает. И… Да! Воспроизвел!

Когда произошло, я через Wireshark посмотрел на пакеты, и выяснилось, что в пакетах вместо направления и номера сенсора — идёт «0 1 0 1 0 1». Хвост данных был как всегда, таким образом кусоче квартерниона направления таки менялся, потому и получалось что-то видеть.

Это сработала вот эта мина отложенного действия, которая есть и в прошивке сенсоров ног и в прошивке направления:

    if (something) {         out[0] = 0; out[1] = 1; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 1;     }

Это же объясняет, почему пропадала одна из ног — когда этот код срабатывал, координаты-то не затирались (в ногах они в самом конце пакета), а вот данные о батарее и номер сенсора — затирались.

Прекрасно, мы знаем, что случилось — но почему? К счастью, флаг «somthing» трогался только один раз, внутри обработчика таймера тикающего раз в секунду; и только если некий счетчик доходил до 1800, и не сбрасывался по дороге. Сбрасывался он при определённых условиях связанными с разрядом батареи. Проще говоря, это оказался отладочный код для настройки скорости разряда батареи и тюнинга перехода в режим сна. Я нашел в обоих сенсорах хвосты для настройки этого параметра по USB.

В любом случае, это отладочный кусок который в релизной прошивке не нужен — а значит, просто можно безопасно вырезать.

Вырезать опять же можно несколькими споосбами — через замыкание if’а, через затирание nop’ами… Я просто заNOPил строку, где ставилось «something = 1».

А что не так

Примечательно, что исправленные сенсоры остаются совместимы с оригинальными ресивером… Почти. Главная разница — значения, которые читаются сенсорами, теперь читаются с частотой обновления (86Гц..133Гц) а не с фиксированными ~140Гц.

Оптический сенсор на каждом чтении возвращает некое расстояние (в попугаях), которые он насчитал с прошлого чтения.

Оригинальный сенсор читает последнее доступное значение 86 раз в секунду, получается, использует данные о расстоянии как скорость — часть пройденного расстояния при этом теряется, но, с некоторой точностью, восстанавливается перемножая на время.

Патченный сенсор начинает читать данные со скоростью опроса — то есть данные о пройденном расстоянии почти не теряются (теряется каждый 500й пакет + часть пакетов просто теряется по радио), но при этом каждое значение получается больше, чем в оригинале: при обновлении 86 раз в секунду значения будут амплитудой до 163% по отношению к исходным, а на 133Гц всего 105%.

Представляет ли это проблему? Зависит от того, как этими данными пользоваться. Если использовать данные для вычисления скорости напрямую (как, к сожалению, делает гейтвей) — то и да и нет. Нет — так как при использовании 133Гц ресивера и исправленные сенсоры практически не чувствуется разницы, зато задержки ощутимо ниже (реально ощущается, особенно при игре на 120fps). Да — так как при использовании исправленного сенсора и исходного ресивера все скорости сильно выше и нужно исправлять настройки для каждой игры — понижать скорость разбега.

Можно ли это тоже исправить? Да, есть несколько способов исправления: патч гейтвея, патча исходного ресивера, более сложный патч сенсоров… Есть где разгуляться. Но это тема отдельного разговора.

Что дальше

А дальше на самом деле — избавление от гейтвея, как минимум для нативных игр — сейчас, используя KAT SDK невозможно сделать Standalone игры, так как SDK жестко прибит гвоздями к винде и ресиверу подключенному к нему. А я раньше уже показал как связаться с ресивером напрямую, сделал маленький ресивер который прикидывается оригинальным… То есть есть всё, что нужно, для создания действительно standalone игры — идеально вписывается в концепцию игры, которую разрабатывает Utopia Machina. Так что в следующей серии я покажу итог совместной наработки — UE SDK с прямым доступом до платформы хоть под виндой хоть нативно на Quest 2/3 🙂 Не переключайтесь!

Ссылки

  • Часть 1: «Играем с платформой» на [Habr], [Medium] и [LinkedIn].

  • Часть 2: «Начинаем погружение» на [Habr], [Medium] и [LinkedIn].

  • Часть 3: «Отрезаем провод» на [Habr], [Medium] и [LinkedIn].

  • Часть 4: «Играемся с прошивкой» на [Habr], [Medium] и [LinkedIn].

  • Часть 5: «Оверклокинг и багфиксинг» на [Habr], [Medium] и [LinkedIn].


ссылка на оригинал статьи https://habr.com/ru/articles/805747/