
Очиститель вполне приличный, а вот приложение к нему мне не нравится.
На дворе 2017 год. Западное побережье США год за годом накрывают лесные пожары. Днём небо выглядит вот так:

Мой дом, как и многие дома в районе залива Сан-Франциско, не утеплён и толком не герметичен. Чтобы не задыхаться от копоти, многие тогда стали покупать очистители воздуха вроде этого:

Почтенный Coway Airmega
По сути это слишком дорогой вентилятор в квадратном корпусе с HEPA-фильтром и датчиком качества воздуха. Скорость можно выставить вручную, а можно доверить автоматике: при плохом воздухе он сам прибавит обороты. Выглядит он некрасиво, но здесь он стоит почти в каждом доме, и, кажется, его уже никто не замечает. И, справедливости ради, он работает: воздух действительно становится чище.
Со временем очиститель стал у нас дома незаменимым: жена начала включать его на ночь на полную мощность как генератор белого шума. Я привык, и теперь сам без него не засыпаю.
Кнопка «Спокойной ночи»
Освещение у нас умное и подключено к Apple Home. Перед сном я всегда нажимаю кнопку «Спокойной ночи», и весь свет гаснет. Мне очень хотелось привязать к ней и вентилятор: нажал кнопку, свет выключился, вентилятор раскрутился до той громкости белого шума, которую любит жена.

У старого Airmega не было Wi-Fi, так что простого способа подключить его к Apple Home не существовало. Когда он в конце концов сломался, я купил Coway Airmega IconS (сейчас его уже сняли с производства). Это всё тот же вентилятор с HEPA-фильтром, только заметно дороже, стилизованный под дизайнерскую мебель середины прошлого века и, главное, управляемый по Wi-Fi (более или менее).

Coway Airmega IconS
Готовая интеграция для Home Assistant
Приложение Coway для iPhone — классика IoT. Загружается целую вечность и почти при каждом запуске просит сменить пароль. К счастью, один смельчак разобрал это приложение и написал интеграцию для Home Assistant. Интеграция, по сути, притворяется телефонным приложением: опрашивает облако Coway, получает оттуда состояние и через него же отправляет команды вентилятору. Home Assistant пробрасывает всё это в Apple Home, и готово.
Облако Coway IoCare (серверы Coway где-то в интернете) ↑ команды ↓ состояние ↑ состояние ↓ команды Мой дом: Home Assistant (интеграция IoCare) Очиститель (Airmega IconS, Wi-Fi) ↓ Apple Home (та самая кнопка «Спокойной ночи»)
Зачем уходить из облака
Минус такого подхода в том, что ты по-прежнему зависишь от милости облачного сервиса Coway (точнее, от её отсутствия). Похоже, как минимум раз или два в год что-нибудь да ломается.
Вот что ломалось на моей памяти:
Coway не понравилось, что интеграция долбит их серверы, и они её заблокировали. После долгого спора с сообществом всё-таки согласились её разрешить.
Coway переписали приложение и переименовали его из IoCare в IoCare+, так что интеграцию тоже пришлось переписывать.
Coway слегка поменяли вёрстку HTML, который интеграция парсит, и она сломалась.
Серверы Coway время от времени лежали, и управлять устройством удалённо было нельзя.
Однажды Coway заблокировали мой аккаунт за старую версию интеграции. Несколько недель я не мог управлять вентилятором удалённо, пока поддержка снимала бан.
К тому же вряд ли Coway будет поддерживать этот сервис вечно. Подозреваю, что рано или поздно, как и все производители IoT, они либо сделают его платным, либо закроют. Моя модель уже снята с производства (хотя другие модели с Wi-Fi продаются, так что кто знает).
Лазейка
Признаюсь, я ленился. Годами откладывал попытку избавиться от облака Coway: если устройство по-настоящему закрыто, это будет тяжело. Скорее всего, при включении вентилятор подключается к coway.com (или куда там ещё) и проверяет, что SSL-сертификат сервера валиден. Поддельный сертификат, который пройдёт такую проверку, я, скорее всего, сделать не смогу, и к моему серверу вентилятор просто не подключится.
Кроме того, поднимать стенд для атаки «человек посередине» (MitM) — та ещё морока. Вентилятор должен подключиться к изолированной сети Wi-Fi, весь трафик которой идёт в интернет через мой сервер. Для этого нужна куча правил маршрутизации и файрвола, перенаправляющих трафик. Ничего невозможного (я такое уже делал), но возни много, и настроить всё правильно всегда непросто. А когда стенд готов, это только первый шаг.
Тем не менее за это время изменились две важные вещи.
Во-первых, недавно (в октябре 2026 года) случился инцидент безопасности: выяснилось, что eMudhra, один из доверенных удостоверяющих центров интернета, использовал для части своих закрытых ключей очень слабый параметр.
Ian Carroll (@iangcarroll), 20 октября 2026: «Ближайшие дни или недели корневой УЦ есть у любого — спасибо eMudhra. Самое время поразвлекаться с MiTM несговорчивых устройств!»

