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.
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 :
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 :
time(NULL), soit l'heure exacte0x20a6e = 133742 tops d'horloge pour mélanger0x4000000000000001, indiquant que ceux actifs sont 0 et 62 dans0x3e, ce qui se traduit en 63.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.
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 :
iVar1.uVar2 & (1 << (<taille du nombre oritinal>) - 1U).value * 2), après application d'un OU Exclusif avec l'entier aléatoireOn 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 :
time(NULL), ce qui laisseEn 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.
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.
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)
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.
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.
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>
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".




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.
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 :


Nous retrouvons ainsi l’adresse e-mail utilisée par l'attaquant pour réceptionner les mails de sa victime.
Vous avez le XML des transactions respectant le schéma de données de l'ESMA (MiFIR Reporting).
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.
