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
call get_ret_ptrpushes the return address (address of thepop raxinstruction) onto the stack. This is whatCALLalways does - it pushes the address of the next instruction soRETcan return there.pop raxretrieves that address from the stack into RAX. Now RAX holds the runtime address ofget_ret_ptr.sub rax, 5adjusts back by the size of the CALL instruction (5 bytes on x64 for a near call). Now RAX points toGetIpitself.retreturns to the caller with RAX containing the absolute runtime address ofGetIp.
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:
&GetIp= 0x1000 (compile-time address of GetIp)&Stub= 0x0800 (compile-time address of Stub)- Distance = 0x1000 - 0x0800 = 0x200
At runtime, the code is loaded at a completely different location:
GetIp()returns 0x7FFE3000 (actual runtime address)OFFSET(Stub)= 0x7FFE3000 - 0x200 = 0x7FFE2E00 (actual runtime address of Stub)
OFFSET Macro Visualization
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:
- .text$A (entry point) comes first - the shellcode starts executing here
- .text$B (core loader) comes next -
Ace()andLoader() - .text$C (Stub data) is in the middle - accessible via OFFSET from any hook
- .text$D (hooks) follow - these are the functions that intercept Beacon API calls
- .text$E (utilities) - helper functions like
HashStringandFindModule - .text$F (end) comes last - contains the
GetIpimplementation and the end marker
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:
- Compile: Each .c file is compiled to an object file with
SECTION()attributes - Assemble: Assembly files (start.asm, misc.asm) are assembled with NASM
- Link: The custom linker script (
link.ld) merges everything into a single .text section in the correct order - Extract:
extract.pyreads the compiled PE, finds the "ACELDR" marker, and dumps everything before it as raw shellcode - 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:
- String literals: In normal C,
"hello"is stored in.rdataand referenced by absolute address. AceLdr's linker script pulls.rdatainto.textto solve this. - Global variables: These live in
.dataor.bssand are referenced absolutely. AceLdr avoids globals entirely, using the STUB structure and OFFSET() instead. - Switch statements: Compilers may generate jump tables with absolute addresses. AceLdr uses if-else chains or carefully written switch statements.
- Function pointers: Taking the address of a function (
&MyFunc) gives a compile-time address. AceLdr usesOFFSET(MyFunc)instead.
Pop Quiz: Position-Independent Code
Q1: The GetIp function uses call + pop. What does CALL push onto the stack?
Q2: Why does AceLdr use a custom linker script?