Трансляция своего видео через чужой радиоканал или реверс-инжиниринг BETAFPV P1 Air Unit
Привет, SE7EN!
На потребительском рынке FPV-видеосистем сегодня доминируют два направления — аналоговые и цифровые. Исторически сложилось так, что аналоговые передатчики выпускают практически все кому не лень, поскольку спрос на них стабилен и, судя по всему, спадать не собирается. С цифровыми решениями ситуация обстоит куда интереснее.
Сама концепция цифровой беспроводной связи далеко не нова, однако в сфере FPV-дронов она упирается в две извечные проблемы: габариты и энергопотребление (не говоря уже о дальности действия и задержке сигнала). До недавнего времени разработчиков подобного железа можно было пересчитать по пальцам: DJI, Walksnail, HDZero и Siyi (последнюю, впрочем, упоминать в данном контексте вряд ли уместно из-за её громоздкости, делающей её непригодной для подавляющего большинства FPV-пилотов).
Тем не менее, сравнительно недавно такие известные бренды, как BetaFPV, HGLRC и другие, объявили о создании собственных цифровых комплексов. Прошло совсем немного времени, и эти системы появились в свободной продаже.
Разумеется, энтузиасты тут же разобрали новинки и выяснили любопытную деталь: все новоявленные цифровые модули построены на идентичных платах, в основе которых лежат чипы от компании Artosyn.

Если вкратце, Artosyn — это китайский вендор, специализирующийся на RF-микросхемах для цифрового видео. Более подробную информацию о нём можно отыскать на профильных ресурсах, однако примечательна одна деталь: напрямую производителям FPV-оборудования OEM-платы Artosyn не отгружает. Вся цепочка поставок завязана на посредника — дочернюю (?) структуру KAP, которая уже распределяет готовые модули по брендам вроде BetaFPV, HGLRC и прочим. Наглядно эта иерархическая структура выглядит следующим образом:

Знакомство с платой
Для начала позвольте представить сегодняшнего подопытного: BetaFPV P1 Air Unit HD VTX.

