Architecture
What this project is
Section titled “What this project is”A malware loader packer for authorized red team engagements and security research. It takes a PE (or a DLL, or raw shellcode) and a YAML plan and produces a packed PE with the selected evasion techniques applied.
Pipeline (build)
Section titled “Pipeline (build)”flowchart TD
A[YAML plan] --> B[parse + validate]
B --> C[read payload: file or URL]
C --> D[encrypt: XChaCha20-Poly1305]
D --> E[render Rust stub]
E --> F[compile with rustc]
F --> G[embed PE resources .rsrc]
G --> H[sign artifact]
H --> I[provenance manifest]
picaro build plan.yaml --dry-run stops after step 2 (no encryption,
compilation or output).
The generated stub decrypts the payload in memory and runs it. No cleartext payload touches disk.
Technique pipeline model
Section titled “Technique pipeline model”Each technique is Preparation, Execution or Control (src/techniques/mod.rs):
- Preparation — modifies the current process state (e.g.
patch_amsi) but never runs the payload. - Execution — runs a payload (
process_hollowing,reflective_loading,dotnet_hosting,execution_callbacks,module_stomping,fibers). - Control — orchestrates the chain without touching the payload
(
living_off_the_land,sleep).
The plan is a flat runtime: list of steps; src/plan/steps.rs resolves it and
src/plan/validate.rs checks the per-step rules (the terminal-step rule above
all, plus format/arch/capability checks).
The stub renders the steps into three placeholders:
$STEP_MODULES$— onemod step_<i>per step (a Preparation exposesrun() -> Result<bool, _>, an Executionpayload(image), a Controlrun() -> Result<i32, _>), so two steps can reuse a technique without colliding on top-level names.$STEP_RUNNER_BODY$— the generated straight-line runner that walks the steps in order, decrypting each payload and applying the declared gating.$STUB_HELPERS$— the shareddec_xor/decrypt_stephelpers.
run() calls init_syscalls()?, then the runner. A terminal step
(reflective_loading of a PE executable, dotnet_hosting) must be last.
Syscall layer (build.stub.syscalls)
Section titled “Syscall layer (build.stub.syscalls)”Not a pipeline technique: a loader configuration that changes how the stub
invokes the kernel. mode is none (default) or indirect; resolver is
hells_gate, tartarus_gate or api_hash.
The stub template renders the layer into $SYSCALLS_BLOCK$ (a no-op
init_syscalls when nothing needs it), generated by src/engine/syscalls.rs:
- Every technique fragment calls the
nt_*helpers (src/engine/syscalls.rs:nt_alloc_vm,nt_write_vm,nt_read_vm,nt_protect_vm,nt_create_thread_ex,nt_wait_for_single_object,nt_get_context_thread, …) and never the Win32 APIs directly. Withmode: nonethe helpers map to the Win32 APIs; withmode: indirectthey resolve SSNs at runtime and invoke the kernel through the firstsyscall; retgadget in ntdll’s.text(aglobal_asm!trampoline,nt_invoke). Theapi_hashresolver matches each export by a precomputed FNV-1a hash, so no Nt* name string (and noGetProcAddress) is involved at all. TechniqueDef::syscallslists each technique’s Nt* requirements; the block only resolves the union of the active pipeline’s requirements.- All Nt*/DLL names go through
StringsConfig(or are replaced by hash constants), so they never appear in the binary. See Syscall layer and Measurements.
API resolution (build.stub.api_resolution)
Section titled “API resolution (build.stub.api_resolution)”Also a loader configuration, not a pipeline technique: it decides who serves
the payload’s imports. With mode: proxy, patch_imports rewrites the
payload’s IAT so the selected calls land on proxies generated in the loader, and
the intercepted names never enter the loader’s own import table
(verify_no_intercepted_imports fails the build if one does). The catalogue
(src/engine/api_resolution.rs) is deliberately small:
ntdll!NtReadVirtualMemory, ntdll!NtOpenProcess, kernel32!ReadProcessMemory
and kernel32!OpenProcess are re-implemented on the Nt* calls through the
syscall layer, and kernel32!GetProcAddress is a handler that answers catalogue
hits with the matching proxy (ordinals and misses fall through to the original).
Two couplings, both hard validation errors: mode: proxy needs
syscalls.mode: indirect (otherwise the re-routed calls fall back to the Win32
APIs and the imports reappear in the stub’s IAT), and it needs a pe/dll
payload (there is no IAT to rewrite otherwise). It is rejected with
process_hollowing, because the proxies live in the loader image, which is not
mapped in the target. unmatched decides what a non-catalogue import does:
forward (default) resolves it normally, abort fails the load. Design, phases
and limitations: API resolution design.
Payload formats (build.payloads.<name>.format)
Section titled “Payload formats (build.payloads.<name>.format)”format describes the input: pe (default), shellcode, dll or dotnet.
pe— a PE32/PE32+ executable. Validated as such; run by either native execution technique.dll— a PE32/PE32+ library (IMAGE_FILE_DLL). The packer rejects a DLL declared aspeand vice versa. The payload’sexportoptionally names an exported function to call afterDllMain(DLL_PROCESS_ATTACH).shellcode— a raw, position-independent code blob with no PE headers. The packer only checks that it is non-empty;reflective_loadingcopies the bytes into an executable allocation and runs them on a thread.dotnet— a managed assembly (a non-null CLR header / COM descriptor). The stub hosts the CLR and loads the assembly from memory;dotnet_hostingis the matching execution technique and the two imply each other. A managed image declared aspe/dllis rejected at build time (mapping it would fail inside the CLR), and the assembly’s bitness must matchoutput.arch(a32BITREQUIREDassembly needsarch: x86).
process_hollowing replaces a suspended process’s image, so it only runs
format: pe today and plan::validate rejects the other formats with a
pointer to reflective_loading. The format reaches the Execution fragment
through TechniqueKind::render_fragment, which selects the technique’s
format-specific fragment.
Output kind (build.output.kind)
Section titled “Output kind (build.output.kind)”The artifact is a PE executable: compile_stub compiles the rendered stub
as a binary through rustc and the builtin template ends in fn main().
A host-loaded DLL output (kind: dll, for hosts that application
whitelisting already allows — a JVM, CPython, .NET, rundll32) is implemented
in src/engine/entry.rs (the entry block) and src/engine/compiler.rs
(cdylib); its phases, couplings and limitations are in
DLL output design. The lab verification of the host
matrix (the design’s Phase D) is still pending.
Payload arguments (build.payloads.<name>.args)
Section titled “Payload arguments (build.payloads.<name>.args)”A packed binary is a stub, so the payload does not inherit the operator’s command
line by itself. args fixes arguments at build time; args_mode is join
(default) or override.
The stub embeds the YAML args as runtime-reconstructed strings (through
StringsConfig) and builds the final command line from them plus the args the
operator passes to the packed binary (argv[1..]):
join: YAML args first, then the runtime args.override: the runtime args replace the YAML ones; with no runtime args the YAML ones are used.
The logic is rendered once into $ARGS_BLOCK$ (src/engine/args.rs) and both
execution techniques use it: process_hollowing passes the line as
lpCommandLine to CreateProcessA, while reflective_loading calls
patch_command_line() before mapping. Token 0 is always the executable name, so
the payload’s argv[0] is the exe and its args start at argv[1]. See
Payload arguments.
Pipeline (audit)
Section titled “Pipeline (audit)”flowchart TD
A[binary] --> B[PE header + sections + entropy]
A --> C[imports / exports]
A --> D[strings scan]
A --> E[YARA]
A --> F[Defender offline scan]
B --> G[report]
C --> G
D --> G
E --> G
F --> G
G --> H[baseline comparison]
--json emits the same data as a serializable audit::model::Report;
--baseline compares it against a saved report and fails (exit 3) on a
regression. --focus only affects the pretty output.
PE resources (build.output.resources)
Section titled “PE resources (build.output.resources)”Icon, VERSIONINFO and the Windows application manifest are built in Rust
(src/engine/resources/), not by rc.exe: tree.rs emits the .rsrc
directory, icon.rs/version.rs/manifest.rs produce the three payload types,
and inject.rs appends the result as a new section to the already-compiled stub,
patching SizeOfImage and the RESOURCE data directory. Offsets in the
directory tree are section-relative, while
IMAGE_RESOURCE_DATA_ENTRY.OffsetToData is an image RVA — the one detail that
has to be right for Windows to load the resources.
Architecture support (build.output.arch)
Section titled “Architecture support (build.output.arch)”The packer emits one architecture at a time, and arch decides all of it: the
stub’s rustc target, the dependency set it links, and the payload’s expected
architecture.
arch | Stub target (Windows / cross) | Payload | windows import lib |
|---|---|---|---|
x64 (default) | x86_64-pc-windows-msvc / -gnu | PE32+ | windows_x86_64_msvc / _gnu |
x86 | i686-pc-windows-msvc / -gnu | PE32 | windows_i686_msvc / _gnu |
Two rules follow from the architecture being shared between the stub and the payload:
- The payload must match
arch. A 32-bit payload cannot run in a 64-bit stub (or the reverse), sobuildrejects a mismatch before encrypting or compiling anything. - A technique must declare the architecture capability it supports
(
TechniqueDef::requires), andplan::validatechecks it againstarch. Both execution techniques andanti_debug/patch_amsi/patch_etw/bouncerdeclareWindowsX64 | WindowsX86;sleep_obfuscationstaysWindowsX64, so anarch: x86pipeline that uses it fails with a message naming the technique (see x86 support). Forprocess_hollowing, a target that resolves to a local file is checked too — mapped the way WOW64 would resolve it.
The shared stub blocks are already architecture-aware: the import resolver reads
the image’s optional-header magic (4-byte thunks and IMAGE_ORDINAL_FLAG32 for
PE32, 8-byte and IMAGE_ORDINAL_FLAG64 for PE32+), the payload-argument block
uses the right PEB offsets (fs:[0x30]/+0x10/+0x40 on x86,
gs:[0x60]/+0x20/+0x70 on x64), the resource injector handles both
optional-header layouts and audit parses both. syscalls.mode: indirect is
x64-only for now — x86 has no syscall; ret gadget and the WOW64 SSNs differ —
so it is rejected on x86.
Cross-compilation from Linux
Section titled “Cross-compilation from Linux”When picaro runs on Linux/WSL the stub is cross-compiled to
x86_64-pc-windows-gnu. Two -L flags are required and easy to forget:
-L native=<...>/windows_x86_64_gnu-<ver>/lib—windows-rslinks viaraw-dylibagainstlibwindows.0.52.0.a, provided by thewindows_x86_64_gnucrate (on Windows:windows_x86_64_msvc).-L dependency=<target/{debug,release}/deps>— thewindows-rsproc-macros are compiled for the host, not the cross target.
Without the first, linking fails with cannot find -lwindows.0.52.0; without the
second, compilation fails with can't find crate for windows_implement.
Repo layout
Section titled “Repo layout”src/ plan/ YAML parsing and validation engine/ crypto, pe parsing, stub generation, compilation, syscall layer, imports, api_resolution, args, resources (icon/VERSIONINFO/ manifest), identity, signing, manifest, hash, builtin stub template audit/ static measurement (pe_info, strings, yara, defender, report) techniques/ one directory per technique + the registry (mod.rs) error.rs top-level error types ui.rs terminal output: banner, palette, formatting primitivesexamples/ plans/ example YAMLs payloads/ test payloadsdocs/ this file + the rest of the book; the navigation is docs/nav.ymldocs/site/ the Starlight site project (assembler, config, Dockerfile)build/ (gitignored) intermediate artifactsdist/ (gitignored) final packed binaries