Loading episodes…
0:00 0:00

揭秘ROP链:如何用一行代码黑掉一个系统

00:00
BACK TO HOME

揭秘ROP链:如何用一行代码黑掉一个系统

10xTeam February 11, 2026 4 min read

想象一下,一个系统仅通过一条命令就被攻破了。

当然,你可能会看着它说:“这只是定义一个数值变量并给它赋一个值。” 一条命令和一行代码怎么可能黑掉一个系统?

这就是我想谈论的,关于隐藏的抽象基础抽象的区别,以及我们如何仅用一条命令来破解一个系统。

我敢肯定……98%的程序员会告诉你:“这是一个常量,常量是不能被改变的。”

不幸的是,这是程序员中一个普遍的误解,认为常量在程序运行时无法更改。 今天,我们想打破这个事实,改变“不可变”的常量,并打破语言的规则。

现在,我们只需执行这个命令,按下回车,然后注意到我们启动了计算器。 通过这种方式,我们现在完全控制了处理器,并按我们想要的方式指挥它。

此外,我们执行了程序员从未编写过的命令,这些命令在系统中也根本不存在。 这就是核心思想。

现代防御的演变

以前,事情很简单。 只需将代码(也就是Shellcode)注入内存,执行它,系统就被轻易地黑掉了。

但现在,随着现代系统的发展,情况已经改变。 直接从内存运行代码的想法已变得几乎不可能,特别是有了像NX位(或简称“No-Execute”)这样的新技术。

这个想法很简单: 接受写入的内存区域禁止执行。 接受执行的内存区域禁止写入。

这是现代系统中的法则,它阻止我们注入我们稍后可能利用来破解系统的编程代码或外部代码。

那么,简单的问题是:我们如何破解系统?我们如何创建一个可以用来破解系统的payload

答案很简单。 我们将讨论我在之前文章中承诺过的“禁忌技术”。

“剪报勒索信”技术

在犯罪世界里,罪犯会想尽一切办法隐藏自己的身份。 其中一种方法就是“剪报”技术。

这个想法很简单,罪犯拿一份包含正常文本的普通报纸,然后开始有意地从报纸上剪下单词。 也就是说,他们从第一页剪下一个特定的词,从第二页剪下另一个特定的词,然后组成一条信息或一句话,比如一封威胁信。

罪犯为什么要这样做? 为了避免被追踪和笔迹分析,这样特殊机构就无法识别出他们特定的笔迹。

同时,他们使用的是一种自然且合法的东西——报纸。 报纸由记者撰写,包含非常自然的文本。 但他们从特定的地方和特定的区域提取单词,从而形成一条威胁信息或坐标等,并避免被直接追踪。

这就是黑客在ROP链(Return-Oriented Programming)技术中使用的方法,我们今天将要讨论它。 它依赖于剪报或碎片的思想,我们称之为“小工具”(Gadgets)。

当然,我首先想说的是,在我们进入其余细节之前,我们必须理解内存结构。

理解内存布局

当我们双击程序来运行它时,系统加载器(Loader)会从硬盘中取出原始数据,无论是Windows中的EXE还是Linux中的ELF。

这些原始数据被加载,加载器会对其进行划分和调整。 系统给程序一种错觉,让它以为自己占用了整个内存,而实际上它只获得了虚拟地址(Virtual Addresses)。

有一个单元负责将虚拟地址转换为随机的物理地址(Physical Addresses)。 然而,程序看到的是它获得了虚拟地址,这些地址是排列有序、组织分明的。

graph TD
    subgraph "进程内存空间 (高地址)"
        direction TB
        H["环境变量与参数"]
        G["栈 (Stack)<br/>向下增长<br/>权限: RW"]
        F["共享库 (Shared Libraries)<br/>例如: libc, kernel32.dll"]
        E["堆 (Heap)<br/>向上增长<br/>权限: RW"]
    end
    subgraph " "
        direction TB
        D["BSS段<br/>未初始化的全局/静态变量"]
        C["Data段 (.data)<br/>已初始化的全局/静态变量"]
        B[".rodata / .rdata<br/>只读数据 (常量, 字符串)"]
        A[".text段<br/>可执行代码<br/>权限: RX"]
    end
    subgraph " (低地址)"
    end

    H --> G --> F --> E --> D --> C --> B --> A

