CTF思路
点击放大


PolyEngine is a research-grade, e PE packer designed for CTF challenges and low-le security education. It focuses on bypassing EDR and A through a layered stack of in-memory execution and obfuscation techniques.
This is a side project I've been working on for some time. I have used Claude Code to implement and correct some of techniques I wanted to implement in my own PE packer. There's a lot of comments on functions and what they do, since it was a big learning experience for me and Claude does this flawlessly (I'm bad at it). I hope it helps some people to learn some Windows internals or with bypassing some AVs and static detections from more ad solutions when you tackle those ProLabs 🏯.
🔥 Big thank you to MalDe for all the materials to create it and inspiration.
🌩 Thanks to for inspiration via some goofy tweet with silly cat.
> Disclaimer: This tool is intended exclusively for authorized security testing, CTF competitions and educational use. Usage against systems without explicit permission is prohibited. The author assumes no liability for misuse.
---
Build order: Stub first, then Builder. Stub Release|x64 emits stub_v0.bin..stub_v3.bin; Builder embeds one into .rsrc.
Ensure stub_v0.bin..stub_v3.bin are in the working directory (or pass --stub).
---
Worked examples grouped by scenario. Every flag is opt-out (e is fully ON by default), so the simplest in already gets the full stack.
<details>
<summary><b>Basic packing - EXE / DLL / shellcode</b></summary>
Pack an unmanaged EXE. Builder auto-detects the MZ header and routes through the RunPE path:
Pack raw position-independent shellcode (Cobalt Strike .bin, msfvenom -f raw, etc.). No MZ → direct call into the decompressed buffer:
Pack a DLL and call its default DllMain only (no export):
Use a stub from a non-default location:
</details>
<details>
<summary><b>DLL payloads with exports - Havoc / Sliver / custom beacons</b></summary>
Call a named export after DllMain returns. Most C2 implants ship as a DLL with a single entry export (e.g. Ha: Start, Sliver: RunSliver):
Pass a string argument to the export (max 127 chars). Useful for payloads that take a config string, URL, or shell command:
--keep-alive is required for any payload that spawns its own threads - without it the loader calls ExitProcess and kills the beacon.
</details>
<details>
<summary><b>Long-running implants (C2 beacons)</b></summary>
Cobalt Strike / Sliver / Havoc all spawn a beacon thread and return. The loader thread must terminate without taking the process down:
</details>
<details>
<summary><b>Module stomping presets - picking a host DLL</b></summary>
The decryptor stub is hidden inside the .text section of a benign Windows DLL. Pick the preset whose loaded modules look most legitimate for the target context:
PRINT (default) - xpsservices.dll, msi.dll, dbghelp.dll. Common across most workstations.NETWORK - winhttp.dll, wtsapi32.dll, wlanapi.dll. Fits a payload that already needs network APIs loaded.WEB - mshtml.dll (idx 10, ~17 MB .text) with msi.dll/dbghelp.dll fallbacks. Required for payloads whose compressed blob exceeds the ~2.5 MB ceiling of indices 0-9 (e.g. Go-based implants such as Sliver).RANDOM - three random indices from indices 0-9 (including bcrypt.dll); mshtml.dll (idx 10) is reachable only via --preset WEB.
Switch from LoadLibraryW stomping to NtCreateSection+NtMap o (DLL ne PEB.Ldr):
</details>
<details>
<summary><b>EDR userland unhooking</b></summary>
Restore clean .text bytes for ntdll, kernel32, kernelbase from \KnownDlls\ over any EDR inline hooks. HellsHall already bypasses sensitive Nt* hooks; --unhook is needed only when the payload itself calls hooked Win32 APIs (e.g. LoadLibrary, CreateProcess):
</details>
<details>
<summary><b>PEB spoofing - masquerading the process</b></summary>
O the auto-picked spoof name. Pick something that fits the parent process / launch context (Office macro → RuntimeBroker.exe looks weird, WmiPrvSE.exe blends in better):
The spoof name is just an ASCII filename - Stub prepends C:\Windows\System32\ at runtime. Anything not in the default 9-process pool works too.
</details>
<details>
<summary><b>Tuning e thresholds</b></summary>
Longer hammer delay against patient sandboxes that run for tens of seconds before judging:
Higher uptime threshold - only run if the box has been up for at least 30 minutes (most sandboxes spin a fresh VM per sample):
Tighter sleep-forwarding detection (shorter sleep, harder to fast-forward without observable error):
Custom semaphore name for the exec-control check (avoids clashing with another sample using the default wuauctl):
Stack e for a paranoid profile:
</details>
<details>
<summary><b>Disabling features for debugging / lab work</b></summary>
When iterating in a debugger, the TLS callback and debugger checks fire immediately. Disable both to attach freely:
Skip every sandbox check (still keeps OPSEC features on - ETW patch, PEB spoof, call-stack spoof):
Same thing, shorter:
Disable specific OPSEC features (e.g. when the target en doesn't need ETW patching, or PEB spoof breaks a payload that walks its own PEB):
--disable all ORs every token (OPSEC + sandbox/debug checks) and sets the E fast-exit shortcut. To keep OPSEC features on while skipping only the checks, list the check tokens explicitly instead of all.
</details>
<details>
<summary><b>Identity cloning — , icon, Authenticode cert</b></summary>
Copy the cosmetic identity of any donor PE into the packed output. The output binary's Explorer properties, taskbar icon, and Digital Signatures tab all reflect the donor:
What gets cloned:
RT_) — Explorer "Properties → Details" tab: company, product name, file , copyright. All language IDs present in the donor are copied.RT_GROUP_ICON + RT_ICON) — the lowest-ID icon group (the one Explorer uses for the shell icon). Taskbar, alt-tab, and file browser all show the donor's icon.WIN_CERTIFICATE PKCS#7 blob is appended at 8-byte-aligned EOF. Explorer "Properties → Digital Signatures" shows the donor's signer (e.g. Microsoft Windows). Get-AuthenticodeSignature returns Status = HashMismatch — the signature is structurally valid but the hash covers the donor's bytes, not ours. Defeats casual inspection; any real (signtool , Win, A) detects the mismatch.OPSEC — OriginalFilename: the donor's embeds OriginalFilename (e.g. notepad.exe). Some signature checkers and Defender heuristics flag a mismatch between OriginalFilename and the actual file name on disk. Name the output to match:
Combination with --pfx: when both flags are present, Phase 11 (clone) runs before Phase 12 (sign). The real signature o the cloned cert directory; and icon are preserved. Get-AuthenticodeSignature shows your certificate as valid, not the donor's hash-mismatched cert:
:
</details>
<details>
<summary><b>UAC ele — requireAdministrator manifest</b></summary>
Embed a requestedExecutionLevel="requireAdministrator" manifest so the output PE triggers a UAC prompt on launch and receives a high-integrity token if the user approves:
The manifest is an RT_MANIFEST resource (resource ID 1 — CREATEPROCESS_MANIFEST_RESOURCE_ID), the same slot the Windows loader checks for application compatibility and pri manifests. The output is otherwise identical to a build without --uac; no changes to the Stub or payload path.
Combination with --clone-meta and --pfx: Phase 10.5 (manifest) runs before Phase 11 (clone) and Phase 12 (sign). The Authenticode signature computed in Phase 12 covers all embedded resources including the manifest — the hash is valid over the final binary. The UAC dialog shows the publisher name from the signing cert:
OPSEC: a UAC prompt is a , user-facing event. The "Do you want to allow this app to make changes to your device?" dialog displays the on-disk filename and the Authenticode publisher (or "Unknown publisher" if unsigned). Combine --uac with --clone-meta to show a familiar icon and --pfx to show a credible publisher. For unattended execution that already starts from an elevated process (e.g. service, WMI lateral mo, local admin shell), --uac is unnecessary.
</details>
<details>
<summary><b>Authenticode signing (PFX, no signtool)</b></summary>
Sign the packed output with a PFX certificate. Builder talks to mssign32!SignerSignEx2 directly — no signtool.exe on the operator workstation, no Windows SDK signing tool required:
PFX with no password (omit the --pfx-pass flag entirely):
Add an RFC 3161 timestamp so the signature stays valid after the cert expires or is revoked. Note that the timestamp authority logs the build IP and the exact signing moment — see the OPSEC note below:
OPSEC — when to skip the timestamp: --ts-url is opt-in. Each request to a public TSA puts the operator's build host into the TSA's HTTP logs alongside the exact second the signature was produced — a strong forensic correlation if the sample later surfaces in incident response. Embedding the timestamp also stamps that same moment into the signature blob inside the packed PE, where any later analyst can read it. When no timestamp is required (typical for self-signed certs or short-lived ops where signature beyond cert expiry doesn't matter), omit the flag and signing stays fully air-gappable.
OPSEC — when to keep the timestamp: stolen or short-li certs that will be revoked benefit from a TSA countersignature — Windows accepts the signature post-re as long as the timestamp predates the re entry. In that case, sign from an isolated a proxy / Tor, and treat the TSA's logs as a deliberate (but contained) exposure.
The private key is ne: PFXImportCertStore is called with PKCS12_NO_PERSIST_KEY, so no key container appears under %APPDATA%\Microsoft\Crypto. SHA-256 digest, CNG-preferred KSP for compatibility with PFXs produced by New-SelfSignedCertificate and OpenSSL ≥3.x.
</details>
<details>
<summary><b>Realistic combinations</b></summary>
Cobalt Strike beacon DLL, network-themed host, full e, custom mutex name:
Ha shellcode, o instead of stomping, longer hammer delay:
Stage-2 EXE for a CTF where you control the trigger and don't need anti-debug:
Lateral-mo helper DLL with a path argument:
Long-term implant disguised as a common Microsoft binary, full e stack:
Packed sample with a real self-signed cert + matching identity:
</details>
---
Studio 2022 (MSVC v143), Release|x64 only. Open PolyEngine.sln.
Build order matters:
1. Stub project (Release|x64) → MSBuild fan-out emits x64/Release/stub_v0.bin … stub_v3.bin (POLY_=0..3)
2. Builder project → produces Builder/x64/Release/Builder.exe (or solution OutDir)
Each stub_v*.bin is a PE with linker entry EntryPoint only (no CRT). differ in OPSEC phase order and decoy/island layout; HellsHall/Moonwalk stay shared. Builder picks one at random from the CWD (or via --stub), runs StubMorph, then embeds the payload under a per-build RT_RCDATA ID.
MASM: Stub. → HellsHall.asm; Builder → Engine/DecryptorStub.asm. No CMake, no Makefile.
The solution also builds PolyEngine.GUI (nati + OpenGL3 + Dear ImGui, in-tree) and its test runner. Both land in x64/Release/ next to Builder.exe and stub_v*.bin — the GUI resol/stub paths from its own directory, so it works straight after the build with zero configuration. Run x64/Release/PolyEngine.GUI.exe; run x64/Release/PolyEngine.GUI.Tests.exe to execute the unit tests (38 tests, no external framework). GUI projects are x64-only and are skipped in Release|x86 solution builds.
!GUI-1
!GUI-2
!GUI-3
To add a new API hash: compute Djb2HashA("ApiName") (same algorithm as in ApiHashing.cpp), add a g_Hash_* global in ApiHashing.h, initialize it in ApiHashing_InitHashes().
---
Three components implementing a pack → encrypt → inject pipeline: Builder packs the input PE/shellcode, Engine (shared lib) provides crypto/compression/mutation primitives, Stub is the runtime executor embedded in the output PE.
<details>
<summary>Detailed pipeline + Stub execution flow</summary>
</details>
---
<details>
<summary><b>Loader — <code>stub_v0.bin</code>..<code>stub_v3.bin</code></b></summary>
Stub Release|x64 builds four loaders (POLY_=0..3, separate IntDir). Each binary has a different OPSEC phase order and per- decoy/island sizes (PolyIslands.c), so static hashes diverge. Shared and never : HellsHall.asm, SilentMoonwalk data layout (g_SpoofSyntheticStack), TLS/ResID marker tags.
| | Loader_OpsecPhase order |
|---|---|
| V0 | Unhook → Spoof → ETW → AMSI → XTEA → PEB |
| V1 | Spoof → Unhook → XTEA → PEB → ETW → AMSI |
| V2 | Unhook → Spoof → PEB → XTEA → ETW → AMSI |
| V3 | Spoof → ETW → AMSI → Unhook → XTEA → PEB |
Builder with no --stub picks randomly among stub_v0.bin..stub_v3.bin in the CWD (CryptGenRandom). --stub <path> forces one file.
</details>
<details>
<summary><b>Pack-time StubMorph</b></summary>
After TLS/ResID marker patches and before writing the output PE, StubMorph_Apply (Engine/StubMorph.c) mutates the chosen loader image in-place:
TimeDateStamp drawn from [now−5y, now] — a fully random DWORD can land in the future, which is a heuristic flag.rsrc, .reloc, .tls, .CRT) — random 8-char names would trip UPX-style packer heuristicsDanS..Rich+key zeroed — a Microsoft toolchain signature would contradict the rename story; MS keeps it for coherence)IMAGE_DIRECTORY_ENTRY_DEBUG + PE checksum (a real checksum is written back at the final Step 13 on every build)50 4C 59 A0 … AF markers in PolyIslands.c, incl. the g_PolyDecoy island) with random bytes of the same length, then randomize the tag markers themselves — no PLY pattern or fixed decoy content survives into the output PE; no PE growth, no reloc fixupsDoes not touch HellsHall, spoof globals, or marker tags used by TLS/ResID patching.
</details>
<details>
<summary><b>Indirect Syscalls — HellsHall</b></summary>
All sensitive NT operations (NtProtect, NtAllocate, etc.) go through indirect syscalls rather than the hooked user-mode stubs in the process ntdll:
1. At startup, Syscalls_Init() parses process ntdll's Export Directory and collects every Zw* function's RVA into a flat table.
2. SSNs are derived by R (HellsGate/FreshyCalls ): all Zw* RVAs are sorted; sorted index == SSN. Hook-agnostic by design — works even if EDR hooks ha function prologues, because hooks don't reorder exports.
3. A syscall; ret (0F 05 C3) trampoline is located inside process ntdll's .text section. The 3-byte sequence is the standard tail of every Nt* stub. EDR userland inline hooks target the *entry point* of exported Nt* functions (first 5–15 bytes) — never the syscall instruction at the stub's end, because patching the middle of a stub would break its semantics. The bytes at any matching site are therefore unmodified, identical to a clean \KnownDlls\ mapping, and live in MEM_IMAGE memory backed by C:\Windows\System32\ntdll.dll on disk. g_CleanTrampoline points there directly — no secondary ntdll mapping, no MEM_PRIVATE copy.
4. All syscalls jump to g_CleanTrampoline — EDR hooks at exported Nt* entry points are bypassed. From an ETW kernel-side stack walker's perspective, the leaf frame already lands inside ntdll.dll (no "unbacked syscall" IOC).
</details>
<details>
<summary><b>Userland Unhooking — <code>--unhook</code></b></summary>
Optional pass that runs once after Syscalls_Init() and before any payload-relevant syscall. For each of ntdll, kernel32, kernelbase:
1. NtOpenSection(\KnownDlls\<dll>) + NtMap — clean image bytes (same shared section the loader originally mapped from, before any EDR could install hooks).
2. Page-by-page memcmp of the live .text against the clean copy.
3. Where bytes differ (= EDR inline hook): NtProtect RX→RW, memcpy clean bytes over the hook, NtProtect RW→RX.
4. Unmap and close.
This restores normal ntdll.dll/kernel32.dll/kernelbase.dll semantics for any subsequent Win32 call (PEB walks, LoadLibrary, etc.). Skipped when --unhook is omitted — HellsHall on its own already bypasses all sensitive Nt* hooks, so unhooking is opt-in (it's a heavier action with a small risk of breaking unusual hook layouts).
</details>
<details>
<summary><b>Call Stack Spoofing — SilentMoonwalk RSP Pivot</b></summary>
EDRs monitor the thread call stack at the moment a syscall fires to the caller chain looks legitimate. PolyEngine defeats this with a SilentMoonwalk-style RSP pivot — no hardware breakpoints, no , no exceptions:
1. StackSpoof_Init() scans ntdll's .text (and any other IMAGE_SCN_MEM_EXECUTE section) for two gadgets:
- Gadget 1 — add rsp, imm8; ret (48 83 C4 XX C3) inside a function whose UNWIND_INFO ad a matching imm8 alloc delta. Constrained to imm8 < 0x20 so the chain doesn't collide with forwarded stack args.
- Gadget 2 — jmp rbx (FF E3), located in any executable section by raw byte scan, but only accepted if the matching site sits inside a RUNTIME_FUNCTION whose UNWIND_INFO parses cleanly. The function's allocDelta determines where RtlUserThreadStart is planted on the synthetic stack, so a gadget without a matching runtime function would break the EDR- chain. Jumping into the middle of a longer instruction is fine — the CPU decodes FF E3 from the gadget address regardless of the preceding byte.
2. A static g_SpoofSyntheticStack[32] is laid out so that the trampoline's ret walks gadget1 → gadget2 → back to the loader's continuation point, with RtlUserThreadStart planted further down as the apparent thread root.
3. Every call to HellsHallSyscall (when spoofing is enabled) does push rbx; lea rbx, AfterJmpPoint; mo], rsp; lea rsp, g_SpoofSyntheticStack; jmp r11. The kernel sees an RSP pointing into the synthetic stack, anchored in legitimate ntdll code.
4. Stack-based syscall arguments (5..10) are forwarded from the caller's frame to the synthetic stack at offsets 0x28..0x50 *before* the pivot — without this, the kernel would read gadget addresses as arguments and return STATUS_ACCESS_.
5. After the syscall, gadget2's jmp rbx lands on AfterJmpPoint, which restores RSP from g_SpoofSa, pops rbx, and returns to the loader.
6. StackSpoof_Cleanup() clears g_SpoofEnabled so the payload's own threads see real return addresses.
</details>
<details>
<summary><b>Module Stomping / Module O</b></summary>
Instead of (RWX), the polymorphic decryptor executes inside the .text section of a legitimately loaded Windows DLL. No RWX memory is e.
Stomping (default — LoadLibraryW, DLL appears in PEB LDR):
1. ModuleStomp_Alloc() iterates the three DLLs selected by --preset. DLL indices are stored in .rsrc and resolved from g_DllPool at runtime.
2. The first DLL with an executable section large enough for the decryptor stub is chosen.
3. Original .text bytes are saved to a private RW buffer before touching the section.
4. The decryptor stub (only) is copied into the stomped region. The payload blob stays in a separate RW allocation (pEncryptedPayload).
5. NtProtect RW → RX: the stomped region becomes executable. The payload allocation remains RW — decryptor receives its address in RCX (Windows x64 ABI first argument).
6. Decryptor runs, decrypts pEncryptedPayload in-place. NtProtect RX → RW immediately after return.
7. Region is wiped, original bytes are restored, section is set back to PAGE_EXECUTE_READ.
O (--o — NtCreateSection(SEC_IMAGE) + NtMap, NOT in PEB LDR):
PEB.Ldr, defeating tools that enumerate loaded modules.NtUnmap discards COW private pages, removing all e of the write.Result in both cases: memory region is MEM_IMAGE backed by the DLL's file on disk — memory scanners see a legitimate image mapping, not an anonymous region.
</details>
<details>
<summary><b>Polymorphic Decryptor — MutationEngine</b></summary>
Each build produces a unique 34-byte x64 ASM decryptor stub, ne to any prior build:
xor al, k / sub al, ~k+1 / not al; xor al, ~k)inc r9 randomized between inc r9, add r9,1, lea r9,[r9+1]; comparison swapped between cmp rdx,r9 and cmp r9,rdxxorSwapped flag flips outer key application order; encryptor + decryptor stay in sync bitThe CompoundEncrypt cipher (XOR→ROL→ADD→XOR on each byte) maps cleanly to the four-instruction decryptor template. The MutationEngine emits a different combination for every step, making static signature matching of the decryptor loop infeasible.
</details>
<details>
<summary><b>Encryption Stack</b></summary>
| Layer | Algorithm | Key source |
|---|---|---|
| Outer | XTEA-CTR (128-bit) | Runtime-derived base XOR per-build CryptGenRandom salt |
| Inner | CompoundEncrypt (XOR+ROL+ADD+XOR) | Per-build __rdtsc-seeded compound key, embedded in decryptor stub |
Outer XTEA key deri (Xtea_DeriveKey): the 128-bit key is built at runtime from arithmetic on irrational-number constants (φ, √2, √3, √5, √10 scaled to 32 bits). All five seed constants are themsel into XOR pairs (A ^ B) so no plaintext irrational byte sequence appears in .rdata — recovery requires running the deri. No contiguous 16-byte key blob exists in the binary. The final key is deri XOR key_salt, where key_salt is 16 bytes of CryptGenRandom output stored in .rsrc — every build produces a unique keystream.
Dynamic magic (no static YARA anchor): the .rsrc metadata block ends with magic = key_salt[0]^key_salt[1]^key_salt[2]^key_salt[3]. The Stub locates the block by scanning backwards and this in — no 0xDEADBEEF or other fixed constant exists for a YARA rule to anchor on.
</details>
<details>
<summary><b>TLS Callback Anti-Debug</b></summary>
The Windows Loader invokes TLS callbacks from .CRT$XLB before AddressOfEntryPoint recei — before any payload is in memory. At that moment the en is probed with zero external API dependencies (only CPU intrinsics and direct PEB reads):
| Check | PEB / Heap field | Detection condition |
|---|---|---|
| BeingDebugged | PEB+0x002 | any Win32 debugger attached |
| NtGlobalFlag | PEB+0xBC (x64) | bits 0x70 set by ntdll under a debugger |
| Heap Flags | ProcessHeap+0x70 (x64) | value != 2 (HEAP_GROWABLE) |
| Heap ForceFlags | ProcessHeap+0x74 (x64) | value != 0 |
On detection: __fastfail(FAST_FAIL_FATAL_APP_EXIT) — bypasses all user-mode exception handlers (VEH, SEH, UnhandledExceptionFilter). WER records STATUS_STACK_BUFFER_O (c0000409), indistinguishable from a legitimate memory-safety crash.
Can be disabled at build time with --disable tls. Builder patches a 5-byte marker in the loader stub to neutralize the callback before embedding.
</details>
<details>
<summary><b>ETW Patching</b></summary>
Opsec_PatchEtw() writes a 3-byte no-op to the first bytes of EtwE in the process ntdll:
Applied through NtProtect ( + Moonwalk RSP pivot). A separate pPage holds the base address for NtProtect (the kernel may round it to a page boundary); pEtw is preserved for the actual byte write.
</details>
<details>
<summary><b>AMSI Patching</b></summary>
Opsec_PatchAmsi() writes a 6-byte stub over AmsiScanBuffer in the in-process amsi.dll:
Callers treat E_IN as "bad input", not "malicious" — no alert path fires. Applied through the same HellsHall NtProtect RX→RW→RX pattern as the ETW patch, immediately after it in every POLY_ order.
Patch only if already mapped: amsi.dll is loaded into the process by A before EntryPoint runs. If it is *not* present, the patch is skipped entirely — the loader ne LoadLibrary("amsi.dll"), because an on-demand load would leave a fresh, timestamped IOC in the PEB LDR list. Soft-fail: a failed patch ne payload delivery. Disable with --disable amsi.
</details>
<details>
<summary><b>Block non-Microsoft DLLs — blockdlls</b></summary>
Right after payload extraction (before e checks), Loader_BlockDlls() sets SetProcessMitigationPolicy(ProcessSignaturePolicy, MicrosoftSignedOnly). From that point the OS refuses to load any DLL that is not Microsoft-signed into the loader process — EDR userland agents injected via LoadLibrary (in-process scanners, hooking DLLs) are turned away. The policy is one-way and survives for the process lifetime; it covers both the module-stomp DLLs (all System32, MS-signed) and anything the payload later loads.
Two implementation notes:
SetProcessMitigationPolicy only as a *forwarder string* (api-ms-win-core-...), so a nai walk returns a pointer to ASCII text (crash at call time).DynamicCodePolicy is deliberately not set — it would kill the loader's own module-stomping writes.Tradeoff: payloads that LoadLibrary non-Microsoft-signed DLLs (custom plugin DLLs dropped next to the output, third-party dependencies) will fail those loads. Disable with --disable blockdlls for such payloads. Get-ProcessMitigation does not display runtime-set signature policy (cmdlet blind spot) — via the API return value or by attempting an unsigned LoadLibrary.
</details>
<details>
<summary><b>Entropy shaping — nibble-to-text payload resource</b></summary>
A raw XTEA blob sits at ~7.99 bits/byte — the classic *high-entropy resource* heuristic that static scanners flag instantly. Engine/EntropyCodec.c maps every byte to two symbols of a 16-letter lowercase ASCII alphabet between the XTEA pass (Builder Phase 8.5) and the .rsrc write. The alphabet is per-build, not a compile-time constant: EntropyDeri draws an ordered 16-of-26 subset from a-z Fisher-Yates, seeded by key_salt (a fixed alphabet would be a cross-sample fingerprint — one charset-regex retro-hunt would match every build):
.rsrc payload entropy drops from ~7.8 to ~4.0 bits/byte, and statistically indistinguishable from text/config datametadata.blobSize keeps the decoded size; the resource physically holds blobSize*2 encoded bytes immediately before the metadata blockkey_salt in the metadata and decodes in GetPayloadFromResource (EntropyDecode, 256-byte reverse table on stack) into the same RW buffer XTEA then decrypts in place — no extra allocation, no crypto change</details>
<details>
<summary><b>Decoy import directory — synthetic .idata</b></summary>
The CRT-free stub has an empty import directory — and a GUI-subsystem exe with zero imports is as much of a packer tell as a suspicious one. (RtlAddFunctionTable, AllocConsole, GetConsoleWindow, ShowWindow are all lazy-resolved via GetProcAddressH at their RunPE call sites, so the old "console-hider trio + SEH registration" import set is gone.)
Engine/DecoyImports.c (Builder Phase 11.5) appends a synthetic .idata section to the packed output: one kernel32.dll descriptor with 5-8 randomly picked innocuous exports (partial Fisher-Yates over a 12-name pool: GetTickCount64, QueryPerformanceCounter, GetSystemTimeAsFileTime, ...), INT+IAT thunk arrays, and NumberOfSections / SizeOfImage / DataDirectory[IMPORT] / [IAT] updates. The result looks like a minimal MSVC GUI binary's import table. The decoys are ne; the loader fills the IAT with real addresses at load time as usual.
Pipeline position matters: Phase 11.5 runs *after* the last BeginUpdateResource/EndUpdateResource cycle (Phases 10/10.5/11) because UpdateResource relocates sections on .rsrc growth and fixes up only the RESOURCE and BASERELOC directories — an import directory planted earlier ends up with a stale RVA. It runs *before* signing (Phase 12) so the signature covers it, and is skipped when a cert table already exists (--clone-meta path — appending past WIN_CERTIFICATE would in the cloned cert).
</details>
<details>
<summary><b>PEB Spoofing</b></summary>
Opsec_SpoofPeb() rewrites:
PEB.ImageBaseFileName — process name shown by Process Hacker, etc.PEB.ImagePathName and PEB.CommandLine — full path in process listingsPEB.BeingDebugged = 0, PEB.NtGlobalFlag = 0 — anti-debug flagsProcessHeap.Flags = 2, ProcessHeap.ForceFlags = 0 — heap debug flagsThe spoof filename is set via --spoof-name. If omitted, Builder picks randomly from a pool of 9 common System32 processes (RuntimeBroker.exe, SgrmBroker.exe, WmiPrvSE.exe, SearchIndexer.exe, taskhostw.exe, spoolsv.exe, wlrmdr.exe, WMPDMC.exe, hvix64.exe) using CryptGenRandom.
</details>
<details>
<summary><b>Sandbox & Anti-Analysis Checks</b></summary>
E() runs before any syscall initialization and uses only the Win32 API layer. Checks are indi toggleable via --disable.
Hard checks — a single positi immediate exit:
| Check | Method | What it detects |
|---|---|---|
| debugger | PEB.BeingDebugged, PEB.NtGlobalFlag, ProcessHeap flags, NtQueryInformationProcess(ProcessDebugPort) | Win32 debugger attached |
| api-emu | RtlComputeCrc32(seed, NULL, 0) — must equal seed | API emulation that returns wrong results |
| exec-ctrl | Named semaphore (wuauctl by default, configurable) — ERROR_ALREADY_EXISTS | Second execution of the sample |
| sleep-fwd | Sleep(ms) + GetTickCount64 delta, threshold = 90% of ms | Sandbox that fast-forwards Sleep calls |
Soft checks — 2 or more positi to exit (reduces false positives):
| Check | Threshold | What it detects |
|---|---|---|
| uptime | system uptime < N minutes (default: 2) | Freshly spawned sandbox VM |
| cpu | logical processor count < 2 | Low-spec sandbox |
| screen | screen width ≤ 1024 px | 800×600 / 1024×768 sandbox resolutions |
| files | HKCU\...\Explorer\RecentDocs subkey count < 5 | Clean / fake user profile |
Timing delay:
E() burns real wall-clock time via / pairs timed by GetTickCount64. The duration is configurable via --hammer-s (default: 3 seconds). Sandbox time-accelerators cannot fast-forward allocator round-trips, making this effecti sleep-fast-forward e that bypasses the sleep-fwd check.
</details>
<details>
<summary><b>API Hashing — Djb2</b></summary>
All Windows API names are replaced at compile time with precomputed Djb2 hashes stored as g_Hash_* globals. GetProcAddressH() walks the export directory and hashes each exported name until a match is found — no plaintext API string appears in the import table or .data.
Forwarded exports: on Win10/11 many kernel32 exports are forwarders (InitializeCriticalSection → NTDLL.RtlInitializeCriticalSection, ExitThread → NTDLL.RtlExitUserThread) — their EAT entries point at MODULE.Function ASCII strings inside the export directory, not code. Both GetProcAddressH and RunPE's RunPE_GetExport detect forwarder RVAs (inside the export directory range) and follow them to the target module's EAT, so payloads (e.g. MinGW CRT init calling EnterCriticalSection) never end up executing a string as code.
</details>
<details>
<summary><b>Identity cloning — <code>--clone-meta</code></b></summary>
Optional Phase 11 post-build step that copies three cosmetic attributes from a donor PE into the already-built output. Runs after BuildInfectedPE (which rewrites .rsrc entirely — anything written earlier would be lost) and before SignPeWithPfx (which o the cert directory with a real signature if --pfx is also given).
CloneMeta_CopyResources loads the donor as a flat data file (LOAD_LIBRARY_AS_DATAFILE — deliberately *without* LOAD_LIBRARY_AS_IMAGE_RESOURCE, which activates MUI resource redirection on system files and routes RT_GROUP_ICON lookups to a language satellite .mui that contains no icons). RT_ is enumerated via EnumResourceLanguagesA to collect all language IDs; each is written with UpdateResourceA. For RT_GROUP_ICON, the lowest integer-ID group is selected (the one Explorer uses for the shell icon by con). FindResourceA is not used for the actual data lookup — it fails with ERROR_RESOURCE_NAME_NOT_FOUND (1813) on LOAD_LIBRARY_AS_DATAFILE handles even for resources that EnumResourceNamesA just found, because the LANG_NEUTRAL fallback path is broken in datafile mode. Instead, EnumResourceLanguagesA extracts the exact LANGID stored in the donor, then FindResourceExA uses it directly. All UpdateResourceA calls open the output PE with BeginUpdateResourceA(FALSE) — the merge flag preserves the existing .rsrc payload entry from Phase 10.
CloneMeta_CopyCertDirectory reads the donor into a flat buffer via ReadFileToBuffer, parses the DOS → NT headers (supporting both x86 and x64 donors via OptionalHeader.Magic dispatch), and extracts DataDirectory[IMAGE_DIRECTORY_ENTRY_SECURITY]. That directory entry uses a file offset (not an RVA) — it is the only PE data directory where is a raw byte offset into the file. The cert blob is appended at 8-byte-aligned EOF (WIN_CERTIFICATE alignment requirement), the target's SECURITY directory entry is patched in-place, then MapFileAndCheckSumA (from imagehlp.lib) recomputes the PE checksum. The write handle is closed before calling MapFileAndCheckSumA — MapFileAndCheckSumA opens its own internal handle and would fail with a sharing if the caller holds an exclusi handle — then re-opened briefly to write just the 4-byte checksum at the computed file offset.
</details>
<details>
<summary><b>Authenticode signing — <code>--pfx</code></b></summary>
Optional post-build step. When --pfx is supplied, Builder signs the packed output via mssign32!SignerSignEx2 (resolved at runtime — no link-time mssign32 dependency, no signtool.exe on the operator workstation). Signing runs as Phase 9 after BuildInfectedPE returns, because writing the signature rewrites IMAGE_DIRECTORY_ENTRY_SECURITY and recalculates the PE checksum; any later resource edit would in the signature.
The PFX is imported with PKCS12_NO_PERSIST_KEY | PKCS12_PREFER_CNG_KSP | PKCS12_INCLUDE_EXTENDED_PROPERTIES. NO_PERSIST_KEY keeps the private key resident in memory only — no key container file written under %APPDATA%\Microsoft\Crypto, which would otherwise tie the operator's workstation to the signed sample. PREFER_CNG_KSP is required for modern PFXs (PowerShell New-SelfSignedCertificate, OpenSSL ≥3.x); without it SignerSignEx2 fails with NTE_BAD_TYPE (0x8009000A). Digest is SHA-256.
--ts-url is opt-in. RFC 3161 timestamping sends an HTTP request to the timestamp authority, which logs the requester IP plus the signing moment, and embeds that moment into the signature blob inside the PE. Skip the flag for fully air-gappable signing; use it only when the signature must outlive the cert's lifetime (e.g. stolen / short-li certs that will be revoked).
</details>
---
Resource payload is not fixed at ID 101. At pack time Builder:
1. Draws a WORD RT_RCDATA ID via CryptGenRandom in range 0x0100..0x7EFF
2. Patches LE bytes at g_PayloadResIdMarker[4..5] in the loader stub (tag {0xB1,0x0B,0x1D,0xE0} in Payload.c; unpatched default = 101)
3. Runs StubMorph_Apply on the stub image
4. Writes UpdateResource(RT_RCDATA, id) = [EntropyCodec-shaped XTEA blob (blobSize*2 bytes) | PAYLOAD_METADATA (280 bytes)]
At runtime Stub reads the marker and calls FindResourceW with that ID. The metadata block is located by scanning backwards from the end of the resource (up to 128 bytes, tolerating UpdateResource alignment padding) and magic == XOR(key_salt[0..3]).
<details>
<summary>Field-by-field layout</summary>
flags bits (see Engine/OpsecFlags.h): OPSEC 0–5, e/unhook 6–16, PAYLOAD_FLAG_IS_SHELLCODE (17), OPSEC_FLAG_NO_AMSI (18), OPSEC_FLAG_NO_BLOCKDLLS (19).
No fixed value exists anywhere in the block — every field is either random (key_salt, magic) or build-specific. The RT_RCDATA ID is also per-build. YARA cannot anchor on a static byte sequence or a fixed resource ID.
Loader exit codes (Release): all loader failure paths exit with code 0 via LOADER_EXIT (Stub/Common.h). Debug builds keep distinct codes for step diagnosis. E detections already exit 0.
</details>
---
Builder --preset selects 3 DLL indices stored in .rsrc. Stub resol from g_DllPool at runtime — no DLL name appears in the payload or metadata.
<details>
<summary>DLL pool table</summary>
| Index | DLL | Group |
|---|---|---|
| 0 | xpsservices.dll | PRINT |
| 1 | msi.dll | PRINT |
| 2 | dbghelp.dll | PRINT |
| 3 | winmm.dll | MEDIA |
| 4 | dxgi.dll | MEDIA |
| 5 | oleaut32.dll | MEDIA |
| 6 | winhttp.dll | NETWORK |
| 7 | wtsapi32.dll | NETWORK |
| 8 | wlanapi.dll | NETWORK |
| 9 | bcrypt.dll | (RANDOM-only) |
| 10 | mshtml.dll | WEB (~17 MB .text — fits payloads > 2.5 MB) |
Index 9 (bcrypt.dll) is reachable only via --preset RANDOM; index 10 (mshtml.dll) only via --preset WEB. Builder at build time that at least one of the three selected DLLs has an executable section large enough for the payload blob, and warns if none qualify. Index 10 (mshtml.dll) is there to allow big payloads to run after being packed.
</details>
---
<details>
<summary>File tree</summary>
</details>
---
/NODEFAULTLIB), no malloc/free, no <string.h>---
Maintained by: Razz | Built for opsec-conscious security research and fun
*Assistance in implementation done with Claude Code and Kimi K3*
---
MIT — authorized use only. See LICENSE for the full disclaimer.
开发语言:C++
Star 数:167
该项目属于CTF思路方向的开源资源,代码结构清晰,适合安全研究人员学习参考。项目围绕PolyEngine is an evasive PE packer designed for CTF challenges and low-level Windows security education. It focuses on bypassing EDR and AV heuristics through a layered stack of in-memory execution and obfuscation techniques.展开,提供了可运行的实现,对理解相关安全机制、工具原理与攻防技术具有较高的研究价值。
从技术实现角度看,此类项目的核心在于对底层原理的深入理解与工程化落地。开发者在研究过程中可以结合官方文档与社区讨论,梳理项目的初始化流程、核心模块职责与数据流转,逐步掌握其中的技术细节与设计思路。
1. 下载源码压缩包,解压到本地环境。
2. 根据项目类型准备对应的运行环境与依赖。
3. 按照 README 中的步骤完成配置与部署。
4. 在隔离的测试环境中运行验证,避免影响生产系统。
本资源来源于开源社区,仅供安全研究、技术学习与合法授权范围内的攻防演练使用,请勿用于任何非法或侵害他人权益的用途。使用本项目产生的一切后果由使用者自行承担。
| 资源标题 | PolyEngine - PolyEngine是一款专为CT设计的回避式PE封隔器 |
| 所属分类 | CTF思路 |
| 发布作者 | admin |
| 发布时间 | 2026-10-05 23:48:25 |
| 下载权限 | 免费下载 |
| 附件数量 | 2 个下载链接 |
| 浏览次数 | 347 |
| 讨论数量 | 0 |
| 主题状态 | 正常讨论中 |