Difficulty: Intermediate

Module 5: Position-Independent Code (PIC)

Code that doesn't know where it lives - and doesn't need to.

Why PIC Matters

Shellcode gets injected at an unknown, random address. It can't use global variables or absolute addresses because it doesn't know where it is. Every data reference must be relative to the current instruction pointer. AceLdr is entirely position-independent - from its ASM entry point to its C hook functions.

The Core Challenge

When you compile a normal C program, the compiler generates code that references data at fixed addresses. For example, a global variable might live at 0x00404000. But shellcode could be injected at 0x7FFE1234, 0x0012AB00, or anywhere else. If the code refers to 0x00404000, it will crash or read garbage. Position-independent code solves this by computing addresses at runtime relative to the current position.

The GetIp Trick

The foundation of PIC: "Where am I right now?" On x64, you can use RIP-relative addressing natively, but AceLdr uses a classic technique that has been a staple of shellcode development for decades:

ASM - from misc.asmGetIp:
    call   get_ret_ptr    ; CALL pushes the address of the next instruction

get_ret_ptr:
    pop    rax            ; RAX = address of get_ret_ptr (= our current location)
    sub    rax, 5         ; Adjust back to GetIp's address (CALL is 5 bytes)
    ret                   ; Return with RAX = address of GetIp()

How It Works Step by Step

  1. call get_ret_ptr pushes the return address (address of the pop rax instruction) onto the stack. This is what CALL always does - it pushes the address of the next instruction so RET can return there.
  2. pop rax retrieves that address from the stack into RAX. Now RAX holds the runtime address of get_ret_ptr.
  3. sub rax, 5 adjusts back by the size of the CALL instruction (5 bytes on x64 for a near call). Now RAX points to GetIp itself.
  4. ret returns to the caller with RAX containing the absolute runtime address of GetIp.

Since we know where GetIp is relative to the rest of our code (determined at compile time by the linker), we can calculate the address of anything.

The OFFSET Macro

With GetIp() giving us a known runtime anchor point, the OFFSET macro calculates the runtime address of any symbol:

C - from include.h// "Where is X right now, in this process's memory?"
#define OFFSET( x ) ( ULONG_PTR )( GetIp() - ( (ULONG_PTR)&GetIp - (ULONG_PTR)x ) )

// At compile time: &GetIp - x = distance between GetIp and x
// At runtime:      GetIp() returns actual address of GetIp
// So:              GetIp() - distance = actual address of x

Breaking Down the Math

Let's trace through a concrete example. Suppose at compile time:

At runtime, the code is loaded at a completely different location:

OFFSET Macro Visualization

0x????
Stub: (data we want to find)
... other code ...
GetIp: (we know this address at runtime)
... more code ...
distance = &GetIp - &Stub (known at compile time)
OFFSET(Stub) = GetIp() - distance = actual address of Stub

Why This Works

The key insight is that while absolute addresses change when code is loaded at different locations, relative distances between symbols remain constant. The linker computes the distance between GetIp and any target symbol at compile time. At runtime, GetIp() provides the actual anchor address, and simple arithmetic gives us the actual address of any target. This is the foundation of all position-independent code in AceLdr.

The Custom Linker Script

For the OFFSET macro to work correctly, AceLdr must control the exact layout of code in memory. Without a custom linker script, the linker is free to reorder functions, which would break all the distance calculations. AceLdr uses a linker script that assigns each component to a named subsection:

LD - from link.ldSECTIONS
{
    .text :
    {
        *( .text$A )    /* start.asm - Entry point (Start) */
        *( .text$B )    /* ace.c     - Loader/Ace functions */
        *( .text$C )    /* misc.asm  - Stub data + GetIp */
        *( .text$D )    /* hooks     - Sleep_Hook, Spoof hooks, Heap hook */
        *( .text$E )    /* util.c    - HashString, FindModule, etc. */
        *( .rdata* )    /* Read-only data (strings, etc.) */
        *( .text$F )    /* misc.asm  - GetIp impl + "ACELDR" marker */
    }
}

Understanding the Section Ordering

The subsections (.text$A through .text$F) are ordered alphabetically by the linker and merged into a single .text section. This ensures:

Note that .rdata (read-only data) is also merged into .text. This is because shellcode has only one section - everything must be in the executable code section.

The SECTION() Macro

Each C function uses the SECTION() macro to declare which subsection it belongs to:

C - Section assignment macro#define SECTION( x ) __attribute__(( section( ".text$" #x ) ))

// Usage:
SECTION( B ) VOID Loader( VOID ) { ... }      // Goes into .text$B
SECTION( D ) VOID Sleep_Hook( ... ) { ... }    // Goes into .text$D
SECTION( E ) PVOID FindModule( ... ) { ... }   // Goes into .text$E

The __attribute__((section(...))) is a GCC/Clang extension that tells the compiler to place the function's code into the specified section instead of the default .text. The #x uses the C preprocessor's stringification operator to convert the argument to a string, so SECTION(B) becomes __attribute__((section(".text$B"))).

The ACELDR End Marker

After compilation, the extraction script needs to know where the shellcode ends. AceLdr places the string "ACELDR" at the very end of .text$F to serve as a delimiter:

ASM - from misc.asm[SECTION .text$F]
GetIp:
    call    get_ret_ptr
get_ret_ptr:
    pop    rax
    sub    rax, 5
    ret

Leave:
    db 'A', 'C', 'E', 'L', 'D', 'R'   ; End marker

The Python extraction script (extract.py) then finds this marker and extracts everything before it as the final shellcode blob:

Python - from extract.py# Find the marker and extract everything before it
PeSec = PeExe.sections[0].get_data()
ScRaw = PeSec[ : PeSec.find( b'ACELDR' ) ]  # Shellcode = bytes before marker

The Build Pipeline

The full build process for AceLdr is:

  1. Compile: Each .c file is compiled to an object file with SECTION() attributes
  2. Assemble: Assembly files (start.asm, misc.asm) are assembled with NASM
  3. Link: The custom linker script (link.ld) merges everything into a single .text section in the correct order
  4. Extract: extract.py reads the compiled PE, finds the "ACELDR" marker, and dumps everything before it as raw shellcode
  5. Prepend: The shellcode is prepended to the Beacon DLL, creating the final payload

Common PIC Pitfalls

Writing position-independent C code is tricky. Common mistakes include:

Pop Quiz: Position-Independent Code

Q1: The GetIp function uses call + pop. What does CALL push onto the stack?

CALL pushes the address of the next instruction (the return address) and then jumps to the target. POP then retrieves this address into a register. This is the classic way to get the current instruction pointer in shellcode.

Q2: Why does AceLdr use a custom linker script?

The OFFSET() macro relies on knowing the compile-time distance between functions. The linker script ensures functions are placed in a predictable order (.text$A through .text$F). Without it, the linker could reorder functions and break all the position-independent addressing.