ELF Mechanics - Static, Dynamic, PIE, Toolchain Defaults

In this article, I assume you probably know what an ELF file is, or you’ve read the quick introduction ELF Internals - Objects, Executables, and Libraries . At this point you understand the difference between an object file, an executable, and a library. Most of the time you work and interact directly with executables.

Suppose you have some C source code. You know enough programming to make it work, you know the specific syntax, how to compile it, how to run it. However, you decide to try a new programming language like Go, or Rust. You notice the resulting binaries behave differently and often differ in size. But how come they still execute on a Linux machine?

The answer lies not in the programming language itself, but the toolchain and linking choices. They define how the binary is built: static vs dynamic, PIE or non-PIE, and which runtime or libraries are included. I will shortly provide examples, but for now the idea is to stop thinking “this is a C thing” or to clear any ambiguity of why binaries resulted from different programming languages may look differently.

Case Study - What the Kernel sees #

A common misconception is that these properties (linking type, preconditions for ASLR) are inherent to the language. In reality, they are determined by compiler defaults, runtime design, and ABI compliance.

The kernel only sees ELF headers, program headers, interpreter (or lack of one). Let’s quickly compare what binary we get when using different programming languages.

Example in C #

New file: payload.c

#include <stdio.h>  
#include <stdlib.h>  
  
int main(int argc, char *argv[], char *envp[]) {  
   printf("[*] Hello payload!\n");  
   printf("[*] Received %d arguments.\n", argc);  
  
   for(int i = 0; i < argc; i++) {  
       printf("    argv[%d]: %s\n", i, argv[i]);  
   }  
  
   if (envp[0]) {  
       printf("[*] First environment variable: %s\n", envp[0]);  
   }  
  
   return 0;  
}
# Compile with default settings like this:
gcc payload.c -o payload

# Analyze the file (for more details readelf -a):
payload: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b1dbd97462f8fc20da006dc7ee2b3f869328594e, for GNU/Linux 3.2.0, not stripped

# Look at the binary size:
du -b payload
16008 # ~16kb 

Modern GCC on most distros defaults to -fPIE -pie flags. This is the default behavior on hardened distributions. Again, this does not mean “C code = dynamic PIE binary”. It simply means the default GCC profile on this distro is configured to produce PIE.

Example in Go #

New file payload.go:

package main  
  
import (  
   "fmt"  
   "os"  
)  
  
func main() {  
   fmt.Println("[*] Hello payload!")  
   fmt.Printf("[*] Received %d arguments.\n", len(os.Args))  
  
   for i, arg := range os.Args {  
       fmt.Printf("    argv[%d]: %s\n", i, arg)  
   }  
  
   if len(os.Environ()) > 0 {  
       fmt.Printf("[*] First environment variable: %s\n", os.Environ()[0])  
   }  
  
   os.Exit(0)  
}
# Compile with default settings like this:
go build payload.go

# Analyze the file (for more details readelf -a):
payload: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked,
BuildID[sha1]=01c064fa960e69ea7b4b9909bddac12265b42b8d, with debug_info, not stripped

# Look at the binary size:
du -b payload
1513688 # ~1.5mb 

Example in Rust #

New file: payload.rs:

use std::env;  
  
fn main() {  
   let args: Vec<String> = env::args().collect();  
   let envs: Vec<String> = env::vars()  
       .map(|(k, v)| format!("{}={}", k, v))  
       .collect();  
  
   println!("[*] Hello payload!");  
   println!("[*] Received {} arguments.", args.len());  
  
   for (i, arg) in args.iter().enumerate() {  
       println!("    argv[{}]: {}", i, arg);  
   }  
  
   if !envs.is_empty() {  
       println!("[*] First environment variable: {}", envs[0]);  
   }  
  
   std::process::exit(0);  
}
# Compile with default settings like this:
rustc payload.rs

# Analyze the file (for more details readelf -a):
payload: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=7af723bcd28a34d8740b7be44734407d74bc8547, with debug_info, not stripped

# Look at the binary size:
du -b payload
3913152 # ~3.8mb 

At this point, we’ve seen that binaries differ even when behavior is identical. To understand why, we need to define the two things that control ELF layout, which are, linking strategy and position independence.

Linking Strategy #

Before interpreting the results, we must define the linking strategies themselves, as they are the primary driver of file structure.

Static Linking #

