动态链接库的加载(翻译)

程序员小x大约 15 分钟LinuxELF文件动态库

动态链接库的加载(翻译)

什么是动态库

library(函数库)是一个包含编译后的代码和数据的文件。 library大多情况下是非常有用的,因为它们可以让编译过程更加的快速,因为你不必每次编译所有依赖的文件,这使得我们可以进行模块化开发。静态库可以被链接到一个已经编译好的可执行文件中或者其他库中,在编译之后,静态库中的内容就被嵌入到了可执行文件或者其他库当中。动态库则是可执行文件在运行时刻才被加载的,这意味着这样的过程将会更加的复杂。

搭建demo

为了更好的探索动态链接库,我们使用一个例子贯穿整文。 我们以三个源文件开始。

main.cpp 是可执行文件的入口。 这个文件中不会包含太多的内容,仅仅调用了一个random库中的方法。

#include "random.h"

int main() {
    return get_random_number();
}

random库只定义了一个函数,random.h的内容如下所示:

int get_random_number();

get_random_number的实现在random.cpp文件中定义:

#include "random.h"

int get_random_number(void) {
    return 4;
}

编译动态库

在创建动态库之前,首先创建.o文件,称之为对象文件。

clang++ -o random.o -c random.cpp

上面的命令的参数含义如下:

  • -o random.o: 定义输出文件的名字
  • -c: 仅仅编译,不做链接
  • random.cpp: 选择输入的文件名

接下来将.o文件生成动态库:

clang++ -shared -o librandom.so -c random.o

-shared指定了生成的库的类型是动态库。注意,我们将动态库的名称是libramdom.soopen in new window。这个名字并不能随意取,需要按照lib<name>.so格式来命名,这个名称后续在链接的时候会用到。

编译可执行文件使用动态库

首先为main.cpp创建目标文件main.o

clang++ -o main.o -c main.cpp

这个步骤和之前创建random.o是一样的。 现在尝试创建可执行文件。

clang++ -o main main.o
main.o: In function `main':
main.cpp:(.text+0x10): undefined reference to `get_random_number()'
clang: error: linker command failed with exit code 1 (use -v to see invocation)

我们需要告诉clang去链接librandom.soopen in new window,使用下面的语句操作:

$ clang++ -o main main.o -lrandom
/usr/bin/ld: cannot find -lrandom
clang: error: linker command failed with exit code 1 (use -v to see invocation)

我们告诉编译器去使用librandom.soopen in new window。 既然是运行时才会加载,为什么编译的时候就需要指定呢?

这是因为我们需要确保动态库中包含了可执行文件所需要的所有符号。 另请注意,我们指定 random 作为库的名称,而不是 librandom.soopen in new window。还记得关于库文件命名的约定吗?这就是它的用处。

上面的输出提示我们找不到random库,原因是我们没有指定动态库的路径。我们可以使用-L参数。 注意到这个这个参数仅仅制定了编译时动态库的路径,并不是指定了运行时的加载路径。 指定当前路径作为动态库的搜索路径,如下所示:

$ clang++ -o main main.o -lrandom -L.

太棒了,我们成功的编译出了可执行文件。

下面让我们运行它。

$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory 

这个问题是我们的依赖无法被找到。它甚至会在我们的应用程序运行第一行代码之前发生,因为动态库是在可执行文件中的符号之前加载的。

由此,引出了几个问题:

为了回答这些问题,我们必须更深入地了解这些文件的结构。

ELF - 可执行和可链接格式

动态库和可执行文件格式称为ELF(可执行和可链接格式)。如果您查看维基百科文章,您会发现它一团糟,因此我们不会详细介绍所有内容。总之,ELF 文件包含:

  • ELF头
  • 文件数据,包含
    • 程序头表 (a list of segmemt headers)
    • 节头表 (a list of section headers)
    • 数据 (由段表和节表指向)

ELF头中指出了程序头表中段的大小和数量,也指出了节头表中节的大小和数量。每个这样的表都由固定大小的条目组成。条目中包含了头部和指定数据的指针组成。一个段会包含多个节。

实际上,根据当前上下文,相同的数据被引用为段的一部分或部分。链接时使用节,执行时使用段。

段表和节表
段表和节表

段(segment)和节(section)的关联如下图所示:

段表和节表的区别
段表和节表的区别

我们可以使用readelf工具去查看elf文件的内容,例如main的内容如下所示:

$ readelf -h main
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:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x4005e0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          4584 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 27

我们看到main的类型是EXEC,代表其是一个可执行文件。main有9个程序头表(段表)和30个节表。

接下来查看段表节表

$ readelf -l main

