Reverse engineering logiciel d'un drone Parrot

Analyse du firmware d'un drone parrot dans le cadre du projet CPEFly

Le

Cet article fait suite à Reverse engineering des moteurs d'un drone Parrot (CPEFly), disponible ici.

L'objectif de cette partie est d'en apprendre plus sur le fonctionnement du protocole de contrôle des moteurs. Pour rappel, de ce qu'on l'on sait, les moteurs sont contrôlés par un programme sur la carte mère (processeur ARMv8) et les commandes sont transmises par une liaison UART avec un protocole propriétaire Parrot inconnu et non documenté.

Extraction du firmware

La première étape consistait à obtenir le firmware de l'AR DRONE 2, afin de pouvoir l'analyser.

Un firmware est un logiciel spécial de bas niveau intégré directement au matériel d'un appareil (comme une puce ROM, EPROM, ou Flash). Il fournit les instructions essentielles qui permettent au matériel de fonctionner, de communiquer avec d'autres composants.

Il existe deux méthodes pour acquérir un firmware :

  • L'extraction physique directement depuis les puces de mémoire du matériel.
  • L'extraction à partir d'un fichier de mise à jour (update file).

J'ai opté pour la seconde option. Il est beaucoup plus simple et rapide de télécharger un fichier de mise à jour directement en ligne si celui-ci est disponible que d'effectuer une extraction matérielle complexe. Encore faut-il qu'il soit facile d'en extraire le firmware.

Les fichiers relatifs aux mises à jour peuvent être téléchargés directement et gratuitement sur le site officiel de Parrot.

Site officiel Parrot : page de documentation listant le firmware de mise à jour AR Drone 2 v2.4.8 à télécharger

