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?

Un furet dans le labo?

Binaire suspect détecté !

À la suite de la révélation concernant la fuite de données, les chercheurs du laboratoire ont commencé à porter une attention accrue à leur environnement de travail. L'un d’entre eux a signalé un comportement inhabituel sur l'un de ses fichiers Excel: la cellule indiquant la version du document aurait changé de valeur automatiquement, sans qu'il n’ait effectué de modification.

Intrigué, il a immédiatement rapporté l'anomalie à l'équipe cybersécurité. Après analyse, cette dernière a découvert la présence d’un binaire suspect sur son poste de travail. Ce fichier exécutable, dissimulé parmi les fichiers temporaires du système, semble être à l’origine de l'activité anormale observée dans le document Excel. Cette découverte suggère une compromission locale du poste de travail, possiblement dans le cadre d'une attaque ciblée ou d’un logiciel malveillant conçu pour manipuler ou exfiltrer des fichiers sensibles.

Cote 95 pts

Faire son rapport

Étape 1 – Reverse

On reconnait que le binaire est compilé statiquement, mais en y laissant
les symboles.

$ file files/pherret
files/pherret: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=a6f5002aca4faea763b0d05fdea3982ef32a1bd6, for GNU/Linux 3.2.0, with debug_info, not stripped

On peut alors s'attendre à pouvoir comprendre le mécanisme du binaire
en chargeant celui-ci dans un décompilateur, comme Ghidra.
Après analyse automatique par Ghidra, on se dirige naturellement
vers la fonction main :

int main(void) {
  int iVar1;

  lfsr_init();
  iVar1 = finders_destroyers();
  return iVar1;
}

On trouve deux fonctions à analyser : lfsr_init et finders_destroyers.
Étudions-les dans l'ordre :

Le LFSR

void lfsr_init(void) {
  int i;
  ulong ori_seed;

  seed = time((time_t *)0x0);
  for (i = 0; i < 0x20a6e; i = i + 1) {
    lfsr_clock();
  }
  return;
}
_Bool lfsr_clock(void) {
  ulong uVar1;
  _Bool new_bit;
  _Bool out_bit;

  uVar1 = seed & 1;
  seed = seed >> 1 |
         (ulong)((POPCOUNT((byte)(seed & 0x4000000000000001)) & 1U) != 0) << 0x3e;
  return uVar1 != 0;
}

On récupère ici ses paramètres :

  • Le LFSR est initialisé avec comme graine time(NULL), soit l'heure exacte
    sous format epoch au moment du lancement du programme ;
  • Le LFSR effectue ensuite 0x20a6e = 133742 tops d'horloge pour mélanger
    la graine.
  • Les étages utilisés sont encodés par le nombre hexadécimal
    0x4000000000000001, indiquant que ceux actifs sont 0 et 62 dans
    la fonction de rétroaction.
  • La taille du LFSR est confirmée par le bit éventuellement ajouté de poids
    0x3e, ce qui se traduit en 63.

Parcours des fichiers Excel

On se dirige cette fois-ci dans la fonction finders_destroyers afin
de comprendre comment le LFSR est utilisé pour modifier le document.

int finders_destroyers(void) {
  // [Déclaration des variables coupées]

  __s = find_xlsx_files();
  if (__s == (char *)0x0) {
    fwrite("Failed to find files or allocate memory.\n",1,0x29,(FILE *)stderr);
    count = 0;
  }
  else {
    count = 0;
    puts("Modifying found .xlsx files...");
    line = strtok(__s,"\n");
    while (line != (char *)0x0) {
      printf("Processing: %s\n",line);
      unzip_xlsx_file(line,"unzipped_xlsx");
      iVar1 = list_worksheet_xml_files("unzipped_xlsx");
      iVar2 = rezip_to_xlsx_linux("unzipped_xlsx",line);
      if (iVar2 != 0) {
        fprintf((FILE *)stderr,"Failed to rezip %s\n",line);
      }
      line = strtok((char *)0x0,"\n");
      count = count + iVar1;
    }
    free(__s);
    puts("Modification complete.");
  }
  return count;
}

