All posts
Testing & CI · · 8 min read

How to implement UDS SecurityAccess correctly

SecurityAccess — UDS service 0x27 — is the one service most ECU projects get wrong in a way that doesn't show up in normal testing. The happy path is trivial: send a seed request, compute a key, send it back, get unlocked. Every SecurityAccess implementation passes that test. The failure modes are all in what happens around it — and none of them produce an error until someone is actually trying to break in.


What service 0x27 is actually for

SecurityAccess gates write access, routine control, and protected DIDs behind a challenge-response exchange: the tester requests a seed (sub-function 0x01, 0x03, ... for odd levels), the ECU returns a random value, the tester derives a key from it using an algorithm both sides know, and sends the key back (sub-function 0x02, 0x04, ...) for the ECU to verify. The point of the seed is to make the key non-replayable and non-guessable — if the ECU returned the same seed every time, or a predictable one, the whole exchange degrades to a fixed password anyone can sniff once.

That's the entire spec-level guarantee: you cannot derive a valid key without knowing the algorithm and, depending on design, a secret embedded in it. Everything else — how strong that guarantee actually is in your firmware — is implementation-defined, which is exactly where it goes wrong.


Four ways SecurityAccess gets built wrong

Predictable seeds. A static seed, an incrementing counter, or a software LFSR with a short period turns the "random" challenge into something an attacker can predict after watching a handful of exchanges. The seed has to come from a real entropy source.

No replay protection. A captured (seed, key) pair should not work a second time. Without some binding between a seed and the session it was issued in — a sequence number, a nonce, a timestamp — a single recorded exchange unlocks the ECU indefinitely.

Non-constant-time key comparison. Comparing the received key to the expected key with a short-circuiting loop (stop at the first mismatched byte) leaks timing information about how many leading bytes were correct. It's a real side channel, and it's also one of the cheapest things to fix — the comparison just has to take the same time regardless of where the mismatch is.

Unlock state that outlives the session. ISO 14229 says a diagnostic session returns to defaultSession either on an explicit request or on an S3 timeout with no TesterPresent traffic. Both paths are supposed to drop the SecurityAccess unlock. It's common to wire the reset into the explicit session-change handler and forget that the S3-timeout path re-enters the same default state through different code — leaving a DID unlocked well past the session that unlocked it.


What a correct implementation looks like

EDS's reference implementation (core/uds_security_algo.c, GPL v2, always free to read) addresses each of the four points above directly:

Seeds come from a hardware TRNG rather than a software PRNG. The key derivation is AES-128-CMAC, not a reversible scheme like XOR. A sequence counter is embedded in the seed and checked on key verification, so a (seed, key) pair from an earlier exchange is rejected once the counter has advanced — the replay-protection property above. And the byte comparison between received and expected key runs through a fixed-time loop specifically to avoid the timing side-channel.

None of this is exotic. It's the standard shape of a challenge-response scheme done properly — TRNG entropy, a real cryptographic primitive instead of an ad-hoc mixing function, an anti-replay binding, and a comparison that doesn't leak. What's worth internalizing is that "we implemented SecurityAccess" and "we implemented SecurityAccess correctly" are different claims, and the gap between them is invisible until someone tests specifically for it.


Proving it — not just implementing it

A test that only exercises the unlock path — send a seed request, compute the right key, confirm the DID is now readable — proves the happy path works. It proves nothing about whether the DID was actually protected in the first place. A real SecurityAccess test has to assert the locked state too.

Here's the campaign we use for this, runnable against xaloqi-testlab-core's built-in virtual ECU — free, no hardware, no CAN interface:

jobs:
  security_access:
    description: >
      Verify that DID F187 is denied before SecurityAccess and readable
      after a level-1 unlock.

    timeout_ms: 10000
    on_failure: abort

    steps:
      - action: session
        value: extended

      - action: read_did
        did: "0xF187"
        expect_nrc: "0x33"        # securityAccessDenied — must be locked

      - action: security_access
        level: 1

      - action: read_did
        did: "0xF187"
        save_as: part_number      # must succeed now

      - action: session
        value: default

Run it with:

testlab-run \
  --config testlab_config.yaml \
  --campaign examples/security_access/campaign.yaml \
  --job security_access \
  --virtual
[01/05] session(extended)          → OK
[02/05] read_did(0xF187)           → OK
[03/05] security_access(level=1)   → OK
[04/05] read_did(0xF187)           → OK
[05/05] session(default)           → OK

Result:  PASS

The step that matters most is the second one: expect_nrc: "0x33" asserts that reading a protected DID before unlock fails with exactly securityAccessDenied — not just "some error." security_access then performs the real seed/key exchange (real AES-CMAC, real TRNG seed on the ECU side), and the second read_did must now succeed.

One more step is worth doing before you trust this test at all: break it on purpose. Change expect_nrc: "0x33" to "0x31" and re-run. If the assertion is real, the campaign now fails — the ECU still returns 0x33, not the 0x31 the step now (wrongly) expects:

[02/05] read_did(0xF187)  → FAIL  NRC 0x33 (securityAccessDenied)

ABORTED at step 2 (on_failure: abort)
Result:  FAIL

If that mutation doesn't fail, the assertion was never checking anything — it's a vacuous pass waiting to hide a real regression the day someone actually breaks the lock.


The security_access example is part of xaloqi-testlab-core — free, forever, Apache-2.0. pip install xaloqi-tester →