操作系统学习笔记 · 第 18 课 · 安全与保护——内存与系统的防线
前面几课反复出现的"段错误""只读不可执行"这些字眼,其实都在暗示一件事:操作系统在偷偷保护你。这一课,我们站到攻击者的视角,看一个程序是怎么被攻破的——以及操作系统用什么手段,把这道防线一层层垒起来。这也是第四阶段(内存管理)的收官课。
从"一个程序崩溃,为什么系统不崩"说起
你可能遇到过:某个程序崩溃了,弹出"段错误(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 ][ 返回地址 ]
写超长数据 → 覆盖返回地址 → 函数返回时跳到攻击者指定位置原理拆解:
- 函数里的局部数组
char buf[8]分配在栈上(第 15 课:局部变量在栈); - 栈上,紧接着
buf的,是返回地址——函数执行完要"跳回哪里"; - 如果程序用
strcpy把超长字符串拷进buf,不检查长度,多出来的字节就会溢出,覆盖掉返回地址; - 函数返回时,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 拦定位、内核隔离拦提权;
- 最小权限原则:权限越小,被攻破损失越小。
留三个问题:
- 操作系统靠什么机制阻止一个进程读别的进程内存?
- 缓冲区溢出攻击利用了什么?
- 栈金丝雀、NX、ASLR 分别防什么?
到这里,第四阶段(内存管理)收官——地址、虚拟内存、页面置换、安全保护,四课走完。从下一课起,我们进入第五阶段:存储与 I/O。内存是"电"里存的东西,一断电就没了;那你的文件到底是怎么持久地存到磁盘上的?"文件名"和"文件"是同一个东西吗?下一课,我们走进文件系统。