Skip to content

Polymorphism

Picaro makes every release build of a plan emit different code. Two artifacts packed from the same plan do not share a byte-identical .text, so a YARA rule over the loader’s code bytes, a .text fuzzy hash, or a code-similarity cluster does not group the family.

It is on by default and needs no flag. The only things you ever write are an optional block to reproduce a build or narrow a pool, and mode: none to turn it off. This page is the whole feature: how to use it (section 2), what it does (3), what it moves (4), how it is measured (5), and what is still planned (8).


rustc is deterministic for a given source and flag set. Without this feature, compile_stub (src/engine/compiler.rs) pins every flag, and the generated stub varies across builds only in its data (encrypted payload, fresh key/nonce, re-rolled string obfuscation) — all of which lands in .rdata. The code stayed identical. Measured on the dist/ artifacts (SHA-256 truncated to 16 hex chars):

Artifact.text.rdata
test_packed_reflective1.exee79d56670f7a109061e34bfb…
test_packed_reflective2.exee79d56670f7a1090e1c66f73…
test_packed_reflective.exe (different plan)879460cd7d538a2c144bc133…

Same plan ⇒ same .text. The file hash already differed (.rdata), so hash-only AV was never the concern; code signatures and clustering are.


Nothing to do. A build applies the default — mode: full, a random seed — so two runs of picaro build plan.yaml already produce different code. The block exists only to reproduce a build or to change the pools.

build:
polymorphism:
# Cumulative: each level includes the one above. Default `full`.
mode: none | codegen | source | full
# `random` (default) or a fixed `literal:<u64>` (decimal or 0x hex). The
# resolved seed is recorded in the manifest, so a build is reproduced by
# reusing it.
seed: random | literal:<u64>
# Optional pools; each draws one value per build.
codegen:
codegen_units: [1, 2, 4, 8] # -C codegen-units
opt_level: [z, s, 2] # -C opt-level
lto: [off] # -C lto; needs bitcode-bearing .rlibs
inline_threshold: [25, 100, 300] # -C llvm-args=--inline-threshold
mir_opt_level: [1, 2, 3] # -Z mir-opt-level (0..=4); needs bootstrap
randomize_layout: false # -Z randomize-layout; opt-in
  • A missing block means the defaults above. You do not have to write any of it.

  • mode is cumulative:

    modeApplies
    noneNothing. Byte-identical, reproducible (labs, regression)
    codegenThe compiler profile only
    sourcecodegen + the per-build source immediates
    full (default)source + the future premium layers (section 8)
  • seed accepts random, literal:<decimal> or literal:0x<hex>. Reuse the recorded seed to reproduce an artifact byte-for-byte.

  • Pools are validated before the build: codegen_units >= 1, `inline_threshold

    = 0, mir_opt_levelin0..=4, and opt_level/ltonon-empty.codegen_unitsmay be omitted (falls back to1`).

A debug build — build.stub.debug: true or --debug — always forces mode: none: a debug artifact exists to stay reproducible and debuggable, and codegen variation defeats that. plan::validate::advisories warns when a plan asks for polymorphism and a debug build overrides it. For a single release build, set mode: none.

-C target-cpu variation and section reordering are deliberately not exposed: target-cpu shrinks the set of machines the artifact runs on, and reordering is a loadability risk (notably for kind: dll).


Two behavior-preserving layers, both part of the seeded profile:

  1. A seeded codegen profile. The build seed draws -C codegen-units, -C opt-level, -C lto, a -C llvm-args=--inline-threshold, a -Z mir-opt-level, and (opt-in) -Z randomize-layout + -Z layout-seed from the pools, so rustc emits different instructions each build.

  2. Per-build source entropy. A generated poly_seed_mix() function, whose body holds four immediates drawn from the seed (splitmix64), is appended to $STUB_HELPERS$ and called once at the top of the runner:

    #[inline(never)]
    fn poly_seed_mix() -> u64 {
    let a: u64 = 0x…; // four immediates derived from the seed
    let b: u64 = 0x…;
    let mut x = a ^ core::hint::black_box(b);
    x = x.wrapping_mul(0x…) ^ 0x…;
    core::hint::black_box(x)
    }

    The immediates land inside instructions, so .text differs even when the codegen profile matches. #[inline(never)] + black_box keep the optimizer from folding it away, the result is unused, and it is deterministic in the seed.

codegen uses StdRng::seed_from_u64(seed), so the same seed always yields the same profile (PolymorphismConfig::from_yaml); the resolved CodegenProfile is carried on CompileOptions, so the compiler stays a pure function of its inputs. The -Z flags need RUSTC_BOOTSTRAP=1, which the packer sets itself when the profile uses them (see Limitations).

