#!/usr/bin/env python3
"""
interlock_key_poc.py : reimplementation fidele de la generation de cle par
fichier de l'echantillon InterLock deballe
(9b31237dbf87d06caa3769bec491fad2dae131846e323bed636a6c6b8e24d7ae, voir
runbook.md section L pour le detail du reversing), et
preuve de concept de recuperation par brute-force de l'etat interne du LCG
MSVCRT rand().

Ce qui est reproduit ICI, extrait par decompilation reelle (pas suppose) :

  - rand()/srand() MSVCRT standard, documentation publique Microsoft
    (LCG `next = next*214013+2531011`, sortie 15 bits `(next>>16)&0x7fff`),
    confirme par les imports msvcrt.dll du payload deballe, pas une
    reimplementation par InterLock.
  - FUN_00403210 (thunk_FUN_00403210) : cle de 48 octets (int[12]) en deux
    passes de 12 tirages rand() chacune :
      1re passe : cle[i] = rand()
      2e passe  : cle[i] += rand() ^ clock() ^ taille_fichier
    (clock() capture UNE SEULE FOIS avant les deux passes, pas par tirage).
  - FUN_00401a60 : srand() n'est appele qu'UNE SEULE FOIS par process
    (confirme par la chaine d'appel, un seul appelant statique), avec un
    seed melangeant clock()*nb_process ^ clock() ^ valeur_CSPRNG (rand_s()
    ou repli RtlGenRandom via SystemFunction036, cf. FUN_0042ffc0).

IMPORTANT (meme nuance que l'article, mais affinee par le reversing reel) :
la source d'entropie principale du seed (rand_s/RtlGenRandom) est un VRAI
CSPRNG, pas un horodatage devinable -- la faiblesse exploitable n'est donc
PAS "deviner le timing d'execution", mais plus fondamentale : `srand()` ne
prend qu'un entier de 32 bits en parametre quelle que soit la qualite de
l'entropie mixee dedans, et TOUT le flux rand() qui en sort ensuite est un
LCG public et entierement deterministe. Brute-forcer directement L'ETAT
INTERNE DU LCG au moment ou les 24 tirages d'un fichier donne commencent
(pas necessairement le seed d'origine du process) est donc strictement
equivalent en cout a brute-forcer le seed lui-meme (meme espace de 2^32,
independant du nombre de fichiers deja traites avant ou du threading) :
c'est ce que fait brute_force_state() ci-dessous.

Le contenu du fichier est chiffre en AES-256-CBC (cle 32 octets + IV 16
octets tires des 48 octets ci-dessus, comme documente par l'article et
recoupe ici par la presence de chaines de debug LibTomCrypt authentiques
dans le binaire -- "AES Encrypt"/"AES Decrypt"/"rijndael" -- mais PAS
encore verifie par emulation Unicorn du vrai code AES du binaire comme
cela a ete fait pour Akira/Clop dans ce depot : ce PoC utilise donc AES-CBC
de bibliotheque standard (pycryptodome) comme reference du mode, pas le
code machine reel du binaire).

Ce PoC demontre le PRINCIPE (l'etat 32 bits du LCG est la seule inconnue
reelle une fois clock() et la taille de fichier connus/bornes) et sa
vitesse en Python pur, pas un outil de recuperation cle en main sur un cas
reel : aucun fichier InterLock reellement chiffre n'est disponible pour
cette analyse (memes limites que le reste de ce dossier, voir
runbook.md).

Usage (auto-test, aucun fichier reel requis) :
    interlock_key_poc.py
"""
import struct
import sys
import time

from Crypto.Cipher import AES

MASK32 = 0xFFFFFFFF


def msvcrt_rand_advance(state: int) -> int:
    """Un pas du LCG MSVCRT (documentation publique Microsoft, confirme par
    les imports msvcrt.dll du payload, pas une reimplementation InterLock).
    Retourne le nouvel etat interne (32 bits) ; l'appelant extrait la sortie
    15 bits visible de rand() avec (etat >> 16) & 0x7fff si besoin."""
    return (state * 214013 + 2531011) & MASK32


def msvcrt_rand_value(state: int) -> int:
    """Sortie 15 bits de rand() pour un etat interne donne (deja avance)."""
    return (state >> 16) & 0x7FFF


def derive_file_key(state: int, clock_val: int, filesize: int):
    """FUN_00403210 : genere les 48 octets (int[12]) cle+IV pour UN fichier,
    a partir de l'etat interne du LCG juste avant les 24 tirages de ce
    fichier (pas necessairement l'etat juste apres srand() : peut etre
    n'importe quel etat atteignable si d'autres fichiers/tirages ont deja
    consomme le flux avant celui-ci). Retourne (buffer_48_octets, etat_final).
    """
    values = [0] * 12
    # 1re passe : 12 tirages rand() bruts
    for i in range(12):
        state = msvcrt_rand_advance(state)
        values[i] = msvcrt_rand_value(state)
    # 2e passe : 12 tirages de plus, ajoutes avec clock()/taille en XOR
    for i in range(12):
        state = msvcrt_rand_advance(state)
        r = msvcrt_rand_value(state)
        values[i] = (values[i] + (r ^ clock_val ^ filesize)) & MASK32
    buf = b"".join(struct.pack("<I", v) for v in values)
    return buf, state