Le fichier de mise à jour est au format PLF (d'après son extension), un autre format propriétaire Parrot.

Étude du format de fichier PLF

La première étape consiste à vérifier si le contenu du fichier est chiffré.

En extrayant les chaines de caractères présentes dans le fichier (via la commande strings), on obtient une série de chaines de caractères lisibles et bien formée, ce qui indique que le firmware n'est pas entièrement chiffré.

21 stream tcp nowait root ftpd ftpd -w /data/video
5551 stream tcp nowait root ftpd ftpd -w /update

On peut vérifier si le fichier n'est pas chiffré en analysant son entropie avec binwalk.

L'entropie H d'un fichier mesure la quantité minimale d'information (en bit) nécessaire pour coder un symbole (un octet) de ce fichier. Elle est calculée en fonction de la fréquence d'apparition de chaque octet dans le fichier : Formule mathématique de l'entropie de Shannon utilisée pour mesurer si le fichier est chiffré Où pi est la probabilité (la fréquence) d'apparition de l'octet de valeur i, et N est le nombre total de valeurs d'octets possibles (256 pour 8 bits).

$ binwalk -E ardrone_2_update.plf
DECIMAL       HEXADECIMAL     ENTROPY
--------------------------------------------------------------------------------
0             0x0             Falling entropy edge (0.712636)
12288         0x3000          Rising entropy edge (0.961492)
2068480       0x1F9000        Falling entropy edge (0.733456)
2195456       0x218000        Rising entropy edge (0.991415)
4470784       0x443800        Rising entropy edge (0.976645)
5439488       0x530000        Rising entropy edge (0.976732)
10119168      0x9A6800        Rising entropy edge (0.992391)

binwalk : courbe d'entropie du firmware proche de 1, signe que son contenu est chiffré ou compressé

On remarque que le fichier a une entropie forte (proche de 1), ce qui signifie qu'une grande partie de son contenu est chiffré ou compressé. On peut vérifier si il s'agit de compression en réutilisant l'outil binwalk, cette fois pour détecter des données compressées en se basant sur les magic bytes.

Les Magic Bytes sont des séquences d'octets spécifiques (des signatures) situées généralement au début d'un fichier et utilisées pour identifier de manière catégorique son format (y compris les types de compression et les systèmes de fichiers intégrés), indépendamment de l'extension.

binwalk ardrone2_update.plf

binwalk : détection de deux zImage ARM et de nombreux blocs gzip dans le fichier de mise à jour PLF

Binwalk détecte beaucoup de données compressées, ce qui est un très bon signe. Si le firmware avait été chiffré (ce qui est courant), il aurait fallu trouver un moyen de le déchiffrer, ce qui aurait considérablement complexifié la suite de la tâche.

Extraction d'un premier filesystem

L'outil binwalk a révélé que le fichier de mise à jour contenait beaucoup de données compressées, dont deux zImage.

zImage is compressed version of the Linux kernel image that is self-extracting.

J'ai commencé par extraire chaque zImage et les données compressées qui les suivaient dans deux fichiers distincts, PLF_PART_1 et PLF_PART_2, en utilisant les offset indiqué par binwalk.

binwalk : les deux zImage et leurs données gzip regroupées en deux parties PLF_PART_1 et PLF_PART_2

dd if=ardrone2_update.plf bs=1 skip=772 count=2179912 of=PLF_PART_1 # (1)
dd if=ardrone2_update.plf bs=1 skip=2180684 of=PLF_PART_2 #(2)

J'ai ensuite extrait les deux premières données compressées (GZIP) de la première partie du fichier (PLF_PART_1) dans deux fichiers distincts (PLF_PART_1_gz_1.gz et PLF_PART_1_gz_2.gz). Je les ai décompressés individuellement puis je les ai concaténés.

# Extraction des deux premiers GZIP
dd if=../PLF_PART_1 bs=1 skip=13069 count=1183519 of=PLF_PART_1_gz_1.gz
dd if=../PLF_PART_1 bs=1 skip=1196588 of=PLF_PART_1_gz_2.gz
# Décompression des données
gunzip PLF_PART_1_gz_1.gz
gunzip PLF_PART_1_gz_2.gz
# Concaténation dans un nouveau fichier
cp PLF_PART_1_gz_1 PLF_PART_1_decompressed
cat PLF_PART_1_gz_2 >> PLF_PART_1_decompressed

J'ai ensuite lancé l'outil binwalk en mode extraction sur le fichier obtenu et j'ai pu obtenir un filesystem.

binwalk -e PLF_PART_1_decompressed

Terminal : l'extraction binwalk du premier bloc révèle une archive cpio et un système de fichiers racine

Arborescence du premier filesystem : structure minimaliste à base de busybox, typique d'un noyau d'amorçage

Le système de fichiers extrait s'est avéré être minimaliste, ne contenant qu'une structure de base et peu de fichiers exécutables ou de configurations utiles pour une analyse approfondie.

La présence de deux images de noyau (zImage) et la nature minimaliste/rudimentaire du premier système de fichiers corrobore l'hypothèse d'un système d'amorçage initial (staging kernel) ou d'un système de recovery, dont la fonction est de préparer l'environnement pour le système d'exploitation principal et son filesystem associé, situé dans la deuxième partie du fichier.

Le contenu d'intérêt se situe donc très probablement dans la deuxième partie du fichier.

Extraction du deuxième filesystem

Comme la deuxième partie du fichier contient beaucoup de données compressées, j'ai développé un petit script Python dont le rôle est de récupérer les données compressées et de les décompresser. En observant les fichiers ainsi générés, je m'aperçois qu'ils ont tous un chemin en guise d’entête, suivi de 12 octets nuls (00).

xxd : chaque fichier décompressé débute par son chemin suivi d'octets nuls servant d'entête

J'en déduis que l'entête indique l'emplacement du fichier, et qu'elle est suivie du contenu de celui-ci. J'ai donc adapté mon script en conséquence afin de reconstruire l'arborescence complète.

import re
import subprocess
import os

os.system("rm files/*")

output = subprocess.check_output(["binwalk","../PLF_PART_2"]).decode()
match = re.findall(r"([0-9]+)       0x([0-9A-F]+)        gzip compressed data, from Unix, last modified:", output)
offsets = [offset for (offset,offset_hex) in match] 

for i in range(0,len(offsets)-1):
    start_offset = int(offsets[i])
    end_offset = int(offsets[i+1])
    command = f"dd if=../PLF_PART_2 bs=1 skip={start_offset} count={end_offset-start_offset} of=files/file{i}.gz"
    os.system(command)
    os.system(f"gunzip files/file{i}.gz")
    f = open(f"files/file{i}","rb")
    file_data = f.read()
    f.close()
    filename = file_data.split(b"\x00")[0].decode()
    file_real_data = file_data[len(filename)+13:]
    print(filename)
    folders = filename.split("/")[:-1]
    for i in range(len(folders)+1):
        print("fs/" + "/".join(folders[:i]))
        if not os.path.exists("fs/" + "/".join(folders[:i])):
            os.makedirs("fs/" + "/".join(folders[:i]))
    f = open("fs/" + filename,"wb")
    f.write(file_real_data)
    f.close()

Après exécution du script, on arrive à reconstruire une arborescence complète, comme on peut le voir sur la capture ci-dessous.

Arborescence du second filesystem reconstruit : nombreux binaires et scripts shell dont program.elf

Celle-ci contient plusieurs programmes et scripts Shell.

Analyse du firmware (motherboard)

On a désormais accès à un système de fichier contenant un grand nombre d'éléments.

Afin d'identifier les programmes / scripts shells d'intérêt, c'est-à-dire ceux en lien avec le pilotage des moteurs, je liste uniquement les fichiers qui contiennent la chaine de caractère motor.

grep -lF "motor" \*

Terminal : grep identifie program.elf comme binaire contenant les chaines motor et /dev/ttyPA0

J'obtiens un exécutable au format ELF, "program.elf".

Reverse engineering du programme principal

Le fichier program.elf est un binaire au format ELF destiné aux architectures ARM. Afin de l'analyser, j'ai utilisé l'outil « Ghidra », développé par la NSA.

Ghidra est un outil de rétro-ingénierie logiciel (SRE), gratuit et open-source, utilisé pour l'analyse de code binaire via le désassemblage et la décompilation.

L'avantage de Ghidra est qu'il supporte beaucoup d'architecture (dont l'ARM), qu'il génère du pseudocode C plus facile à interpréter que l'assembleur et qu'il est gratuit.

Binaire strippé

Un point important à noter est que le binaire est strippé, ce qui complexifie l'analyse.

Un binaire strippé est un fichier exécutable dont on a retiré les tables de symboles et les informations de débogage.

J'ai donc renommé dans Ghidra chaque fonction au fur et à mesure de mon analyse, en me basant sur leur rôle (du moins l'idée que j'avais de leur rôle).

Dans la suite de cette étude, j’appellerai les fonctions par le nom que je leur ai donné, afin de rendre la lecture plus facile.

Fonctionnement général

Comme le programme contient beaucoup de code, j'ai commencé par son point d’entrée. J'ai ensuite suivi les appels aux fonctions afin de remonter jusqu'à une fonction qui s'apparente à la fonction main. Celle-ci est passée en argument d'une autre fonction __libc_start_main, qui a pour rôle d'initialiser l'environnement de la bibliothèque C et de gérer les préparatifs nécessaires (comme les arguments et les constructeurs) avant d'appeler la fonction principale du programme, main().