What this is not. It is not a metamorphic engine. The knobs go beyond layout — opt-level, --inline-threshold and mir-opt-level change which functions are inlined and how the MIR is lowered, so the instruction sequences and their lengths differ between builds. But they are ordinary compiler optimizations, not an adversarial transform: the control-flow structure is unchanged, so a CFG-aware comparison or a patient analyst still recognises the loader. Instruction-level restructuring needs a different mechanism (section 8).

flowchart TD
    A[parse + validate plan] --> B["resolve seed + codegen profile"]
    B --> C[read + encrypt payload]
    C --> D["render stub (per-build immediates)"]
    D --> E["compile with rustc (codegen profile)"]
    E --> F[embed PE resources .rsrc]
    F --> G["(planned) premium layers, before signing"]
    G --> H[sign artifact]
    H --> I["manifest (records seed + profile)"]

Measured on two builds of mimikatz (dist/, .text ≈ 300 KB, ≈ 74 000 instructions):

DetectorEffect
File hash (MD5/SHA)Changes — already true before this feature (.rdata)
.text SHA-256Changes — always
Byte-level fuzzy hash of .text (ssdeep/TLSH)Moves — ~95% of the bytes shift when the profile differs
Instruction stream (mix, lengths)Moves — the knobs change inlining and MIR lowering (section 5)
Function-level similarity / clusteringWeakens, does not vanish — the sequences differ but the control flow is unchanged (section 3)
imphashUnchanged (see Limitations)

The honest bottom line: this is build-time code diversity, not control-flow obfuscation. It defeats hashes and byte-level fuzzy matching outright, and it genuinely changes the instruction stream, but a CFG-aware comparison can still match the loader.


Repeated builds of examples/plans/shellcode.yaml on x86_64-pc-windows-msvc (SHA-256 of the .text raw section, truncated to 16 hex chars):

ScenarioBuildsDistinct .text
mode: none (baseline)21 (byte-identical)
mode: codegen (profile only, before the knobs)43 (one collision)
mode: full (default, before the knobs)66
mode: full, seed: literal:0xdeadbeef21 (reproducible)
mode: full (default, with inline_threshold/mir_opt_level)1010

With the seed and every pool pinned so exactly one knob differs between the two builds, .text disassembled with Capstone (x86-64); the last column is the share of (address, mnemonic, operands) tuples that appear at the same address with the same operands:

Knob varied.text A → B (bytes)Instructions A → BIdentical tuples
inline_threshold (25 vs 300)309 248 → 310 27273 896 → 74 0370.64%
mir_opt_level (0 vs 3)309 760 → 307 71267 951 → 67 4321.08%
randomize_layout (on vs off)309 760 → 309 76074 020 → 74 020100% (no-op)

inline_threshold and mir_opt_level change the instruction stream, so two default builds that hit the same opt-level/codegen-units/immediates still differ. randomize_layout is a no-op for this stub (section 7) and is off by default.

The regression guard is the #[ignore] test two_polymorphism_seeds_differ_in_the_text_section (src/engine/steps.rs); it compiles the same stub with two seeds and asserts the .text sections differ. Run it with cargo test -- --ignored.


FeatureInteraction
build.stub.debug / --debugForces polymorphism off (mode: none)
output.signUnaffected; the planned premium layers run before signing
kind: dllCodegen and source phases apply unchanged
output.resourcesUnaffected (the source phase runs before the .rsrc embed)
output.subsystemIndependent; applied while rendering the stub
Custom stubs (build.stub.path)The codegen profile applies to the compile; a stub that omits $STUB_HELPERS$/$STEP_RUNNER_BODY$ simply gets no source entropy
ManifestRecords polymorphism.{mode, seed, codegen_units, opt_level, lto, inline_threshold, mir_opt_level, randomize_layout, layout_seed}

  • imphash is unchanged. The import-table shape is out of scope.
  • Control-flow structure is unchanged. The knobs move instructions, not the call graph; a CFG-aware comparison still recognises the loader (section 8).
  • The source phase is not configurable. It is on whenever mode is source or full; there is no per-item toggle.
  • target-cpu is not varied (portability) and section reordering is not done (loadability). Both are deliberate.
  • lto is effectively unused with the current dependency rlibs (no bitcode), so the effective pools are codegen-units × opt-level × inline_threshold × mir_opt_level.
  • The -Z knobs need RUSTC_BOOTSTRAP=1. mir-opt-level (on by default) and randomize-layout (opt-in) are unstable flags; the packer sets the escape hatch, which is defensible only because the toolchain is pinned. A toolchain bump must re-check that the flags still exist.
  • randomize-layout is a no-op for the canonical stub (measured: identical .text with it on and off), because the stub keeps almost no layout-sensitive repr(Rust) types in memory at opt-level >= s. It can still help a plan that keeps such types.
  • Reproducibility depends on the recorded seed. A fresh build is not byte-reproducible unless its polymorphism.seed is reused; mode: none or --debug restore the old guarantee.
  • Not behavioral evasion. AMSI, ETW and behavioral detection are untouched; this is a static-similarity measure.

