Wiki / Protocols

UDS (ISO 14229)

The diagnostic language an ECU speaks over CAN. One-byte service ids, a positive response that echoes the service plus 0x40, and a negative response whose reason code tells you exactly which gate refused you.

One UDS request, three possible answers
Request on 0x7E0: SID + parameters (22 F1 90)
Positive response on 0x7E8: SID + 0x40
62 F1 90 followed by the record bytes
Negative response: 7F SID NRC
7F 22 33 securityAccessDenied: the service exists, you are not unlocked
Pending: 7F SID 78, repeated
Keep waiting, the real answer follows
A negative response is not a failure of your tooling, it is the ECU naming the gate. 0x11 means the service does not exist here, 0x7F means it exists but not in this session, 0x33 means it exists in this session but you have not passed SecurityAccess, and 0x78 means it is working on it and you must extend your timeout instead of retrying.

UDS (Unified Diagnostic Services, ISO 14229) is the request/response protocol a workshop tool speaks to an ECU. It is an application layer: it needs a transport underneath, which on a vehicle is almost always Wiki: iso-tp over Wiki: can-bus. Every request starts with a one-byte service id, every positive answer echoes that service id plus 0x40, and every refusal is three bytes that name the exact reason.

What it is

UDS defines the services a diagnostic client may ask an ECU to perform: read a record, write a record, reset, run a routine, download new software. It says nothing about how the bytes travel. On CAN the pairing is fixed by convention:

Address Meaning
0x7E0 to 0x7E7 physical request, addressed to one specific ECU
0x7E8 to 0x7EF the matching response ids (request id + 8)
0x7DF functional request, broadcast to every diagnostic ECU on the bus

A request is SID [parameters]. A positive response is SID+0x40 [data]. A negative response is always three bytes: 7F <SID> <NRC>.

The same protocol also runs over DoIP (diagnostics over IP, ISO 13400) on newer vehicles and over K-line on old ones. The service ids and reason codes are identical; only the transport changes.

The services you actually meet

Service SID Positive What it does
DiagnosticSessionControl 0x10 0x50 Switch session: 01 default, 02 programming, 03 extended
ECUReset 0x11 0x51 Reboot the ECU (01 hard reset, 03 soft reset)
ReadDataByIdentifier 0x22 0x62 Read a record by its 2-byte DID
SecurityAccess 0x27 0x67 The seed/key challenge that unlocks privileged functions
WriteDataByIdentifier 0x2E 0x6E Write a record by DID
RoutineControl 0x31 0x71 Start, stop, or read the result of a built-in routine
RequestDownload 0x34 0x74 Open a transfer to write memory or flash
TesterPresent 0x3E 0x7E Keepalive that stops the session timing out

Most services take a subfunction as their first parameter byte. Bit 7 of that byte is the suppressPosRspMsgIndicationBit: sending 3E 80 instead of 3E 00 asks the ECU to stay silent on success, which is how a keepalive avoids flooding the bus. A non-default session dies after roughly five seconds (the S3 timer) unless a TesterPresent keeps it open, and that single fact explains most "it worked in the terminal but not in my script" reports.

SecurityAccess, the gate

0x27 runs as paired subfunctions: an odd one requests a seed, and the even one immediately above it sends the key back.

27 01           -> 67 01 <seed bytes>      requestSeed, level 1
27 02 <key>     -> 67 02                   sendKey, level 1 granted

Higher levels (03/04, 05/06, ...) exist and usually guard more dangerous services. Two behaviours are worth knowing before you conclude anything from a capture: an ECU that is already unlocked answers requestSeed with an all-zero seed, and a wrong key consumes the seed, so you must request a fresh one for every attempt.

The key is computed from the seed by a fixed manufacturer transform. It is not a cryptographic protocol, it is a shared secret in the shape of a small function, and its whole security rests on that function staying inside the vendor. It rarely does: the transform lives in a diagnostic tool, in an ECU firmware image, or in a leaked specification, and recovering it from any of those is the real work. Once recovered, passing the gate is mechanical.

Negative response codes

The NRC is the most useful byte in the protocol because it distinguishes "not here" from "not yet".

NRC Name What it really tells you
0x11 serviceNotSupported This ECU does not implement the service at all
0x12 subFunctionNotSupported Right service, wrong subfunction
0x13 incorrectMessageLengthOrInvalidFormat Your length is wrong, check the ISO-TP framing
0x22 conditionsNotCorrect Valid request, wrong state (no session, no seed requested)
0x24 requestSequenceError Right steps, wrong order
0x31 requestOutOfRange The DID or address does not exist
0x33 securityAccessDenied It exists, you are not unlocked
0x35 invalidKey Your key was wrong; the seed is now burned
0x36 exceedNumberOfAttempts Too many wrong keys, you are locked out
0x37 requiredTimeDelayNotExpired Locked out and still serving the delay
0x78 responsePending Working on it, extend your timeout
0x7F serviceNotSupportedInActiveSession Exists, but not in the session you are in

0x78 is not an error. The ECU may repeat it many times while a slow operation runs, and a client that treats it as a failure will abort a request that was about to succeed.

How to work it

The raw path uses can-utils and reads exactly like the protocol:

sudo ip link set can0 up type can bitrate 500000
isotpsend -s 7E0 -d 7E8 can0 <<< "10 03"     # enter the extended session
isotprecv -s 7E0 -d 7E8 can0 | xxd           # in a second terminal, before you send

The library path is udsoncan on top of can-isotp, and it handles the response matching, the 0x78 waiting, and the NRC decoding for you:

import isotp, udsoncan
from udsoncan.connections import PythonIsoTpConnection
from udsoncan.client import Client

s = isotp.socket()
s.bind("can0", isotp.Address(rxid=0x7E8, txid=0x7E0))

with Client(PythonIsoTpConnection(s), request_timeout=2) as client:
    client.change_session(0x03)
    client.unlock_security_access(1)          # needs config["security_algo"]
    print(client.read_data_by_identifier(0xF190))

unlock_security_access only works once you give the client your recovered transform: set client.config["security_algo"] to a callable taking (level, seed, params) and returning the key bytes. Without it the library has nothing to send.

For discovery rather than exploitation, caringcaribou scans for responding ids and supported services (caringcaribou uds discovery, then uds services), which is the fastest way to find out which of the ids above are alive on a bus you do not have documentation for.

Pitfalls

  • Physical versus functional. SecurityAccess and most reads must be addressed to the ECU on its physical id. Broadcasting them to 0x7DF often gets silence, which reads like "no such service" and is not.
  • The session times out. Without TesterPresent you drop back to the default session mid-attack and every later request answers 0x7F.
  • A burned seed. After a wrong key, requesting the same seed again and replaying an old key pair proves nothing. Read the fresh seed every time.
  • Lockout is real. 0x36 and 0x37 mean the ECU is counting your failures, often across power cycles. Brute forcing a 4-byte key over UDS is not a plan.
  • Writes and routines are not reversible. 0x2E, 0x31 and 0x34 change a real vehicle. Bench harness only.

Further reading