Ghidra : le point d'entrée passe la fonction main à __libc_start_main, permettant de la localiser

Cela permet de déterminer sans trop de doute l'offset de la fonction main du programme.

Ghidra : pseudocode de la fonction main qui appelle setup_hardware puis lance run en boucle

Fonction main()

Ghidra : la fonction run attend des évènements via epoll et exécute en boucle les callbacks du timer

Ghidra : fin de la fonction run montrant la boucle infinie while true

Fonction run()

Ghidra : début de setup_hardware verrouillant program.elf et détectant la version de la carte mère

Ghidra : suite de setup_hardware initialisant les services réseau et appelant BLC_setup

Ghidra : fin de setup_hardware affichant une erreur fatale si l'initialisation du matériel échoue

Fonction Setup_hardware()

La fonction main appelle une fonction setup_hardware qui initialise l'ensemble des composants du drone (GPS, Moteurs...) puis lance un fonction run qui itère à l'infini. La fonction setup_hardware initialise un protocole qui s'appelle BLC et semble être le protocole de gestion des moteurs.

En analysant les références à la chaine de caractère "BLC", on détermine un ensemble des fonctions en lien avec le protocole.

Ghidra : liste des chaines contenant BLC révélant les fonctions liées au protocole de gestion des moteurs

Ghidra : références croisées des chaines BLC hex available vers les fonctions du protocole

Initialisation des moteurs

Il y a une fonction qui a retenu mon attention, que j'ai baptisée BLC_checkup_motor. Elle prend en argument le numéro d'un moteur, le firmware du moteur, le checksum du firmware et deux autres variables.

                    /* Resultat des commandes, chaque element indique le resultat d'une commande */
  *motor_command_check = 0;
  motor_command_check[1] = 0;
  motor_command_check[2] = 0;
  motor_command_check[3] = 0;
                    /* Preparation des commandes */
  command_E0_check_motor_state[0] = 0xe0;
  uart_command_response = 0x40;
  command_A1_set_motor_ok[0] = 0xa1;
  command_40_get_motor_version_2[0] = 0x40;
                    /* Appelle de la commande 1: E0 */
  print_blc(7,"BLC call for motor %d\n",motor_number);
  intermediate_buffer[0] = 1;
  BLC_send(command_E0_check_motor_state,intermediate_buffer,0);
  intermediate_buffer[0] = 1;
  read_is_ok = BLC_recv(&uart_command_response_E0,intermediate_buffer,0);
  motor_command_check[3] = 0;
  read_is_ok_ = read_is_ok & 0xffff;
  if ((read_is_ok_ == 0) &&
     (uart_command_response = (uint)uart_command_response_E0, uart_command_response != 0x50)) {
    if (uart_command_response != 0) {
      read_is_ok_ = 0xffff;
      read_is_ok = 0xffffffff;
      goto need_reflash;
    }
  }

Cette première partie du code envoie la commande 0xE0 au moteur et récupère la réponse. Si elle est égale à 0x50 ou différente de 0x00, le code saute vers un bout de code qui est responsable du flashage du firmware sur la motorboard.

  else {
need_reflash:
    if (firmware_checksum == 0) {
check_and_flash_motor_if_needed:
      read_is_ok_ = uart_command_response;
      if (firmware_ptr == 0) {
        read_is_ok_ = read_is_ok & 0xffff;
      }
      uart_command_response = read_is_ok & 0xffff;
      if (firmware_ptr == 0) goto something_is_not_ok;
check_and_flash_motor_if_needed_2:
      if (uart_command_response != 0) goto flash_and_start_failed;
      uart_command_response = 0;
      if ((param_4 & 2) != 0) {
        print_blc(7,"BLC start flash\n");
        iVar5 = 0;
                    /* Flashing the firmware */
        do {
          command_flash_71 = 0x71;
          firmware_ptr_+_40 = firmware_ptr + 0x40;
          intermediate_buffer_1 = 1;
          BLC_send(&command_flash_71,&intermediate_buffer_1,0);
          intermediate_buffer_1 = 0x40;
          BLC_send(firmware_ptr,&intermediate_buffer_1,0);
          intermediate_buffer_1 = 1;
          iVar3 = BLC_recv(&command_0x91_get_checksum,&intermediate_buffer_1,0);
          iVar5 = iVar5 + 2;
          firmware_ptr = firmware_ptr + 0x80;
          if ((iVar3 == 0) || ((char)command_0x91_get_checksum != 'p')) {
flash_aborted:
            read_is_ok = 0xffffffff;
            print_blc(7,"BLC flash aborted\n");
            goto flash_done;
          }
          command_flash_71 = 0x71;
          intermediate_buffer_1 = 1;
          BLC_send(&command_flash_71,&intermediate_buffer_1);
          intermediate_buffer_1 = 0x40;
          BLC_send(firmware_ptr_+_40,&intermediate_buffer_1,0);
          intermediate_buffer_1 = 1;
          iVar3 = BLC_recv(&command_0x91_get_checksum,&intermediate_buffer_1,0);
          if ((iVar3 == 0) || ((command_0x91_get_checksum & 0b11111111) != 0x70))
          goto flash_aborted;
        } while (iVar5 != 120);
        read_is_ok = 0;
        print_blc(7,"BLC flash done\n");

Cette partie du code s'occupe d'envoyer le nouveau firmware sur le motorboard.
- Dans une itération, il envoie d'abord le code 0x71 pour annoncer qu'il va envoyer de la donnée, puis il envoie 0x40 octets du firmware. Il vérifie ensuite que la réponse est égale à 0x70 avant de répéter une nouvelle fois l'opération.
- Il itère 120/2=60 fois. En effet, le compteur s’incrémente de deux à chaque itération, ce qui est logique, car il envoie deux blocs de 0x40=64 octets en une itération. Au total, 7680 octets sont transférés.

flash_done:
        memset(&multi_usage_buffer,0,0x78);
        if (firmware_checksum == 0) {
          read_is_ok_ = 0xffff;
          read_is_ok = 0xffffffff;
          uart_command_response = read_is_ok_;
        }
        else {
          print_blc(7,"BLC verify\n");
          multi_usage_buffer_2 = CONCAT31(multi_usage_buffer_2._1_3_,0x91);
          intermediate_buffer_1 = 1;
          BLC_send(&multi_usage_buffer_2,&intermediate_buffer_1,0);
          intermediate_buffer_1 = 0x78;
          BLC_recv(&multi_usage_buffer,&intermediate_buffer_1,0);
          compteur = 0;
          do {
            if (((*(char *)(firmware_checksum + compteur) !=
                  *(char *)((int)&multi_usage_buffer + compteur)) ||
                (*(char *)(firmware_checksum + compteur + 1) !=
                 *(char *)((int)&multi_usage_buffer + compteur + 1))) ||
               ((*(char *)(firmware_checksum + compteur + 2) !=
                 *(char *)((int)&multi_usage_buffer + compteur + 2) ||
                (*(char *)(firmware_checksum + compteur + 3) !=
                 *(char *)((int)&multi_usage_buffer + compteur + 3))))) {
              read_is_ok = 0xffffffff;
              print_blc(7,"BLC verify FAILED - page %d \n");
              read_is_ok_ = 0xffff;
              uart_command_response = read_is_ok_;
              goto something_is_not_ok;
            }
            compteur = compteur + 4;
          } while (compteur != 0x78);
          read_is_ok_ = read_is_ok & 0xffff;
          print_blc(7,"BLC verify OK\n");
          uart_command_response = read_is_ok_;
        }
        goto something_is_not_ok;
      }

Il vérifie ensuite si la fonction a été appelée avec le checksum du firmware, et si c'est le cas elle envoie la commande 0x91 (qui demande au motorboard d'envoyer le checksum de son firmware) et il le compare avec le sien.

      }
