Getting the Keys: Reverse Engineering UDS Seed-Key Algorithms from Firmware

Introduction
In the first article of this series we got a Linux box speaking raw CAN. In the second we climbed the stack to UDS and ended at the gate itself: security access, service 0x27, the seed-key handshake that guards everything interesting in an ECU - memory reads, firmware rewrite, developer sessions. That article used a deliberately simple algorithm so we could focus on the protocol mechanics: three bad keys, a lockout, a penalty window, then success.
Here is the uncomfortable truth that article parked: on a real ECU, the algorithm that turns a seed into a key is vendor-secret. Your dealership tool carries a licensed implementation. The public documentation (ISO 14229) specifies the handshake to the byte and says precisely nothing about f(seed). If you work on your own bench ECUs, if you build diagnostics tooling, or if you assess vehicle electronics for a living, sooner or later you will hold a firmware image and need to answer one question: what does this box compute?
This article is the full workflow, end to end, on a target built for the purpose:
- Find the cryptography before reading a single instruction - scanning a binary for known crypto tables
- Map the table cluster - keys, masks and round constants hide next to the tables they serve
- Read the code - loading a raw image in Ghidra, and what a literal pool tells you in ten seconds
- Verify against the firmware’s own self-test - most security modules ship with a known-answer test, and it is a free verification vector
- Unlock the ECU - plug the extracted algorithm into our
udscopetoolkit and complete a real 0x27 handshake on a virtual CAN bus
The target is TECU-3, a small synthetic ARM Cortex-M3 firmware image that accompanies this article. Its security module belongs to the family you genuinely meet in production vehicles: a modified AES core with bespoke byte-mangling wrapped around it. The construction pattern is realistic; every constant inside it was randomly generated for this series. Nothing in this article derives from any real vendor’s material - the point is the method, transferable to whatever dump you are authorised to analyse.
As always in this series: your own vehicle, your own bench units, your engagement scope. The techniques are identical whether the target is a body control module you bought on eBay or a virtual training blob; the authorisation differs.
Seed-Key Algorithms: What You Meet in the Wild
A quick recap of where the algorithm sits. Security access is a two-step handshake: the tester requests a seed (a fresh random value), and must send back the key that the ECU’s secret algorithm computes from that seed. Guess wrong three times and the ECU locks you out for a penalty window - behaviour we exercised in detail last time. The ECU never transmits the algorithm; it only ever emits correct/incorrect verdicts. Everything you want to learn has to come out of the firmware itself.
When you start opening firmware images, the algorithms sort into a handful of families. Knowing the family tells you where to look:
| Family | Typical construction | Size (seed/key) | Where it hides |
|---|---|---|---|
| ARX chains | rotates, XOR and ADD with a handful of 32-bit constants | 4/4 bytes | immediates in one small function |
| Table mixers | nibble or byte substitutions through 16/256-entry tables | 2-4/2-4 bytes | the tables dominate the binary |
| LFSR / shift registers | Galois/Fibonacci shifting keyed by the seed | 2-4/2-4 bytes | a tight bit-loop, constants in the taps |
| Crypto core + scramblers | a real cipher (AES variants are common) with custom pre/post processing | 4-20/4-16 bytes | tables plus keys plus a crypto-sized function |
| Vendor soup | combinations of all of the above | anything | everywhere |
The fourth family is the reason this article exists. Stock AES is verifiable against public test vectors, which makes it look easy - until you notice the vendor’s core runs a non-standard round count, or feeds the cipher a message layout no protocol ever defined, or truncates and scrambles the ciphertext into an output no crypto library will produce. Modified-AES constructions of exactly this shape (altered parameters, seed-derived message blocks, output compression through bespoke scramblers) are common enough in security-access and immobilizer code that knowing how to dissect one is a core skill. That is what TECU-3 models.
One property of this family is worth stating up front, because it drives the whole workflow: the cipher needs its tables. An AES core without its S-box is useless, so the S-box ships in the firmware, and the S-box is one of the most recognizable byte sequences in existence. That is our entry point.
Asymmetric cryptography: the modern arrivals around the gate
Everything in the table above is symmetric, and it is tempting to read it as a museum of 1990s crypto. Modern vehicles also run asymmetric cryptography, and increasingly so - RSA survives in older certificate infrastructures, but new deployments are dominated by elliptic curves: ECDSA signatures, ECDH key agreement, X.509 certificates. The difference is where it sits. Asymmetric crypto in today’s vehicles mostly surrounds the diagnostic gate rather than living inside the 0x27 computation:
- The 2020 edition of ISO 14229-1 introduced the Authentication service (0x29), explicitly positioned as an alternative to SecurityAccess: the ECU challenges the tester to prove ownership of a certificate, verifies the chain against its stored trust anchors, and both sides derive a fresh session key. The standard even grew a family of authentication and certificate-verification negative response codes (
0x50through0x5D) for it. - Over-the-air updates are protected by Uptane, an open update-security framework (a Linux Foundation project) that signs update metadata with multiple asymmetric keys and roles, so that compromising a single server or vehicle gateway is not enough to push malicious firmware.
- Vehicle-to-everything messaging (IEEE 1609.2) signs every broadcast with ECDSA, and plug-and-charge EV billing (ISO 15118) authenticates the car to the charging station with contract certificates over TLS.
So why does this article - and, frankly, most bench work - still center on the symmetric seed-key? Two reasons. First, fleet reality: 0x27 is deployed across effectively the entire installed base, 0x29 is the new arrival, and hybrid stacks run both. Second, the analysis skills transfer directly: trust anchors and certificates are constants in flash too, and the same table-scan and literal-pool workflow that found our AES cluster finds them. The genuine boundary is elsewhere - when the private key lives in a hardware security module (automotive HSMs, as classified by the EU’s EVITA research project), no flash dump contains it, and the extraction target becomes the HSM’s interface rather than its key store. That is a topic for a later article in this series.
The Target and the Lab
TECU-3 is a 4 KB raw firmware image for a fictional body-control-style ECU: a Cortex-M3 vector table, a startup, a main loop with a power-on self-test, some identification strings, a calibration table as noise - and a security access module consisting of a modified AES core and two output scramblers, with all its tables clustered in one region of flash.
Everything you need is in the companion repository, including the full build so you can rebuild or fork the target:
The lab is the same hardware-free setup from the previous article - a virtual CAN interface and our udscope toolkit:
$ git clone https://github.com/Hunt-Benito/getting-the-keys-reverse-engineering-uds-seed-key-algorithms-from-firmware.git
$ cd getting-the-keys-reverse-engineering-uds-seed-key-algorithms-from-firmware
$ python3 -m venv venv && source venv/bin/activate
(venv) $ pip install git+https://github.com/Hunt-Benito/udscope
(venv) $ udscope setup
vcan0 is present and up
The repository layout:
01_hunt_sbox.py scan any binary for the AES S-box (Hamming distance)
02_map_blob.py dump and annotate the table cluster behind the S-box
03_unlock.py end-to-end security access against the virtual ECU
sim_tecu.py the TECU-3 virtual ECU (udscope simulator + plugin)
tecu_algo.py the extracted algorithm, pure Python, zero dependencies
firmware/ the blob itself plus its complete build (Makefile, C, linker)
From here on, we play the analyst who has just been handed firmware/tecu3_fw.bin and knows nothing else.
Step 1: Find the Crypto Before You Read a Single Instruction
Disassembling 4 KB of unknown Thumb-2 code instruction by instruction is a last resort, not a plan. The first move on any firmware blob is a known-constant scan: slide a window across the binary and compare each window against tables you know crypto needs. The AES S-box is the classic target - 256 very specific bytes, defined by FIPS-197, identical in every stock implementation.
How you compare matters. An exact byte match finds a verbatim table, but vendors sometimes tweak a few S-box entries precisely to defeat scanners that only look for exact matches. Comparing by Hamming distance (count the differing bits across the whole window) catches both: a verbatim table scores 0, a lightly tweaked one scores a small number, and unrelated data clusters around 1024 - the expected distance between two random 256-byte sequences.
The scanner core is a dozen lines of Python, and this is the version that ships in the repository:
def hamming_distance(a: bytes, b: bytes) -> int:
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
def scan(blob: bytes, table: bytes, max_report: int = 5):
hits = []
for off in range(0, len(blob) - len(table) + 1):
dist = hamming_distance(blob[off:off + len(table)], table)
if dist <= 128: # 256 bytes * 8 bits: 128 = coin flip
hits.append((off, dist))
hits.sort(key=lambda h: (h[1], h[0]))
return hits[:max_report]
Run it against the blob:
(venv) $ python3 01_hunt_sbox.py
scanning firmware/tecu3_fw.bin (4096 bytes) for the AES S-box ...
candidate @ file 0x0520 (addr 0x08000520) distance 0 bits EXACT - stock FIPS-197 S-box embedded verbatim
next: dump the bytes around the best hit - vendor keys and masks
often sit next to the tables they belong with
One exact hit at file offset 0x0520, distance zero, everything else buried in the noise floor around 1024 bits. In a real dump the profile is messier - more tables, more near-misses - but the shape is the same: cryptography announces itself.
Two practical notes for real targets. First, the S-box’s inverse table (InvSbox, used by decryption) is just as recognizable; scan for both, because finding one but not the other tells you the module only ever encrypts - which is typical for seed-key computation and a useful early hint. Second, a total absence of stock tables does not mean no AES: some vendors generate the S-box at boot from its algebraic definition, and some use tweaked tables (your Hamming-distance scanner just produced your first piece of evidence for that). TECU-3 plays it straight, which is the common case.
Step 2: Map the Cluster
Firmware authors place a module’s tables together - and even when they do not care, the linker places each module’s objects and their rodata contiguously anyway. The bytes following your S-box hit are the highest-value real estate in the dump. The repository’s 02_map_blob.py prints the cluster as a structured hexdump with ASCII columns:
(venv) $ python3 02_map_blob.py 520
security cluster at file 0x0520 (S-box first, then 0x140 bytes of neighbours):
S-box 0x08000520 .. 0x0800061F
head: 63 7c 77 7b f2 6b 6f c5
tail: 41 99 2d 0f b0 54 bb 16
cluster after the S-box (file 0x0620+):
08000620: f7 a1 c3 03 07 5a 14 5c db 6d 78 49 e6 a5 77 66 |.....Z.\.mxI..wf|
08000630: 2a 97 49 8b 99 29 15 1e 7f 03 ed 63 c1 29 ab 79 |*.I..).....c.).y|
08000640: 46 a0 8c 7a 76 bf d6 a1 00 01 02 04 08 10 20 40 |F..zv......... @|
08000650: 80 1b 36 00 4d 3c 2b 1a f7 e1 27 45 f5 b0 e0 46 |..6.M<+...'E...F|
08000660: ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff |................|
[+] AES Rcon signature at 0x08000648 (file 0x0648) - key schedule confirmed
40 bytes of constants sit between S-box and Rcon
The dump is already speaking. Reading it as an analyst:
- A second fingerprint: at
0x08000648sits00 01 02 04 08 10 20 40 80 1b 36- the AES round-constant sequence (Rcon), a doubling chain in GF(2^8) that is unique to AES key schedules. People forget to scrub or search for it; here it confirms the S-box finding and tells you a key expansion exists. Two independent AES fingerprints in one cluster is no coincidence. - Two 16-byte key-shaped runs at
0x08000620and0x08000630. High entropy, no structure, exactly cipher-key sized. Candidates. - Two 4-byte runs at
0x08000640and0x08000644- mask-shaped, or an unfortunate coincidence. Candidates. - A suspicious pair at
0x08000654: a 32-bit value4d 3c 2b 1afollowed by an 8-byte valuef7 e1 27 45 f5 b0 e0 46. Remember this one - it is the firmware’s power-on self-test vector, and it will hand us a free verification test later.
One subtlety worth internalizing now: 4d 3c 2b 1a reads as 0x4D3C2B1A big-endian, but Cortex-M is little-endian - the actual 32-bit constant is 0x1A2B3C4D. Whenever you lift “a seed” or “a constant” out of an ARM dump, mind the byte order; nothing burns an afternoon faster than a byte-reversed constant that almost works.
What we have so far is shapes: two 16-byte blobs, two 4-byte blobs, a self-test pair, an S-box, an Rcon. Nothing yet says which byte is a key, which is a mask, or how they combine. Constants do not reveal roles. For that, we read the code.
Step 3: Read the Code - Just Enough of It
To disassemble a raw binary you need two facts: the architecture and the load address. For TECU-3 both are guessable from the image itself - the first two words of the blob are a plausible stack pointer (0x20002000, top of 8 KB SRAM) and a flash address (0x080000xx), which is the signature of a Cortex-M vector table and gives us the base 0x08000000.
In Ghidra: File > Import File, select tecu3_fw.bin, set language to ARM Cortex Little Endian (v7-M) (or plain ARM LE with Thumb mode), then in the import options set the base address to 0x08000000. After auto-analysis, create a function at Reset_Handler (value at 0x08000004) and let references pull you forward. Screenshots would not add much here - the excerpts below are exactly the instructions Ghidra shows, only in objdump syntax, and you can reproduce them with plain binutils if you prefer:
$ arm-none-eabi-objdump -D -b binary -m arm -M force-thumb --adjust-vma=0x08000000 firmware/tecu3_fw.bin
The literal pool is where the function keeps its secrets
ARM Thumb code cannot load a 32-bit constant in one instruction, so compilers park constants in a literal pool - a run of .word values right after the function that references them - and load them with ldr rN, [pc, #offset]. In seed-key code, those parked constants are the whole story. Here is the head of tecu_compute_key, the function that eventually calls everything else (found at 0x08000398):
08000398 <tecu_compute_key>:
8000398: b5f0 push {r4, r5, r6, r7, lr}
800039a: 4b3f ldr r3, [pc, #252] ; 0x8000498
800039c: b093 sub sp, #76
800039e: 4043 eors r3, r0 ; r3 = seed ^ <const>
80003a0: 9303 str r3, [sp, #12]
80003a2: ea4f 43f0 mov.w r3, r0, ror #19 ; r3 = ROR(seed, 19) == ROL(seed, 13)
80003a6: 9304 str r3, [sp, #16]
80003a8: 4b3c ldr r3, [pc, #240] ; 0x800049c
80003ac: 4403 add r3, r0 ; r3 = seed + <const>
...
80003da: 4931 ldr r1, [pc, #196] ; 0x80004a0
80003de: f7ff fec1 bl 8000164 <aes9_block> ; lane A
...
80003e4: 492f ldr r1, [pc, #188] ; 0x80004a4
80003e8: f7ff febc bl 8000164 <aes9_block> ; lane B
Follow those four pool entries to the end of the function:
8000498: ef3ceb7f .word 0xef3ceb7f ; the XOR constant (C1)
800049c: 64487893 .word 0x64487893 ; the ADD constant (C2)
80004a0: 08000620 .word 0x08000620 ; -> our "key-shaped run" #1
80004a4: 08000630 .word 0x08000630 ; -> our "key-shaped run" #2
80004a8: 08000643 .word 0x08000643 ; mystery pointer - see the scramblers
Thirty seconds of reading and the picture assembles itself:
- The seed (argument
r0) is expanded into four 32-bit words: the seed itself, the seed XORed with0xEF3CEB7F, the seed rotated left by 13 (encoded asror #19- same thing), and the seed plus0x64487893. The loop at0x80003bastores them big-endian into a 16-byte block. This is the AES message block construction - and it is already non-standard: no protocol feeds AES a block built this way. - The same block is encrypted twice through the function at
0x08000164- two lanes - withr1pointing at0x08000620and0x08000630respectively. Those are our two 16-byte cluster runs: they are not just key-shaped, they are the AES keys, in the order the code uses them. Roles assigned. - In this code, the literal pool even told us where the constants live before we had to understand a single branch.
Counting rounds: the one-byte difference that defeats stock crypto
The cipher function at 0x08000164 has AES written all over it - S-box substitutions against the table at 0x08000520, the ShiftRows byte shuffles, the characteristic GF(2^8) doubling (lsls/lsrs/conditional eors triples implementing multiplication by 2). But “looks like AES” is not “is AES”, and the question that decides whether pycryptodome will answer this ECU’s challenge is: how many rounds?
The answer is in the loop bounds. The round loop increments a word counter r2 by 4 per iteration and exits on:
80002c8: 2a24 cmp r2, #36
36 divided by 4 is 9 - the loop consumes round keys up to word index 36, i.e. round keys 0 through 8: an initial key addition plus eight full rounds. And the final round’s key addition reads its round key at a fixed offset:
8000358: f894 4090 ldrb.w r4, [r4, #144] ; 144 = 9 * 16 = round key #9
Initial key + 8 full rounds + final round without MixColumns = nine rounds. Stock AES-128 runs ten. One round short of the standard, with everything else textbook - which means no stock crypto library, no cryptography, no OpenSSL call will ever produce this ECU’s answers. You must implement the core yourself, parameterised by round count. (Verify your core the cheap way: at 10 rounds it must reproduce the FIPS-197 Appendix C.1 test vector. If it does, and the ECU still disagrees, the difference is parameters - rounds, keys, message layout - not your AES.)
The scramblers, and why they matter
Two 16-byte AES outputs must become one 8-byte key. The tail of tecu_compute_key does it with inlined byte operations:
80003f8: 096a lsrs r2, r5, #5 ; >> 5 ...
80003fa: ea42 02c5 orr.w r2, r2, r5, lsl #3 ; | << 3 -> ROR(byte, 5)
80003fe: 79dd ldrb r5, [r3, #7] ; the byte 8 positions later
8000402: ea82 0205 eor.w r2, r2, r5 ; cross-XOR the lanes' halves
...
800042a: f240 1707 movw r7, #263 ; 0x107 = x^8+x^2+x+1 -> CRC-8!
...
8000442: f085 05a0 eor.w r5, r5, #160 ; 0xA0
8000448: f081 0146 eor.w r1, r1, #70 ; 0x46
800044c: f080 008c eor.w r0, r0, #140 ; 0x8C
8000450: f083 037a eor.w r3, r3, #122 ; 0x7A
Even without decompiling, three signatures jump out:
ror #5composed with a cross-XOR between the first and second halves of the 16-byte ciphertext, chained through rotate-left-3 steps - scrambler A, four bytes out.movw r7, #0x107- the CRC-8 polynomialx^8 + x^2 + x + 1(the SMBus CRC), applied per 4-byte quarter of the second ciphertext - scrambler B, four bytes out.- Four immediate XOR values
46 A0 8C 7A- which are, byte for byte, our 4-byte cluster run at0x08000640. Roles assigned: that run is the scrambler-A output mask. Its neighbour gets its role a few instructions later, and the “mystery pointer” solves it:0x08000643is loaded intor5, then the writeback loadldrb r1, [r5, #1]!walks0x08000644through0x08000647, XORing one byte into each CRC-8 result - that is exactly the second 4-byte run (MASK_B) doing its job.
And with that, the algorithm is fully specified. Here is the whole thing as a diagram:
Step 4: Verify Against the Firmware’s Own Test
Remember the suspicious pair from the cluster dump: 4d 3c 2b 1a at 0x08000654 followed by eight bytes. This is TECU-3’s power-on self-test, and it is a pattern you meet constantly in real security modules: at boot, the module computes a key for a fixed seed and compares it against an expected value stored in flash - a known-answer test, so a broken crypto build fails loudly on the bench instead of silently in the field. (It exists because exactly the class of C bug we will not joke about here - a sign-extension, a truncation, a missed reduction - would otherwise ship.)
For an analyst, a KAT is a gift: a seed, an expected output, and a verification oracle that requires no hardware. Read the little-endian seed (0x1A2B3C4D), read the expected key (F7 E1 27 45 F5 B0 E0 46), implement the algorithm as extracted, and compare.
The repository’s tecu_algo.py is that reimplementation - pure Python with zero dependencies, because the whole point is that the core is not stock AES and no library will do. Structure: the block builder, a parameterised AES core (aes_encrypt_block(block, key, rounds) - set rounds=10 and it is textbook AES-128, which the self-test exploits), the two scramblers, and the constants lifted from the cluster and the literal pool. Its self-test:
(venv) $ python3 tecu_algo.py --selftest
[1] AES core, 10 rounds vs FIPS-197 C.1 vector ....... PASS
[2] Same input at 9 rounds differs from AES-128 ....... PASS
[3] CRC-8/0x07 check value ('123456789' -> 0xF4) ...... PASS
[4] Firmware self-test vector (seed 0x1A2B3C4D) ........ PASS
firmware expects f7e12745f5b0e046, we compute f7e12745f5b0e046
Line by line: the core is provably correct AES (check 1); the 9-round modification demonstrably changes the output, so nobody can substitute a stock library (check 2); the CRC flavour is the standard one (check 3); and - the moment of truth - the firmware’s own test vector passes against our reimplementation built entirely from static analysis (check 4). When check 4 fails on a real target, the usual culprits are a mis-read constant (byte order!), a wrong round count, or a scrambler detail; having checks 1-3 green shrinks the suspect list to the extraction, not the primitives.
Step 5: Unlock the ECU
Static analysis has delivered a verified algorithm. The last step is to prove it against a live target speaking real ISO-TP over a real kernel CAN interface - with the ECU being udscope’s simulator wearing the TECU-3’s security module.
The plugin is a dozen lines and doubles as a template for plugging any extracted algorithm into the toolkit. udscope keeps seed-key algorithms in a runtime registry; the plugin registers ours, then points the simulator at the registered name:
from udscope import security
from udscope.security import SeedKeyAlgorithm
security.register(SeedKeyAlgorithm(
name="tecu_scramble",
seed_len=4, key_len=8,
fn=lambda seed, level=0x11: tecu_compute_key_int(seed),
description="modified AES (9 rounds) with output scramblers (synthetic)",
origin="synthetic (TECU-3 training target)",
))
Terminal one brings up the virtual ECU; note the algorithm banner - this is no longer the demo ECU’s toy algorithm:
(venv) $ python3 sim_tecu.py
udscope 0.3.7 - TECU-3 training ECU on socketcan:vcan0
(requests 0x7E0, responses 0x7E8)
Level 0x11 seed-key algorithm: modified AES + scramblers | Ctrl-C to stop
Terminal two runs the end-to-end proof - request a seed, deliberately send a wrong key (to prove the ECU is actually checking), then send the key our extracted algorithm computes:
(venv) $ python3 03_unlock.py
[1] extended diagnostic session
50 03 00 05 01 f4 (0x50 = positive, session 0x03)
[2] seed request, then a key we KNOW is wrong (all zeros)
seed: 0x203174AD
reply: NRC 0x35 (invalid key)
[3] fresh seed, key computed by our extracted algorithm
seed: 0x42943921 -> key: 494ECDA204B93702
reply: 67 12 (0x67 0x12 = key accepted)
[4] proof of unlock: developer session + gated DID 0xF22C
50 60 00 05 01 f4 (0x50 = positive, session 0x60)
DID 0xF22C: 53 81 db 0f 2a bc b1 f5 - readable only when unlocked
security access: REVERSE ENGINEERED AND UNLOCKED
Every byte on the wire is visible in the simulator’s terminal as it happens:
[RX] 7E0 27 11
[TX] 7E8 67 11 42 94 39 21 <- the seed
[RX] 7E0 27 12 49 4e cd a2 04 b9 37 02 <- our computed key
[TX] 7E8 67 12 <- accepted
[RX] 7E0 10 60
[TX] 7E8 50 60 00 05 01 f4 <- developer session granted
A seed-key algorithm that existed only as bytes in a flash image twenty minutes ago now runs on our side of the handshake, and the ECU’s own gates - developer session, gated DIDs - swing open in response. That is the complete loop: table scan, cluster map, code reading, KAT verification, live unlock.
Why This Keeps Working on Real Targets
Vendors know their tables are fingerprints, and you will meet countermeasures: S-boxes computed at boot instead of stored, tweaked tables, whitebox-style encodings, algorithms split across functions, constants hidden in calibration data. All of them raise the cost of extraction. None of them change the fundamentals:
- The ECU must compute the key cheaply and deterministically on a slow CPU, so the algorithm stays small and its structure stays recognizable.
- The keys must live somewhere in the firmware image, and the cipher needs its tables or a compact way to derive them.
- Self-tests, diagnostics and dealer tooling keep creating verification oracles for the analyst.
In over a decade of doing this professionally, I have not met a security-access algorithm that survived a motivated owner of the firmware image - and that includes the fancy ones. The realistic defense is not obscurity of the algorithm; it is the key management around it (per-vehicle diversification, server-side authorization, unlock counters with audit trails), which is a topic for a future article in this series.
Conclusion and What Comes Next
We set out to answer “what does this box compute?” and built a transferable workflow around it: fingerprint the crypto with a Hamming-distance scanner, mine the table cluster (keys, masks, Rcon, and the self-test pair all sat within 64 bytes of the S-box), let the literal pool hand us the constants, count the rounds out of the loop bounds - the single modification that makes the cipher unanswerable by stock libraries - and verify against the firmware’s own known-answer test before touching a bus. The unlock that followed was almost ceremonial.
The next article in the series takes the natural next step: now that we can authenticate, we will use it - dumping ECU firmware ourselves with read-by-address campaigns at scale and the 0x34-0x37 transfer services, turning everything we cannot yet see into a file we can. After that: the algorithm families we only tabled today, from LFSRs to the immobilizer crypto that guards the starter.
The full PoC, the TECU-3 blob and its build system are on GitHub:
SOURCES
ISO 14229-1 Unified diagnostic services (UDS) - Part 1: Application layer: https://www.iso.org/standard/87962.html
ISO 15765-2 Road vehicles - Diagnostic communication over CAN (DoCAN) - Part 2: Transport protocol and network layer services: https://www.iso.org/standard/84211.html
NIST FIPS-197 Advanced Encryption Standard (AES): https://csrc.nist.gov/publications/detail/fips/197/final
NIST FIPS-197 Appendix C example vectors: https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197-upd1.pdf
System Management Bus (SMBus) Specification (CRC-8 definition): https://www.smbus.org/specs/SMBus_3_2_20220112.pdf
ARM v7-M Architecture Reference Manual: https://developer.arm.com/documentation/ddi0403/latest/
Uptane - secure software update framework for automobiles (Linux Foundation project): https://uptane.org/
IEEE 1609.2 - Standard for Wireless Access in Vehicular Environments - Security Services: https://standards.ieee.org/ieee/1609.2/7086/
Ghidra - open-source software reverse engineering framework: https://ghidra-sre.org/
GNU MCU Eclipse / xPack Arm Embedded GCC (toolchain used to build the target): https://github.com/xpack-dev-tools/arm-none-eabi-gcc-xpack
python-can: https://github.com/hardbyte/python-can
can-isotp (ISO 15765-2 for python-can): https://github.com/pylessard/python-can-isotp
udscope - UDS diagnostics toolkit for this series: https://github.com/Hunt-Benito/udscope
Getting Started with CAN Bus Reverse Engineering on Linux (series part 1): https://www.hunt-benito.com/blog/getting-started-with-can-bus-reverse-engineering-on-linux/
Getting Deeper with UDS: Automotive Diagnostics on Linux (series part 2): https://www.hunt-benito.com/blog/getting-deeper-with-uds-automotive-diagnostics-on-linux/