On comprend ici rapidement que le binaire va chercher des fichiers Excel
(fonction find_xlsx_files), puis pour chaque fichier trouvé, va le dézipper
(unzip_xlsx_file), effectuer certaines actions dans le fichier
(list_worksheet_xml_files ?), puis finalement le rezipper
(rezip_to_xlsx_linux).

Les modifications effectuées aux fichiers Excel devraient se trouver
entre le dézippage et le rezippage du fichier, donc on se dirige
dans la fonction list_worksheet_xml_files :

int list_worksheet_xml_files(char *unzipped_root) {
  // [Déclaration des variables coupées]

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  snprintf(search_dir,0x1000,"%s/xl/worksheets",unzipped_root);
  init_buffer(&sb);
  __dirp = opendir(search_dir);
  if (__dirp == (DIR *)0x0) {
    fprintf((FILE *)stderr,"No worksheets found in: %s\n",search_dir);
    count = 0;
  }
  else {
    count = 0;
    while( true ) {
      pdVar3 = readdir(__dirp);
      if (pdVar3 == (dirent64 *)0x0) break;
      __s1 = rindex(pdVar3->d_name,0x2e);
      if (__s1 != (char *)0x0) {
        iVar2 = strcmp(__s1,".xml");
        if (iVar2 == 0) {
          snprintf(full_path,0x200,"%s/xl/worksheets/%s",unzipped_root,pdVar3->d_name);
          append_line(&sb,full_path);
          attaque_data(full_path);
          count = count + 1;
        }
      }
    }
    closedir(__dirp);
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail_local();
  }
  return count;
}

On repère très rapidement la fonction attaque_data, vraisemblablement
responsable de la modification des données. On se rend dedans,
et on trouve cette fois-ci une fonction beaucoup moins compréhensible
au premier coup d'œil.
Néanmoins, on peut deviner son fonctionnement en prenant un peu de recul
sur la structure de la fonction :

int attaque_data(char *filename) {
  // [Déclaration des variables coupées]

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  row = 0;
  // Double boucle for imbriquée, d'abord lignes puis colonnes,
  // sur une zone de 100×100 cases
  do {
    if (99 < row) {
      return 0;
    }
    for (col = 0; col < 100; col = col + 1) {
      _Var2 = cell_exists(filename,row,col);
      if (_Var2) {
        lVar5 = xmlReadFile(filename,0,0);
        // [Coupé : parcours du fichier XML]
              if (bVar11) {
                value_1 = NAN;
                for (current_node_1 = *(xmlNodePtr *)(lVar6 + 0x18);
                    current_node_1 != (xmlNodePtr)0x0; current_node_1 = current_node_1->next) {
                  if ((current_node_1->type == XML_ELEMENT_NODE) &&
                     (iVar4 = xmlStrcmp(current_node_1->name,&DAT_0070e0b2), iVar4 == 0)) {
                    pcVar9 = (char *)xmlNodeGetContent(current_node_1);
                    if (pcVar9 != (char *)0x0) {
                      // value_1 contient vraisemblablement le contenu de la case
                      value_1 = atof(pcVar9);
                      (*(code *)xmlFree)(pcVar9);
                    }
                    break;
                  }
                }
                xmlFreeDoc(lVar5);
                xmlCleanupParser();
                goto LAB_0040788b;
              }
            }
            xmlFreeDoc(lVar5);
            xmlCleanupParser();
            value_1 = NAN;
          }
LAB_0040788b:
          if (((0.0 < value_1) && (value_1 < 2147483648.0)) && ((double)(long)value_1 == value_1)) {
            // Vérification : la valeur doit être strictement positive
            //                et entière
            uVar3 = xmlReadInteger((uint)(long)value_1);
            dVar12 = (double)uVar3;
            if (dVar12 != 0.0) {
              lVar5 = xmlReadFile(filename,0,0);
              if (lVar5 == 0) {
                printf("Error: Could not read file %s\n",filename);
                bVar11 = false;
              }
              else {
                  // [Coupé : parcours du fichier XML]
                  if (bVar11) {
                    bVar11 = false;
                    for (current_node = *(xmlNodePtr *)(lVar6 + 0x18);
                        current_node != (xmlNodePtr)0x0; current_node = current_node->next) {
                      if ((current_node->type == XML_ELEMENT_NODE) &&
                         (iVar4 = xmlStrcmp(current_node->name,&DAT_0070e0b2), iVar4 == 0)) {
                        snprintf((char *)xpath_expr,0x32,"%f",dVar12);
                        xmlNodeSetContent(current_node,xpath_expr);
                        // La nouvelle valeur provient de `dVar12`
                        bVar11 = true;
                        break;
                      }
                    }
                    if (bVar11) {
                      xmlSaveFormatFileEnc(filename,lVar5,"UTF-8",1);
                    }
                    xmlFreeDoc(lVar5);
                    xmlCleanupParser();
                    goto LAB_00407fe3;
                  }
                }
                printf("Error: Cell (%u, %u) does not exist or is not a number.\n",(ulong)row,
                       (ulong)col);
                xmlFreeDoc(lVar5);
                xmlCleanupParser();
                bVar11 = false;
              }
LAB_00407fe3:
              if (bVar11) {
                printf("Modified cell (%u, %u): %f -> %f\n",value_1,dVar12,(ulong)row,(ulong)col);
              }
            }
          }
        }
      }
    }
    row = row + 1;
  } while( true );
}

