linux user pwn 基础知识
环境搭建
虚拟机安装
- 镜像下载网站
- 为了避免环境问题建议 22.04 ,20.04,18.04,16.04 等常见版本 ubuntu 虚拟机环境各准备一份。注意定期更新快照以防意外。
- 虚拟机建议硬盘 256 G 以上,内存也尽量大一些。硬盘大小只是上界,256 G 不是真就占了 256 G,而后期如果硬盘空间不足会很麻烦。
- 更换 ubuntu 镜像源 ,建议先在
系统设置 → Software & Updates → Download from → 选择国内服务器例如阿里云
(貌似不这样后续换源会出错),然后再sudo gedit /etc/apt/sources.list
将镜像源中不高于当前系统版本的镜像复制进去(高于当前系统版本容易把apt
搞坏)。 - Ubuntu 换源 error:The following signatures couldn’t be verified because the public key is not available 解决方法:
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 5523BAEEB01FA116
其中的5523BAEEB01FA116
是根据错误提示写的。
基础工具
net-tools
ifconfig
查看网络配置需要安装 net-tools
。
1 | sudo apt install net-tools |
vim
1 | sudo apt install vim |
gedit
不习惯 vim 的可以使用 gedit 文本编辑器。
1 | sudo apt install gedit |
git
1 | sudo apt install git |
gcc
1 | sudo apt install gcc |
python
ipython 提供了很好的 python 交互命令行,建议安装。
1 | sudo apt install python2 |
另外有的版本 ubuntu 的不好安装 pip2 可以使用 get-pip.py
脚本安装。
1 | sudo apt install python3-pip |
ubuntu 22.04 的 ipython(python2)必须使用 pip2 安装:
1 | sudo pip2 install ipython |
docker
1 | sudo apt install docker.io |
默认情况下,Docker 命令需要使用 sudo 权限才能运行,这是因为 Docker 守护进程以 root 用户身份运行。然而,你可以通过以下步骤将当前用户添加到 Docker 用户组,从而允许在不使用 sudo 的情况下运行 Docker 命令:
确保当前用户属于
docker
组:运行以下命令检查当前用户是否已添加到 docker 组:1
groups
在输出的组列表中查找
docker
。如果没有找到docker
组,请继续下一步。将当前用户添加到
docker
组:运行以下命令将当前用户添加到docker
组中(将<username>
替换为你的用户名):1
sudo usermod -aG docker <username>
更新用户组更改:运行以下命令使用户组更改生效:
1
newgrp docker
重新登录或重启系统:要使用户组更改永久生效,你需要注销当前会话并重新登录,或者重启系统。
oh-my-zsh
安装 zsh
1 | sudo apt install zsh |
安装 oh-my-zsh
1 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" |
设置 zsh 为默认 shell(重启虚拟机后生效)
1 | chsh -s /bin/zsh |
安装 oh-my-zsh 插件 zsh-autosuggestions ,zsh-syntax-highlighting
1 | git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions |
编辑 ~/.zshrc
添加插件:
1 | plugins=( |
更新:
1 | omz update |
pwn 相关工具
clion
clion 是一款 C\C++ 的 IDE ,可以用来阅读 glibc 源码的工具,这款工具对宏展开,符号跳转,结构体大小以及成员偏移计算都有很好的支持。这款软件需要付费使用,不过可以某宝搞一个教育邮箱。
首先用打开 debug_glibc 解压后的 glibc 源码,这里有以下几点需要注意:
- 源码在对应版本的
source
目录下。 - 最好不要使用解压到默认
\glibc
路径下的源码,因为源码调试与行号绑定,阅读源码可能会修改到源码。 - 这里用
debug_glibc
中的源码是因为这里的源码是编译过的,clion 分析代码需要编译的配置文件。
然后这里我们看到 Makefile 没有正确导入:
在较新版本的 clion 中位于 source
根目录下的 autoreconf
的配置文件 configure.ac
配置有问题,需要改成以下内容(这个主要看版本,有时默认的就好使):
1 | GLIBC_PROVIDES dnl See aclocal.m4 in the top level source directory. |
另外还需要右键 Makefile 设置在命令后面添加 --disable-sanity-checks
。另外构建目标要填 all
,否则 clion 分析的源码的不全。
完整预配置命令如下:
1 |
|
之后右键重新加载 Makefile 项目。
不勾选清理项目。
如果最后这样说明导入成功,之后耐心等待项目导入完毕即可。
gdb
1 | sudo apt-get install gdb gdb-multiarch |
pwntools
注意我这里的 pwntools 是 python2 版本的,需要指定为 4.9.0 ,因为高版本的 pwntools 已经不支持 python2 了(具体来说是高版本的 pwntools 必须依赖 unicorn 2.x.x ,而 unicorn 2.x.x 只支持 python3)。
1 | pip install pwntools==4.10.0 -i https://pypi.tuna.tsinghua.edu.cn/simple |
如果已经装了 pwntools 需要先卸载干净再重新安装,否则更改版本无效(最好不带 sudo
也来一遍确保卸载干净)。
1 | sudo pip2 uninstall pwntools |
这样安装的 pwntools 的 plt 功可能无法正常使用,需要手动安装 Unicorn 库。
1 | pip install unicorn==1.0.3 -i https://pypi.tuna.tsinghua.edu.cn/simple |
当然这样做的代价是一些特殊架构老版本的 pwntools 不支持,这时候最好换 python3 的 pwntools 。
gdb 插件
主要有 pwndbg,peda,gef ,这里我常用的是 pwndbg 。对于一些版本过于古老导致环境装不上的可以尝试一下 peda 。
先将三个项目的代码都拉取下来。
1 | git clone https://github.com/longld/peda.git |
pwndbg 需要运行初始化脚本。
1 | cd pwndbg |
另外还有一个 pwngdb 插件在调试多线程堆(heapinfoall
命令)的时候很有用,建议安装。
1 | git clone https://github.com/scwuaptx/Pwngdb.git |
gdb 在启动的时候会读取当前用户的主目录的 .gdbinit
文件进行 gdb 插件的初始化,这里提供一个配置方案。
1 | source /home/sky123/tools/pwndbg/gdbinit.py |
注意
- 以普通用权限和管理员权限启动 gdb 时读取的
.gdbinit
文件的路径是不同的,普通权限读取的是/home/<username>/.gdbinit
而管理员权限读取的是/root/.gdbinit
。 - 上述配置方案是为了使用
Pwngdb
插件,而该插件唯一的作用就是打印每个线程的对应的tcache
,而实际上这个库已经很久不维护了,因此建议还是直接source /home/sky123/tools/pwndbg/gdbinit.py
。
pwndbg 安装 ghidra 插件可以支持代码反编译(虽然没啥用 )
安装
r2pipe
库1
pip3 install r2pipe
下载安装 radere2 项目
1
2
3git clone https://github.com/radareorg/radare2.git
cd radare2
sudo sys/install.sh下载编译安装 r2ghidra 项目
1
2
3
4
5
6git clone https://github.com/radareorg/r2ghidra.git
cd r2ghidra
sudo ./preconfigure
sudo ./configure
sudo make -j16
sudo make install
gadget 搜索工具
ROPgdbget
安装:
1 | git clone https://github.com/JonathanSalwan/ROPgadget.git |
使用:
1 | ROPgadget --binary ntdll.dll > rop |
有时候 ROPgadget
会出现如下报错:
1 | ROPgadget --binary init_60D_fwf > rop |
此时需要重新安装 capstone
:
1 | sudo pip uninstall capstone |
如果出现这个报错:
1 | ➜ ~ ROPgadget |
这里需要将 ROPGadget
安装目录下的 script
目录拷贝到 /home/ubuntu/.local/lib/python3.10/site-packages/ROPGadget-7.5.dist-info
中。
1 | cd ROPGadget |
ropper
安装:
在 pypi 的 ropper 官网上下载 ropper
运行安装脚本完成 ropper 安装
1
sudo python3 setup.py install
使用:
1
ropper --file ./pwn --nocolor > rop
one_gadget
用于搜索 libc 中能够实现 execve("/bin/sh", (char *[2]) {"/bin/sh", NULL}, NULL);
的效果的跳转地址,由于是采用特征匹配的方法,因此只能是在 libc 中查找。
安装:
1
2sudo apt install -y ruby ruby-dev
sudo gem install one_gadget使用:可以查找到 gadget 地址以及条件限制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24➜ ~ one_gadget /lib/x86_64-linux-gnu/libc.so.6
0x50a37 posix_spawn(rsp+0x1c, "/bin/sh", 0, rbp, rsp+0x60, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
rbp == NULL || (u16)[rbp] == NULL
0xebcf1 execve("/bin/sh", r10, [rbp-0x70])
constraints:
address rbp-0x78 is writable
[r10] == NULL || r10 == NULL
[[rbp-0x70]] == NULL || [rbp-0x70] == NULL
0xebcf5 execve("/bin/sh", r10, rdx)
constraints:
address rbp-0x78 is writable
[r10] == NULL || r10 == NULL
[rdx] == NULL || rdx == NULL
0xebcf8 execve("/bin/sh", rsi, rdx)
constraints:
address rbp-0x78 is writable
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL如果
one_gadget
在一个版本的 Ubuntu 中搜索某一版本的 glibc 的 gadget 出现如下报错可以尝试换另一个版本的 Ubuntu 。貌似是权限问题,可以以 root 权限重新装一下。
seccomp-tools
用于查看和生成程序沙箱规则。
安装:
1
sudo gem install seccomp-tools
使用:
1
seccomp-tools dump ./pwn
LibcSearcher
通过泄露的 libc 中函数的地址来确定 libc 版本。
1 | git clone https://github.com/lieanu/LibcSearcher.git |
glibc-all-in-one
临时找 glibc 和 ld 或者编译 glibc 。
1 | git clone https://github.com/matrix1001/glibc-all-in-one.git |
更新下载列表:
1 | ➜ glibc-all-in-one ./update_list |
下载 libc ,注意要安装解压工具 zstd
,因为下载脚本中用到了。
1 | sudo apt-get install zstd |
编译 libc
1 | sudo ./build [版本例如2.29] [架构例如 i686 amd64] |
patchelf
安装:
1 | sudo apt install patchelf |
qemu
1 | sudo apt install qemu-user qemu-system |
如何使用题目提供的 docker 环境
netcat
在官网下载项目源码,使用如下命令进行编译。
1 | ./configure LDFLAGS=-static # 考虑到 docker 环境恶劣选择静态编译 |
编译后生成的 netcat
位于项目 src
目录下。netcat
即我们常用的 nc
命令对应的可执行程序。
在 docker 中使用如下命令将题目 io 映射到 8888 端口。
1 | ./netcat -lvp 8888 -e ./pwn |
在本机可以使用如下命令连接并交互。(前提是 docker 的 8888 端口映射到本机的 8888 端口)
1 | nc 127.0.0.1 8888 |
gdb
在官网下载项目源码,使用如下命令编译 gdbserver :
1 | sudo apt-get install libgmp-dev libmpfr-dev |
对于 gdb ,由于编译 gdb 时依赖的静态库需要提前编译,因此想要编译 gdb 最好直接编译整个项目:
1 | cd gdb-9.2 |
注意以下几点:
- 编译的
gdbserver
版本一定要与本机的gdb
匹配,不同版本的gdbserver
通信协议不同。 - 有的时候在
gdbserver
中运行./configuer
命令会出现找不到Makefile
的情况,这时在根目录进行一次编译就好了。 - 连接失败之后再运行一次编译命令就可能编译成功。
gdb
位于./gdb/gdb
中。gdbserver
位于./gdbserver/gdbserver
中。
docker
加载镜像
1
docker load -i 题目附件.tar
查看现有镜像
1
docker images
启动容器
1
docker run --privileged -it -w /home/ctf -v ~/Desktop/本机目录:/home/ctf/镜像目录 -p 8888:8888 -p 9999:9999 镜像名 /bin/bash
--privileged
:加这个参数才能gdbserver
附加进程远程调试-v
:目录映射,方便传文件。-p
:端口映射,开两个端口分别给netcat
和gdbserver
用。改用--net=host
可以映射全部端口。-w
:进入 docker 后目录为/home/ctf
。
查看现有容器
1
docker ps
进容器 shell ,即同一个容器再开一个 shell 。
1
sudo docker exec -it -w /home/ctf 容器ID /bin/bash
停止所有容器:
1
docker stop $(docker ps -a -q)
删除所有容器:
1
docker rm $(docker ps -a -q)
删除所有镜像:
1
docker rmi $(docker images -q)
使用方法
exp.py
模板如下:
1 | from pwn import * |
运行脚本前首先在 docker 容器中用
netcat
将题目程序 IO 映射到 8888 端口:1
./netcat -lvp 8888 -e ./pwn
运行脚本,阻塞在
gdb.attach
时脚本已经与远程的netcat
连接,此时 docker 镜像中已经有pwn
这个进程了。此时使用ps -aux | grep pwn
查看进程pid
然后运行如下命令让gdbserver
附加进程并监听 9999 端口。1
gdbserver :9999 --attach 进程pid
此时脚本执行
gdb.attach
连接 docker 中的gdbserver
并阻塞在pause()
上直到gdb
成功连接gdbserver
。在脚本运行窗口按回车解除阻塞进行调试。
其中 docker 中的操作可以通过脚本自动化实现。
1 |
|
ELF 文件格式
ELF(Executable and Linkable Format)是一种常见的可执行文件和可链接文件格式,主要用于Linux和类Unix系统。ELF 文件可以包含不同的类型,常见的 ELF 文件类型包括:
- 可执行文件(
ET_EXEC
):这种类型的 ELF 文件是可直接执行的程序,可以在操作系统上运行。 - 共享目标文件(
ET_DYN
):这种类型的 ELF 文件是可被动态链接的共享库,可以在运行时与其他程序动态链接。该类型文件后缀名为.so
。 - 可重定位文件(
ET_REL
):这种类型的 ELF 文件是编译器生成的目标文件,通常用于将多个目标文件链接到一个可执行文件或共享库中。该类型文件后缀名为.o
,静态链接库(.a
)也可以归为这一类。 - 核心转储文件(
ET_CORE
):这种类型的 ELF 文件是操作系统在程序崩溃或发生错误时生成的核心转储文件,用于调试和分析程序崩溃的原因。
ELF 文件结构及相关常数被定义在 /usr/include/elf.h
里,因为 ELF 文件在各种平台下都通用,ELF文件有 32 位版本和 64 位版本。32 位版本与 64 位版本的 ELF 文件的格式基本是一样的(部分结构体为了优化对齐后大小调整了成员的顺序),只不过有些成员的大小不一样。
elf.h
使用 typedef 定义了一套自己的变量体系:
自定义类型 | 描述 | 原始类型 | 长度(字节) |
---|---|---|---|
Elf32_Addr |
32 位版本程序地址 | uint32_t |
4 |
Elf32_Half |
32 位版本的无符号短整型 | uint16_t |
2 |
Elf32_Off |
32 位版本的偏移地址 | uint32_t |
4 |
Elf32_Sword |
32 位版本有符号整型 | uint32_t |
4 |
Elf32_Word |
32 位版本无符号整型 | int32_t |
4 |
Elf64_Addr |
64 位版本程序地址 | uint64_t |
8 |
Elf64_Half |
64 位版本的无符号短整型 | uint16_t |
2 |
Elf64_Off |
64 位版本的偏移地址 | uint64_t |
8 |
Elf64_Sword |
64 位版本有符号整型 | uint32_t |
4 |
Elf64_Word |
64 位版本无符号整型 | int32_t |
4 |
ELF 主要管理结构为文件头,程序头表(可重定位文件没有)和节表,其他部分有一个个节组成,多个属性相同的节构成一个段。对于节的介绍这里按照静态链接相关和动态链接相关分别介绍。
文件头
我们这里以 32 位版本的文件头结构 Elf32_Ehdr
作为例子来描述,它的定义如下:
1 | /* The ELF file header. This appears at the start of every ELF file. */ |
e_ident
:ELF 文件的魔数和其他信息。- 前 4 字节为
ELFMAG
即\x7fELF
。 - 第 5 字节为 ELF 文件类型,值为
ELFCLASS32(1)
代表 32 位,值为ELFCLASS64(2)
代表 64 位。 - 第 6 字节为 ELF 的字节序,0 为无效格式,1 为小端格式,2 为大端格式。
- 第 7 字节为 ELF 版本,一般为 1 ,即 1.2 版本。
- 后面 9 字节没有定义一般填 0 ,有些平台会使用这 9 个字节作为扩展标志。
- 前 4 字节为
e_type
:表示ELF文件类型,如可执行文件、共享对象文件(.so
)、可重定位文件(.o
)等。e_machine
:表示目标体系结构,即程序的目标平台,如 x86、ARM 等。相关常量以EM_
开头。e_version
:ELF 文件版本号,一般为常数 1 。e_entry
:表示程序入口点虚拟地址。操作系统加载完程序后从这个地址开始执行进程的命令。可重定位文件一般没有入口地址,则这个值为 0 。e_phoff
:表示程序头表的文件偏移量。e_shoff
:表示节表的文件偏移量。e_flags
:表示处理器特定标志。e_ehsize
:表示 ELF 文件头的大小。e_phentsize
:表示程序头表中每个表项的大小。e_phnum
:表示程序头表中表项的数量。e_shentsize
:表示节表中每个表项的大小。e_shnum
:表示节表中表项的数量。e_shstrndx
:表示节表中字符串表的索引。
程序头表
ELF 可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存段(注意不是节)的信息。因为 ELF 目标文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件都有程序头表。
程序头表是由 Elf*_Phdr
组成的数组,用于描述 ELF 文件中每个节的属性和信息。
1 | /* Program segment header. */ |
p_type
:段的类型,例如可执行段、数据段等。p_offset
:段在文件中的偏移量。p_vaddr
:段在虚拟内存中的起始地址。p_paddr
:段在物理内存中的起始地址。因为 ELF 还没装载不知道物理地址,所以作为保留字段。通常和p_vaddr
的值是一样的。p_filesz
:段在文件中的大小。p_memsz
:段在内存中的大小。p_flags
:段的标志,例如可读、可写、可执行等。p_align
:段在文件和内存中的对齐方式。段的的加载地址要能被 整除。
节表
ELF文件里面定义一个固定长度的 Elf*_Shdr
结构体数组用来存放节相关信息,与 PE 文件的节表相似。
在 ELF 文件中,段(Segment)和节(Section)是两个不同的概念,它们在文件结构中具有不同的作用和目的。
段(Segment)是一种逻辑上的组织单位,它定义了可执行文件或共享库在内存中的一个连续区域。每个段都有自己的虚拟地址空间,可以包含多个节。常见的段类型包括代码段(.text
),数据段(.data
、.bss
),只读数据段(.rodata
)等。段在加载和执行时被操作系统用来管理内存,设置内存保护属性以及指定虚拟地址空间的起始地址和大小。
节(Section)是一种更细粒度的组织单位,它包含了文件中的特定类型的数据或代码。每个节都有自己的名字、类型和内容。常见的节类型包括代码节(.text
),数据节(.data
、.bss
),只读数据节(.rodata
),符号表节(.symtab
),字符串表节(.strtab
)等。节不直接参与内存的加载和执行,而是用于链接器(Linker)和调试器(Debugger)等工具对文件进行处理和分析。
通俗的讲,在装载程序的时候为了节省内存会将 ELF 文件中属性相同的节(Section)合并成在一个段(Segment)加载到内存中。
段和节之间存在对应关系和映射关系:
- 一个段可以包含多个节,这些节的内容和属性都属于该段。
- 段提供了对应于虚拟内存的逻辑映射,而节则提供了对应于文件的逻辑映射。
- 段的加载和执行涉及内存管理和地址映射,而节则用于链接和调试过程中的符号解析、重定位等操作。
其中 Elf32_Shdr
定义如下:
1 | /* Section header. */ |
sh_name
:表示节的名称在字符串表中的索引。字符串表节存储了所有节的名称,sh_name
指定了节的名称在字符串表中的位置。sh_type
:表示节的类型,指定了节的用途和属性。常见的类型包括代码段(SHT_PROGBITS(1)
)、数据段(SHT_PROGBITS(1)
)、符号表(SHT_SYMTAB(2)
)、字符串表(SHT_STRTAB(3)
)等。sh_flags
:表示节的标志,用于描述节的特性和属性。标志的具体含义取决于节的类型和上下文。sh_addr
:表示节的虚拟地址,只在可执行文件中有意义。对于可执行文件,sh_addr
指定了节在内存中的加载地址,如果该节不可被加载,则该值为 0 。sh_offset
:表示节在文件中的偏移量,指定了节在文件中的位置。对于 bss 段来说该值没有意义。sh_size
:表示节的大小,指定了节所占据的字节数。sh_link
:表示链接到的其他节的索引,用于建立节之间的关联关系,具体含义依赖于节的类型。sh_info
:附加信息,具体含义依赖于节的类型。sh_addralign
:表示节的地址对齐要求,指定了节在内存中的对齐方式。即sh_addr
需要满足 。如果sh_addralign
为 0 或 1 表示该段没有对齐要求。sh_entsize
:表示节中每个项的大小,如果该字段为 0 说明节中不包含固定大小的项。
ELF 中常见的节如下:
.text
:代码段(Code Section),用于存储程序的可执行指令。.rodata
:只读数据段(Read-Only Data Section),用于存储只读的常量数据,例如字符串常量。.data
:数据段(Data Section),用于存储已初始化的全局变量和静态变量。.bss
:未初始化的数据段(Block Started by Symbol),用于存储未初始化的全局变量和静态变量。它不占用实际的文件空间,而是在运行时由系统自动初始化为零。.symtab
:符号表节(Symbol Table Section),用于存储程序的符号表信息,包括函数、变量和其他符号的名称、类型和地址等。.strtab
:字符串表节(String Table Section),用于存储字符串数据,如节名称、符号名称等。字符串表节被多个其他节引用,通过偏移量和索引来访问具体的字符串。.rel.text
或.rela.text
:代码重定位节(Relocation Section),用于存储代码段中的重定位信息,以便在链接时修正代码中的符号引用。.rel.data
或.rela.data
:数据重定位节(Relocation Section),用于存储数据段中的重定位信息,以便在链接时修正数据段中的符号引用。.dynamic
:动态节(Dynamic Section),用于存储程序的动态链接信息,包括动态链接器需要的重定位表、共享对象的名称、版本信息等。.note
:注释节(Note Section),用于存储与程序或库相关的注释或调试信息。
静态链接相关
注意:静态链接相关只在可重定位文件中存在。比如可执行文件,如果不开启 PIE 加载地址固定,不需要对自身进行重定位,而开启 PIE 后为地址无关代码,也不需要对自身进行重定位。因此不需要静态链接也就丢弃了静态链接相关的节。
符号表(.symtab)
注意:符号表除了静态链接外没有用,但是程序为了方便调试会保留符号表,我们可以通过 strip + 程序名
的方式将符号表去除,这就是为什么有的 pwn 题的附件没有函数和变量名而有的却有。
ELF 文件中的符号表往往是文件中的一个段,段名一般叫 .symtab
。符号表是一个 Elf*_Sym
结构(32 位 ELF 文件)的数组,每个 Elf*_Sym
结构对应一个符号。
1 | /* Symbol table entry. */ |
st_name
:符号名称在字符串表中的偏移量。st_value
:符号的值,即符号的地址或偏移量。- 如果该符号在目标文件中,如果是符号的定义并且该符号不是
COMMON
块类型的则st_value
表示该符号在段中的偏移。 - 在目标文件中,如果符号是
COMMON
块类型的则st_value
表示该符号的对齐属性。 - 在可执行文件中,
st_value
表示符号的虚拟地址。
- 如果该符号在目标文件中,如果是符号的定义并且该符号不是
st_size
:符号的大小,如果符号是一个函数,则表示函数的大小。如果该值为 0 表示符号的大小为 0 或未知。st_info
:该字段是一个字节,包含符号的类型和绑定信息。符号类型包括函数、数据、对象等,符号绑定包括局部符号、全局符号、弱符号等。该字段的高 4 位表示符号的类型,低 4 位表示符号的绑定信息。st_other
:保留字段,通常为 0 。st_shndx
:通常为符号所在节的索引。- 如果符号是一个常量,该字段为
SHN_ABS
(初始值不为 0 的全局变量) 或SHN_COMMON
(初始值为 0 的全局变量)。 - 如果该符号未定义但是在该文件中被引用到,说明该符号可能定义在其他目标文件中,则该字段为
SHN_UNDEF
。
- 如果符号是一个常量,该字段为
重定位表(.rel.text/.rel.data)
重定位表是一个 Elf*_Rel
结构的数组,每个数组元素对应一个重定位入口。重定位表主要有.rel.text
或 .rela.text
,即代码重定位节(Relocation Section)和 .rel.data
或 .rela.data
:数据重定位节(Relocation Section)。
1 | /* Relocation table entry without addend (in section of type SHT_REL). */ |
r_offset
:需要进行重定位的位置的偏移量或地址。这个位置通常是指令中的某个操作数或数据的地址,需要在链接时进行修正,以便正确地引用目标符号。- 对于可执行文件或共享库,
r_offset
表示需要修改的位置在内存中的位置(用于动态链接)。 - 对于可重定位文件,
r_offset
表示需要修改的位置相对于段起始位置的偏移(用于静态链接)。
- 对于可执行文件或共享库,
r_info
:低 8 位表示符号的重定位类型,重定位类型指定了进行何种类型的修正,例如绝对重定位、PC 相对重定位等。高 24 位表示该符号在符号表中的索引,用于解析重定位所引用的符号。
字符串表(.strtab)
ELF 文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
通过这种方法,在ELF文件中引用字符串只须给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存,常见的段名为“.strtab
”或“.shstrtab
”。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名(sh_name
)。
注意,在字符串表中的每个字符串的开头和结尾都有一个 \x00
填充。
动态链接相关
.interp 段
在动态链接的 ELF 可执行文件中,有一个专门的段叫做 .interp
段(“interp”是“interpreter”(解释器)的缩写)。
.interp
的内容很简单,里面保存的就是一个字符串 /lib64/ld-linux-x86-64.so.2
,这个字符串就是可执行文件所需要的动态链接器的路径。
通常系统通过判断一个 ELF 程序是否有 .interp
来判断该 ELF 文件是否为动态链接程序。
.dynamic 段
动态链接 ELF 中最重要的结构是 .dynamic
段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。.dynamic
段是由Elf*_Dyn
构成的结构体数组。
1 | /* Dynamic section entry. */ |
Elf32_Dyn
结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。我们这里列举几个比较常见的类型值(这些值都是定义在 elf.h
里面的宏),
DT_SYMTAB
:指定了符号表的地址,d_ptr
表示.dynsym
的地址。DT_STRTAB
:指定了字符串表的地址,d_ptr
表示.synstr
的地址。DT_STRSZ
:指定了字符串表的大小,d_val
表示大小。DT_HASH
:指定了符号哈希表的地址,用于加快符号查找的速度,d_ptr
表示.hash
的地址。DT_SONAME
:指定了共享库的名称。DT_RPATH
:指定了库搜索路径(已废弃,不推荐使用)。DT_INIT
:指定了初始化函数的地址,动态链接器在加载可执行文件或共享库时会调用该函数。DT_FINI
:指定了终止函数的地址,动态链接器在程序结束时会调用该函数。DT_NEEDED
:指定了需要的共享库的名称。DT_REL/DT_RELA
:指定了重定位表的地址。
动态符号表(.dynsym)
为了完成动态链接,最关键的还是所依赖的符号和相关文件的信息。我们知道在静态链接中,有一个专门的段叫做符号表 .symtab
(Symbol Table),里面保存了所有关于该目标文件的符号的定义和引用。为了表示动态链接这些模块之间的符号导入导出关系,ELF 专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做 .dynsym
(Dynamic Symbol),同样也是由 Elf*_Sym
构成的结构体数组。
与 .symtab
不同的是,.dynsym
只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 .dynsym
和 .symtab
两个表,.symtab
中往往保存了所有符号,包括 .dynsym
中的符号。
与 .symtab
类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表 .strtab
(String Table),在这里就是动态符号字符串表 .dynstr
(Dynamic String Table);由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(.hash
)。
动态链接重定位表(.rel.dyn/.rel.data)
共享对象需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时,那么它的代码或数据中就会有对于导入符号的引用。在编译时这些导入符号的地址未知,在静态链接中,这些未知的地址引用在最终链接时被修正。但是在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
共享对象的重定位与我们在前面“静态链接”中分析过的目标文件的重定位十分类似,唯一有区别的是目标文件的重定位是在静态链接时完成的,而共享对象的重定位是在装载时完成的。在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如 .rel.text
表示是代码段的重定位表,.rel.data
是数据段的重定位表。
动态链接的文件中,也有类似的重定位表分别叫做 .rel.dyn
和 .rel.plt
,它们分别相当于 .rel.data
和 .rel.text
。.rel.dyn
实际上是对数据引用的修正,它所修正的位置位于 .got
以及数据段;而 .rel.plt
是对函数引用的修正,它所修正的位置位于 .got.plt
。
PLT 表(.plt)
在未开启 FULL RELRO 的情况下 PLT 表的结构如下图所示, PLT 表在 .plt
(有的还包括 .plt.got
) 中。
PLT 表的形式如下所示:
其中 为函数 bar
在 GOT 表中的值的索引,bar@GOT
中初始值为 jmp *(bar@GOT)
指令的下一条指令,也就是说第一次调用 bar
函数的时候会继续执行跳转至 PLT0
进行 bar@GOT
的重定位并调用 bar
函数;第二次调用 bar
函数的时候由于 bar@GOT
已完成重定位因此会直接跳转至 bar
函数。
在开启 FULL RELRO 的情况下 PLT 表的结构如下图所示,此时的 PLT 表在 .plt.sec
而不是 .plt
中。
由于 GOT 表在装载时已经完成重定位且不可写,因此不存在延迟绑定,PLT 直接根据 GOT 表存储的函数地址进行跳转。
GOT 表(.got/.got.plt)
ELF 将 GOT 拆分成了两个表叫做 .got
和 .got.plt
。其中 .got
用来保存全局变量引用的地址,.got.plt
用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了 .got.plt
中(当然有的 ELF 文件可能吧这两个表合并为一个 .got
表,结构等同于后面提到的 .got.plt
)。另外 .got.plt
还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下:
- 第一项保存的是
.dynamic
段的偏移(也有可能是.dynamic
段的地址)。 - 第二项是一个
link_map
的结构体指针,里面保存着动态链接的一些相关信息,是重定位函数_dl_runtime_resolve
的第一个参数。 - 第三项保存的是
_dl_runtime_resolve
的地址。
.got.plt
在内存中的状态如下图所示:
注意:静态链接程序也是有 plt 表和 got 表的,并且 plt 表也会被调用。
辅助信息数组
无论静态还是动态链接程序都有辅助信息数组,只是动态链接程序是动态链接器使用辅助信息数组。
站在动态链接器的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作,那么至少它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段(“Segment”)、每个段的属性、程序的入口地址(因为动态链接器到时候需要把控制权交给可执行文件)等。
这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。我们在前面提到过,进程初始化的时候,事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组(Auxiliary Vector)。辅助信息的格式也是一个结构数组,它的结构被定义在 elf.h
:
1 | typedef struct |
a_type
字段表示辅助信息数组的类型。下面是一些常见的a_type
值及其对应的含义:AT_NULL (0)
:辅助向量列表的结束标志。在列表的最后一个条目中使用。AT_IGNORE (1)
:忽略的辅助向量类型。在某些情况下,可以将该类型的辅助向量忽略。AT_EXECFD (2)
:可执行文件的文件描述符。表示打开可执行文件的文件描述符。AT_PHDR (3)
:程序头表的地址。指向程序头表在内存中的起始地址。AT_PHENT (4)
:程序头表中每个条目的大小(字节)。指示每个程序头表条目的字节数。AT_PHNUM (5)
:程序头表的条目数量。指示程序头表中的条目数量。AT_PAGESZ (6)
:页面大小。表示操作系统使用的页面大小。AT_BASE (7)
:共享对象的基地址。指向主共享对象的基地址。AT_FLAGS (8)
:标志位。包含一些特定于操作系统的标志。AT_ENTRY (9)
:程序入口点的地址。指向程序的入口点地址。AT_NOTELF (10)
:不是ELF文件。指示加载程序的文件不是有效的ELF文件。
a_un
:该成员是一个联合体(union),用于存储辅助向量条目的值。在这段代码中,由于指针类型的元素会在 32 位和 64 位平台上产生兼容性问题,所以注释中提到不再添加指针元素。a_val
:如果辅助向量条目的类型是一个整数值,那么该成员将存储该整数值。它也是一个 32 位的无符号整数。
程序编译过程
从源文件编译链接形成 ELF 文件的过程如下图所示:
预编译
首先是源代码文件和相关的头文件,如 stdio.h
等被预编译器 cpp 预编译成一个 .i
文件。对于 C++ 程序来说,它的源代码文件的扩展名可能是 .cpp
或 .cxx
,头文件的扩展名可能是 .hpp
,而预编译后的文件扩展名是 .ii
。
第一步预编译的过程相当于如下命令(-E
表示只进行预编译):
1 | gcc –E hello.c –o hello.i |
或者:
1 | cpp hello.c > hello.i |
预编译过程主要处理那些源代码文件中的以 #
开始的预编译指令。比如 #include
、#define
等,主要处理规则如下:
- 将所有的
#define
删除,并且展开所有的宏定义。 - 处理所有条件预编译指令,比如
#if
、#ifdef
、#elif
、#else
、#endif
。 - 处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。 - 删除所有的注释
//
和/* */
。 - 添加行号和文件名标识,比如
#2"hello.c"2
,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。 - 保留所有的
#pragma
编译器指令,因为编译器须要使用它们。
经过预编译后的 .i
文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到 .i
文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。
编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。
上面的编译过程相当于如下命令:
1 | gcc –S hello.i –o hello.s |
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。
上面的汇编过程我们可以调用汇编器 as 来完成:
1 | as hello.s –o hello.o |
或者使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出目标文件(Object File):
1 | gcc –c hello.c –o hello.o |
链接
静态链接
静态链接是在编译过程的最后阶段将多个目标文件(如 .o
文件)以及所需的库文件合并在一起,生成最终的可执行文件或共享库的过程。
可以使用如下命令将 a.o
和 b.o
链接为目标文件 ab 。
1 | ld a.o b.o -o ab |
合并代码和数据段(Code and Data Segment Merging)
链接器将多个目标文件中的代码段和数据段合并成一个更大的代码段和数据段。这样,所有的目标文件中的代码和数据都会被整合到最终的可执行文件或静态库中。
符号解析(Symbol Resolution)
链接器负通过重定位表解析目标文件中的符号引用。每个目标文件都包含对其他目标文件或库中定义的符号的引用,例如函数、变量等。链接器会检查这些引用并确定对应的定义位置。
对于可重定位的 ELF 文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的 ELF 段都有一个对应的重定位表,而一个重定位表往往就是 ELF 文件中的一个段,所以其实重定位表也可以叫重定位段。
比如代码段 .text
如有要被重定位的地方,那么会有一个相对应叫 .rel.text
的段保存了代码段的重定位表;如果代码段 .data
有要被重定位的地方,就会有一个相对应叫 .rel.data
的段保存了数据段的重定位表。
链接器通过 Elf32_Rel
的 r_offset
加上所在段的起始位置得到重定位入口的位置;通过 r_info
的低 8 为得知重定位类型;通过 r_info
的高 24 位得到重定位符号在符号表(.symtab
)中的下标。
符号重定位(Symbol Relocation)
链接器通过符号表对应的 Elf32_Rel
的 st_value
表示该符号在段中的偏移,进而可以根据重定位类型计算出重定位入口所要修正的值。最后将对应的重定位入口 patch 成正确的值。32 位静态链接常用到的重定位类型如下:
R_386_32
:绝对地址。R_386_PC32
:相对于当前指令地址的下一条指令相对地址。
解析库依赖关系(Library Dependency Resolution)
如果目标文件依赖于外部库文件(如标准库或其他第三方库),链接器会解析这些库的依赖关系,并将所需的库文件链接到最终的可执行文件或静态库中。这样,在运行时,可执行文件或静态库就能够访问和使用这些库中提供的功能。
生成重定位表(Relocation Table)
链接器生成重定位表,记录了需要进行符号重定位的位置和相关信息。这些重定位表将在最终的可执行文件或静态库中被使用,以便在加载和执行时进行正确的符号重定位。
动态链接
动态链接(Dynamic Linking)本质是指把链接这个过程推迟到了运行时再进行,准确的说这个过程应该放在装载部分。不过动态链接的出现很大一部分原因是为了解决内存浪费问题,因此直接照搬静态链接的方式不合理,需要做一些改变。
另外我们称一个程序为动态链接程序或静态链接程序指的是该程序是否有动态链接过程。
注意动态链接不包括合并代码和数据段的过程,各个模块在内存中独立存在。
装载时重定位
由于需要将多个模块装载到内存中,因此动态链接难免会有地址冲突问题,这就需要我们在加载的时候将模块中的相关地址修改为正确的值,这就是装载时重定位。
Linux和GCC支持这种装载时重定位的方法,在产生共享对象时,使用了两个GCC参数 -shared
和 -fPIC
,如果只使用 -shared
,那么输出的共享对象就是使用装载时重定位的方法。
地址无关代码
如果采用装载时重定位的方法虽然能够做到任意地址装载,但存在弊端。比如模块装载到不同位置会导致模块的代码段内容发生改变,无法实现共享库的复用,造成内存浪费;每次装载重定位会影响性能等。
地址无关代码的出现很好的解决了装载时重定位的缺点。地址无关代码的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。这也就是 GCC 的 -fPIC
编译参数。
模块中各种类型的地址引用方式有以下 4 种:
- 模块内部的函数调用、跳转等。
- 模块内部的数据访问,比如模块中定义的全局变量、静态变量。
- 模块外部的函数调用、跳转等。
- 模块外部的数据访问,比如其他模块中定义的全局变量。
对于前两种引用方式由于是在模块内部,相对地址偏移固定,因此可以通过 [rip + xxx]
(注意这里的 rip 是当前指令的下一条指令的地址,下一条指令指的是地址相邻的下一条指令)的方式进行引用,从而做到地址无关。因此关键在于后两种怎么解决。
模块间的访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用。
前面模块内部的解决方法实际上并不严谨,比如一些全局变量以及函数声明没有初始化会被认为是若弱符号,这些弱符号编译器并不知道是否只在本模块定义,因此不能仅使用 [rip + xxx]
的方式访问。
针对这种情况的解决办法是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过 GOT 来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向共享模块内部的该变量副本。这就是为什么 libc 的 GOT 表中会有自身函数。
地址无关代码虽然解决了模块复用的问题,但是本质还是装载时重定位因此没有解决性能问题,实际上 ELF 采用了延迟绑定的方法来解决这一问题。
地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE, Position-Independent Executable)。与 GCC 的 -fPIC
和 -fpic
参数类似,产生 PIE 的参数为 -fPIE
或 -fpie
。
延迟绑定
在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。
注意,延迟绑定一般只出先在未开启 FULL RELRO 的时候,如果开启 FULL RELRO 则 got 表不可写,程序在装载时完成 got 表的重定位。当然特殊情况也有在开启 FULL RELRO 的时候进行重定位,比如 ret2dlresolve 。
我们以调用 puts
函数为例讲解一下延迟绑定的过程。
首先第一次调用 puts
时由于 puts@got
没有进行重定位,因此会调用 _dl_runtime_resolve
函数进行重定位,_dl_runtime_resolve
函数将查找到的 puts
函数地址填写到 puts@got
后会调用 puts
函数。
再次调用 puts
函数时由于 puts@got
已经完成重定位,因此会直接调用 puts
函数。
其中在第一次调用 puts
函数时调用的 _dl_runtime_resolve
函数的具体实现为:
- 用第一个参数
link_map
访问.dynamic
,取出.dynstr
,.dynsym
,.rel.plt
的指针。 .rel.plt + 第二个参数
求出当前函数的重定位表项Elf32_Rel
的指针,记作rel
。rel->r_info >> 8
作为.dynsym
的下标,求出当前函数的符号表项Elf32_Sym
的指针,记作sym
。.dynstr + sym->st_name
得出符号名字符串指针。- 在动态链接库查找这个函数的地址,并且把地址赋值给
*rel->r_offset
,即 GOT 表。 - 调用这个函数。
动态链接的步骤和实现
动态链接器自举
由于动态链接器本身的作用是重定位,因此自身的重定位也需要自身来完成,完成自身重定位的过程成为自举(Bootstrap)。
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的 GOT 。而 GOT 的第一个入口保存的即是 .dynamic
段的偏移地址,由此找到了动态连接器本身的“.dynamic”段。通过 .dynamic
中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。
从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。
装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,我们前面提到过 .dynamic
段中,有一种类型的入口是 DT_NEEDED
,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的 ELF 文件头和 .dynamic
段,然后将它相应的代码段和数据段映射到进程空间中。
如果这个 ELF 共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。
当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。
重定位和初始化
当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟我们前面提到的地址重定位的原理基本相同。
动态链接重定位除了前面静态链接重定位类型外还有如下重定位类型:
R_386_RELATIVE
:针对下面这种代码的重定位,由于加载地址不确定,需要加载后的才能确定。1
2static int a;
static int* p = &a;R_386_GLOB_DAT
:位于.got
的重定位入口,只需要填入正确变量地址即可。R_386_JUMP_SLOT
:位于.got.plt
的重定位入口,只需要填入正确的函数地址即可。
重定位完成之后,如果某个共享对象有 .init
段,那么动态链接器会执行 .init
段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的 C++ 的全局/静态对象的构造就需要通过 .init
来初始化。相应地,共享对象中还可能有 .fini
段,当进程退出时会执行 .fini
段中的代码,可以用来实现类似 C++ 全局对象析构之类的操作。
如果进程的可执行文件也有 .init
段,那么动态链接器不会执行它,因为可执行文件中的 .init
段和 .fini
段由程序初始化部分代码负责执行。当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入口并且开始执行。
装载
Linux 内核装载 ELF 过程
首先在用户层面,bash 进程会调用 fork()
系统调用创建一个新的进程,然后新的进程调用 execve()
系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
execve()
系统调用被定义在 unistd.h
,它的原型如下:
1 | /* Replace the current process, executing PATH with arguments ARGV and |
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。
Glibc 对 execvp()
系统调用进行了包装,提供了 execl()
、execlp()
、execle()
、execv()
和 execvp()
等5个不同形式的 exec
系列 API ,它们只是在调用的参数形式上有所区别,但最终都会调用到 execve()
这个系统调用。下面是一个简单的使用 fork()
和 execlp()
实现的 minibash :
1 |
|
在进入 execve()
系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中, execve()
系统调用相应的入口是 sys_execve(), 它被定义在 arch\i386\kernel\Process.c
。sys_execve()
进行一些参数的检查复制之后,调用 do_execve()
。do_execve()
会首先查找被执行的文件,如果找到文件,则 do_execve()
读取文件的前128个字节判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如 ELF 的可执行文件格式的头 4 个字节为 \x7felf
;而 Java 的可执行文件格式的头4个字节为 cafe
;如果被执行的是 Shell 脚本或 perl 、python 等这种解释型语言的脚本,那么它的第一行往往是 #!/bin/sh
或 #!/usr/bin/perl
或 #!/usr/bin/python
,这时候前两个字节 #
和 !
就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当 do_execve()
读取了这 128 个字节的文件头部之后,然后调用 search_binary_handle()
去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程, search_binary_handle()
会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如 ELF 可执行文件的装载处理过程叫做 load_elf_binary()
; a.out 可执行文件的装载处理过程叫做 load_aout_binary()
;而装载可执行脚本程序的处理过程叫做 load_script()
。 这里我们只关心 ELF 可执行文件的装载, load_elf_binary()
被定义在 fs/Binfmt_elf.c
,这个函数的代码比较长,它的主要步骤是:
- 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
- 寻找动态链接的
.interp
段,设置动态链接器路径。 - 根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据。
- 初始化 ELF 进程环境,比如进程启动时 EDX 寄存器的地址应该是
DT_FINI
的地址(参照动态链接)。 - 将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中
e_entry
所指的地址;对于动态链接的 ELF 可执行文件,程序入口点是动态链接器。
当 load_elf_binary()
执行完毕,返回至 do_execve()
再返回至 sys_execve()
时,上面的第 5 步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。所以当 sys_execve()
系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是新的程序开始执行,ELF 可执行文件装载完成。
进程虚拟地址空间
在现代操作系统中,每个进程都有自己的虚拟地址空间,这是一个抽象的地址空间,由连续的虚拟地址组成。每个进程在其虚拟地址空间中运行,不会直接访问物理内存地址。
操作系统将每个进程的虚拟地址空间划分为多个区域,例如代码段、数据段、堆和栈等。每个区域具有特定的用途和权限。
- 代码段:包含可执行程序的机器指令。
- 数据段:包含静态和全局变量的初始值。
- BSS 段:包含需要初始化为零的静态和全局变量。
- 动态链接段:包含动态链接所需的信息。
加载器将这些段从 ELF 文件中复制到相应的虚拟内存地址,并建立虚拟地址与物理内存地址的映射关系。
execve 系列函数之间的区别
execve
和其他 exec
系列函数(execl
, execlp
, execle
, execv
, execvp
, execvpe
)是 UNIX 和 Linux 系统编程中用于执行程序的重要工具。它们都用于在当前进程中加载并执行一个新程序,从而完全替换当前进程的内存空间、数据、堆栈等内容,但进程ID保持不变。这些函数通常用于需要替换当前执行的程序的情况,如 shell 实现中运行外部命令。
基本原理
所有这些函数最终都会调用系统的 execve
系统调用。execve
是实现其他 exec
函数的底层基础。当调用任一 exec
函数时,当前进程的地址空间将被新程序替换,但进程的 PID 保持不变。这意味着新程序将继续使用调用 exec
的进程的 PID,并从 main()
函数开始执行,而原进程的所有代码和数据则被新程序的代码和数据所替换。
函数差异
execve
- 原型:
int execve(const char *pathname, char *const argv[], char *const envp[]);
- 参数:
pathname
:要执行的程序路径。argv
:传递给新程序的参数数组,以 NULL 结尾。envp
:传递给新程序的环境变量数组,以 NULL 结尾。
- 特点:是唯一一个直接系统调用的
exec
函数,其他exec
函数最终都是通过调用execve
实现的。
- 原型:
execl, execlp, execle
- 特点:这些函数允许直接在函数调用中列出参数,而不是通过数组传递。
execl
和execle
需要提供程序的完整路径,而execlp
在 PATH 环境变量中搜索程序名。execle
允许直接指定环境变量。
execv, execvp, execvpe
- 特点:这些函数通过数组传递参数给新程序。
execv
需要提供程序的完整路径。execvp
和execvpe
在 PATH 环境变量中搜索程序名。execvpe
类似于execvp
,但允许指定环境变量。
使用场景
- execve:需要精确控制新程序的环境变量时使用。
- execl, execlp, execle:当参数数量已知且不需要动态构建参数数组时使用。
- execv, execvp, execvpe:当参数以数组形式提前构建好或在程序中动态生成时使用。
运行
进程栈的初始化
我们知道进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。
假设我们运行如下命令,即运行 ls
程序,传入的参数为 /home
。
1 | ls /home |
在程序初始状态的栈如下图所示。
栈顶寄存器 rsp 指向的位置是初始化以后堆栈的顶部,最前面的 8 个字节表示命令行参数的数量,我们的例子里面是两个,即 /usr/bin/ls
和 /home
,紧接的就是分布指向这两个参数字符串的指针;后面跟了一个0;接着是一个以 0 结尾的指向环境变量字符串的指针数组。
main 函数之外的代码
当运行程序时,shell 或 gui 调用 execve()
,它执行 linux 系统调用 execve()
设置一个堆栈,并将 argc
、 argv
和 envp
压入其中。文件描述 0、1 和 2(stdin
、stdout
、stderr
)保留为 shell 设置的值,动态链接器完成重定位工作。当一切准备就绪后,通过调用 _start()
将控制权交给程序。
一般情况下 ELF 的入口点为 _start
函数,这个函数的主要作用是设置 ___libc_start_main
函数的所需参数。
1 | .text:08049080 endbr32 |
之后调用 __libc_start_main
函数,通过调试发现使用 glibc-2.23 的 32 位程序实际调用的是 generic_start_main
函数,该函数位于 csu/libc-start.c
中,定义如下:
1 |
|
可见和 _start
函数里的调用一致,一共有 7 个参数,其中 main
由第一个参数传入,紧接着是 argc
和 argv
(这里称为 ubp_av
,因为其中还包含了环境变量表)。除了 main
的函数指针之外,外部还要传入 3 个函数指针,分别是:
init
:main
调用前的初始化工作,默认是__libc_csu_init
函数指针。fini
:main
结束后的收尾工作,默认是__libc_csu_fini
函数指针。rtld_fini
:和动态加载有关的收尾工作,rtld
是 runtime loader 的缩写。如果是动态链接程序默认是_dl_fini
函数指针,如果是静态链接程序默认为 NULL 。
最后的 stack_end
标明了栈底的地址,即最高的栈地址。
首先初始化 __libc_multiple_libcs
为 0 之后 generic_start_main
会调用 __cxa_atexit
将 rtld_fini
注册为 main
函数结束后的回调函数。
1 | if (__glibc_likely (rtld_fini != NULL)) |
当然如果是静态链接程序还会做一些额外的初始化,在动态链接程序中这些初始化是在动态连接器中完成的。
- 如果是开启 PIE 的静态程序会调用
_dl_relocate_static_pie
函数初始化link_map
并且进行重定位。 - 初始化
__environ
。 - 初始化
__libc_stack_end
。 - 调用
dl_aux_init
根据栈上的辅助信息数组做相关的初始化工作。 - 如果程序头表指针
dl_phdr
没有初始化,就通过 ELF 文件头的e_phoff
初始化dl_phdr
(程序头表地址),通过e_phnum
初始化dl_phnum
(程序头表中的项数)。不过这个一般在上一步根据栈上的辅助信息数组做相关的初始化工作时就已经初始化过了。 - 调用
_libc_init_secure
函数,如果_libc_enable_secure_decided
不为 0 则初始化_libc_enable_secure
为(__geteuid() != __getuid() || __getegid() != __getgid())
。__geteuid() != __getuid()
:比较有效用户 ID(effective user ID)和实际用户 ID(real user ID)。如果它们不相等,表示当前进程以特权用户身份运行(比如以 root 用户权限运行)。__getegid() != __getgid()
:比较有效组 ID(effective group ID)和实际组 ID(real group ID)。如果它们不相等,表示当前进程以特权用户组身份运行。- 这段代码的目的是判断当前进程是否以特权用户或特权用户组身份运行。这在某些情况下可能需要采取不同的安全措施或限制特权操作。
- 调用
__tunables_init
函数从环境变量中提取信息,并用于初始化可调节项列表,以便在程序运行时可以根据这些可调节项来进行相应的配置或调整。 - 使用
ARCH_INIT_CPU_FEATURES
宏初始化 CPU 的相关参数到cpu_features
类型的结构体_dl_x86_cpu_features
中。 - 重定位代码中的绝对地址引用 。
- 调用
__libc_setup_tls
函数初始化 tls 。 - 如果
__libc_multiple_libcs
为 0 则调用DL_SYSDEP_OSCHECK
宏来初始化dl_osversion
为内核版本号。 - 调用
__pthread_initialize_minimal
函数初始化线程库 。 - 初始化
__stack_chk_guard
。 - 初始化
pointer_chk_guard
。 - 调用
_libc_init_first
函数初始化_libc_argc
,_libc_argv
和environ
等。 - 调用
__cxa_atexit
函数将fini
注册为main
函数结束后的回调函数 。
之后判断函数指针 init
是否为空,如果不为空则调用该函数指针,也就是 __libc_csu_init
函数。
__libc_csu_init
函数定义在 csu/elf-init.c
中,内容如下:
- 如果是静态链接程序会依次调用函数指针数组
__preinit_array_start
中的所有函数。 - 调用
_init
函数。 - 依次调用函数指针数组
__init_array_start
(.init_array
)中的所有函数。
1 | void |
其中调用的 init
函数如下:
1 | .init:0000037C push ebx ; _init |
在静态链接程序中直接 mov eax, 0; test eax, eax;
,因此这个函数什么也不做。而动态链接程序中由于此时 __gmon_start___@got
为 NULL ,因此同样什么也不做。
从 __libc_csu_init
函数返回后会调用 main
函数和 exit
函数。
exit 中的 hook
exit
函数定义如下:
1 | //stdlib/exit.c |
首先 __call_tls_dtors
会被 exit
调用。
1 | void __call_tls_dtors (void) { |
泄露 pointer_guard
后可以劫持 tls_dtor_list
,构造 dtor_list
结构体控制 rdi(obj
域)和 rdx(next
域),进而利用 setcontext
来劫持程序执行流程 。
1 | struct dtor_list { |
泄露 pointer_guard
后(如果该 glibc 版本加密了该函数指针)可以通过劫持 __exit_funcs
数组来获取控制流。
1 | //遍历__exit_funcs,包括_dl_fini |
但这种方法只能控制 rsi 。
1 | struct exit_function { |
如果是动态链接程序 __run_exit_handlers
函数会调用 _dl_fini
函数。
_dl_fini
函数定义如下,该函数的主要作用就是依次调用 link_map->l_info[DT_FINI_ARRAY]
中描述的函数数组中的函数指针。有一种攻击方法就是通过伪造 link_map
来实现控制流劫持,这种攻击方法叫做 House Of Banana 。
1 | //省略了有关SHARED的操作 |
另外 _dl_fini
中的 __rtld_lock_lock_recursive
和 __rtld_lock_unlock_recursive
宏展开后分别为 _rtld_local._dl_rtld_lock_recursive(&(_rtld_local._dl_load_lock).mutex)
和 _rtld_local._dl_rtld_unlock_recursive(&(_rtld_local._dl_load_lock).mutex)
因此我们可以劫持对应函数指针完成控制流劫持,这些函数指针就是狭义上的 exit hook 。
如果是静态链接程序 __run_exit_handlers
函数会调用 __libc_csu_fini
函数。__libc_csu_fini
函数会依次调用 .fini_array
中的函数指针,因此我们可以通过改写 .fini_array
实现控制流劫持。
1 | void |
之后调用 RUN_HOOK
宏:
1 | if (run_list_atexit) |
这个宏展开后的结果如下,可以看到这个宏会依次调用 __start___libc_atexit
函数指针数组直到遇到 NULL 。
1 | do { |
函数指针所在的内存在动态链接程序中位于 libc 上。
在静态链接程序中位于程序的 __libc_atexit
段。
用于 FSOP
的 _IO_cleanup
就是在这里被调用的,另外如果我们能控制这里的函数指针也可以劫持程序执行流程。
共享库
共享库版本
共享库版本命名
Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:
1 | libname.so.x.y.z |
最前面使用前缀 lib
、中间是库的名字和后缀 .so
,最后面跟着的是三个数字组成的版本号。x
表示主版本号(Major Version Number),y
表示次版本号(Minor Version Number),z
表示发布版本号(Release Version Number)。三个版本号的含义不一样。
- 主版本号表示库的重大升级,不同主版本号的库之间是不兼容的,依赖于旧的主版本号的程序需要改动相应的部分,并且重新编译,才可以在新版的共享库中运行;或者,系统必须保留旧版的共享库,使得那些依赖于旧版共享库的程序能够正常运行。
- 次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。在主版本号相同的情况下,高的次版本号的库向后兼容低的次版本号的库。
- 发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容,依赖于某个发布版本号的程序可以在任何一个其他发布版本号中正常运行,而无须做任何修改。
SO-NAME
系统普遍采用一种叫做 SO-NAME 的命名机制来记录共享库的依赖关系。每个共享库都有一个对应的 SO-NAME ,这个 SO-NAME 即共享库的文件名去掉次版本号和发布版本号,保留主版本号。比如一个共享库叫做 libfoo.so.2.6.1
,那么它的 SO-NAME 即 libfoo.so.2
。很明显,SO-NAME 规定了共享库的接口,SO-NAME 的两个相同共享库,次版本号大的兼容次版本号小的。在 Linux 系统中,系统会为每个共享库在它所在的目录创建一个跟 SO-NAME 相同的并且指向它的软链接(Symbol Link)。比如系统中有存在一个共享库 /lib/libfoo.so.2.6.1
,那么 Linux 中的共享库管理程序就会为它产生一个软链接 /lib/libfoo.so.2
指向它。比如 Linux 系统的 Glibc 共享库(注意稍高版本的 libc 的 libc.so.6
本身就是动态库,不是符号链接):
1 | $ ls -l /lib/x86_64-linux-gnu/libc.so.6 |
由于历史原因,动态链接器和 C 语言库的共享对象文件名规则不按 Linux 标准的共享库命名方法,但是 C 语言的 SO-NAME 还是按照正常的规则。
另外动态连接器的 SO-NAME 命名不按照普通的规则。
1 | $ ls -al /lib64/ld-linux-x86-64.so.2 |
建立以 SO-NAME 为名字的软链接目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的 SO-NAME ,而不使用详细的版本号。
动态链接文件中的 .dynamic
段中的 DT_NEED
类型的字段就是 SO-NAME 而不是共享库的完整名字,这样当动态链接器进行共享库依赖文件查找时,就会根据系统中各种共享库目录中的SO-NAME软链接自动定向到最新版本的共享库。
当共享库进行升级的时候,如果只是进行增量升级,即保持主版本号不变,只改变次版本号或发布版本号,那么我们可以直接将新版的共享库替换掉旧版,并且修改 SO-NAME 的软链接指向新版本共享库,即可实现升级;当共享库的主版本号升级时,系统中就会存在多个 SO-NAME ,由于这些 SO-NAME 并不相同,所以已有的程序并不会受影响。
Linux 中提供了一个工具叫做 ldconfig
,当系统中安装或更新一个共享库时,就需要运行这个工具,它会遍历所有的默认共享库目录,比如 /lib
、/usr/lib
等,然后更新所有的软链接,使它们指向最新版的共享库;如果安装了新的共享库,那么 ldconfig
会为其创建相应的软链接。
符号版本
根据提到的可知,一个程序所依赖的共享库的次版本号如果高于系统中的共享库,那么就不保证该程序能在该系统中运行,这类问题叫做次版本号交会问题(Minor-revision Rendezvous Problem)。
这种次版本号交会问题并没有因为 SO-NAME 的存在而得到任何改善。对于这个问题,现代的系统通过一种更加精巧的方式来解决,那就是符号版本机制。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。
.dynamic
段中的 DT_VERSYM
类型字段包含了符号版本。它的作用是维护库的版本信息,以便在运行时进行版本控制和符号解析。通过 DT_VERSYM
,动态链接器可以确定所链接的库的版本与运行时环境是否兼容,以及选择正确的版本来解析符号。
共享库系统路径
目前大多数包括 Linux 在内的开源操作系统都遵守一个叫做 FHS(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。共享库作为系统中重要的文件,它们的存放方式也被 FHS 列入了规定范围。FHS 规定,一个系统中主要有两个存放共享库的位置,它们分别如下:
/lib
:该目录包含操作系统核心组件所需的共享库文件。这些库文件通常是系统引导和运行时所必需的,例如与操作系统内核相关的库文件。/usr/lib
:该目录包含操作系统提供的额外共享库文件。这些库文件用于支持系统上安装的应用程序和工具的运行,如图形界面工具包(GUI toolkit)、网络库、数据库驱动程序等。/usr/local/lib
:该目录是用于安装本地(local)软件的库文件的默认位置。当用户手动编译和安装软件到系统时,通常会将其安装到/usr/local
目录下。因此,相关的库文件也会被安装到/usr/local/lib
目录下。
共享库查找过程
动态链接器对于模块的查找有一定的规则:如果 DT_NEED
里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;如果 DT_NEED
里面保存的是相对路径,那么动态链接器会在 /lib
、/usr/lib
和由 /etc/ld.so.conf
配置文件指定的目录中查找共享库。为了程序的可移植性和兼容性,共享库的路径往往是相对的。
ld.so.conf
是一个文本配置文件,它可能包含其他的配置文件,这些配置文件中存放着目录信息。
1 | ➜ ~ cat /etc/ld.so.conf |
如果动态链接器在每次查找共享库时都去遍历这些目录,那将会非常耗费时间。所以 Linux 系统中都有一个叫做 ldconfig
的程序,这个程序的作用是为共享库目录下的各个共享库创建、删除或更新相应的 SO-NAME(即相应的符号链接),这样每个共享库的 SO-NAME 就能够指向正确的共享库文件;并且这个程序还会将这些 SO-NAME 收集起来,集中存放到 /etc/ld.so.cache
文件里面,并建立一个 SO-NAME 的缓存。当动态链接器要查找共享库时,它可以直接从 /etc/ld.so.cache
里面查找。而 /etc/ld.so.cache
的结构是经过特殊设计的,非常适合查找,所以这个设计大大加快了共享库的查找过程。
如果动态链接器在 /etc/ld.so.cache
里面没有找到所需要的共享库,那么它还会遍历 /lib
和 /usr/lib
这两个目录,如果还是没找到,就宣告失败。
所以理论上讲,如果我们在系统指定的共享库目录下添加、删除或更新任何一个共享库,或者我们更改了 /etc/ld.so.conf
的配置,都应该运行 ldconfig
这个程序,以便调整 SO-NAME 和 /etc/ld.so.cache
。很多软件包的安装程序在往系统里面安装共享库以后都会调用 ldconfig
。
更改共享库
Linux 系统提供了很多方法来改变动态链接器装载共享库路径的方法,通过使用这些方法,我们可以满足一些特殊的需求,比如共享库的调试和测试、应用程序级别的虚拟等。
LD_LIBRARY_PATH
在 Linux 系统中,LD_LIBRARY_PATH
是一个由若干个路径组成的环境变量,每个路径之间由冒号隔开。默认情况下, LD_LIBRARY_PATH
为空。如果我们为某个进程设置了 LD_LIBRARY_PATH
,那么进程在启动时,动态链接器在查找共享库时,会首先查找由 LD_LIBRARY_PATH
指定的目录。这个环境变量可以很方便地让我们测试新的共享库或使用非标准的共享库。
比如更换 libdl.so.2
和 libc.so.6
的 pwntools 脚本如下:
1 | sh = process("./lib/ld.so --preload libdl.so.2 ./pwnhub".split(), env={"LD_LIBRARY_PATH": "./lib/"}) |
LD_PRELOAD
系统中另外还有一个环境变量叫做 LD_PRELOAD
,这个文件中我们可以指定预先装载的一些共享库甚或是目标文件。在 LD_PRELOAD
里面指定的文件会在动态链接器按照固定规则搜索共享库之前装载,它比 LD_LIBRARY_PATH
里面所指定的目录中的共享库还要优先。无论程序是否依赖于它们,LD_PRELOAD
里面指定的共享库或目标文件都会被装载。
比如更换 libdl.so.2
和 libc.so.6
的 pwntools 脚本如下:
1 | process("./lib/ld.so ./pwnhub".split(), env={"LD_PRELOAD": "./lib/libc.so.6 ./lib/libdl.so.2"}) |
LD_DEBUG
另外还有一个非常有用的环境变量 LD_DEBUG
,这个变量可以打开动态链接器的调试功能,当我们设置这个变量时,动态链接器会在运行时打印出各种有用的信息,对于我们开发和调试共享库有很大的帮助。
例如运行 LD_DEBUG=files /bin/ls
命令时动态链接器打印出了整个装载过程,显示程序依赖于哪个共享库并且按照什么步骤装载和初始化,共享库装载时的地址等。
bindings
:显示动态链接的符号绑定过程。libs
:显示共享库的查找过程。versions
:显示符号的版本依赖关系。reloc
:显示重定位过程。symbols
:显示符号表查找过程。statistics
:显示动态链接过程中的各种统计信息。
patchelf
用于对于依赖不是很复杂的程序更换 libc ,有一下几点需要注意:
- 如果在漏洞利用时用到了动态链接相关结构最好不要 patchelf,因为 patchelf 会改变动态链接相关结构的位置。
- 一个程序在一个版本的虚拟机里面 patchelf 后换到另一个版本虚拟机中可能会运行失败。
- 在 patch 完 libc 后最好把 ld 也 patch 成大版本相同的 ld ,否则会运行失败。
修改 libc:
1 | patchelf --replace-needed libc.so.6 ./libc.so.6 ./pwn |
修改 ld:
1 | patchelf --set-interpreter ./ld-2.31.so ./pwn |
多线程与 TLS
基本概念
线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,那么这就是很少见的情况),但实际运用中线程也拥有自己的私有存储空间,包括以下几方面:
- 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。
- 线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
- 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。
实际上,线程私有的数据有:
- 局部变量
- 函数的参数
- TLS 数据
线程共享的数据有:
- 全局变量
- 堆上的数据
- 函数里的静态变量
- 程序代码,任何线程都有有权利读取并执行任何代码。
- 打开的文件,A 线程打开的文件可以由 B 线程读写。
一个全局变量如果使用 __thread
关键字修饰,那么这个变量就变成线程私有的 TLS 数据,也就是说每个线程都在自己所属 TLS 中单独保存一份这个变量的副本。例如下面的代码中,a
和 b
都是 TLS 数据,而 c
是全局变量。
1 | // gcc test.c -o test -g -pthread |
分析生成的 ELF 文件的节表,发现多出了 .tdata
和 .tbss
,这两个节分别记录已初始化和未初始化的 TLS 数据。
其中 .tbss
在 ELF 文件中不占用空间, .tdata
在 ELF 中存储了初始化的数据,比如上面的代码中的 __thread uint32_t a = 0x114514
。
ELF 加载到内存中后, .tdata
和 .tbss
这两个节合并为一个段,在程序头表中这个段的 p_type
为 PT_TLS(7)
。
TLS(Thread Local Storage)的结构与 TCB(Thread Control Block)以及 dtv(dynamic thread vector)密切相关,每一个线程中每一个使用了 TLS 功能的模块都拥有一个 TLS Block 。这几者的关系如下图所示:
注意
这里是 x86_64-ABI 要求的 TLS 结构,Glibc 实现的 TLS 结构与上图有一些差异。
根据图中显示的信息,TLS Blocks 可以分为两类:
- 一类是程序装载时就已经存在的(位于 TCB 前),这一部分 Block 被称为
_static TLS_
。 - 一类是右边的 Blocks 是动态分配的,它们被使用
dlopen
函数在程序运行时动态装载的模块所使用。
TCB 作为线程控制块,保存着 dtv
数组的入口,dtv
数组中的每一项都是 TLS Block 的入口,它们是指向 TLS Blocks 的指针。特别的,dtv
数组的第一个成员是一个计数器,每当程序使用 dlopen
函数或者 dlfree
函数加载或者卸载一个具备 TLS 变量的模块,该计数器的值都会加一,从而保证程序内版本的一致性。 特别的,ELF 文件本身对应的 TLS Block 一定在 dtv
数组中占据索引为 1 的位置,且位置上与 TCB 相邻。 还需要注意的是,图中出现了一个名为 的指针,在 i386 架构上,这个指针为 gs 段寄存器;在 x86_64 架构上,该指针为 fs 段寄存器。由于该指针与 ELF 文件本身对应的 TLS Block 之间的偏移是固定的,程序在编译时就可以将 ELF 中线程变量的地址硬编码到目标文件中。
主线程 TLS 初始化
前面提到过在 main
开始前会调用 __libc_setup_tls
初始化 TLS 。
在 __libc_setup_tls
函数中,首先会遍历 ELF 的程序头表,找到 p_type
为 PT_TLS(7)
的段,这个段中就存储着 TLS 的初始化数据。
1 | /* Look through the TLS segment if there is any. */ |
然后通过 brk
调用为 TLS 中的数据以及一个 pthread
结构体分配内存。其中 pthread
结构体的第一项为 tcbhead_t header;
,即前面提到的 TCB 。
1 | /* Align the TCB offset to the maximum alignment, as |
tcbhead_t
结构体定义如下,也就是很多资料中提到的 TLS 。
1 | typedef struct |
之后初始化 _dl_static_dtv
,也就是前面提到的 dtv
数组,具体过程为:
- 将
tlsblock
地址关于max_align
向上对齐。 _dl_static_dtv[0].counter
初始化为dtv
的数量,由于_dl_static_dtv
前两项分别用于记录dtv
总数和使用的数量,因此这里记录的dtv
数量是要减去这两项的。_dl_static_dtv[1].counter
初始化为 0 。_dl_static_dtv[2]
也就是当前模块对应的dtv
的pointer.val
指向 TLS 。_dl_static_dtv[2].pointer.to_free
置为 NULL 。- 将 TLS 的初始数据也就是
PT_TLS
段中的数据复制到 TLS 中。
1 | struct dtv_pointer |
此时 TLS 相关结构之间的关系如下图所示:
另外还会初始化 link_map
中的 TLS 相关的数据,由此我们可以知道 link_map
中这些字段的含义:
l_tls_offset
:TCB 在 TLS 中的偏移。l_tls_align
:TLS 初始数据的对齐,在 TLS 中 TLS 初始数据关于l_tls_align
向上取整。l_tls_blocksize
:TLS 初始数据的大小,也就是前面提到的 TLS Block 的大小。l_tls_initimage
:TLS 初始数据的地址。也就是PT_TLS
段的地址。l_tls_initimage_size
:PT_TLS
段在文件中的大小,也就是.tdata
的大小。l_tls_modid
:模块编号。
1 | struct link_map *main_map = GL(dl_ns)[LM_ID_BASE]._ns_loaded; |
创建线程时 TLS 初始化
创建线程的函数 pthread_create
实际调用的是 __pthread_create_2_1
函数,在该函数中调用了 allocate_stack
函数。
1 |
|
在 allocate_stack
函数中会调用 mmap
为线程分配栈空间,然后初始化栈底为一个 pthread
结构体并将指针 pd
指向该结构体。最后调用 _dl_allocate_tls
函数为 TCB 创建 dtv
数组。
1 | struct pthread *pd; |
_dl_allocate_tls
函数依次调用 allocate_dtv
和 _dl_allocate_tls_init
分配和初始化 dtv
数组。
1 | void * |
allocate_dtv
函数调用了 ptmalloc 堆管理器的 calloc
函数为 dtv
数组分配内存,初始化 dtv[0].counter
为数组中元素数量,并且让 pd->dtv
指向 dtv[1]
。
1 | /* Install the dtv pointer. The pointer passed is to the element with |
_dl_allocate_tls_init
函数会遍历 dl_tls_dtv_slotinfo_list
中的 link_map
,初始化 dtv
数组并将初始数据复制到 TLS 变量中。从这里可以看出,如果一个模块有 TLS 变量,则该模块对应的 dtv->pointer.val
指向 TLS 变量的起始地址。
1 | dtv[map->l_tls_modid].pointer.val = TLS_DTV_UNALLOCATED; |
回到 __pthread_create_2_1
函数,在完成了 pthread
的一系列初始化后调用了 THREAD_COPY_STACK_GUARD
和 THREAD_COPY_POINTER_GUARD
两个宏,这两个宏的展开如下:
1 | ((pd)->header.stack_guard = ({ |
不难看出这两个宏把当前线程(当前 fs 寄存器还没有指向新线程的 TCB)的 TLS 中的 stack_guard
和 pointer_guard
都复制到子线程的 TLS 的对应位置上。因此可以确定线程的 stack_guard
和 pointer_guard
与主线程相同。
最后需要确定是 fs 寄存器何时被修改,因为 fs 寄存器不能再用户态修改,因此一定是一个系统调用完成了对 fs 寄存器的修改。
通过调试发现,pthread_create->create_thread->clone
中的 clone
系统调用完成了对 fs 寄存器的修改。
子进程调试
gdb默认情况下,父进程 fork
一个子进程,gdb 只会继续调试父进程而不会管子进程的运行(pwndbg 插件设置相反)。
相关设置
- 跟踪子进程进行调试,可以使用
set follow-fork-mode mode
来设置fork
跟随模式。show follow-fork-mode
:进入 gdb 以后,我们可以使用show follow-fork-mode
来查看目前的跟踪模式。set follow-fork-mode parent
:gdb 只跟踪父进程,不跟踪子进程,这是默认的模式。set follow-fork-mode child
:gdb 在子进程产生以后只跟踪子进程,放弃对父进程的跟踪。
- 想同时调试父进程和子进程,以上的方法就不能满足了。Linux 提供了
set detach-on-fork mode
命令来供我们使用。show detach-on-fork
:show detach-on-fork
显示了目前是的detach-on-fork
模式。set detach-on-fork on
:只调试父进程或子进程的其中一个(根据follow-fork-mode
来决定),这是默认的模式。set detach-on-fork off
:父子进程都在 gdb 的控制之下,其中一个进程正常调试(根据follow-fork-mode
来决定),另一个进程会被设置为暂停状态。
调试进程切换
使用 gdb 调试多进程时,如果想要在进程间进行切换,那么就需要
- 在
fork
调用前设置:set detach-on-fork off
。 - 使用
info inferiors
来查看进程信息,得到的信息可以看到最前面有一个进程编号,使用inferior num
来进行进程切换。
常见保护
checksec 可以查看程序开启了哪些保护。
1 | ➜ ~ checksec /bin/ls |
Canary
canary 是一种防止缓冲区溢出攻击的保护机制。它的基本思想是在程序的堆栈中插入一个随机生成的数值,用于检测缓冲区溢出攻击。
1 | .text:0000000000001189 endbr64 |
canary 的初始值存储在 tls 中,也就是前面提到的 stack_guard
。
在编译 c 程序时使用 -fno-stack-protector
参数可以关闭 canary 保护(注意高版本的 gcc 的 canary 保护关不掉)。
NX
NX 即 No-eXecute(不可执行),NX 的基本原理是将数据所在内存页标识为不可执行,也就是同一内存可写与可执行不共存。
gcc 编译器默认开启了 NX 选项,如果需要关闭 NX 选项,可以给 gcc 编译器添加 -zexecstack
参数。
PIE
PIE 主要随机了代码段(.text
),初始化数据段(.data
)和未初始化数据段(.bss
)的地址。另外 PIE 是否开启还会影响堆的基址。
开启 PIE:
关闭 PIE:
在编译 c 程序时使用 -no-pie
参数可以关闭 PIE 保护。
ASLR
ASLR 是系统级别的地址随机。通过修改 /proc/sys/kernel/randomize_va_space
的值可以控制 ASLR 的级别:
- 0:关闭 ASLR
- 1:栈基址,共享库,mmap 基址随机
- 2:在 1 的基础上增加堆基址的随机
RELRO
- 当 RELRO 保护为 NO RELRO 的时候,
init.array
、fini.array
、got.plt
均可读可写。 - 为 PARTIAL RELRO 的时候,
init.array
、fini.array
根据实际调试结果判断是否可写,got.plt
可读可写。 - 为 FULL RELRO 时,
init.array
、fini.array
、got.plt
均可读不可写。 -Wl,-z,norelro
编译参数可以关闭 RELRO ,使 RELRO 状态变为 NO RELRO 。-Wl,-z,lazy
会开启延迟绑定,使 RELRO 状态变为 Partial RELRO 。
调用约定
栈结构
注意 canary 不一定与 ebp 相邻,因为有些函数会先将一些寄存器保存到栈中。canary 实际位置以调试为准。
函数调用过程
32位为例:
函数参数传递
注意:通常 linux 下的程序的函数调用都是外平栈的。
32位程序
- 普通函数传参:参数基本都压在栈上(有寄存器传参的情况,可查阅相关资料)。
int 0x80
传参:eax对应系统调用号,ebx、ecx、edx、esi、edi、ebp 分别对应前六个参数多余的参数压在栈上。
64位程序:
- 普通函数传参:先使用 rdi、rsi、rdx、rcx、r8、r9 寄存器作为函数参数的前六个参数,多余的参数会依次压在栈上。
syscall
传参:rax 对应系统调用号,传参规则位 rdi、rsi、rdx、r10、r8、r9。
系统调用号
32 位
1 |
64 位
1 |
- Title: linux user pwn 基础知识
- Author: sky123
- Created at : 2024-11-07 18:56:56
- Updated at : 2025-01-04 17:11:13
- Link: https://skyi23.github.io/2024/11/07/linux-user-pwn-basic-knowlege/
- License: This work is licensed under CC BY-NC-SA 4.0.