Так что если устройство не обновлялось, И в нём зашиты корневые сертификаты, И оно не привязывает (pinning) сертификат облачного сервиса, то… возможно, это и есть лазейка. Многовато «если», согласен, но если в устройстве нет грубых ошибок безопасности, это может стать нужной точкой опоры.
Во-вторых, просто появился ИИ. LLM может настроить всё, что нужно для MitM, и можно сразу переходить к разбору того, как работает вентилятор. Причём сделать это без присмотра и за считаные минуты.
Встав между вентилятором и интернетом, можно увидеть, как он общается, а это поможет понять, как заставить его говорить с нами вместо облака.
Наш человек посередине
Сначала я собрал такую схему:
Очиститель (Airmega IconS) │ Wi-Fi Raspberry Pi — точка доступа Wi-Fi (Pi в режиме AP, своя изолированная сеть), │ весь трафик перенаправлен в mitmproxy, который расшифровывает и пишет его в лог Мой домашний роутер (обычная домашняя сеть) │ Облако Coway IoCare (в интернете)
Это не сработало: у Raspberry Pi маленькая антенна дорожкой на плате, и сигнал у неё слабый. Пока Pi стоял в одной комнате с роутером, до вентилятора он не добивал. Можно было, наверное, перенести вентилятор в комнату с роутером, но зачем, если задачу можно решить запутанным техническим способом?

Raspberry Pi своим радиомодулем Wi-Fi поднимает точку доступа, к которой подключается вентилятор. Одновременно с этим подключаться по Wi-Fi к домашней сети ему непросто. Поэтому я перенёс Pi в комнату с вентилятором и воткнул его Ethernet-порт в ноутбук, а ноутбук уже по Wi-Fi соединяет его с домашней сетью:
Очиститель (Airmega IconS) │ Wi-Fi, в той же комнате Raspberry Pi — точка доступа Wi-Fi (изолированная сеть), │ весь трафик перенаправлен в mitmproxy (расшифровка и лог) │ Ethernet Мой ноутбук (общий доступ к интернету: мост Wi-Fi ↔ Ethernet) │ Wi-Fi Мой домашний роутер (обычная домашняя сеть) │ Облако Coway IoCare (в интернете)
Дальше нужно настроить Pi.

Не берусь судить, честно ли такое блокировать, но Claude выполнять просьбу отказался, даже «для тестирования».
К счастью, у ChatGPT таких угрызений совести нет.