Первое, что бросилось в глаза при осмотре платы — это контрольная точка (testpoint) Rx. Логично предположить, что где-то рядом должен располагаться и Tx, а значит, перед нами стандартный UART.
Однако, припаяв к нему провода и подключив логический анализатор, я выяснил, что это интерфейс от FreeRTOS, что несколько не соответствовало нашим ожиданиям.
V1.5-19:51:01
0:boot#04
4.SDIO
0009d149us
10
version: Nov 13 2025, compiled at: 09:04:01
chan valid bmp:FFFFFFFF
=FreeRTOS command bb=_cfg[bb_set_cfg_hook:137] no flash mode!
s3
bb_cfg[bb_load_power_calibration:2095] ver[default], load [default] pwr calibration
bb_cfg[bb_load_configure:3579] load default configure : ok!
[660][ 0][INF] bb_init: gSDK_VERSION=1.0.01 build=11/13/2025 09:03:31
[660][ 1][INF] bb_task_entry: task bb_mac_tx_task started
[660][17][ERR] bb_mac_transport_create: create transport failed 8 0 6 512 0
[660][ 1][INF] bb_task_entry: task bb_mac_rx_task started
[661][ 1][INF] bb_task_entry: task bb_link_task started
[661][ 1][INF] bb_task_entry: task bb_phy_task started
[661][ 4][INF] bb_phy_basic_init: basic init!
ну и так далее
Зато буквально по соседству обнаружилась еще одна тройка пинов. Пара движений паяльником — и бинго! Мы успешно добрались до системного UART, причем сразу с правами корневой консоли.
adc_val0 = 453
DDR use user-modify0
DDR warmboot not support
Find vendor
SDRAM init start...
(B) Start Clocks and Reset the PHY
DDR change freq 800
Calculate MB
(A) Bring up VDD, VDDQ, and VAA
(C) Initialize PHY Configuration
(D) Load IMEM 1D
(E) Set PHY input clocks
DDR change freq 2132
(F) Load DMEM 1D
(G) Execute
Training successfully.
(H) Read msg blk results
(I) Load PIE Image
(J) Initialize Mission Mode
Trying to boot from SPINAND
spi_load_image_os 114 143296
Uncompressing Firmware
spi_load_image_os 149 396276
jump_to_image_linux 163 417179
kernel start finish
artosyn,proxima-9311
get get_parts for flashtype= nand
mounting factory flash_type=nand:8
mcp mounting factory
waitting for /dev/ubi10_0 ready ...
ubi10_0
mounting usrdata flash_type=nand:19
mcp mounting usrdata
ubi7_0
mounting usrlog flash_type=nand:20
mcp mounting /tmp/usrlog
ubi13_0
start nomal system...
Please press Enter to activate this console. start...
/ #
А теперь давайте разберем аппаратную начинку подробнее.
Система
В сердце устройства установлен чип Artosyn AR9311, он же Proxima-9311 с кодовым именем Sirius (конфигурации содержат метки art_sirius, board=artosyn, а сборка подписана тегом ars31-c401-rbox-2.0.4-00-release). Процессор базируется на архитектуре aarch64, работает под управлением ядра Linux 4.9.38 в комплекте с BusyBox, а пользовательское окружение опирается на glibc 2.25. Мне достался юнит с прошивкой версии v1.0.44 (P1_SKY_v1.0.44_20260122_09de6e7).
Небольшая ремарка касательно наименований: префикс P1_SKY обозначает SKY-сторону линка, то есть бортовую часть, установленную на дроне и транслирующую видеопоток. Наземная часть в очках функционирует как GROUND-сторона.
Кроме того, на плате (и в её конфигурационных файлах) фигурирует упоминание упомянутого ранее чипа AR8030 под управлением FreeRTOS (именно его UART я запеленговал в первую очередь). Он отвечает за физический прием и передачу радиопакетов. Связь между SoC и радиомодулем реализована через SDIO-eth RPC — поверх интерфейса SDIO поднят изолированный сетевой сегмент, где за радиочипом закреплен IP-адрес 10.0.0.1. Драйвер этого механизма называется artosyn_sdio, по соседству функционируют модули ar_mpp_drv, ar_vb, ar_sys, а за общую координацию радиоканала отвечают пользовательские демоны arlink_daemon и arlink_fpv.
Но самое приятное для нас — это наличие на плате полноценного USB-интерфейса с поддержкой периферийного режима (а не хоста, как это часто бывает) и активированным протоколом RNDIS. Иными словами, модуль можно подключить к ПК по кабелю, и он определится как стандартная сетевая карта.
Правда, здесь кроется технологический нюанс: корневая файловая система упакована в squashfs, поэтому просто так отредактировать /etc/passwd не получится.
Пару слов об U-Boot, к обходу которого привыкли многие исследователи подобного железа: в перехваченном логе загрузки его нет. Все сообщения в ttyS0 до момента старта ядра генерируются проприетарным мини-загрузчиком Artosyn. Он проводит инициализацию памяти DDR (блоки (A)–(J)), выводит строку Trying to boot from SPINAND, подгружает образ (spi_load_image_os), распаковывает его (Uncompressing Firmware) и передает управление ядру (jump_to_image_linux → kernel start finish):
Trying to boot from SPINAND
spi_load_image_os 114 143296
Uncompressing Firmware
spi_load_image_os 149 396276
jump_to_image_linux 163 417179
kernel start finish
Никаких параметров вроде bootdelay, приглашений «Hit any key to stop autoboot» или интерактивного промпта => здесь не предусмотрено. Иными словами, штатного способа прервать загрузку и попасть в консоль бутлоадера с командами setenv или bootm нет. Тем не менее, механизм Secure Boot честно проверяет целостность образов (цепочка подписана ключом RSA-2048), но доступ к рут-оболочке через ttyS0 он не блокирует. Нам несказанно повезло, движемся дальше.
А что с паролем?
На борту запущен демон dropbear, причем аутентификация по паролю разрешена (скрипт start_ssh.sh инициирует dropbear -E без ключа -s). Беда в том, что сам пароль нам изначально неизвестен.
На этом этапе можно было бы пуститься в поиски уязвимостей для выполнения произвольных команд на этапе загрузки (к чему в итоге всё равно пришлось бы прибегнуть, но уже для иных задач). Однако, имея на руках хеш пароля, первым делом стоит обратиться к поисковикам — вдруг его уже кто-то сбрутил? К сожалению, готового ответа на этот раз не нашлось.
Хеш хранится непосредственно в файле /etc/passwd, отдельный /etc/shadow на устройстве отсутствует:
root:$1$tDKJIIxn$.rv7GNd0J9MjlSoHMu0MI.:0:0:root:/root:/bin/sh
Конечно, перебор хэша — затея сомнительная, но я всё же прогнал стандартный список из 10 000 популярных комбинаций, что ожидаемо не принесло результата.
Тогда я попросил нейросеть сгенерировать сотню контекстных вариантов и парочку специфических масок. И вы не поверите.

