#!/usr/bin/env python3
r"""
crytox_lcg_poc.py : reimplementation exacte, en Python, du generateur de cle
AES-256 reconstruit par lecture statique du payload final Crytox (offset
0x1396 du blob de 20480 octets dechiffre et injecte dans explorer.exe, cf
runbook.md pour le desassemblage annote complet, instruction par
instruction, avec adresses).

Algorithme reconstruit (traduction directe du desassemblage x86-64, PAS une
approximation) :

  rax = GetTickCount()          # appele a CHAQUE invocation de la fonction
                                 # (donc a chaque nouveau fichier, si la
                                 # fonction est appelee une fois par fichier)
  state = [r12+0x10]            # accumulateur PERSISTANT, partage entre
                                 # TOUS les appels de tout le run (jamais
                                 # remis a zero), initialement 0
  pour 4 tours (-> 32 octets de sortie au total) :
      acc = 0    (registre RSI ; son contenu initial est sans importance,
                  entierement remplace par exactement 64 decalages de 1 bit)
      pour 64 iterations :
          rax    = (rax + state) mod 2**64      # 0x13b7
          rax    = (rax * 0x7D6C3 + 0x159EC7) mod 2**64   # 0x13bc-0x13c3, LCG
          state  = rax                                     # 0x13c9, PERSISTE
          haut   = rax >> 16                                # 0x13ce
          cube   = haut**3 mod 2**64             # deux imul, 0x13d8/0x13dc
          cube   = cube mod 2**32                # <-- CORRECTION (voir plus bas)
          quotient, reste = divmod(cube, MODULUS)  # 0x13e0, MODULUS = config
          rax    = quotient          # <-- REINJECTE dans l'iteration SUIVANTE
                                      #     (32 bits, RAX zero-etendu par le DIV)
          acc    = (acc >> 1) | ((reste & 1) << 63)   # 0x13e5, shrd, 1 bit/iteration
      sortie += acc (8 octets, little-endian)
      rax = acc     # <-- le mot de sortie du tour REINJECTE au tour suivant
                     #     (rax n'est PAS remis a GetTickCount() entre les tours)

Point technique cle pour l'exploitation : GetTickCount() n'est ajoute QUE
tout au debut du tout premier appel de la fonction dans tout le run
(`rax` vaut alors sa valeur brute) ; a l'interieur d'une meme execution,
`rax` est ensuite entierement gouverne par la recurrence deterministe
ci-dessus (quotient de division puis mot de sortie). Si cette fonction est
appelee une fois par fichier (accumulateur `state` PERSISTANT entre
fichiers, jamais reinitialise), alors :
  - la cle du PREMIER fichier depend de GetTickCount() ET de l'etat
    initial (connu, = 0) : cassable par brute-force sur 2**32 candidats
    au pire cas.
  - la cle du fichier suivant depend d'un NOUVEL appel GetTickCount()
    (qui peut valoir EXACTEMENT la meme chose si moins d'un "tick" de
    resolution systeme -- typiquement ~15,6 ms par defaut sous Windows --
    s'est ecoule) ET de l'accumulateur laisse par le fichier precedent
    (entierement determine des lors qu'on connait la cle du fichier
    precedent). Retrouver la toute premiere valeur GetTickCount() permet
    donc, en pratique, de rejouer une grande partie sinon la totalite
    d'une sequence de chiffrement rapide (nombreux fichiers chiffres en
    quelques secondes -> tres peu de "tics" distincts reellement utilises),
    sans avoir a re-bruteforcer 2**32 valeurs pour chaque fichier.

**Valide bit-a-bit contre le vrai code machine** (pas seulement contre la
lecture statique) : `tools/emulate_stage3_keygen.py` execute reellement
(Unicorn) le payload jusqu'a cette fonction et compare l'etat interne pas
a pas. Point a ne pas rater : l'instruction reelle est
`div DWORD PTR [r12+0x58]`, une division 32 BITS (EDX:EAX avec EDX=0), pas
64 bits -- seuls les 32 bits bas du cube
comptent, et le quotient qui en ressort (reinjecte a l'iteration suivante)
tient sur 32 bits. La sortie Python est **identique
octet pour octet** a la sortie de l'emulation Unicorn, sur deux valeurs
GetTickCount de test independantes.

**MODULUS reellement extrait** (pas un placeholder) : `0xa77efce7`,
recupere par emulation du dechiffrement AES-NI de la configuration
embarquee (12 080 octets) du meme echantillon
(`32eef267a1192a9a739ccaaae0266bc66707bb64768a764541ecb039a50cba67`).
Cette configuration contient egalement, en clair une fois dechiffree : la
note de rancon HTA complete (contact `Colin_Kaan@aol.com`, ID d'operateur,
delai de 5 jours), l'extension de fichier `.waiting`, la commande de
persistance (`C:\Windows\System32\mshta.exe "C:\ReadMe.hta"` via la cle de
registre Run), et la cle publique RSA-2048 de l'attaquant pour CE build
precis (format CAPI `PUBLICKEYBLOB`, magic `RSA1`, exposant 65537) --
toutes ces valeurs sont DONC SPECIFIQUES A CET ECHANTILLON/BUILD, pas des
constantes universelles a Crytox (voir runbook.md pour le detail complet).
Utiliser cette valeur de MODULUS par defaut n'est valide QUE pour un
fichier chiffre par ce build precis ; un autre echantillon aurait
vraisemblablement une configuration (et donc un MODULUS) different,
necessitant de rejouer la meme extraction.

Ce script :
  1. expose `derive_key_chain(first_ticks, modulus)` : rejoue une SEQUENCE
     de cles comme le ferait un run reel chiffrant plusieurs fichiers a la
     suite, `first_ticks` etant la liste des valeurs GetTickCount()
     (une par fichier, potentiellement repetees) a rejouer.
  2. demontre l'effondrement de l'espace de recherche (2**256 -> au plus
     2**32) via un benchmark de debit Python pur.
  3. fournit un oracle de reconnaissance par plaintext connu
     (`bruteforce_known_plaintext`) : le coeur d'un decrypteur reel, des
     lors que le format IV/footer exact du fichier chiffre est connu (non
     regle dans cette analyse -- cf runbook.md, "Ce qui reste
     ouvert").

Usage :
  crytox_lcg_poc.py bench [MODULUS]
  crytox_lcg_poc.py derive TICK [MODULUS]
  crytox_lcg_poc.py bruteforce CIPHERTEXT_HEX IV_HEX PLAINTEXT_PREFIX_HEX [MODULUS] [TICK_MAX]

MODULUS par defaut si omis : 0xa77efce7 (valeur reelle extraite de
l'echantillon 32eef267..., voir avertissement ci-dessus avant de
l'appliquer a un autre echantillon).
"""
import sys
import time

