第一章:二进制安全基础

大家好,欢迎来到《二进制漏洞利用技术精讲》。我是你们这门课的主讲人。

在正式开始之前,我想先聊聊一个老生常谈但极其重要的话题:基础。很多人觉得二进制漏洞利用就是拿着现成的exp跑一跑,或者用工具自动fuzz一下。说实话,这种想法很危险。我见过太多人,工具用得贼溜,但一遇到稍微变形的漏洞就彻底抓瞎。为什么?因为底层的东西没吃透。

这一章,我们就来把地基打牢。你想想看,如果你连程序在内存里怎么摆的都不知道,你怎么可能精准地控制它的执行流?

1.1 计算机体系结构回顾

我们先从最宏观的视角看起。一台计算机,说白了就是CPU、内存、I/O设备这三板斧。CPU负责算,内存负责存,I/O负责跟外界打交道。

我个人习惯把CPU想象成一个「超级执行者」。它从内存里一条条地取指令,然后执行。这个过程叫「取指-译码-执行」循环。嗯,这里要注意,这个循环的速度决定了你的程序跑得快不快。

在漏洞利用中,我们最关心的是CPU的两种工作模式:用户态和内核态。用户态下,你只能访问自己的那一亩三分地;内核态下,你拥有整个系统的控制权。我们的终极目标,往往就是让程序从用户态「越狱」到内核态,或者至少拿到内核态才能干的事。

核心概念:冯·诺依曼体系结构。程序和数据存储在同一个内存空间里。这意味着什么?意味着代码可以被当作数据修改,数据也可以被当作代码执行。这是所有缓冲区溢出漏洞的根源。

1.2 内存布局与寻址

好,现在我们聚焦到内存。一个进程的内存空间,在Linux下大致是这样的:

高地址
+------------------+
| 内核空间         |  (用户不可见)
+------------------+
| 栈 (Stack)       |  (向下增长)
+------------------+
| 堆 (Heap)        |  (向上增长)
+------------------+
| BSS 段           |  (未初始化全局变量)
+------------------+
| 数据段 (Data)    |  (已初始化全局变量)
+------------------+
| 代码段 (Text)    |  (程序指令)
+------------------+
低地址

这个布局图,我建议你刻在脑子里。每次分析漏洞时,你都要问自己:我现在操作的是哪块内存?

寻址方式,简单说就是CPU怎么找到你要的数据。最常见的是「基址+偏移」模式。比如 mov eax, [ebx+0x10],意思就是把ebx寄存器的值加上0x10,得到一个新的地址,然后从这个地址里取出数据放到eax里。

我在项目中遇到过很多次,新手搞不清楚「地址」和「地址里存的值」的区别。比如一个指针变量,它本身也有地址,它里面存的是另一个地址。千万别搞混了。

避坑指南:我曾经在调试一个堆溢出漏洞时,花了整整两天时间,最后发现是寻址模式写错了。x64下默认是64位寻址,但我用了32位的寄存器去访问,结果地址被截断了。记住:在x64下,指针是8字节,不是4字节。

1.3 寄存器与栈帧

寄存器是CPU内部的高速存储单元。我们重点关注几个关键的:

寄存器 x86名称 x64名称 主要用途
通用寄存器 EAX, EBX, ECX, EDX RAX, RBX, RCX, RDX 算术运算、数据暂存
栈指针 ESP RSP 指向当前栈顶
基址指针 EBP RBP 指向当前栈帧底部
指令指针 EIP RIP 指向下一条要执行的指令

栈帧是什么?说白了,就是每次函数调用时,在栈上分配的一块「临时工作区」。它里面保存了:

  • 函数的局部变量
  • 函数的参数(部分)
  • 返回地址(最关键!)
  • 保存的基址指针(旧EBP/RBP)

你想想看,如果我们能控制返回地址,是不是就能控制程序跳转到任意地方?这就是栈溢出漏洞的核心原理。

警告:千万不要在栈上分配过大的局部变量。比如 char buf[1024*1024],这会导致栈溢出,直接覆盖到其他线程的栈空间。我见过一个线上事故,就是因为一个程序员在递归函数里分配了大数组,结果把栈给撑爆了,整个进程崩溃。

1.4 汇编语言基础(x86/x64)

汇编语言是二进制安全的「母语」。你不需要成为汇编高手,但必须能读懂常见的指令。

我挑几个最常用的说说:

; 数据传送
mov eax, 0x1234      ; 把立即数0x1234放到eax
mov ebx, [eax]       ; 把eax指向的内存里的值放到ebx

; 算术运算
add eax, 0x10        ; eax = eax + 0x10
sub esp, 0x20        ; esp = esp - 0x20 (分配栈空间)

; 栈操作
push eax             ; 把eax的值压入栈顶
pop ebx              ; 从栈顶弹出值到ebx

; 控制流
call func            ; 调用函数func (会把返回地址压栈)
jmp 0x8048000        ; 无条件跳转到地址0x8048000
je label             ; 如果相等则跳转 (条件跳转)

; 函数返回
ret                  ; 从栈顶弹出返回地址并跳转

x64和x86的主要区别:

  • 寄存器变成64位:RAX, RBX, ... R15
  • 多了8个通用寄存器:R8-R15
  • 参数传递方式变了(见下一节)
  • 地址空间更大,但指令编码更复杂

我个人习惯在分析漏洞时,先用objdump或IDA把目标二进制反汇编,然后重点关注call和ret指令附近的代码。因为这里最容易出问题。

1.5 调用约定(cdecl/stdcall/fastcall)

调用约定,说白了就是函数之间怎么「约好」传递参数和清理栈的规则。不同的编译器、不同的操作系统,用的约定可能不一样。

约定名称 参数传递方式 栈清理者 常见平台
cdecl 从右向左压栈 调用者 Linux x86, Windows C库
stdcall 从右向左压栈 被调用者 Windows API
fastcall 前两个参数用ECX/EDX,其余压栈 被调用者 Windows内核, 部分编译器优化
x64 (Microsoft) 前四个参数用RCX/RDX/R8/R9,其余压栈 调用者 Windows x64
x64 (System V) 前六个参数用RDI/RSI/RCX/RDX/R8/R9,其余压栈 调用者 Linux x64, macOS

为什么这个重要?因为如果你搞错了调用约定,你构造的ROP链或者shellcode就会崩溃。举个例子:

; cdecl 调用方式
push arg3
push arg2
push arg1
call func
add esp, 0x0C    ; 调用者清理栈 (3个参数 * 4字节)

; stdcall 调用方式
push arg3
push arg2
push arg1
call func        ; 被调用者内部会执行 ret 0x0C 来清理

我曾经在分析一个Windows内核漏洞时,因为没注意它用的是fastcall,结果构造的payload总是蓝屏。后来才发现,前两个参数是通过ECX和EDX传递的,而不是压栈。嗯,这个坑我踩过,你们就别再踩了。

总结一下:这一章我们覆盖了二进制安全最核心的基础知识。从计算机体系结构到内存布局,从寄存器到栈帧,再到汇编和调用约定。这些内容看似枯燥,但它们是后续所有漏洞利用技术的基石。我建议你花时间把每个概念都亲手验证一遍,比如写个小程序,用gdb单步调试,看看栈帧是怎么变化的。

下一章,我们将正式进入「缓冲区溢出」的世界。准备好了吗?