Да, это банальный artosyn. Видимо, инженеры BetaFPV даже не предполагали, что кто-то проявит такой глубокий интерес к внутренностям юнита.
Правда, версия dropbear здесь используется довольно древняя, и современный клиент OpenSSH из коробки отказывается с ней общаться — приходится принудительно указывать устаревшие алгоритмы:
ssh -o HostKeyAlgorithms=+ssh-rsa \
-o KexAlgorithms=+diffie-hellman-group14-sha1 \
-o Ciphers=+aes128-cbc \
root@192.168.3.100
Мы внутри системы.

Устроим небольшой Рикролл?
Первая идея была наглейшей: полностью исключить штатный энкодер и напрямую пустить в радиоканал готовый поток H.265. Подход рабочий в том плане, что очки его действительно декодируют, однако добиться стабильной работы не получилось из-за проблем с контролем битрейта и таймингами. Поэтому я пошел по другому пути — не трогать сам энкодер, а подменить видеоданные на его входе.
Вооружаемся Binary Ninja и начинаем препарировать софт в компании с нейросетью.
На борту имеется камера с сенсором sc231ai (SmartSens, 1/2.7″, 2 Мп, 1920×1080@60), подключенная через интерфейс MIPI CSI. При загрузке система опрашивает её по I2C, отрапортовав в логах sc231ai probe success. Видеопоток с матрицы обрабатывается через MPP-пайплайн (Media Process Platform, стандартный для китайских SoC) и поступает на аппаратный кодировщик H.265/HEVC. Сам кодек оформлен в виде бинарного блоба прямо в корневой файловой системе — это файлы chagall.bin.gz и его облегченная версия chagall_lowmem.bin.gz.
Что с форматами? На входе энкодера кадр представлен в цветовом пространстве I420 (он же YUV 4:2:0 planar, где в структуре VIDEO_FRAME_INFO_S значится pixfmt=4): раздельные плоскости Y, U и V при разрешении 1920×1080 со страйдами (шагом строк) 2048/1024/1024. То есть яркостная составляющая Y выровнена до 2048 байт, а хрома U и V — вдвое уже. На выходе мы получаем H.265/HEVC в формате Annex-B (с NAL-заголовками VPS 40 01, SPS 42 01, PPS 44 01 и IDR-слайсом 26 01). Важный нюанс: энкодер функционирует только при поднятом радиолинке (когда очки включены и сопряжены с дроном). Нет линка — нет кодирования.
Почему кадр делится на два канала?
p>
Одна из неожиданностей заключалась в том, что поток 1080p сжимается не целиком, а параллельно по двум независимым каналам-полосам. Канал chn0 отвечает за верхнюю половину 1920×560, а chn1 — за нижнюю 1920×552, с небольшой зоной перекрытия в районе строк 528–560. На приемной стороне очки склеивают их обратно в единое разрешение 1080p посредством функции AR_LDRT_RX_FusionProcess. Следовательно, чтобы картинка заполнила весь экран, транслировать данные нужно одновременно в оба канала.
Точные причины такого архитектурного решения в документации не описаны (если знаете наверняка — добро пожаловать в комментарии). Но поскольку весь софтовый стек носит название ar_lowdelay, где во главу угла поставлена минимальная задержка, можно сделать следующие предположения:
-
Параллельное кодирование. Обрабатывать две узкие полосы одновременно быстрее, чем один крупный кадр целиком.
-
Пайплайнинг считывания с сенсора. MIPI-матрица выдает изображение построчно сверху вниз; верхнюю полосу можно отправить на кодирование и передачу в эфир еще до того, как сенсор полностью отсканирует нижнюю.



