Skip to main content

PortableExecutable shellcodifier

Project description

PEOR — PE-to-Shellcode Converter

PEOR converts a compiled Windows PE file (EXE or DLL, x86 or x64) into a position-independent shellcode that can be loaded into any flat memory buffer and executed without a loader, module list, or image base guarantee.

The output is a self-contained binary: a small assembly stub prepended to a memory-mapped copy of the original PE. No OS loader involvement is needed; the stub performs whatever setup the PE requires (relocations, imports, exception tables, TLS callbacks) before jumping to the entry point.

Disclaimer — PEOR is made for educational purposes and embedded-software development (bare-metal, UEFI pre-OS, custom hypervisors, security research labs, CTF challenges). It must not be used for any unauthorized or illegal activity. Use legally, and only with full authorization on systems you own or have explicit permission to test.


Installation

pip install peor

Or from source:

git clone https://github.com/mon231/peor
cd peor
pip install -e .

Python 3.10+ is required. The Keystone assembler (keystone-engine) is a build-time dependency used by setup.py to assemble the stub components; it does not need to be present at runtime.


Usage

python -m peor -i input.exe -o output.bin
python -m peor -i input.exe -o output.bin -r   # resolve imports
python -m peor -i input.dll -o output.bin -r   # works for DLLs too
Flag Meaning
-i / --input-file Path to the source PE (EXE or DLL, x86 or x64)
-o / --output-file Path to write the shellcode binary
-r / --resolve-imports Prepend the import resolver; required when the PE uses Windows APIs
-m / --ignore-imports Zero the import directory in the output (for importless PEs loaded by a custom environment)

The shellcode is then executed by any loader that allocates executable memory, copies the binary in, and calls it (e.g. VirtualAlloc + memcpy + call). A minimal reference loader is included in tests/test_loader/.


How It Works

Output format

┌──────────────────────────────────────────────────────┐
│  shellcode prefix (assembled stubs, position-independent)  │
│  ┌──────────────────────────────────────────────────┐ │
│  │  [import resolver]   (optional, -r)              │ │
│  │  relocation resolver                             │ │
│  │  [C++ EH IAT fixer]  (x64, if needed)            │ │
│  │  [SEH registrar]     (x86 always, x64 if .pdata) │ │
│  │  [TLS callback invoker] (if TLS directory present)│ │
│  │  entry point dispatcher                          │ │
│  └──────────────────────────────────────────────────┘ │
│  memory-mapped PE image (headers + sections, zero-padded) │
└──────────────────────────────────────────────────────┘

Each stub runs and falls through to the next. EBX/RBX carries the PE base address forward through the chain. After the chain completes, execution jumps to the PE's own entry point.


Shellcode Methods Explained

Base Relocations

Windows PE files are compiled with a preferred ImageBase. When the OS loads a PE at a different address it applies base relocations: the .reloc section lists every absolute pointer in the image that must be adjusted by delta = actual_base - preferred_base.

PEOR's relocation stubs (relocations_resolver32/64.asm) are position-independent and use the CALL/POP trick to discover their own runtime address:

call _base       ; push next-instruction address
_base:
pop rbx          ; RBX = runtime address of _base label

The distance from _base to the PE image header is a compile-time constant (PE_OFFSET_PLACEHOLDER) that setup.py patches at install time. At runtime:

  1. Compute delta = actual_base - PE.OptionalHeader.ImageBase.
  2. If delta == 0, skip (already at preferred address).
  3. Walk IMAGE_BASE_RELOCATION blocks; for each IMAGE_REL_BASED_HIGHLOW (x86) or IMAGE_REL_BASED_DIR64 (x64) entry, add delta to the stored pointer.

PEs compiled with /FIXED have no .reloc section; the stub detects this and falls through immediately.


Usermode Import Resolution

When -r is specified, the import stub (imports_resolver32/64.asm) resolves every entry in IMAGE_IMPORT_DESCRIPTOR before the reloc stub runs.

Step 1 — find kernel32 via the PEB loader list, without any imports of its own:

Architecture PEB register PEB offset Ldr offset Module list
x86 FS:[0x30] +0x0C → Ldr +0x14 → InMemoryOrderModuleList [0]=exe, [1]=ntdll, [2]=kernel32
x64 GS:[0x60] +0x18 → Ldr +0x20 → InMemoryOrderModuleList same order

DllBase sits at +0x10 from an InMemoryOrderLinks node.

Step 2 — locate GetProcAddress by walking kernel32's export table: scan AddressOfNames for the string, resolve via AddressOfNameOrdinals + AddressOfFunctions.

Step 3 — use GetProcAddress to get LoadLibraryA, then walk IMAGE_IMPORT_DESCRIPTOR; for each DLL: call LoadLibraryA, then call GetProcAddress for each thunk.


x64 Exception Tables (SEH Registrar)