После нескольких итераций получились вот такие правила nftables:
scottjg@pi:~ $ sudo nft -a list ruleset table inet mitmproxy_ap { # handle 8 set client_interfaces { # handle 4 type ifname elements = { "wlan0" } } chain prerouting { # handle 1 type nat hook prerouting priority dstnat; policy accept; meta nfproto ipv4 iifname @client_interfaces tcp dport { 80, 443 } counter packets 2 bytes 120 redirect to :8080 # handle 6 } chain input { # handle 2 type filter hook input priority filter - 10; policy accept; iifname != "lo" iifname != @client_interfaces tcp dport 8080 counter packets 0 bytes 0 reject with tcp reset # handle 7 iifname @client_interfaces meta nfproto ipv6 counter packets 9 bytes 2397 drop # handle 8 iifname @client_interfaces ct state established,related accept # handle 9 iifname @client_interfaces udp dport { 53, 67 } accept # handle 11 iifname @client_interfaces tcp dport { 53, 8080 } accept # handle 13 iifname @client_interfaces ip protocol icmp accept # handle 14 iifname @client_interfaces counter packets 23 bytes 4697 reject with icmpx admin-prohibited # handle 15 } chain forward { # handle 3 type filter hook forward priority filter - 10; policy accept; iifname @client_interfaces meta nfproto ipv6 counter packets 0 bytes 0 drop # handle 16 oifname @client_interfaces meta nfproto ipv6 counter packets 0 bytes 0 drop # handle 17 iifname @client_interfaces udp dport 443 counter packets 1 bytes 57 reject # handle 18 iifname @client_interfaces oifname != "eth0" counter packets 0 bytes 0 reject with icmpx admin-prohibited # handle 19 } } table ip nm-shared-wlan0 { # handle 12 chain nat_postrouting { # handle 1 type nat hook postrouting priority srcnat; policy accept; ip saddr 10.42.0.0/24 ip daddr != 10.42.0.0/24 masquerade # handle 2 } chain filter_forward { # handle 3 type filter hook forward priority filter; policy accept; ip daddr 10.42.0.0/24 oifname "wlan0" ct state { established, related } accept # handle 5 ip saddr 10.42.0.0/24 iifname "wlan0" accept # handle 6 iifname "wlan0" oifname "wlan0" accept # handle 7 iifname "wlan0" reject # handle 8 oifname "wlan0" reject # handle 9 } }
Если коротко, правило masquerade выпускает трафик изолированной сети Wi-Fi в интернет через домашнее подключение, а правило redirect отправляет HTTP и HTTPS в запущенный mitmproxy.
Там же, на Pi, mitmproxy работает со своим веб-интерфейсом mitmweb примерно с такой командной строкой:
mitmweb \ --mode transparent --showhost \ --listen-host 0.0.0.0 --listen-port 8080 \ --web-host 127.0.0.1 --web-port 8081 \ --set web_open_browser=false \ --emudhra-old
Да, в mitmproxy прямо из коробки есть опция (--emudhra-old), которая автоматически перехватывает трафик с корневым сертификатом eMudhra.
Подглядываем за вентилятором
Дальше я через приложение на iPhone стал подключать вентилятор к нашей сети Pi-MITM-Test. На середине приложение сообщило, что настройка не удалась. Заглянем в веб-интерфейс mitmproxy:

Итак, с подключением что-то не так: приложение говорит, что вентилятор подключиться не смог. Но в нашу изолированную сеть он явно попал, потому что сделал запрос.
И запрос этот очень подозрительный. Похоже, вентилятор скачивает корневой сертификат удостоверяющего центра Amazon по обычному HTTP. Интерфейс показывает только соединения, которые дошли до настоящего HTTP-запроса. Если, например, вентилятор не принимает сертификат eMudhra, которым мы выдаём себя за настоящие серверы, его HTTPS-соединения обрываются раньше и в списке не появляются.
Гипотеза: устройство скачивает этот публичный сертификат по HTTP, чтобы было чем проверять последующие (пока невидимые) HTTPS-соединения. Встроенных корневых сертификатов у него нет вообще.
У mitmproxy есть режим плагинов, в котором можно подменять отдельные запросы. С его помощью подсунем вместо этого публичного сертификата собственный УЦ, сгенерированный mitmproxy. Если гипотеза верна, вентилятор будет доверять этому УЦ, и мы сможем перехватить остальной трафик.
"""Serve this lab's public RSA CA only to the authorized Coway test device.""" from pathlib import Path from mitmproxy import ctx, http def response(flow: http.HTTPFlow) -> None: if ( flow.client_conn.peername[0] == "10.42.0.93" # device IP and flow.request.scheme == "http" and flow.request.method == "GET" and flow.request.pretty_host == "rootca-fs.iocareus.com.s3.ap-northeast-2.amazonaws.com" and flow.request.path.split("?", 1)[0] == "/AmazonRootCA1.pem" and flow.response is not None and flow.response.status_code == 200 ): certificate = Path("/var/lib/mitmproxy-ap/mitmproxy-ca-cert.pem").read_bytes() flow.response = http.Response.make( 200, certificate, { "Content-Type": "application/x-pem-file", "Cache-Control": "no-store", }, ) flow.metadata["coway_test_ca_served"] = True ctx.log.info("Served lab RSA CA to the Coway's HTTP CA bootstrap request")
И действительно, теперь устройство подключается до конца, и появляются новые запросы:

Гипотеза подтвердилась.
Мы можем перехватывать трафик между вентилятором и сервером, а значит, скорее всего, сможем написать свой сервер вместо облака Coway IoCare. Заодно выяснилось, что трюк с eMudhra вообще не нужен. Вентилятор скачивает корневой сертификат по обычному HTTP. Перехватить его трафик можно было всегда.
Не хватает одного: ни один из этих запросов не связан ни со скоростью вентилятора, ни с чем-либо ещё, что можно сделать в приложении. Но это легко выяснить. Если запустить на Pi tcpdump и пощёлкать кнопками в приложении, видно зашифрованный трафик на порту 8883 — стандартном порту MQTT поверх TLS. MQTT — протокол шины сообщений, его часто используют в IoT-устройствах.
Вернёмся к POST из списка запросов выше. Вот что вернул этот вызов API:
{ "header": { "success": true, "result": "S1000", "message": "SUCCESS" }, "payload": { "macAddr": "<redacted>", "certToken": "<redacted>", "host": "a29vouc2qruduj-ats.iot.ap-northeast-2.amazonaws.com", "port": 8883, "serial": "<redacted>", "certSvrUrl": "https://cert.iotp.coway.com/iot/v1/cert/r0", "caUrl": "http://rootca-fs.iocareus.com.s3.ap-northeast-2.amazonaws.com/AmazonRootCA1.pem", "privateUrl": "https://icert-fs.iocareus.com/<redacted>/55d66923e9-private.pem.key", "certUrl": "https://icert-fs.iocareus.com/<redacted>/55d66923e9-certificate.pem.crt", "svcCode": "002AIR007", "countryCd": "US" } }
Получается, кто контролирует ответ на этот вызов, тот решает, с какими ключами вентилятор подключается к MQTT-серверу и где этот сервер находится.
Пока что можно написать ещё один плагин для mitmproxy, который подставляет в этот ответ наш собственный ключ, и через него проксировать MQTT-трафик. Код плагина получился длинноватым, поэтому я его сюда не включил, но ИИ генерирует его довольно быстро. MQTT mitmproxy не декодирует, но сырой поток показать может:

Данные выглядят как смесь бинарной и текстовой абракадабры, но это стандартный поток протокола MQTT. Когда я меняю скорость в приложении, видно, как сервер Coway присылает устройству новое сообщение. Обычно я бы загрузил это в анализатор трафика и составил таблицу: какая абракадабра приходит на каждую кнопку в приложении. Но теперь…

…и через несколько минут:

Этого уже достаточно, чтобы собрать собственный веб-сервер, который при настройке выдаёт вентилятору сертификаты и конфигурацию, и собственный MQTT-сервер для отправки команд. В обычной работе облако Coway полностью выпадает из схемы, но для настройки устройства мы всё ещё пользуемся приложением Coway. И нам всё ещё нужен довольно надоедливый MitM-стенд, чтобы вентилятор подключался к нам, а не к Coway.
Настройка по Bluetooth
При настройке через приложение Coway ищет устройство по Bluetooth. Я надеялся найти способ настраивать устройство самому, без приложения. А если повезёт, то и наткнуться по пути на что-нибудь ещё полезное.
Реверсом Bluetooth-устройств я уже занимался. Раньше моя стратегия была такой: взять Android-версию приложения, декомпилировать её и просто корпеть над кодом. Это не всегда так просто, как звучит. Разработчики часто обфусцируют приложения, и декомпилированный код читать труднее. К тому же часть байт-кода не декомпилируется обратно в исходник чисто, и приходится читать сырой байт-код.
И всё же, на мой взгляд, обычно это проще, чем возиться с приложениями для iPhone. Android-приложения обычно пишут на Java (или Kotlin), а Java-приложения реверсить проще, чем то, что скомпилировано из Swift или Objective-C.
На этот раз я попробовал новый подход: встроенные отладочные логи Bluetooth в Android.

Потом я подключил телефон по USB и попросил ИИ управлять телефоном через adb, изучить логи Bluetooth и восстановить протокол. С этой задачей ИИ возился довольно долго. В итоге он установил, что сообщение с настройками Wi-Fi зашифровано. Раз всё равно придётся выяснять алгоритм шифрования, без декомпилированного приложения, видимо, не обойтись. И точно: следующим шагом он стянул приложение Coway с телефона, декомпилировал его и начал сопоставлять исходный код с логами Bluetooth.
В результате выясняется, что настройка устроена так:
Прочитать по Bluetooth идентификационные данные вентилятора из характеристики
FFA0(«характеристика» — это конечная точка Bluetooth, из которой можно читать или в которую можно писать). Там есть базовые сведения об устройстве, включая его MAC-адрес.Записать в характеристику
FFA2ключ шифрования (20 случайных строчных латинских букв):
"P001" + protobuf { command: 1, key: Base64(random_letters) }
Составить AES-ключ: ключ из шага 2, к которому приписаны 12 символов MAC-адреса в верхнем регистре ASCII.
Собрать конфигурацию, которая выглядит так:
{ "cmd": 2, "ssid": "Example Wi-Fi", "pwd": "example-wifi-password", "bssid": "AABBCCDDEEFF", "type": 1, "macAddr": "112233445566", "caUrl": "http://192.0.2.1:8080/ca.pem", "certSvrUrl": "https://192.0.2.1:9443/iot/v1/cert/r0", "certToken": "example-enrollment-token", "serial": "EXAMPLE000000000001" }
Её заворачивают в protobuf, дополняют по PKCS#7 и шифруют AES-ключом из шага 3. Затем добавляют спереди строку «P002» и тоже отправляют в FFA2.
Само собой, восстанавливать всё это от начала до конца по декомпилированному Android-приложению вручную очень долго. Это как минимум часы работы (скорее, дни). Тут ИИ здорово выручает: у него ушло около 20 минут.
Приятно и то, что все данные для расшифровки этой полезной нагрузки есть в логах Bluetooth, снятых с телефона, так что правильность нашей реализации можно проверить только по ним. Дальше ИИ быстро переписал весь процесс настройки в небольшой скрипт на Python.
Я довольно внимательно следил за ходом работы. Полную полезную нагрузку ИИ мне так и не показал, но я видел её в промежуточном выводе скриптов, которые он писал. И тут я кое-что заметил. Два URL из нашего перехвата задаются прямо в этой нагрузке (caUrl и certSvrUrl). Первый говорит вентилятору, откуда скачивать корневой сертификат по обычному HTTP, второй — куда отправлять POST по HTTPS за конфигурацией MQTT.
Раз эту нагрузку теперь отправляем мы, эти URL можно направить куда угодно. А самое главное, MitM больше не нужен. Достаточно запустить свой сервер и при настройке вентилятора указать его адреса.
Интеграция с Home Assistant
На основе всего этого я написал собственную интеграцию для Home Assistant. У моего сервера Home Assistant нет Bluetooth, и стоит он далеко от вентилятора, поэтому настройку я сделал через Web Bluetooth. Так можно поднести ноутбук к вентилятору и настроить его на прямую работу с Home Assistant.

После этого устройство общается с Home Assistant напрямую по Wi-Fi.

А главное, Home Assistant пробрасывает его в мой Apple Home.


Вот и вся работа за день. Теперь я наконец могу нажать «Спокойной ночи».
