Хабр, привет!
На пользовательском рынке fpv видеосистем на сегодняшний день есть два типа видеосистем — аналоговые и цифровые. Исторически так сложилось, что аналоговые видеопередатчики клепал практически каждый производитель, так как спрос на рынке был, есть и, скорее всего, ещё будет. А вот с цифровыми системами всё немного сложенее.
Сама по себе технология цифровой видеосвязи не нова, но в контексте fpv-дронов у неё есть две проблемы — размер и потребляемая мощность (а ещё дистанция связи и задержка передачи данных). И до недавнего времени производителей такого оборудования можно было пересчитать на пальцах одной руки — DJI, Walksnail, HdZero и Siyi (однако упоминать эту систему не совсем уместно, так как сама по себе она большая и не подходит для большинства fpv-пилотов).
Однако совсем недавно такие крупные бренды как Betafpv, hglrc и т.д. заявили о разработке собственной цифровой системы. И через некоторое время их системы уже можно было купить обычному пользователю.
Естественно, энтузиасты сразу же купили такие системы и обнаружили, что у всех новоиспечённых производителей цифровой связи одни и те же платы на одних и тех же чипах от компании Artosyn

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

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

Первым делом, когда я увидел плату, то в глаза бросился testpoint Rx, а значит, где-то рядом с ним есть и Tx, а значит, скорее всего, это UART.
Однако, припаявшись к нему и подцепившись логическим анализатором, я обнаружил, что это оказался 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! ну и так далее
А вот совсем рядом наблюдаются ещё три пина, припаиваемся и бинго! Мы попали в юарт системы, так ещё и с рут-консолью.
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 aka 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 (юарт которого я нашел первым), который как раз таки и ответственен за физическую отправку и приём пакетов через радио. 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. То есть плату можно воткнуть в комп по USB, и она поднимется как сетевая карта.
Однако, у нас есть проблема: рут файловой системы это 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
Конечно, пытаться кракать пароль — сомнительное дело, но всё же я прогнал список с топ-10000 популярными паролями — результата это не дало.
Тогда я попросил нейронку сгенерировать топ-100 контекстных паролей и пару масок и вы не поверите.

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

Wanna some Rick Roll?
Первая мысль была наглой - а давайте вообще выкинем бортовой энкодер и зашлём в радиолинк уже готовый 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-энкодер. Кодек, кстати, лежит блобом прямо в rootfs — chagall.bin.gz и его lowmem-версия chagall_lowmem.bin.gz.
Что по форматам? На входе энкодера кадр лежит как I420 (он же YUV 4:2:0 planar - в структуре VIDEO_FRAME_INFO_S это pixfmt=4): три раздельные плоскости Y/U/V, разрешение 1920×1080, строки (stride) 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), и — важная деталь — работает он только пока радиолинк поднят (очки включены и спарены): нет линка — нет и энкода.
Почему кадр идёт двумя каналами?
Одна из неожиданностей: 1080p-кадр энкодер жмёт не целиком, а двумя независимыми каналами-полосами. chn0 — это верхняя полоса 1920×560, chn1 — нижняя 1920×552, с небольшим нахлёстом в районе строк 528–560. На приёмнике очки склеивают их обратно в 1080p функцией AR_LDRT_RX_FusionProcess — то есть чтобы заполнить весь экран, кормить надо оба канала.
Зачем так сделано — в логах прямо не написано, поэтому дальше предположение (если вы реально знаете почему, то коментарии открыты). Но весь стек называется ar_lowdelay, низкая задержка тут - главная цель, поэтому есть пару догадок:
Параллельный энкод. Две полосы можно жать одновременно, а не гонять одну большую картинку последовательно.
Пайплайнинг с чтением сенсора. MIPI-сенсор выдаёт кадр построчно сверху вниз; верхнюю полосу можно начинать кодировать и отправлять в радио, ещё не дочитав нижнюю.



Весь видеопайплайн крутится в бинаре ar_lowdelay. Он берёт кадры MPP-пайплайна (с MIPI-камеры) и заливает их в аппаратный HEVC-энкодер вызовом AR_MPI_VENC_SendFrame(chn, frame, ...). И вот сюда, между «сырыми пикселями» и «входом энкодера», можно вклиниться через LD_PRELOAD (очень повезло, что бинариники собраны не статично): подсовываем свою реализацию AR_MPI_VENC_SendFrame, портим ей буфер кадра и передаём управление в настоящую функцию через dlsym(RTLD_NEXT, ...). Дальше плата своим железным HEVC жмёт уже нашу картинку и гонит её в радиолинк - очки декодируют её нативно, как будто это и есть камера.
Как устроен инжект
Давайте по коду, тут интересно.
Конструктор. При загрузке .so первым делом резолвим три символа из следующей библиотеки в цепочке — сам 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 */
Тут два подводных камня. Во-первых, шим прелоадится в кучу процессов, а трогать нам надо только ar_lowdelay - поэтому is_al() читает /proc/self/comm и активируется, лишь если имя процесса совпало. Во-вторых, плата на glibc 2.25, поэтому dlsym намертво прибит к старой версии символа, иначе на новом хосте соберётся ссылка на слишком свежий glibc:
__asm__(".symver dlsym, dlsym@GLIBC_2.17");
Дальше конструктор один раз считает LUT горизонтального скейла (xmap[dest_x] -> src_x, обычный nearest-neighbour) и поднимает фоновый поток-приёмник. Скейл тут нужен из-за того, что на плате толко usb2.0, который не даёт скорости для сырого full hd кадра.
Приёмник. Отдельный поток слушает TCP :9000 и в бесконечном цикле читает по SRC_W*SRC_H*3/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 frameId, +0x04/08 W/H, +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 */
Дальше — проверка, что кадр действительно 1920x1080 и указатели живые, и скейлим наш I420 640x360 прямо в буфер энкодера. Тут ещё пара оптимизаций под слабый проц: по вертикали апскейл отображает несколько строк назначения в одну исходную, поэтому исходную строку собираем через LUT (это cache-unfriendly операция) только один раз, а её дубликаты просто копируем 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; }
Хитрость с chn: энкодер получает картинку двумя каналами-полосами (0 и 1) — верхняя и нижняя половины 1080p-кадра с небольшим нахлёстом (h > 568 ? h - 568 : 0), поэтому для каждого канала мы заполняем только свою вертикальную область.
И последний обязательный штрих - раз мы писали в буфер по виртуальному адресу, а железный энкодер читает его напрямую из памяти, надо сбросить кэш по физическому адресу, иначе энкодер увидит старые данные и картинка будет не полная. Флашим чанками по 1 МБ (ограничение API):
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
Vladekk
Хм, а зачем такое может быть нужно в практическом ключе?
Если вокруг мобильники? Или просто ради интереса?
remove-rf Автор
ну это из разряда "запустили дум на <вставьте название устройства>")