This is the process where the linker copies the machine code of all used library routines (from libc.a) directly into the final executable file at build time. The library code becomes a permanent part of the binary’s own .text segment. You get a strictly self-contained executable, which is also portable. It has zero external dependencies for those functions. The tradeoff is significant increase in disk usage and memory footprint. Imagine 100 static programs use printf, this means you have 100 copies of printf in memory.

Dynamic Linking #

This is the process where the linker places references in the executable, pointing to shared libraries (libc.so) that must exist on the target system. The actual resolution happens at load time. When the kernel runs the binary, it invokes the Dynamic Linker (as seen in the file command output it is usually /lib64/ld-linux-x86-64.so.2), which maps the required libraries into virtual memory and patches the binary’s call sites to point to the actual function addresses. You get a lightweight executable that acts as a coordinator for system libraries, but the binary is not portable. If the target system lacks the specific library version, the program will not start.

Why the Size Difference #

You can force C binaries to be static, and you can force Go binaries to be dynamic, but you cannot easily remove the language runtime.

Go was designed at Google to solve deployment headaches. They wanted to upload a single file onto a server and have it work. That requires bundling the Garbage Collector and the Goroutine scheduler into the binary. That is why a “Hello World” is 2MB. C is the native language of Unix. It assumes the OS does the heavy lifting through libc, thus the dynamic linking. Now these concepts will start to make more sense.

Malware Perspective #

There are many reasons malware authors often prefer some languages over others, like C or Assembly, because they produce “pure” machine code without a runtime manager. A 15kb C implant is easier to hide in another process than a 2MB Go binary. Go binaries have a distinct, non-standard structure and massive symbol tables that can trigger heuristic alerts in security tools. Plus normal Linux Processes aren’t usually static/non-PIE. In C, you control every byte. In Go/Rust, the runtime executes code you didn’t write (cleanup, stack checks) which can interfere with evasion techniques. Though, keep in mind these are only a few reasons biased towards C. There’s plenty of reasons and cases where it is wiser to choose Rust or Go over C.

Controlling the build #

We established that “Static vs Dynamic” and “PIE vs non-PIE” are choices. To understand the underlying ELF mechanics, the best approach is to take our simple C program and force it to adopt these different characteristics. I’ll stick to C, because it exposes the linker and loader decisions with minimal abstraction.

What is non-PIE #

Non-PIE (non-Position-Independent Executable) binaries are traditional ELF executables of type ET_EXEC. These are characterized by a fixed load address. The binary is linked to run at a specific virtual address (usually 0x400000 on x86_64 Linux). The ELF header e_entry points to the absolute address of _start. Because the code is loaded at a fixed address, it’s more predictable for attackers, making it easier to exploit memory corruption vulnerabilities. Segments (.text, .data, .bss) are mapped at fixed offsets in memory.

If dynamically linked (dynamic non-pie binary), it still uses /lib64/ld-linux-x86-64.so.2 to resolve shared libraries. If statically, no interpreter is used.

Static Non-PIE Binary #

# Compile with a static non-pie binary 
gcc payload.c -static -no-pie -o payload_snp  # snp: static no-pie

du -b payload_snp
785456 # ~768kb

# use readelf for more information about the PHDR
readelf -l payload_snp

Elf file type is EXEC (Executable file)  
Entry point 0x401790  
There are 10 program headers, starting at offset 64  
  
Program Headers:  
 Type           Offset             VirtAddr           PhysAddr  
                FileSiz            MemSiz              Flags  Align  
 LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000  
                0x00000000000004f8 0x00000000000004f8  R      0x1000  
 LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000  
                0x000000000007da0d 0x000000000007da0d  R E    0x1000  
 LOAD           0x000000000007f000 0x000000000047f000 0x000000000047f000  
                0x0000000000025858 0x0000000000025858  R      0x1000  
 LOAD           0x00000000000a4f50 0x00000000004a5f50 0x00000000004a5f50  
                0x0000000000005b70 0x000000000000b2d8  RW     0x1000  
 NOTE           0x0000000000000270 0x0000000000400270 0x0000000000400270  
                0x0000000000000030 0x0000000000000030  R      0x8  
 NOTE           0x00000000000002a0 0x00000000004002a0 0x00000000004002a0  
                0x0000000000000044 0x0000000000000044  R      0x4  
 TLS            0x00000000000a4f50 0x00000000004a5f50 0x00000000004a5f50  
                0x0000000000000018 0x0000000000000058  R      0x8  
 GNU_PROPERTY   0x0000000000000270 0x0000000000400270 0x0000000000400270  
                0x0000000000000030 0x0000000000000030  R      0x8  
 GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000000000 0x0000000000000000  RW     0x10  
 GNU_RELRO      0x00000000000a4f50 0x00000000004a5f50 0x00000000004a5f50  
                0x00000000000040b0 0x00000000000040b0  R      0x1  
  
