来自某课的附加练习,由于很少接触 pwn,这种高级 ROP 技术没怎么接触过,借此学习一下这个方法
问题分析
使用 IDA 反编译看一下结果
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
1 | Partial RELRO |
程序传入参数 argv[1],然后调用 strcpy 将其拷贝到 dest 中,而 dest 的大小为 300 字节
没有对输入进行长度检查,并且 No canary ,因此可以通过传入超过 300 字节的字符串来覆盖栈上的返回地址
栈不可执行,需要使用 ROP,开启了 ASLR,NX,Partial RELRO
ret2resolver 变体
GOT 表可写,因此可以通过 ret2resolver 的方式来进行攻击
这个方法的核心是利用动态链接器的解析机制,通过构造一个假的 Elf32_Rel 和 Elf32_Sym 结构体来调用任意函数
在程序调用 printf 的时候,会跳到 plt 表,然后 plt 表跳到 GOT 表项,第一次调用的时候,还不是 libc 的 printf,大部分情况是 0,如果 GOT 表项还没有解析,就会跳到 _dl_runtime_resolve
1 | push $0 |
这里压入的 0 是 reloc_index,实际上是 .rel.plt 里面的偏移,跳到 PLT0:
1 | 08048380 <PLT0>: |

然后根据 link_map 和 _dl_runtime_resolve 来解析
这里栈大概是
1 | [link_map] |

_dl_runtime_resolve 保存寄存器后,调用: _dl_fixup(link_map, reloc_arg); ,这里的 reloc_arg 就是 reloc_index
在 _dl_fixup 内部,他拿到 link_map 后,需要找到 .dynstr/.strtab .symtab / .dynsym .rel.plt 这些段,然后从link_map 里的 l_info 数组中
1 | l_info[DT_STRTAB] -> .dynstr |
每个动态链接的库都有一个 link_map,里面有很多信息,包括库的基地址、符号表、字符串表、重定位表等,GOT[1] 指向它

