Runbook : reproduction pas à pas (Qilin Rust)
A. Acquisition et extraction#
h=61f7aa918b55238278a1666cb723df9e3639d229d1027611e02ae3808ede33ed
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $(cat ~/ctf-reverse/.mb_api_key)" \
-d "query=get_file&sha256_hash=$h" -o q.zip
7z x -pinfected q.zip; chmod -x $h.exe; sha256sum $h.exe; file $h.exe
Retour : PE32 console i386 stripped, hash conforme.
B. Rust, toolchain, crates, crypto#
rabin2 -qz $h.exe > s.txt
grep -ao '/rustc/[a-f0-9]*' s.txt | head -1
grep -aoE '(chacha20|aes|rsa|rand|serde_json|zeroize)-[0-9.]+' s.txt | sort -u
grep -aqF 'expand 32-byte k' $h.exe && echo chacha20
python3 -c "print('aesenc', open('$h.exe','rb').read().count(b'\x66\x0f\x38\xdc'))"
Retour : rustc d5c2e9c3... ; crates aes-0.8.3, rsa-0.7.2, rand-0.8.5, serde_json-1.0.105, zeroize-1.6.0 ; ChaCha20 présent ; aesenc = 130 (AES-NI).
C. Extraction de la config (en clair)#
python3 -c "d=open('$h.exe','rb').read(); import json; \
i=d.rfind(b'{',0,d.find(b'company_id')); \
c,_=json.JSONDecoder().raw_decode(d[i:i+20000].decode('utf-8','replace')); \
print(list(c.keys())); print('company_id',c['company_id'],'n',c['n'],'p',c['p']); \
print('public',len(c['public_rsa_pem']),'private',len(c['private_rsa_pem']),'pwhash',bool(c['password_hash']))"
Retour : config JSON en clair (offset 0x1a7b8c), schéma avec password_hash en plus. company_id=yd9Ca9y5RN, n=0, p=1, public 0, private 3243, password_hash présent. Config déplacée dans samples/ (gitignoré).
D. Vérification de la clé privée (faille de récupération)#
python3 -c "import json; from cryptography.hazmat.primitives.serialization import load_pem_private_key; \
c=json.load(open('config_qilin_2025.json')); pem=c['private_rsa_pem']; \
print('ENCRYPTED' in pem); k=load_pem_private_key(pem.encode(),password=None); print('RSA',k.key_size,'bits')"
Retour : PEM non chiffré, clé RSA-4096 valide chargée sans mot de passe. Voir qilin-rust.md §6 (cadrage : exception au niveau du build, pas un cassage universel de Qilin).
E. YARA#
yara -w qilin.yar $h.exe
Voir qilin.yar.
F. Détecteur de fuite de clé + survey d'un lot#
python3 tools/qilin_config_scan.py sample.exe # verdict sur un fichier
python3 tools/qilin_config_scan.py batch/* # survey d'un lot
Retour : sur 11 échantillons Qilin 2025, 2 exposent leur config en clair (variante Windows PE non packée), et les 2 fuient une clé RSA-4096 privée (affiliés yd9Ca9y5RN, MFnPJ_k7BE). Les PE ~5,25 Mo sont packés (config non visible), les ELF portent la note "Qilin" mais pas de config JSON en clair. Voir qilin-rust.md §6bis.
G. Recherche de clé PEM brute et balayage exhaustif MalwareBazaar#
# scanner ameliore : fallback recherche directe de cle PEM (hors config JSON)
python3 tools/qilin_config_scan.py sample.exe
# balayage de toute la signature/tag Qilin (limite API 2000/j, backoff 429, resumable)
python3 tools/mb_sweep.py --signature Qilin --limit 1000 --out sweep_qilin.csv --keep leaking
python3 tools/mb_sweep.py --tag Agenda --out sweep_agenda.csv
Retour : 67 samples Qilin/Agenda checkes (config + PEM brut). 4 fuient (petite variante EXE 2025, 4 affilies). 0 cle en clair hors config. Voir qilin-rust.md §6bis.
H. Mécanisme du flip public/privé#
# comparer public_rsa_pem vs private_rsa_pem sur builds qui fuient vs sains
python3 -c "import json; c=json.load(open('config.json')); print('pub',len(c['public_rsa_pem']),'priv',len(c['private_rsa_pem']))"
Retour : builds sains = public rempli / private vide ; builds qui fuient = public vide / private RSA-4096 rempli. Deux configs de build distinctes.
I. Preuve du principe de récupération (sans exécuter le malware)#
python3 tools/recovery_poc.py config_qilin.json # chiffre modele + dechiffre avec la cle fuitee
Retour : roundtrip OK, fichier test identique a l'original. Principe prouve (pas la compat bit-a-bit).
J. Reference-build (identifier les fonctions crypto du binaire strippe)#
cargo new qilin_ref && cd qilin_ref
cargo add aes@0.8.3 chacha20@0.8.1 ctr@0.8.0 rsa@0.7.2 rand@0.8.5 serde_json@1.0.105 zeroize@1.6.0 sha2@0.10
# remplacer src/main.rs par un main qui appelle chaque primitive (voir tools/reference_build/)
cargo build --release # -> target/release/qilin_ref AVEC symboles (AES-NI, ChaCha20, RSA OAEP/PKCS1)
nm target/release/qilin_ref | grep -iE 'encrypt_block|chacha|oaep|pkcs1'
K. Reversing du format via Ghidra headless (100% CLI, sans GUI)#
Import + analyse complete (reutilisable ensuite via -process) :
/opt/ghidra/support/analyzeHeadless PROJ_DIR smallvar -import 61f7aa91....exe
Script Java post-analyse (les scripts Jython ne marchent pas sans PyGhidra ; Java oui) :
/opt/ghidra/support/analyzeHeadless PROJ_DIR smallvar \
-process '61f7aa91....exe' -noanalysis \
-scriptPath SCRIPTS -postScript monscript.java
Localisation de l'orchestrateur de chiffrement (l'ancrage par references IAT echoue, appels indirects Rust) -> force brute scriptee : decompiler toutes les fonctions de taille moyenne et chercher l'appel dans le C decompile (le decompilateur resout les appels indirects) :
// decgrep.java : pour chaque fonction (120<size<9000), decompiler et tester
// c.contains("MapViewOfFile") -> imprimer la fonction
Retour : sur 1285 fonctions, une seule reference MapViewOfFile = FUN_00548540 (helper
mapping). Son unique appelant = l'orchestrateur FUN_00533bc0 (6633 octets).
Puis remonter les sous-fonctions crypto/footer :
// getReferencesTo(toAddr(0x548540)) -> appelant ; decompiler FUN_00533bc0
// lister ses appels : FUN_00425270 (x3), FUN_00548c50, FUN_005357a0, ... + memcpy (x9)
Reste (itératif, CLI) : matcher ces sous-fonctions au reference-build (cipher/RSA/keygen), et tracer l'ecriture du pied de fichier. Voir qilin-rust.md §6ter.
Note profil d'entropie (pour la grande variante 5,25 Mo) :
# .rdata global ~7,4 (donnees Rust) mais region 0x716000-0x777000 a ~7,95 = blob chiffre
# blob non compresse standard (zlib/gzip/lzma/bz2 echouent) -> config chiffree custom
Fin de l'analyse en l'etat. Points ouverts : voir qilin-rust.md §8.
L. Confirmation du format de fichier (recoupement public + marqueurs binaires)#
# marqueur de pied de fichier + OAEP présents dans le binaire ?
grep -aoiE 'END CIPHERTEXT BLOCK|begin ciphertext|oaep|label' 61f7aa91....exe | sort -u
Retour : -----END CIPHERTEXT BLOCK-----, begin ciphertext, oaep, label présents.
Recoupé avec l'analyse publique (AhnLab/ASEC) : pied de fichier = 550 octets =
clé AES-256 wrappée RSA-4096-OAEP (512 o) + longueur (8 o) + marqueur (30 o).
Cipher = AES-256-CTR (AES-NI) sinon ChaCha20. Recoupe exactement le reversing ASM
(FUN_0042f2d0 AES-NI, clés 32 o). Voir qilin-rust.md §6ter.
M. Décrypteur (variante à clé fuitée)#
# self-test roundtrip (chiffre au format Qilin documente avec la cle fuitee, dechiffre)
python3 tools/qilin_decryptor.py --selftest config_qilin.json
# sur un vrai fichier chiffre :
python3 tools/qilin_decryptor.py --config config_qilin.json --in fichier.chiffre --out fichier.clair
Format implemente (recoupe AhnLab/ASEC + derp.ca + ASM local) : pied 550 o = [512 o cle wrappee RSA-4096-OAEP][8 o longueur][30 o "-----END CIPHERTEXT BLOCK-----"], corps en AES-256-CTR. Self-test roundtrip OK. Disposition interne du bloc RSA (cle/nonce) et compteur CTR a confirmer contre un vrai fichier. Voir qilin-rust.md §6ter.