Reverse engineering van een goedkope CarPlay-dongle
Ik kocht een draadloze CarPlay-dongle op AliExpress voor ongeveer twintig euro. Hij werkt, maar in het CarPlay-startscherm van mijn Opel Corsa uit 2017 passen maar acht apps per pagina, en er is nergens een instelling om dat te veranderen. Dus heb ik het ding uit elkaar gehaald.
De adapter meldt zichzelf als hardwaretype HW501. Hij wordt onder minstens een tiental merknamen verkocht, draait embedded Linux op een 32-bits RISC-V-SoC, heeft gesloten firmware en geen enkele publieke documentatie. Alles hieronder komt uit zijn eigen update-server, zijn eigen binaries en één zeer geduldige reserve-dongle.
Wat er precies in zit
Elk ELF-bestand in de firmware is 32-bits RISC-V (ELF machine 243), gelinkt tegen de ILP32D-loader /lib/ld-linux-riscv32-ilp32d.so.1. De hoofdapplicatie heet CPAAProxyEx en bevat een buildpad dat eindigt op libs/v821. Bluetooth loopt via een apart blueware-proces, discovery via mdnsd, en updates via een kleine HTTP-server genaamd UpdateServer die luistert op het eigen wifi-accesspoint van de dongle.
Die updater is de ingang. Zijn strings laten de hele updateprocedure zien: schrijf /tmp/appupdate/app.img naar /dev/by-name/app nadat een MD5 is vergeleken met de meegeleverde appmd5sum.txt. Geen handtekeningcontrole, alleen een checksum die de uitgever in hetzelfde archief meelevert.
De stock firmware binnenhalen
Het history-endpoint van de fabrikant noemt versies 131, 128, 127 en 126. De archief-URL's zijn voorspelbaar:
http://43.138.184.52/appupdate/hw501_update_v<VERSION>.tar126, 128 en 131 downloaden en verifiëren zonder problemen. 127 staat in de lijst, maar de server meldt update file not found, zowel via de directe URL als via de chunk-API. Grappig genoeg adverteert het lastversion-endpoint nog steeds 126 als nieuwste release, terwijl de geschiedenis tot 131 loopt. De webinterface van v131 wijst naar een tweede server, 114.132.94.163, die byte-identieke archieven serveert.
Elk archief bevat precies twee bestanden: app.img, een XZ-gecomprimeerde SquashFS, en appmd5sum.txt. Er zit geen kernel in, geen bootloader en geen volledige flashback-up. Dit zijn alleen updates van de applicatiepartitie, en juist daardoor is experimenteren te overleven: de stock updater is het pad terug.
Wat de fabrikant tussen releases veranderde
Alle drie de images uitpakken en elk bestand vergelijken maakt de release notes (die er niet zijn) alsnog concreet.
Tussen v126 en v128 verhuisde de Android Auto-implementatie uit de hoofdbinary naar een nieuwe gedeelde library. CPAAProxyEx krimpt van 2.393.408 naar 1.153.348 bytes, de .text-sectie van 1.685.944 naar 910.088 bytes, en het aantal gedefinieerde dynamische functiesymbolen daalt van 269 naar 60. Van de namen die verdwijnen, duiken er 373 weer op in de nieuwe libAutoProxy.so, inclusief de objecten van het Android Auto gal-protocol. Ook de build-identifier van de Bluetooth-stack schuift op:
v126: ZA_NAS_BT01,V2.7.2,20250611
v128: ZA_NAS_BT01,V2.9.5,20251028Tussen v128 en v131 wordt de discovery-stack in zijn geheel vervangen: mdnsd verdubbelt bijna, van 284.704 naar 559.264 bytes. De best leesbare wijziging zit in libiAP2Link.so, waar iAP2LinkQueueSendData groeit van 332 naar 410 bytes. In v128 roept die functie iAP2LinkAddPacketAfter aan en gaat ze verder ongeacht het resultaat. In v131 vergelijken de nieuwe instructies dat resultaat met 255, en bij een fout loggen ze de wachtrij, roepen ze iAP2PacketDelete aan en geven ze nul terug in plaats van door te gaan met een pakket dat nooit in de wachtrij kwam. Dat is een echte fix in de iPhone-linklaag, alleen zichtbaar in de disassembly.
Eén ding hebben ze kapotgemaakt: in de Engelse configuratiepagina van v131 roepen de updateknoppen nog steeds startUpdateApp(...) aan, maar de functiedefinitie is verwijderd. Op de Chinese pagina staat ze er nog wel. De Engelse updater-UI verwijst dus naar een functie die niet meer bestaat.
CarPlay meer iconen laten tonen
CarPlay bepaalt zijn startscherm niet aan de hand van pixels, maar van de fysieke schermafmetingen die de autoradio doorgeeft. De Corsa meldt 800x480 pixels bij 152x91 mm, en bij die maat geeft iOS je een raster van 4x2 en verbergt het de instelling Smart Display Zoom volledig.
De patch liegt dus over millimeters. Op virtueel adres 0x4dc74 in CPAAProxyEx onderschept een hook de display-property-getter op het pad waar de gecachte display-array van de autoradio wordt teruggegeven. De hook is bewust achterdochtig: hij eist precies één display-dictionary in de array en een exacte match op alle vier de waargenomen afmetingen. Alleen dan kopieert hij de dictionary, vervangt widthPhysical en heightPhysical, en geeft hij een nieuwe array met één element terug met het eigenaarschap dat de aanroeper verwacht. De originele objecten van de auto worden nooit overschreven. Pixelafmetingen, framerate, UUID, features en touch-metadata gaan onaangeroerd door, en elk foutpad valt terug op de oorspronkelijke return.
Drie profielen, getest in één auto:
- density125, meldt 190x114 mm: kleinere elementen en de Smart Display Zoom-schakelaar verschijnt eindelijk, maar het startscherm blijft 4x2.
- density137.5, meldt 209x125 mm: installeert en start op, layout ongetest.
- density150, meldt 228x137 mm: 5 kolommen bij 3 rijen, dus 15 apps per pagina in plaats van 8, op een onveranderd 800x480-scherm.
De build is net zo behoudend als de hook. De SHA-256 van de stock executable moet exact kloppen voordat er gepatcht wordt, de hook-instructies gaan in geverifieerde nulopvulling zodat het bestaande load-segment ter plekke wordt uitgebreid zonder secties te verplaatsen of de bestandslengte te wijzigen, en na het herbouwen van de SquashFS worden elk bestand, elke symlink, permissie, UID, GID en timestamp vergeleken met de stock listing. Alleen bin/CPAAProxyEx verschilt. De resulterende image is 4.472.832 bytes, ruim binnen de 5.242.880 bytes van de app-partitie die het kernellog meldt.
Het moment waarop ik er bijna eentje sloopte
Een apart experiment probeerde screen mirroring op de dongle zelf toe te voegen. Die image bevatte een supervisorproces dat in dezelfde procesgroep als UpdateServer terechtkwam, waardoor de opruimactie de updater middenin een update afschoot. Dat is precies het scenario dat je niet wil bij een apparaat dat je alleen via zijn eigen wifi kunt bereiken.
Wat de boel redde, was de stock diagnostics-handler, die shell-substituties in de opgegeven archiefnaam uitvoert. Daarmee heb ik, met een BusyBox die zo uitgekleed was dat setsid, command en base64 ontbraken, een RV32-helper van 324 bytes met alleen syscalls naar /tmp overgezet via octale printf, zijn checksum geverifieerd, en hem setsid laten aanroepen om de ongewijzigde stock UpdateServer op poort 8082 in een eigen sessie opnieuw te starten. Die overleefde de shutdown die de originele updater doodde, flashte een schone image tot 100%, en de readback van de partitie kwam overeen met de verwachte MD5. De dongle startte gewoon weer op.
Dat mirroring-archief is ingetrokken, en de tooling weigert de hash ervan nu expliciet.
Hoe het er nu voor staat
Het analysegereedschap is het stevige deel: een apparaatinspecteur en offline diagnoseverzamelaar die de firmware nooit aanraken, een downloader die structuur en checksums verifieert, een vergelijkingspijplijn die elk bestand tussen versies hasht, en geannoteerde RV32-disassembly. De density-patches werken, op precies één combinatie van adapter, auto en iOS-versie. Emulatie onder QEMU met Andes-instructieondersteuning draait de componenttests en brengt de stock app tot aan initialisatie, waarna hij op ontbrekende hardware stuit en crasht. Handig om hooks te testen, maar bepaald geen volledige apparaatemulator.
Alles staat op github.com/kacper-serewis/smartbox-re, met de gebruikelijke waarschuwing erbij: firmware van het internet flashen naar een apparaat dat aan je dashboard hangt is precies zo onverstandig als het klinkt, en elk schrijfgereedschap weigert te draaien op hardware die het niet als HW501 herkent.
Gecoördineerd door mij en geschreven met GPT-6-Astra.