深入理解CC++的编译与链接技术(番外):动态库可以像可执行文件那样执行嘛?

我知道有朋友看到这个话题会下意识的发笑,会觉得笔者在胡言乱语。其实,笔者在最最开始的时候,也对这个事情一笑了之,觉得太荒唐。但是实际上,动态库是可以像可执行文件那样执行的。

会有人直接甩给我一个Segment Fault,告诉我你就是在胡言乱语。您可以自行切换到/lib目录下,找一个自己喜欢的库,比如说,笔者看重了libcurl库和libcrypt库,我们可以直接尝试执行它。

[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.0

我们第一个想法是——为什么?为什么事情会变成这样?答案很简单,在后续的博客中,笔者会强调,一般而言,以.so结尾的,一般是动态库(或者说共享库,笔者已经说明了在今天的操作系统中,可以不再刻意的区分共享库和动态库了)

深入理解CC++的编译与链接技术2:动态库静态库导论-CSDN博客

很显然,当我们直接输入文件的绝对地址的时候,操作系统的bash会尝试将它当作一个可独立运行的程序,然而,这个跟我们的动态库的定义:包含一组函数和数据的动态共享组件是不一致的。由于共享库没有设计像普通程序那样的标准主入口点($\text{main}$ 函数),直接运行时,执行流很可能跳转到无效的内存地址。操作系统检测到这种非法内存访问(试图访问程序无权访问的内存区域)时,就会触发段错误。我想很多人看到这里的时候,已经确信我这篇博客中指出:动态库是可以像可执行文件那样执行的这个论点就是错误的。

然而并不是,我们可以再次尝试一下执行C库:

[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>.

嗯?这个事情跟我们想的很不一样。这一次,C库不光没有SegmentFault,还甚至打印出来一段非常具备标识性的字符串并且优雅的退出了!很神秘对不对?没关系,笔者来带你一步一步探究到底发生了什么。

所以,到底怎么回事?

很简单, 我们这样开始——既然这个事情涉及到程序的运行开始,显然熟悉ELF文件格式的朋友就会指出——也许我们的玄机藏在ELF Header指向的地址中,几乎很容易猜到——肯定是libc的ELF Header指向的Entry Point跟咱们的一般的类似于libcurl这样的出于组件目的的库是不一致的。那么,查看ELF头信息的工具,就是大名鼎鼎的readelf工具。

我们需要强调一下ELF格式的一个基本知识——所有 ELF 文件(可执行文件和共享库)都有一个“入口点”,这是 CPU 开始执行指令的地方。或者说,告诉CPU的执行流(X86-64上是EIP或者RIP的值)一个确切的初始值。

[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: 27

呦呵,这下不就真相大白了?如果我们尝试将/lib/libcurl.so视作一个可执行文件处理,那么这个时候,操作系统的加载器读取/lib/libcurl.so并且通过一般的检查后,将跳转地址设置成了0x0,啊哈,这不就访问空指针了嘛?

这就跟我们做这样的事情的性质完全是一样的!

#include <stdio.h>

int main() {
	printf("Jumping to address 0x0...\n");
	void (*func)() = (void (*)())0x0;
	func();
}

编译并且执行它,得到的正好就是:

[charliechen@Charliechen runaable_dynamic_library]$ gcc dump.c -o dump
[charliechen@Charliechen runaable_dynamic_library]$ ./dump 
Jumping to address 0x0...
Segmentation fault         (core dumped) ./dump

那么我们的libc库如何呢?

[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: 63

嗯?还真不一样,不要着急,只有一个0x27830,我们什么也不知道,下一步,就是请出我们的objdump大法看看细节:

会有朋友问我,为什么不是nm,嗯,对于动态库,nm暴露的是对外导出符号的地址,一般而言,你找不到EntryPoint对应的到底是什么。不过不用担心,我们还有一个招数,那就是objdump看反汇编。

[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                      nop

不必太着急,我们现在发动回忆大法,现在我们从0x27834开始,代码试图做这些事情:

x64.syscall.sh,关于Syscall表,我放在这里了

  • 将0x01放入edi,这里放置了第一个系统调用需要的参数

  • 然后将第三个参数放到了edx中,拜托,这不就是字符串的长度嘛?十进制的 483

  • 不要着急,我们稍后还需要放置的是rsi中的字符串地址,也就是第二个参数。注意到的是——指令是 lea (Load Effective Address),它将当前指令后的地址加上偏移量。所以不能直接去找0x18d85a,而是要加上当前指令的偏移量。

    复习一下,objdump是如何算出来1b50a0的?首先,当前指令的基地址在:0x2783f,指令的长度本身是48 8d 35 5a d8 18 00 共 7 个字节。所以下一条指令在0x2783f + 7 = 0x27846。加上所给出的偏移地址,那就是——0x27846 + 0x18d85a = 0x1b50a0。OK,我们确信了objdump没有骗我们(大概率他当然不会!)

想要查看是不是真放的?

[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                                          |>..|
001b5283

足够了!后面的分析,显然就是将0作为exit的参数放置到edi中,并且优雅的退出了。

我们可以干这档事情嘛?

拜托!当然可以啊!现在笔者就陪你干一票!但是会有点难,因为我们现在不可能依赖libc库,因为动态库的初始化跟咱们的可执行程序有不一致的地方,比如说不会主动的初始化CRunTime,没办法主动链接C库(当然笔者之前做dynamic linker指定过,发现没有用,而且代码崩在了stack函数跳转上,有点无能为力了,搞半天没搞定)等等。

所以,现在我们可以搞一处了:

#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);
}

编译这段代码:

gcc -shared -fPIC -o libcclib.so cclib.c -Wl,-e,direct_load_helper_main

执行一下,就能得到结果了!

[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!

感兴趣的读者可以仿照笔者之前的分析重新走一遍流程。

那么问题来了,我们其他的可执行程序,可以像使用库那样,使用这个代码嘛?可以的。我们稍微把可见的add符号挪出来一个头文件:cclib.h

#pragma once

int add(int a, int b);

并且在main.c中像我们一般的库编程一样做这个事情:

#include "cclib.h"
#include <stdio.h>

int main() {
	int result = add(1, 2);
	printf("Result of 1 + 2 = %d\n", result);
}

毫无压力!

[charliechen@Charliechen runaable_dynamic_library]$ gcc main.c -o main ./libcclib.so 
[charliechen@Charliechen runaable_dynamic_library]$ ./main 
Result of 1 + 2 = 3