Главный видеопайплайн инкапсулирован в бинарнике ar_lowdelay. Он забирает кадры из подсистемы MPP (полученные с камеры) и передает их в аппаратный HEVC-энкодер вызовом функции AR_MPI_VENC_SendFrame(chn, frame, ...). Именно здесь, в зазоре между исходными пикселями и входом кодировщика, мы можем перехватить управление с помощью механизма LD_PRELOAD (благо бинарники нестатические): подменяем функцию AR_MPI_VENC_SendFrame собственной реализацией, модифицируем буфер кадра и перенаправляем вызов к оригиналу через dlsym(RTLD_NEXT, ...). В результате плата сжимает нашим аппаратным HEVC-блоком уже подготовленное нами изображение и отправляет его в эфир — а очки декодируют его в нативном режиме, принимая за сигнал с камеры.
Реализация инжектора
Давайте взглянем на код, это самое любопытное.
Конструктор. При загрузке нашей разделяемой библиотеки мы в первую очередь резолвим три символа из следующего по цепочке ELF-объекта — оригинальный AR_MPI_VENC_SendFrame, функцию сброса кэша ar_hal_sys_mmz_flush_cache_pa и функцию создания потоков pthread_create:
real_sf = (sf_fn)dlsym(RTLD_NEXT, "AR_MPI_VENC_SendFrame");
flush_pa = (flush_fn)dlsym(RTLD_NEXT, "ar_hal_sys_mmz_flush_cache_pa");
pcreate_fn pcreate = (pcreate_fn)dlsym(RTLD_NEXT, "pthread_create");
if (!is_al()) return; / активны только внутри ar_lowdelay /
Здесь кроются две тонкости. Во-первых, библиотека через LD_PRELOAD подгружается ко всем процессам подряд, а перехват нужен исключительно для ar_lowdelay — поэтому функция is_al() проверяет содержимое /proc/self/comm и активируется только при совпадении имени. Во-вторых, так как окружение собрано на glibc 2.25, вызов dlsym жестко привязывается к старой версии символа, иначе на хосте сборки возникнет привязка к чересчур свежей версии библиотеки:
asm(".symver dlsym, dlsym@GLIBC_2.17");
Затем конструктор единоразово рассчитывает таблицу пересчета (LUT) горизонтального масштабирования (xmap[dest_x] -> src_x, стандартный метод ближайшего соседа). Масштабирование необходимо, поскольку пропускная способность USB 2.0 на плате физически не способна прокачать несжатый फुल-HD поток.
Приёмник. Фоновый поток прослушивает TCP-порт :9000 и в бесконечном цикле принимает порции данных размером SRC_WSRC_H3/2 байт (формат I420) в двойной буфер. Критически важный момент для минимизации задержек: после приема кадра поток опрашивает сокет через ioctl(FIONREAD) на предмет скопившихся в буфере лишних данных. Если таковые имеются и там набрались готовые кадры, поток просто пропускает их, забирая исключительно самый актуальный:
for (;;) {
int avail = 0;
if (ioctl(cl, FIONREAD, &avail) != 0 || avail < (int)SRC_SZ) break;
if (read_frame(cl, dst) != 0) goto disc;
skipped++; / выкинули устаревший кадр /
}
atomic_store_n(&cur, widx, ATOMIC_RELEASE);
Благодаря этому скачки производительности у источника или просадки линка никогда не формируют очередь — на кодировщик всегда поступает новейший фрейм, а общая задержка не превышает длительности одного кадра.
Процесс перехвата. Наша функция AR_MPI_VENC_SendFrame парсит переданную структуру VIDEO_FRAME_INFO_S по смещениям, выявленным в ходе реверс-инжиниринга: +0x00 — идентификатор кадра, +0x04/08 — ширина и высота, +0x30/34/38 — шаги строк (stride) для плоскостей Y, U и V, +0x78/80/88 — физические адреса, а +0x90/98/a0 — виртуальные адреса памяти:
uint32_t fid = (uint32_t )(s + 0x00);
uint32_t sy = (uint32_t )(s + 0x30); / stride Y /
uint64_t py = (uint64_t )(s + 0x78); / phys addr Y /
uint8_t vy = (uint8_t *)(s + 0x90); / virt addr Y /
Далее мы проверяем, что размерность кадра составляет ровно 1920×1080, указатели валидны, после чего масштабируем наш входной I420-буфер 640×360 прямо в память энкодера. Здесь применена еще одна оптимизация под скромные вычислительные мощности процессора: при вертикальном апскейле несколько целевых строк могут проецироваться на одну исходную. Поэтому поиск пикселей по LUT (операция, недружелюбная к кэшу процессора) выполняется для исходной строки лишь единожды, а её дубликаты заполняются банальным memcpy:
int32_t srow = (int32_t)((uint32_t)r SRC_H / OUT_H);
if (srow == prev) { memcpy(d, pd, OUT_W); } / дубль строки - быстрый путь /
else { const uint8_t sr = sY + (size_t)srow SRC_W;
for (uint32_t x = 0; x < OUT_W; x++) d[x] = sr[xmap[x]];
prev = srow; pd = d; }
Нюанс с каналами: поскольку энкодер ожидает картинку разделенной на две полосы (0 и 1) с небольшим нахлёстом (h > 568 ? h - 568 : 0), для каждого канала мы заполняем строго отведенную ему вертикальную область.
И заключительный обязательный штрих: поскольку мы модифицировали данные по виртуальному адресу, а аппаратный кодер считывает их напрямую из физической памяти, необходимо сбросить процессорный кэш для физических адресов. Иначе энкодер захватит устаревшие данные и на выходе получится «каша». Процедура сброса выполняется порциями по 1 МБ (таково ограничение программного интерфейса):
flush_range(py + y0 sy, (y1 - y0) sy); / Y /
flush_range(pu + cy0 su, (cy1 - cy0) su); / U /
flush_range(pv + cy0 sv, (cy1 - cy0) sv); / V /
Теперь остается передать управление оригинальной функции real_sf(chn, frame, a3, a4) — и кодировщик пожмет уже наше изображение. Вот и всё, объем нашей обертки составил около 200 строк кода.
Заключение

