July 25, 2026
Send your video via someone else’s radio or reverse engineering BETAFPV P1 Air Unit
In today’s consumer FPV video market there are two kinds of video systems — analog and digital. Historically, pretty much every…

By rm_rf
10 min read
In today's consumer FPV video market there are two kinds of video systems — analog and digital. Historically, pretty much every manufacturer cranked out analog video transmitters, since the market demand was, is, and most likely will remain there. Digital systems are a bit trickier. The technology of digital video links itself isn't new, but in the context of FPV drones it has two problems — size and power consumption (and also link range and transmission latency). Until recently you could count the manufacturers of such gear on the fingers of one hand — DJI, Walksnail, HDZero and Siyi (though mentioning that last one isn't quite fair, since it's big on its own and doesn't suit most FPV pilots).
Recently, however, big brands like BetaFPV, HGLRC and so on announced that they were developing their own digital systems. And after a while their systems became available for regular customers to buy.
Naturally, enthusiasts immediately bought these systems and discovered that all the newly minted digital-link manufacturers use the same boards on the same chips from a company called Artosyn.
In short, it's a Chinese manufacturer specializing in RF chips for digital video systems. A detailed description of the manufacturer can be found on dedicated resources, but one detail is interesting: Artosyn doesn't supply OEM boards to FPV manufacturers directly. Everything goes through a middleman — a subsidiary(?) company called KAP — and it's KAP that hands finished modules out to brands like BetaFPV, HGLRC and the rest. This hierarchy is nicely illustrated by the following diagram:
Getting to know the board
First, let me introduce today's test subject: the BetaFPV P1 Air Unit HD VTX.
The first thing that caught my eye when I saw the board was an Rx test point, which means there's a Tx somewhere nearby too, which most likely means it's a UART.
However, once I soldered to it and hooked up a logic analyzer, I found out it was a UART from FreeRTOS — and that's not quite what we're looking for.
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!
and so onV1.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!
and so onBut right next to it there are three more pins — solder onto those and bingo! We land in the system's UART, and with a root console to boot.
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...
/ #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...
/ #Now let's figure out what's what here.
The system
Inside we have an Artosyn AR9311 aka Proxima-9311, codenamed Sirius (in the configs it shows up as art_sirius, board=artosyn, and the build itself is tagged ars31-c401-rbox-2.0.4–00-release). The CPU is aarch64 architecture running Linux 4.9.38 with BusyBox on board, and a glibc 2.25 userland. My unit was on firmware v1.0.44 (P1_SKY_v1.0.44_20260122_09de6e7).
A small note on naming: P1_SKY is the SKY side of the link — the half that sits on the drone and transmits video. On the ground, in the goggles, the GROUND side runs correspondingly.
The board (and its configs) also mention an R8030 chip running FreeRTOS (whose UART I found first), which is exactly the part responsible for physically sending and receiving packets over the radio. The SoC talks to the radio chip via SDIO-eth RPC — that is, a separate network link is brought up over SDIO, where the radio chip has IP 10.0.0.1. The driver for this is called artosyn_sdio in the system, and alongside it run the modules ar_mpp_drv, ar_vb, ar_sys and the userspace daemons arlink_daemon / arlink_fpv, which manage the whole radio link.
And the most beautiful thing for us is that the board already has a USB interface with peripheral-mode support (rather than host mode, as usual) and RNDIS enabled. That means you can plug the board into a computer over USB and it will come up as a network card.
There's a problem, though: the root filesystem is squashfs, which means you can't just go and rewrite /etc/passwd.
A separate note on U-Boot, since everyone is used to breaking such boards through it: it's simply not there in the captured boot log. Everything printed on ttyS0 before the kernel starts is Artosyn's proprietary mini-loader. It trains DDR (the (A)…(J) block), then prints Trying to boot from SPINAND, pulls in the image (spi_load_image_os), unpacks it (Uncompressing Firmware) and jumps into the kernel (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 finishTrying 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 finishThere's no bootdelay, no Hit any key to stop autoboot, no interactive => prompt here — meaning there's no standard window to interrupt autoboot and drop into a bootloader console with setenv/bootm on this board. Secure boot still enforces image integrity all the while (the chain is signed with RSA-2048), but it obviously doesn't touch the root shell on ttyS0. Very lucky — let's move on.
Password?
The board runs dropbear, password authentication is allowed (start_ssh.sh launches dropbear -E, there's no -s flag). We just don't know it, though.
At this point I could tell you how I looked for a spot to exploit (which I did end up having to do later, but for other purposes) so that our commands would run when the board boots — but if you have a password hash in hand, the first thing to do is google it (maybe it's already been cracked?). Not this time, though, no luck.
The hash sits right in /etc/passwd, there's no /etc/shadow here:
root:$1$tDKJIIxn$.rv7GNd0J9MjlSoHMu0MI.:0:0:root:/root:/bin/shroot:$1$tDKJIIxn$.rv7GNd0J9MjlSoHMu0MI.:0:0:root:/root:/bin/shOf course, trying to crack the password is a dubious endeavor, but I ran through the top-10000 popular passwords anyway — no result.
So I asked a ai to generate the top 100 contextual passwords plus a couple of masks, and you won't believe it.
Yep, artosyn — apparently the folks at BetaFPV didn't think anyone would be interested in poking around inside the unit :)
Mind you, the dropbear there is ancient, and modern OpenSSH won't work with it by default — you have to enable legacy algorithms:
ssh -o HostKeyAlgorithms=+ssh-rsa \
-o KexAlgorithms=+diffie-hellman-group14-sha1 \
-o Ciphers=+aes128-cbc \
root@192.168.3.100ssh -o HostKeyAlgorithms=+ssh-rsa \
-o KexAlgorithms=+diffie-hellman-group14-sha1 \
-o Ciphers=+aes128-cbc \
root@192.168.3.100We're in.
Wanna some Rick Roll?
My first idea was brazen — let's just throw out the onboard encoder entirely and push a ready-made H.265 bitstream into the radio link. The approach works in the sense that the goggles decode it, but I couldn't get it stable (rate control, timings — it's hard). So I went a different way — don't touch the encoder, but swap out the pixels at its input.
So let's fire up Binary Ninja and start digging in.
The board has a camera with an sc231ai sensor (SmartSens, 1/2.7″, 2 MP, 1920×1080@60), connected via a MIPI CSI interface — at startup the system probes it over i2c and reports sc231ai probe success. Its image is run through the MPP pipeline (Media Process Platform, like on most Chinese SoCs) and lands in a hardware H.265/HEVC encoder. The codec, by the way, sits as a blob right in the rootfs — chagall.bin.gz and its lowmem version chagall_lowmem.bin.gz.
What about the formats? At the encoder's input the frame is laid out as I420 (aka YUV 4:2:0 planar — in the VIDEO_FRAME_INFO_S structure this is pixfmt=4): three separate Y/U/V planes, resolution 1920×1080, strides 2048/1024/1024 — i.e. the luma Y is padded to 2048 bytes, and the chroma U/V are half as wide. At the output the encoder produces H.265/HEVC in Annex-B (NAL headers VPS 40 01, SPS 42 01, PPS 44 01, IDR slice 26 01), and — an important detail — it only works while the radio link is up (goggles on and paired): no link, no encode.
Why does the frame go over two channels?
One of the surprises: the encoder compresses a 1080p frame not as a whole, but as two independent channel-strips. chn0 is the top strip 1920×560, chn1 is the bottom strip 1920×552, with a small overlap around rows 528–560. On the receiver, the goggles stitch them back into 1080p with the function AR_LDRT_RX_FusionProcess — so to fill the whole screen you have to feed both channels.
Why it's done this way isn't stated outright in the logs, so what follows is speculation (if you actually know why, the comments are open). But the whole stack is called ar_lowdelay, low latency being the main goal here, so there are a couple of guesses:
— Parallel encode. The two strips can be compressed simultaneously, rather than pushing one big picture through sequentially.
— Pipelining with sensor readout. A MIPI sensor delivers a frame row by row from top to bottom; you can start encoding the top strip and sending it into the radio before you've finished reading the bottom one.
The whole video pipeline runs inside the ar_lowdelay binary. It takes frames from the MPP pipeline (from the MIPI camera) and pushes them into the hardware HEVC encoder via a call to AR_MPI_VENC_SendFrame(chn, frame, …). And this is exactly where — between the "raw pixels" and the "encoder input" — we can wedge in via LD_PRELOAD (very lucky that the binaries aren't built statically): we substitute our own implementation of AR_MPI_VENC_SendFrame, corrupt its frame buffer, and hand control off to the real function via dlsym(RTLD_NEXT, …). From there, the board's own hardware HEVC compresses our picture and pushes it into the radio link — the goggles decode it natively, as if it were the camera itself.
How the injection works
Let's go through the code, it's interesting here.
Constructor. When the .so loads, the first thing we do is resolve three symbols from the next library in the chain — AR_MPI_VENC_SendFrame itself, the cache-flush function ar_hal_sys_mmz_flush_cache_pa, and 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; /* active only inside ar_lowdelay */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; /* active only inside ar_lowdelay */There are two pitfalls here. First, the shim is preloaded into a bunch of processes, but we only need to touch ar_lowdelay — so is_al() reads /proc/self/comm and activates only if the process name matches. Second, the board is on glibc 2.25, so dlsym is nailed to the old version of the symbol, otherwise on a newer host you'd link against a too-fresh glibc:
__asm__(".symver dlsym, dlsym@GLIBC_2.17");__asm__(".symver dlsym, dlsym@GLIBC_2.17");Next, the constructor computes the horizontal scaling LUT (xmap[dest_x] -> src_x, plain nearest-neighbour) once and spins up a background receiver thread. Scaling is needed here because the board only has USB 2.0, which doesn't give enough throughput for a raw full-HD frame.
Receiver. A separate thread listens on TCP :9000 and, in an infinite loop, reads SRC_W*SRC_H*3/2 bytes at a time (an I420 frame) into a double buffer. The most important part for latency is this bit: after reading a frame, the thread asks the socket via ioctl(FIONREAD) how many bytes are already sitting in the buffer, and if whole frames have accumulated there — it fast-forwards through them, keeping only the freshest one:
for (;;) {
int avail = 0;
if (ioctl(cl, FIONREAD, &avail) != 0 || avail < (int)SRC_SZ) break;
if (read_frame(cl, dst) != 0) goto disc;
skipped++; /* dropped a stale frame */
}
__atomic_store_n(&cur, widx, __ATOMIC_RELEASE);for (;;) {
int avail = 0;
if (ioctl(cl, FIONREAD, &avail) != 0 || avail < (int)SRC_SZ) break;
if (read_frame(cl, dst) != 0) goto disc;
skipped++; /* dropped a stale frame */
}
__atomic_store_n(&cur, widx, __ATOMIC_RELEASE);This way a burst from the producer or a hiccup in the link never builds up a queue — the encoder always gets the newest frame, and latency stays around a single frame.
The interception itself. Our AR_MPI_VENC_SendFrame parses the incoming VIDEO_FRAME_INFO_S structure by the offsets the ai pulled out through reversing: +0x00 frameId, +0x04/08 W/H, +0x30/34/38 strides Y/U/V, +0x78/80/88 physical plane addresses, +0x90/98/a0 — virtual ones:
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 */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 */Next comes a check that the frame really is 1920x1080 and the pointers are alive, and we scale our I420 640x360 straight into the encoder buffer. There are a couple more optimizations here for the weak CPU: on the vertical axis the upscale maps several destination rows onto one source row, so we build the source row through the LUT (a cache-unfriendly operation) only once, and simply memcpy its duplicates:
int32_t srow = (int32_t)((uint32_t)r * SRC_H / OUT_H);
if (srow == prev) { memcpy(d, pd, OUT_W); } /* duplicate row - fast path */
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; }int32_t srow = (int32_t)((uint32_t)r * SRC_H / OUT_H);
if (srow == prev) { memcpy(d, pd, OUT_W); } /* duplicate row - fast path */
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; }The trick with chn: the encoder receives the picture over two channel-strips (0 and 1) — the top and bottom halves of the 1080p frame with a small overlap (h > 568 ? h — 568 : 0), so for each channel we fill only its own vertical region.
And the last mandatory touch — since we wrote into the buffer at a virtual address, but the hardware encoder reads it directly from memory, we have to flush the cache by physical address, otherwise the encoder will see stale data and the picture will be incomplete. We flush in 1 MB chunks (an API limitation):
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 */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 */After that we hand the frame to the real real_sf(chn, frame, a3, a4) — and the encoder now compresses our picture. That's it — a shim of about 200 lines.
Wrapping up
The upshot is that we have a great video modem that can transmit any video over a distance of up to 5 km. The best part — this solution runs off 5 volts and is compact in size, unlike the competitors that offer comparable video transmission.
If you'd like to reproduce the experiment or dig deeper into the system's binaries, here's the link to the GitHub project for you: https://github.com/XuliGan4eg2006/VR04_HD_video_injector
Also you can contact me in Telegram: https://t.me/circle_of_winds