Rust implementation of a Mythic Framework agent (wrapper=False, raw shellcode output). Features per-build function shuffle, ChaCha20 data-section obfuscation, dynamic API resolution via PEB walk, and a full Python Mythic Payload Type service.
17 KiB
DEV_GUIDELINES.md — Conventions de développement Proteus
Source de vérité unique pour le code nouveau (proteus-agent, loader, futurs agents). Consolide ce qui était dispersé dans
STATE_OF_WORK.md §5,DATA_SECTIONS_CIPHERING.md, les commentaires deCargo.toml, et les README de crates.Hiérarchie de docs :
README.md,Payload_Type/proteus/proteus/agent_code/*/README.md— pitch + quick-start.documentation-payload/ARCHITECTURE.md— schémas système.ROADMAP.md— plan macro par phases.documentation-payload/DATA_SECTIONS_CIPHERING.md— design figé Phase 1.5.documentation-payload/DEV_GUIDELINES.md(ce fichier) — règles applicables au code.documentation-payload/STATE_OF_WORK.md— carnet de bord live (bugs en cours, refactors).
0. Layout d'un crate Rust — où trouver / ranger quoi
proteus-agent suit le découpage :
| Sous-module | Contenu | Quand y toucher |
|---|---|---|
src/main.rs |
_start, initialize(), run(), _Unwind_Resume |
bootstrap order, reentrancy guard, handover |
src/log.rs |
dbg_log! family, feature debug-log |
nouveaux helpers de log dev |
src/obf.rs |
MASTER_KEY static + re-exports macros obf!* |
jamais — c'est juste de la glue Phase 1.5 |
src/build_config.rs |
include!() du fichier généré par build.rs |
jamais — build.rs change pour ajouter un CFG_* |
src/win/peb.rs |
structures NT (PEB, LDR, IMAGE_*) + find_peb + get_cstr_len + UnicodeString |
nouvelle structure native rare |
src/win/resolver.rs |
find_module_by_name_w + ExportsIter + ascii_eq_ci(_w) |
jamais — base figée, single-pass |
src/win/allocator.rs (proteus-agent only) |
#[global_allocator] backed by RtlCreateHeap |
jamais sauf si on swap d'allocator |
src/win/nocrt.rs |
mem* shims (memcpy, memset, memmove, memcmp) |
jamais — défini par le linker |
src/win/instance.rs |
Instance struct + INSTANCE_MAGIC + get_instance (+ load_dll côté proteus-agent) |
nouveau champ partagé sur Instance |
src/win/<dll>.rs (ntdll, kernel32, winhttp, bcrypt, advapi32, crypt32) |
typedefs Win32 + struct <Module> + init_<module> single-pass |
nouvelle Win32 API à résoudre — voir §3 |
src/agent/config.rs (proteus-agent only) |
AgentConfig (decoders for the build-time CFG_*) |
nouveau CFG_* |
src/agent/state.rs (proteus-agent only) |
AgentState (mutable runtime) |
nouveau champ runtime |
src/agent/tasking.rs (proteus-agent only) |
main loop : run, do_initial_checkin_loop, do_tasking_loop, exchange_message, checkin/get_tasking/post_responses |
nouvelle phase de loop |
src/agent/messages.rs (proteus-agent only) |
JSON message builders (build_checkin_message, parse_tasking_response, consume_checkin_response, …) |
nouveau type de message Mythic |
src/agent/commands.rs (proteus-agent only) |
cmd_* handlers + dispatch_one + dispatch_all + sleep_with_jitter + parsers |
nouvelle commande agent (cf. §6.1) |
src/comms/{crypto,envelope,http,json}.rs (proteus-agent only) |
crypto BCrypt + Mythic envelope + WinHTTP + JSON encoder/decoder | nouveau header HTTP / champ d'enveloppe / etc. |
src/techniques/<tech>.rs * |
une technique d'injection par fichier | nouvelle technique (cf. §6) |
Règle d'or : si un nouveau fichier a sa place dans aucun de ces sous-modules, c'est probablement qu'il faut un nouveau sous-module — pas un fichier orphelin à la racine de src/.
1. Taille et découpage des fonctions
Règle : viser la cohésion, pas la fragmentation. Une fonction doit faire une chose et la faire entièrement, peu importe la longueur.
- Pas de minimum imposé. Les fonctions de 20 à 200 lignes sont normales et préférables à 6 helpers de 5 lignes chacun.
- Limite haute indicative ~250 lignes. Au-delà, splitter pour la lisibilité — pas pour le shuffle.
- Le shuffle ne demande pas d'avoir beaucoup de petites fonctions ; il demande seulement que les fonctions ne s'inlinent pas entre elles. Voir §2.
- Quand splitter : la cohérence sémantique l'exige (ex.
tasking::checkin→build_checkin_message/consume_checkin_responseparce que ce sont 2 phases distinctes), OU contrainte OPSEC précise (ex. chaque expansionobf!(…)génère sa propre fonction décodeuse, c'est intentionnel — voir §3). - Quand NE PAS splitter : extraire un helper de 4 lignes utilisé une seule fois "pour être propre". Inline-le.
2. Inlining et frontières de fonctions
Le shuffle pipeline (-Z function-sections=yes + tools/gen-shuffled-linker.py) ne peut randomiser que ce qui survit à LLVM en tant que fonction distincte. Sans précaution, LLVM inline tout et il ne reste qu'un blob .text.
Règles :
#[inline(never)]sur toute fonction non triviale. Y compris les helpers, y compris les méthodes courtes. Préférer le faux-positif (annoter une fonction qui ne serait pas inlinée de toute façon) au faux-négatif (oublier une fonction qui devient inlinée et perd sa frontière).- Ne pas mettre
#[inline]ni#[inline(always)]sauf raison documentée. - Le profile.release a
lto = true, codegen-units = 1pour folder le programme en un seul.o; l'anti-inlining est fait au niveau LLVM via-C llvm-args=--inline-threshold=0dansRUSTFLAGS_SHUFFLE(voir agent_code/proteus-agent/Cargo.toml:36-58 et Makefile.toml::RUSTFLAGS_SHUFFLE). Ne pas toucher sans comprendre l'effet sur le shuffle. - Coup de sonde : après build,
tools/gen-shuffled-linker.pylog[shuffle] seed=… fns=N rdata_prx=M. Si N stagne après l'ajout d'une fonctionnalité, suspecter un inlining caché.
3. Obfuscation des littéraux (Phase 1.5)
Toute donnée littérale qui peut atterrir dans .rdata/.rodata du binaire DOIT passer par les macros obf! (voir DATA_SECTIONS_CIPHERING.md pour le design complet).
| Type d'utilisation | Macro |
|---|---|
&str Mythic-protocol, command output, header HTTP |
obf!("…") |
&[u8] byte literal |
obf_bytes!(b"…") |
[u16; N+1] UTF-16 nul-terminé (PWSTR Win32) |
obf_utf16_z!(b"…") |
[u16; N] UTF-16 sans nul (compare avec PEB names) |
obf_utf16!(b"…") |
u32 / u64 magic numbers |
obf_u32!(0x…) / obf_u64!(0x…) |
Anti-patterns à proscrire :
- ❌
format!("…", x)ouString::from("…")côté agent → ramène les tablescore::fmt::num(digits base-N), les escape Unicode ("\u{0000}"), et le panic boilerplate dans.rdata. Hand-rouler unVec<u8>builder à la place. Pattern visible dans commands.rs::push_u64. - ❌
unwrap()/expect()sur des chemins chauds → idem, le panic-fmt traîne du plaintext. Préférerunwrap_unchecked()quand l'invariant est garanti par construction (commentaire de safety obligatoire), OU pattern match avec early return. - ❌ Logger une valeur
obf!-décodée viadbg_log_bytes!ou similaire → la decoded view vit temporairement sur la stack mais le format string du log la copierait dans une nouvelle string littérale dans.rdata. Si nécessaire pour debug, fairedbg_log!("static label")puis decoder à part. - ❌ Hashing compile-time djb2 (ancienne approche) → supprimé. Toute résolution Win32 passe par
crate::win::resolver.
Pattern OPSEC validé (agent_code/proteus-agent/src/win/kernel32.rs::init_kernel32) :
pub fn init_kernel32() {
unsafe {
let instance = get_instance().unwrap_unchecked();
let module_name_w = crate::obf::obf_utf16!(b"kernel32.dll");
let base = find_module_by_name_w(&module_name_w);
instance.kernel32.module_base = base;
// Pré-décode tous les noms de fonctions sur la stack — UNE fois.
let n_write_file = crate::obf::obf_bytes!(b"WriteFile");
let n_get_std_handle = crate::obf::obf_bytes!(b"GetStdHandle");
// … autant de let que de fonctions à résoudre
// UNE itération sur toute la table d'exports.
for (name, addr) in ExportsIter::new(base) {
if ascii_eq_ci(name, &n_write_file) {
instance.kernel32.write_file = transmute(addr);
} else if ascii_eq_ci(name, &n_get_std_handle) {
instance.kernel32.get_std_handle = transmute(addr);
} /* … */
}
}
}
Reproduire ce pattern à l'identique pour tout nouveau module Win32 ; ne jamais retomber sur du résolution-par-fonction-séparée.
4. Boot sequence et Instance
L'Instance est l'unique container d'état partagé. Pointeur enregistré dans PEB.ProcessHeaps au boot, identifié par magic == INSTANCE_MAGIC.
Ordre d'init imposé (main.rs::initialize) :
init_ntdll()— résoutRtlCreateHeapetc.init_kernel32()— résoutLoadLibraryW,WriteFile,GetStdHandle.NT_HEAPALLOCATOR.initialize()— heap up,alloc::allocutilisable.init_winhttp()/init_advapi32()— DLL imports viaLoadLibraryW+ PEB walk.alloc(layout)+copy_nonoverlapping— promotion stack→heap.- Swap PEB.ProcessHeaps slot vers le permanent.
(*permanent).agent.populate()— décodeCFG_*.- Clear reentrancy guard,
run().
Règles immuables :
INSTANCE_MAGICdistinct entre loader (0x4C4F4144) et proteus-agent (0x17171717). Cf. commentaires dans les deuxwin/instance.rs. Ne jamais re-symlinker les arbreswin/entre les deux crates : la divergence est volontaire pour préserver la distinction du magic.- Le champ
magicdoit rester en premier dans la structInstance—get_instance()lit les 4 premiers octets pour matcher. dbg_log!est self-guarded (null-check sur les fp kernel32) → safe partout, mais reste feature-gateddebug-log. Production builds = feature OFF (les literals plaintext du log défaire Phase 1.5).
5. Shuffle pipeline
Invariants à préserver quand on touche au build ou aux linker scripts (agent_code/proteus-agent/README.md:30-67) :
_starttoujours à offset0duProteus.bin(entry-point contract).- Toutes les références
REL32/ RIP-relative patchées parx86_64-w64-mingw32-ldaprès reordering. - Les sections
.rdata$prx_*participent au shuffle au même titre que les.text$<sym>— c'est ce qui randomise les blobsobf!et lesCFG_*.
Tests automatisables :
cargo make shuffle-report(root) → produit deux builds successifs et reporte le % de bytes différents + le plus long run identique. Cible : ≥ 90% de byte-diff, longest run < 700 B. Aujourd'hui : 94.8% / ~643 B (cf. SHUFFLE_REPORT.md et ROADMAP §1.5).strings -n 6 <binaire>→ ne doit montrer que des coïncidences x86 (séquencesAWAVAUATVWUSHetc.) et la constante publique ChaCha20expand 32-byte k. Aucun nom Win32, aucune string Mythic-protocole, aucune URI.
6. Cargo features pour les variantes de code
Quand plusieurs implémentations alternatives existent (techniques d'injection, profils C2 futurs, etc.) :
- Une feature cargo par variante, mutuellement exclusive (mais pas formellement — c'est le caller qui passe
--no-default-features --features tech-Xqui garantit l'exclusivité). mod X;etpub use X::run;gated par#[cfg(feature = "tech-X")]au niveau du module dispatcher (jadis dans la crateloaders(supprimée — voir Minotaur pour les techniques dinjection actives)).- Un
compile_error!()au niveau du dispatcher pour rejeter le build sans feature sélectionnée. - Côté Mythic builder.py :
BuildParameteravechide_conditions=[…]pour cacher les paramètres dépendants (ex.injection_techniquemasqué quandoutput != "executable"). - Toujours définir
ui_position=Nsur chaqueBuildParameter. Sans position explicite Mythic re-sort (alphabétique selon la version), ce qui peut afficher un paramètre dépendant avant son gate (ex.injection_techniqueavantoutput). Numéroter de 1 à N dans l'ordre logique de remplissage. - Pin
mythic-container >= 0.6.0dansPayload_Type/proteus/requirements.txt. Les attributsui_positionet les classesHideCondition/HideConditionOperandont été ajoutés dans la branche 0.6.x — un container avec 0.5.x crashe au boot avecImportError, et Mythic continue à afficher le dernier sync valide (donc l'ancienne config) sans signaler l'erreur dans la webui. - Install :
sudo mythic-cli install folder . -fdepuis la racineProteus/. Pas de symlinks, pas de staging — les crates Rust vivent àPayload_Type/proteus/proteus/agent_code/qui EST le Docker build context, donc leCOPY proteus proteusdu Dockerfile suffit.
6.1. Commandes Mythic — recette à 3 endroits
Ajouter une commande à l'agent demande de toucher trois endroits, dans cet ordre :
a) Côté Rust agent — la fonction qui exécute
Dans agent/commands.rs :
- Une fonction
cmd_<name>(instance: &mut Instance, params: &str) -> String(ou&Instancesi la commande ne mute pas l'état).#[inline(never)]obligatoire. - Une branche dans
dispatch_one(même fichier) :} else if cmd == &*obf!("<name>") { cmd_<name>(instance, &task.parameters) } - Si la commande a besoin de nouvelles Win32 APIs : suivre §3 (single-pass dans
init_<dll>côtécrate::win::<dll>,obf_bytes!les noms d'export, ne PAS recréer le pattern hash-table). Si elle envoie des messages JSON Mythic, ajouter le builder dans agent/messages.rs. - Output : hand-roller les
Stringwrites (pas deformat!). Voircmd_sysinfocomme modèle.
b) Côté Mythic Python — la déclaration
Un fichier Payload_Type/proteus/proteus/mythic/agent_functions/<name>.py qui :
- déclare
<Name>Arguments(TaskArguments)(vide si pas d'argument) - déclare
<Name>Command(CommandBase)aveccmd = "<name>"(string identique à celui matché pardispatch_one) - ajoute
builtin=True, suggested_command=TruedansCommandAttributespour que la command soit auto-incluse dans la build et pré-cochée dans la webui (sans ça, l'opérateur voit le warning "No exit command selected" et la command n'est pas pré-sélectionnée) - est auto-importée par proteus/mythic/__init__.py — pas d'action manuelle d'import requise
Modèles minimums (sans args) : exit.py, pwd.py, sysinfo.py. Avec args : sleep.py.
c) Côté ROADMAP — cocher la case
ROADMAP.md liste les commandes par phase. Marquer [x] quand l'implémentation est livrée + testée end-to-end (build via Mythic + tasking sur la VM lab).
d) Validation post-add
cargo make shuffledoit passer sans warning.sudo mythic-cli install folder . -fdoit dérouler sans erreur.- Webui : la commande apparaît dans Step 3 (Select Commands), pré-cochée si
suggested_command=True. - Tasking sur la VM lab : la commande retourne sa réponse dans Active Callbacks.
Sans le fichier Python (b), l'opérateur ne voit AUCUNE commande dans l'écran "Select Commands" du payload generation, même si l'agent sait l'exécuter. Règle absolue : toute commande dispatchée par dispatch_one doit avoir un fichier Python miroir.
7. Validation post-build (checklist avant un release)
À cocher avant tout commit qui touche au binaire produit :
cargo make shufflepasse sans warning.cargo make shuffle-reportretourne ≥ 90% byte-diff.strings -n 6 Proteus.binpropre (cf. §5).strings -n 6 Proteus.binpropre (aucune chaîne plaintext).- Build
PROTEUS_FEATURES=debug-log cargo make shuffleproduit l'agent qui boote sans crash sur la VM lab. - Pas de
format!/String::fromintroduit côté agent. - Pas de
expect()/unwrap()sur chemin chaud. - Tout nouveau littéral Win32/Mythic/HTTP passe par
obf!/obf_bytes!/obf_utf16!. - Toute nouvelle fonction non-triviale annotée
#[inline(never)].
8. Quand modifier ce fichier
Tout changement structurel (nouvelle macro obf_*, nouvelle phase d'init, nouveau type de feature, etc.) doit être ajouté ici dans le même PR que le code. Ce fichier est censé refléter ce qui est vrai à l'instant T sur main, pas une liste d'intentions futures (celles-ci vont dans ROADMAP.md).