Section to Segment mapping:  
 Segment Sections...  
  00     .note.gnu.property .note.gnu.build-id .note.ABI-tag .rela.plt    
  01     .init .plt .text .fini    
  02     .rodata .stapsdt.base rodata.cst32 .eh_frame .gcc_except_table    
  03     .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss    
  04     .note.gnu.property    
  05     .note.gnu.build-id .note.ABI-tag    
  06     .tdata .tbss    
  07     .note.gnu.property    
  08        
  09     .tdata .init_array .fini_array .data.rel.ro .got

We successfully compiled a static binary: Elf file type is EXEC (Executable file)

Notice the address: 0x0000000000400000, this is also a tell we are dealing with a non-PIE binary. The binary demands to be loaded starting at 0x400000. Let’s look at this address through a reverse engineering tool like radare2.

# Launch radare 2 with -A (analyze)
r2 -A payload_snp
            > s @ 0x400000    # Seek at 0x400000
[0x00400000]> px  # Print heXadecimal
0x00400000  7f45 4c46 0201 0103 0000 0000 0000 0000  .ELF............  
0x00400010  0200 3e00 0100 0000 9017 4000 0000 0000  ..>.......@.....

Physically, 0x400000 is the Image Base. It is the very beginning of the memory page where the kernel mapped your file. After inspecting this address, we can see the ELF Magic bytes: 7f 45 4c 46 (which spell .ELF).

When we say a static binary is fixed at 0x400000, we mean in its own Virtual Address Space. Thanks to the OS managing page tables, you can run a thousand instances of this static binary simultaneously. Each one believes it owns 0x400000, but the OS maps them to different physical RAM chips behind the scenes.

The fixed entry point can be seen here: Entry point 0x401790. This is a large, absolute number, not an offset. The binary is telling the kernel to jump exactly to the memory address 0x401790. If the kernel cannot map memory there, the program crashes.


> s @ 0x401790    # seek at the entry point address
> pd              # Print N bytes/instructions bw/forward
           ;-- _start:  
           ;-- rip:  
           ; DATA XREF from sym._dl_aux_init @ 0x42903d  
┌ 38: entry0 (int64_t arg3);  
│           ; arg int64_t arg3 @ rdx  
│           0x00401790      f30f1efa       endbr64
---snip---

Dynamic Non-PIE Binary #

Dynamic Non-PIE is a binary format that was the standard for Linux executables for nearly 20 years (until ~2017 “Around July 2017, Debian enabled PIE by default” - wikipedia). It represents a compromise of saving space by using shared libraries (.so files) and being loaded at a fixed address (0x400000).

Before modern hardware optimization, calculating “relative” addresses (needed for PIE) had a small overhead to it, so starting at 0x400000 always was faster and with no apparent drawbacks.

# Compile a dynamic no-pie binary
gcc -no-pie payload.c -o payload_dnp

du -b payload_dnp
15920 # ~16kb

readelf -l payload_dnp    
  
Elf file type is EXEC (Executable file)  
Entry point 0x401070  
There are 13 program headers, starting at offset 64  
  
