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.