obsidian community edition & pro

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

community edition
xorshift64+ obfuscation. amd64 + arm64 stubs. pe section preservation. peb-based imports. open source (acsl v1.4)
pro
speck 128/128 ctr encryption. aplib compression. speck-cbc-mac integrity. multi-layered anti-debug/anti-sandbox via direct syscalls. stub self-wipe. licensed (eula)

source files

fileeditionpurpose
obsidian.ccepe packing tool. reads input pe, encrypts sections with xorshift64+, appends stub + config as .nexus section
stub.ccex64 runtime stub. peb import resolution, xorshift64+ decryption, relocation, import restoration, oep jump
stub-arm64.ccearm64 runtime stub. same logic as x64 stub with arm64-specific teb access, relocation types, and entry signature
obsidian.cprope packing tool. speck 128/128 ctr encryption, optional aplib compression, speck-cbc-mac integrity, anti-debug stub support
stub.cprox64 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

featurecommunity editionpro
encryptionxorshift64+ (xorshift64+ subkeys)speck 128/128 ctr mode
key size64-bit128-bit key + 64-bit iv + 64-bit counter
compressionnoneaplib (optional)
integritynonespeck-cbc-mac (triple verification)
anti-debugnonemulti-layered via direct syscalls (--ultra)
anti-sandboxnonecpu/ram/hostname checks
stub wipesecurewipe config onlybackground thread wipes entire stub
arm64separate stub-arm64.cnot yet (todo)
mitigation policiesnoneacg, dep, payload restriction
peb modules resolvedkernel32.dll onlykernel32.dll + ntdll.dll + kernelbase.dll

pack-time flow

load input pe and validate dos/nt headers
save directory rvas (import, reloc, tls, resource, exception)
zero out pe data directories (import, iat, reloc, debug, tls, etc.)
encrypt all section data from first section to end of file
generate encryption key (bcrypt + sha-256)
add .nexus section containing: stub code + encrypted payload (pro) + stub_config + section_infos
randomize config marker (replace 0xdeadbeef in stub binary)
locate entry signature in stub, set new addressofentrypoint
recalculate pe checksum, write output file

run-time flow

pe entry point → _start (stack alignment, call position_independent_entry)
scan forward from rip for second marker value occurrence
locate stub_config at (marker - sizeof(stub_config))
resolve imports via peb walk (kernel32.dll by crc32 hash)
allocate decryption buffer, copy encrypted sections, decrypt in buffer
write decrypted sections back over original section locations
restore pe directories (import, reloc, tls, resource, exception)
apply relocations (standard + manual scan)
resolve payload imports via iat, restore section permissions
invoke tls callbacks, wipe config, jump to original oep

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:

  1. collect pe info — save original oep, imagebase, and all data directory rvas (import, tls, resource, exception, reloc)
  2. zero directories — clear import, bound import, iat, tls, reloc, exception, resource, debug, load config, security, and coff symbol table entries
  3. determine encryption region — encrypt from sizeofheaders to end of file. save section info (virtual_address, raw_offset, raw_size) for each section
  4. generate keybcryptgenrandom(32 bytes)sha-256 → first 8 bytes as uint64_t key. fallback: queryperformancecounter ^ gettickcount64 ^ &key
  5. obfuscate payload — apply obfuscate_data() (xorshift64+) to all section bytes
  6. add stub section — append .nexus section containing: stub.bin + stub_config + 4-byte marker + section_info[]
  7. write config — populate stub_config with key, oep, encrypted_size, image_base, all saved directory rvas, section count
  8. locate entry signature — scan stub binary for entry signature. set new entry point to stub_rva + signature_offset + sig_length
  9. 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

  1. windows starts the packed executable at obsidian's .nexus stub entry point
  2. the obsidian stub decrypts the original PyInstaller executable sections in memory
  3. the stub restores imports, relocations, resources, exceptions, tls metadata, and section permissions
  4. the stub jumps to the original PyInstaller bootloader entry point
  5. the patched PyInstaller bootloader opens its own executable and looks for the WWNPYI1! footer at EOF
  6. 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
  7. 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.

markerused bypurpose
DE AD BE EFobsidian stublocates stub_config in the .nexus section
WWNPYI1!patched PyInstaller bootloaderlocates 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:

on windows:

  1. install the official MSYS2 UCRT64 package from https://www.msys2.org/
  2. open the terminal, then run pacman -Syu && pacman -S --needed mingw-w64-ucrt-x86_64-gcc
  3. use the same MSYS2 terminal to open cmd.exe, then navigate to the cloned obsidian-pyinstaller folder
  4. 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:

functioncrc32 hash
VirtualAlloc0xDAEC3C14
VirtualProtect0x796AECA9
VirtualFree0x26F919CC
OutputDebugStringA0x35228EDA
GetModuleHandleA0x41D65003
GetProcAddress0x794CF7AA
LoadLibraryA0x839EC179

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

  1. resolve imports via peb
  2. allocate decryption buffer with VirtualAlloc
  3. get actual base address from peb (__readgsqword(0x60) → peb+0x10)
  4. copy each section from the packed pe into the buffer using section_info offsets
  5. decrypt the buffer in-place with xorshift64_plus()
  6. write decrypted sections back to the original section locations in memory
  7. SecureWipe and VirtualFree the decryption buffer
  8. zero out bss sections (sections with SizeOfRawData == 0 and VirtualSize > 0)
  9. restore pe directories via restore_directories_and_relocate()
  10. resolve payload imports via resolve_payload_imports()
  11. restore section permissions via apply_section_permissions()
  12. invoke tls callbacks if present
  13. wipe the config zone
  14. 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

aspectx64arm64
peb access__readgsqword(0x60)x18 register (teb) → teb + 0x60
actual base__readgsqword(0x60) → peb+0x10x18 → teb+0x60 → peb+0x10
relocation typesIMAGE_REL_BASED_DIR64 onlyDIR64 + PAGEBASE_REL21 + PAGEOFFSET_12A + PAGEOFFSET_12L
stack alignmentand rsp, ~0xFand 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 += delta
  • IMAGE_REL_BASED_ARM64_PAGEBASE_REL21 (type 3) — encodes the page address into an adrp-style instruction. extracts page offset bits and patches the instruction's immediate fields
  • IMAGE_REL_BASED_ARM64_PAGEOFFSET_12A (type 4) — encodes a 12-bit page offset into an add instruction's immediate field
  • IMAGE_REL_BASED_ARM64_PAGEOFFSET_12L (type 5) — encodes a scaled 12-bit offset into a ldr/str instruction. 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>
