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:
- Compute
delta = actual_base - PE.OptionalHeader.ImageBase. - If
delta == 0, skip (already at preferred address). - Walk
IMAGE_BASE_RELOCATIONblocks; for eachIMAGE_REL_BASED_HIGHLOW(x86) orIMAGE_REL_BASED_DIR64(x64) entry, adddeltato 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:
- Walk PEB
InMemoryOrderModuleList[1]to get ntdll's base without any imports. - Scan ntdll's export table for
RtlAddFunctionTable. - Call
RtlAddFunctionTable(DataDir[3].VA + base, count, base)whereDataDir[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:
- Walk PEB
InMemoryOrderModuleList[1]to get ntdll's base. - Scan ntdll's export table for
RtlAddVectoredExceptionHandlerdirectly (kernel32'sAddVectoredExceptionHandleris a forwarded export; resolving it via a raw export-table walk returns the forwarder string, not a callable VA). - 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 (bypassingRtlIsValidHandler). - When
__CxxFrameHandler3finds a matching catch block it internally callsRtlUnwindand longjmps to the catch body — it never returns to the VEH.RtlUnwinddoes not invokeRtlIsValidHandlerduring the unwind phase, so unwind handlers inVirtualAllocmemory work correctly. - If the chain is exhausted without a match, returns
EXCEPTION_CONTINUE_SEARCHso 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:
- Calls
RtlPcToFileHeader(throw_site, &ImageBase)to get the module base. - Stores
ThrowInfo - ImageBase(a 32-bit RVA) inExceptionInformation[2]. - Stores
ImageBaseinExceptionInformation[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 writesbaseinto the out-parameter and returnsbase. - 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:
- Read
DataDir[9].VirtualAddress(TLS directory RVA). - Read
IMAGE_TLS_DIRECTORY.AddressOfCallBacks(a VA pointing to a null-terminated array of callback VAs). - 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:
- Calls
dump_memory_layoutto produce a shellcode binary. - Executes it via
tests/Win_x86/test_loader.exeortests/Win_x64/test_loader.exe. - 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05003a30da632f4939daf319dca63431250f671ddffbd133a46e4675f1704b53
|
|
| MD5 |
1783d22530ef4b04b61f3180f92c1959
|
|
| BLAKE2b-256 |
b22d0652e7aa822960655b5e6a9ee2aef18d92ad73df981f314ee4f9ce982d51
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
27c168ca585fbfed34f92c5b1434da5b6d7dd1b81991058e1b070ca39876522d
|
|
| MD5 |
39f43a99e87dd46ddbe1c07752708072
|
|
| BLAKE2b-256 |
75785cd3ada3150429c6415ad3af90d46f444f9736e14f119120d8a00306040c
|