Elf file type is EXEC (Executable file)
Entry point 0x4005e0
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP         0x0000000000000238 0x0000000000400238 0x0000000000400238
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x000000000000089c 0x000000000000089c  R E    200000
  LOAD           0x0000000000000dd0 0x0000000000600dd0 0x0000000000600dd0
                 0x0000000000000270 0x0000000000000278  RW     200000
  DYNAMIC        0x0000000000000de8 0x0000000000600de8 0x0000000000600de8
                 0x0000000000000210 0x0000000000000210  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_EH_FRAME   0x0000000000000774 0x0000000000400774 0x0000000000400774
                 0x0000000000000034 0x0000000000000034  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000dd0 0x0000000000600dd0 0x0000000000600dd0
                 0x0000000000000230 0x0000000000000230  R      1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07     
   08     .init_array .fini_array .jcr .dynamic .got 

同样,我们看到我们有9个段。它们的类型是LOAD, DYNAMIC,NOTE等。我们还可以看到每个节属于哪个段。 操作系统加载elf文件时,只会加载类型时LOAD的段。

最后我们看下节表:

$ readelf -S main
There are 30 section headers, starting at offset 0x11e8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400238  00000238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             0000000000400254  00000254
       0000000000000020  0000000000000000   A       0     0     4

  [..]

  [21] .dynamic          DYNAMIC          0000000000600de8  00000de8
       0000000000000210  0000000000000010  WA       6     0     8

  [..]

  [28] .symtab           SYMTAB           0000000000000000  00001968
       0000000000000618  0000000000000018          29    45     8
  [29] .strtab           STRTAB           0000000000000000  00001f80
       000000000000023d  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

为了简洁起见,我修剪了这一点。我们看到列出的 30 个部分具有不同的名称(例如 .note.ABI-tag)和类型(例如 SYMTAB)。

你现在可能很困惑。别担心——它不会出现在测试中。我解释这一点是因为我们对此文件的特定部分感兴趣:在其程序头表中,ELF 文件可以具有(尤其是动态库必须具有)描述 PT_DYNAMIC 类型段的段头。该段拥有一个名为.dynamic 的节,其中包含了解动态依赖关系的有用信息。

直接依赖

我们可以使用 readelf 实用程序来进一步探索可执行文件的 .dynamic 部分2。特别是,此部分包含 ELF 文件的所有动态依赖项。我们只指定 librandom.soopen in new window 作为依赖项,因此我们希望只列出一个依赖项:

$ readelf -d main | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [librandom.so]
 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

我们可以看到我们指定的 librandom.soopen in new window,但我们还获得了四个我们没有预料到的额外依赖项。这些依赖项似乎出现在所有已编译的动态库中。这些是什么?

  • libstdc++:标准 C++ 库。
  • libm:包含基本数学函数的库。
  • libgcc_s:GCC(GNU 编译器集合)运行时库。
  • libc:C 库:定义"系统调用"和其他基本设施(例如 open、malloc、printf、exit 等)的库。

现在我们知道编译好的可执行文件main 知道它依赖于 librandom.soopen in new window。那么为什么 main 在运行时找不到 librandom.soopen in new window 呢?

运行时搜索路径

ldd 是一个允许我们查看递归动态库依赖关系的工具。这意味着我们可以看到工件在运行时所需的所有动态库的完整列表。它还允许我们查看这些依赖项所在的位置。让我们在 main 上运行它,看看会发生什么:

$ ldd main
	linux-vdso.so.1 =>  (0x00007fff889bd000)
	librandom.so => not found
	libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f07c55c5000)
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f07c52bf000)
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f07c50a9000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f07c4ce4000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f07c58c9000)

我们看到librandom.so在列表中出现了,但是状态是未找到。我们还可以看到有两个额外的库(vdso 和 ld-linux-x86-64)。它们是间接依赖关系。

更重要的是,我们看到 ldd 报告了库的位置。例如libstdc++.so.6,ldd 报告其位置为 /usr/lib/x86_64-linux-gnu/libstdc++.so.6。它是怎么知道的?

我们的依赖项中的每个动态库都会按顺序在以下位置进行搜索:

  • 可执行文件的 rpath 中列出的目录。
  • LD_LIBRARY_PATH 环境变量中的目录,其中包含以冒号分隔的目录列表(例如,/path/to/libdir:/another/path)
  • 可执行文件 runpath 所指定的目录。
  • 文件 /etc/ld.so.conf 中的目录列表。该文件可以包含其他文件,但它基本上是一个目录列表 - 每行一个。
  • 默认系统库 - 通常为 /lib 和 /usr/lib (如果使用 -z nodefaultlib 编译则跳过)。

修复我们的可执行文件

好吧。我们验证了 librandom.soopen in new window 是列出的依赖项,但找不到它。我们知道在哪里搜索依赖项。我们将再次使用 ldd 确保我们的目录实际上不在搜索路径上。

