Catégories
ECW 2022 Reverse Write-ups

ECW 2022 – Minifilter

Un an après mes derniers articles (Chest et Go Game de l’ECW 2021), on reprend avec un nouveau challenge de reverse provenant du CTF de l’European Cyber Week 2022. Je ne participais pas au CTF mais j’ai regardé les challenges qui m’intéressaient : Minifilter était clairement le plus abordable des challenges de reverse de cette édition avec 14 solves en 2 semaines.

Énoncé du challenge

A user noticed a bug when saving his file from notepad. We found some suspect files his computer. Please find out what it is.

PS: Please use a virtual machine to run the minifilter 🙂

Auteur : valkheim

Fichier du challenge

Première approche

On télécharge l’archive 7zip et on décompresse tout ça (pour une raison qui m’échappe, 7z a du mal à gérer la décompression des dossiers sous linux, ils seront toujours vides et leur contenu sera extrait à la racine).

$ 7z e minifilter.7z 
[...]
Folders: 1
Files: 2
Size:       17348
Compressed: 6631
$ ls
file.txt.lock  minifilter.7z minifilter/ truc.sys
$ rm -r minifilter minifilter.7z 

On se retrouve donc avec deux fichiers : “file.txt.lock” et “truc.sys”. L’extension de fichier “.sys” fait penser aux drivers Windows. Prenons quelques informations supplémentaires sur ces deux fichiers.

$ xxd file.txt.lock 
00000000: fc32 be2f 40cc ac00 b4f8 6a00 a3f8 36ce  .2./@.....j...6.
00000010: a62d 71ce b606 fcfe 7e06 f9fe 57c8 a02b  .-q.....~...W..+
00000020: 61c8 b604 85fc 6104 b8fc 4dca ce29 41ca  a.....a...M..)A.
00000030: a20a 95f2 740a f5f2 48c4 c027 58c4 c608  ....t...H..'X...
00000040: aef0 4a08 80f0 74c6 fc25 03c6            ..J...t..%..
$ file truc.sys 
truc.sys: PE32+ executable (native) x86-64, for MS Windows

Le fichier “file.txt.lock” est visiblement chiffré et notre théorie sur le driver Windows se confirme bien.

Les minifilters en bref

Après une rapide recherche, on tombe sur une doc MSDN (https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/filter-manager-concepts) qui nous explique ce que sont les minifilters.

A minifilter driver attaches to the file system stack indirectly, by registering with FltMgr for the I/O operations that the minifilter driver chooses to filter.

Microsoft – Filter Manager Concepts

En gros, ce sont donc des drivers Windows qui peuvent s’attacher au système de fichier pour filtrer les inputs/outputs sur celui-ci : par exemple, cela peut servir aux antivirus pour scanner chaque fichier créé sur le système de fichier et faire des actions si nécessaire (comme supprimer le fichier par exemple).

Reverse du driver

Lors de l’approche de ce challenge, je n’ai pas tellement cherché à savoir comment le filtre était mis en place (par flemme) et j’ai préféré aller à l’essentiel. Pour ce faire, on peut commencer par ouvrir le driver dans IDA et regarder les chaines de caractères potentiellement intéressantes.

On peut presser Shift+F12 pour ouvrir la liste des chaines de caractère dans IDA et on oublie pas que l’on à affaire à du Windows : qui dit Windows dit Unicode (UTF-16) alors on fait clic droit > setup et on ajoute l’UTF-16 dans les string types.

Détection des string Unicode (UTF-16)

On trouve justement 3 chaines intéressantes.

Strings Unicode intéressantes

Gotta go fast

Comme on a dit, on va pas tarder plus que ça sur l’analyse du fonctionnement du driver pour aller à l’essentiel alors on reverse rapidement le code où sont utilisées les strings “\\secret\\” et “\\private\\” .

Utilisation de “\\secret\\” et “\\private\\”

On a donc une fonction qui prend visiblement en paramètre une chaîne Unicode en paramètre. On voit un buffer de 1026 bytes alloué puis passé en paramètre à une fonction avec 0 et 1026 : probablement un “memset(buffer, 0, 1026)”. Ensuite on copie globalement dans ce buffer alloué la chaîne Unicode passée en paramètre et on retourne la présence ou non (booléen) dans cette chaîne des chaines “\\secret\\” ou “\\private\\”.

