
Всем привет! Я Андрей, в Яндексе работаю над IoT‑железками в Городских сервисах. Но сегодня речь пойдёт не о них. Эта история началась с неожиданной находки в новой квартире — с обычной, на первый взгляд, трубки домофона. Кажется: ну трубка и трубка. Но вот что бросилось в глаза: она была подключена по Ethernet. А если есть Ethernet, значит, внутри что‑то с TCP/IP, то есть уже маленький компьютер.
Любопытство победило, и я решил разобраться, как же устроена эта железка и что она умеет. Провёл небольшое техническое расследование: от осмотра корпуса до анализа сетевых пакетов и выяснения, какие протоколы вообще использует домофон.
Я давно увлекаюсь железками и автоматизацией дома — эдакий «полу‑умный» дом, который вроде бы и работает сам, но иногда всё же требует вмешательства человека. Поэтому, обнаружив Ethernet‑домофон, я и решил разобраться в нём.
Основная цель эксперимента — научить трубку открывать дверь по решению автоматизации в Home Assistant. Побочная — внести вклад в сообщество и, возможно, помочь самому вендору, если в процессе вскроются любопытные особенности или проблемы.
Знай врага в лицо
Сначала меня удивила клавиатура — у моей трубки были все возможные кнопки, кроме той самой, что открывает дверь. Уже интрига.
Информации о модели в интернете оказалось немного, но производитель всё‑таки указал несколько ключевых характеристик:
веб‑интерфейс;
кнопка «Консьерж»;
режим громкой связи;
поддержка протоколов SIP и TCP/IP;
питание DC 5V/2A или PoE 802.3af;
Веб‑интерфейс — уже хороший знак: значит, можно будет посмотреть настройки. Оставалось лишь найти способ заглянуть внутрь. И тут удача — со стороны крепления кто‑то заботливо подписал IP‑адрес трубки маркером прямо на корпусе. Видимо, устройство работает со статической адресацией. Отличная точка входа для исследования.

Подключаемся к сети домофонов
С точки зрения протоколов трубка оказалась вполне предсказуемой: она работает как обычный SIP VoIP‑телефон, точно такие же стоят, например, в офисах Яндекса. Значит, взаимодействовать с ней можно привычными средствами.
В моём случае трубка питалась прямо по Ethernet — через PoE. Этот стандарт часто используется в офисах для точек доступа и IP‑телефонов: питание идёт по тому же кабелю, что и данные, что избавляет от лишних проводов. Если вы сейчас на работе — посмотрите на потолок: возможно, там висит коробочка, питающаяся тем же способом и раздающая вам Wi‑Fi.
К счастью, мой роутер MikroTik RB5009UPr+S+ умеет запитывать устройства по стандарту 802.3af, так что вопрос с питанием решился сам собой.
Дальше — немного магии RouterOS. В терминологии MikroTik «бридж» — это программный коммутатор второго уровня.То есть нам нужно сделать отдельный мост между трубкой и сетью домофонов, чтобы не мешать основной сети.
Создаём новый бридж, называем его
intercom.Перевешиваем на него два порта — трубку и порт uplink, — чтобы изолировать от локальной сети.
Добавляем маршрут до сети домофонов. Маску мы не знаем, но можно взять /16 от IP, написанного на корпусе трубки. Например:
/ip/route/add dst-address=10.17.0.0/16 gateway=eth-intercom-uplink
Эти же действия можно было выполнить и с помощью обычного неуправляемого коммутатора. Но под рукой был только MikroTik.
После всех манипуляций втыкаем оба провода в роутер. Теперь он работает как прозрачный коммутатор между трубкой и сетью домофонов. Снимаем трубку — слышен гудок. Отлично: значит, устройство подключено к SIP‑серверу и связь работает.
Что там в админке
Маршруты настроены, связь есть — пора заглянуть внутрь. Обычно в такой момент кулхацкеры первым делом сканируют устройство на открытые порты. Но поскольку я уже знаю, что у трубки есть веб‑интерфейс, начнём с него. Вбиваю IP‑адрес в браузер — и вижу страницу с авторизацией.

Для начала нужно понять, как реализована авторизация. Открываю инструменты разработчика, пробую наугад логин admin и пароль admin. Браузер немного думает… и вот я попадаю внутрь.