start_motor:
      motor_command_check[3] = 1;
      intermediate_buffer[0] = 1;
      BLC_send(command_A1_start_motor,intermediate_buffer,0);
      local_104 = FUN_000186a0;
      intermediate_buffer[0] = 4;
      multi_usage_buffer = 0;
      BLC_recv(&uart_command_response_E0,intermediate_buffer,&multi_usage_buffer);
      if (uart_command_response_E0 != 0xa0) {
        print_blc(7,"BLC start FAILED on motor %d\n",motor_number);
        read_is_ok = 0xffffffff;
        goto flash_and_start_failed;
      }
    }

Il initie les moteurs en envoyant la commande 0xA1. Si le résultat n'est pas 0xA0, c'est que l'initialisation n'a pas fonctionné.

    else {
      if ((read_is_ok_ == 0) && ((param_4 & 1) != 0)) {
        print_blc(7,"BLC check BLC memory corruption on motor %d\n",motor_number);
        memset(&multi_usage_buffer,0,120);
        print_blc(7,"BLC verify\n");
        command_flash_71 = 0x91;
        multi_usage_buffer_2 = 1;
        BLC_send(&command_flash_71,&multi_usage_buffer_2,0);
        multi_usage_buffer_2 = 0x78;
        BLC_recv(&multi_usage_buffer,&multi_usage_buffer_2,0);
        compteur = 0;
        do {
          if ((((*(char *)(firmware_checksum + compteur) !=
                 *(char *)((int)&multi_usage_buffer + compteur)) ||
               (*(char *)(firmware_checksum + compteur + 1) !=
                *(char *)((int)&multi_usage_buffer + compteur + 1))) ||
              (*(char *)(firmware_checksum + compteur + 2) !=
               *(char *)((int)&multi_usage_buffer + compteur + 2))) ||
             (*(char *)(firmware_checksum + compteur + 3) !=
              *(char *)((int)&multi_usage_buffer + compteur + 3))) {
            print_blc(7,"BLC verify FAILED - page %d \n");
            print_blc(7,"BLC memory corrupted on motor %d\n",motor_number);
            param_4 = param_4 | 2;
            print_blc(7,"BLC erase memory on motor %d\n",motor_number);
            intermediate_buffer[0] = 1;
            command_61 = 0x61;
            BLC_send(&command_61,intermediate_buffer,0);
            intermediate_buffer[0] = 1;
            read_is_ok = BLC_recv(&uart_command_response_E0,intermediate_buffer,0);
            uart_command_response = (uint)uart_command_response_E0;
            if (uart_command_response == 0x60) goto check_and_flash_motor_if_needed;
            read_is_ok_ = 0xffff;
            read_is_ok = 0xffffffff;
            goto error_in_erasing_firmware;
          }
          compteur = compteur + 4;
        } while (compteur != 120);
        print_blc(7,"BLC verify OK\n");
      }
error_in_erasing_firmware:
      uart_command_response = read_is_ok_;
      if (firmware_ptr != 0) goto check_and_flash_motor_if_needed_2;
      uart_command_response = read_is_ok & 0xffff;
something_is_not_ok:
      if (read_is_ok_ == 0) goto start_motor;
    }
    if (uart_command_response != 0) goto flash_and_start_failed;
  }

