Reverse engineering a cheap CarPlay dongle
I bought a wireless CarPlay dongle on AliExpress for about twenty euros. It works, but the CarPlay launcher in my 2017 Opel Corsa only fits eight apps per page, and there is no setting anywhere to change that. So I took the thing apart.
The adapter reports its hardware type as HW501. It is sold under at least a dozen brand names, runs embedded Linux on a 32-bit RISC-V SoC, ships closed firmware and has no public documentation. Everything below comes from its own update server, its own binaries and one very patient spare unit.
What is actually inside
Every ELF file in the firmware is 32-bit RISC-V (ELF machine 243), linked against the ILP32D loader /lib/ld-linux-riscv32-ilp32d.so.1. The main application binary is CPAAProxyEx, with a build path ending in libs/v821. Bluetooth runs through a separate blueware process, discovery through mdnsd, and updates through a small HTTP server called UpdateServer that listens on the dongle's own Wi-Fi access point.
That updater is the way in. Its strings show the whole update procedure: write /tmp/appupdate/app.img to /dev/by-name/app after comparing an MD5 against the bundled appmd5sum.txt. No signature check, just a checksum the publisher ships in the same archive.
Getting the stock firmware
The vendor's history endpoint lists versions 131, 128, 127 and 126. The archive URLs are predictable:
http://43.138.184.52/appupdate/hw501_update_v<VERSION>.tar126, 128 and 131 download and verify. 127 is listed but the server reports update file not found for it, both over the direct URL and over the chunked API. Amusingly, the lastversion endpoint still advertises 126 as the newest release while the history list goes up to 131. The v131 web UI points at a second server, 114.132.94.163, which serves byte-identical archives.
Each archive contains exactly two files: app.img, an XZ-compressed SquashFS, and appmd5sum.txt. There is no kernel, no bootloader and no full flash backup in there. These are application-partition updates only, which is what makes experimenting survivable: the stock updater is the rollback path.
What the vendor changed between releases
Extracting all three images and diffing every file turns the release notes (there are none) into something concrete.
Between v126 and v128 the Android Auto implementation moved out of the main binary into a new shared library. CPAAProxyEx drops from 2,393,408 to 1,153,348 bytes, its .text section shrinks from 1,685,944 to 910,088 bytes, and its defined dynamic function symbols fall from 269 to 60. Of the names it loses, 373 reappear in the new libAutoProxy.so, including the Android Auto gal protocol objects. The Bluetooth stack build identifier moves too:
v126: ZA_NAS_BT01,V2.7.2,20250611
v128: ZA_NAS_BT01,V2.9.5,20251028Between v128 and v131 the discovery stack is replaced wholesale: mdnsd nearly doubles, from 284,704 to 559,264 bytes. The most legible change is in libiAP2Link.so, where iAP2LinkQueueSendData grows from 332 to 410 bytes. In v128 it calls iAP2LinkAddPacketAfter and carries on regardless of the result. In v131 the new instructions compare that result against 255, and on failure log the queue state, call iAP2PacketDelete and return zero instead of continuing with a packet that was never queued. That is a real fix in the iPhone link layer, visible only in the disassembly.
One thing they broke: in the v131 English configuration page, the update buttons still call startUpdateApp(...), but the function definition was deleted. The Chinese page still has it. So the English updater UI references a function that no longer exists.
Making CarPlay show more icons
CarPlay does not lay out its launcher from pixels. It uses the physical display size the head unit reports. The Corsa reports 800x480 pixels at 152x91 mm, and at that size iOS gives you a 4x2 grid and hides the Smart Display Zoom setting entirely.
So the patch lies about millimeters. At virtual address 0x4dc74 in CPAAProxyEx, a hook intercepts the display-property getter on the path where the cached head-unit display array is returned. It is deliberately paranoid: it requires exactly one display dictionary in the array and an exact match on all four observed dimensions. Only then does it copy the dictionary, replace widthPhysical and heightPhysical, and return a new single-element array with the ownership the caller expects. The car's original objects are never written to. Pixel dimensions, frame rate, UUID, features and touch metadata all pass through untouched, and every failure path falls back to the original return.
Three profiles, tested on one car:
- density125, reporting 190x114 mm: smaller items and the Smart Display Zoom toggle finally appears, but the launcher stays 4x2.
- density137.5, reporting 209x125 mm: installs and boots, layout untested.
- density150, reporting 228x137 mm: 5 columns by 3 rows, so 15 apps per page instead of 8, on an unchanged 800x480 screen.
The build is as conservative as the hook. The stock executable's SHA-256 must match exactly before patching, the hook instructions go into verified zero padding so the existing load segment is extended in place without moving sections or changing file length, and after rebuilding the SquashFS every file, symlink, permission, UID, GID and timestamp is compared against the stock listing. Only bin/CPAAProxyEx differs. The resulting image is 4,472,832 bytes, comfortably inside the 5,242,880-byte app partition the kernel log reports.
The part where I nearly killed one
A separate experiment tried to add screen mirroring on the dongle itself. That image shipped a supervisor process which ended up in the same process group as UpdateServer, so its cleanup killed the updater in the middle of an update. That is exactly the failure mode you do not want in a device you can only reach over its own Wi-Fi.
What saved it was the stock diagnostics handler, which expands shell substitutions in the archive filename it is given. Through that, with a BusyBox so stripped it had no setsid, no command and no base64, I transferred a 324-byte syscall-only RV32 helper into /tmp using octal printf, verified its checksum, and used it to call setsid and re-launch the unmodified stock UpdateServer on port 8082 in its own session. It survived the shutdown that killed the original one, flashed a clean image to 100%, and the partition readback matched the expected MD5. The dongle rebooted normally.
That mirroring archive is withdrawn, and the tooling now refuses its hash by name.
Where it stands
The analysis tooling is the solid part: a device inspector and offline diagnostic collector that never touch firmware, a downloader that verifies structure and checksums, a comparison pipeline that hashes every file between versions, and annotated RV32 disassembly. The density patches work, on exactly one combination of adapter, car and iOS version. Emulation under QEMU with Andes instruction support runs the component tests and gets the stock app as far as initialization before it hits missing hardware and segfaults, which is useful for testing hooks and nothing like a full device emulator.
Everything is at github.com/kacper-serewis/smartbox-re, with the usual warning attached: flashing firmware from the internet onto a device wired into your dashboard is exactly as unwise as it sounds, and every write tool refuses to run against hardware it does not recognise as HW501.
Coordinated by me and written with GPT-6-Astra.