Difficulty: Advanced

Module 8: The Linker Script & Section Layout

Why section order is everything when your code is its own loader.

Why This Module?

In a normal executable, the OS loader handles section placement and address fixups. Shellcode has no loader — the byte layout of the binary IS the memory layout. If RipStart isn't at byte 0, the entry point is wrong. If RipData isn't immediately adjacent to the data section, symbol<T> calculations break. The linker script is what guarantees this layout.

Why Section Order Matters

Stardust's position-independent code relies on precise spatial relationships between code and data:

The Linker Script: linker.ld

Stardust uses a custom linker script at scripts/linker.ld to enforce this layout. The MinGW toolchain's linker reads this script and places sections in exactly the specified order:

Linker ScriptSECTIONS
{
    .text :
    {
        *( .text$A )    /* RipStart - entry point, must be byte 0 */
        *( .text$B )    /* Main C++ code (start(), resolve, etc.) */
        *( .rdata* )    /* Read-only data: strings, constants */
        *( .text$C )    /* RipData - anchor for symbol<T> */
    }
}

Final Shellcode Memory Layout

.text$A — RipStart (entry point)
Byte 0. Saves registers, calls start(), restores, returns.
.text$B — Main C++ Code
start(), resolve::module(), resolve::_api(), implant logic
.rdata* — Read-Only Data
Embedded strings, constants, compile-time hash tables
.text$C — RipData (anchor)
symbol<T>::RipData() lives here, adjacent to data above

Everything merges into a single .text section — the extracted shellcode is one flat blob.

Why This Specific Order?

  1. .text$A first: The injector calls byte 0 of the shellcode. RipStart must be here.
  2. .text$B second: The main implant code. Section ordering is alphabetical within .text sub-sections, so $B naturally follows $A.
  3. .rdata* third: String data is sandwiched between code sections. By placing it inside the .text section (not as a separate PE section), it becomes part of the executable code blob. This is unconventional but necessary — shellcode is extracted as a single flat binary.
  4. .text$C last: RipData() must be physically adjacent to the data it references. Placing it immediately after .rdata means the compile-time distance to any string is small and predictable.

The declfn Attribute

To place a function in a specific section, Stardust uses compiler attributes (via a declfn macro or direct section attributes). This tells the compiler which sub-section a function belongs to:

C++// Place RipStart in .text$A (first in the binary)
__attribute__(( section(".text$A") ))
void RipStart() {
    // Entry point code...
}

// Place RipData in .text$C (last, after data)
__attribute__(( section(".text$C") ))
auto RipData() -> uintptr_t {
    return (uintptr_t)&RipData;
}

// Regular functions go in .text$B by default
// (or explicitly placed there via attribute)

Comparison: AceLdr vs Stardust Section Layout

AceLdr: 6 Code Sections
  • .text$A — Entry / Stub
  • .text$B — Hooks
  • .text$C — Core loader
  • .text$D — Data section
  • .text$E — Additional code
  • .text$F — End marker
  • Written in C with assembly
Stardust: 3+1 Layout
  • .text$A — RipStart (entry)
  • .text$B — All C++ code
  • .rdata* — Data (merged in)
  • .text$C — RipData (anchor)
  • Simpler, fewer sections
  • Modern C++ templates reduce section needs

AceLdr needs more sections because its C-based design uses explicit section placement for different categories of code (hooks, loader logic, data). Stardust's C++ approach consolidates most logic into .text$B, relying on templates and the type system instead of manual section separation.

Extracting the Shellcode

After compilation, the shellcode blob is extracted from the PE using objcopy. This strips all PE headers and metadata, leaving just the raw contents of the merged .text section:

Shell# Extract the .text section as a flat binary blob
objcopy -O binary -j .text stardust.exe stardust.bin

# The result is raw shellcode:
# - Starts at RipStart (byte 0)
# - Contains all code, data, and the RipData anchor
# - No PE headers, no section table, no imports
# - Ready to inject and execute

Key Takeaway

The linker script is the invisible backbone of Stardust. It ensures that every byte is in the right place so that position-independent code works, the entry point is at byte 0, and symbol<T> can find its data. Without it, the shellcode would be a jumbled mess of code and data at unpredictable offsets.

Knowledge Check

Q1: Why is .rdata placed between .text$B and .text$C in the linker script?

Correct! symbol<T> calculates string addresses using the compile-time distance from RipData to the string. For this to work reliably, RipData (.text$C) must be physically adjacent to .rdata. If other code were placed between them, the distances would be larger and less predictable.

Q2: What tool is used to extract the final shellcode blob from the compiled PE?

Correct! objcopy with the -O binary flag extracts the raw contents of the .text section, stripping all PE headers and metadata. The result is a flat binary blob that starts at RipStart and contains everything the shellcode needs.