Креды по умолчанию — admin/admin. Ну что ж, зато ничего ломать не пришлось.
На главной странице админки сразу отображаются базовые характеристики устройства:
ROM: 1,5/16 M
RAM: 1/23 M
Не густо, Python, конечно, не запустишь, но жить можно.
Что удалось найти внутри
Полазив по разделам, вытаскиваю несколько действительно полезных вещей:
Настройки сети. Тут, наконец, выяснилось, что маска подсети —
/23.Параметры SIP‑подключения. Имя пользователя и номер телефона формируются просто:
6 + номер квартиры.DTMF‑код открытия двери. Это те самые тональные сигналы, которые мы все слышали в голосовом меню колл‑центров. В моём случае комбинация
123#отправляется на SIP‑сервер — и тот понимает, что дверь нужно открыть.
Кроме того, трубка унаследовала от своих старших братьев из линейки Fanvil богатую функциональность: поддержку OpenVPN, 802.1X‑аутентификации и других корпоративных штук, явно избыточных для обычной квартиры.
Пароль от SIP‑сервера, к сожалению, увидеть не удалось — в интерфейсе он скрыт звёздочками. Я проверил HTML‑код на всякий случай, но и там: value="****".
На удивление секурно!
Больше настроек богу настроек
На этом этапе я немного застрял: пароль от SIP‑сервера админка упрямо не показывала, а значит, подключиться напрямую без самой трубк�� не получится. В голове уже крутилась идея: а не послушать ли трафик между трубкой и сервером с помощью Wireshark? Тем более, RouterOS позволяет делать такие штуки буквально одним кликом: включил сниффер, направил на Wireshark, и вот тебе весь SIP‑трафик как на ладони.
Но прежде чем привлекать тяжёлую артиллерию, я вспомнил, что где‑то в интерфейсе промелькнула опция импорта и экспорта настроек. Если есть экспорт — значит, где‑то должен лежать файл конфигурации. А если есть файл, то, возможно, там и спрятан тот самый пароль.
Пора посмотреть, что именно отдаёт устройство и в каком формате сохраняет настройки.