_dl_fixup 会根据 reloc_index 找到对应的重定位条目,然后根据重定位条目中的符号索引去 .dynsym 找到符号,再去 .dynstr 找到符号名,最后解析出函数的实际地址,并写入 GOT 表项
1 | l_info[5] = l_info[DT_STRTAB] |
每个 l_info[tag] 指向一个 Elf32_Dyn,里面有一个 d_ptr,就是表的地址
_dl_fixup 计算 reloc = .rel.plt + reloc_arg
在 .rel.plt 中
1 | struct { |
以 printf 为例
1 | r_offset = 0x0804a00c // 也就是 printf 的 GOT 槽 |
r_info 拆分:
1 | sym_index = 0x00000107 >> 8 = 1 |
type = 7 表示 R_386_JUMP_SLOT,也就是“函数跳转槽”
_dl_fixup 接着计算 sym = .dynsym + sym_index * 16
sym_index = 1,所以取 .dynsym 的第 1 项,Elf32_Sym 是 16 字节,第一个字段是
1 | uint32_t st_name; // 名字在 .dynstr 里的偏移 |
第 1 项取出来,st_name 指向 .dynstr 里的 "printf",到此, _dl_fixup 就知道要解析的符号是 printf
接着, _dl_fixup 调用
1 | _dl_lookup_symbol_x("printf", link_map, ..., link_map->l_scope, ...) |
l_scope 是主程序的搜索作用域,也就是它会在主程序和所有依赖的库里查找 printf,加载器在 libc 的 .dynsym/.dynstr 里找到 printf,拿到:
1 | st_value = printf 在 libc 内的偏移 |
最后
1 | printf 真实地址 = libc 加载基址 + st_value |
于是_dl_fixup 把 printf 的真实地址写到 GOT 槽
1 | r_offset + l_addr |
简单来说就是
1 | reloc = l->l_info[DT_JMPREL]->d_ptr + reloc_arg; |

而 ret2dlresolve 就是利用这个机制,构造一个假的 .rel.plt、.dynsym、.dynstr,让 _dl_runtime_resolve 解析出想要的函数(比如 system),然后调用它
1 | reloc_index/偏移 -> 假 reloc |
不改 l_scope,只改 link_map 的三个 l_info 指针,让加载器去读伪造的三张表
原来的调用链:
1 | PLT0 -> _dl_fixup -> l_info[STRTAB/SYMTAB/JMPREL] -> 真表 -> l_scope -> printf |
改为:
1 | PLT0 -> _dl_fixup -> 被改过的 l_info -> 假表 -> 真 l_scope -> system |

CSU gadget
改link_map 时, GOT[1] 槽的地址是固定的,但槽里的内容是运行时 ld.so 分配的 link_map 指针,被 ASLR 随机化
GCC/glibc 编译程序时会自动生成的一个启动函数:
1 | 0x08048618: |
EBX + EDI*4 - 0xF8决定调用哪个函数指针;EBP被写进[esp],成为函数的第 1 个参数;[esp+0x34]被写进[esp+4],成为第 2 个参数;ESI和EDI控制循环几次;- 调用完后,它能从帧里弹出下一帧,继续执行
可以使用这个配合 strcpy 来写入 link_map
我们知道 GOT 表是页对齐的,最后低字节是 00,只需要找到这样的一个 gadget,让他修改低字节位,然后取出 GOT 表项,存到我们的 payload 里面,即可调用执行 link_map + 0x34
然后调用 PLT0 来解析 execve,最后执行 /bin/sh

攻击思路
argv[1] 传入的字符串,长度超过 300 字节,覆盖返回地址,然后通过 strcpy 将构造的 payload 写在可写页上,然后扫描二进制的可写页,通过 strcpy 将 payload 写在可写页上
如果没有在二进制中找到相关的字节,需要通过相关的运算 gadget 来构造相关字节
然后需要去执行写在可写页的 payload,使用 CSU gadget 调用执行
1 | F1 + 0x00: link_map + 0x34 ; 当前这次 strcpy 的 dest |
1 | 0x804a3e0: 00 00 00 00 ; dummy EBP |

1 | main |
寻找字节
用 pwntool 找需要 strcpy 的字节
1 | #!/usr/bin/env python3 |
1 | cat > ~/.pwn.conf <<'EOF' |

值得注意到,找不到可用的 0x76 源字节即 execve 的 v 没有找到可用字节需要找一个 gadget 去运算处理
1 | ROPgadget --binary ds/bin/tryofm32_default_compiled | grep -i "or.*byte ptr" |

这个
1 | 0x08048449: |
需要
1 | 目标 = 0x804a094 |
于是设置
1 | EBX = 0x804a094 - 0x17706f8 |
我们选:
1 | SCRATCH = 0x804a176 |
它的低字节是:
1 | 0x76 |
然后执行:
1 | strcpy(0x804a176, zero_src); |
strcpy 写完返回:
1 | EAX = 0x804a176 |
同时它会在 SCRATCH 写一个 0,但 SCRATCH 之后不再使用,所以无所谓
payload 构造
| 地址 | 大小 | 内容 |
|---|---|---|
0x804a060 | 8 | D1:fake Elf32_Dyn,tag 5,d_ptr 指向 strtab |
0x804a068 | 8 | D2:fake Elf32_Dyn,tag 6,d_ptr 指向 symtab |
0x804a070 | 8 | D3:fake Elf32_Dyn,tag 23,d_ptr 指向 reloc |
0x804a078 | 4 | SB1:存放 D1 的地址 |
0x804a080 | 4 | SB2:存放 D2 的地址 |
0x804a088 | 4 | SB3:存放 D3 的地址 |
0x804a090 | 7 | fake strtab:execve\\0 |
0x804a0a0 | 16 | fake symtab:16 个 0 |
0x804a0b0 | 8 | fake reloc:r_offset=0x804a014,r_info=7 |
0x804a0c0 | 8 | /bin/sh\\0 |
0x804a0e0 | 8 | argv 数组:[0x804a0c0, 0] |
0x804a104 | 4 | CSU 地址 0x08048618 的源缓冲 |
0x804a176 | 4 | SCRATCH,用来让 strcpy 返回 AL=0x76 |
0x804a290 | 8 | PTRBUF,运行时造 link_map+offset 指针 |
0x804a2f0 | 0x50 | F1,CSU 帧 1 |
0x804a340 | 0x50 | F2,CSU 帧 2 |
0x804a390 | 0x50 | F3,CSU 帧 3 |
0x804a3e0 | 0x20 | FINAL,最终 PLT0 调用帧 |

构造 argv[1]
先填 buf 到返回地址,然后修改可写页表,用 strcpy,
1 | strcpy@plt |
最后用那个 OR 运算完成 "exec\0e" -> "execve",最后写tag
1 | strcpy(D1, 0x8049f54) ; tag 5 |
CSU 帧
1 | PTRBUF = link_map+offset |
这些值运行时才知道,所以是 5 帧 × 3
1 | strcpy(PTRBUF, low_src) |
执行 CSU 链
1 | pop ebx; ret |
构造完整的 payload,也就是 argv[1]
这里还有个问题
strcpy(buf, argv[1]); buf 在栈上,argv[1] 的字符串也在栈上,而且 argv[1] 在比 buf 更高的地址,
strcpy 的行为是:
- 从
argv[1]读一个字节; - 写到
buf; - 地址都往后加 1;
- 直到遇到
\0。
也就是说,目标 buf 是从低地址往高地址写,源 argv[1] 也在高地址方向
buf 到 argv[1] 源串的距离只有 789 字节,payload 装不下,所以 strcpy 往高地址写的过程中,还没复制完,目标地址就会追上源串破坏掉
于是传入一个 argv[0],把 argv[1] 源串往远处顶
1 | argv0 = "A" * 12000 |
最终脚本
Ver1
1 | #!/usr/bin/env python3 |
1 | #!/usr/bin/env python3 |
Ver2
1 | #!/usr/bin/env python |
脚本运行截图:
