内存安全主要分为空间安全性和时间安全性两个方面,内存安全主要讨论的是代码本身的逻辑错误会不会造成数据损坏或者未定义、不可预见的的逻辑行为。 而OOM实际上并不属于内存安全的范畴,因为它并不会造成数据本身的损坏,只是程序逻辑本身造成的资源管理问题。
空间安全性
缓冲区溢出
本质是访问了合法内存范围之外的地址。一个程序的栈帧是从高地址向下(低地址)增长的,而程序中的临时变量中的数组、字符串等连续内存结构内部的元素索引,是从低地址向高地址增长的。如果在C语言中使用了gets()等方法,他们的行为是默认使用\n换行符来进行输入结束的,
如果你在char password[8]输入了超过8个字符的长度的密码:AAAAAAAA那么就会将多出来的非零值就会直接赋值给auth变量。
更危险的是,不进行长度检查的gets()函数会覆盖返回地址:
函数栈帧:
调用父函数Caller的返回地址RA
父函数的ebp指针地址
临时变量
可见,当变量赋值时若不进行类型检查,是可以直接将RA替换为新程序的地址的,CPU 不会回到 main 函数,而是直接跳到指定的恶意指令去执行。
void get_auth() {
int auth = 0;
char password[8] = "1234567";
printf("input pwd");
gets(password); //不检查长度
if(strcmp(password, "1234567") == 0) {
auth = 1;
}
}
Canary
编译器会使用一种名为金丝雀检查的方法来做检查:编译器在局部变量(缓冲区)和保存的 EBP 及返回地址之间插入一个随机数(Canary),如果黑客通过缓冲区溢出尝试修改序言部分的RA地址等信息那么 这个生成的随机数也会被修改掉。在函数执行 结语 准备 ret 返回之前,加入了一段校验指令来检查 Canary 的值是否与寄存器/TLS中的原始值一致 。如果不一致,调用 __stack_chk_fail 终止程序。黑客植入的Shell就永远不会加载到PC并跳转执行。
ASLR
早期的 ASLR(地址空间布局随机化)主要针对进程的堆、栈以及动态链接共享库(如 libc)的加载基址进行随机化,但主程序的代码段(.text)和数据段通常仍加载在固定地址。 现代系统通过配合编译器的 PIE(位置无关可执行文件) 机制,实现了连同主程序代码段在内的全空间随机化。
在这种全随机化防护下,攻击者无法预知任何绝对地址。由于同一个二进制模块内部(无论是 libc 还是开启 PIE 的主程序),各指令或变量相对于该模块基址的相对偏移量在编译时就已固定。攻击者常利用信息泄露漏洞获取某模块内特定指令的指针,通过“泄露地址 - 固定偏移 = 模块基址”的反推逻辑,也可绕过随机化防护实施攻击。
DEP/NX
DEP/NX (数据执行保护 / 不可执行位)利用 CPU 硬件特性,将内存页标记为“不可执行”。攻击者的恶意代码写在栈上。但DEP 规定栈只能读写,不能执行代码。 即使 RA 被成功修改并跳到了栈上,CPU 也会因为尝试在不可执行区域取指而触发异常。
越界访问
int main()
{
int arr[3] = {1, 2, 3};
printf("%d\n", arr[0]); // 正常
printf("%d\n", arr[3]); // 越界读[3]
printf("%d\n", arr[-1]); // 负数下标
return 0;
}
c语言中越界读是”合法的”,它不会像JS等语言指出undefined或者抛出异常,而操作系统以页为单位进行内存管理,越界访问只要不跨出当前进程已被映射且权限允许的内存页,操作系统底层就不会抛出异常,在逻辑上均是合理的,arr[3]这个地址是已映射且有读权限的,但很明显这会产生未定行为,是不安全的。
如果 size 大小为 0xffffff8 在32位下size + 8 = 0发生整数溢出,那么在堆上分配了很小的内存。实际复制就会发生越界写。
void *buff = malloc(size + 8);
memcpy(buff, input, size);
时间安全性
野指针 | 悬垂指针
野指针指向了无效内存地址,指向的可能是其他人正在使用的空间,也可能是内核不允许访问的空间。由于 free() 后操作系统可能不会立即收回这块内存地址,所以在这时访问它是“安全”的,甚至可以让程序正常的运行而不被察觉问题。
可一旦操作系统收回了这块地址,并被其他程序修改、赋值,那接下来如果该程序继续使用这个悬垂指针就会导致数据损坏或逻辑被劫持。
A = malloc(64)
free(A)
B = malloc(64) // 伪造size=64的函数指针
A->callback() // 执行B
同样的原理,浏览器则是UAF的重灾区:
1. JS 创建一个 DOM 节点对象(含虚函数表指针)
2. JS 删除该节点(free)
3. 攻击者的 JS 立刻 spray 堆,申请大量同尺寸对象
→ 其中一个落在刚释放的地址
→ 写入伪造的虚函数表
4. 浏览器内部代码通过旧指针调用虚函数
→ 跳转到攻击者代码
复杂度不会凭空消失
可当我们在使用Java、Python等现代语言时,大多数场景下并不需要我们手动GC,那么内存安全问题就不存在了吗?
肯定是否定的:内存安全的问题是编程语境下本来就有、本来就需要关注的问题,工具、语言框架、规范流程的引入可以减少手动free等偶然复杂度,可是数据边界,并发访问,某个变量的生命周期,谁拥有这块数据等问题是 问题本身所固有的的复杂度,任何工具都无法消除。而我们更希望的是,当问题出现,编译器能在编译阶段报错并定位问题,而不是当程序已经跑起来之后无声运行,内部却出现了野指针等内存安全问题。
为什么正确性必须优先
async function purchaseProduct(userId: UserId, skuId: SkuId): Promise<Result<OrderId>> {
const price = await ProductService.lookupPrice(skuId);
const remaining = await StockService.remainingStocks(skuId);
if (remaining === 0) {
return err("no stocks");
}
const deducted = await BankService.deduct(userId, price);
if (!deducted) {
return err("insufficient balance");
}
const orderId = await StockService.scheduleShipment(userId, skuId);
return ok(orderId);
}
检查库存、扣款、发货,三个操作之间存在时间间隔,且不是原子的。两个用户同时购买最后一件商品:两人都通过了库存检查,两人都完成了扣款,最后发出了两件实际上只有一件的货。
代码逻辑看起来无懈可击,但在并发场景下,检查和使用之间的任何间隙都是潜在的竞态窗口,这些问题不是工具层面可以解决的偶然复杂度,而是业务本身就需要关注的问题。
Java 的 GC 把手动内存管理的偶然复杂度几乎完全转移给了运行时,代价是 GC 停顿和更高的内存开销。可是上述代码的TOCTOU 问题在 Java 里同样存在,因为它属于本质复杂度。
在应用层,写错了程序崩溃,重启即可恢复。在系统层,代价不对称:一个静默的内存错误可能在几个月后才触发,触发时的崩溃位置和 bug 的根源相距甚远,而在此期间它可能已经被当作攻击面利用。
可用性当然重要。但”能用”是一个太低的标准——它只告诉你程序在你测试的那个场景下、那个时间点、那次内存分配顺序下表现正常。它不告诉你程序是否正确。 正确性优先,不是因为可用性不重要,而是因为错误的可用性比不可用更难发现,也更难修复。