Жмём на ссылку и получаем XML‑файлик — никакого хитрого шифрования. Внутри очень много настроек, но нам нужен всего лишь пароль…
<line index="1"> <PhoneNumber>6149</PhoneNumber> <DisplayName>6149</DisplayName> <SipName></SipName> <RegisterAddr>10.17.0.1</RegisterAddr> <RegisterPort>5060</RegisterPort> <RegisterUser>6149</RegisterUser> <RegisterPswd>6149</RegisterPswd> <RegisterTTL>3600</RegisterTTL> </line> <!-- и еще 1500 строк -->
..а пароль (RegisterPswd) совпадал с юзернеймом SIP‑аккаунта. Сработавший admin/admin ничему меня не научил:(
Копаем глубже
Итак, все данные сервера у меня есть. У самурая два пути:
полностью выкинуть трубку и подключиться к SIP‑серверу напрямую с помощью Asterisk — всё для этого уже готово;
заставить трубку делать всю работу через её собственный API (если он есть) — то есть писать софт для трубки.
Я выбрал второй вариант. Думаю, написать софт прямо для трубки домофона — это весело.
Вернёмся к XML с параметрами. И там — прекрасное.
<web> <EnableTelnet>0</EnableTelnet> <!-- ← это же Telnet! --> <TelnetPort>23</TelnetPort> <TelnetPrompt></TelnetPrompt> <LogonTimeout>30</LogonTimeout> <account index="1"> <Name>admin</Name> <Password>admin</Password> <Level>10</Level> </account> </web>
Telnet — это очень простой, как палка, протокол из 1972 года. Используется для удалённого подключения к шеллу. Галочку, включающую Telnet, в админке я не видел. Но на всякий случай захожу в раздел с настройками портов и проверяю инпуты в коде страницы.

В коде просто спрятали опцию через CSS. Очевидно, S в CSS — это Safety & Security. Убираем стили, нажимаем галочку и подключаемся по Telnet.

admin/admin не подходитТеперь знаем две важные вещи:
Трубка работает на Linux, о чём свидетельствует using Poky 13.0 — это значит, что прошивка сделана на референсном дистрибутиве Yocto Project.
Название платформы, на которой эта трубка построена, — DSPG DVF97. Под этим названием скрывался SoC для VoIP‑телефонов (внезапно).
Тем временем я нашёл подробный мануал для другого телефона, но с такой же прошивкой. В мануале честно указано наличие Telnet в виде скрытой настройки.


Пользователем оказался root, а пароля вовсе не было. Такая безопасность, конечно, удручает, но и упрощает реверсерам жизнь. Железка окончательно повержена.
Грызем кактус и юзерспейс
Теперь есть полное понимание, что за железо стоит в трубке:
Слабенький одноядерный ARMv5-процессор.
Из 24 МБ ОЗУ свободно 1,5 МБ, а доступно около 6 МБ (в чём разница, объясняется на этой странице).
Корневая ФС примонтирована как read‑only, используется SquashFS, а значит, «поправить» систему не получится.
Даже не знаю, что больше всего добавляет сложностей — настолько малый объём памяти или столь древняя архитектура.

Сразу отметаем интерпретируемые языки. В основном я пишу на Go, но он слишком прожорлив — получаются толстые бинарники, которые не поместились бы на флешку. C и C++ для такой задачи я отбросил: слишком больно и громоздко.
Остаётся только Rust. Он весьма экономичен по памяти (no GC, zero cost abstractions), и вокруг Rust сложилась экосистема — крутая система сборки и море пакетов. А ещё он поддерживает нужную мне архитектуру.
Сборка "Hello, world!"
Кросс‑компиляция (сборка под архитектуру, отличную от хоста) подразумевает установленные тулчейны. К счастью, в репозитории Fedora всё есть. Цели (target) для сборки бинарников задаются триплетами (triplet) — строками вида «архитектура‑платформа‑ОС‑ABI».
Для трубки подошёл таргет armv5te-unknown-linux-musleabi.
# добавляем целевую платформу для Rust rustup target add armv5te-unknown-linux-musleabi # и устанавливаем утилиты работы с бинарниками под ARM # оттуда мне нужен только линковщик (ld) # => Fedora dnf install -y gcc-arm-linux-gnueabi # => macOS brew install arm-linux-gnueabihf-binutils
В настройки Cargo (.cargo/config.toml) подкидываем пути до линкера, иначе он попробует собрать всё с помощью линкера хоста.
[target.armv5te-unknown-linux-musleabi] linker = "arm-linux-gnueabihf-ld" ar = "arm-linux-gnueabihf-ar"
Написать «Hello, world!» в консоль — моветон. Я решил собрать HTTP‑сервер, да чтоб на модном асинхронном фреймворке. HTTP‑сервер нам ещё пригодится, но об этом позже.
use axum::{routing::get, Router}; #[tokio::main] async fn main() { let app = Router::new().route("/", get(|| async { "Hello, World!" })); println!("Listening on port 3000"); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
Собираем бинарник с --release‑флагом, скачиваем его в /tmp трубки (через wget). И запускаем.


Автозапуск в read-only-системе
Рабочий HTTP‑сервер — это замечательно, но надо бы научить его запускаться вместе с системой. Я планирую использовать MQTT для автоматизации, поэтому процесс должен работать фоново. Очевидно, что трубка где‑то хранит свои настройки. Узнать точки монтирования поможет команда mount.
root@dvf97:~# mount rootfs on / type rootfs (rw) /dev/root on / type squashfs (ro,relatime) devtmpfs on /dev type devtmpfs (rw,relatime,size=12124k,nr_inodes=3031,mode=755) proc on /proc type proc (rw,relatime) sysfs on /sys type sysfs (rw,relatime) debugfs on /sys/kernel/debug type debugfs (rw,relatime) tmpfs on /run type tmpfs (rw,nosuid,nodev,mode=755) tmpfs on /var/volatile type tmpfs (rw,relatime) devpts on /dev/pts type devpts (rw,relatime,gid=5,mode=620) /dev/mtdblock11 on /mnt type jffs2 (rw,relatime) /dev/mtdblock7 on /base type squashfs (ro,relatime) /dev/mtdblock9 on /userdata type squashfs (ro,relatime) /dev/mtdblock8 on /usr type jffs2 (rw,relatime)
Тут что‑то странное:
/userdata, которая, казалось бы, должна хранить данные пользователя, примонтирована как RO. Там хранится кастомизация админской панели и рингтоны.А вот
/usr, где хранятся бинарники, смонтирована на запись. Используется JFFS2 — простая журналируемая ФС. Но свободно там всего лишь 32 КБ.В
/mntтоже можно писать. И места много — свободно 1,4 МБ.
Чёрт ногу сломит!
К слову, подробную таблицу про разделы я оставил в своём Гитхабе.
Раз уж вендор нам дал возможность писать в /usr, то можно заменить какой‑нибудь бинарник или подредактировать скрипт. А свой бинарник положу в /mnt, так как в /usr очень мало свободного места.
Патчим жертву
К счастью, найти жертву‑бинарник оказалось очень просто. Вот список процессов:
root@dvf97:~# ps - PID USER TIME COMMAND 1 root 0:16 init [5] < ... kernel threads ... > 329 root 38:02 {afterUpgrade.sh} /bin/sh /bin/afterUpgrade.sh 350 root 0:00 /sbin/getty -L 115200 ttyS 351 root 0:00 /sbin/getty 38400 tty1 361 root 234:23 {callManager} XGUI 411 root 228:01 app_dsp 508 root 0:41 {ipwatchd-script} /bin/sh /usr/sbin/ipwatchd-script 597 root 0:01 telnetd -p 23 25323 root 0:00 -sh 29050 root 0:00 sleep 60 29067 root 0:00 -sh 29156 root 0:00 sleep 1
На трубке крутится пара процессов (XGUI и app_dsp), которые ��твечают за непосредственные функции телефона. Я не разбирался, как между ними реализован IPC и какие функции у каждого бинарника. Но в чём я уверен — их точно не стоит трогать.
А вот потрогать /bin/sh /usr/sbin/ipwatchd-script — звучит как идеальный план. Он лежит в /usr, куда можно писать. И этот скрипт написан на unix shell, а значит, можно его поправить, ничего не сломав.
Оригинальный скрипт
#!/bin/sh CM_FIFO=/tmp/ipconflict_fifo DEVICE=$1 IP=$2 MAC=$3 while true do ipStr=`ifconfig eth0 | grep "inet addr" | awk '{ print $2}' | awk -F: '{print $2}'` if [ "$ipStr" ]; then arping -D -c 1 -w 1 $ipStr if [ $? == 1 ]; then echo "ip_conflict \"$ipStr\"" > $CM_FIFO fi fi sleep 60 done exit 0
Скрипт раз в минуту проверяет ARP записи по IP‑адресу телефона. Если произошла коллизия, то выполняется запись в FIFO. Зачем это трубке и какой процесс это читает — не знаю.
Я не стал доверять жизненному циклу ipwatchd-script и решил использовать его как трамплин для своего скрипта. В начало добавим запуск своего init‑скрипта и отвяжемся от ipwatchd-script:
if [ -f /mnt/userdata/hottubes-init.sh ]; then /mnt/userdata/hottubes-init.sh & fi # ... старый код
Теперь записываю init‑скрипт в /mnt/userdata/hottubes-init.sh:
Код init скрипта
#!/bin/sh PIDFILE="/run/hottubes.pid" if [ -f "$PIDFILE" ]; then PID=$(cat "$PIDFILE") if kill -0 "$PID" 2>/dev/null; then echo "hottubes is already running (PID: $PID). Exiting." exit 0 else echo "stale PID file found; removing it." rm -f "$PIDFILE" fi fi # ignore SIGHUP so that the child process isn't killed when this shell exits. trap '' HUP /mnt/userdata/hottubes </dev/null >/run/hottubes.log 2>&1 & child_pid=$! echo "$child_pid" > "$PIDFILE" echo "started /mnt/userdata/hottubes detached with PID $child_pid."
Осталось переместить бинарник из /tmp в /mnt/userdata/hottubes и проверить в бою. Перезагружаем трубку. Всё работает!
Что делать дальше, я уже примерно представлял:
в админке задать вебхуки на localhost, чтобы понимать, в каком состоянии трубка;
дёргать какую‑нибудь ручку для ответа на вызов и отправки DTMF‑кода;
всё это обернуть в конечный автомат;
писать в топики по MQTT и объяснить Home Assistant, как с ними работать.
Почти что вебхуки
Вернёмся в самое начало — в админку. В разделе Phone → Action можно задать что‑то вроде вебхуков. На целевое устройство прилетает GET‑запрос без полезных данных. Но нужные события тут есть: Idle, Ringing и Talking.
Вот для этого и пригодился HTTP‑сервер! Прописываем вебхуки до 127.0.0.1 (localhost трубка не хочет принимать):

Снял трубку и увидел запросы в консоли — обратные вызовы работают!
Нажимаем кнопки
Для управления трубкой есть всего одна ручка. Ручка кривая, но что поделать. Как ни странно, её достаточно. Мануал нашёл здесь.
Трубка принимает GET /cgi-bin/ConfigManApp.com. Используется Basic‑аутентификация. В параметре key указывается клавиша, которую нужно «нажать». Последовательность простая:
F_ACCEPT— принять вызов.1;2;3;POUND— набрать DTMF‑код123#. Это же действие совершает кнопка с человечком на трубке.F_RELEASE— положить трубку.
MQTT
Теперь бинарник знает о состоянии звонка и умеет открывать дверь. Кайф!

Для этого идеально подходит MQTT — лёгкий протокол обмена сообщениями, изначально созданный для умных устройств с ограниченными ресурсами. Он работает по принципу publisher/subscriber: устройство публикует события в определённые топики, а другие системы (например, Home Assistant) на них подписываются.
В центре этой схемы стоит брокер — сервер, который принимает сообщения и раздаёт их подписчикам. Самый популярный брокер — Mosquitto, его можно запустить хоть на Raspberry Pi.
При старте бинарник собирает информацию о железке — модель, IP и MAC‑адрес — и отправляет в специальный топик homeassistant/device/{device_id}/config сообщение с длинным JSON.
Вот как формируется JSON
json!({ "device": { "name": metadata.model, "manufacturer": metadata.vendor, "model": metadata.model, "sw_version": metadata.firmware_version, "configuration_url": metadata.ip_address.as_ref().map(|address| format!("http://{}", address)), "identifiers": [state.client_id], "connections": [["mac", metadata.mac_address]], }, "origin": { "name": env!("CARGO_PKG_NAME"), "sw": env!("CARGO_PKG_VERSION"), "url": env!("CARGO_PKG_HOMEPAGE"), }, "components": { "phone": { "unique_id": format!("{}-phone-status", state.client_id), "platform": "sensor", "name": "Phone Status", "device_class": "enum", "icon": "mdi:phone-ring", "options": [ "IDLE", "TALKING", "RINGING", ], "state_topic": format!("{}/phone", state.client_id), }, "open": { "unique_id": format!("{}-open-button", state.client_id), "platform": "button", "name": "Open Door", "icon": "mdi:door", "command_topic": format!("{}/open", state.client_id), }, "ring": { "unique_id": format!("{}-ring-trigger", state.client_id), "automation_type": "trigger", "platform": "device_automation", "type": "phone", "subtype": "ring", "topic": format!("{}/phone", state.client_id), "payload": "RINGING", }, }, });
Последний шаг к победе: реализовать запись в топики и подписаться на команду /open. Finita la commedia!
Что в итоге
С одной стороны, это история о том, как небрежность в разработке и настройке может сделать уязвимым даже простейшее устройство. С другой — отличный пример того, как из подобных ситуаций рождаются проекты, где можно исследовать железо, экспериментировать и искать творческие решения.
В итоге получился отличный пет‑проект, где можно было поковыряться в железе, покодить на Rust под древнюю архитектуру, покопать сетевые стеки, — и сделать наконец умный домофон, который реально что‑то умеет.
Без хаков, конечно, ничего бы не вышло. Но когда железку можно взломать логином admin/admin и запустить Telnet галочкой, спрятанной через CSS, — понимаешь, в каком прекрасном мире мы живём.
Миссия выполнена. Дверь открыта.