当程序从硬盘上死气沉沉的原始数据转变为一个完全运行的活动进程时,它的内存有哪些现有的划分?

  • Text段: 这是程序代码的栖身之所。我们编写的代码、命令、main函数等,经过编译后变成机器码,就存放在这个区域。这个区域只能执行和读取(RX),我们无法在此注入或修改代码。

  • R-Data段: 意思是只读数据(Read-Only Data)。这里存放的是你不能修改的数据,比如文本字符串(例如"Hello World")和全局常量。这个区域的权限只有读取。

  • Data段: 这里存放的是已经初始化的变量,即在定义时就被赋予了值的变量。例如 int x = 10;

  • BSS段: 这个区域用于存放未初始化的数据。例如,如果我们定义了一个变量但没有给它赋值,比如 int x;,它就会被放在BSS段,并被赋予一个默认值(通常是零)。

  • 堆 (Heap): 这是程序员的游乐场。当程序员需要预留特定空间时,他们会使用像C语言中的malloc这样的函数,从堆中预留一块空间。堆的增长方向是向上的。

  • 共享库 (Shared Libraries): 程序正常运行所需的库(如系统库、C库等)就存放在这里。这一点对于理解ROP攻击Return-to-Libc攻击至关重要。

  • 栈 (Stack): 函数在这里被处理。当一个函数被调用时,它的栈帧(function frame)、信息、内部定义的变量都存储在栈中。栈的增长方向是向下的。

  • 参数和环境变量: 在内存的最高地址部分,存储着通过终端传递给程序的参数以及包含系统路径等信息的环境变量。

[!TIP] 理解每个内存区域的权限(读、写、执行)是安全研究的关键。NX位正是利用这些权限来防止攻击。

C代码内存布局示例

让我们看看一个C代码示例,以及它的各个部分会去到内存的哪个位置。

#include <stdio.h>
#include <stdlib.h>

int x; // 未初始化全局变量
int y = 20; // 初始化全局变量
const int z = 30; // 全局常量

void main() {
    int c = 40; // 局部变量
    char* ptr = (char*)malloc(sizeof(int)); // 动态分配内存
    printf("Hello World");
}
  • int x;: 将被存储在BSS段,因为它未被初始化。
  • int y = 20;: 将被存储在Data段,因为它有初始值。
  • const int z = 30;: 将被存储在R-Data段,因为它是一个全局常量。
  • int c = 40;: 将被存储在上,因为它是main函数内部的局部变量。
  • ptr: ptr变量本身(存储地址的地方)位于上。但通过malloc分配的4字节空间位于内存中。
  • "Hello World": 这个字符串字面量是一个常量,将被存储在R-Data段

核心战场:栈 (The Stack)

栈是我们在这篇文章中将要战斗的区域。我们必须理解它。

它很简单。栈只是内存中一个预留的区域,数据被临时存储在这里供处理器使用。 它遵循LIFO系统:后进先出(Last In, First Out)。

这意味着最后一个进入栈的值是第一个从栈中出来的值。 就像一叠盘子。

  • PUSH: 向栈中添加一个值。
  • POP: 从栈中拉出最后一个添加的值。

这很简单。但它对于稍后理解缓冲区溢出至关重要。

处理器的大脑:寄存器 (Registers)

你经常听到我说“寄存器”。 这些是位于处理器内部的、速度极快且非常小的存储单元。

寄存器是处理器在进行算术运算时使用的“工作台”。 它们分为两类:

  1. 通用寄存器 (General Purpose Registers): 如EAX, EBX, ECX。我们可以向它们添加值,无论是数字、地址还是其他任何东西。
  2. 专用寄存器 (Special Purpose Registers): 它们有特定的功能和任务,我们不应该随意操纵它们的值。
    • EIP/RIP (Instruction Pointer): 这是“处理器的手指”。它存储着处理器将要执行的下一条指令的地址。
    • ESP/RSP (Stack Pointer): 它指向栈顶,即最后一个被添加的值。