Program Headers:  
 Type           Offset             VirtAddr           PhysAddr  
                FileSiz            MemSiz              Flags  Align  
 PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040  
                0x00000000000002d8 0x00000000000002d8  R      0x8  
 INTERP         0x0000000000000318 0x0000000000400318 0x0000000000400318  
                0x000000000000001c 0x000000000000001c  R      0x1  
     [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]  
 LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000  
                0x0000000000000538 0x0000000000000538  R      0x1000  
 LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000  
                0x0000000000000219 0x0000000000000219  R E    0x1000  
 LOAD           0x0000000000002000 0x0000000000402000 0x0000000000402000  
                0x000000000000014c 0x000000000000014c  R      0x1000  
 LOAD           0x0000000000002df8 0x0000000000403df8 0x0000000000403df8  
                0x0000000000000228 0x0000000000000230  RW     0x1000  
 DYNAMIC        0x0000000000002e08 0x0000000000403e08 0x0000000000403e08  
                0x00000000000001d0 0x00000000000001d0  RW     0x8  
 NOTE           0x0000000000000338 0x0000000000400338 0x0000000000400338  
                0x0000000000000030 0x0000000000000030  R      0x8  
 NOTE           0x0000000000000368 0x0000000000400368 0x0000000000400368  
                0x0000000000000044 0x0000000000000044  R      0x4  
 GNU_PROPERTY   0x0000000000000338 0x0000000000400338 0x0000000000400338  
                0x0000000000000030 0x0000000000000030  R      0x8  
 GNU_EH_FRAME   0x0000000000002074 0x0000000000402074 0x0000000000402074  
                0x0000000000000034 0x0000000000000034  R      0x4  
 GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000000000 0x0000000000000000  RW     0x10  
 GNU_RELRO      0x0000000000002df8 0x0000000000403df8 0x0000000000403df8  
                0x0000000000000208 0x0000000000000208  R      0x1  
  
Section to Segment mapping:  
 Segment Sections...  
  00        
  01     .interp    
  02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt    
  03     .init .plt .plt.sec .text .fini    
  04     .rodata .eh_frame_hdr .eh_frame    
  05     .init_array .fini_array .dynamic .got .got.plt .data .bss    
  06     .dynamic    
  07     .note.gnu.property    
  08     .note.gnu.build-id .note.ABI-tag    
  09     .note.gnu.property    
  10     .eh_frame_hdr    
  11        
  12     .init_array .fini_array .dynamic .got

Because this binary is tiny (about 16kb), it does not know functions like printf or malloc on its own. It relies on the system’s shared libraries. When you run this binary, the kernel maps it into memory, but it does not run your code immediately. Instead, it looks for a special segment called PT_INTERP. The kernel sees this segment and will now load /lib64/ld-linux-x86-64.so.2 into memory. The interpreter now finds libc.so, loads it, connects the printf calls from our binary to the real printf in the library, and only then jumps to the main function. The binary image is still mapped at 0x400000 in the memory page.

Let’s look at INTERP:

r2 payload_dnp

[0x400000]> iS    # View sections, or use iSS to view actual segments
[Sections]  
  
nth paddr        size vaddr       vsize perm name  
―――――――――――――――――――――――――――――――――――――――――――――――――  
0   0x00000000    0x0 0x00000000    0x0 ----    
1   0x00000318   0x1c 0x00400318   0x1c -r-- .interp

[0x400000]> s @ 0x00400318
[0x400000]> px
0x00400318  2f6c 6962 3634 2f6c 642d 6c69 6e75 782d  /lib64/ld-linux-  
0x00400328  7838 362d 3634 2e73 6f2e 3200 0000 0000  x86-64.so.2.....  
0x00400338  0400 0000 2000 0000 0500 0000 474e 5500  .... .......GNU.

What is PIE #

PIE (Position Independent Executable) is the modern standard (Type ET_DYN). Unlike the previous examples, these binaries don’t care about their memory location. They are built using relative addressing, rather than a fixed address.

This allows the Operating System to utilize ASLR (Address Space Layout Randomization). Every time you run the program, the kernel picks a random base address to load it. This makes it significantly harder for attackers to rely on known memory addresses for exploits.

Dynamic PIE Binary #

This is the modern standard, and this is what gcc produces by default on most modern distros.

gcc payload.c -o payload_dp

du -b payload_dp
16008 # ~16kb

readelf -l payload_dp  
  
Elf file type is DYN (Position-Independent Executable file)  
Entry point 0x1080  
There are 13 program headers, starting at offset 64  
  
