Deep Dive into C/C++ Compilation and Linking (Side Note): Can a Dynamic Library Be Executed Like an Executable?
I know some of you reading this will laugh out loud and think I've lost my mind. Honestly, the very first time I came across this, I laughed it off too — it just sounded absurd. But the truth is, a dynamic library can be executed like an executable.
Someone is going to throw a Segmentation Fault in my face and tell me I'm full of it. You can cd into /lib yourself, pick a library you like — I went with libcurl and libcrypt — and just try running it.
[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcurl.so
Segmentation fault (core dumped) /lib/libcurl.so
[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcurl.so.4.8.0
Segmentation fault (core dumped) /lib/libcurl.so.4.8.0
[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcrypt.so.2.0.0
Segmentation fault (core dumped) /lib/libcrypt.so.2.0.0Our first thought is — why? Why does it end up like this? The answer is simple. In a later post I'll stress that, generally, anything ending in .so is a dynamic library (or shared library — as I've said before, on modern operating systems you don't really need to distinguish between "shared" and "dynamic" anymore).
Clearly, when you hand bash an absolute path like that, it tries to treat the file as a standalone program. That clashes with what a dynamic library actually is: a dynamically shared component bundling a set of functions and data. A shared library isn't designed with a standard main entry point (
Except it isn't. Let's try running the C library again:
[charliechen@Charliechen runaable_dynamic_library]$ /lib/libc.so.6
GNU C Library (GNU libc) stable release version 2.42.
Copyright (C) 2025 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 15.2.1 20250813.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 4.4.0
For bug reporting instructions, please see:
<https://gitlab.archlinux.org/archlinux/packaging/packages/glibc/-/issues>.Huh? That's nothing like what we expected. This time the C library didn't segfault — it printed a very recognizable string and exited gracefully. Pretty mysterious, right? Don't worry, I'll walk you through exactly what happened, step by step.
So, What's Actually Going On?
Simple. Let's start here — since this whole thing is about where program execution begins, anyone who knows the ELF format is going to point out that the trick must be hiding in the address the ELF Header points to. It's almost too easy to guess: libc's ELF Header must point to an entry point that's different from a component-purpose library like libcurl. And the tool for peeking at ELF headers is the famous readelf.
Quick ELF refresher — every ELF file (executable or shared library) has an "entry point," which is where the CPU starts executing instructions. Put another way, it gives the CPU's instruction pointer (EIP or RIP on x86-64) a concrete starting value.
Expand (23 lines)Collapse
[charliechen@Charliechen runaable_dynamic_library]$ readelf -h /lib/libcurl.so
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 64 (bytes into file)
Start of section headers: 945200 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 11
Size of section headers: 64 (bytes)
Number of section headers: 28
Section header string table index: 27Ha, mystery solved, right? If we try to treat /lib/libcurl.so as an executable, the OS loader reads it, runs its usual checks, and then sets the jump address to 0x0. And there you go — that's a null pointer dereference.
This is exactly the same thing as doing this:
#include <stdio.h>
int main() {
printf("Jumping to address 0x0...\n");
void (*func)() = (void (*)())0x0;
func();
}Compile and run it, and you get exactly:
[charliechen@Charliechen runaable_dynamic_library]$ gcc dump.c -o dump
[charliechen@Charliechen runaable_dynamic_library]$ ./dump
Jumping to address 0x0...
Segmentation fault (core dumped) ./dumpSo how about our libc?
Expand (23 lines)Collapse
[charliechen@Charliechen runaable_dynamic_library]$ readelf -h /lib/libc.so.6
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x27830
Start of program headers: 64 (bytes into file)
Start of section headers: 2145632 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 16
Size of section headers: 64 (bytes)
Number of section headers: 64
Section header string table index: 63Huh, so it really is different. Hold your horses, though — all we've got is 0x27830, which tells us nothing on its own. Next step: bring out the big gun, objdump, and look at the details.
Someone might ask, why not
nm? Well, for dynamic libraries,nmonly shows you the addresses of exported symbols — you generally won't find what the entry point actually maps to. Don't worry, we've got another trick up our sleeve: disassemble withobjdump.
Expand (21 lines)Collapse
[charliechen@Charliechen runaable_dynamic_library]$ objdump -d /lib/libc.so.6 --start-address=0x27830 --stop-address=0x27860
/lib/libc.so.6: file format elf64-x86-64
Disassembly of section .text:
0000000000027830 <gnu_get_libc_version@@GLIBC_2.2.5+0x10>:
27830: f3 0f 1e fa endbr64
27834: 55 push %rbp
27835: bf 01 00 00 00 mov $0x1,%edi
2783a: ba e3 01 00 00 mov $0x1e3,%edx
2783f: 48 8d 35 5a d8 18 00 lea 0x18d85a(%rip),%rsi # 1b50a0 <__nptl_version@@GLIBC_PRIVATE+0x2b2d>
27846: 48 89 e5 mov %rsp,%rbp
27849: e8 d2 6c 0e 00 call 10e520 <__write@@GLIBC_2.2.5>
2784e: 31 ff xor %edi,%edi
27850: e8 7b d8 0b 00 call e50d0 <_exit@@GLIBC_2.2.5>
27855: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
2785c: 00 00 00
2785f: 90 nopNo need to rush. Let's dig into our memory now. Starting from 0x27834, here's what the code is trying to do:
x64.syscall.sh — the syscall table reference, dropping it here for you.
Put
0x01intoedi— that's the first argument the syscall needs.Then the third argument goes into
edx. Come on, that's just the string length — decimal 483.Hold on, we still need to put the string address into
rsi, which is the second argument. Notice the instruction islea(Load Effective Address), which adds the offset to the address right after the current instruction. So you can't just go look up0x18d85adirectly — you have to add the current instruction's offset.Quick refresher: how does objdump arrive at
1b50a0? The current instruction's base address is0x2783f, and the instruction itself is48 8d 35 5a d8 18 00, which is 7 bytes long. So the next instruction is at0x2783f + 7 = 0x27846. Add the given offset, and you get0x27846 + 0x18d85a = 0x1b50a0. OK, we've confirmed objdump isn't lying to us (not that it probably ever would!).
Want to verify the bytes are really there?
Expand (35 lines)Collapse
[charliechen@Charliechen runaable_dynamic_library]$ hexdump -C -s 0x1b50a0 -n 483 /lib/libc.so.6
001b50a0 47 4e 55 20 43 20 4c 69 62 72 61 72 79 20 28 47 |GNU C Library (G|
001b50b0 4e 55 20 6c 69 62 63 29 20 73 74 61 62 6c 65 20 |NU libc) stable |
001b50c0 72 65 6c 65 61 73 65 20 76 65 72 73 69 6f 6e 20 |release version |
001b50d0 32 2e 34 32 2e 0a 43 6f 70 79 72 69 67 68 74 20 |2.42..Copyright |
001b50e0 28 43 29 20 32 30 32 35 20 46 72 65 65 20 53 6f |(C) 2025 Free So|
001b50f0 66 74 77 61 72 65 20 46 6f 75 6e 64 61 74 69 6f |ftware Foundatio|
001b5100 6e 2c 20 49 6e 63 2e 0a 54 68 69 73 20 69 73 20 |n, Inc..This is |
001b5110 66 72 65 65 20 73 6f 66 74 77 61 72 65 3b 20 73 |free software; s|
001b5120 65 65 20 74 68 65 20 73 6f 75 72 63 65 20 66 6f |ee the source fo|
001b5130 72 20 63 6f 70 79 69 6e 67 20 63 6f 6e 64 69 74 |r copying condit|
001b5140 69 6f 6e 73 2e 0a 54 68 65 72 65 20 69 73 20 4e |ions..There is N|
001b5150 4f 20 77 61 72 72 61 6e 74 79 3b 20 6e 6f 74 20 |O warranty; not |
001b5160 65 76 65 6e 20 66 6f 72 20 4d 45 52 43 48 41 4e |even for MERCHAN|
001b5170 54 41 42 49 4c 49 54 59 20 6f 72 20 46 49 54 4e |TABILITY or FITN|
001b5180 45 53 53 20 46 4f 52 20 41 0a 50 41 52 54 49 43 |ESS FOR A.PARTIC|
001b5190 55 4c 41 52 20 50 55 52 50 4f 53 45 2e 0a 43 6f |ULAR PURPOSE..Co|
001b51a0 6d 70 69 6c 65 64 20 62 79 20 47 4e 55 20 43 43 |mpiled by GNU CC|
001b51b0 20 76 65 72 73 69 6f 6e 20 31 35 2e 32 2e 31 20 | version 15.2.1 |
001b51c0 32 30 32 35 30 38 31 33 2e 0a 6c 69 62 63 20 41 |20250813..libc A|
001b51d0 42 49 73 3a 20 55 4e 49 51 55 45 20 49 46 55 4e |BIs: UNIQUE IFUN|
001b51e0 43 20 41 42 53 4f 4c 55 54 45 0a 4d 69 6e 69 6d |C ABSOLUTE.Minim|
001b51f0 75 6d 20 73 75 70 70 6f 72 74 65 64 20 6b 65 72 |um supported ker|
001b5200 6e 65 6c 3a 20 34 2e 34 2e 30 0a 46 6f 72 20 62 |nel: 4.4.0.For b|
001b5210 75 67 20 72 65 70 6f 72 74 69 6e 67 20 69 6e 73 |ug reporting ins|
001b5220 74 72 75 63 74 69 6f 6e 73 2c 20 70 6c 65 61 73 |tructions, pleas|
001b5230 65 20 73 65 65 3a 0a 3c 68 74 74 70 73 3a 2f 2f |e see:.<https://|
001b5240 67 69 74 6c 61 62 2e 61 72 63 68 6c 69 6e 75 78 |gitlab.archlinux|
001b5250 2e 6f 72 67 2f 61 72 63 68 6c 69 6e 75 78 2f 70 |.org/archlinux/p|
001b5260 61 63 6b 61 67 69 6e 67 2f 70 61 63 6b 61 67 65 |ackaging/package|
001b5270 73 2f 67 6c 69 62 63 2f 2d 2f 69 73 73 75 65 73 |s/glibc/-/issues|
001b5280 3e 2e 0a |>..|
001b5283That's enough! The rest of the analysis is obvious: put 0 into edi as the argument to exit, and exit gracefully.
Can We Pull Off the Same Trick?
Come on, of course we can! Let me walk you through doing it ourselves. It's going to be a little tricky, though, because we can't lean on libc this time. A dynamic library's initialization differs from a normal executable's — for instance, it won't initialize the C runtime for you, and you can't just link against the C library (I did try specifying a dynamic linker earlier, to no avail — the code blew up on a stack function jump, and after banging on it for ages I just couldn't get it working), and so on.
So here's what we can cobble together:
Expand (48 lines)Collapse
#define NOT_API __attribute__((visibility("hidden")))
long NOT_API syscall_write(int fd, const char* buf, unsigned long len) {
long ret;
asm volatile(
"syscall"
: "=a"(ret)
: "a"(1), "D"(fd), "S"(buf), "d"(len) // 1 is sys_write
: "rcx", "r11", "memory");
return ret;
}
void NOT_API syscall_exit(int code) {
asm volatile(
"syscall"
:
: "a"(60), "D"(code) // 60 is sys_exit
: "memory");
}
unsigned long NOT_API ccstrlen(const char* s) {
unsigned long i = 0;
while (s[i])
i++;
return i;
}
int add(int a, int b) {
return a + b;
}
void NOT_API _printf(const char* msg) {
syscall_write(1, msg, ccstrlen(msg));
}
int NOT_API direct_load_helper_main() {
_printf("Hey! Welcome CCLibrary! "
"These is a dynamic library helps math calculations\n");
_printf("Current Version is 0.1.0\n");
_printf("You can process add by using the library!\n");
// Must Call these to remind linux
// to clear the stack
syscall_exit(0);
}Compile it with:
gcc -shared -fPIC -o libcclib.so cclib.c -Wl,-e,direct_load_helper_mainRun it, and there's your result:
[charliechen@Charliechen runaable_dynamic_library]$ ./libcclib.so
Hey! Welcome CCLibrary! These is a dynamic library helps math calculations
Current Version is 0.1.0
You can process add by using the library!If you're curious, you can walk through the same analysis I did above on this library yourself.
So here's the question: can our other executables use this code the way they'd use any other library? Yep. Let's pull the visible add symbol out into a header, cclib.h:
#pragma once
int add(int a, int b);And in main.c, do the usual library-style thing:
#include "cclib.h"
#include <stdio.h>
int main() {
int result = add(1, 2);
printf("Result of 1 + 2 = %d\n", result);
}No sweat at all!
[charliechen@Charliechen runaable_dynamic_library]$ gcc main.c -o main ./libcclib.so
[charliechen@Charliechen runaable_dynamic_library]$ ./main
Result of 1 + 2 = 3Through the Lens of Modern CMake
That gcc -shared -fPIC -Wl,-e,direct_load_helper_main line from this post is something you basically never hand-type in a modern project — you hand it to CMake instead. add_library(cclib SHARED cclib.c) automatically adds -fPIC and produces the .so; the visibility("hidden") symbol-visibility trick maps to set_target_properties(cclib PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON), which CMake turns into -fvisibility=hidden for you. Overriding the entry point (-Wl,-e) is a pretty unusual need, and CMake has no built-in target property to set it directly — you typically feed it to the linker explicitly via target_link_options(cclib PRIVATE "-Wl,-e,direct_load_helper_main"). On the other side, the executable gcc main.c -o main ./libcclib.so becomes add_executable(main main.c) plus target_link_libraries(main PRIVATE cclib), where CMake works out the link paths and -lcclib from the target dependency graph — no more hand-picking -L and -l. Once you understand the underlying ELF entry-point and symbol-visibility mechanics, looking back at these CMake commands, you can see exactly which chunk of the linker's job each one takes off your hands.