Le code ci-dessus s’exécute si la motorboard n'a pas demandé de flash un nouveau firmware. Il envoie la commande 0x91 pour récupérer le checksum du firmware actuel de la motorboard. Si le checksum diffère de celui de la carte mère, alors la commande 0x61 est envoyée, ce qui a pour effet d'effacer le firmware actuel du motorboard. Si la commande s'est bien déroulée, la valeur 0x60 doit être retournée par la motorboard et le code jump vers le flashage du nouveau firmware.

  make_sleep(100);
  motor_number_4LSB[0] = (byte)motor_number & 0b00001111;
  intermediate_buffer[0] = 1;
  BLC_send(motor_number_4LSB,intermediate_buffer,0);
  intermediate_buffer[0] = 1;
  BLC_send(command_40_get_motor_version_2,intermediate_buffer,0);
  intermediate_buffer[0] = 2;
  read_is_ok = BLC_recv(&uart_command_response_E0,intermediate_buffer,0);
  if ((read_is_ok & 0xffff) == 0) {
    *(byte *)((int)motor_command_check + 9) = uart_command_response_E0;
    *(byte *)((int)motor_command_check + 10) = local_6f;
    if ((uint)local_6f + (uint)uart_command_response_E0 * 256 < 263) {
      intermediate_buffer[0] = 6;
    }
    else {
      intermediate_buffer[0] = 9;
    }
    read_is_ok = BLC_recv(motor_command_check,intermediate_buffer,0);
    if ((read_is_ok & 0xffff) == 0) {
      motor_command_check[3] = 2;
      if (*(short *)motor_command_check == -1) {
        subversion_motor = 1;
        uVar3 = 6;
        uVar5 = 10;
        motor_number_cpy = 3;
        *(undefined *)motor_command_check = 3;
        *(undefined *)((int)motor_command_check + 1) = 0;
        uVar1 = 10;
        *(undefined *)((int)motor_command_check + 2) = 1;
        subversion_motor_hardware = 6;
        *(undefined *)((int)motor_command_check + 3) = 1;
        *(undefined *)(motor_command_check + 1) = 6;
        *(undefined *)((int)motor_command_check + 5) = 10;
        *(undefined *)((int)motor_command_check + 6) = 1;
        *(undefined *)((int)motor_command_check + 7) = 6;
        *(undefined *)(motor_command_check + 2) = 10;
        version_motor = 0;
        version_motor_hardware = subversion_motor;
        uVar4 = subversion_motor;
      }
      else if (*(short *)motor_command_check == 0) {
        uVar1 = *(undefined *)((int)motor_command_check + 5);
        subversion_motor_hardware = *(undefined *)(motor_command_check + 1);
        subversion_motor = *(undefined *)((int)motor_command_check + 2);
        uVar3 = *(undefined *)((int)motor_command_check + 7);
        uVar5 = *(undefined *)(motor_command_check + 2);
        motor_number_cpy = 5;
        *(undefined *)motor_command_check = 5;
        *(undefined *)((int)motor_command_check + 1) = 0;
        version_motor = 0;
        version_motor_hardware = *(undefined *)((int)motor_command_check + 3);
        uVar4 = *(undefined *)((int)motor_command_check + 6);
      }
      else {
        subversion_motor_hardware = *(undefined *)(motor_command_check + 1);
        uVar1 = *(undefined *)((int)motor_command_check + 5);
        motor_number_cpy = *(undefined *)motor_command_check;
        version_motor = *(undefined *)((int)motor_command_check + 1);
        subversion_motor = *(undefined *)((int)motor_command_check + 2);
        uVar3 = *(undefined *)((int)motor_command_check + 7);
        uVar5 = *(undefined *)(motor_command_check + 2);
        version_motor_hardware = *(undefined *)((int)motor_command_check + 3);
        uVar4 = *(undefined *)((int)motor_command_check + 6);
      }
      print_blc(7,
                "BLC motor %d soft version %d.%d, hard version %d.%d, supplier %d.%d, lot number %02 d/%02d, FVT1 %02d/%02d/%02d\n"
                ,motor_number,*(undefined *)((int)motor_command_check + 9),
                *(undefined *)((int)motor_command_check + 10),motor_number_cpy,version_motor,
                subversion_motor,version_motor_hardware,subversion_motor_hardware,uVar1,uVar4,uVar3,
                uVar5);
      return read_is_ok;
    }
  }

Cette dernière partie du code sélectionne le moteur (commande unicast) en envoyant comme commande le numéro du moteur, puis récupère sa version en envoyant la commande 0x40. Il parse ensuite la version pour l'afficher sur le terminal de la carte mère.
On en déduit donc le résultat suivant:

Nom de la commande Valeur Description Valeur de retour
Récupérer le statut du moteur E0 Récupère le statut du moteur 00 : ok 50 : besoin de flash la mémoire
Écrire dans la mémoire du contrôleur du moteur 71 XX…XX Écris dans la mémoire le programme du moteur. Le programme doit faire 64 octets, représenté par les XX…XX dans la commande. La commande doit être envoyée 60 fois (60*64 octets) 70
Récupérer le checksum de la mémoire 91 Demande de récupérer le checksum du moteur. 120 octets, correspondant au checksum de la mémoire
Initier le moteur A1 "Lance" le moteur A0
Assigner un moteur comme étant le moteur 1,2,3 ou 4 01 ou 02 ou 03 ou 04 Assigner un moteur comme étant le moteur 1,2,3 ou 4 00
Effacer le contenu de la mémoire 61 Effacer le contenu de la mémoire 60
Récupérer la version du moteur 40 Récupérer la version du moteur sous la forme de 11 octets. Exemple : 01 0b 03 00 01 01 0a 0a 1a 0a 0a = soft version 1.11, hard version 3.0, supplier 1.1, lot number 10/10, FVT1 26/10/10 11 octets