On comprend ici le fonctionnement global du code malveillant.
Les deux boucles imbriquées nous indiquent que le code parcours une zone de
100×100 cases en haut à gauche de la feuille de calcul, pour chaque ligne et
colonne.
La condition sur value_1 ainsi que le message d'erreur dans le printf
nous indique que le code ne s'intéresse qu'aux cases contenant des
entiers positifs.

Maintenant, retrouver la modification effectuée est un peu plus subtil.
On remarque en partant de l'appel à xmlNodeSetContent que la valeur modifiée
est inscrite dans la variable dVar12, qui elle-même provient
d'un appel à la fonction xmlReadInteger avec en paramètre la
donnée originale (value_1).
On peut alors raisonnablement émettre une suspicion sur l'origine et le
nommage de la fonction xmlReadInteger, et en effet, en investiguant son
contenu, on trouve assez clairement du code qui ne provient pas
de la bibliothèque XML.

Modifications effectuées

uint xmlReadInteger(uint value) {
  // [Déclaration des variables coupées]
  uVar2 = random_int();
  iVar1 = 0x1f;
  if (value != 0) {
    for (; value >> iVar1 == 0; iVar1 = iVar1 + -1) {
    }
  }
  return value * 2 ^ uVar2 & (1 << (0x20 - ((byte)iVar1 ^ 0x1f) & 0x1f)) - 1U;
}
uint random_int(void) {
  _Bool _Var1;
  uint result;
  int i;

  result = 0;
  for (i = 0; i < 0x20; i = i + 1) {
    _Var1 = lfsr_clock();
    result = result << 1 | (uint)_Var1;
  }
  return result;
}

On comprend assez facilement que la fonction random_int construit un
entier 32 bits (0x20) à partir de 32 appels consécutifs à lfsr_clock.