MULT = 0x7D6C3
INC = 0x159EC7
MASK64 = (1 << 64) - 1

# Valeur REELLE extraite par emulation (Unicorn) du dechiffrement AES-NI de
# la configuration embarquee de l'echantillon
# 32eef267a1192a9a739ccaaae0266bc66707bb64768a764541ecb039a50cba67 (voir
# tools/emulate_stage3_keygen.py et runbook.md). Validee : une cle derivee
# avec cette valeur reproduit EXACTEMENT (bit a bit) la sortie de la
# fonction reelle executee dans Unicorn, sur deux valeurs GetTickCount de
# test independantes. Rappel : ce modulus fait partie de la configuration
# PROPRE A CE BUILD (comme la cle publique RSA et l'ID de contact qui
# vivent dans la meme configuration) -- un autre echantillon Crytox aurait
# tres probablement un modulus different, a re-extraire de la meme facon.
DEFAULT_MODULUS_SAMPLE_32eef267 = 0xA77EFCE7


MASK32 = (1 << 32) - 1


def _run_function(rax, state, modulus):
    """Un appel complet a generer_cle_32_octets() : 4 tours de 64
    iterations. `rax` = GetTickCount() brut si c'est le tout premier appel
    du run, sinon peu importe sa valeur d'entree ici (elle est fournie par
    l'appelant, voir derive_key_chain). Retourne (cle_32_octets, nouvel
    etat persistant).

    Point verifie par validation dynamique (Unicorn, cf runbook.md) : la
    division `div DWORD PTR [r12+0x58]` est une division 32 BITS (pas 64),
    operant sur EDX:EAX avec EDX=0 -- seuls les 32 bits BAS du cube
    comptent, le quotient tient sur 32 bits et RAX en sort zero-etendu.
    Utiliser le cube 64 bits complet donne une cle totalement differente
    de celle du vrai code (visible en comparant octet-a-octet l'etat
    interne apres chaque etape contre une trace Unicorn du vrai payload)."""
    out = bytearray()
    for _tour in range(4):
        acc = 0
        for _i in range(64):
            rax = (rax + state) & MASK64
            rax = (rax * MULT + INC) & MASK64
            state = rax
            haut = rax >> 16
            cube32 = (haut * haut * haut) & MASK32
            quotient, reste = divmod(cube32, modulus)
            rax = quotient
            acc = (acc >> 1) | ((reste & 1) << 63)
        out += acc.to_bytes(8, "little")
        rax = acc  # le mot de sortie du tour est reinjecte au tour suivant
    return bytes(out), state


def derive_key(tick, modulus):
    """Cle du tout PREMIER appel d'un run (accumulateur persistant a 0)."""
    key, _state = _run_function(tick, 0, modulus)
    return key