Program Headers:  
 Type           Offset             VirtAddr           PhysAddr  
                FileSiz            MemSiz              Flags  Align  
 PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040  
                0x00000000000002d8 0x00000000000002d8  R      0x8  
 INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318  
                0x000000000000001c 0x000000000000001c  R      0x1  
     [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]  
 LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000000660 0x0000000000000660  R      0x1000  
 LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000  
                0x000000000000022d 0x000000000000022d  R E    0x1000  
 LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000  
                0x0000000000000154 0x0000000000000154  R      0x1000  
 LOAD           0x0000000000002db0 0x0000000000003db0 0x0000000000003db0  
                0x0000000000000260 0x0000000000000268  RW     0x1000  
 DYNAMIC        0x0000000000002dc0 0x0000000000003dc0 0x0000000000003dc0  
                0x00000000000001f0 0x00000000000001f0  RW     0x8  
 NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338  
                0x0000000000000030 0x0000000000000030  R      0x8  
 NOTE           0x0000000000000368 0x0000000000000368 0x0000000000000368  
                0x0000000000000044 0x0000000000000044  R      0x4  
 GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338  
                0x0000000000000030 0x0000000000000030  R      0x8  
 GNU_EH_FRAME   0x0000000000002074 0x0000000000002074 0x0000000000002074  
                0x0000000000000034 0x0000000000000034  R      0x4  
 GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000000000 0x0000000000000000  RW     0x10  
 GNU_RELRO      0x0000000000002db0 0x0000000000003db0 0x0000000000003db0  
                0x0000000000000250 0x0000000000000250  R      0x1  
  
Section to Segment mapping:  
 Segment Sections...  
  00        
  01     .interp    
  02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt    
  03     .init .plt .plt.got .plt.sec .text .fini    
  04     .rodata .eh_frame_hdr .eh_frame    
  05     .init_array .fini_array .dynamic .got .data .bss    
  06     .dynamic    
  07     .note.gnu.property    
  08     .note.gnu.build-id .note.ABI-tag    
  09     .note.gnu.property    
  10     .eh_frame_hdr    
  11        
  12     .init_array .fini_array .dynamic .got

Now the base is 0x0 (look at VirtAddr), instead of 0x400000. The kernel will choose a random base address and will add it to the 0x0. We still see an Entry point value, but in a PIE binary, the Entry Point is a relative offset to the load base. So basically, the code starts 0x1060 bytes after the start of the file (random ASLR Base + 0x0)

If we try to analyze the file directly with radare2 and since the OS hasn’t run the file yet, there is no real address. Radare2 will usually set the base to 0x0. You will only see offsets. Though, for real addresses, we can run it in debug mode:

r2 -d payload_dp -a
> iS    # List sections
[Sections]  
  
nth paddr        size vaddr           vsize perm name  
―――――――――――――――――――――――――――――――――――――――――――――――――――――  
0   0x00000000    0x0 0x00000000        0x0 ----    
1   0x00000318   0x1c 0x5642c9dbe318   0x1c -r-- .interp

With debug mode, r2 attaches to the actual process. It asks the kernel the ASLR base and then updates all its addresses to match reality:

                > s @ 0x5642c9dbe318  
[0x5642c9dbe318]> px  
0x5642c9dbe318  2f6c 6962 3634 2f6c 642d 6c69 6e75 782d  /lib64/ld-linux-  
0x5642c9dbe328  7838 362d 3634 2e73 6f2e 3200 0000 0000  x86-64.so.2.....  
0x5642c9dbe338  0400 0000 2000 0000 0500 0000 474e 5500  .... .......GNU.

You can also inspect /proc/PID/maps. Firstly we need to make the process run continuously (can be achieved by adding a getchar(); right before return):

// payload.c
// ...
   getchar(); // this will make the program "hang" until keypress
   return 0;  
}

Now recompile and start the process in the background:

./payload_dp &
[1] 32451    # This is our PID