$ LD_DEBUG=libs ldd main
      [..]

      3650:	find library=librandom.so [0]; searching
      3650:	 search cache=/etc/ld.so.cache
      3650:	 search path=/lib/x86_64-linux-gnu/tls/x86_64:/lib/x86_64-linux-gnu/tls:/lib/x86_64-linux-gnu/x86_64:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu/tls/x86_64:/usr/lib/x86_64-linux-gnu/tls:/usr/lib/x86_64-linux-gnu/x86_64:/usr/lib/x86_64-linux-gnu:/lib/tls/x86_64:/lib/tls:/lib/x86_64:/lib:/usr/lib/tls/x86_64:/usr/lib/tls:/usr/lib/x86_64:/usr/lib		(system search path)
      3650:	  trying file=/lib/x86_64-linux-gnu/tls/x86_64/librandom.so
      3650:	  trying file=/lib/x86_64-linux-gnu/tls/librandom.so
      3650:	  trying file=/lib/x86_64-linux-gnu/x86_64/librandom.so
      3650:	  trying file=/lib/x86_64-linux-gnu/librandom.so
      3650:	  trying file=/usr/lib/x86_64-linux-gnu/tls/x86_64/librandom.so
      3650:	  trying file=/usr/lib/x86_64-linux-gnu/tls/librandom.so
      3650:	  trying file=/usr/lib/x86_64-linux-gnu/x86_64/librandom.so
      3650:	  trying file=/usr/lib/x86_64-linux-gnu/librandom.so
      3650:	  trying file=/lib/tls/x86_64/librandom.so
      3650:	  trying file=/lib/tls/librandom.so
      3650:	  trying file=/lib/x86_64/librandom.so
      3650:	  trying file=/lib/librandom.so
      3650:	  trying file=/usr/lib/tls/x86_64/librandom.so
      3650:	  trying file=/usr/lib/tls/librandom.so
      3650:	  trying file=/usr/lib/x86_64/librandom.so
      3650:	  trying file=/usr/lib/librandom.so

      [..]

我修剪了输出,因为它非常的喋喋不休。难怪我们的动态库librandom.so找不到,因为其​​所在的目录不在搜索路径中!解决这个问题最特别的方法是使用 LD_LIBRARY_PATH

$ LD_LIBRARY_PATH=. ./main

它可以工作,但不太方便。我们不想每次运行程序时都指定 lib 目录。更好的方法是将我们的依赖项放入文件中。

rpath和runpath可以帮助我们。

rpath和runpath

rpathrunpath 是我们的运行时搜索路径"清单"中最复杂的项目。可执行文件或动态库的 rpathrunpath 是**.dynamic**节中的可选内容。它们都是要搜索的目录列表。

rpath 和 runpath 之间的唯一区别是它们的搜索顺序。具体来说,它们的区别是和 LD_LIBRARY_PATH 的关系, rpath 在 LD_LIBRARY_PATH 之前搜索,而 runpath 在之后搜索。LD_LIBRARY_PATH的改变不会影响rpath的搜索,而会影响 runpath,如果LD_LIBRARY_PATH包含了动态库,则直接去LD_LIBRARY_PATH的目录中加载,而不会继续去runpath的目录中寻找。

让我们将 rpath 添加到可执行文件中,看看是否可以让它工作:

$ clang++ -o main main.o -lrandom -L. -Wl,-rpath,.

-Wl 标志将以下以逗号分隔的标志传递给链接器。在本例中,我们传递 -rpath ., 要设置runpath,我们还必须传递 --enable-new-dtags。 让我们检查一下结果:

$ readelf main -d | grep path
 0x000000000000000f (RPATH)              Library rpath: [.]

$ ./main

可执行文件运行,但这添加了 .到 rpath,即当前工作目录。这意味着它无法在不同的目录中工作:

$ cd /tmp
$ ~/code/shared_lib_demo/main
/home/nurdok/code/shared_lib_demo/main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory

我们有几种方法来解决这个问题。最简单的方法是将 librandom 复制到我们搜索路径中的目录(例如 /lib)。显然,我们要做的更复杂的方法是指定相对于可执行文件的 rpath。

$ORIGIN

rpath 和 runpath 中的路径可以是绝对路径(例如,/path/to/my/libs/),相对于当前工作目录(例如,.),但它们也可以是相对于可执行文件的路径。这是通过在 rpath 定义中使用 $ORIGIN 变量来实现的:

$ clang++ -o main main.o -lrandom -L. -Wl,-rpath,"\$ORIGIN" 

请注意,我们需要转义美元符号(或使用单引号),以便我们的 shell 不会尝试扩展它。结果是 main 在每个目录中工作并正确找到 librandom.soopen in new window