[!NOTE] 寄存器名称开头的 ‘E’ 表示32位架构,而 ‘R’ 表示64位架构(例如 RAX, RSP, RIP)。功能是相同的。

关键概念:栈帧 (Stack Frame)

简单来说,栈帧是每个被调用函数专属的工作空间。

当一个函数被调用时,会为它预留一个工作区。这个工作区就叫做栈帧。 让我们来谈谈这个帧的组成部分。

graph TD
    subgraph "函数栈帧 (高地址)"
        direction TB
        A["参数 (Arguments)"]
        B["返回地址 (Return Address) ↞ **攻击目标**"]
        C["保存的基指针 (Saved RBP)"]
    end
    subgraph " "
        direction TB
        D["局部变量 (Local Variables)<br/>通过 RBP - offset 访问"]
    end
    subgraph " (低地址)"
    end

    A --> B --> C --> D
  • 参数 (Arguments): 我们传递给函数的值。
  • 返回地址 (Return Address): 这是最重要的部分。当函数执行完毕后,处理器需要知道返回到哪里。这个返回地址就存储在这里。如果我们能改变这个地址,我们就能控制程序的执行流程。
  • 保存的基指针 (Saved RBP): 这是调用当前函数的前一个函数的基地址。它用于构建所谓的调用栈(Call Stack),并允许我们在函数返回后恢复前一个函数的工作空间。
  • 局部变量 (Local Variables): 在函数内部定义的变量存储在这里,位于RBP指针下方。

缓冲区溢出的根源

这里有一个关键点:

  • 栈的增长方向是向下的(从高地址到低地址)。
  • 但数据的写入方向是向上的(从低地址到高地址)。

想象一个局部变量是一个4字节的数组。 如果我们向这个数组写入超过4个字节的数据,比如5个字节,我们就会覆盖它旁边的内存。

如果我们继续写入,我们将覆盖其他局部变量,然后是保存的RBP,最终,我们会覆盖返回地址

这就是缓冲区溢出(Buffer Overflow)的核心思想。 我们利用容量溢出,从低地址向上写入,最终覆盖了位于高地址的返回地址,从而劫持了指令指针(RIP)。

返回导向编程(ROP):化整为零的艺术

我们已经可以控制返回地址了,但我们不能简单地将它指向我们在栈上注入的Shellcode。 因为NX位会阻止在可写内存区域(如栈)执行代码。

那么,解决方案是什么? 我们必须使用已经存在的代码。存在于.text段中的代码,因为那个区域被标记为“可执行”。

这就是“剪报勒索信”技术的用武之地。 报纸就是系统内存。 剪下来的单词就是“小工具”(Gadgets)。

[!TIP] 小工具 (Gadget) 是一个简短的指令序列,以 RET 指令结尾。

为什么它必须以RET结尾? 因为RET是“胶水”。它允许我们将这些碎片链接在一起。

RET指令实际上等同于 POP RIP。 它从栈中弹出一个值,并将其放入指令指针寄存器中,然后跳转到那里。

如果我们能控制栈,我们就能控制RIP

ROP链如何工作

想象一下,我们找到了一个 POP RCX; RET 的小工具。

  1. 我们通过缓冲区溢出,将返回地址覆盖为这个小工具的地址。
  2. 在栈上,紧跟在小工具地址后面,我们放置一个我们想要加载到RCX寄存器中的值(例如,一个字符串的地址)。
  3. 再后面,我们放置下一个小工具的地址。

执行流程如下:

  • 函数返回,RIP跳转到我们的第一个小工具。
  • 处理器执行 POP RCX,将我们放在栈上的值弹入RCX
  • 处理器执行 RET,它从栈上弹出下一个地址(我们第二个小工具的地址)并跳转到那里。

通过这种方式,我们执行了一条命令链,而没有注入任何新指令。 我们只是在重用内存中已经存在的字节。