cat /proc/32451/maps
5a20d6bd8000-5a20d6bd9000 r--p 00000000 103:02 54397814                  /home/cation/Documents/elf-loader/tests/c/payload_dp  
5a20d6bd9000-5a20d6bda000 r-xp 00001000 103:02 54397814                  /home/cation/Documents/elf-loader/tests/c/payload_dp  
5a20d6bda000-5a20d6bdb000 r--p 00002000 103:02 54397814                  /home/cation/Documents/elf-loader/tests/c/payload_dp  
5a20d6bdb000-5a20d6bdc000 r--p 00002000 103:02 54397814                  /home/cation/Documents/elf-loader/tests/c/payload_dp  
5a20d6bdc000-5a20d6bdd000 rw-p 00003000 103:02 54397814                  /home/cation/Documents/elf-loader/tests/c/payload_dp  
5a20fad3c000-5a20fad5d000 rw-p 00000000 00:00 0                          [heap]  
74cd12000000-74cd12028000 r--p 00000000 103:02 55054548                  /usr/lib/x86_64-linux-gnu/libc.so.6  
74cd12028000-74cd121b0000 r-xp 00028000 103:02 55054548                  /usr/lib/x86_64-linux-gnu/libc.so.6  
74cd121b0000-74cd121ff000 r--p 001b0000 103:02 55054548                  /usr/lib/x86_64-linux-gnu/libc.so.6  
74cd121ff000-74cd12203000 r--p 001fe000 103:02 55054548                  /usr/lib/x86_64-linux-gnu/libc.so.6  
74cd12203000-74cd12205000 rw-p 00202000 103:02 55054548                  /usr/lib/x86_64-linux-gnu/libc.so.6  
74cd12205000-74cd12212000 rw-p 00000000 00:00 0    
74cd123bc000-74cd123bf000 rw-p 00000000 00:00 0    
74cd123de000-74cd123e0000 rw-p 00000000 00:00 0    
74cd123e0000-74cd123e2000 r--p 00000000 00:00 0                          [vvar]  
74cd123e2000-74cd123e4000 r--p 00000000 00:00 0                          [vvar_vclock]  
74cd123e4000-74cd123e6000 r-xp 00000000 00:00 0                          [vdso]  
74cd123e6000-74cd123e7000 r--p 00000000 103:02 55054545                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2  
74cd123e7000-74cd12412000 r-xp 00001000 103:02 55054545                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2  
74cd12412000-74cd1241c000 r--p 0002c000 103:02 55054545                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2  
74cd1241c000-74cd1241e000 r--p 00036000 103:02 55054545                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2  
74cd1241e000-74cd12420000 rw-p 00038000 103:02 55054545                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2  
7ffc48391000-7ffc483b3000 rw-p 00000000 00:00 0                          [stack]  
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

This way we can see the ASLR base address, the memory regions and their permissions. If this were a non-PIE binary, this number would be exactly 0x400000. Instead, it is a high, random address. If you kill the process and run it again, this number will change. Flags r-xp stand for Read, Execute, Private, here lives the executable code.

Static PIE Binary #

Finally, is it possible to have a binary that is Static but also PIE ? This is a relatively modern feature called Static PIE. You get a single file that runs anywhere but can be loaded at any address. Normally, the dynamic linker handles the relocations at startup. If we are static, we don’t have a dynamic linker, so how are addresses shifted? The binary must contain a self-contained linker.

gcc -static-pie payload.c -o payload_sp

du -b payload_sp
822648 # ~804kb

readelf -l payload_sp
  
Elf file type is DYN (Position-Independent Executable file)  
Entry point 0x7950  
There are 12 program headers, starting at offset 64  
  
Program Headers:  
 Type           Offset             VirtAddr           PhysAddr  
                FileSiz            MemSiz              Flags  Align  
 LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000006c38 0x0000000000006c38  R      0x1000  
 LOAD           0x0000000000007000 0x0000000000007000 0x0000000000007000  
                0x000000000007eccd 0x000000000007eccd  R E    0x1000  
 LOAD           0x0000000000086000 0x0000000000086000 0x0000000000086000  
                0x0000000000027b38 0x0000000000027b38  R      0x1000  
 LOAD           0x00000000000adcd0 0x00000000000aecd0 0x00000000000aecd0  
                0x0000000000005d30 0x000000000000b438  RW     0x1000  
 DYNAMIC        0x00000000000b1cc8 0x00000000000b2cc8 0x00000000000b2cc8  
                0x00000000000001b0 0x00000000000001b0  RW     0x8  
 NOTE           0x00000000000002e0 0x00000000000002e0 0x00000000000002e0  
                0x0000000000000030 0x0000000000000030  R      0x8  
 NOTE           0x0000000000000310 0x0000000000000310 0x0000000000000310  
                0x0000000000000044 0x0000000000000044  R      0x4  
 TLS            0x00000000000adcd0 0x00000000000aecd0 0x00000000000aecd0  
                0x0000000000000018 0x0000000000000058  R      0x8  
 GNU_PROPERTY   0x00000000000002e0 0x00000000000002e0 0x00000000000002e0  
                0x0000000000000030 0x0000000000000030  R      0x8  
 GNU_EH_FRAME   0x00000000000a2400 0x00000000000a2400 0x00000000000a2400  
                0x00000000000020e4 0x00000000000020e4  R      0x4  
 GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000  
                0x0000000000000000 0x0000000000000000  RW     0x10  
 GNU_RELRO      0x00000000000adcd0 0x00000000000aecd0 0x00000000000aecd0  
                0x0000000000004330 0x0000000000004330  R      0x1  
  