C’est donc visiblement une fonction qui va vérifier un chemin d’accès, si un fichier se trouve dans l’arborescence de dossiers appelés “\\secret\\” ou “\\private\\” : c’est un comportement imaginable de malware qui irait chercher (ou surveiller, dans le cas d’un minifilter) les fichiers importants sur un filesystem Windows. On renomme donc cette fonction “is_important_file“.

On peut alors s’intéresser à où est utilisé la chaîne “.lock”, qui est d’ailleurs l’extension de notre fichier chiffré.

Utilisation de la chaîne “.lock”

On a visiblement ici trouvé la fonction responsable du chiffrement des fichiers, probablement donc les fichiers se situant dans l’arborescence de dossiers appelés “private” ou “secret”. On remarque que cette fonction va appeler une autre fonction interne au programme dans une boucle, la fonction “sub_140001C80“, qui serait alors probablement une fonction qui va chiffrer un bloc de données : allons voir ce que fait celle-ci.

Chiffrement d’un bloc

Le pseudo code de cette fonction ressemble à ceci.

Fonction de chiffrement d’un bloc

Notre paramètre a1 est visiblement le bloc (char*) à chiffrer, a2 est visiblement la taille de ce bloc et nous n’avons pour le moment pas d’information sur ce que peut être a3. On voit ici que le buffer (bloc) passé en paramètre va être XORé à a3 ainsi qu’à une clé “byte_140004020” qui fait visiblement 4 bytes.

De plus, la fonction ne semble pas retourner de résultat exploité dans le contexte appelant, on peut donc mettre son type de retour à void et on y voit plus clair.

Fonction “encrpyt_block” après retypage

Il nous manque alors deux informations : a3 et la KEY. Allons voir comment cette KEY est générée. Pour cela, on regarde les cross-references (touche “x”) et on tombe sur une fonction que j’ai appelé “generate_KEY“.

Fonction generate_KEY()

J’ai renommé les variables ainsi que la fonction “memmove” pour gagner du temps. Globalement ce qu’il se passe ici est que notre KEY sera en fait égale aux 4 bytes qui se trouvent à l’adresse 0xFFFFF78000000018.

KUSER_SHARE_DATA

En faisant une recherche google sur l’adresse “0xFFFFF78000000018” on ne trouve rien. Par contre, en recherchant l’adresse “0xFFFFF78000000000”, on trouve que c’est là où se situe une structure appelée “KUSER_SHARE_DATA”.

