第一章:二进制安全基础
大家好,欢迎来到《二进制漏洞利用技术精讲》。我是你们这门课的主讲人。
在正式开始之前,我想先聊聊一个老生常谈但极其重要的话题:基础。很多人觉得二进制漏洞利用就是拿着现成的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单步调试,看看栈帧是怎么变化的。
下一章,我们将正式进入「缓冲区溢出」的世界。准备好了吗?