<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Ahmet Yusuf Birinci</title>
<link>https://ahmetbirinci.dev/</link>
<description>Notes on building software, breaking hardware, and the stuff in between.</description>
<language>en</language>
<item>
<title>the midnight garage</title>
<link>https://ahmetbirinci.dev/posts/the-midnight-garage</link>
<guid>https://ahmetbirinci.dev/posts/the-midnight-garage</guid>
<pubDate>Fri, 07 Aug 2026 00:00:00 +0000</pubDate>
<description>Every night until midnight in a ground-floor garage, locking and unlocking my car while hex scrolled on a laptop. Then the carbon monoxide caught up.</description>
<content:encoded><![CDATA[<p>December 2025. I&#39;m sitting in my Ioniq 6 at 11:40 PM. It&#39;s cold enough that I&#39;m wearing two layers and thick socks. The car&#39;s heater is off because I need to capture the CAN bus in a known idle state. I&#39;ve locked and unlocked the doors about forty times tonight, each time watching a terminal scroll hex on my laptop. The car is fine with it. I&#39;m the one whose fingers are getting stiff.</p>
<p>The garage is the ground floor of a residential complex, five apartment blocks joined by an elongated structure, four entrances per block. The garage spans the full length at level zero, open-air in the summer but sealed with roller shutters in winter. Apartments start from the first floor. It&#39;s not underground. It&#39;s just the bottom of a very long building, and right now every shutter is down because it&#39;s December.</p>
<p>This has been my routine for weeks. Come home from work, eat, go down to the garage, sit in the car with a laptop until midnight. Sometimes later. The project doesn&#39;t have a deadline or a customer. Nobody asked for it. I&#39;m doing it because the car has a CAN bus with 397 IDs on it and I&#39;ve decoded 34 of them, and I want to know what the other 363 do.</p>
<p>The problem is where the hours come from.</p>
<h2>the feed</h2>
<p>I did the math at some point in November. Screen time report: Instagram, 45 minutes a day. Twitter, 30 minutes. That&#39;s 75 minutes of scrolling every day, or just over 8 hours a week. I wasn&#39;t posting, creating, or networking. I was consuming. Reels, threads, takes, drama. None of it was making me better at anything.</p>
<p>75 minutes a day is exactly one garage session. Every evening I spent on the couch scrolling was an evening I didn&#39;t spend in the car with a CAN sniffer.</p>
<p>I didn&#39;t delete the apps. I installed One Sec, an app that intercepts the launch and makes you wait before it lets you in. Every time you try to open Instagram, it holds you for a breath. Try again too soon and the wait gets longer. Twitter at thirty minutes a day wasn&#39;t the problem; Instagram at two hours was. The friction was enough. Within a week I&#39;d stopped opening Instagram almost entirely. Not because it blocked me, but because the pause made me realize I didn&#39;t actually want to be there. I just had a habit of tapping the icon.</p>
<h2>what the time bought</h2>
<p>The numbers are boring but they&#39;re real. In the ten weeks after throttling the feed, I:</p>
<ul>
<li>Decoded the full BMS PID set (44 PIDs, 35+ fields)</li>
<li>Captured and catalogued 37 in-car CAN tests in a single night session</li>
<li>Cracked the door lock checksum table (three separate CAN channels, weeks of captures)</li>
<li>Wrote a PID scanner that mapped the BMS address space</li>
<li>Built a working real-time battery monitor</li>
<li>Started designing the custom board</li>
</ul>
<p>All of that happened in evening sessions between 9 PM and midnight. If I&#39;d kept scrolling, maybe I&#39;d have gotten to half of it by February.</p>
<p>The cold helped, weirdly. The garage was uncomfortable enough that there was no temptation to do anything except work. No couch, no TV, no distractions. Just me, the car, and the laptop. The discomfort was a forcing function.</p>
<h2>the air</h2>
<p>The thing about an EV is that it doesn&#39;t produce exhaust. You can sit in it for hours with the windows up and the air is fine. I&#39;d internalized this so completely that I forgot where I was parked.</p>
<p>The garage has a hundred-odd parking spots. Most of the cars in it are petrol or diesel. People come home in the evening, pull in, idle for a minute, shut off. In the summer the shutters are open and none of it matters. In December they&#39;re all closed. CO doesn&#39;t smell, doesn&#39;t sting, doesn&#39;t announce itself. It just accumulates.</p>
<p>I noticed it as headaches first. Dull, persistent, worse at the end of a session. I blamed the cold, the posture, staring at hex for three hours. Then one night I got back upstairs and the headache didn&#39;t go away. I felt nauseous. My chest was tight. I sat on the bathroom floor for twenty minutes before it passed.</p>
<p>I went to the doctor. Bloodwork came back with elevated carboxyhemoglobin. Carbon monoxide poisoning, not acute enough to land me in a hospital, but enough to show up on a test. The source was obvious once I thought about it: three hours a night in a sealed garage where ICE cars had been running earlier that evening, the CO hanging in the still air with nowhere to go.</p>
<p>After that I started opening the nearest shutter before every session, even when it was freezing. The headaches stopped. I kept going to the garage.</p>
<h2>the car survived</h2>
<p>I locked and unlocked the car thousands of times over these months. Folded and unfolded the mirrors hundreds of times. Raised and lowered every window. Turned the signals on and off, cycled the ambient lights, toggled the climate. Pressed the brake pedal while monitoring CAN frames. Started and stopped charging sessions to capture state transitions.</p>
<p>The car handled all of it without complaint. No warning lights, no weird behavior, no dealer visit. Hyundai builds these things to take abuse from a factory test line. My garage sessions were gentle by comparison.</p>
<h2>Reddit</h2>
<p>Around the same time, I started spending more time on Reddit. Different dynamic entirely. Reddit has r/ReverseEngineering, r/embedded, r/esp32, communities where people actually discuss CAN bus protocols and ELM327 quirks and ESP32 memory architectures. The content is technical. The comments are useful. When someone posts a teardown or a reverse engineering writeup, the responses are &#34;what tool did you use&#34; and &#34;have you tried this approach,&#34; not heart emojis.</p>
<p>I didn&#39;t post much. Mostly lurked and read. But the information density per minute spent was incomparably higher than anything I&#39;d gotten from the feed. I learned about OVMS, about the Hyundai EGMP community&#39;s existing PID research, about ISO-TP framing edge cases that would have taken me weeks to discover on my own.</p>
<p>Trading consumption for a technical community wasn&#39;t a lateral move. It was an upgrade.</p>
<h2>the actual lesson</h2>
<p>Throttling social media didn&#39;t give me superpowers. It gave me 75 minutes a day. That&#39;s it. But 75 minutes a day, applied consistently to a single problem for ten weeks, compounds into something that looks from the outside like obsession or talent. It&#39;s neither. It&#39;s just time, redirected.</p>
<p>The project is nine months old now. Every feature, the CAN decoder, the custom board, the Go backend, the iOS app, the signed OTA pipeline, was built in reclaimed evenings. Not weekends, not vacations, not sabbaticals. Evenings. The ones that used to belong to the feed.</p>
<p>In the next post: I solder a custom PCB, kill it with a capacitor, and learn that 8 KB of stack is not enough for TLS on an ESP32.</p>
]]></content:encoded>
</item>
<item>
<title>the XOR method</title>
<link>https://ahmetbirinci.dev/posts/the-xor-method</link>
<guid>https://ahmetbirinci.dev/posts/the-xor-method</guid>
<pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate>
<description>From a BLE OBD dongle in an apartment garage to decoding 44 BMS PIDs, cracking a door lock checksum, and discovering the BCM&#39;s internal lock channel. The first two months.</description>
<content:encoded><![CDATA[<p>October 22, 2025. An apartment garage. I&#39;m sitting in my Ioniq 6 with a $15 Vlink ELM327 BLE dongle plugged into the OBD-II port and a MacBook running a Python script I wrote twenty minutes ago. The script connects to the dongle over Bluetooth, sends AT commands to initialize the ELM327, and requests UDS PID <code>0x220101</code> from the BMS at CAN address <code>0x7E4</code>. The response comes back as nine CAN frames, ISO-TP multi-frame, 62 bytes total. I have no idea what any of the bytes mean.</p>
<p>Four days later I&#39;ll have decoded 35 fields across 8 PIDs, built a real-time battery monitor, and written a PID scanner that maps the entire BMS address space. A month after that I&#39;ll have cracked a door lock checksum and found the CAN channel the BCM uses to lock the car internally, without the key fob.</p>
<p>This is the story of the first two months.</p>
<h2>talking to the BMS</h2>
<p>The Ioniq 6&#39;s diagnostic bus (D-CAN) is accessible through the OBD-II port on <strong>pins 3 and 11</strong>, not the standard pins 6 and 14. This non-standard assignment is something I&#39;d rediscover the hard way months later when wiring custom hardware. The BMS listens at CAN address <code>0x7E4</code> and responds on <code>0x7EC</code>, at 500 kbps. Communication uses UDS (ISO 14229) over ISO-TP (ISO 15765-2), the same protocol stack that dealer tools and Car Scanner use.</p>
<p>The ELM327 handles the low-level CAN framing. From the Python side, the sequence is:</p>
<figure class="codeblock"><div class="codeblock__bar"><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>ATZ          # reset
ATE0         # echo off
ATH1         # show CAN headers
ATSP6        # ISO 15765-4, 500 kbps, 11-bit
ATSH7E4      # set TX header to BMS address
2201 01      # UDS ReadDataByIdentifier, PID 0x0101
</code></pre></figure>
<p>The response arrives as a First Frame (<code>10 3E 62 01 01 ...</code>) followed by eight Consecutive Frames (<code>21 ...</code> through <code>28 ...</code>). After ISO-TP reassembly and stripping the 3-byte UDS header (<code>62 01 01</code>), you get 59 bytes of raw BMS data.</p>
<p>Those 59 bytes contain everything: pack voltage, current, power, state of charge, module voltages, coolant temperatures, operating time, and a set of status flags. The problem is figuring out which bytes mean what.</p>
<h2>the XOR method</h2>
<p>I had two captures: one with the car idle, one while AC charging. Comparing them byte-by-byte revealed exactly which bytes changed between states.</p>
<p>Byte 4 shifted from <code>0x32</code> (50 decimal) to <code>0x34</code> (52). Divided by 2: 25.0% to 26.0%. That&#39;s SoC, and the formula is <code>byte / 2 = percent</code>. Confirmed against the dashboard.</p>
<p>Byte 11 was the interesting one. Idle: <code>0x03</code> (<code>0b00000011</code>). Charging: <code>0x81</code> (<code>0b10000001</code>). Bit 7 flipped from 0 to 1 during charging and back to 0 when idle. That&#39;s the HV charging flag. Bit 0 toggles independently, BMS ignition/awake status.</p>
<figure class="codeblock"><div class="codeblock__bar"><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>byte 11 map:
  bit 7 (0x80): HV_Charging  -- 1 = charging active
  bit 1 (0x02): unknown      -- toggles during idle
  bit 0 (0x01): BMS ignition -- 1 = BMS awake
</code></pre></figure>
<p>Bytes 5-8 were all zeros when idle and populated during charging, available charge power and current limits that the BMS only reports when a charger is connected.</p>
<p>This XOR-compare approach turned out to be the single most useful technique in the entire project. Capture two known states, diff the bytes, and the changed ones are your signals. I&#39;d use it hundreds more times over the next nine months.</p>
<h2>the PID scanner</h2>
<p>The BMS responds to UDS Service <code>0x22</code> (ReadDataByIdentifier) with 16-bit PIDs. The address space is <code>0x0000</code> to <code>0xFFFF</code>. I wrote <code>pid_scanner.py</code> to walk through ranges and record which PIDs return a positive response (<code>0x62</code>) versus a Negative Response Code.</p>
<p>Result: <strong>44 responding PIDs</strong> out of the ranges I scanned. Most of the interesting ones clustered in the <code>0x2201xx</code> range:</p>
<div class="table-scroll"><table><thead><tr><th>PID</th><th>Size</th><th>Content</th></tr></thead><tbody>
<tr><td><code>0x220101</code></td><td>59 B</td><td>Pack status: SoC, voltage, current, power, status flags, operating time</td></tr>
<tr><td><code>0x220102</code></td><td>36 B</td><td>Cell voltage group 1</td></tr>
<tr><td><code>0x220103</code></td><td>36 B</td><td>Cell voltage group 2</td></tr>
<tr><td><code>0x220104</code></td><td>36 B</td><td>Cell voltage group 3</td></tr>
<tr><td><code>0x220105</code></td><td>46 B</td><td>SoH, coolant temps, power limits, cumulative energy</td></tr>
<tr><td><code>0x220133</code></td><td>14 B</td><td>Battery module temperatures B01-B07</td></tr>
<tr><td><code>0x220135</code></td><td>68 B</td><td>Per-module SoH (modules 1-16)</td></tr>
<tr><td><code>0x220136</code></td><td>68 B</td><td>Per-module SoH (modules 17-28)</td></tr>
<tr><td><code>0x2201F2</code></td><td>180 B</td><td>BMS charging time estimates at 50/100/175 kW</td></tr>
</tbody></table></div>
<p>The cell voltage PIDs contain unsigned 16-bit big-endian values scaled by <code>/ 50</code> to get volts. The 54 kWh pack has 96 cells in series (~3.6V nominal each, ~400V total). Between these three PIDs and a few similarly-sized ones I&#39;d discover later, I could read every cell individually.</p>
<h2>cracking the temperature encoding</h2>
<p>Module temperatures (PID <code>0x220133</code>, bytes 4-10) were the hardest to decode. The raw values (117, 118, 119) didn&#39;t map to any obvious temperature scale. Not Celsius, not Fahrenheit, not tenths of either.</p>
<p>I worked backwards from Car Scanner. It showed module B01 at 20C. The raw byte was <code>0x76</code> (118). The offset: <code>118 - 98 = 20</code>. I checked the other six modules. All matched with the same <code>-98</code> offset.</p>
<figure class="codeblock"><div class="codeblock__bar"><span class="codeblock__lang">python</span><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>&#34;battery_module_temps&#34;: {
    &#34;decode&#34;: lambda b: [b[i] - 98 for i in range(4, min(11, len(b))) if b[i] &gt; 50]
}
</code></pre></figure>
<p>Car Scanner showed 5 module temperatures. My decoder showed 7. First small win.</p>
<h2>what the script could do</h2>
<p>By the end of October 26 I had a working real-time BMS monitor that decoded more fields than I expected. The script could read individual cell voltages across multiple PIDs, 7 module temperatures, 28 per-module SoH values, pack-level power limits, cumulative energy counters, and round-trip efficiency, on top of the basics like SoC, voltage, current, and charging status.</p>
<p>The BMS&#39;s own charging time predictions came from PID <code>0x2201F2</code>. Bytes 8-9: time to 80% at 50 kW. Bytes 21-22: time at 100 kW. Bytes 114-115: time at 175 kW. These are the BMS&#39;s internal estimates based on battery temperature and SoC, not a simple energy-remaining calculation.</p>
<p>All of this from a $15 dongle and a Python script running on a laptop in an apartment garage. No special hardware, no dealer tools, no leaked documentation.</p>
<h2>switching buses</h2>
<p>Five days later, October 31. I&#39;d been reading BMS data through UDS queries on the D-CAN bus. But diagnostic queries are request-response, the BMS only talks when you ask. For a telemetry device that monitors the car passively, I needed the body CAN bus (B-CAN), where ECUs broadcast state continuously without being asked.</p>
<p>I wired an ESP32-S3 dev board with a CAN transceiver to the B-CAN lines on the OBD port and started capturing.</p>
<p><img src="/posts/the-xor-method/esp32-dev-board.jpg" alt="A LilyGo T-SIM7080G board wired to a CAN transceiver breakout and OBD connector. The first hardware to touch the car&#39;s B-CAN bus." loading="lazy" /></p>
<p>The first baseline capture showed <strong>194 unique CAN IDs</strong> broadcasting continuously, a mix of standard 11-bit and extended 29-bit identifiers.</p>
<p>That night I sat in the car and ran 37 systematic tests. Turn on the left signal, capture 10 seconds. Turn it off, capture. Lock the doors, capture. Open the trunk, capture. Change the gear. Press the brake. Cycle the ambient lighting. Each test compared against the baseline to find which IDs changed and how.</p>
<p>Three new CAN IDs appeared that weren&#39;t in the baseline:</p>
<div class="table-scroll"><table><thead><tr><th>ID</th><th>Trigger</th><th>Type</th></tr></thead><tbody>
<tr><td><code>0x05F</code></td><td>Ambient light control</td><td>Periodic while active</td></tr>
<tr><td><code>0x4AD</code></td><td>Ambient light brightness</td><td>Periodic while active</td></tr>
<tr><td><code>0x4D6</code></td><td>Door lock/unlock</td><td>Event-only (single shot)</td></tr>
</tbody></table></div>
<p><code>0x4D6</code> was the most interesting. It appeared <em>only</em> during lock/unlock operations -- an event frame, not a periodic broadcast. That made it a candidate for replay: if the car sends this frame when you press the lock button, maybe sending it yourself would lock the car.</p>
<p>Turn signals decoded cleanly. CAN ID <code>0x413</code>, byte 2: <code>0x10</code> = left, <code>0x40</code> = right. Byte 0 is a counter/checksum.</p>
<h2>the door lock problem</h2>
<p>November 17. I&#39;d been trying to lock the car via CAN injection for three weeks. The first target was <code>0x4D6</code>, the event frame from the inside door lock button.</p>
<p>The frame format: <code>[checksum] [counter] [command] [0x00 x5]</code>. Counter increments by <code>0x10</code> per action (<code>0x00, 0x10, 0x20, ... 0xF0, 0x00</code>). Command <code>0x01</code> = lock, <code>0x04</code> = unlock. The checksum is a per-counter lookup value, not algorithmic.</p>
<p>I built a lookup table from captured frames, tracked the live counter by monitoring <code>0x4D6</code> on the bus, and injected frames with the correct next-counter and checksum.</p>
<p>It worked. The doors locked. But the security system didn&#39;t arm. No hazard flash, no mirror fold, no &#34;armed&#34; indicator. Just a mechanical click. For a product, this was useless, you need the alarm armed, not just the latches engaged.</p>
<h2>the 0x4E4 dead end</h2>
<p>The key fob uses CAN ID <code>0x4E4</code>. When you press the lock button on the fob, the car locks <em>and</em> the alarm arms. So I went after <code>0x4E4</code>.</p>
<p>Same approach: capture fob lock/unlock events, build a checksum lookup table, track the counter, inject. I captured 25 out of 48 possible checksums (the table has 16 counter values times 3 commands). Good enough for a test.</p>
<p>I injected a valid frame. Nothing happened. Tried again with a different counter. Nothing. Tried every counter value I had a checksum for. The car ignored all of them.</p>
<p>The <code>0x4E4</code> channel requires RF authentication. The BCM checks that the physical key fob is nearby via its 433 MHz radio before accepting lock commands on this CAN ID. No amount of correct checksums will work without the fob present. The CAN frame is a <em>notification</em> from the fob&#39;s receiver to the BCM, not a command the BCM blindly executes.</p>
<p>Three weeks of checksum capture, wasted. Or so I thought.</p>
<h2>the auto-lock insight</h2>
<p>November 18. I was thinking about how aftermarket auto-lock modules work. There are commercial products that plug into the Ioniq&#39;s B-CAN and lock the car 10 seconds after the driver exits, without the key fob nearby. They use only CAN-H and CAN-L wires. No RF, no key fob relay, no magic.</p>
<p>Then the obvious question: <em>when does the car lock itself without the fob?</em></p>
<p>Answer: auto-lock. If you unlock the car with the fob but don&#39;t open a door within 30 seconds, the BCM re-locks it automatically. The fob isn&#39;t nearby when this happens, you walked away, the car decided to re-lock on its own.</p>
<p>I captured the auto-lock sequence. The BCM sends the lock command on **CAN ID <code>0x392</code>**, not <code>0x4E4</code>. Same frame format: <code>[checksum] [counter] [command]</code>. But <code>0x392</code> is the BCM&#39;s own internal command channel. It doesn&#39;t check for RF authentication because <em>it is the BCM</em>, the module that would do the checking.</p>
<p>I searched every capture file I&#39;d accumulated over the previous month. Between fob operations, door button presses, auto-lock events, and baseline captures, I found checksums for <strong>14 out of 16</strong> counter values for the LOCK command. 87.5% coverage from data I already had.</p>
<figure class="codeblock"><div class="codeblock__bar"><span class="codeblock__lang">cpp</span><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>const uint8_t CHECKSUM_TABLE_392[16][3] = {
    //        IDLE   LOCK   UNLOCK
    /* 0x00 */ {0xAC, 0xEA, 0xFF},
    /* 0x10 */ {0xFF, 0x53, 0x10},
    /* 0x20 */ {0xC3, 0x76, 0xC6},
    /* 0x30 */ {0x7A, 0x2F, 0x7F},
    /* 0x40 */ {0x72, 0x96, 0xFF},
    /* 0x50 */ {0xCB, 0xD3, 0xFF},
    /* 0x60 */ {0xFF, 0x6A, 0xFF},
    /* 0x70 */ {0xA4, 0xFF, 0xFF},  // LOCK missing
    /* 0x80 */ {0xFF, 0x4B, 0xFF},
    /* 0x90 */ {0xB4, 0x08, 0xFF},
    /* 0xA0 */ {0xFF, 0x24, 0x67},
    /* 0xB0 */ {0xDB, 0x9D, 0xFF},
    /* 0xC0 */ {0xFF, 0xC4, 0xFF},
    /* 0xD0 */ {0xFF, 0x31, 0xFF},
    /* 0xE0 */ {0xFF, 0xFA, 0xFF},
    /* 0xF0 */ {0xFF, 0xFF, 0xFF},  // all missing
};
</code></pre></figure>
<p>I injected a LOCK command on <code>0x392</code> with the correct counter and checksum. The doors locked mechanically, same as <code>0x4D6</code>. I also got mirror folding to work through a combination of <code>0x392</code> and <code>0x4D6</code>. But it wasn&#39;t reliable. The full alarm arm (hazard flash, interior indicator, the works) never happened consistently. The BCM accepted the lock command but wouldn&#39;t fully arm the security system the way a fob press does.</p>
<p><code>0x4D6</code> and <code>0x392</code> turned out to be the two most reliably injectable CAN IDs on the bus. Both produce a mechanical lock. Neither reliably arms the alarm. The fob channel (<code>0x4E4</code>) remains gated behind RF authentication. Getting the full &#34;fob-quality&#34; lock from CAN injection alone is still an unsolved problem on this car.</p>
<h2>three channels, one lesson</h2>
<div class="table-scroll"><table><thead><tr><th>CAN ID</th><th>What it does</th><th>RF required</th><th>Mechanical lock</th><th>Full alarm arm</th></tr></thead><tbody>
<tr><td><code>0x4D6</code></td><td>Inside door button</td><td>No</td><td>Yes</td><td>No</td></tr>
<tr><td><code>0x4E4</code></td><td>Key fob / outside handle</td><td>Yes</td><td>--</td><td>Yes</td></tr>
<tr><td><code>0x392</code></td><td>BCM internal command</td><td>No</td><td>Yes</td><td>Not reliably</td></tr>
</tbody></table></div>
<p>The month-long door lock saga didn&#39;t end in a clean victory. But the insight that made <code>0x392</code> findable, <em>when does the car do this thing by itself?</em>, turned out to be the most important question of the entire project. Whatever channel the car&#39;s own BCM uses internally is the one that doesn&#39;t need external authentication. That principle would apply to almost every problem I&#39;d hit later.</p>
<p>By the end of November I had passive CAN decoding of body state, UDS access to the full BMS, and mechanical door locking without the fob. The OBD dongle and Python scripts were about to be replaced by custom hardware. But that&#39;s the next post.</p>
<p>In the next post: I solder a custom PCB, kill it with a capacitor, and learn that 8 KB of stack is not enough for TLS on an ESP32.</p>
]]></content:encoded>
</item>
<item>
<title>why I&#39;m building my own vehicle telemetry system</title>
<link>https://ahmetbirinci.dev/posts/why-own-telemetry</link>
<guid>https://ahmetbirinci.dev/posts/why-own-telemetry</guid>
<pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate>
<description>My car shipped without its telematics modem. Nine months later I have a custom board, a Go backend, and an iOS app. Series intro.</description>
<content:encoded><![CDATA[<p>I own a Hyundai Ioniq 6. Outside Turkey the car ships with BlueLink, a factory telematics modem that lets you check charge status, lock doors, and precondition the cabin from your phone. Hyundai Turkey stripped BlueLink from every Ioniq it imported, citing BTK telecom regulations. No modem, no app, no remote anything. As of this writing the facelifted Ioniq 6 still hasn&#39;t launched here, so no Turkish Ioniq owner has ever used BlueLink.</p>
<p>That gap, and the fact that the head unit runs open-source software, made me curious enough to start poking at the car.</p>
<h2>the gap</h2>
<p>Remote vehicle access is table stakes now. Tesla set the expectation, and everyone from BMW to BYD followed. How much charge do I have? Are the doors locked? Where did I park? Is a window open? These are questions that most modern EVs answer from a phone. Mine answers none of them unless I&#39;m sitting in it.</p>
<p>The car&#39;s CAN buses carry hundreds of signals: individual cell voltages, battery health, coolant temperatures, door states, window positions, charge connector status. The driver&#39;s display shows maybe six of these. You can see more with a BLE OBD dongle and an app like Car Scanner, but only while the phone is in Bluetooth range with the ignition on. That&#39;s diagnostics, not telemetry.</p>
<p><img src="/posts/why-own-telemetry/elm327-pcb.jpg" alt="The inside of a $15 ELM327 OBD dongle. This is where it started." loading="lazy" /></p>
<p>What I wanted was something closer to what BlueLink would have been: a device that stays in the car, connected over LTE, reporting state whether I&#39;m in the cabin or across the city.</p>
<h2>what exists, and why it wasn&#39;t enough</h2>
<p><strong>OVMS</strong> is the closest thing to what I want. Open source, community-driven, supports dozens of vehicles. But there&#39;s no Ioniq 6 module, the hardware is a generation behind, and adding a new vehicle means reverse engineering the CAN protocol anyway. If I have to do the RE work regardless, I&#39;d rather build on hardware I control.</p>
<p><strong>Commercial fleet solutions</strong> target companies with 500-truck fleets. Priced accordingly, designed accordingly. Individual EV owners are not the customer.</p>
<p><strong>ELM327 dongles</strong> are $15 and work well for what they are. But they&#39;re passive -- phone must be nearby -- have no LTE, no storage, no autonomy. They&#39;re a diagnostic tool, not a telemetry platform.</p>
<p>Nothing I found did what I wanted without requiring either a subscription or my location history sent to someone else&#39;s server.</p>
<h2>what I&#39;m building</h2>
<p>An ESP32-S3 board with a CAN transceiver, LTE modem, and GPS, roughly the size of a phone. It lives behind the dashboard, wired to the OBD port, always on.</p>
<p><img src="/posts/why-own-telemetry/board-in-car.jpg" alt="The board installed in the car, wired to the CAN bus behind the dashboard. The blue LEDs mean it&#39;s alive and talking." loading="lazy" /></p>
<p>The firmware is C on ESP-IDF. The backend is Go. The iOS app talks to the device over BLE when nearby and to the backend over HTTPS when not. OTA firmware updates are signed and delivered over LTE.</p>
<p>The car speaks CAN bus and UDS. Every signal the system decodes was reverse engineered by hand: driving with a laptop, capturing frames, correlating byte changes against physical state, validating against known-good sources. There is no public DBC file for this car. The Ioniq 6&#39;s B-CAN carries 397 distinct CAN IDs. The firmware currently decodes 34 of them.</p>
<p>The details of how all of this works are the subject of the rest of this series.</p>
<h2>why now</h2>
<p>It&#39;s been nine months. I started with a $15 BLE dongle and a Python script reading OBD PIDs in a parking lot. That turned into passive CAN decoding, then a custom PCB, then a Go backend, then an iOS app, then signed OTA updates, then a fleet management console, then secure boot with burned eFuses.</p>
<p>Along the way I cracked a CRC polynomial only to have the BCM ignore my injected frames. I desoldered a capacitor to fix a PSRAM bus conflict. I discovered that my car&#39;s OBD port has non-standard pin assignments. I mined 2.1 GB of CAN logs with a correlation scanner and found a speed signal with r=0.997. I bricked a board with a bad OTA and watched the boot-loop rollback save it.</p>
<p><img src="/posts/why-own-telemetry/ios-app-final.png" alt="The iOS app today. 3D model, live range, door/lock state, gear position -- all decoded from raw CAN frames." loading="lazy" /></p>
<p>The project has been closed source so far. I&#39;m planning to open parts of it up. But first, the story.</p>
<p>In the next post: I sit in my car at night with a terminal full of hex, and by morning I&#39;ve decoded more battery data than Car Scanner shows.</p>
]]></content:encoded>
</item>
<item>
<title>security research on the HomePod Mini: from iBoot shell to AirPlay protocol analysis</title>
<link>https://ahmetbirinci.dev/posts/homepod-mini-security-research</link>
<guid>https://ahmetbirinci.dev/posts/homepod-mini-security-research</guid>
<pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
<description>First publicly documented iBoot recovery shell session on a HomePod Mini, with firmware analysis and AirPlay protocol research.</description>
<content:encoded><![CDATA[<p>The Apple HomePod Mini has never been jailbroken. The jailbreak community wrote it off after confirming its S5 chip (T8006) falls outside checkm8&#39;s range (A5-A11 only). This writeup documents what I believe is the first publicly documented non-invasive security exploration of the HomePod Mini via its USB-C port, covering an interactive iBoot recovery shell session, full binary analysis of the firmware, and AirPlay protocol analysis on the running audioOS.</p>
<p>Target: AudioAccessory5,1, Apple S5 (T8006), board b520ap, audioOS 26.3 (Build 23K620), iBoot-13822.80.422.0.2.</p>
<h2>USB reconnaissance</h2>
<p>With the HomePod Mini plugged into a Mac via USB-C, <code>ideviceinfo</code> connects over usbmuxd and returns detailed device information. Notably, <code>system_profiler SPUSBDataType</code> returns empty. The Mini communicates exclusively through usbmuxd&#39;s TCP-over-USB protocol, not as a raw USB device. Tools like <code>irecovery</code> cannot see it in this state.</p>
<p>Key identifiers from normal-mode enumeration:</p>
<div class="table-scroll"><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>
<tr><td>ChipID</td><td>32774 (0x8006, T8006/S5)</td></tr>
<tr><td>CPUArchitecture</td><td>arm64e</td></tr>
<tr><td>HardwareModel</td><td>B520AP</td></tr>
<tr><td>ProductType</td><td>AudioAccessory5,1</td></tr>
<tr><td>ProductName</td><td>Apple TVOS (audioOS is tvOS under the hood)</td></tr>
<tr><td>FirmwareVersion</td><td>iBoot-13822.80.422.0.2</td></tr>
</tbody></table></div>
<p>The device shows <code>TrustedHostAttached: true</code>, meaning lockdownd services are available. NVRAM was also readable: <code>auto-boot</code> = &#34;true&#34;, <code>usbcfwflasherResult</code> = &#34;No errors&#34;.</p>
<h3>DFU mode attempts</h3>
<p>No public method to enter DFU mode on the HomePod Mini has ever been documented. I tried multiple button/power combinations while monitoring USB events:</p>
<ol>
<li>Hold touch surface, plug USB-C, plug power (held 20 seconds)</li>
<li>Plug USB-C, plug power, hold touch surface through recovery</li>
<li>Rapid power cycling (3x) with USB connected, hold touch surface</li>
<li>Touch surface hold before any power with USB connected</li>
<li>Holding both + and - volume zones simultaneously during power-on</li>
</ol>
<p>All attempts resulted in normal boot (white light), recovery mode (orange flashing light), or factory reset (3 beeps). Attempt 3 triggered Siri announcing a WiFi connection issue, confirming it was booting to userspace audioOS.</p>
<p>The orange flashing light during standard Finder restore is <strong>not</strong> iBoot recovery mode. It&#39;s a userspace restore daemon running on minimal audioOS.</p>
<h2>Entering iBoot recovery mode</h2>
<p>Since lockdownd trusts the host Mac, <code>ideviceenterrecovery</code> can instruct the device to reboot into iBoot recovery mode over usbmuxd:</p>
<figure class="codeblock"><div class="codeblock__bar"><span class="codeblock__lang">bash</span><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>$ ideviceenterrecovery $(ideviceinfo -k UniqueDeviceID)
Telling device with udid 00008006-000629620103C02E to enter recovery mode.
Device is successfully switching to recovery mode.
</code></pre></figure>
<p>The HomePod goes completely dark. No orange light, no white light, total silence. Consistent with every other Apple device&#39;s bootloader behavior.</p>
<h3>First irecovery session with a HomePod Mini</h3>
<p>With the device in iBoot recovery mode, <code>irecovery -q</code> connects:</p>
<figure class="codeblock"><div class="codeblock__bar"><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>CPID: 0x8006
CPRV: 0x11
BDID: 0x22
ECID: 0x000629620103c02e
CPFM: 0x03
SCEP: 0x01
IBFL: 0x3d
SRTG: N/A
MODE: Recovery
PRODUCT: AudioAccessory5,1
MODEL: b520ap
NAME: HomePod mini
</code></pre></figure>
<p><strong>MODE: Recovery</strong> (not DFU). This is iBoot, not SecureROM. <code>SRTG: N/A</code> confirms it; DFU mode would show the SecureROM version tag. <strong>CPFM: 0x03</strong> means production fused, both production and security bits set. No debug access.</p>
<h3>Interactive iBoot shell</h3>
<p><code>irecovery -s</code> opens an interactive shell:</p>
<figure class="codeblock"><div class="codeblock__bar"><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>=======================================
::
:: Supervisor iBoot for b520, Copyright 2007-2025, Apple Inc.
::
::      Local boot, Board 0x22 (b520ap)/Rev 0x3
::
::      BUILD_TAG: iBoot-13822.80.422.0.2
::
::      BUILD_STYLE: RELEASE
::
=======================================
Entering recovery mode, starting command prompt
</code></pre></figure>
<p>The shell accepts <code>getenv</code> queries. <code>secure-boot</code> returns <code>0x1</code>, <code>auto-boot</code> returns <code>false</code>. Most variables return &#34;false&#34; (not found): <code>boot-args</code>, <code>debug-enabled</code>, <code>firmware-version</code>. The <code>help</code> command returns empty. <code>bgcolor</code>, <code>version</code>, <code>meminfo</code>, <code>devicetree</code> all silently fail. <code>reboot</code> works. <code>irecovery -n</code> returns the HomePod to normal operation.</p>
<h2>iBoot binary analysis</h2>
<h3>Firmware acquisition</h3>
<p>HomePod Mini IPSW files are standard ZIP archives from ipsw.me. The iBoot binary was decrypted using published firmware keys from The Apple Wiki for audioOS 16.6 (Build 20M73):</p>
<ul>
<li><strong>IV:</strong> <code>158dd8de55acbe7f475674aa98af825b</code></li>
<li><strong>Key:</strong> <code>a1b0fc7917caf43613086e68666139733ceefed3715a0aa4277b986af02118dd</code></li>
</ul>
<p>The resulting binary is 1,397,928 bytes, arm64e, virtual base 0x180000000.</p>
<h3>Command table</h3>
<p>The RELEASE iBoot has an extremely minimal command set. Only infrastructure commands are registered: <code>command</code>, <code>menu_loop</code>, <code>poweroff</code>, <code>idleoff</code>, USB handlers (<code>usb</code>, <code>usb req</code>, <code>usb-hi-current</code>, <code>usb-no-current</code>, <code>usb_serial</code>, <code>vbus poll</code>, <code>usb vbus</code>), and boot flow (<code>main</code>, <code>fsboot</code>, <code>upgrade</code>, <code>recover</code>, <code>recover-once</code>, <code>darkboot</code>).</p>
<p><strong>Dead strings present but unreferenced by code:</strong> <code>getenv</code>, <code>setenv</code>, <code>bgcolor</code>, <code>bootx</code>, <code>memboot</code>, <code>reboot</code>, <code>reset</code>, <code>go</code>. These are residual string data from shared object files, compiled out via preprocessor guards.</p>
<p><strong>Completely absent:</strong> <code>md</code> (memory dump) and <code>mw</code> (memory write) don&#39;t exist as strings at all. Not gated, removed entirely.</p>
<h3>Security architecture</h3>
<div class="table-scroll"><table><thead><tr><th>Mitigation</th><th>Status</th></tr></thead><tbody>
<tr><td>PAC (Pointer Authentication)</td><td>Active, 1,683 functions</td></tr>
<tr><td>BLRAA (authenticated indirect calls)</td><td>Active, 244 instances</td></tr>
<tr><td>Authenticated returns (RETAB)</td><td>Active, 1,432 instances</td></tr>
<tr><td>Stack canaries</td><td>Active, 167 functions</td></tr>
<tr><td>Image4 chain-of-trust</td><td>Active</td></tr>
<tr><td>Debug commands</td><td>Completely stripped</td></tr>
</tbody></table></div>
<h2>A11 vs S5: how Apple killed checkm8</h2>
<p>To understand how Apple hardened the S5, I compared the HomePod Mini&#39;s iBoot against an iPhone X (A11/T8015) iBoot from the same build train. The A11 is the last checkm8-vulnerable chip.</p>
<h3>PAC enabled in iBoot</h3>
<p>The A11 supports arm64e but Apple never enabled PAC in its iBoot. The S5 has 1,683 PAC-protected functions, 1,432 authenticated returns, and 244 authenticated indirect branches. In the USB code region alone, 455 TBZ checks on bit 62 (PAC signature region). The A11 has zero.</p>
<div class="table-scroll"><table><thead><tr><th>Metric</th><th>A11 (iPhone X)</th><th>S5 (HomePod Mini)</th></tr></thead><tbody>
<tr><td>pacibsp instructions</td><td>0</td><td>1,683</td></tr>
<tr><td>BLRAA instructions</td><td>0</td><td>244</td></tr>
<tr><td>RETAB instructions</td><td>0</td><td>1,432</td></tr>
<tr><td>Bit 62 checks in USB region</td><td>0</td><td>455</td></tr>
</tbody></table></div>
<h3>USB handler shrunk 80x</h3>
<p>The A11&#39;s monolithic USB request handler spans ~16KB with 470 function calls, handling device tree setup, crypto, display init, and battery management inline. The S5 replaced this with a 196-byte thin wrapper making 4 delegated calls.</p>
<div class="table-scroll"><table><thead><tr><th>Component</th><th>A11</th><th>S5</th></tr></thead><tbody>
<tr><td>USB req handler</td><td>~16 KB, 470 BL calls</td><td>196 bytes, 4 BL calls</td></tr>
<tr><td>DFU setup function</td><td>~16 KB, 652 BL calls</td><td>72 bytes, 2 BL calls</td></tr>
</tbody></table></div>
<h3>function-reset removed</h3>
<p>The string <code>function-reset</code> exists in the A11 binary at <code>0x18004bd8c</code>. This is the USB function reset handler that checkm8 directly exploits (the use-after-free occurs during USB function reset). This string and its associated code are entirely absent from the S5 binary. The functionality was either removed or refactored into a new <code>ausb ctr</code> (audio USB controller) abstraction layer that exists only on S5.</p>
<h2>Deterministic stack canary</h2>
<p>The iBoot stack canary value is **<code>0x4752440044003631</code>**, and it is deterministic, not random. The canary is initialized at <code>0x1800021e0</code>, which executes before the SVC handler, before MMU setup, and before any entropy subsystem is available. The <code>random_fill()</code> function at <code>0x18005022c</code> checks a BSS flag that is guaranteed zero at this point, causing it to fall back to a hardcoded seed value.</p>
<p>The ASCII content (<code>GRD\0D061</code>) confirms this is a build tag, not cryptographic randomness. This value is identical across every HomePod Mini running this iBoot version.</p>
<p>If any stack buffer overflow exists anywhere in this iBoot build, the canary provides zero protection. This reduces the mitigation stack from three layers (canary + PAC + controlled value) to two.</p>
<h2>Command parser bounds checking</h2>
<p>Initial analysis suggested the command tokenizer at <code>0x180060288</code> had an out-of-bounds write. The token counter <code>w21</code> appeared to increment past a <code>CMP w21, 6</code> soft limit. Deeper analysis of the CCMP/CSEL pattern disproved this:</p>
<figure class="codeblock"><div class="codeblock__bar"><span class="codeblock__lang">asm</span><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>CMP   w21, 6           ; compare token count
CCMP  w8, 0, 4, le     ; if w21 &lt;= 6: compare next_byte to null
                        ; if w21 &gt; 6: force flags to nzcv=0100 (Z=1)
CSEL  w8, w25, wzr, eq ; if Z=1: state = 2 (dispatch/exit)
</code></pre></figure>
<p>When <code>w21 &gt; 6</code>, the CCMP&#39;s <code>le</code> condition fails, forcing Z=1 regardless of <code>w8</code>. The CSEL picks <code>w25 = 2</code> (command dispatch = function exit). Maximum 7 entries (indices 0-6) are written into a 10-slot array. Well within bounds.</p>
<p>The command parser is correctly bounded. The CMP + CCMP + CSEL pattern creates an effective hard limit.</p>
<h2>Exhaustive iBoot attack surface audit</h2>
<h3>Image4 / DER / ASN.1 parser</h3>
<p>The DER/ASN.1 parser at <code>0x180063e88</code> is Apple&#39;s libDER. Every byte read has pointer range validation (<code>start &lt;= ptr &lt; end</code>), the multi-byte length accumulator has overflow detection at each iteration (<code>LSR + CBNZ</code>), indefinite length forms are rejected, non-minimal encodings are rejected, and the final <code>content_start + content_length</code> addition has an explicit overflow check. Strict mode is set for the DFU input path.</p>
<h3>LZFSE / LZSS decompression</h3>
<p>Three layers protect against decompression overflow: the IM4P header validation caps uncompressed size at ~190MB, the heap allocator adds <code>0x200</code> bytes of headroom, and the LZFSE decoder contains 40+ <code>CMP x20</code> bounds checks.</p>
<h3>ausb ctr USB controller</h3>
<p>The S5 USB stack is fundamentally redesigned:</p>
<ul>
<li><strong>No use-after-free possible.</strong> The 6,144-byte USB transfer buffer is <code>calloc()</code>&#39;d once at <code>0x180031d94</code>, stored at BSS <code>0x18015b9a8</code>, and never freed. Only 2 references exist in the entire binary.</li>
<li><strong>Zero indirect calls.</strong> The entire USB region (<code>0x18002f000</code>-<code>0x180033000</code>) contains no BLR, BLRAA, or BRAAZ instructions. All calls are direct BL.</li>
<li><strong>Fixed sub-buffer layout.</strong> 6,144 bytes partitioned at compile-time: EP0 setup 256B, EP0 data out 2048B, EP0 data in 1024B, bulk in 1024B, bulk out 1028B. No dynamic buffer sizing.</li>
<li><strong>Minimal state machine.</strong> Single abort flag byte, single busy flag byte, single-threaded loop.</li>
</ul>
<p>The bootloader attack surface is effectively closed through static analysis.</p>
<h2>audioOS network attack surface</h2>
<p>With iBoot exhausted, I shifted to the running audioOS.</p>
<p>The root filesystem contains <strong>254 daemons</strong> and <strong>3 apps</strong>. Four daemons bind network ports:</p>
<div class="table-scroll"><table><thead><tr><th>Daemon</th><th>Port</th><th>Notes</th></tr></thead><tbody>
<tr><td>remotepairingdeviced</td><td>Dynamic (TCP)</td><td><code>ExposedToUntrustedDevices: true</code></td></tr>
<tr><td>lockdownd</td><td>62078</td><td>Standard iOS lockdown protocol</td></tr>
<tr><td>mDNSResponder</td><td>5353 (UDP)</td><td>Bonjour/DNS-SD</td></tr>
<tr><td>racoon</td><td>500 (IKE)</td><td>IPSec VPN (likely disabled)</td></tr>
</tbody></table></div>
<p>Live Bonjour reconnaissance confirmed AirPlay on port 7000 with <code>acl=0</code> (no access control, any LAN device can connect).</p>
<h2>OPACK wire format reverse engineering</h2>
<p>Apple&#39;s OPACK is a proprietary binary serialization format used by RemoteXPC and remotepairingdeviced. It is parsed <strong>before authentication</strong> during the pairing handshake. I reverse-engineered it from <code>__OPACKDecodeObject</code> at <code>0x19380c320</code> in CoreUtils.framework.</p>
<div class="table-scroll"><table><thead><tr><th>Tag Range</th><th>Type</th><th>Encoding</th></tr></thead><tbody>
<tr><td>0x01</td><td>NULL</td><td>No payload</td></tr>
<tr><td>0x02</td><td>FALSE</td><td>No payload</td></tr>
<tr><td>0x03</td><td>TRUE</td><td>No payload</td></tr>
<tr><td>0x04</td><td>Terminator</td><td>End marker</td></tr>
<tr><td>0x05</td><td>UUID</td><td>16 bytes follow</td></tr>
<tr><td>0x06</td><td>Date</td><td>8 bytes follow</td></tr>
<tr><td>0x07-0x2F</td><td>Cached constants</td><td>Index into pre-registered object table</td></tr>
<tr><td>0x30</td><td>Int8</td><td>1 byte follows</td></tr>
<tr><td>0x31-0x36</td><td>Int16/32/64, Float, Double</td><td>2/4/8 bytes follow</td></tr>
<tr><td>0x40</td><td>Empty string</td><td>No payload</td></tr>
<tr><td>0x41-0x60</td><td>Short string</td><td>Length = tag - 0x40 (1-32 bytes inline)</td></tr>
<tr><td>0x61-0x64</td><td>String</td><td>1-4 byte length prefix + data</td></tr>
<tr><td>0x65+</td><td>Data, Array, Dictionary, UID</td><td>Container types, recursive</td></tr>
</tbody></table></div>
<p>Arrays and dictionaries are recursive, each call using ~0x70 bytes of stack. Recursion depth limit is 32 levels (<code>CMP w8, 0x20</code> at <code>0x19380cd74</code>). Tag 0x64 allows a 4-byte length prefix (up to ~4GB), creating potential allocation overflow in the <code>CFStringCreate</code> path.</p>
<h2>remotepairingdeviced: less open than advertised</h2>
<p>Despite the <code>ExposedToUntrustedDevices: true</code> flag, the daemon has multiple layers of gating:</p>
<ol>
<li><strong>TCP listener disabled by default.</strong> The string <code>&#34;Not configuring launchd-managed TCP control channel due to &#39;deviceAllowTCPControlChannel&#39; not being set to true&#34;</code> reveals the TCP listener requires an explicit flag.</li>
<li><strong>Requires prior pairing.</strong> TCP channel only activates after at least one host has been paired via USB or Bluetooth.</li>
<li><strong>Promptless pairing is USB-only and time-limited.</strong> <code>&#34;USB host disconnected; promptless pairing disabled&#34;</code>.</li>
<li><strong>Network pairing requires consent</strong> via the Home app on a paired iPhone.</li>
<li><strong>Bluetooth-triggered listener.</strong> The primary discovery path is BLE proximity, not an always-on service.</li>
</ol>
<p>For a LAN-only attacker, the TCP control channel is likely inactive on a stock HomePod Mini. The most promising network target is tvairplayd on port 7000.</p>
<h2>AirPlay protocol analysis</h2>
<p>Live HTTP probing of tvairplayd:</p>
<div class="table-scroll"><table><thead><tr><th>Request</th><th>Response</th><th>Auth Required</th></tr></thead><tbody>
<tr><td><code>GET /info</code></td><td>200 OK (full device info)</td><td>No</td></tr>
<tr><td><code>POST /pair-setup</code></td><td>400 (expects body)</td><td>No, parses pre-auth data</td></tr>
<tr><td><code>POST /fp-setup</code></td><td>400 (FairPlay setup)</td><td>No, parses pre-auth data</td></tr>
</tbody></table></div>
<p>The <code>/pair-setup</code> endpoint implements HomeKit pair-setup using SRP-3072. Sending a valid M1 message returned a valid M2 response with a 16-byte salt and 384-byte SRP server public key.</p>
<p>The classic SRP-zero bypass (<code>A = 0</code>) was blocked. HTTP 470, Apple validates the SRP public key before computation.</p>
<p>After a single failed M3 message, the HomePod imposed a <strong>64,259-second lockout</strong> (~18 hours). Approximately 1.3 attempts per day per HomePod.</p>
<h3>/pair-verify: Curve25519 key exchange</h3>
<p>To establish a session, I implemented the complete pair-verify protocol. This is the standard AirPlay session establishment flow, documented in the pyatv and pair_ap libraries:</p>
<ol>
<li><strong>M1 to M2:</strong> Sent Curve25519 public key, received server&#39;s ephemeral key + encrypted data.</li>
<li><strong>Shared secret:</strong> Curve25519 ECDH exchange computed.</li>
<li><strong>Session key:</strong> HKDF-SHA-512 with salt <code>&#34;Pair-Verify-Encrypt-Salt&#34;</code> and info <code>&#34;Pair-Verify-Encrypt-Info&#34;</code>.</li>
<li><strong>M2 decrypted:</strong> ChaCha20-Poly1305 with nonce <code>&#34;PV-Msg02&#34;</code> revealed TLV8 containing identifier <code>16F9D2EF-3E51-49C2-BD7B-2F145D422227</code> and a 64-byte Ed25519 signature.</li>
<li><strong>M3 sent:</strong> The server successfully decrypted M3 but rejected with Error=1 because the fabricated identifier was not in its paired peers list.</li>
</ol>
<p>The <code>/pair-verify</code> endpoint accepts requests with no rate limiting, though the final authorization check (peer identity lookup) prevents unauthorized session establishment.</p>
<div class="table-scroll"><table><thead><tr><th>Step</th><th>HKDF Salt</th><th>HKDF Info</th><th>Nonce</th></tr></thead><tbody>
<tr><td>Session key</td><td><code>Pair-Verify-Encrypt-Salt</code></td><td><code>Pair-Verify-Encrypt-Info</code></td><td>-</td></tr>
<tr><td>Signing key</td><td><code>Pair-Verify-ECDH-Salt</code></td><td><code>Pair-Verify-ECDH-Info</code></td><td>-</td></tr>
<tr><td>M2 decrypt</td><td>-</td><td>-</td><td><code>\x00\x00\x00\x00PV-Msg02</code></td></tr>
<tr><td>M3 encrypt</td><td>-</td><td>-</td><td><code>\x00\x00\x00\x00PV-Msg03</code></td></tr>
</tbody></table></div>
<h2>AirPlay transient pairing: establishing a session</h2>
<p>AirPlay 2 has two pairing families: HomeKit-based (SRP via <code>/pair-setup</code>) and legacy PIN (via <code>/pair-pin-start</code>). All prior testing targeted the HomeKit path.</p>
<p>The HomePod Mini in &#34;Anyone on the Same Network&#34; mode accepts transient pairing with PIN <strong>3939</strong>. This is documented, intentional behavior for screenless AirPlay devices -- the pair_ap library and pyatv both document it. Screenless devices cannot display a dynamic PIN, so Apple uses a well-known static value. Anyone running Home Assistant connects to their HomePod this way daily.</p>
<figure class="codeblock"><div class="codeblock__bar"><button class="codeblock__copy" type="button" aria-label="copy code">copy</button></div><pre><code>POST /pair-pin-start         → initiates PIN display (no-op on screenless HomePod)
POST /pair-setup (state=1)   → SRP M1, server returns M2 with salt + public key B
POST /pair-setup (state=3)   → SRP M3 with proof using PIN &#34;3939&#34;
                             → server returns M4, authenticated session established
POST /pair-verify            → derives encrypted session keys
</code></pre></figure>
<p>All steps must occur on the same TCP socket. Session state is per-connection. Using separate HTTP requests fails because context is lost between connections. This is the standard mechanism for establishing an AirPlay session from a non-Apple device on the local network.</p>
<h3>SendVoiceInput crashes MRP data channel</h3>
<p>With the authenticated session, the HomePod exposes a Media Remote Protocol channel. Sending any MRP <code>SendVoiceInput</code> message (type 31), even empty, causes the data channel to terminate immediately.</p>
<p>Root cause: <code>mediaremoted</code>&#39;s <code>_handleVoiceDataReceivedMessage:fromClient:</code> calls <code>_MRVirtualVoiceInputProcessAudioData</code> which requires an <code>externalDevice</code> context not initialized for AirPlay MRP tunnel connections. Null-reference bug in the device lookup path.</p>
<h3>TLV8 parser truncation</h3>
<p>The TLV8 parser returns HTTP 500 on truncated input:</p>
<div class="table-scroll"><table><thead><tr><th>Input</th><th>Result</th></tr></thead><tbody>
<tr><td><code>[type, 0x00]</code> (length=0)</td><td>HTTP 200</td></tr>
<tr><td><code>[type, 0x01]</code> (length=1, no data)</td><td>HTTP 500</td></tr>
<tr><td><code>[type, 0x01, data]</code> (length=1, 1 byte)</td><td>HTTP 200</td></tr>
</tbody></table></div>
<p>Stable through 100+ consecutive requests. The exception is caught and does not cause memory corruption.</p>
<h2>Findings</h2>
<div class="table-scroll"><table><thead><tr><th>Finding</th><th>Category</th></tr></thead><tbody>
<tr><td>Deterministic iBoot stack canary (<code>0x4752440044003631</code>)</td><td>Bug (medium)</td></tr>
<tr><td>SendVoiceInput crashes MRP data channel (null-reference in mediaremoted)</td><td>Bug (medium)</td></tr>
<tr><td>HTTP 500 on non-TLV8 input to <code>/pair-verify</code> (pre-auth)</td><td>Bug (medium)</td></tr>
<tr><td>HTTP 500 on invalid <code>X-Apple-HKP</code> header (pre-auth)</td><td>Bug (low-medium)</td></tr>
<tr><td>TLV8 parser HTTP 500 on truncated input</td><td>Bug (low)</td></tr>
<tr><td>AirPlay <code>/pair-verify</code> no rate limit on key exchange</td><td>Observation</td></tr>
<tr><td>AirPlay transient pairing with PIN 3939</td><td>By design (documented)</td></tr>
<tr><td>OPACK binary protocol fully reversed</td><td>Original research</td></tr>
<tr><td>Complete A11 vs S5 iBoot architectural diff</td><td>Original research</td></tr>
</tbody></table></div>
<div class="table-scroll"><table><thead><tr><th>Layer</th><th>Surface</th><th>Status</th></tr></thead><tbody>
<tr><td>iBoot</td><td>USB DFU, command parser, Image4, LZFSE</td><td>All hardened, closed</td></tr>
<tr><td>Kernel</td><td>1,360 kexts, IOUserClients, WiFi driver</td><td>Requires code exec first</td></tr>
<tr><td>AirPlay :7000</td><td>/pair-setup, /pair-verify, /fp-setup</td><td>Transient pairing via PIN 3939 (by design)</td></tr>
<tr><td>Companion :49153</td><td>rapportd, OPACK, requires HomeKit trust</td><td>Silent reject without trust</td></tr>
<tr><td>RemotePairing :49152</td><td>OPACK protocol, TCP</td><td>Needs prior pairing or BLE</td></tr>
<tr><td>Lockdown :62078</td><td>Standard lockdown protocol</td><td>Needs USB pairing</td></tr>
</tbody></table></div>
<p>Every remote vector is either PIN-gated, rate-limited, or requires an established trust relationship. The real bugs are in the post-auth path: the MRP null dereference and the pre-auth parser crashes.</p>
<p>The research is reproducible by anyone with a HomePod Mini, a Mac, and <code>brew install libimobiledevice libirecovery</code>.</p>
<h2>Tools</h2>
<div class="table-scroll"><table><thead><tr><th>Tool</th><th>Version</th><th>Purpose</th></tr></thead><tbody>
<tr><td>libimobiledevice</td><td>latest</td><td>USB communication</td></tr>
<tr><td>libirecovery</td><td>latest</td><td>Recovery mode interaction</td></tr>
<tr><td>pyimg4</td><td>0.8.8</td><td>Image4 extraction and decryption</td></tr>
<tr><td>radare2</td><td>6.1.0</td><td>Disassembly and binary analysis</td></tr>
<tr><td>ipsw</td><td>3.1.664</td><td>IPSW download and firmware extraction</td></tr>
</tbody></table></div>
]]></content:encoded>
</item>
</channel>
</rss>
