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:
- RipStart must be at the very beginning (byte 0) — this is the shellcode entry point. When an injector calls the shellcode, it jumps to the first byte.
- Main C++ code comes next — the bulk of the implant logic.
- String data (.rdata) must be sandwiched between the main code and
RipData— this is where all the embedded strings live. - RipData must be last, immediately after the data —
symbol<T>uses it as an anchor point to calculate string addresses (as covered in Module 7).
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
Byte 0. Saves registers, calls start(), restores, returns.
start(), resolve::module(), resolve::_api(), implant logic
Embedded strings, constants, compile-time hash tables
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?
- .text$A first: The injector calls byte 0 of the shellcode. RipStart must be here.
- .text$B second: The main implant code. Section ordering is alphabetical within .text sub-sections, so $B naturally follows $A.
- .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.
- .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?
Q2: What tool is used to extract the final shellcode blob from the compiled PE?