[置顶] 泰晓 RISC-V 实验箱,配套 30+ 讲嵌入式 Linux 系统开发公开课
深度剖析 Linux 共享库的“位置无关”实现原理
By Falcon of TinyLab.org Nov 09, 2019
背景简介
如何创建一个可执行的共享库 一文谈完了如何让共享库可直接执行,本文再来谈谈共享库的运行时位置无关(PIC)是如何做到的。
PIC = position independent code
-fpic Generate position-independent code (PIC) suitable for use in a shared library
共享库有一个很重要的特征,就是可以被多个可执行文件共享,以达到节省磁盘和内存空间的目标:
- 共享意味着不仅磁盘上只有一份拷贝,加载到内存以后也只有一份拷贝,那么代码部分在运行时也不能被修改,否则就得有多个拷贝存在
- 同时意味着,需要能够灵活映射在不同的虚拟地址空间,以便适应不同程序,避免地址冲突
这两点要求共享库的代码和数据都是位置无关的,接下来先看看什么是“位置无关”。
什么是位置无关
同样以 hello.c 为例:
#include <stdio.h>
int main(void)
{
printf("hello\n");
return 0;
}
以普通的方式来编译并反汇编一个可执行文件看看:
$ gcc -m32 -o hello hello.c
$ objdump -d hello | grep -B1 "call.*puts@plt>"
8048416: 68 b0 84 04 08 push $0x80484b0
804841b: e8 c0 fe ff ff call 80482e0 <puts@plt>
可以看到上面传递给 puts
(printf)的字符串地址是“写死的”,在编译时就是确定的,这意味着 Load Address 也必须是固定的:
$ readelf -l hello | grep LOAD | head -1
LOAD 0x000000 0x08048000 0x08048000 0x005b0 0x005b0 R E 0x1000
上面可以看到 Load Address 为 0x8048000。
如果 Load Address 改变,数据地址就指向别的内容了,这就是“位置有关”。
共享库的话,必须摒弃这种“写死的”地址,要做到“位置无关”(注:prelink 是特殊需求,暂且不表)。
如何做到位置无关(Part1)
位置无关,意味着运行时可以灵活调整 Load Address,当 Load Address 在运行时发生改变后,代码还能被执行到,数据也能被正确访问。
那么代码和数据都变成跟 Load Address 相关的,不能再是绝对地址,而需要采用某个相对 Load Address 的地址。
动态链接器会负责找到可执行文件的共享库并装载它们,所以动态链接器是知道这个 Load Address 的,那么函数符号其实是很容易确定的,来看看不带 -fpic
时编译生成一个共享库:
- 查看
main
函数的初始地址
$ gcc -m32 -shared -o libhello.so hello.c
$ objdump -d libhello.so | grep -A 2 "main>:"
000004a9 <main>:
4a9: 8d 4c 24 04 lea 0x4(%esp),%ecx
4ad: 83 e4 f0 and $0xfffffff0,%esp
- 查看“装载地址”,编译后初始化为 0
$ readelf -l libhello.so | grep LOAD | head -1
LOAD 0x000000 0x00000000 0x00000000 0x0057c 0x0057c R E 0x1000
- 确认
main
在文件中的偏移
$ readelf --dyn-syms libhello.so | grep m
Symbol table '.dynsym' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
4: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
9: 000004a9 46 FUNC GLOBAL DEFAULT 11 main
$ hexdump -C -s $((0x4a9)) -n 10 libhello.so
000004a9 8d 4c 24 04 83 e4 f0 ff 71 fc |.L$.....q.|
000004b3
可以看到,对于 main
而言,无论把共享库装载到哪里,动态链接器总能根据 Load Address 以及 .dynsym
中的偏移把 main
的运行时地址算出来(见 glibc:_dl_fixup
)。
但是,这个时候(不用 -fpic
的话),数据地址和 puts 的地址也是“写死的”(实际上要到运行时才通过动态重定位确定运行时地址):
$ objdump -d libhello.so | grep -B1 "call.*main"
4bd: 68 ec 04 00 00 push $0x4ec
4be: R_386_RELATIVE *ABS*
4c2: e8 fc ff ff ff call 4c3 <main+0x1a>
4c3: R_386_PC32 puts@GLIBC_2.0
4c7: 83 c4 10 add $0x10,%esp
作为对比,来看看加上 -fpic
的效果:
$ gcc -m32 -shared -fpic -o libhello.so hello.c
$ objdump -dr libhello.so | grep -B6 "call.*puts@plt>"
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d2: 83 ec 0c sub $0xc,%esp
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
4db: 52 push %edx
4dc: 89 c3 mov %eax,%ebx
4de: e8 bd fe ff ff call 3a0 <puts@plt>
可以看到,用上 -fpic
以后,传递给 puts 的数据地址(push %edx
)已经是通过动态计算的,那是怎么算的呢?
上面有个内联进来的函数很关键:
$ objdump -dr libhello.so | grep -A3 "__x86.get_pc_thunk.ax>:"
000004f5 <__x86.get_pc_thunk.ax>:
4f5: 8b 04 24 mov (%esp),%eax
4f8: c3 ret
这个函数贼简单,从栈顶取了一个数据就跳回去了,取的数据是什么呢?这就要了解调用它的 call
指令了。
call
指令会把下一条指令的 eip
压栈然后 jump 到目标地址:
call backward ==> push eip;
jmp backward
所以,数据地址是运行时计算的,跟运行时的 “eip” 给关联上了。
不难猜测,如果知道当前指令的位置,又提前保存了数据离当前位置的偏移,那么数据地址是可以直接计算的,只是上面那一段代码还是略微复杂了,因为有一堆 “Magic Number”。
不管怎么样,先来模拟计算一下,假设装载到的地址就是 0x0,那么执行到 add
指令时存到 eax 的 eip,恰好是 call
返回后下一条指令的地址,即 0x4cd:
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
根据上述指令,那么 %edx
计算出来就是 0x510:
$ echo "obase=16;$((0x4cd+0x1b33-0x1af0))" | bc
510
再去取数据:
$ hexdump -C -s $((0x510)) -n 10 libhello.so
00000510 68 65 6c 6c 6f 00 00 00 01 1b |hello.....|
0000051a
果然是字符串的地址,所以,相对偏移其实被拆分成了两部分:0x1b33
和 -0x1af0
。两个 “Magic Number” 一加就出来了。
所以,小结一下,“位置无关” 是通过运行时动态获取 “eip” 并加上一个编译时记录好的偏移计算出来的,这样的话,无论加载到什么位置,都能访问到数据。
如何做到位置无关(Part2)
这对 “Magic Number” 还是需要再看一看,既然是编译时确定的,看看汇编状态是怎么回事:
$ gcc -m32 -shared -fpic -S hello.c
$ cat hello.s | grep -v .cfi
...
.LC0:
.string "hello"
.text
.globl main
.type main, @function
main:
.LFB0:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ebx
pushl %ecx
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
subl $12, %esp
leal .LC0@GOTOFF(%eax), %edx
pushl %edx
movl %eax, %ebx
call puts@PLT
...
从 i386 的 archABI 不难找到这块的定义(P61~P62),name@GOTOFF(%eax)
直接表示 name 符号相对 %eax 保存的 GOT 的偏移地址。
首先,编译时要计算 $_GLOBAL_OFFSET_TABLE
和 .LC0@GOTOFF
。
$_GLOBAL_OFFSET_TABLE_
为 GOT 相对 eip
的偏移,可计算为:
$GLOBAL_OFFSET_TABLE = .got.plt - eip
计算过程如下:
$ readelf -S libhello.so | grep .got.plt
[21] .got.plt PROGBITS 00002000 001000 000010 04 WA 0 0 4
$ echo "obase=16;$((0x2000-0x4cd))" | bc
1B33
接着,计算 .LC0@GOTOFF
:
.LC0 - eip = $GLOBAL_OFFSET_TABLE + .LC0@GOTOFF .LC0@GOTOFF = .LC0 - eip - $GLOBAL_OFFSET_TABLE
计算过程如下:
$ echo "obase=16;$((0x510-0x4cd-0x1B33))" | bc
-1AF0
反过来,运行时的计算公式为:
.LC0 = $GLOBAL_OFFSET_TABLE + .LC0@GOTOFF + eip .LC0 = 0x1B33 + (-1AF0) + eip
.got.plt = $GLOBAL_OFFSET_TABLE + eip .got.plt = 0x1B33 + eip
实际上,只有 .got.plt 的地址,即 ebx
需要 $_GLOBAL_OFFSET_TABLE_
来计算,这个是用来做动态地址重定位的,暂且不表。
.LC0
的地址,完全可以换一种方式,直接用 .LC0
到 eip 的偏移即可,汇编代码改造完如下:
call __x86.get_pc_thunk.ax
.eip:
# 计算 eip + (.LC0 - .eip) 刚好指向内存中的数据 "hello" 所在位置
movl %eax, %ebx
leal (.LC0 - .eip)(%eax), %edx
# 计算 .got.plt 地址,_GLOBAL_OFFSET_TABLE_ 是相对 eip 的偏移,所以必须加上这个 offset:. - .eip
addl $_GLOBAL_OFFSET_TABLE_ + [. - .eip], %ebx
subl $12, %esp
pushl %edx
call puts@PLT
验证结果:
$ gcc -m32 -g -shared -fpic -o libhello.so hello.s
$ gcc -m32 -g -o hello.noc -L./ -lhello
$ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc
hello
小结
本文详细介绍了 Linux 下 C 语言共享库“位置无关”(PIC)的核心实现原理:即用 EIP 相对地址来取代绝对地址。
“位置无关” 代码会带来很大的内存使用灵活性,也会带来一定的安全性,因为“位置无关”以后就可以带来加载地址的随机性,给代码注入带来一定的难度。
由于有上述好处,各大平台的 gcc 都开始默认打开可执行文件的 -pie -fpie
了,因为 gcc 编译时开启了:--enable-default-pie
。这也可能导致一些“衰退”,大家可以根据需要关闭它:-no-pie
,-fno-pie
。
当然,共享库的实现精髓不止于此,最核心的还是函数符号地址的动态解析过程,而这些则跟上面的 .got.plt
地址密切相关,受限于篇幅,暂时不做详细展开。
如果想进一步了解共享库动态链接的工作原理,进一步了解 .got.plt
并理解相关的过程链接表、全局偏移表的工作机制,欢迎订阅吴老师的 10 小时 C 语言进阶视频课:《360° 剖析 Linux ELF》。
猜你喜欢:
- 我要投稿:发表原创技术文章,收获福利、挚友与行业影响力
- 知识星球:独家 Linux 实战经验与技巧,订阅「Linux知识星球」
- 视频频道:泰晓学院,B 站,发布各类 Linux 视频课
- 开源小店:欢迎光临泰晓科技自营店,购物支持泰晓原创
- 技术交流:Linux 用户技术交流微信群,联系微信号:tinylab
支付宝打赏 ¥9.68元 | 微信打赏 ¥9.68元 | |
请作者喝杯咖啡吧 |