Java工程化开发与项目实践 - 第2节

2. 内存布局基础

大家好,我是你们这堂课的老朋友。今天咱们聊聊内存布局,这是系统编程安全的基础。说白了,你要是不懂程序在内存里怎么待着,那后面谈漏洞防范就是空中楼阁。

每个进程启动时,操作系统都会给它分配一块虚拟地址空间。这块空间被划分成几个区域,就像一套房子分成客厅、卧室、厨房一样。我习惯把这几个区域叫做:代码段、数据段、堆、栈。

1.1 四个核心区域

区域 存放内容 生长方向 权限
代码段(.text) 程序指令、机器码 固定 只读+执行
数据段(.data/.bss) 全局变量、静态变量 固定 读写
堆(Heap) 动态分配的内存(malloc/new) 向高地址增长 读写
栈(Stack) 局部变量、函数调用信息 向低地址增长 读写

嗯,这里要注意:代码段是只读的。为什么?因为如果有人能改代码段,那你的程序就变成别人的傀儡了。我在项目中遇到过一起事故,就是某个驱动模块不小心把代码段映射成了可写,结果被注入了一段恶意代码——从那以后,我每次检查内存映射都会多看一眼权限位。

核心要点:栈和堆的生长方向是相反的。栈从高地址往低地址走,堆从低地址往高地址走。这种设计是为了让它们能共享一块地址空间,不至于互相撞上。

1.2 地址空间布局的SVG示意图

进程虚拟地址空间布局 高地址 0xFFFFFFFF 内核空间(用户不可访问) 栈(Stack) 局部变量、函数调用帧、返回地址 生长方向 堆(Heap) 动态分配(malloc/new) 生长方向 数据段(.data / .bss) 代码段(.text) 低地址 0x00000000

你看这张图,栈在顶部,堆在中间偏下。为什么栈要放在高地址?因为栈是向下生长的,放在顶部可以充分利用地址空间。我刚开始学的时候总觉得这设计反直觉,后来写了一个递归爆栈的程序,亲眼看到栈指针一路往下冲,才真正理解了。

二、函数调用栈原理:程序执行的“记账本”

函数调用栈,说白了就是CPU用来记录“我现在执行到哪了”、“回来之后该干嘛”的一个数据结构。每次调用函数,都会在栈上压入一个栈帧(Stack Frame)。

2.1 栈帧里有什么?

  • 返回地址:函数执行完后,回到哪里继续执行
  • 局部变量:函数内部定义的变量
  • 保存的寄存器:调用者寄存器的现场快照
  • 参数:传递给被调用函数的参数(部分平台)

一个小技巧:我个人习惯在调试缓冲区溢出漏洞时,先看栈帧里的返回地址。如果返回地址被改写了,那十有八九是栈溢出了。你可以用GDB的info frame命令查看当前栈帧的详细信息。

2.2 一个简单的例子

void funcB(int x) {
    int local = x + 1;
    // 断点打在这里
}

void funcA() {
    int a = 10;
    funcB(a);
}

int main() {
    funcA();
    return 0;
}

当程序执行到funcB里的断点时,栈上的情况是这样的:

  1. main的栈帧:包含局部变量(如果有)和返回地址(指向操作系统)
  2. funcA的栈帧:包含变量a=10,以及返回地址(指向main中调用funcA的下一行)
  3. funcB的栈帧:包含参数x=10,局部变量local=11,以及返回地址(指向funcA中调用funcB的下一行)

你想想看,如果funcB里有个缓冲区,你往里面写了超过它大小的数据,会发生什么?没错,返回地址会被覆盖。这就是经典的栈缓冲区溢出漏洞。我曾经在一个嵌入式项目里排查过一个诡异的崩溃问题,查了三天才发现是某个驱动函数里有个strcpy没做长度检查,把返回地址给冲了。

三、寄存器的作用:CPU的“贴身秘书”

寄存器是CPU内部最快的内存,没有之一。它们直接参与指令的执行。对于系统编程安全来说,有几个寄存器你必须烂熟于心。

3.1 关键寄存器一览

寄存器 全称 作用 安全相关
RSP / ESP 栈指针寄存器 指向当前栈顶 栈溢出攻击时会被篡改
RBP / EBP 基址指针寄存器 指向当前栈帧的底部 用于恢复栈帧,溢出攻击常伪造它
RIP / EIP 指令指针寄存器 指向下一条要执行的指令 控制流劫持的目标
RAX / EAX 累加器 存放函数返回值 ROP攻击中用于传递参数

警告:RIP寄存器是攻击者的“圣杯”。如果能控制RIP,就能让CPU执行任意地址的代码。这就是为什么现代操作系统都实现了NX(No-Execute)位和ASLR(地址空间布局随机化)来保护它。

3.2 函数调用时的寄存器约定

在x86-64架构下,有个调用约定(Calling Convention)。我简单说一下:

  • 参数传递:前6个整数参数用RDI、RSI、RDX、RCX、R8、R9传递,多余的压栈
  • 返回值:放在RAX里
  • 被调用者保存的寄存器:RBX、RBP、R12-R15——被调用函数必须恢复它们的原值
  • 调用者保存的寄存器:RAX、RCX、RDX、RSI、RDI、R8-R11——调用者自己负责保存

嗯,这里有个坑。我曾经在写一个汇编优化的加密函数时,忘了保存R12的值,结果调用者那边的循环计数器被冲掉了。调试了一整个下午才发现是寄存器保存没做对。所以,写汇编或者内联汇编时,一定要遵守调用约定,不然就会出现诡异的“幽灵bug”。

四、总结:安全视角下的内存布局

好了,咱们把今天的内容串一下。理解内存布局,不是为了写个“Hello World”,而是为了知道攻击者可能从哪里下手:

  • 栈:缓冲区溢出、ROP攻击、返回地址篡改
  • 堆:堆溢出、Use-After-Free、Double Free
  • 数据段:全局变量覆盖、格式化字符串漏洞
  • 代码段:如果可写,就是灾难(但现代系统默认只读)

我个人觉得,学安全就像学开车——你得先知道车子的每个部件是干嘛的,才能理解怎么防止出事故。内存布局就是那本“汽车说明书”。下一节课,咱们会深入栈缓冲区溢出,看看攻击者到底是怎么利用这些知识的。

一句话记住:栈向下,堆向上,代码只读,数据可写。寄存器是CPU的贴身秘书,保护好RIP就是保护了程序的“命根子”。