前面几课反复出现的"段错误""只读不可执行"这些字眼,其实都在暗示一件事:操作系统在偷偷保护你。这一课,我们站到攻击者的视角,看一个程序是怎么被攻破的——以及操作系统用什么手段,把这道防线一层层垒起来。这也是第四阶段(内存管理)的收官课。

从"一个程序崩溃,为什么系统不崩"说起

你可能遇到过:某个程序崩溃了,弹出"段错误(Segmentation fault)",但系统还好好的,其他程序照跑

为什么一个程序崩溃,不会把整台机器搞挂?因为操作系统给每个进程划了独立的内存空间,并且处处设卡——你只能在自己的一亩三分地里活动,越界就立刻被拦下。

这一课的三个问题:保护靠什么实现?攻击者怎么绕过?现代系统又怎么反击?


18.1 内存保护靠什么

一句话理解:页表里每个页都有权限位(读/写/执行、内核态专属)。用户态程序访问越权页,CPU 直接报段错误(SIGSEGV)。

还记得第 16 课的页表吗?它不只是"虚拟页 → 物理页"的对照表,每个页还带着权限位

  • 读(r):能不能读;
  • 写(w):能不能写;
  • 执行(x):能不能把这页当指令执行;
  • 内核态专属:这页只有内核能碰,用户态禁止访问。

于是:

  • 代码段是"只读 + 可执行"(r-x)——你想写它 → 段错误
  • 数据段是"可读可写 + 不可执行"(rw-)——你想跳过去执行它 → 段错误
这就是为什么/proc/self/maps里每段都有权限列(上一课见过)。每一次内存访问,MMU 都会检查权限,越权就立刻触发异常(SIGSEGV)。这就是内存保护的第一道闸。

而"内核态/用户态"是第二道闸:用户程序跑在用户态,碰不到内核的内存,也执行不了特权指令——内核内存和硬件资源被一道"态"的墙隔开


18.2 缓冲区溢出攻击

防护讲完了,那攻击者是怎么攻破程序的?最经典的,就是缓冲区溢出(Buffer Overflow)

一句话理解:程序给一个局部数组分配了 10 字节,却写进去 100 字节,多出来的覆盖了栈上相邻的"返回地址"——攻击者能借此让程序跳到恶意代码。

看一个典型栈布局:

栈布局:[ buffer ][ 返回地址 ]
写超长数据 → 覆盖返回地址 → 函数返回时跳到攻击者指定位置

原理拆解:

  1. 函数里的局部数组 char buf[8] 分配在上(第 15 课:局部变量在栈);
  2. 栈上,紧接着 buf 的,是返回地址——函数执行完要"跳回哪里";
  3. 如果程序用 strcpy 把超长字符串拷进 buf不检查长度,多出来的字节就会溢出,覆盖掉返回地址;
  4. 函数返回时,CPU 按"返回地址"跳转——但这个地址已经被攻击者篡改,于是跳到了攻击者想让它去的地方(比如一段恶意代码)。
一句话:缓冲区溢出 = 写越界覆盖返回地址 = 劫持程序控制流。 这是历史上最多的一类安全漏洞。

18.3 现代防护手段

针对缓冲区溢出,现代系统垒起了多道防线:

防护作用
栈金丝雀(stack canary)在返回地址前放哨兵值,被覆盖就检测到并终止
NX / DEP(不可执行)栈/堆不可执行,跳过去也跑不了
ASLR随机化地址,让攻击者猜不到要跳哪
内核态隔离用户态无法访问内核内存

逐个看:

① 栈金丝雀(stack canary)

在返回地址前面放一个随机"哨兵值"。攻击者要覆盖返回地址,必然先经过哨兵;函数返回前检查哨兵,被改了就立刻终止,报"stack smashing detected"。

名字的来历很有意思:就像煤矿里放一只金丝雀,有毒气它先死,矿工就知道危险了。哨兵值就是那只"报警的金丝雀"。

② NX / DEP(数据不可执行)

给栈、堆标记为"不可执行"。即使攻击者把自己的恶意代码写进栈,CPU 也拒绝执行——跳过去就是段错误。

③ ASLR(地址空间布局随机化)

第 15 课讲过:每次运行,栈/堆/库的起始地址随机。这招是让攻击者猜不到"返回地址要改写成什么值"——连目标地址都不知道,攻击自然失效。

④ 内核态隔离

用户态代码永远碰不到内核内存。即使一个进程被攻破,也无法拿到内核权限去控制整台机器。

