Skip to content
λmaldev wiki/
pagesPosition-Independent Code (Shellcode)
T1027.009WindowsLinuxASMC / C++x64

Position-Independent Code (Shellcode)

updated 2026-08-0410 min readthehackersbrain
Authorized use only. This material is published for detection engineering, malware analysis and authorized red-team engagements. Running these techniques against systems you do not own or have written permission to test is illegal.

Overview

Position-independent code is code that contains no absolute addresses — every reference to data or code is expressed as an offset relative to the instruction pointer. Any code that will be injected into another process, loaded into an arbitrary allocation, or transmitted as a blob must be PIC, because it will not load at the address its compiler assumed.

The x64 architecture makes PIC natural: LEA rax, [rip+offset] accesses data relative to the current instruction pointer. x86 requires an explicit trick (call + pop) to find the current address. The harder challenge is not addressing — it is resolving the Windows API without importing anything.

Note.

The Windows loader normally resolves imports by reading the PE’s import directory. Shellcode has no import directory. It must locate needed functions by walking the in-memory loader structures (PEB → loader list → module bases → export tables) using only what the CPU and memory give it.

The call chain

  1. 1
    load effective address of .data
    Use RIP-relative addressing (x64) or a call/pop prologue (x86) to find the shellcode's own base address.
  2. 2
    resolve kernel32 / ntdll from PEB
    Walk the PEB loader list to find loaded module base addresses without calling GetModuleHandle.
  3. 3
    resolve needed exports
    Parse the export directory of each needed module manually — no GetProcAddress dependency.
  4. 4
    execute payload logic
    All API calls go through the resolved function pointers, all strings are accessed relative to RIP.

PEB walking — the core primitive

peb_walk.asmASM
; x64 — find kernel32.dll base from the PEB without any imports
;
; GS:[0x60] = TEB.Peb
; PEB + 0x18 = Ldr (PEB_LDR_DATA*)
; Ldr + 0x20 = InMemoryOrderModuleList (FLINK points to first entry)
; Each LIST_ENTRY (LDR_DATA_TABLE_ENTRY.InMemoryOrderLinks)
;   + 0x20 = DllBase
;   + 0x50 = BaseDllName (UNICODE_STRING)

find_kernel32:
  mov   rbx, gs:[0x60]           ; PEB
  mov   rbx, [rbx + 0x18]       ; PEB.Ldr
  mov   rbx, [rbx + 0x20]       ; Ldr.InMemoryOrderModuleList.Flink
  ; The list is: [ntdll] -> [kernel32] -> ...
  ; skip the first entry (ntdll), take the second
  mov   rbx, [rbx]              ; skip ntdll entry
  mov   rbx, [rbx + 0x20]       ; DllBase of next module (kernel32)
  ; rbx now holds kernel32.dll base address

Export table parser

export_walk.cC
// Walk the PE export table to find a named export
// Compiled with -fpic -fno-stack-protector, all strings in read order
typedef unsigned char  u8;
typedef unsigned short u16;
typedef unsigned int   u32;

static void* find_export(void *base, const char *name) {
  u8 *b = (u8*)base;
  // DOS header -> NT headers
  u32 *nt  = (u32*)(b + *(u32*)(b + 0x3c));
  // Optional header data directory
  u32 edt_rva = *(u32*)((u8*)nt + 0x88 + 0*8);  // [0] = export dir
  u8  *edt    = b + edt_rva;

  u32 num_names = *(u32*)(edt + 0x18);
  u32 *names    = (u32*)(b + *(u32*)(edt + 0x20));
  u16 *ordinals = (u16*)(b + *(u32*)(edt + 0x24));
  u32 *funcs    = (u32*)(b + *(u32*)(edt + 0x1c));

  for (u32 i = 0; i < num_names; i++) {
      const char *n = (const char*)(b + names[i]);
      // simple strcmp without libc
      const char *a = name, *c = n;
      while (*a && *a == *c) { a++; c++; }
      if (*a == 0 && *c == 0)
          return b + funcs[ordinals[i]];
  }
  return 0;
}

Writing PIC in C

Clang and GCC can compile C to position-independent shellcode with the right flags:

build.shshell
# Compile PIC shellcode from C (Linux cross-compile for Windows)
$ x86_64-w64-mingw32-gcc -O2   -fpic   -fno-stack-protector   -fno-exceptions   -nostdlib   -masm=intel   -Wl,--entry=shellcode_main   -Wl,--gc-sections   -o shellcode.exe shellcode.c

# Extract the .text section as a raw binary
$ objcopy -O binary --only-section=.text shellcode.exe shellcode.bin

# Check for absolute addresses (should be empty)
$ objdump -d shellcode.bin | grep -E "0x[0-9a-f]{8,}"
(no output = PIC is clean)

Common mistakes

Mistake Symptom Fix
Global / static variables Crash at non-base-address Move to stack; access via [rip+offset]
String literals in .rdata Access violation Embed strings as stack arrays or char-by-char
C runtime call Missing import crash Replace with PIC-safe equivalents or inline
call dword ptr [import] IAT not set up Resolve via export walk; no IAT in shellcode
Stack alignment (x64) Crash in SIMD intrinsics Ensure RSP is 16-byte aligned before any call

Tooling that generates PIC

Tool Language Notes
msfvenom Assembly Metasploit payloads are PIC by design
donut C# / VBScript / PE → PIC Converts any payload to shellcode
sRDI C Converts a reflective DLL to shellcode
BofNet / BOF C Beacon Object Files — lightweight PIC modules

Detection

MEMORY SCAN
Executable non-image memory (MEM_PRIVATE + PAGE_EXECUTE_*) containing a structured byte sequence without PE headers.
YARA
PEB walker patterns — the TEB.PEB chain traversal byte sequence is recognisable across tools.
ETW-TI
Thread start in private RX memory — same signal as any other shellcode execution technique.
STACK WALK
Return addresses resolve to anonymous private memory rather than a module's .text section.

PEB walker patterns are the most consistent YARA target across all shellcode tooling: the GS:[0x60] read followed by [rbx+0x18][rbx+0x20] chain appears almost verbatim in Cobalt Strike, Meterpreter, custom loaders, and every manually-written payload. The sequence is short enough (12–20 bytes) to match reliably in memory without false positives.

Was this page useful?edit this page ↗