$ ./main
$ cd /tmp
$ ~/code/shared_lib_demo/main

让我们使用我们的工具包来确保:

$ readelf main -d | grep path
 0x000000000000000f (RPATH)              Library rpath: [$ORIGIN]

$ ldd main
	linux-vdso.so.1 =>  (0x00007ffe13dfe000)
	librandom.so => /home/nurdok/code/shared_lib_demo/./librandom.so (0x00007fbd0ce06000)
	[..]

运行时搜索路径:安全性

如果您曾经从命令行更改过 Linux 用户密码,则可能使用过 passwd 实用程序:

$ passwd
Changing password for nurdok.
(current) UNIX password: 
Enter new UNIX password: 
Retype new UNIX password: 
passwd: password updated successfully

密码哈希值存储在 /etc/shadow 中,该文件受 root 保护。那么,您可能会问,您的非 root 用户如何更改该文件?

答案是 passwd 程序设置了 setuid 位,您可以使用 ls 看到:

$ ls -l `which passwd`
-rwsr-xr-x 1 root root 39104 2009-12-06 05:35 /usr/bin/passwd
#  ^--- This means that the "setuid" bit is set for user execution.

它是 s(该行的第四个字符)。所有设置了此权限位的程序都作为该程序的所有者运行。在此示例中,用户是 root(该行的第三个单词)。

这与动态库有什么关系?”你问。我们将通过一个例子来了解。

现在,我们将在 main 旁边的 libs 目录中添加 librandom,并将 $ORIGIN/libs 添加到 main 的 rpath 中:

$ ls
libs  main
$ ls libs
librandom.so
$ readelf -d main | grep path
 0x000000000000000f (RPATH)              Library rpath: [$ORIGIN/libs]

如果我们运行 main,它会按预期工作。让我们打开主可执行文件的 setuid 位并使其以 root 身份运行

$ sudo chown root main
$ sudo chmod a+s main
$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory

好吧,rpath 不起作用。让我们尝试设置 LD_LIBRARY_PATH。

$ LD_LIBRARY_PATH=./libs ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory

这里发生了什么?

出于安全原因,当使用提升的权限(例如 setuid、setgid、特殊功能等)运行可执行文件时,搜索路径列表与正常情况不同:LD_LIBRARY_PATH 被忽略,以及 rpath 或 runpath 中包含$ORIGIN的路径。

原因是使用这些搜索路径允许利用提升权限的可执行文件以 root 身份运行。有关此漏洞的详细信息可以在此处open in new window找到。基本上,它允许您使提升的权限可执行文件加载您自己的库,该库将以 root(或其他用户)身份运行。以 root 身份运行您自己的代码几乎可以让您完全控制您正在使用的机器。

如果您的可执行文件需要具有提升的权限,则需要在绝对路径中指定依赖项,或将它们放置在默认位置(例如 /lib)。

这里需要注意的一个重要行为是,对于此类应用程序,ldd 当着我们的面撒谎:

% ldd main
	linux-vdso.so.1 =>  (0x00007ffc2afd2000)
	librandom.so => /home/nurdok/code/shared_lib_demo/libs/librandom.so (0x00007f1f666ca000)
	libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f1f663c6000)
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f1f660c0000)
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f1f65eaa000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1f65ae5000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f1f668cc000)

ldd 不关心 setuid,它在搜索我们的依赖项时会扩展 $ORIGIN。在调试 setuid 应用程序的依赖项时,这可能是一个相当大的陷阱。

调试备忘录

如果您在运行可执行文件时遇到此错误:

$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory 

您可以尝试执行以下操作:

  • 找出 ldd <executable> 缺少哪些依赖项。
  • 如果您无法识别它们,可以通过运行 readelf -d <executable> |grep NEEDED 检查它们是否是直接依赖项。
  • 确保依赖项确实存在。也许您忘记编译它们或将它们移动到 libs 目录?
  • LD_DEBUG=libs ldd <executable> 找出在何处搜索依赖项。
  • 如果需要将目录添加到搜索中:
    • 将目录添加到 LD_LIBRARY_PATH 环境变量中。
    • 使用rpath或者runpath:通过传递 -Wl,-rpath,<dir>(对于 rpath)或 -Wl,--enable-new-dtags,-rpath,<dir> 将目录添加到可执行文件或动态库的 rpath 或 runpath (对于运行路径)。使用 $ORIGIN 作为相对于可执行文件的路径。
  • 如果 ldd 显示没有缺少任何依赖项,请查看您的应用程序是否具有提升的权限。如果是这样,ldd可能会撒谎。请参阅上面的安全问题。

如果您仍然无法弄清楚 - 您需要再次阅读整篇文章:

参考文章

https://amir.rachum.com/shared-libraries/open in new window

Loading...