flagdescription
--debugverbose debug output with hexdumps, verification, and step-by-step logging
--ultraenable anti-debugging protections in the stub (peb checks, syscall-level detection, stub integrity enforcement)
--compresscompress the payload with aplib before encryption. the stub decompresses at runtime
--fixcompatibility mode. allows dynamic loading of non-microsoft dlls via LoadLibraryA in the stub
--pinkshow 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:

  1. collect pe info — same as ce, plus CompressedSize and OriginalPayloadSize fields
  2. zero directories — same as ce
  3. compress (if --compress) — compress section data with aP_pack() from aplib.dll (loaded from resource). truncate pe to headers only, zero all section PointerToRawData and SizeOfRawData
  4. determine encryption region — if compressed: encrypt the compressed buffer. if not: encrypt section data in-place (same as ce)
  5. generate key + ivbcryptgenrandom(48 bytes)sha-256(entropy[:32]) for 128-bit key, sha-256(entropy[32:48]) for 64-bit iv
  6. encrypt payloadencrypt_data() with speck 128/128 ctr mode
  7. compute encrypted crc — crc32 of the encrypted payload for config integrity
  8. add .nexus section — contains: stub code + [encrypted payload if compressed] + stub_config + marker + section_infos
  9. write config — extended stub_config with speck key/iv, ultra/compressed/compatibility flags, stub_rva, stub_mac, encrypted_crc
  10. compute speck-cbc-mac — derive integrity key, compute cbc-mac over stub code, store in config->stub_mac
  11. locate entry signature — scan for "obsidian" ascii bytes (0x6F 0x62 0x73 0x69 0x64 0x69 0x61 0x6E). set new ep
  12. 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:

  1. 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)
  2. after section write-back — recomputes mac. if tampered, wipes the written sections and sets ultra=1
  3. 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 debuggeranti_anti_anti_debug() issues NtQueryInformationProcess(ProcessDebugPort) via direct syscall (bypassing hooks). reads the syscall number from ntdll's stub pattern mov r10, rcx; mov eax, <number>
  • kernel debuggercheck_kernel_debugger() issues NtQuerySystemInformation(SystemKernelDebuggerInformation) via direct syscall. checks KernelDebuggerEnabled && !KernelDebuggerNotPresent
  • hide from debuggerNtSetInformationThread(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 plus CreateThread, 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

propertyxorshift64+ (ce)speck 128/128 ctr (pro)
block size1 byte (stream)16 bytes
key size64 bit128 bit key + 64 bit iv + 64-bit counter
roundsn/a (xorshift64+)32
modecustom streamctr (counter)
integritynonespeck-cbc-mac
key sourcesha-256(bcrypt entropy)sha-256(bcrypt entropy) x2
strengthobfuscation (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:

checkpointlocationfailure behavior
1. before decryptionstub_main() entryzero encryption key, set ultra=1 (anti-debug will further corrupt)
2. after section writeafter memcpy to targetwipe written sections with overwrite(), set ultra=1
3. before oep jumpbefore 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:

  1. load aplib.dll from pe resource 101. the dll is written to a temp file with a randomized name (%temp%\XXXXXXXX.dll), loaded with LoadLibraryA, then the file is deleted from disk
  2. resolve aP_pack, aP_max_packed_size, aP_workmem_size from the dll
  3. allocate compression buffer (aP_max_packed_size) and work memory (aP_workmem_size)
  4. call aP_pack(source, dest, length, workmem, null, null) to compress
  5. truncate the pe to headers only — all section PointerToRawData and SizeOfRawData are set to 0
  6. the compressed buffer is encrypted with speck ctr, then stored inside .nexus after the stub code

run time

the pro stub contains a built-in aplib decompressor (decompress()) — no external dll is needed at runtime. after decryption:

  1. allocate a decompression buffer of config->original_payload_size bytes
  2. if ultra mode is active, perform anti-debug checks before decompression. on failure, set buffer pages to PAGE_NOACCESS
  3. call decompress(decrypted, compressed_size, buffer, original_payload_size)
  4. wipe and free the decryption buffer
  5. write decompressed sections from buffer to target base, same as uncompressed mode

config fields

fieldmeaning
compressed1 = payload is compressed, 0 = raw sections
compressed_sizesize of compressed + encrypted data in bytes
original_payload_sizesize of the original uncompressed payload
encrypted_start_rvarva 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

checkmethodfailure behavior
peb.beingdebugged*(byte*)(peb + 2)fastfail (int 0x29)
ntglobalflag*(dword*)(peb + 0x68) & 0x70fastfail
process debug portsyscall(NtQueryInformationProcess, ProcessDebugPort=7)zero encryption key
kernel debuggersyscall(NtQuerySystemInformation, SystemKernelDebuggerInformation=0x23)corrupt key with 0x123456789ABCDEF0
post-decrypt integrityspeck-cbc-mac after section writewipe sections, infinite loop on failure
pre-oep integrityspeck-cbc-mac before oep jumpcorrupt base pointer

anti-sandbox checks

anti_sandbox() runs early in stub_main(), before any decryption:

checkthresholdrationale
processor count< 2sandboxes typically assign 1 cpu
physical memory< 3 gbsandboxes have limited ram
computer nameblacklist of 13 crc32 hashesknown 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
  • arm64x18 register 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

dllcrc32 hashresolved by
kernel32.dll0xB37B13DAce + pro
ntdll.dll0xD39C6577pro only
kernelbase.dll0x5FFDA9B8pro only

function hashes (ce)

the ce stub resolves 7 functions from kernel32.dll:

functionhashpurpose
VirtualAlloc0xDAEC3C14allocate decryption buffer
VirtualProtect0x796AECA9change section permissions
VirtualFree0x26F919CCrelease decryption buffer
OutputDebugStringA0x35228EDAdebug output
GetModuleHandleA0x41D65003check if dll already loaded
GetProcAddress0x794CF7AAresolve payload imports
LoadLibraryA0x839EC179load 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:

functionhashdllpurpose
CreateThread0x04536658kernel32spawn stub wipe thread
Sleep0x9C12938Fkernel32delay in wipe/sandbox paths
NtQuerySystemInformation0x5FEC0C21ntdllkernel debugger detection
NtSetInformationThread0x4AD12562ntdllhide from debugger
NtQueryInformationProcess0xA6E4CCF2ntdlluser-mode debugger detection
SetProcessMitigationPolicy0x5FEC0C21kernelbaseenable 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:

directoryreason
IMAGE_DIRECTORY_ENTRY_IMPORToriginal iat is invalid (encrypted). stub resolves imports manually after decryption
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORTobsolete, removes bound import references
IMAGE_DIRECTORY_ENTRY_IATiat is encrypted, prevents pe loader from processing it
IMAGE_DIRECTORY_ENTRY_TLStls directory is encrypted. stub restores and invokes tls callbacks manually
IMAGE_DIRECTORY_ENTRY_BASERELOCrelocs are encrypted. stub restores and applies them manually
IMAGE_DIRECTORY_ENTRY_EXCEPTIONexception data is encrypted. stub restores after decryption
IMAGE_DIRECTORY_ENTRY_RESOURCEresource data is encrypted. stub restores after decryption
IMAGE_DIRECTORY_ENTRY_DEBUGremoves debug information from the packed pe
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIGremoves load config (contains security cookie, cfg tables)
IMAGE_DIRECTORY_ENTRY_SECURITYremoves 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 FileAlignment bytes to make space
  • the new section's VirtualAddress is aligned to SectionAlignment, starting after the last section
  • SizeOfRawData is aligned to FileAlignment
  • characteristics: IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE
  • NumberOfSections and SizeOfImage are updated

dll characteristics

after adding the stub section, the following flags are set in DllCharacteristics:

  • IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE — enable aslr
  • IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA — allow high-entropy 64-bit addresses
  • IMAGE_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.