Ces résultats rappellent ceux trouvé sur internet que je cite dans mon précédent article, avec néanmoins quelques différences.

On peut également résumer le processus de flashage du firmware par le schéma ci-dessous.

Diagramme : vérification du checksum puis flashage conditionnel du firmware sur la motorboard

Transmission de la commande vitesse au moteur

Une autre fonction qui a piqué mon intérêt est la fonction responsable du pilotage de la vitesse des moteurs. Pour être honnête, je l'ai trouvé en tâtonnant. J'ai regardé toutes les fonctions qui faisaient des appels à la fonction qui envoie des données par le port UART. Je suis tombé sur celle ci-contre, et elle semble correspondre. Elle prend un argument un array de 4 int (32 bits), fait des calculs et génère une commande qu'elle envoie.

La première partie du code vérifie si la taille des 4 commandes pour le moteur est inférieure à 512=0b1000000000 (c'est-à-dire qu'il n'y a pas de bit égal à 1 au-dessus du 9ème bit). Si c'est le cas, il récupère ces 9bits, sinon il les met tous à 1. On en déduit que la commande est codée sur 4x9bits, et que si on code sur plus il met la vitesse max, c'est-à-dire 511=111111111.

Ghidra : la fonction BLC_set_motor_speed plafonne chaque vitesse moteur à 9 bits avant de bâtir la commande

La deuxième partie construit la commande: une liste de 5 int codé sur 8bits.

command[0] = (byte)(motor1_speed_LSB >> 4) | 0b00100000;

Il récupère les 5 premiers bits de la commande pour le moteur 1 ( shift de 4 vers la droite, comme la commande fait 9 bits, les 4 bits de LSB sont effacés et il ne reste que les 5 bits MSB, qui se retrouve au début 000 XXXXX). Il passe ensuite un masque (0x20=00100000). On obtient ainsi le premier octet de la commande : 001 XXXXX.

motor1_speed_et_return_value = (motor1_command & 0b00001111) << 4;
// ...
motor2_command_MSB = (motor2_command << 19) >> 24;
// ...
command[2] = (byte)motor3_command_MSB | (byte)motor2_speed;

Il applique ensuite un masque pour récupérer les 4 bits LSB de la commande du moteur 1, les décales 4 fois vers la gauche, on a donc XXXX 0000. Il récupère ensuite les 4 bits de MSB du moteur 2 (shift de 19 vers la gauche, puis shift de 24 vers la droite, c'est-à-dire au total on a bougé de 24-19=5 bits vers la droite, ce qui écrase les 5 bits de LSB. Comme la commande fait 9 bits, il ne reste que 9-5=4 bits de MSB). Il ajoute ensuite ça au résultat précédent et on obtient XXXX XXXX pour le deuxième octet de la commande.

motor2_speed = (motor2_command & 0b00011111) << 3;
// ... 
motor3_command_MSB = (motor3_command << 18) >> 24;
// ...
command[2] = (byte)motor3_command_MSB | (byte)motor2_speed;

De la même manière, il récupère les 5 bits du LSB de la commande du moteur 2 (avec et ET logique), et les décale de 3 vers la gauche XXXXX 000. Il récupère ensuite les 9-(24-18)=9-6=3 bits de MSB de la commande du moteur 3, puis effectue un OU logique pour les additionner. On obtient donc XXXXX XXX. On peut continuer ainsi de suite jusqu'à avoir obtenu la commande complète, que l'on peut résumer avec un schéma :

Diagramme : les quatre vitesses de 9 bits empaquetées dans la commande de 5 octets préfixée par 001

Gestion du firmware des motorboards

Le code fait référence à un fichier BLC.hex situé dans /firmware/BLC.hex. Il lit son contenu, le décode en instruction ARM et le place en mémoire (fonction ). Plus précisément, il repère le caractère ":", puis récupère les 8 premiers caractères (2-4-2). Ces caractères ont l'air d'agir comme une sorte de header pour la ligne en question. Enfin, les caractères suivant le "header" sont les opcodes des instructions ARM.

Ghidra : la fonction lit BLC.hex, repère les deux-points et parse chaque ligne au format Intel HEX

D'après la description que je viens de donner, il s'agit du format HEX de Intel (ici), utilisé pour structurer de l'information destinée à des microcontrôleurs, des EEPROM ou d'autres composants programmables. D'après Wikipédia, le format peut être décrit comme un ensemble de lignes de texte où chaque ligne respecte la syntaxe suivante ::BBAAAATTHHHHHH.....HHHHCC avec

  1. BB est le nombre d'octets de données dans la ligne (en hexadécimal)
  2. AAAA est l'adresse absolue (ou relative) du début de la ligne
  3. TT est le champ spécifiant le type
  4. HH...HHHH est le champ des données
  5. CC est l'octet de checksum. C'est le complément à deux de la somme des valeurs binaires des octets de tous les autres champs. (Les calculs sont faits sur 8 bits, en ignorant les retenues.)

On peut vérifier si cette analyse est correcte en visionnant directement le contenu du fichier BLC.hex dans le système de fichier obtenu précédemment.

Éditeur : contenu du fichier BLC.hex au format Intel HEX contenant les opcodes AVR du firmware moteur

Celui-ci semble correspondre au format iHEX. On peut utiliser l'outil objcopy afin d'extraire le contenu du fichier et obtenir un fichier binaire brute.

objcopy --input-target=ihex --output-target=binary BLC.hex BLC.bin

On peut également utiliser l'outil avrdude pour obtenir le même résultat, étant donné que ce code est très certainement destiné de la motorboard, et que pour rappelle celle-ci est équipé d'un microcontrôleur Atmel ATMega8a (un AVR).

avr-objcopy -I ihex -O binary BLC.hex BLC.bin

Vérification de l'intregrité du firmware

Juste après la récupération du firmware, on a un bout de code qui peut sembler compliqué. Je pense qu'il s'agit du calcul du condensat dont je parle dans mon précédant article. Pour chaque bloc de 128 octets, un octet est retourné. J'ai essayé de comprendre comment fonctionne l’algorithme sans essayer de comprendre le code, en interprétant uniquement les résultats (vu que visiblement il n'y a que des additions).

0xFF * 128 : 0xC0 = 192
0x00 * 128 : 0x00 = 00
0x01 * 128 : 0x40 = 64
0x02 * 128: 0x80 = 128
0x80 * 128 : 0x00 = 128
0x40 * 128 : 0x00 = 0
0x20 * 128 : 0x00 = 0
0x10 * 128 : 0x00 = 0

A ce niveau là je comprends qu'il s'agit d'une addition de tout les octets du blocs, puis une division par 2 sur la somme et enfin on garde que le premier octet du résultat.

>>> hex((0xff*128)//2)[-2:]
'c0'
>>> hex((0x80*128)//2)[-2:]
'00'
>>> hex((0x01*128)//2)[-2:]
'40'
>>> hex((0x02*128)//2)[-2:]
'80'
>>> hex((0x40*128)//2)[-2:]
'00'
>>> hex((0x27*128)//2)[-2:]
'c0'

Après vérification en ayant saisit tout un tas de valeurs arbitraires, il semblerait que j'ai vu juste.

Analyse du firmware (motorboard)

Pour cette seconde phase d'analyse sur ce nouveau firmware, j'ai utilisé le logiciel IDA Pro (car malheuresement Ghidra ne supporte pas nativement l'AVR).

Le point positif est que la documentation du microcontrôleur ATMEGA8A est disponible gratuitement ici. Je vais pouvoir m'en servir pour mieux comprendre le code.

Fonctionnement de l'ATMEGA8A

Avant de commencer, quelques informations importantes (tiré de la documentation de l'ATMEGA8A). Elles aideront notre reverse, notamment à comprendre le rôle de chaque registre.

  • UDR (UART Buffer) Buffer qui contient les données à envoyer et qui stock les données à réceptionner.

Documentation ATMEGA8A : schéma fonctionnel de l'USART avec ses registres UDR, UBRR et UCSRA/B/C

Schéma fonctionnel

Diagramme : cartographie des 8 Ko de mémoire du moteur, bootloader et zones de firmware ReadOnly et Read/Write

Processus d'initialisation

J'ai démarré mon analyse depuis le code du signal _RESET. Son objectif principal est d'amener le microcontrôleur à un état initial connu et stable afin que l'exécution du programme puisse commencer correctement.

Dans notre cas _RESET appelle un ensemble de fonctions de manière récursif jusqu'à appeler une "grande" fonction qui ne return jamais (elle boucle à l'infini). J'ai alors supposais qu'il s'agissait de la fonction principale du microcontrôleur.

IDA Pro : graphe de flot de la fonction principale de la motorboard atteinte depuis le vecteur _RESET

Cette fonction semble initialiser l'ensemble des composants du microcontrôleur (l'ADC, les timers et la connexion USART).

Reception UART (USART_RX)

La fonction USART_RX contient le code qui nous intéresse le plus: celui qui est exécuté à la réception d'une trame UART.

IDA Pro : graphe de flot de la routine USART_RX exécutée à la réception d'une trame UART

Vérification de l'état de la commande

Premièrement, elle vérifie la valeur d'une variable en mémoire (que j'ai appelée command_state). Cette variable peut prendre trois valeurs : 0, 1 ou 2. Si elle est différente de ces trois valeurs, la fonction jump à la fin et return directement (sans aucune autre opération). Je suppose qu'il s'agit du statut de la réception d'une commande - 0 : nouvelle commande - 1 : commande en cours - 2 : comme reçu

IDA Pro : la routine UART teste command_state et se termine si sa valeur n'est pas 0, 1 ou 2

Vérification du type de commande

Si command_state est à 0 (nouvelle commande), alors elle récupère les 3 bits de poids fort (MSB) de la commande UART reçue et la place dans une zone mémoire que j'ai appelée prefix_command. Pour cela, elle effectue les opérations binaires suivantes : - 1. SWAP(XXXXYYYY) = YYYYXXXX - 2. LSR(YYYYXXXX) = 0YYYYXXX - 3. ANDI(0YYYYXXX, 111) = XXX Elle compare ensuite la valeur de prefix_command (000,001,010,011,111)

IDA Pro : extraction des 3 bits de poids fort de la commande pour en déduire la catégorie par comparaisons

On peut déduire que c'est les 3 premiers bits de la commande qui fixe une catégorie de commande.
- 0xE0 = 0b11100000 (Récuperer le statut) - 0x40 = 0b01000000 (Récupérer version) - 0x91 = 0b10010001 (Récupérer checksum fw) - 0xA1 = 0b10100001 (Lancer moteur) - 0x61 = 0b01100001 (Effacer le fw) - 0x71 = 0b01110001 (Ecrire le fw) - 0b001XXXXX... (Vitesse moteur)

Pilotage des moteurs

Les commandes dont le préfixe est 011 et 001 finissent rapidement par jumper au même endroit. Leur effet est simplement de placer une valeur dans une variable que j'ai appelée next_iteration (car elle est utilisée lors de la prochaine itération, c'est-à-dire lors de la réception d'un nouvel octet par UART). - 011 : next_iteration=1 - 001 : next_iteration=4

IDA Pro : les préfixes 011 et 001 fixent next_iteration à 1 ou 4 et passent command_state à 1

La valeur 4 n'est pas anodine. On sait que 001 est la commande qui permet d'envoyer la vitesse, et que, pour rappel, la vitesse est codée sur 5 octets. Il reste donc 4 octets à réceptionner dès lors que le système reçoit le premier octet de la commande (celui qui contient le préfixe).

La variable command_state est ensuite passée à 1, pour indiquer très certainement que la motorboard attend d'autres octets appartenant à la même commande.

Lors de la réception de l’octet suivant, celui-ci est stocké dans un tampon que j’ai nommé unknown, puis la variable next_iteration est décrémentée de 1. Cela confirme l’hypothèse qu’il s’agit d’un compteur indiquant le nombre d’octets restant à recevoir.

IDA Pro : l'octet suivant est stocké dans un tampon et next_iteration décrémenté, confirmant un compteur

Si, après décrémentation, cette valeur n’est pas nulle, le programme effectue alors un jump vers la phase de comparaison (décrite précédemment), en omettant cette fois la vérification des motifs 001 et 011 de départ.

IDA Pro : aux octets suivants, prefix_command garde la valeur du premier octet et saute la re-vérification

IDA Pro : pour un préfixe inférieur à 011, le code teste MOTOR_ID et oriente vers la commande d'assignation

Si le préfixe de la commande précédente est inférieur à 011 (c'est-à-dire 000, 001 ou 010), alors :

  • 000 : Vérifie si le contenu d'une variable que j'ai appelé motor_id est nulle.
    • Si elle n'est pas nulle, elle ne fait rien.
    • Si elle est nulle :
      • elle lui assigne la valeur du nouvel octet reçu par UART si celle-ci est strictement inférieure à 5.
      • Sinon elle lui assigne la valeur de R1 qui vaut 0.

IDA Pro : cas du préfixe 000 assignant MOTOR_ID avec le nouvel octet reçu s'il est inférieur à 5

  • 001 : Compare MOTOR_ID à 1,2,3,4 et jump sur le code correspondant. Selon le cas, extrais la vitesse associée au moteur en utilisant les variables :
    • unk_2001DA (command_history): 1er octet de la commande
    • unk_2001DB : 2ème octet de la commande
    • unk_2001DC : 3ème octet de la commande
    • unk_2001DD : 4ème octet de la commande
    • unk_2001DE : 5ème octet de la commande

IDA Pro : quatre blocs selon MOTOR_ID extrayant la vitesse de chaque moteur depuis les octets de la commande

On peut rapidement le vérifier. Prenons l'exemple du moteur 4 et disons que les deux derniers octets de la commande valent : - unk_2001DD=XXXXXX12 - unk_2001DE=3456789X

ROM:0848 lds     r18, unk_2001DD # R18 = XXXXXX12
ROM:084A ldi     r19, 0
ROM:084B andi    r18, 3          # R18 = XXXXXX12 & 11 = 12
ROM:084C andi    r19, 0          # R19 = 00000000
ROM:084D lsr     r19 
ROM:084E mov     r19, r18        # R19 = 00000012
ROM:084F clr     r18             # R18 = 00000000
ROM:0850 ror     r19             # R19 = 00000001; CR=2; R18 = 00000000
ROM:0851 ror     r18             # R19 = 00000001; CR=0; R18 = 20000000
ROM:0852 lds     r24, unk_2001DE # R24 = 3456789X
ROM:0854 lsr     r24             # R24 = 03456789
ROM:0855 ldi     r25, 0          # R25 = 00000000
ROM:0856 or      r18, r24        # R18 = 23456789
ROM:0857 or      r19, r25        # R19 = 00000001
ROM:0858 sts     unk_2001D3, r19
ROM:085A sts     unk_2001D2, r18
ROM:085C sts     unk_2001D4, r20

Diagramme : rappel du format de la commande de vitesse confirmant l'extraction des 9 bits du moteur 4

On obtient bien R19:R18 = 0000000123456789 la vitesse du moteur 4. On a ensuite, peu importe l'identifiant du moteur : - unk_2001D3 : contient les 8 premiers bits de la vitesse - unk_2001D2 : contient les 8 derniers bits de la vitesse


Comme j'étais un peu pressé, j'ai arrêté mon reverse là, en considérant que j'avais toutes les informations nécessaires pour piloter les moteurs.

Conclusion

On a désormais une idée plutôt précise du fonctionnement du protocole BLC utilisé pour piloter les moteurs des motorboard. On peut synthétiser nos résultats avec les figures ci-dessous:

Diagramme : séquence du protocole BLC entre carte mère et motorboard pour les commandes unicast et multicast

Diagramme : échange répété des commandes 71 et 70 lors du transfert du firmware vers la motorboard

On a maintenant toutes les informations qu'il nous faut pour passer au pilotage des moteurs. La suite dans mon prochain article, disponible ici.

Accès Restreint

Cet article n'est pas (encore) publique.
Pour obtenir un accès anticipé, contactez-moi.

[[ passwordError ]]

[[ post.title ]]

[[ post.description ]]

Le [[ post.date ]]

[[ tag ]]
[[ currentMatchIndex + 1 ]] / [[ searchMatches.length ]] 0 / 0

Sommaire

Page principale