HB Updated Oct 16, 2026

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

Delidded microcontroller on a dark workbench, amber light on the die and bond wires (AI-generated illustration)

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:

  1. Find the cryptography before reading a single instruction - scanning a binary for known crypto tables
  2. Map the table cluster - keys, masks and round constants hide next to the tables they serve
  3. Read the code - loading a raw image in Ghidra, and what a literal pool tells you in ten seconds
  4. Verify against the firmware’s own self-test - most security modules ship with a known-answer test, and it is a free verification vector
  5. Unlock the ECU - plug the extracted algorithm into our udscope toolkit 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 (0x50 through 0x5D) 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.

The TECU-3 memory map with the security cluster highlighted

Everything you need is in the companion repository, including the full build so you can rebuild or fork the target:

https://github.com/Hunt-Benito/getting-the-keys-reverse-engineering-uds-seed-key-algorithms-from-firmware

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

Hamming distance profile of every 256-byte window against the AES S-box

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 0x08000648 sits 00 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 0x08000620 and 0x08000630. High entropy, no structure, exactly cipher-key sized. Candidates.
  • Two 4-byte runs at 0x08000640 and 0x08000644 - mask-shaped, or an unfortunate coincidence. Candidates.
  • A suspicious pair at 0x08000654: a 32-bit value 4d 3c 2b 1a followed by an 8-byte value f7 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:

  1. The seed (argument r0) is expanded into four 32-bit words: the seed itself, the seed XORed with 0xEF3CEB7F, the seed rotated left by 13 (encoded as ror #19 - same thing), and the seed plus 0x64487893. The loop at 0x80003ba stores 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.
  2. The same block is encrypted twice through the function at 0x08000164 - two lanes - with r1 pointing at 0x08000620 and 0x08000630 respectively. 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.
  3. 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 #5 composed 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 polynomial x^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 at 0x08000640. 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: 0x08000643 is loaded into r5, then the writeback load ldrb r1, [r5, #1]! walks 0x08000644 through 0x08000647, 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:

The TECU-3 algorithm: block builder, two 9-round AES lanes, two scramblers, 8-byte key


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:

https://github.com/Hunt-Benito/getting-the-keys-reverse-engineering-uds-seed-key-algorithms-from-firmware


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/

Tags

Account-Takeover Agentic-AI android Android Android-Adb Android-apktool Android-Avd Android-Studio Anti-Bot API-Security Authentication Automotive Backslash-Breakout Baseband Bot-Detection Browser-Automation Browser-Fingerprinting Buffer-Overflow C2 Cache-Key-Collision Camoufox CAN-Bus CAPTCHA Chrome Cloudflare Command-Injection Container-Escape Cross-Site-Scripting Cryptography Cudy CWE-121 CWE-122 CWE-125 CWE-1333 CWE-23 CWE-288 CWE-290 CWE-347 CWE-384 CWE-706 CWE-798 CWE-862 CWE-89 CWE-926 CWE-94 Data-Scraping Denial-of-Service Diagnostics Elixir Embedded-Systems Exploit FastGPT Firmware Frida Geolocation GooglePlay Hardware Heap-Overflow Hi-Browser html-sanitize-ex HTTP2 Hugging-Face Identity-Confusion Information Security Integer-Overflow IoT JNI JWT Lexer-Differential Linux Linux-Kernel llama-cpp LLaMA-Factory LLM LwM2M Machine-Learning MCP MediaTek Memory Disclosure MindsDB Missing-Authorization mitmproxy Mobile-Security MQTT Network-Analysis Networking OAuth OAuth2 OBD2 OIDC OpenID-Connect Out-of-Bounds Page-Cache Patchright Path-Traversal Penetration Testing pgAdmin PHP phpIPAM Playwright PostgreSQL Privilege-Escalation Prompt-Injection Python Race-Condition RCE ReDoS Reverse Engineering Reverse-Engineering Router Ruby Samsung-Bixby Samsung-Exynos Security Security-Research Session Hijacking Session-Hijacking SiYuan SMS Snapchat SNMP Speech-Recognition SQL-Injection SSD SSL-Pinning STIG-Manager Supply-Chain TECNO Tenda Traefik UC-Browser UDS Vulnerability Web Applications Web Scraping Web-Crawling Web-Scraping Web-Security WebGPU Websocket WeChat Wi-Fi Zammad Zephyr Zero-Day