shantanu.tech
← Writing
Nov 1, 2026·9 min read

Teaching a CarPlay Dongle to Remember Two Phones

My wireless CarPlay dongle only ever auto-connected to the last phone it saw. Unpacking its unsigned firmware, a 3.6 KB LD_PRELOAD shim that rotates through paired phones, the musl symbol that killed Bluetooth on the first flash, and repacking the image byte-for-byte.

embedded-linuxreverse-engineeringbluetooth

The problem

This week I patched the firmware on my wireless CarPlay dongle so it auto-connects to whichever paired phone is actually in the car, not just the last one it saw.

The dongle is a cheap LYLink unit (project name ly2517, firmware 23120718.2517.1). More than one phone gets used in this car. Whichever phone connected last “owned” the dongle: if a different phone got in, CarPlay just sat there waiting for a phone that wasn't there. The fix every forum suggests is to unpair and re-pair. That gets old fast.

I spent an evening with Claude Code taking the firmware apart to see what was going on. The answer turned out to be smaller and more interesting than I expected.

Opening up the update image

The way in was the vendor's own update file: there's no shell on the dongle, but the 6.6 MB update.img turned out to be a standard format with no signature.

Under the plastic it's an Allwinner V831 (ARM Cortex-A7) with an AIC8800 Wi-Fi/Bluetooth chip, running Tina Linux, Allwinner's OpenWrt fork. Updates are uploaded through a web page the dongle serves on its own Wi-Fi.

The first line of the file gave most of it away:

78ae6201e7cb244417a907859fb4b875  update.swu
070702...

Line 1 is plain md5sum output: a hash of everything after the newline, plus a filename. Everything after it is an SWUpdate package, which is just a cpio archive in the “new ASCII with CRC” format (magic 070702).

cpio is easy to parse by hand. Each file gets a 110-byte header of 13 hex fields (inode, mode, size, name length, checksum and so on), then its name, then its data, each padded to 4 bytes. The archive ends with an entry named TRAILER!!!. The “CRC” is only a 32-bit sum of the data bytes. Walking those headers gives this layout:

update.img                      6,584,877 bytes
├── "<md5>  update.swu\n"         hash of everything below
└── update.swu                    cpio archive, magic 070702
    ├── sw-description   3,186 B  install recipe (text)
    ├── customer         4.6 MB   squashfs 4.0, xz, 256 KB blocks, 101 inodes
    │   └── vendor apps: btapp, lylinkapp, blink.sh, Bluetooth stack, ...
    ├── kernel           1.9 MB   U-Boot uImage, Linux 4.9.118
    └── TRAILER!!!       + 140 bytes of padding

sw-description was the most useful file to read. The only checks in it are magic = "0xAA55A55A" and ly_project_name = "ly2517". There's no signature, no encryption and no per-image hash. It also spells out a three-stage install, which explained the odd reboot dance later:

  1. upgrade_recovery sets boot variables and reboots into the recovery partition.
  2. upgrade_kernel writes kernel to /dev/by-name/boot and customer to /dev/by-name/customer.
  3. upgrade_usr clears the update variables and reboots normally.

The root filesystem isn't in the update at all, so everything I could change lives in the customer partition.

To unpack it, I wrote swu.py, about 100 lines of Python. It checks the MD5 line, walks the cpio headers and writes each entry to a folder. It also saves each entry's metadata (inode, mode, timestamp) in an ORDER file and the trailing padding in TAIL. Before writing anything, it rebuilds the archive from what it just parsed and asserts that the result matches the original byte-for-byte:

$ python3 swu.py unpack "update (1).img" ./swu
ok: 3 entries, md5 verified, byte-exact round-trip, tail 140 bytes

That round-trip check was the safety net for everything after it. If the packer can reproduce the stock image exactly, then the only differences in a patched image are the ones I made on purpose.

From there, unsquashfs turns customer into a normal folder. file and objdump -T show the vendor programs are 32-bit ARM (EABI5, hard-float) binaries linked against musl's libc.so. Remember that musl detail; it comes back later.

Digging in: the dongle already remembers 9 phones

The dongle never forgot the other phones. It only ever tried to reconnect one of them.

The vendor apps in the customer partition tell the story: btapp runs Bluetooth, libGbtsTask.so is the Bluetooth stack, lylinkapp handles the CarPlay side, and blink.sh is the shell script that starts them all at boot.