def derive_key_chain(ticks, modulus):
    """Rejoue une sequence de cles comme un run reel chiffrant len(ticks)
    fichiers a la suite. `ticks[i]` = valeur GetTickCount() lue par le
    malware juste avant de chiffrer le fichier i (typiquement identique
    ou tres proche de ticks[i-1] si les fichiers sont petits/rapides a
    traiter)."""
    keys = []
    state = 0
    for tick in ticks:
        key, state = _run_function(tick, state, modulus)
        keys.append(key)
    return keys


def bench(modulus, seconds=2.0):
    t0 = time.perf_counter()
    n = 0
    tick = 1
    while time.perf_counter() - t0 < seconds:
        derive_key(tick, modulus)
        tick += 1
        n += 1
    elapsed = time.perf_counter() - t0
    rate = n / elapsed
    print(f"Debit Python pur (mono-coeur, non optimise) : {rate:,.0f} cles/seconde")
    full_space = 1 << 32
    print(f"Espace GetTickCount complet (32 bits)        : {full_space:,} valeurs")
    print(f"Temps pour tout parcourir a ce debit          : {full_space/rate/3600:.1f} heures "
          f"({full_space/rate/60:.0f} minutes)")
    print("Note : un portage GPU (cf. akira-cpp/ dans ce depot pour la methode) reduirait "
          "ce temps de 2 a 3 ordres de grandeur, comme deja demontre sur Akira "
          "(~1,45M candidats/s, gain ~485x vs CPU mono-coeur).")
    print("Une fenetre d'infection connue a quelques heures pres (horodatage de fichiers/"
          "note de rancon) reduirait l'espace reel de recherche de plusieurs ordres de "
          "grandeur par rapport au pire cas 32 bits ci-dessus. Une fois la cle du PREMIER "
          "fichier retrouvee, les cles des fichiers suivants du meme run se deduisent "
          "quasi gratuitement (accumulateur persistant, voir docstring du module).")


def bruteforce_known_plaintext(ciphertext_block, iv, known_prefix, modulus, tick_max):
    """Cherche la valeur GetTickCount qui, une fois passee par le LCG puis
    AES-256-CBC, dechiffre ciphertext_block en un clair commencant par
    known_prefix. Necessite pycryptodome (deja present dans le venv du
    depot, ~/.venvs/ctf-reverse)."""
    from Crypto.Cipher import AES

    prefix_len = len(known_prefix)
    for tick in range(tick_max):
        key = derive_key(tick, modulus)
        cipher = AES.new(key, AES.MODE_CBC, iv=iv)
        candidate = cipher.decrypt(ciphertext_block[:max(16, prefix_len)])
        if candidate[:prefix_len] == known_prefix:
            return tick, key
        if tick % 5_000_000 == 0 and tick:
            print(f"  ... {tick:,} candidats testes sans succes")
    return None, None


def main():
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    cmd = sys.argv[1]

    if cmd == "bench":
        modulus = int(sys.argv[2], 0) if len(sys.argv) > 2 else DEFAULT_MODULUS_SAMPLE_32eef267
        bench(modulus)

    elif cmd == "derive":
        tick = int(sys.argv[2], 0)
        modulus = int(sys.argv[3], 0) if len(sys.argv) > 3 else DEFAULT_MODULUS_SAMPLE_32eef267
        key = derive_key(tick, modulus)
        print(f"GetTickCount = {tick} (0x{tick:x}), modulus = {hex(modulus)}")
        print(f"Cle AES-256 derivee (32 octets) : {key.hex()}")

    elif cmd == "bruteforce":
        ct = bytes.fromhex(sys.argv[2])
        iv = bytes.fromhex(sys.argv[3])
        prefix = bytes.fromhex(sys.argv[4])
        modulus = int(sys.argv[5], 0) if len(sys.argv) > 5 else DEFAULT_MODULUS_SAMPLE_32eef267
        tick_max = int(sys.argv[6], 0) if len(sys.argv) > 6 else (1 << 32)
        print(f"Recherche sur {tick_max:,} valeurs GetTickCount candidates, "
              f"modulus={hex(modulus)}...")
        t0 = time.perf_counter()
        tick, key = bruteforce_known_plaintext(ct, iv, prefix, modulus, tick_max)
        elapsed = time.perf_counter() - t0
        if tick is None:
            print(f"Aucune correspondance trouvee en {elapsed:.1f}s "
                  f"(modulus ou format IV/plaintext probablement incorrect -- "
                  f"voir runbook.md 'Ce qui reste ouvert').")
        else:
            print(f"TROUVE en {elapsed:.1f}s : GetTickCount={tick} (0x{tick:x})")
            print(f"Cle AES-256 : {key.hex()}")
    else:
        sys.exit(__doc__)


if __name__ == "__main__":
    main()