graph TD
    subgraph "栈 (Stack)"
        direction TB
        E["system() 函数地址"]
        D["'RET' 小工具地址 (用于对齐)"]
        C["'cmd' 字符串地址"]
        B["'POP RCX; RET' 小工具地址"]
        A["... 填充字节 ..."]
    end

    subgraph "执行流程"
        F("1. 溢出后, RET跳转到B") --> G("2. 执行 POP RCX<br/>(C的值进入RCX)")
        G --> H("3. RET跳转到D")
        H --> I("4. 执行 RET (对齐栈)<br/>跳转到E")
        I --> J("5. 执行 system('cmd')<br/>成功!")
    end

    A --> B --> C --> D --> E

API vs. ABI:深入理解调用约定

要成功发起攻击,我们还需要理解APIABI之间的区别。

  • API (应用程序编程接口): 是你和编译器之间的契约。它说:“只要写 system 就行了。” 这是高层次的。
  • ABI (应用程序二进制接口): 是可执行文件、处理器、寄存器和操作系统之间的契约。它定义了数据如何传递。

这在不同的操作系统之间是不同的:

  • Windows x64架构中,函数的第一个参数必须放在RCX寄存器中。
  • Linux x64中,第一个参数必须放在RDI寄存器中。

因此,要在Windows上手动调用system("cmd"),我们不能只是将"cmd"推到栈上。 我们必须找到一种方法,将字符串"cmd"的地址移入RCX寄存器。

这就是为什么我们需要一个 POP RCX; RET 小工具。

隐藏的抽象:当数据变成代码

这是魔法发生的地方。 看这行代码:int score = 50009;

对一个普通程序员来说,这是一个变量。一个数字。无害。 但对于逆向工程师来说,我看到了它的物理本质。

我将50009转换为十六进制。它变成了 0xC359。 但等等,在内存中,由于小端序(Little Endian),它被存储为 59 C3

  • 59 对处理器意味着什么?它意味着 POP RCX
  • C3 意味着什么?它意味着 RET

你看到发生了什么吗? 程序员创建了一个变量score。 但他无意中创建了一个“小工具”。他在自己的代码内部创造了一件武器。

这就是“隐藏的抽象”。 将基础现实隐藏在高层抽象之后。

我们剥离抽象。我们必须剥离语言的法则。 忘记“整数”。忘记“字符串”。 回到本源。处理器的物理学。

处理器不区分数据和指令。 它盲目地执行RIP指向的任何东西。 如果我们把RIP指向score变量的中间……它就会把它当作代码来执行。

这就是“非对齐执行”(Misaligned Execution)。

构建终极Payload

现在,我们来构建最终的Payload。 但还有一个陷阱:栈对齐(Stack Alignment)。

system这样的函数内部可能包含要求内存地址是16字节对齐的指令(如movaps)。 如果我们的RSP(栈指针)在跳转到system时没有对齐(即地址末尾不是0),程序就会崩溃。

解决方案出奇地简单: 我们再添加一个“什么都不做”的小工具。 我们在链中添加一个额外的RET指令。

RET会从栈中弹出8个字节。如果栈指针的地址以8结尾,弹出8字节后就会以0结尾。 它修复了对齐问题!

最终的Payload结构

这是我们将发送给程序的内容:

  1. 填充字节 (Padding): 足够多的字节来填满缓冲区,直到覆盖返回地址。
  2. POP RCX; RET 小工具的地址: 用于准备寄存器。
  3. 字符串 “calc” 的指针: 这是我们函数的参数。
  4. RET 小工具的地址: “栈对齐”修复。
  5. system 函数的地址: 最终的目的地。

我们按下回车。 计算器启动了!

我们仅使用系统自身的资源,成功地绕过了NX。

结论

你的知识是你唯一真正的武器。

如果你在真实的交锋中没有IDE、调试器或强大的工具…… 如果你只依赖它们,你将束手无策。

但如果你拥有知识,你就能自给自足。 无论变量如何变化,或者工具是否可用。

我们知道现实与测试环境不同。 在这里,工具有限,我们使用了调试器模式。 但在现实世界中,你可能只有一个终端。 和你对比特的理解。


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?