В сухом остатке мы получили отличный компактный видеомодем, способный транслировать произвольный видеопоток на дистанции до 5 километров. Главное достоинство такого подхода — питание от 5 вольт и скромные габариты, выгодно отличающие его от конкурирующих решений с аналогичным качеством передачи видео.
Всех, кто хочет повторить эксперимент или подробнее изучить бинарники системы, отсылаю к репозиторию проекта на GitHub: https://github.com/XuliGan4eg2006/VR04_HD_video_injector
Смартфоны Xiaomi и Redmi потеряли доступ к Google Play: список из 12 моделей
Xiaomi выпустила миллион бесплатных самоучителей по использованию смартфонов
Раскрыты характеристики Poco M8 Power: аккумулятор 8000 мАч, зарядка 45 Вт, защита IP65 и процессор Snapdragon 4 Gen 4
Рекордный 24-часовой перелет: Airbus A350-1000ULR прибыл в Австралию на глазах у 3 миллионов зрителей
Samsung готовит второе поколение тройного складного смартфона Galaxy Z TriFold
Жаркая осень 2026: iPhone 18, Huawei Mate 90, Xiaomi 18, Vivo X500 и Oppo Find X10 выйдут в сентябре
Цены на Google Pixel вырастут: компания больше не может сдерживать удорожание комплектующих
AMD продлит жизнь платформе AM5 ради процессоров Zen 7 и отложит выход AM6 с поддержкой DDR6 и архитектуры Zen 8 до 2029 года