On en déduit ensuite le fonctionnement de xmlReadInteger, qui est
responsable de la modification des données :

  • La fonction utilise le LFSR pour générer un entier 32 bits aléatoire ;
  • La fonction calcule ensuite la taille en bits du nombre original dans la
    variable iVar1.
  • La valeur aléatoire est ensuite tronquée afin de ne prendre que les derniers
    bits, pour autant de bits que la taille de l'originale
    (uVar2 & (1 << (<taille du nombre oritinal>) - 1U).
  • La valeur retournée est finalement le double de l'originale
    (value * 2), après application d'un OU Exclusif avec l'entier aléatoire
    masqué.

Étape 2 – Récupération des données

On sait désormais quelles cases ont été modifiées sur le fichier Excel.
Maintenant, il va falloir trouver un moyen de recouvrer les données originales.

Voyons comment sont modifiés les valeurs plus en détail.
Le binaire implémente un registre à décalage à rétroaction linéaire (LFSR).
Ce LFSR possède 63 bits d'état, qui est initialisé avec l'heure Epoch
(nombre de secondes depuis 1970).
Le générateur est initialisé en cadençant 133 742 fois,
et permet ensuite de générer des entiers aléatoires en sortant les
32 bits suivants du LFSR.

Maintenant, on va chercher un moyen de récupérer la graine afin de récupérer
les données originales du tableur.
Pour cela, on exploite deux faiblesses dans la méthode utilisée par l'attaquant
pour ajouter du bruit dans les données :

  • Le générateur pseudo-aléatoire est initialisé par time(NULL), ce qui laisse
    moins d’une vingtaine de bits d’incertitude sur la graine utilisée selon la
    seconde exacte à laquelle le binaire a été lancé ;
  • Lorsque le générateur modifie une valeur $x$ du tableur, il génère un entier
    32 bits $y$ et remplace $x$ par $2x \oplus y$.
    Le dernier bit de ce nouveau nombre
    correspond alors directement à un bit de sortie du LFSR, ce qui nous donne
    une relation linéaire supplémentaire sur les bits de la graine.

En combinant les bits d'informations récupérés sur les valeurs modifiées, il
est possible de réduire le nombre de candidats pour la graine à seulement une
dizaine, et sélectionner la graine correspondant à la date la plus crédible
permet de prédire avec précision le bruit qui a été rajouté aux données.

En effet, considérons l'état initial du LFSR $s = (s_0, s_1, \dots, s_{62})$.
Comme le générateur est linéaire, chaque bit de sortie du générateur peut être
représenté comme une combinaison linéaire de l'état initial.
Le code SageMath suivant permet d'obtenir rapidement la combinaison linéaire
qui représente la relation entre la graine et le n-ième bit de sortie du
générateur :

# taps = 0x4000000000000001
taps = [GF(2)(0)] * 64
taps[0] = taps[-1] = taps[-2] = GF(2)(1)
M = companion_matrix(taps)

def nth_bit_coefficients(n):
    return (M^n).column(0)

Pour chaque valeur modifiée, le générateur génère un entier $y$ sur 32 bits
et remplace la valeur originale par son double plus $y$ (tronqué).
Le bit de poids faible de la valeur modifiée correspond donc directement au
dernier bit de $y$, ce qui nous permet de retrouver une relation sur les bits
de la graine :

leaks = [...] # liste des valeurs du tableur modifiées par le binaire
eqn = []
res = []
for i, leak in enumerate(leaks):
    n = 133742 + i*32 + 31 # LSB du i-ème entier généré après initialisation
    eqn.append(nth_bit_coefficients(n))
    res.append(leak % 2)
    # eqn * seed = res

Malheureusement, en regardant le tableur en détail, on se rend compte
que la plupart des nombres sont en réalité des chaînes de caractères.
On retrouve au final uniquement 35 valeurs qui ont été modifiées dans le
tableur dans la feuille principale.
On doit compléter nos informations avec ce que l'on sait de la date de lancement
du binaire.
Comme la graine correspond à une date Epoch, on peut s'attendre à ce que ses 32
premiers bits soient des zéros (le trente-deuxième bit sera actif en 2038).
On peut rajouter ces contraintes à nos équations :

for i in range(31, 63):
    eqn.append(vector(GF(2), [1 if k == i else 0 for k in range(63)]))
    res.append(0)

On obtient finalement un peu plus que 63 contraintes sur la graine.
Ce n'est pas exactement suffisant, car certaines de nos premières
contraintes sont liées.
Il y a donc encore plusieurs possibilités de graines qui répondraient à
toutes les contraintes, sur lesquelles on peut itérer en considérant
le noyau de notre matrice d'équations :

from datetime import datetime

E = Matrix(GF(2), eqn)
K = E.right_kernel()
KB = K.basis_matrix()
R = vector(GF(2), res)

sol0 = E.solve_right(R)
for ker in GF(2)^(K.rank()):
    candidate = sol0 + ker * KB
    seed_guess = sum(int(candidate[k]) << k for k in range(63))
    date = datetime.utcfromtimestamp(seed_guess)
    date_string = date.strftime("%Y-%m-%d %H:%M:%S UTC")
    print(f"  Date: {date_string} (seed = {guess})")

Comme le nombre de candidats restants est relativement restreint,
on peut afficher la date correspondant à chaque graine et simplement
sélectionner la date la plus cohérente par rapport à la date de l'attaque.

On retrouve en réalité seulement 32 candidats, dont trois en 2025 :

// ...
13    | 922625574       | 1999-03-28 12:52:54
14    | 925279782       | 1999-04-28 06:09:42
15    | 930965030       | 1999-07-03 01:23:50
16    | 1753359910      | 2025-07-24 12:25:10
17    | 1759110694      | 2025-09-29 01:51:34
18    | 1761699366      | 2025-10-29 00:56:06
19    | 1767548454      | 2026-01-04 17:40:54
20    | 1812653606      | 2027-06-10 18:53:26
// ...

Étant donné que l'exfiltration a eu lieu le 23 juillet, la date
du 24 juillet pour la corruption des données semble très cohérente.
On retrouve ainsi la graine exacte utilisée, qui correspond précisément
au moment où le code a été exécuté sur le poste de l'attaquant.

Une fois la graine retrouvée, on peut assez simplement simuler le générateur
et inverser les modifications effectuées pour retrouver les données originales :

seed = int(input("Seed la plus probable: "))
s = vector(GF(2), [(seed >> k) & 1 for k in range(63)])

def random_int(i):
    # Retrouve le i-ème nombre généré par le LFSR
    n = 133742 + i*32
    result = 0
    for k in range(n, n + 32):
        result <<= 1
        result |= int(s * nth_bit_coefficients(k))
    return result

print("Valeurs originales :")
for i, leak in enumerate(leaks):
    r = random_int(i)
    r %= 1 << (leak.bit_length() - 1)
    print(f'Nombre #{i}: {leak} → {(leak ^ r) // 2}')

Les données originales étant rétablies, on peut trier la feuille selon la
colonne VIM, et voir apparaître dans la première colonne le flag.


Question 1

Entrée

Vous avez à disposition la Table de Fichiers Maître, qui est la base de données centrale de NTFS où toutes les métadonnées des fichiers et des répertoires sont stockées.

Cette table a été parsée en utilisant l'outil MFTECmd pour en obtenir le fichier mft.csv donné dans l'exercice.

Résolution

Analysez les données du fichier en ajoutant des filtres pour affiner la recherche de données suspectes.

Pour ce faire, ajoutez les filtres suivants :
- SI<FN == True : Permet de savoir si l'horodatage du fichier a été modifié (manipulation suspecte)
- Extension == ".dll" : Conserver uniquement les manipulations de DLL (librairies dynamique Windows)
- Copied == True : Copier-coller des DLL n'est pas un comportement normal

Voici un script python pour manipuler le fichier de données :

import csv
import pandas as pd

csv_file = "mft.csv"

## Ouvrir fichier csv

df = pd.read_csv(csv_file)

timestamp = df[df['SI<FN'] == True] # Filtre timestamp
dll = timestamp[timestamp['Extension'] == '.dll'] # Filtre .dll
copied = dll[dll['Copied'] == True] # Filtre copied

print(timestamp)
print(dll)
print(copied)

Question 2

Extraction de la DLL malveillante depuis la capture mémoire

Après avoir identifié le chemin de la DLL utilisée pour l’injection de code malveillant, l’étape suivante consiste à l’extraire de la capture mémoire.
Plusieurs techniques sont possibles pour effectuer cette extraction. Nous en présentons ici une basée sur l’outil Volatility.

1. Identifier l’adresse virtuelle de la DLL

Tout d’abord, nous utilisons la commande filescan de Volatility afin de retrouver la DLL en mémoire et obtenir son adresse virtuelle :

vol -f <dmp_file> windows.filescan | grep -i fcon

Cette commande permet d’identifier les fichiers présents en mémoire, puis de filtrer les résultats afin de retrouver spécifiquement la DLL malveillante.

2. Extraction de la DLL depuis la capture mémoire

Une fois l’adresse virtuelle récupérée, nous pouvons extraire la DLL à l’aide de la commande capturefiles :

vol -f <dmp_file> -o <out_folder> windows.capturefiles --virtaddr <virtual_address>

Analyse de la DLL

Une fois la DLL extraite, nous procédons à son analyse. Pour ce faire plusieurs outils sont possibles (IDA Pro, Ghidra, etc.). Dans notre cas, nous avons choisi Ghidra.

L’objectif est d’identifier des éléments permettant de comprendre le comportement de la DLL et de répondre à la question posée.

Lors de l’analyse, nous observons la présence du champ "MAPI".

Ghidra image 1

Ghidra image 2

Ghidra image 3

Ghidra image 4

MAPI (Messaging Application Programming Interface) qui est une API COM fournie par Microsoft, principalement utilisée par Outlook et Exchange pour la gestion des courriels (envoie, réception ...) .

Nous pouvons donc en conclure que cette DLL malveillante exploite les objets COM d’Outlook afin d’exfiltrer des données par e-mail.

Question 3

Afin de trouver l’adresse e-mail de l’expéditeur, nous poursuivons l’analyse de la DLL malveillante à partir du point où nous nous étions arrêtés à la question précédente.

Après avoir identifié l’utilisation des objets COM d’Outlook, nous recherchons directement dans le code les chaînes de caractères correspondant à une adresse e-mail ou à une configuration d’envoi.

Cette recherche nous permet d’identifier l’adresse utilisée pour l’exfiltration :

Ghidra image 5

Ghidra image 6

Nous retrouvons ainsi l’adresse e-mail utilisée par l'attaquant pour réceptionner les mails de sa victime.

Question 4

Entrée

Vous avez le XML des transactions respectant le schéma de données de l'ESMA (MiFIR Reporting).

Résolution

Analysez les données sous la forme d'un graphe en utilisant un script python, que voici :

import xml.etree.ElementTree as ET
import pandas as pd
import plotly.express as px

def parse_transactions(xml_file):
    tree = ET.parse(xml_file)
    root = tree.getroot()
    ns = {'ns': 'urn:iso:std:iso:20022:tech:xsd:auth.016.001.01'}

    trades = []
    for tx in root.find('ns:FinInstrmRptgTxRpt', ns):
        new = tx[0]
        trade = {
            'tx_id': new.find('ns:TxId', ns).text,
            'exctg_pty': new.find('ns:ExctgPty', ns).text,
            'buyer': new.find('.//ns:Buyr/ns:AcctOwnr/ns:Id/ns:Prsn/ns:Nm', ns).text,
            'seller': new.find('.//ns:Sellr/ns:AcctOwnr/ns:Id/ns:Prsn/ns:Nm', ns).text,
            'trad_dt': new.find('.//ns:TradDt', ns).text,
            'tradg_cpcty': new.find('.//ns:TradgCpcty', ns).text,
            'qty': int(new.find('.//ns:Unit', ns).text),
            'price': float(new.find('.//ns:Amt', ns).text),
            'venue': new.find('.//ns:TradVn', ns).text,
            'isin': new.find('.//ns:FinInstrm/ns:Id', ns).text,
            'notional': 0
        }
        trade['notional'] = trade['qty'] * trade['price']
        trades.append(trade)

    return pd.DataFrame(trades)

transactions = "auth016_trades.xml"

# Parse les transactions
df = parse_transactions(sys.argv[1])
print(f"Dataset: {len(df)} transactions\n")
print(df.head())

# GRAPH
fig1 = px.scatter(df, x='trad_dt', y='price', size='notional',
                  color='venue', hover_data=['tx_id', 'buyer'])
fig1.show()

Une analyse visuelle du graphe suffit pour se rendre compte qu'une personne a acheté énormément d'actions avant la divulgation du rapport le 14/09/2025 à 9h.

graphique analyse donnée