x64 Windows uses table-based structured exception handling. There is no stack-linked SEH frame chain; instead, the kernel's unwinder calls RtlLookupFunctionEntry to find a RUNTIME_FUNCTION record (from .pdata) for the faulting RIP. That record points to unwind info and the frame handler.

When a PE runs as shellcode from VirtualAlloc memory it is not registered with the OS module list. RtlLookupFunctionEntry falls back to dynamic function tables registered via RtlAddFunctionTable.

seh_registrar64.asm does exactly this:

  1. Walk PEB InMemoryOrderModuleList[1] to get ntdll's base without any imports.
  2. Scan ntdll's export table for RtlAddFunctionTable.
  3. Call RtlAddFunctionTable(DataDir[3].VA + base, count, base) where DataDir[3] is the exception directory.

This is only inserted when DataDir[3].VirtualAddress != 0.


x86 Exceptions — Bypassing SafeSEH (VEH Approach)

x86 Windows uses a stack-based SEH chain: each function prologue pushes an EXCEPTION_REGISTRATION_RECORD onto the stack and links it into FS:[0]. When an exception is dispatched, the kernel walks this chain calling each handler.

The problem — SafeSEH / RtlIsValidHandler: On Windows Vista+ with DEP, RtlDispatchException calls RtlIsValidHandler before each call. This checks that the handler address belongs to a module known to RtlPcToFileHeader. On Windows 11 WoW64, RtlPcToFileHeader uses NtQueryVirtualMemory (MemoryImageInformation) at the kernel level; VirtualAlloc memory always returns ImageBase=0, so no usermode LDR-list injection can fix this.

The fix — Vectored Exception Handlers: RtlAddVectoredExceptionHandler registers a VEH that runs before RtlDispatchException walks the chain — and VEH handlers are never validated by RtlIsValidHandler.

seh_registrar32.asm:

  1. Walk PEB InMemoryOrderModuleList[1] to get ntdll's base.
  2. Scan ntdll's export table for RtlAddVectoredExceptionHandler directly (kernel32's AddVectoredExceptionHandler is a forwarded export; resolving it via a raw export-table walk returns the forwarder string, not a callable VA).
  3. Register a VEH with First=TRUE.

The VEH handler:

  • Ignores any exception code other than 0xE06D7363 (the MSVC C++ exception magic).
  • Walks the SEH chain from FS:[0], calling each frame's handler directly (bypassing RtlIsValidHandler).
  • When __CxxFrameHandler3 finds a matching catch block it internally calls RtlUnwind and longjmps to the catch body — it never returns to the VEH. RtlUnwind does not invoke RtlIsValidHandler during the unwind phase, so unwind handlers in VirtualAlloc memory work correctly.
  • If the chain is exhausted without a match, returns EXCEPTION_CONTINUE_SEARCH so Windows can handle it as unhandled.

Note: this approach deliberately avoids LDR-list injection, which would cause GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) to return the shellcode base — causing the CRT's _beginthreadex wrapper to call FreeLibraryAndExitThread(shellcode_base, result) and crash.


x64 Typed C++ Exceptions (IAT Hook)

Modern MSVC (exception magic 0x19930522) changed how _CxxThrowException passes the throw object to the frame handler:

  1. Calls RtlPcToFileHeader(throw_site, &ImageBase) to get the module base.
  2. Stores ThrowInfo - ImageBase (a 32-bit RVA) in ExceptionInformation[2].
  3. Stores ImageBase in ExceptionInformation[3].

__CxxFrameHandler3 reconstructs the pointer as ImageBase + RVA.

For shellcode not in the loader list, RtlPcToFileHeader returns NULL. Storing a 64-bit ThrowInfo as a 32-bit offset from NULL truncates the high 32 bits — the reconstructed pointer is garbage and the catch block never fires.

cxx_eh_fixer64.asm patches the PE's own IAT entry for RtlPcToFileHeader to point to a small hook stub assembled inline:

  • If the queried address is within the PE image (base ≤ addr < base + SizeOfImage), the hook writes base into the out-parameter and returns base.
  • Otherwise it tail-calls the real RtlPcToFileHeader.

Two values — SizeOfImage and the IAT RVA — are baked in by peor/__main__.py at conversion time, replacing placeholder constants in the assembled bytes.

x86 does not need this hook. On 32-bit Windows all addresses fit in 32 bits; ThrowInfo - NULL is just ThrowInfo, and NULL + ThrowInfo reconstructs the correct pointer.


TLS Callbacks

A PE can declare Thread-Local Storage callbacks in IMAGE_DIRECTORY_ENTRY_TLS (data directory index 9). The OS normally invokes these before main. When running as shellcode, the OS is not involved, so PEOR must invoke them manually.