def aes_cbc_encrypt(plaintext: bytes, key32: bytes, iv16: bytes) -> bytes:
    cipher = AES.new(key32, AES.MODE_CBC, iv16)
    pad = 16 - (len(plaintext) % 16)
    padded = plaintext + bytes([pad]) * pad
    return cipher.encrypt(padded)


def aes_cbc_decrypt_prefix(ciphertext: bytes, key32: bytes, iv16: bytes, n: int) -> bytes:
    """Dechiffre seulement les n premiers octets utiles (arrondis au bloc de
    16 superieur), suffisant pour comparer a un prefixe clair connu sans
    dechiffrer tout le fichier a chaque candidat."""
    blocks = ((n + 15) // 16) * 16
    cipher = AES.new(key32, AES.MODE_CBC, iv16)
    return cipher.decrypt(ciphertext[:blocks])[:n]


def brute_force_state(ciphertext: bytes, known_plaintext: bytes,
                       clock_val: int, filesize: int,
                       state_min: int = 0, state_max: int = MASK32):
    """Essaie chaque etat interne LCG candidat dans [state_min, state_max],
    derive la cle+IV correspondante, dechiffre le prefixe connu et compare.
    clock_val et filesize sont supposes connus/bornes (le second l'est
    toujours : taille du fichier chiffre moins l'overhead d'enveloppe ;
    le premier necessite une fenetre de recherche bornee en pratique, non
    modelisee ici pour rester lisible, cf. mt19937_key_poc.py de Clop pour
    la meme nuance)."""
    n = len(known_plaintext)
    for state in range(state_min, state_max + 1):
        buf, _ = derive_file_key(state, clock_val, filesize)
        key32, iv16 = buf[:32], buf[32:48]
        candidate = aes_cbc_decrypt_prefix(ciphertext, key32, iv16, n)
        if candidate == known_plaintext:
            return state
    return None


def main():
    # Auto-test : aucun fichier InterLock reellement chiffre disponible
    # (analyse statique + emulation Unicorn uniquement). On simule un etat
    # LCG interne plausible (comme si srand() avait deja tourne et que
    # quelques fichiers avaient deja ete traites avant celui-ci), on genere
    # la vraie cle+IV avec l'algorithme du binaire, on chiffre un texte de
    # test en AES-256-CBC, puis on verifie que le brute-force la retrouve
    # dans une fenetre resserree autour du vrai etat.
    import os
    real_state = struct.unpack("<I", os.urandom(4))[0]
    clock_val = 123456          # plausible, pas mesure sur une vraie machine
    plaintext = b"INTERLOCK TEST FILE - donnee simulee jetable, aucun rapport avec une vraie victime\n" * 4
    filesize = len(plaintext)

    buf, _ = derive_file_key(real_state, clock_val, filesize)
    key32, iv16 = buf[:32], buf[32:48]
    ciphertext = aes_cbc_encrypt(plaintext, key32, iv16)

    print(f"[*] etat LCG simule : {real_state} (0x{real_state:08x})")
    print(f"[*] cle+IV derivees (48 octets) : {buf.hex()}")

    window = 50_000
    lo = max(0, real_state - window)
    hi = min(MASK32, real_state + window)
    known_plaintext = plaintext[:32]

    t0 = time.time()
    found = brute_force_state(ciphertext, known_plaintext, clock_val, filesize, lo, hi)
    dt = time.time() - t0

    print(f"[*] brute-force sur {hi - lo + 1} etats candidats : {dt:.2f}s "
          f"(~{(hi - lo + 1) / dt:.0f} candidats/s, Python pur mono-coeur)")
    if found == real_state:
        print(f"[OK] etat retrouve : {found} (identique a l'etat simule)")
        buf2, _ = derive_file_key(found, clock_val, filesize)
        key32_2, iv16_2 = buf2[:32], buf2[32:48]
        cipher = AES.new(key32_2, AES.MODE_CBC, iv16_2)
        recovered = cipher.decrypt(ciphertext)
        pad = recovered[-1]
        recovered = recovered[:-pad] if 1 <= pad <= 16 else recovered
        print(f"[OK] fichier entier recupere : {'IDENTIQUE' if recovered == plaintext else 'ECHEC'}")
        return 0
    else:
        print(f"[ERR] etat non retrouve dans la fenetre (trouve={found})")
        return 1


if __name__ == "__main__":
    sys.exit(main())