mode: full reserves room for premium layers that are not built yet. They are documented here so the page stays the single source for how Picaro does polymorphism.

Randomize TimeDateStamp, CheckSum, the COFF Rich header and inter-section padding between the resource embed and signing, so the file-level fuzzy hash (ssdeep/TLSH) moves too. Implemented in Rust (reusing src/engine/pe.rs / src/engine/resources/inject.rs), not via LIEF/pefile. Section reordering stays off (loadability). Optional PATH-detected tools (sgn for format: shellcode payloads) follow the same yara/signtool pattern.

8.2 Self-encrypting loader (“stub crypt”)

Section titled “8.2 Self-encrypting loader (“stub crypt”)”

The loader’s .text becomes ciphertext at rest; a per-build decoder section decrypts it in memory and jumps to the real entry point. Two builds then expose no same instructions on disk. Shape: variant A encrypts .text in place (RVAs unchanged, so .pdata/.xdata stay valid), appends the decoder and patches AddressOfEntryPoint; variant B (a self-mapping image) is parked for the relocation case. The decoder runs before .text/CRT, so it is self-contained machine code with a small built-in cipher, not the payload’s XChaCha20. Pipeline slot: after scrub + resources, before signing.

The cost, stated plainly: an encrypted .text has entropy ≈ 8.0 — the classic “packed” signal. That is accepted, not avoided: a section that is both low-entropy and unreadable needs different instructions, not different bytes. crypt_section: data and a per-build decoder are the mitigations. The open risk is relocations inside .text; x64 kind: exe comes first.

8.3 Instruction-level diversity (LLVM obfuscation)

Section titled “8.3 Instruction-level diversity (LLVM obfuscation)”

The only route that changes the instructions and holds in memory is a compiler that rewrites them. Measured constraint: the pinned rustc statically links LLVM (no libLLVM in the sysroot), so -Z llvm-plugins cannot load a plugin against it; a plugin-loadable rustc must be built from source with [llvm] link-shared = true. The obfuscation passes (control-flow flattening, bogus control flow, instruction substitution) are legacy-PM and must be ported to LLVM 20’s new pass manager — real compiler engineering, self-contained in the container. A spike (build the shared-LLVM rustc, load a trivial pass) decides go/no-go. A Rust-native alternative is a post-link mutator driven by iced-x86 (no toolchain, harder correctness: RIP-relative operands, branch displacement, .pdata/.xdata).

Hand-written source variants (N equivalent bodies per fragment) were considered and rejected: keeping N copies of every technique, and applying every later fix N times, is a maintenance trap.


#DecisionRationaleStatus
D-1build.polymorphism is top-level, not under build.stubSpans the stub compile and the final artifactaccepted
D-2Cumulative mode: none | codegen | source | fullOne knob, incremental adoption, mirrors syscalls.modeaccepted
D-3Default mode: full; none is the opt-outThe safe measures ship on: no reason to leave static similarity in placeaccepted
D-4One seed drives every choice, recorded in the manifestReproducibility; mirrors the icon-seed precedentaccepted
D-5Vary codegen-units/opt-level; keep lto opt-in and target-cpu fixedThe first two are semantically neutral; lto needs bitcode rlibs; target-cpu trades portabilityaccepted
D-6Per-build immediates are the decisive source measureThe codegen profile alone has too few combinations (~1/12 collision)accepted
D-7The entropy function is black_boxed and #[inline(never)]if false is folded away and changes nothingaccepted
D-8Cosmetic PE mutation is implemented in Rust, no LIEF/pefileCross-platform, no new dependency or toolchainaccepted
D-9Section reordering stays offLoadability risk, notably for kind: dllaccepted
D-10All artifact mutation runs before output.signA signature must cover the final bytesaccepted
D-11External tools are PATH-detected and degradableMatches yara/signtool handling; no embedded toolchainaccepted
D-12Polymorphism is a release-only featureDebug builds exist to stay reproducible and debuggableaccepted
D-13No -C target-cpu pool on CodegenProfileKeeps CompileOptions: Copy and avoids a portability regressionaccepted
D-14The codegen profile also draws -C llvm-args=--inline-threshold and -Z mir-opt-levelMeasured to change the instruction stream (0.6–1.1% identical instruction tuples), unlike the layout-only measuresaccepted
D-15-Z randomize-layout is opt-in, off by defaultMeasured a no-op for the canonical stub, and it forces the unstable-flag bootstrapaccepted
D-16Hand-written source variants are not the default pathKeeping N copies of every technique (and applying every fix N times) is a maintenance trapaccepted
D-17The premium layers live in this page, not separate design docsOne source of truth for “how Picaro does polymorphism”accepted