obsidian
obsidian is a pe (portable executable) packer/obfuscation suite for x64 and arm64 windows binaries. it encrypts the target's sections, appends a decryption stub in a new .nexus section, and redirects the entry point to the stub. at runtime the stub decrypts the original sections in-place, restores pe directories, resolves imports, applies relocations, and jumps to the original entry point.
editions
source files
| file | edition | purpose |
|---|---|---|
obsidian.c | ce | pe packing tool. reads input pe, encrypts sections with xorshift64+, appends stub + config as .nexus section |
stub.c | ce | x64 runtime stub. peb import resolution, xorshift64+ decryption, relocation, import restoration, oep jump |
stub-arm64.c | ce | arm64 runtime stub. same logic as x64 stub with arm64-specific teb access, relocation types, and entry signature |
obsidian.c | pro | pe packing tool. speck 128/128 ctr encryption, optional aplib compression, speck-cbc-mac integrity, anti-debug stub support |
stub.c | pro | x64 runtime stub. speck decryption, aplib decompression, triple integrity checks, anti-debug via direct syscalls, stub self-wipe |
architecture
obsidian operates in two phases: pack time (obsidian) and run time (stub). the crypter is a windows console application that processes the input pe. the stub is a position-independent shellcode embedded as raw binary in a pe resource, then appended to the output as a new section.
key differences: ce vs pro
| feature | community edition | pro |
|---|---|---|
| encryption | xorshift64+ (xorshift64+ subkeys) | speck 128/128 ctr mode |
| key size | 64-bit | 128-bit key + 64-bit iv + 64-bit counter |
| compression | none | aplib (optional) |
| integrity | none | speck-cbc-mac (triple verification) |
| anti-debug | none | multi-layered via direct syscalls (--ultra) |
| anti-sandbox | none | cpu/ram/hostname checks |
| stub wipe | securewipe config only | background thread wipes entire stub |
| arm64 | separate stub-arm64.c | not yet (todo) |
| mitigation policies | none | acg, dep, payload restriction |
| peb modules resolved | kernel32.dll only | kernel32.dll + ntdll.dll + kernelbase.dll |
pack-time flow
run-time flow
obsidian.c ce
the community edition crypter is a windows console application that packs pe files using xorshift64+ obfuscation. it supports both amd64 and arm64 targets with architecture-appropriate stubs loaded from pe resources.
usage
obsidian.ce.universal.exe [--debug] [--pink] <input.exe> <output.exe>
packing process
the pack_pe() function performs a 9-step transformation:
- collect pe info — save original oep, imagebase, and all data directory rvas (import, tls, resource, exception, reloc)
- zero directories — clear import, bound import, iat, tls, reloc, exception, resource, debug, load config, security, and coff symbol table entries
- determine encryption region — encrypt from
sizeofheadersto end of file. save section info (virtual_address, raw_offset, raw_size) for each section - generate key —
bcryptgenrandom(32 bytes)→sha-256→ first 8 bytes asuint64_tkey. fallback:queryperformancecounter ^ gettickcount64 ^ &key - obfuscate payload — apply
obfuscate_data()(xorshift64+) to all section bytes - add stub section — append
.nexussection containing:stub.bin+stub_config+ 4-byte marker +section_info[] - write config — populate
stub_configwith key, oep, encrypted_size, image_base, all saved directory rvas, section count - locate entry signature — scan stub binary for entry signature. set new entry point to
stub_rva + signature_offset + sig_length - recalculate checksum — pe checksum algorithm over all 16-bit words + file size
xorshift64+ obfuscation
the obfuscate_data() function applies a per-byte cipher seeded with the encryption key:
uint8_t shift1 = (uint8_t)((i * 8) & 0x3F);
uint8_t shift2 = (uint8_t)((24 + i * 8) & 0x3F);
uint8_t shift3 = (uint8_t)((56 + i * 8) & 0x3F);
uint8_t mask = (uint8_t)(subkey >> shift1)
^ (uint8_t)(subkey >> shift2)
^ (uint8_t)(subkey >> shift3);
data[i] += key_xor_aa_shr8;
data[i] -= key_xor_aa;
data[i] ^= mask;
the decryption in the stub reverses the operations: data[i] += shr8; data[i] -= xor_aa; data[i] ^= mask;
marker randomization
the stub binary contains a static volatile uint32_t MARKER_VALUE = 0xDEADBEEF. at pack time, the crypter generates a random 32-bit marker via bcryptgenrandom and replaces all occurrences of 0xEFBEADDE in the stub binary. the same random marker is written after stub_config. at run time the stub scans for the second occurrence of this marker to locate its config.
pe validation
validate_pe() checks: valid dos magic (MZ), valid nt signature (PE\0\0), machine is amd64 or arm64, optional header magic is pe32+ (0x20B), section count between 1 and 96.
section addition
add_section() handles alignment, header space (shifts sections if needed), creates a new IMAGE_SECTION_HEADER with IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE, updates NumberOfSections and SizeOfImage. also enables IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE, HIGH_ENTROPY_VA, and NX_COMPAT.
architecture detection
after validation, the machine type determines which stub resource to load: resource id 100 for amd64, resource id 101 for arm64. the appropriate entry signature is used when scanning for the new entry point.
pyinstaller ce
obsidian supports python scripts through a patched PyInstaller bootloader. when --pyinstaller is used with a .py file, obsidian first invokes the local patched PyInstaller checkout to create a onefile executable, then immediately packs that executable with the normal obsidian section encryption flow.
how it works
stock PyInstaller onefile executables store their application package as an appended archive at the end of the executable. obsidian removes that plaintext package from the generated executable, encrypts it with the same xorshift64+ obfuscation routine, and appends it back with a custom footer.
[ packed pyinstaller exe sections ]
[ obsidian .nexus stub section ]
[ encrypted pyinstaller pkg archive ]
[ WWNPYI1! footer ]
the footer contains the magic string, encryption key, package offset, and package size:
typedef struct _WWN_PYI_FOOTER {
char magic[8]; // "WWNPYI1!"
uint64_t key;
uint64_t pkg_offset;
uint64_t pkg_size;
} WWN_PYI_FOOTER;
runtime flow
- windows starts the packed executable at obsidian's
.nexusstub entry point - the obsidian stub decrypts the original PyInstaller executable sections in memory
- the stub restores imports, relocations, resources, exceptions, tls metadata, and section permissions
- the stub jumps to the original PyInstaller bootloader entry point
- the patched PyInstaller bootloader opens its own executable and looks for the
WWNPYI1!footer at EOF - the bootloader reads the encrypted pkg archive from
pkg_offset/pkg_size, decrypts it in memory, and parses the normal PyInstaller cookie/toc from the decrypted bytes - the python application runs normally from the decrypted archive
why a fork is required
the old approach attempted to keep a stock PyInstaller bootloader and fake plaintext archive reads by hooking file APIs inside the obsidian stub. the forked bootloader removes that fragile hook path. the bootloader itself understands the encrypted appended archive, so the obsidian stub only needs to decrypt the outer executable and jump to the PyInstaller entry point.
| marker | used by | purpose |
|---|---|---|
DE AD BE EF | obsidian stub | locates stub_config in the .nexus section |
WWNPYI1! | patched PyInstaller bootloader | locates the encrypted appended pkg archive footer |
resource layout
community edition uses separate resources for normal amd64, normal arm64, and PyInstaller-specific amd64 stubs:
100 RCDATA "stub.bin"
101 RCDATA "stub-arm64.bin"
102 RCDATA "stub-py.bin"
the current PyInstaller flow targets amd64 PyInstaller output. normal arm64 packing remains supported through stub-arm64.bin.
build guide
in order to obfuscate a python script with obsidian, you first need to make sure you have installed PyInstaller via pip. then clone and compile the forked obsidian-pyinstaller repo found here:
https://github.com/vertigo-z/obsidian-pyinstalleron windows:
- install the official MSYS2 UCRT64 package from
https://www.msys2.org/ - open the terminal, then run
pacman -Syu && pacman -S --needed mingw-w64-ucrt-x86_64-gcc - use the same MSYS2 terminal to open
cmd.exe, then navigate to the clonedobsidian-pyinstallerfolder - compile the forked PyInstaller package with
python waf all
once complete, copy the contents of obsidian-pyinstaller/pyinstaller/bootloader/Windows-64bit-intel to the system installation of PyInstaller's bootloader, usually found in the site-packages/PyInstaller/bootloader folder of your python installation. it is recommended to back up the old bootloader before doing this step; otherwise you will have to reinstall PyInstaller to use it without packing.
then run obsidian with the --pyinstaller flag:
.\obsidian.ce.universal.exe --pyinstaller script.py obfuscated-py.exe
stub.c (x64) ce
the community edition x64 stub is a position-independent shellcode that runs as the new entry point of a packed pe. it has zero imports — all win32 functions are resolved at runtime by walking the peb and hashing export names with crc32.
entry point
_start is a naked function that begins with the signature bytes. the crypter locates these bytes to calculate the new entry point. after the signature: stack alignment (and rsp, 0xFFFFFFFFFFFFFFF0), then call position_independent_entry.
position-independent entry
position_independent_entry() uses __builtin_return_address(0) to get its own approximate address. it then scans forward byte-by-byte looking for the second occurrence of MARKER_VALUE. the first occurrence is the static variable in the stub code; the second is written by the crypter immediately after stub_config. once found, config = (marker - sizeof(stub_config)).
peb import resolution
resolve_imports() accesses the peb via __readgsqword(0x60), walks InMemoryOrderModuleList, and identifies kernel32.dll by crc32 hash (0xB37B13DA). it then resolves 7 functions from kernel32's export table by crc32 hashing each export name:
| function | crc32 hash |
|---|---|
VirtualAlloc | 0xDAEC3C14 |
VirtualProtect | 0x796AECA9 |
VirtualFree | 0x26F919CC |
OutputDebugStringA | 0x35228EDA |
GetModuleHandleA | 0x41D65003 |
GetProcAddress | 0x794CF7AA |
LoadLibraryA | 0x839EC179 |
decryption
xorshift64_plus() reverses the crypter's obfuscate_data(). for each byte it generates the same xorshift64+ subkey and mask, then applies the inverse operations:
data[i] += key_xor_aa_shr8;
data[i] -= key_xor_aa;
data[i] ^= mask;
stub_main flow
- resolve imports via peb
- allocate decryption buffer with
VirtualAlloc - get actual base address from peb (
__readgsqword(0x60)→ peb+0x10) - copy each section from the packed pe into the buffer using
section_infooffsets - decrypt the buffer in-place with
xorshift64_plus() - write decrypted sections back to the original section locations in memory
SecureWipeandVirtualFreethe decryption buffer- zero out bss sections (sections with
SizeOfRawData == 0andVirtualSize > 0) - restore pe directories via
restore_directories_and_relocate() - resolve payload imports via
resolve_payload_imports() - restore section permissions via
apply_section_permissions() - invoke tls callbacks if present
- wipe the config zone
- jump to original entry point:
((void(*)())(base + config->original_oep))()
relocation
two methods are used. apply_relocations() processes the standard IMAGE_BASE_RELOCATION table, applying IMAGE_REL_BASED_DIR64 entries with the delta between actual and preferred base. manual_relocations() scans all non-executable sections for 8-byte values that fall within the original image range and adjusts them by delta — this catches pointers not covered by the relocation table.
payload import resolution
resolve_payload_imports() walks the original pe's import descriptors (restored from config). for each dll it calls GetModuleHandleA (already loaded) or LoadLibraryA, then resolves each function via GetProcAddress by name or ordinal, writing the result into the iat.
compilation with gcc
gcc.exe stub.c -o stub.o [-DDEBUG] -fno-asynchronous-unwind-tables -fno-ident -fno-stack-protector
ld.exe stub.o -o stub.exe -nostdlib --build-id=none -s --entry=_start
objcopy.exe -O binary stub.exe stub.bin
note that this requires .intel_syntax noprefix at the top of the inline ASM, and .att_syntax prefix at the end.
compilation with llvm
compiling with clang/llvm leads to better size optimization and performance than gcc. we can also apply ollvm-22 obfuscation passes when using llvm.
/llvm/bin/clang \
--target=x86_64-pc-windows-gnu \
-I.../generic-w64-mingw32/include \
-masm=intel \
-fno-asynchronous-unwind-tables -fno-ident -fno-stack-protector -Oz \
-fpass-plugin=libObfuscation.so \
-mllvm -fla -mllvm -sub \
-c stub.c -o stub.o
then apply the same linker and objcopy passes:
ld.exe stub.o -o stub.exe -nostdlib --build-id=none -s --entry=_start
objcopy.exe -O binary stub.exe stub.bin
the stub is compiled to a raw binary (stub.bin) and embedded as a pe resource in the crypter executable.
stub-arm64.c ce
the arm64 stub is functionally identical to the x64 stub but adapted for the aarch64 architecture. it handles arm64-specific peb access, relocation types, and entry point conventions.
differences from x64 stub
| aspect | x64 | arm64 |
|---|---|---|
| peb access | __readgsqword(0x60) | x18 register (teb) → teb + 0x60 |
| actual base | __readgsqword(0x60) → peb+0x10 | x18 → teb+0x60 → peb+0x10 |
| relocation types | IMAGE_REL_BASED_DIR64 only | DIR64 + PAGEBASE_REL21 + PAGEOFFSET_12A + PAGEOFFSET_12L |
| stack alignment | and rsp, ~0xF | and x9, sp, #0xfffffffffffffff0 + mov sp, x9 |
| position_independent_entry | __builtin_return_address(0) | adr instruction + inline marker label |
arm64 relocation types
apply_relocations() handles four relocation types for arm64 pe files:
IMAGE_REL_BASED_DIR64(type 10) — standard 64-bit absolute relocation.*target_ptr += deltaIMAGE_REL_BASED_ARM64_PAGEBASE_REL21(type 3) — encodes the page address into anadrp-style instruction. extracts page offset bits and patches the instruction's immediate fieldsIMAGE_REL_BASED_ARM64_PAGEOFFSET_12A(type 4) — encodes a 12-bit page offset into anaddinstruction's immediate fieldIMAGE_REL_BASED_ARM64_PAGEOFFSET_12L(type 5) — encodes a scaled 12-bit offset into aldr/strinstruction. scale factor derived from instruction bits [31:30]
position-independent entry
instead of __builtin_return_address(0), the arm64 stub uses an adr instruction followed by a branch and an inline marker label.
it then scans forward from x0 + 4 for the second marker occurrence, same as x64.
packer pro
the pro crypter extends the community edition with speck 128/128 ctr encryption, optional aplib compression, speck-cbc-mac integrity verification, and support for anti-debug stub flags. it is amd64-only (arm64 support is planned).
usage
obsidian.exe [--debug] [--ultra] [--compress] [--fix] [--pink] <input.exe> <output.exe>
| flag | description |
|---|---|
--debug | verbose debug output with hexdumps, verification, and step-by-step logging |
--ultra | enable anti-debugging protections in the stub (peb checks, syscall-level detection, stub integrity enforcement) |
--compress | compress the payload with aplib before encryption. the stub decompresses at runtime |
--fix | compatibility mode. allows dynamic loading of non-microsoft dlls via LoadLibraryA in the stub |
--pink | show pink star banner instead of purple |
packing process
the pro crypter follows the same general flow as ce but with additional steps for compression and integrity:
- collect pe info — same as ce, plus
CompressedSizeandOriginalPayloadSizefields - zero directories — same as ce
- compress (if
--compress) — compress section data withaP_pack()from aplib.dll (loaded from resource). truncate pe to headers only, zero all sectionPointerToRawDataandSizeOfRawData - determine encryption region — if compressed: encrypt the compressed buffer. if not: encrypt section data in-place (same as ce)
- generate key + iv —
bcryptgenrandom(48 bytes)→sha-256(entropy[:32])for 128-bit key,sha-256(entropy[32:48])for 64-bit iv - encrypt payload —
encrypt_data()with speck 128/128 ctr mode - compute encrypted crc — crc32 of the encrypted payload for config integrity
- add .nexus section — contains: stub code + [encrypted payload if compressed] + stub_config + marker + section_infos
- write config — extended stub_config with speck key/iv, ultra/compressed/compatibility flags, stub_rva, stub_mac, encrypted_crc
- compute speck-cbc-mac — derive integrity key, compute cbc-mac over stub code, store in
config->stub_mac - locate entry signature — scan for
"obsidian"ascii bytes (0x6F 0x62 0x73 0x69 0x64 0x69 0x61 0x6E). set new ep - recalculate checksum
speck 128/128 ctr encryption
encrypt_data() implements speck 128/128 in counter mode:
rol64: return (x << r) | (x >> (64 - r));
ror64: return (x >> r) | (x << (64 - r));
speck_key_schedule:
g_roundKeys[0] = key[0];
uint64_t b = key[1];
for (int i = 0; i < SPECK_ROUNDS - 1; i++) {
b = ror64(b, 8) + g_roundKeys[i];
b ^= (uint64_t)i;
g_roundKeys[i + 1] = rol64(g_roundKeys[i], 3) ^ b;
}
speck_encrypt_block:
for (int i = 0; i < SPECK_ROUNDS; i++) {
*x = ror64(*x, 8) + *y;
*x ^= g_roundKeys[i];
*y = rol64(*y, 3) ^ *x;
}
the speck block cipher uses 32 rounds with rotate operations: x = ror(x, 8) + y; x ^= roundkey; y = rol(y, 3) ^ x;
aplib compression
when --compress is active, the crypter loads aplib.dll from pe resource 101. the dll is written to a temp file, loaded with LoadLibraryA, then the temp file is deleted. aP_pack() compresses the payload before encryption. the compressed payload is stored inside the .nexus section (after stub code, before config), and the pe is truncated to headers only.
integrity key derivation
derive_integrity_key() generates a mac-specific key.
the derived key is then used for speck-cbc-mac computation over the stub code.
runtime stub (x64) pro
the pro stub extends the ce stub with speck decryption, aplib decompression, triple speck-cbc-mac integrity verification, multi-layered anti-debug via direct syscalls, anti-sandbox checks, stub self-wipe, and process mitigation policies.
triple integrity verification
the stub verifies its own integrity three times during execution using speck-cbc-mac:
- before decryption — computes mac over stub code. if tampered, zeros the encryption key and sets
ultra=1(enabling anti-debug checks that will further corrupt execution) - after section write-back — recomputes mac. if tampered, wipes the written sections and sets
ultra=1 - before oep jump — final check. if tampered, corrupts the base pointer with
target_base ^= key[0] ^ key[1], causing a guaranteed crash at oep
speck decryption
decrypt_data() implements speck 128/128 ctr decryption (identical to encryption since ctr mode is symmetric). it uses the same key schedule and counter structure as the crypter. decryption only proceeds if the first integrity check passes (verified == 1).
aplib decompression
when config->compressed != 0, the stub decompresses the payload after decryption. the decompressor is built directly into the stub (decompress()) — no external dll needed. it implements the full aplib safe decompression algorithm with bit-level reading and gamma decoding. the decompressed data is written to a new buffer, and the decryption buffer is wiped and freed.
anti-debug (ultra mode)
when config->ultra != 0, multiple anti-debug checks are active:
- peb.beingdebugged — checked immediately after peb access. if set, calls
fastfail(int 0x29) - ntglobalflag — peb+0x68 checked for
0x70(flg_heap_enable_tail_check | flg_heap_enable_free_check | flg_heap_validate_parameters). if set,fastfail - user-mode debugger —
anti_anti_anti_debug()issuesNtQueryInformationProcess(ProcessDebugPort)via direct syscall (bypassing hooks). reads the syscall number fromntdll's stub patternmov r10, rcx; mov eax, <number> - kernel debugger —
check_kernel_debugger()issuesNtQuerySystemInformation(SystemKernelDebuggerInformation)via direct syscall. checksKernelDebuggerEnabled && !KernelDebuggerNotPresent - hide from debugger —
NtSetInformationThread(ThreadHideFromDebugger)hides the thread from debugger attach
debugger detection results in encryption key corruption (zeros or garbage values), causing decryption to produce invalid data. the stub also enters infinite wipe loops in some detection paths.
stub self-wipe
a background thread (wipe_stub()) is spawned via CreateThread to erase the stub code from memory after execution. it sleeps 500ms, then wipes all stub pages except the page containing the current instruction pointer (which is cleaned up naturally). the config zone is also wiped via overwrite() before jumping to oep.
mitigation policies
set_mitigation_policies() applies three process mitigation policies via SetProcessMitigationPolicy:
- policy 2 — ar arbitrary code guard (acg)
- policy 8 — dynamic code restriction
- policy 10 — payload restriction
only applied when ultra != 0 and compatibility == 0.
peb modules resolved
the pro stub resolves functions from three dlls (compared to ce's one):
- kernel32.dll (
0xB37B13DA) — same 7 functions as ce plusCreateThread,Sleep,GlobalMemoryStatusEx,GetSystemInfo,GetComputerNameA - ntdll.dll (
0xD39C6577) —NtQuerySystemInformation,NtSetInformationThread,NtQueryInformationProcess - kernelbase.dll (
0x5FFDA9B8) —SetProcessMitigationPolicy
encryption
obsidian uses two different encryption algorithms depending on the edition. both operate on the entire section data of the target pe (everything from the first section's PointerToRawData to the end of the file).
community edition: xorshift64+
a custom stream cipher built on xorshift64+. for each byte index i, a unique subkey is derived:
uint64_t subkey = key ^ (i * 0x9E3779B97F4A7C15ULL);
subkey = (subkey ^ (subkey >> 30)) * 0xBF58476D1CE4E5B9ULL;
subkey = (subkey ^ (subkey >> 27)) * 0x94D049BB133111EBULL;
subkey = subkey ^ (subkey >> 31);
three bit-shifted copies of the subkey are xor'd to form a mask. additional diffusion comes from add/sub with key-derived constants key ^ 0xAA. the key is a single 64-bit integer.
key generation (ce)
bcryptgenrandom(32 bytes) → sha-256 → first 8 bytes as uint64_t. if bcrypt fails: queryperformancecounter ^ gettickcount64 ^ &stackvar.
pro: speck 128/128 ctr
speck is a lightweight block cipher designed by the nsa. the pro edition uses speck 128/128 (128-bit block, 128-bit key) in counter (ctr) mode with 32 rounds.
round function
x = ror64(x, 8) + y
x ^= roundkey[i]
y = rol64(y, 3) ^ x
key schedule
roundKeys[0] = key[0];
uint64_t b = key[1];
for (int i = 0; i < SPECK_ROUNDS - 1; i++) {
b = ror64(b, 8) + roundKeys[i];
b ^= (uint64_t)i;
roundKeys[i+1] = rol64(roundKeys[i], 3) ^ b;
}
ctr mode
each 16-byte keystream block is produced by encrypting { iv, counter++ }. the keystream is xor'd with the plaintext/ciphertext. encryption and decryption are identical operations.
key generation (pro)
bcryptgenrandom(48 bytes) → sha-256(entropy[:32]) → first 16 bytes as uint64_t key[2]. sha-256(entropy[32:48]) → first 8 bytes as uint64_t iv. if bcrypt fails: perf ^ tick ^ &stack with fixed xor constants.
comparison
| property | xorshift64+ (ce) | speck 128/128 ctr (pro) |
|---|---|---|
| block size | 1 byte (stream) | 16 bytes |
| key size | 64 bit | 128 bit key + 64 bit iv + 64-bit counter |
| rounds | n/a (xorshift64+) | 32 |
| mode | custom stream | ctr (counter) |
| integrity | none | speck-cbc-mac |
| key source | sha-256(bcrypt entropy) | sha-256(bcrypt entropy) x2 |
| strength | obfuscation (not cryptographic) | cryptographic (symmetric cipher) |
integrity checks pro
the pro edition includes a speck-cbc-mac integrity system that verifies the stub code has not been modified or patched. this prevents attackers from modifying the stub to bypass anti-debug checks or dump the decrypted payload.
integrity key derivation
derive_integrity_key() produces a mac-specific key which is different per packed binary and tied to unique exe parameters.
speck-cbc-mac computation (pack time)
after writing the stub and config, the crypter computes a cbc-mac over the stub code using the derived key:
uint64_t chain[2] = {0, 0};
uint64_t block[2];
size_t full_blocks = size / 16;
for (size_t i = 0; i < full_blocks; i++) {
memcpy(block, data + (i * 16), 16);
block[0] ^= chain[0];
block[1] ^= chain[1];
speck_encrypt_block(&block[0], &block[1], roundKeys);
chain[0] = block[0];
chain[1] = block[1];
}
out_tag[0] = chain[0];
out_tag[1] = chain[1];
the result is stored in config->stub_mac[2]. partial blocks are zero-padded to 16 bytes.
triple verification (run time)
the pro stub verifies the mac at three points during execution:
| checkpoint | location | failure behavior |
|---|---|---|
| 1. before decryption | stub_main() entry | zero encryption key, set ultra=1 (anti-debug will further corrupt) |
| 2. after section write | after memcpy to target | wipe written sections with overwrite(), set ultra=1 |
| 3. before oep jump | before original_entry() | corrupt base: target_base ^= key[0] ^ key[1] |
each check recomputes the cbc-mac and compares against the stored value. any mismatch indicates the stub code was modified in memory (e.g. by a debugger breakpoint, int3 patch, or code hook).
why triple?
an attacker could modify the stub after the first check but before decryption completes. the second check catches modifications made between decryption and section write-back. the third check catches any last-minute patches before control transfers to the original executable.
compression pro
the pro edition supports optional payload compression using the aplib compression library. when enabled via --compress, the packed output is significantly smaller than the original because only the compressed (and encrypted) payload is stored inside the .nexus section.
pack time
compression happens after directory zeroing and before encryption:
- load
aplib.dllfrom pe resource 101. the dll is written to a temp file with a randomized name (%temp%\XXXXXXXX.dll), loaded withLoadLibraryA, then the file is deleted from disk - resolve
aP_pack,aP_max_packed_size,aP_workmem_sizefrom the dll - allocate compression buffer (
aP_max_packed_size) and work memory (aP_workmem_size) - call
aP_pack(source, dest, length, workmem, null, null)to compress - truncate the pe to headers only — all section
PointerToRawDataandSizeOfRawDataare set to 0 - the compressed buffer is encrypted with speck ctr, then stored inside
.nexusafter the stub code
run time
the pro stub contains a built-in aplib decompressor (decompress()) — no external dll is needed at runtime. after decryption:
- allocate a decompression buffer of
config->original_payload_sizebytes - if ultra mode is active, perform anti-debug checks before decompression. on failure, set buffer pages to
PAGE_NOACCESS - call
decompress(decrypted, compressed_size, buffer, original_payload_size) - wipe and free the decryption buffer
- write decompressed sections from buffer to target base, same as uncompressed mode
config fields
| field | meaning |
|---|---|
compressed | 1 = payload is compressed, 0 = raw sections |
compressed_size | size of compressed + encrypted data in bytes |
original_payload_size | size of the original uncompressed payload |
encrypted_start_rva | rva of encrypted data within .nexus (compressed) or first section (uncompressed) |
aplib decompressor
the built-in decompressor in the pro stub implements the full aplib safe decompression algorithm. it reads the compressed stream bit-by-bit using a gamma code decoder, supporting literal byte output, match copying with various offset/length encodings, and a special end-of-stream marker. the implementation uses __attribute__((annotate("nofla"))) __attribute__((annotate("nosub"))) to prevent the obfuscation compiler passes from transforming these critical functions.
anti-analysis pro
the pro edition includes multi-layered anti-debug and anti-sandbox protections, activated by the --ultra flag. these protections use direct syscall invocation to bypass user-mode api hooks placed by debuggers and security tools.
direct syscall technique
rather than calling ntdll functions through the iat (which can be hooked), the stub reads the syscall number directly from the function prologue in ntdll's memory. modern windows ntdll stubs follow the pattern:
mov r10, rcx // 4C 8B D1
mov eax, <number> // B8 xx xx xx xx
get_syscall_num() reads bytes 3-4 to extract the syscall number, then issues the syscall directly via inline assembly. this bypasses any iat or inline hooks on NtQueryInformationProcess, NtQuerySystemInformation, etc.
anti-debug checks
| check | method | failure behavior |
|---|---|---|
| peb.beingdebugged | *(byte*)(peb + 2) | fastfail (int 0x29) |
| ntglobalflag | *(dword*)(peb + 0x68) & 0x70 | fastfail |
| process debug port | syscall(NtQueryInformationProcess, ProcessDebugPort=7) | zero encryption key |
| kernel debugger | syscall(NtQuerySystemInformation, SystemKernelDebuggerInformation=0x23) | corrupt key with 0x123456789ABCDEF0 |
| post-decrypt integrity | speck-cbc-mac after section write | wipe sections, infinite loop on failure |
| pre-oep integrity | speck-cbc-mac before oep jump | corrupt base pointer |
anti-sandbox checks
anti_sandbox() runs early in stub_main(), before any decryption:
| check | threshold | rationale |
|---|---|---|
| processor count | < 2 | sandboxes typically assign 1 cpu |
| physical memory | < 3 gb | sandboxes have limited ram |
| computer name | blacklist of 13 crc32 hashes | known sandbox/vm hostnames |
sandbox detection causes a 300-second (300000 ms) sleep followed by fastfail. the long sleep wastes automated analysis time.
hide from debugger
hide_from_debugger() calls NtSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger=0x11, NULL, 0). this prevents the debugger from receiving debug events for this thread, making single-stepping impossible.
console ctrl handler
SetConsoleCtrlHandler(ctrl_handler, TRUE) is registered to catch ctrl-c and ctrl-break. the handler calls fastfail, preventing the user from interrupting execution and examining memory.
mitigation policies
set_mitigation_policies() applies windows process mitigation policies that make runtime modification harder:
- policy 2 — prevent arbitrary code from being marked executable (acg)
- policy 8 — prevent dynamic code generation
- policy 10 — restrict child process creation
these are only applied when ultra != 0 and compatibility == 0 (the --fix flag disables mitigation policies because some applications need dynamic code generation).
stub self-wipe
a dedicated wipe_stub() thread is spawned to erase the stub code from memory after execution begins. it calculates the current instruction pointer via lea rax, [rip], sleeps 500ms, then wipes all stub pages except the one containing the current instruction. this prevents dumping the stub from a suspended process snapshot.
stub config
the stub_config structure is written by the crypter into the .nexus section immediately after the stub code. it contains all the information the stub needs to decrypt and restore the original pe. the struct is packed (#pragma pack(push, 1)).
community edition
typedef struct {
uint64_t key; // xorshift64+ encryption key
uint32_t original_oep; // original addressofentrypoint
uint32_t encrypted_start_rva; // start of encrypted region (file offset)
uint32_t encrypted_size; // total encrypted bytes
uint64_t image_base; // original imagebase
uint32_t sections_rva; // rva of first section
uint32_t stub_code_size; // size of stub binary in bytes
uint32_t import_rva; // original import directory rva
uint32_t import_size; // original import directory size
uint32_t resource_rva; // original resource directory rva
uint32_t resource_size; // original resource directory size
uint32_t tls_rva; // original tls directory rva
uint32_t tls_size; // original tls directory size
uint32_t exception_rva; // original exception directory rva
uint32_t exception_size; // original exception directory size
uint32_t reloc_rva; // original reloc directory rva
uint32_t reloc_size; // original reloc directory size
uint8_t section_count; // number of sections in original pe
} stub_config; // followed by: uint32_t marker + section_info[]
pro edition
the pro edition uses a similar struct to community edition with many extra fields.
section info
typedef struct {
uint32_t virtual_address; // section rva
uint32_t raw_offset; // offset from encryption start
uint32_t raw_size; // section data size in bytes
} section_info;
the section info array follows the config (after the 4-byte marker). it maps each section's virtual address to its position within the encrypted payload, allowing the stub to copy sections individually to their correct locations.
marker
a 4-byte random value sits between stub_config and section_info[]. the same value replaces the static 0xDEADBEEF marker in the stub binary. the stub scans for the second occurrence of this marker to locate the config.
layout in uncompressed .nexus section
[ header ] - [ encrypted payload ] - [ stub code ] - [ config ] - [ config marker ] - [ section_info ]
import resolution
obsidian stubs have zero imports in their pe headers. all win32 functions are resolved at runtime by walking the peb (process environment block) and matching export names by crc32 hash. this makes the stub fully position-independent and resistant to iat hooking.
peb walk
the peb is accessed via architecture-specific methods:
- x64 —
__readgsqword(0x60)reads the peb pointer from the teb (thread environment block) at gs:0x60 - arm64 —
x18register points to the teb.*(void**)(teb + 0x60)gives the peb
from the peb, the loader follows: peb → Ldr (offset 0x18) → InMemoryOrderModuleList (offset 0x20). it walks the linked list up to 10 entries, reading each module's base address and unicode name.
crc32 hashing
crc32_hash_str() computes a case-insensitive crc32 hash using polynomial 0x82F63B78 (castagnoli). uppercase conversion is applied before hashing (if (c >= 'a' && c <= 'z') c -= 32). dll names are matched by hash, then export function names are matched by hash.
module hashes
| dll | crc32 hash | resolved by |
|---|---|---|
kernel32.dll | 0xB37B13DA | ce + pro |
ntdll.dll | 0xD39C6577 | pro only |
kernelbase.dll | 0x5FFDA9B8 | pro only |
function hashes (ce)
the ce stub resolves 7 functions from kernel32.dll:
| function | hash | purpose |
|---|---|---|
VirtualAlloc | 0xDAEC3C14 | allocate decryption buffer |
VirtualProtect | 0x796AECA9 | change section permissions |
VirtualFree | 0x26F919CC | release decryption buffer |
OutputDebugStringA | 0x35228EDA | debug output |
GetModuleHandleA | 0x41D65003 | check if dll already loaded |
GetProcAddress | 0x794CF7AA | resolve payload imports |
LoadLibraryA | 0x839EC179 | load dlls for payload imports |
function hashes (pro)
the pro stub resolves additional functions from ntdll.dll and kernelbase.dll for anti-debug and mitigation policy support:
| function | hash | dll | purpose |
|---|---|---|---|
CreateThread | 0x04536658 | kernel32 | spawn stub wipe thread |
Sleep | 0x9C12938F | kernel32 | delay in wipe/sandbox paths |
NtQuerySystemInformation | 0x5FEC0C21 | ntdll | kernel debugger detection |
NtSetInformationThread | 0x4AD12562 | ntdll | hide from debugger |
NtQueryInformationProcess | 0xA6E4CCF2 | ntdll | user-mode debugger detection |
SetProcessMitigationPolicy | 0x5FEC0C21 | kernelbase | enable acg, dep, payload restrictions |
payload import resolution
after restoring the import directory rva, resolve_payload_imports() walks the original pe's IMAGE_IMPORT_DESCRIPTOR chain. for each dll it attempts GetModuleHandleA first (already loaded), then LoadLibraryA. each import thunk is resolved via GetProcAddress by name or ordinal, and the result is written into the iat.
pe manipulation
the crypter performs several transformations on the pe structure to redirect execution to the stub while preserving all information needed for the stub to restore the original binary at runtime.
directories zeroed
the following data directory entries are zeroed (virtualaddress and size set to 0) to prevent analysis tools and the pe loader from processing them before the stub runs:
| directory | reason |
|---|---|
IMAGE_DIRECTORY_ENTRY_IMPORT | original iat is invalid (encrypted). stub resolves imports manually after decryption |
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT | obsolete, removes bound import references |
IMAGE_DIRECTORY_ENTRY_IAT | iat is encrypted, prevents pe loader from processing it |
IMAGE_DIRECTORY_ENTRY_TLS | tls directory is encrypted. stub restores and invokes tls callbacks manually |
IMAGE_DIRECTORY_ENTRY_BASERELOC | relocs are encrypted. stub restores and applies them manually |
IMAGE_DIRECTORY_ENTRY_EXCEPTION | exception data is encrypted. stub restores after decryption |
IMAGE_DIRECTORY_ENTRY_RESOURCE | resource data is encrypted. stub restores after decryption |
IMAGE_DIRECTORY_ENTRY_DEBUG | removes debug information from the packed pe |
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG | removes load config (contains security cookie, cfg tables) |
IMAGE_DIRECTORY_ENTRY_SECURITY | removes authenticode signature (would be invalid anyway) |
the original rvas and sizes are saved in stub_config and restored by the stub before jumping to oep. the coff symbol table pointers (PointerToSymbolTable, NumberOfSymbols) are also zeroed.
section addition
add_section() appends a new section named .nexus to the pe:
- if the section table has no room (section table end + new header would overlap first section data), all section data is shifted by
FileAlignmentbytes to make space - the new section's
VirtualAddressis aligned toSectionAlignment, starting after the last section SizeOfRawDatais aligned toFileAlignment- characteristics:
IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE NumberOfSectionsandSizeOfImageare updated
dll characteristics
after adding the stub section, the following flags are set in DllCharacteristics:
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE— enable aslrIMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA— allow high-entropy 64-bit addressesIMAGE_DLLCHARACTERISTICS_NX_COMPAT— dep compatible
entry point redirection
the original AddressOfEntryPoint is saved in stub_config.original_oep. the new entry point is set to stub_section_rva + entry_signature_offset + signature_length.
the crypter scans the stub binary for these signature bytes. the new entry point starts immediately after the signature.
checksum recalculation
the pe checksum is recalculated using the standard algorithm: sum all 16-bit words with carry-fold, then add the file size. the checksum is written to OptionalHeader.CheckSum. this ensures the packed pe passes basic integrity checks.
optional header cleanup
SizeOfHeaders is updated if sections were shifted. all unused optional header fields remain untouched. the DllCharacteristics flags are updated (not replaced) to preserve any existing flags.