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 owns0x400000, 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