On trouve notamment un article de Microsoft qui nous donne plus d’informations sur cette structure (https://msrc-blog.microsoft.com/2022/04/05/randomizing-the-kuser_shared_data-structure-on-windows/) : on apprend que c’est une structure qui prend une page mémoire (4096 bytes), mappée dans chaque processus à une adresse fixe dans la partie user (0x7FFE0000) comme kernel (0xFFFFF78000000000) de l’espace d’adressage virtuel. Cette structure sert notamment à récupérer des informations souvent demandée comme le temps écoulé depuis le dernier démarrage, l’heure système, la timezone, les extensions du processeur (SSE, SSE2, AVX …) la version de build et autres.

On va se servir de la documentation de cette structure (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-kuser_shared_data) pour voir ce qu’il se trouve à l’offset 0x18 de celle-ci : on note l’offset correspondant avant chaque champ de la structure.

typedef struct _KUSER_SHARED_DATA {
  /*  0  */  ULONG          TickCountLowDeprecated;
  /*  4  */  ULONG          TickCountMultiplier;
  /* ??? */  KSYSTEM_TIME   InterruptTime;
  /* ??? */  KSYSTEM_TIME   SystemTime;
  /* ??? */  KSYSTEM_TIME   TimeZoneBias;
  /* ??? */  USHORT         ImageNumberLow;
  /* ??? */  USHORT         ImageNumberHigh;
  [...]
}

Il faut garder à l’esprit que les LONG/ULONG sont des entiers signés/non signés sur 32 bits (4 octets). Pour savoir ce qu’il se trouve à l’offset 0x18, Il va visiblement falloir savoir à quoi ressemble la structure KSYSTEM_TIME que l’on peut également trouver sur internet (https://www.nirsoft.net/kernel_struct/vista/KSYSTEM_TIME.html).

typedef struct _KSYSTEM_TIME {
  /*  0  */  ULONG     LowPart;
  /*  4  */  LONG      High1Time;
  /*  8  */  LONG      High2Time;
} KSYSTEM_TIME, *PKSYSTEM_TIME;

Notre offset 0x18 (24) dans la structure KUSER_SHARE_DATA correspond donc au champ “SystemTime.High1Time“.

Chiffrement d’un fichier

Maintenant qu’on sait comment un bloc de données est chiffré, allons voir comment ces blocs sont constitués, quel est le fameux paramètre “a3” qu’il nous manque dans la fonction “encrypt_block“. Pour cela, on va regarder le code appelant cette fonction : par soucis de rapidité, j’ai déjà renommé et typé les différentes variables de cette fonction.

Routine de chiffrement d’un fichier, typée correctement

On comprend donc alors mieux ce qu’il se passe ici : le fichier a chiffrer va être lu 7 octets par 7 octets et chaque bloc de 7 octets va être chiffré grâce à la fonction “encrypt_block“. Le 3ème paramètre passé à cette fonction “encrypt_block” est donc l’indice du bloc, qui commence à 1.

Fonction “encrypt_block” finale

On a donc maintenant toutes les informations nécessaires pour résoudre le challenge.

Trouver la clé

Pour ce qui est de la résolution du challenge, on peut penser à deux méthodes possibles : récupérer la valeur “SystemTime.High1Time” pour générer le clé pseudo-aléatoire avec la même fonction et la même seed, ou analyser le fichier chiffré en connaissant le processus de chiffrement et deviner/bruteforcer la clé.

Récupération de la clé

La solution à laquelle j’ai d’abord pensé est d’appeler la fonction RtlRandomEx() avec la même seed pour avoir le même résultat que lors du chiffrement du fichier et ainsi récupérer la clé. Je précise que je ne connaissais pas cette fonction RtlRandomEx().

L’idée est de se servir de la date de modification du fichier “file.txt.lock”.

C:\Users\SoEasY\Desktop\Minifilter>dir


29/10/2022  15:06    <DIR>          .
29/10/2022  14:45    <DIR>          ..
06/06/2022  17:31                76 file.txt.lock
29/10/2022  14:47             6 631 minifilter.7z
29/10/2022  15:48               853 minifilter.cpp
06/06/2022  17:31            17 272 truc.sys

En effet, on voit que le fichier a été modifié pour la dernière fois le 06/06/2022 à 17:31. On peut donc essayer de scripter en C++ la génération de la clé à partir de la seed qui serait donc la high part de la date de dernière modification du fichier.

#include <Windows.h>
#include <stdio.h>

typedef ULONG (__stdcall *_RtlRandomEx) (PULONG Seed);

int main(void){
    FILETIME ftCreate, ftAccess, ftWrite;
    const char* filePath = "file.txt.lock";
    HANDLE hFile;

    hFile = CreateFileA(filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    if(hFile == INVALID_HANDLE_VALUE) {
        printf("[-] CreateFile failed with %d\n", GetLastError());
        return EXIT_FAILURE;
    }

    if (!GetFileTime(hFile, &ftCreate, &ftAccess, &ftWrite))
        return EXIT_FAILURE;

    printf("[+] Seed : 0x%08x\n", ftWrite.dwHighDateTime);

    _RtlRandomEx RtlRandomEx = (_RtlRandomEx) GetProcAddress(GetModuleHandle("ntdll.dll"), "RtlRandomEx");
    printf("[+] Generated key : 0x%08x\n", RtlRandomEx(&ftWrite.dwHighDateTime));
    
    return EXIT_SUCCESS;
}

Le script parait bien mais en le lançant plusieurs fois on se rend compte que RtlRandomEx() renvoie un nombre différent à chaque exécution, même si on lui donne la même seed à chaque fois… Impossible donc à priori de récupérer la clé “proprement”…

Résultat différent à chaque génération de clé

✅ Analyse ou bruteforce

Reprenons la fonction de chiffrement d’un bloc.

void encrypt_block(char *BLOC_7BYTES, int length, int bloc_number) {
  for (int i=0; i < length; ++i)
    BLOC_7BYTES[i] ^= bloc_number ^ KEY[i % 4];
}

On a donc deux XOR : un avec la clé, qu’on ne connait pas, et un autre avec le numéro du bloc, qu’on connait ! On peut donc faire une première passe de déchiffrement en xorant chaque bloc de 7 octets avec le numéro du bloc correspondant.

from pwn import xor

enc = open("file.txt.lock", "rb").read()
dec = b""
bloc_number = 1

for i in range(0, len(enc), 7):
    dec += xor(enc[i:i+7], bloc_number)
    bloc_number += 1

open("step1.bin", "wb").write(dec)
print("[+] Step 1 done")

On exécute le script et on observe le résultat.

$ python3 step1.py 
[+] Step 1 done
$ xxd step1.bin 
00000000: fd33 bf2e 41cd ad02 b6fa 6802 a1fa 35cd  .3..A.....h...5.
00000010: a52e 72cd b502 f8fa 7a02 fdfa 52cd a52e  ..r.....z...R...
00000020: 64cd b302 83fa 6702 befa 4acd c92e 46cd  d.....g...J...F.
00000030: a502 9dfa 7c02 fdfa 41cd c92e 51cd cf02  ....|...A...Q...
00000040: a4fa 4002 8afa 7fcd f72e 08cd            ..@.........

On peut ensuite récupérer le résultat du xxd et organiser l’hexa par blocs de 7 octets : on observe alors un comportement très intéressant.

Observation par blocs de 7

En effet, on voit qu’un octets sur 2 se répète dans l’hexa et on peut alors essayer deviner ce qui pourrait être la clé : “02 CD FA 2E”.

Script de déchiffrement

Vu que chaque bloc est sur 7 octets, la clé à utiliser pour déchiffrer tout le fichier en entier est donc “02 CD FA 2E 02 CD FA”. On peut alors scripter la résolution avec Python.

from pwn import xor

enc = open("step1.bin", "rb").read()
dec = b""
key = b"\x02\xCD\xFA\x2E\x02\xCD\xFA"

for i in range(0, len(enc), 7):
    dec += xor(enc[i:i+7], key)

print(f"[+] Flag : {flag}")

L’exécution donne alors ceci :

$ python3 step2.py 
[+] Flag : b'\xff\xfeE\x00C\x00W\x00{\x00F\x00l\x007\x00_\x00p\x00O\x005\x00T\x000\x00P\x00_\x00f\x00I\x00N\x00I\x00s\x00H\x003\x00D\x00_\x00P\x00R\x000\x00C\x003\x00S\x005\x00i\x00n\x00G\x00}\x00\r\x00\n\x00\x85'

On remarque alors que le flag est en Unicode (UTF-16). C’est donc pour ça qu’on a pu deviner la clé, un octet sur deux est en fait un nullbyte ! On aurait pu se douter de l’Unicode et bruteforcer la clé en cherchant “ECW{” en UTF-16 dans le fichier. On peut alors modifier notre script pour avoir un plus joli affichage du flag.

from pwn import xor

enc = open("step1.bin", "rb").read()
dec = b""
key = b"\x02\xCD\xFA\x2E\x02\xCD\xFA"

for i in range(0, len(enc), 7):
    dec += xor(enc[i:i+7], key)

# On retire "\r\0\n\0\x85" et on decode en UTF-16
flag = dec[:-5].decode('UTF-16')

print(f"[+] Decrypted : {dec}")
print(f"[+] Flag : {flag}")

On exécute et on a le flag proprement.

$ python3 step2.py 
[+] Decrypted : b'\xff\xfeE\x00C\x00W\x00{\x00F\x00l\x007\x00_\x00p\x00O\x005\x00T\x000\x00P\x00_\x00f\x00I\x00N\x00I\x00s\x00H\x003\x00D\x00_\x00P\x00R\x000\x00C\x003\x00S\x005\x00i\x00n\x00G\x00}\x00\r\x00\n\x00\x85'
[+] Flag : ECW{Fl7_pO5T0P_fINIsH3D_PR0C3S5inG}

Et c’est ainsi qu’on termine le challenge ! Merci d’avoir lu jusqu’ici, j’espère que ça vous a plu 🙂