Section to Segment mapping:  
 Segment Sections...  
  00     .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .rela.dyn .rela.plt    
  01     .init .plt .plt.got .plt.sec .text .fini    
  02     .rodata .stapsdt.base rodata.cst32 .eh_frame_hdr .eh_frame .gcc_except_table    
  03     .tdata .init_array .fini_array .data.rel.ro .dynamic .got .data .bss    
  04     .dynamic    
  05     .note.gnu.property    
  06     .note.gnu.build-id .note.ABI-tag    
  07     .tdata .tbss    
  08     .note.gnu.property    
  09     .eh_frame_hdr    
  10        
  11     .tdata .init_array .fini_array .data.rel.ro .dynamic .got

This is the bulkiest C binary by far. There is an interesting aspect to it, because it is marked as being: DYN (Position-Independent Executable file), but there is no INTERP segment (thus the self-contained linker). It supports ASLR as shown before, and also runs on any system with a compatible kernel. So where is the self contained linker?

r2 -a payload_sp
> s @ 0x7950    # Go to entry point
           ;-- _start:  
           ;-- rip:  
           ; DATA XREF from sym._dl_aux_init @ 0x302dd  
┌ 38: entry0 (int64_t arg3);  
│           ; arg int64_t arg3 @ rdx  
│           0x00007950      f30f1efa       endbr64  
│           0x00007954      31ed           xor ebp, ebp  
│           0x00007956      4989d1         mov r9, rdx                 ; arg3  
│           0x00007959      5e             pop rsi                     ; int64_t arg2  
│           0x0000795a      4889e2         mov rdx, rsp                ; int64_t arg3  
│           0x0000795d      4883e4f0       and rsp, 0xfffffffffffffff0  
│           0x00007961      50             push rax  
│           0x00007962      54             push rsp  
│           0x00007963      4531c0         xor r8d, r8d                ; int64_t arg_10h  
│           0x00007966      31c9           xor ecx, ecx                ; int64_t arg6  
│           0x00007968      488d3dca0000.  lea rdi, [main]             ; 0x7a39 ; int64_t arg1  
│           0x0000796f      67e8fb250000   call sym.__libc_start_main_impl

There is no relocate function in _start, because it is contained in sym.__libc_start_main_impl.

[0x00007950]> s @ sym.__libc_start_main_impl    # Go to __libc_start_main_impl
[0x00009f70]> pd  
           ;-- __libc_start_main:  
           ; CALL XREF from entry0 @ 0x796f  
┌ 678: sym.__libc_start_main_impl (int64_t arg1, int64_t arg2, int64_t arg3, int64_t arg6, int64_t arg_10h);  
│           ; var int64_t var_40h @ rbp-0x40  
│           ; var int64_t var_38h @ rbp-0x38  
│           ; arg int64_t arg_10h @ rbp+0x10  
---snip---
│           0x00009fc7      e844420200     call sym.__tunables_init  
│           0x00009fcc      e8efefffff     call sym.init_cpu_features.constprop.0  
│           0x00009fd1      e8ba510200     call sym._dl_relocate_static_pie

_start calls sym.__libc_start_main_impl, which immediately calls sym._dl_relocate_static_pie

This allows the C library to better manage initialization order, but it makes the _start disassembly look “normal” (identical to a standard dynamic binary), hiding the static PIE relocation function a layer deeper.

In short: static PIE embeds just enough of the dynamic linker logic inside libc to relocate itself before main.

Quickly On AI hallucinated knowledge #

When reading technical details about how Linux works (this probably applies to most content online nowadays), treat the following as red flags:

  • Invented function names that only sound plausible
  • Collapsing multiple call layers into fake high-level summaries
  • Mixing historical kernels and academic descriptions
  • Mentioning functions that cannot be found in the kernel via grep
  • Avoiding concrete call graphs or code references
  • Relying on phrases like “high level actions” instead of actually showing the control flow

Any explanation that invents functions or obscures the call chain should be treated as untrusted. Read kernel code. Verify claims. And if I get something wrong in the articles that follow, correct me.

Get Involved #

I think knowledge should be shared and discussions encouraged. So, don’t hesitate to ask questions, or suggest topics you’d like me to cover in future posts.

Stay Connected #

You can contact me at ion.miron@tutanota.com