记住这四道防线:金丝雀拦"覆盖",NX 拦"执行",ASLR 拦"定位",内核态隔离拦"提权"。它们不是万能的,但让攻击成本成倍上升。

18.4 最小权限原则

最后一条,不是技术机制,而是设计哲学

一句话理解:程序只拿它需要的最小权限(能不用 root 就不用 root),被攻破时损失也最小。

例子:

  • 一个 Web 服务,不需要 root 权限,就用一个专用低权限用户去跑。这样即使它被攻破,攻击者拿到的也只是一个"低权限用户",干不了太多坏事;
  • 容器用 namespace / cgroup 限制(第 29 课细讲),本质也是"给它最小的一块天地"。
一句话:权限越小,被攻破后的爆炸半径越小。 这是安全领域最朴素也最重要的原则。

动手实验:C 语言演示缓冲区溢出

// lesson18.c —— 缓冲区溢出演示(教学用,勿用于非法用途)
#include <stdio.h>
#include <string.h>

void vulnerable(char *input) {
    char buf[8];              // 只分配 8 字节
    strcpy(buf, input);       // 不检查长度,直接拷贝
    printf("buf 内容:%s\n", buf);
}

int main(int argc, char **argv) {
    if (argc < 2) { printf("用法:%s <字符串>\n", argv[0]); return 1; }
    vulnerable(argv[1]);
    printf("安全返回了\n");
    return 0;
}

演示(对比不同输入、不同编译选项的结果):

gcc -fno-stack-protector lesson18.c -o lesson18_vuln   # 先关保护,看溢出
./lesson18_vuln "hello"          # 正常
./lesson18_vuln "AAAAAAAAAAAAAAAAAAAA"   # 超长,可能崩溃(段错误)

gcc -fstack-protector-all lesson18.c -o lesson18_safe  # 开栈保护
./lesson18_safe "AAAAAAAAAAAAAAAAAAAA"   # 被金丝雀拦下,报 stack smashing

对比两个版本:

  • 关保护-fno-stack-protector)的版本,输入超长字符串可能直接崩溃(段错误)——因为返回地址被破坏;
  • 开保护-fstack-protector-all)的版本,被栈金丝雀检测到,打印"stack smashing detected"后主动终止——而不是任由控制流被劫持。
亲手跑一下,你会直观体会到"同一份代码,有没有防护,结局完全不同"。这就是金丝雀的价值。

深入点:攻防的军备竞赛

① ROP(Return-Oriented Programming,面向返回编程)

NX 让攻击者不能直接执行自己注入的代码了。攻击者于是想出更狡猾的招:不注入代码,而是复用程序里已有的"小片段"(gadget)

程序里本来就有很多以 ret 结尾的小指令片段(gadget)。攻击者把返回地址串成一串,让程序一个接一个地跳转这些小片段,像用乐高拼出一段"借来的代码"。这就绕过了 NX。

② KASLR / SMEP / SMAP

攻防升级到内核层面:

  • KASLR:把内核的地址也随机化;
  • SMEP:阻止用户态代码去执行内核态的内存;
  • SMAP:阻止用户态代码去读/写内核态的内存。
一句话:安全不是一劳永逸的,而是攻击者和防御者之间持续的军备竞赛。 你今天堵上一个洞,明天就可能有人找到新的绕过方式。

小结与思考题

这一课,我们看清了内存与系统的防线:

  • 内存保护:页表权限位(r/w/x)+ 内核态/用户态隔离,越权即段错误;
  • 缓冲区溢出:写越界覆盖返回地址,劫持控制流——最经典的攻击;
  • 现代防护:栈金丝雀拦覆盖、NX 拦执行、ASLR 拦定位、内核隔离拦提权;
  • 最小权限原则:权限越小,被攻破损失越小。

留三个问题:

  1. 操作系统靠什么机制阻止一个进程读别的进程内存?
  2. 缓冲区溢出攻击利用了什么?
  3. 栈金丝雀、NX、ASLR 分别防什么?
到这里,第四阶段(内存管理)收官——地址、虚拟内存、页面置换、安全保护,四课走完。从下一课起,我们进入第五阶段:存储与 I/O。内存是"电"里存的东西,一断电就没了;那你的文件到底是怎么持久地存到磁盘上的?"文件名"和"文件"是同一个东西吗?下一课,我们走进文件系统

标签: 计算机基础, 操作系统, Linux

添加新评论