Reading the binaries turned up three things:

  • Pairing was never the limit. The Bluetooth stack (libGbtsTask.so) keeps up to 9 bonded phones. maxPairDevSize = 9 is compiled into btapp, and pairing a second phone doesn't erase the first.
  • CarPlay doesn't care either. CarPlay's own keys live in /tmp/cp_keychain.bin, which sits in RAM and is wiped every boot.
  • Auto-connect is the bottleneck. On power-up, btapp runs a retry timer that calls one routine about 30 times. Each call reads a single lastaddr and pages only that phone. lylinkapp adds one “connect last device” (AT#CC) 13 seconds after boot. No other bonded phone is ever tried.

The fix: a 3.6 KB LD_PRELOAD shim

Instead of patching the vendor binary, I wrapped one function. libmultipair.so is an LD_PRELOAD library that intercepts read_last_addr(), the call the auto-connect routine uses to ask “which phone should I page?”

The trick is that other parts of btapp call the same function, and I didn't want to change their behaviour. So the shim checks its return address. Only when the caller is the auto-connect routine (btapp+0x1cbbc) does it step in:

  • Attempt 0: the last connected phone, same as stock.
  • Attempts 1, 2, …: each other bonded phone in turn.
  • Then: back to the last phone, and around again.

Every other caller gets the original function untouched. A 120-second gap between calls starts a fresh cycle.

The core of it is small:

int read_last_addr(bd_addr_t *out)
{
    void *ret = __builtin_return_address(0);
    if (!out || !from_autoconnect(ret))
        return real_read_last_addr(out);   // everyone else: stock behaviour

    // auto-connect caller: attempt 0 = last phone,
    // later attempts rotate through the other bonded phones
    ...
}

from_autoconnect() uses dl_iterate_phdr to find which loaded object the return address belongs to, and only matches when it's the main btapp binary at that exact offset. On any other firmware build the offset won't match, so the shim quietly does nothing.

The only changes to the image are the new .so and one edited line in app/blink.sh that starts btapp with the shim. The kernel and every other file are byte-identical to stock.

Packing it back up

One script, build.sh, goes from the stock image to a flashable one. The goal at each step is to change as little as possible, so the result differs from stock only where I meant it to.

  1. Cross-compile the shim. zig cc -target arm-linux-musleabihf -mcpu=cortex_a7 -shared -fPIC -O2 -s builds it for the dongle's CPU and libc. Zig ships its own cross toolchain, so I didn't need an ARM SDK.
  2. Check its imports. objdump -T lists every symbol the shim needs from the system. The build stops if any of them end in 64. That check came from the mistake in the next section.
  3. Unpack. swu.py unpack splits the image, then unsquashfs extracts customer. It runs under umask 0 so files keep their stock 0775 permissions. /dev/console can't be created without root, so it's skipped here and added back in step 5.
  4. Patch. Copy libmultipair.so into app/ and rewrite the one line in blink.sh that launches btapp. A short Python script asserts exactly one matching line exists, so an unexpected firmware fails loudly instead of being half-patched.
  5. Rebuild the squashfs with stock settings. Same compression and block size as the original, every file owned by root, and the console device added as a pseudo-file:
    mksquashfs customer swu/customer -noappend -comp xz -b 262144 \
        -all-root -no-xattrs -p 'dev/console c 600 0 0 5 1'
  6. Diff against stock. Compare the file listing (unsquashfs -lln) of the new partition with the original. The only differences should be the new .so and the size of blink.sh.
  7. Pack. swu.py pack writes the cpio entries back in their original order with their original inode, mode and timestamp, recalculates each entry's checksum, appends the 140-byte tail, and writes a fresh MD5 line on top.

The patched image is 6,588,185 bytes, 3,308 bytes bigger than stock. The kernel and sw-description are byte-identical. Since nothing is signed and sw-description has no per-image hash, a correct MD5 line is all the dongle needs to accept it.

The first flash killed Bluetooth

Version 1 flashed fine and then Bluetooth simply never came up. The status page showed "bt": null.

The cause was a libc version mismatch. I cross-compiled with zig cc against musl 1.2 headers. Since musl 1.2, time() is redirected to a 64-bit __time64 symbol (the Y2038 fix). The dongle's musl is older: 32-bit time_t, and it only exports plain time. The dynamic loader couldn't resolve __time64, refused the whole library, and took btapp down with it.

Version 2 fixed it three ways:

  1. Bind to the old symbol directly. The shim never includes <time.h> and declares time itself with an __asm__("time") label.
  2. Look up risky symbols at runtime. No stock binary on the dongle imports dl_iterate_phdr, so the shim fetches it with dlsym instead of linking it hard. If it's missing, the shim falls back to stock behaviour.
  3. Make the build and boot fail safe. build.sh now refuses any __*64 import. And blink.sh checks whether btapp is still alive 3 seconds after starting with the shim. If it isn't, it starts btapp again the stock way, so Bluetooth always comes up.

After flashing v2, phones paired normally again.

Flashing gotchas

The dongle's update page has a few surprises worth knowing before you try anything similar:

  • It doesn't reboot on its own. After upload, the status goes from 2 to 3 (“stage 1 done”) and then just sits there. You have to power-cycle it yourself. It then flashes in update mode for about 5 minutes and reboots again. Don't unplug it during that window.
  • Every update wipes the pair list. The first normal boot after any update formats /mnt/UDISK, which is where pairings live. Expect to “Forget device” on each phone and pair again.
  • Keep the stock image. Rolling back is just uploading the original update.img the same way. The recovery partition is never touched, which made experimenting much less scary.

What I learned this week

With the patch, the dongle tries the last phone first, then works through the others. Each absent phone costs about a 5-second page timeout, so a different phone should connect in roughly 10 to 40 seconds after the car starts.

  • Find the real limit before fixing the assumed one. I went in thinking “it only remembers one phone.” It remembered nine. The bug was in one retry loop.
  • LD_PRELOAD is a great scalpel for closed-source Linux devices. Wrapping one function, and only for one caller, beat binary-patching a stripped vendor executable.
  • Match the target's libc, not just its CPU. The ABI, float mode and architecture all matched, and it still failed on a single symbol name. Check imports with objdump -T before you flash.
  • Build the escape hatch first. The 3-second watchdog in blink.sh and a known-good stock image meant a bad build cost a reflash, not a brick.

Next up, maybe: the same chip has leftover hooks for Android Auto. That's a much bigger project, and a post for another week.