tls_callbacks32/64.asm:

  1. Read DataDir[9].VirtualAddress (TLS directory RVA).
  2. Read IMAGE_TLS_DIRECTORY.AddressOfCallBacks (a VA pointing to a null-terminated array of callback VAs).
  3. Call each non-null entry as callback(hModule=base, DLL_PROCESS_ATTACH, NULL).

This stub is only inserted when the TLS directory is present and AddressOfCallBacks is non-zero.


Entry Point Dispatcher

entrypoint_resolver32/64.asm reads AddressOfEntryPoint from the optional header. If IMAGE_FILE_DLL is set in Characteristics, it calls DllMain(base, DLL_PROCESS_ATTACH, NULL) using the correct calling convention (x86 stdcall / x64 Microsoft ABI). For EXEs it jumps directly to the entry point.


Test Suite

All tests live in tests/ and are driven by pytest. Each test:

  1. Calls dump_memory_layout to produce a shellcode binary.
  2. Executes it via tests/Win_x86/test_loader.exe or tests/Win_x64/test_loader.exe.
  3. Asserts on the process exit code.

Build the test PEs first (see Building), then run:

pytest tests/pytest -v
# Test Arch What it verifies Expected exit
01 01_simple_calc x86, x64 Importless EXE; loop 0–99, no relocations needed 4950
02 02_relocs_functions x86, x64 Importless EXE with static globals and .reloc section; tests relocation resolver 90
03 03_winapi_messagebox x86, x64 EXE calling MessageBoxA; tests import resolver and interactive dialog 0 (skipped in CI)
04 04_crt_printf_rand x86, x64 CRT EXE (printf + rand); tests full CRT startup with imports 0 (stdout = Random: <n>)
05 05_dll_entry x86, x64 DLL whose DllMain calls ExitProcess(42); tests DLL entry dispatch 42
06 06_stripped_relocs x86, x64 EXE compiled /FIXED (no .reloc section); resolver must skip relocation 99
07 07_cpp_exceptions x86, x64 __try/__except with RaiseException(77,...); tests SEH registrar 77
08 08_cpp_thread x86, x64 std::thread lambda sets result=42; tests CRT thread machinery 42
09 09_resources x86, x64 EXE reads string resource 100 from its own .rsrc section via __ImageBase; tests resource preservation 42
10 10_tls_callbacks x86, x64 TLS callback sets g_result=88; tests TLS callback invoker runs before main 88
11 11_cpp_exceptions x86, x64 Typed C++ throw/catch; tests that the correct catch branch fires 123
12 12_seh_exceptions x86, x64 Same as 11 but compiled /EHa (SEH-integrated C++ exceptions) 123
certificate_signed_pe x86, x64 PE with a dummy WIN_CERTIFICATE appended (Authenticode structure); verifies peor handles the security directory correctly 90

Test 03 requires an interactive desktop and is automatically skipped when the CI environment variable is set.


Building

Requirements: Visual Studio 2022 with the C++ workload, from a Developer Command Prompt.

rem Release builds (used by pytest)
msbuild tests\tests.sln /p:Configuration=Release /p:Platform=Win32
msbuild tests\tests.sln /p:Configuration=Release /p:Platform=x64

Binaries land in tests/Win_x86/ and tests/Win_x64/. Post-build steps automatically run python -m peor on each output to produce the corresponding .bin shellcode files alongside the PE.


Supported PE Types

Type x86 x64
Windows GUI/console EXE
Windows DLL
EFI application ❌ (planned) ❌ (planned)
Windows kernel driver ❌ (planned) ❌ (planned)

EFI and kernel-mode support are on the roadmap. Attempting to convert them raises ValueError with a descriptive message.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

peor-1.1.1.tar.gz (30.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

peor-1.1.1-py3-none-any.whl (12.5 kB view details)

Uploaded Python 3

File details

Details for the file peor-1.1.1.tar.gz.

File metadata

  • Download URL: peor-1.1.1.tar.gz
  • Upload date:
  • Size: 30.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for peor-1.1.1.tar.gz
Algorithm Hash digest
SHA256 05003a30da632f4939daf319dca63431250f671ddffbd133a46e4675f1704b53
MD5 1783d22530ef4b04b61f3180f92c1959
BLAKE2b-256 b22d0652e7aa822960655b5e6a9ee2aef18d92ad73df981f314ee4f9ce982d51

See more details on using hashes here.

File details

Details for the file peor-1.1.1-py3-none-any.whl.

File metadata

  • Download URL: peor-1.1.1-py3-none-any.whl
  • Upload date:
  • Size: 12.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for peor-1.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 27c168ca585fbfed34f92c5b1434da5b6d7dd1b81991058e1b070ca39876522d
MD5 39f43a99e87dd46ddbe1c07752708072
BLAKE2b-256 75785cd3ada3150429c6415ad3af90d46f444f9736e14f119120d8a00306040c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page