跳到主要内容
段错误是你唯一看得见的那次越界

段错误是你唯一看得见的那次越界

0x7F
0x7F

· 阅读约 7 分钟

struct frame {
    uint32_t len;        /* 外面来的,发的人说什么就是什么 */
    uint8_t  body[64];
};

memcpy(dst, f->body, f->len);

这段代码不会崩。它把 body 后面紧挨着的那几十上百个字节一起抄进 dst,跟着别的字段原样发回去。一次 SIGSEGV 都不用触发。

真正难受的地方在这儿:它没错得很难看。它错得刚刚好,读完了,写完了,响应正常,服务正常。

段错误为什么不常来

进程手里是虚拟地址,管事的是页表。内存按 4KB 一页切,页表把虚拟页指到物理帧。CPU 访存,地址先交给 MMU,翻到了放行,翻不到就拦——硬件抛页错误(中断 14),内核接住,往进程里扔一个 SIGSEGV。你没装处理函数,进程终止。

粒度是全部的关键。MMU 按页翻表,不按你的缓冲区翻。它不知道 body[64] 的边界在哪,也不关心,它只在"这一页有没有映射"这个层面做判断。

你越界了但没跨页,谁都管不着。

反过来,能被段错误抓住的越界,是那种一脚踩进未映射区域的——最难看的、动静最大的那种。抓它不需要什么检测机制,是物理层面刚好没给你这块地。

想通这件事,段错误就不是"内存坏了",它是隔离机制在正常工作。只是这道机制保护的是进程和进程之间、进程和内核之间,不是你的 body 和它后面那片内存之间。

换成攻击者的思路

他要的从来不是让你崩。

崩了,你去重启,去翻日志,去查 core dump,顺手还把现场破坏了。他要的恰恰是那个不崩的版本:读完之后照常应答,监控曲线平平的,日志干干净净。拿你数据的人不会敲门,会安安静静把你的响应一封一封收走。

站在攻击者那边看,这个环境里他手里有什么:

  • 指针就是个整数。char*、int*、指向几 KB 结构体的指针,sizeof 一下都是 8。跟别的整数唯一的区别,是解引用时 CPU 拿它当地址访存。
  • 类型只活在编译期。声明一个 32 位 int,编译器留 4 个连续字节,把名字记在起始地址上;编成机器码之后,名字没了,类型也没了,CPU 手里只剩地址和操作码。它不会在加法之前检查你走没走过头。
  • 数组没有元数据,就是一段连续字节。arr[i] 规范上等价于 *(arr + i),方括号是语法糖。因为加法可交换,*(arr+2) 和 *(2+arr) 等价,所以 2[arr] 也能过编译。别写这个,它不是冷知识,是坑。
  • 指针算术按所指类型缩放:int* 加 1 走 4 字节,char* 加 1 走 1 字节,double* 加 1 走 8 字节。缩放是编译期算好的,运行时没人复核。

摞起来看:C/C++ 进程里越界是个零摩擦动作。没有任何一层会在你多读一个字节的时候拦你。唯一的例外是页表,而页表是 4KB 粒度。

那便宜又有效的拦截点在哪?在长度。而长度恰好是外面给的。

可控长度 → 越界读 → 多出来的字节混进正常响应 → 外带。

四步,全程零段错误。你找的那个入口,一次都没打算让你看见。

页内那几百字节里到底躺了什么

这才是要算的账。越界读要读到有意义的东西,得看那块内存刚被谁用过。

栈:x86_64 里栈顶归 RSP 管,函数进来给局部变量分配空间就是把 RSP 减一个数,返回就是加回去。

sub rsp, 0x20      ; 腾出 32 字节给局部变量
...
add rsp, 0x20      ; 回收——只是把栈顶挪回去

第二行干了什么?挪了个指针。那 32 字节一个都没擦。CPU 没有免费的清零动作。函数返回之后,密钥、token、上一个栈帧里的返回地址,全都还留在原地,等着下一个函数在同一位置放自己的东西。中途谁用一个没写满的 char buf[64],读到的就是上一个住户的残留。

堆:free 的语义是"我不住了",不是"房子拆了"。分配器把那块标回可分配——它在空闲块链表里找位置,找不到就 brk 或者 mmap 向内核要——但内容没擦,指向它的那个指针变量里的地址也没清。

int *p = some_alloc();
...
free(p);
/* p 里还是那个地址。那块内存可能已经归下一个人了。 */

同一块字节于是有了两个"主人",谁后写谁赢。这就是 use-after-free。这类洞不惊艳,但在真实目标上是最省事的入手点之一——不需要什么花哨绕过,只要被复用的那块里恰好有想读的东西。

这条跟"key 进过一次日志就已经不是你的了"是同一个道理,粒度更小而已:free 不负责替你保守秘密。删聊天记录是心理安慰,free 之后不置 NULL 也是。

最低成本的动作就一行:free(p); p = NULL;。强调"立刻",是因为从 free 到下一次有人读 p 之间,是完整的漏洞窗口,中间任何一次间接调用都可能踩进去。

顺带说 int**。它存在只是因为 C 按值传递:要改调用者的 int,传 int*;要改调用者的指针变量,才得传 int**。那些 allocate_memory(int**) 的签名都是这条规则的副产品,不是"更高级的间接"。别把间接层数当设计深度。——离题了,收。

唯一一把真正锁上的门

地址 0x0 通常不映射。这是你进程里唯一一个稳定会被拒绝的地址。

拿 NULL 去解引用,等于把一个没映射的地址交给 MMU,页表查不到,硬件页错误,内核接住,SIGSEGV。整条链路一丝不漏。

看清楚它护住的范围:只在你踩出这张表之外的时候才响。进程里映射着的那几百兆、几个 G,全是敞开的。剩下的边界靠映射表的粒度撑着,而那是 4KB。

说句不客气的,现代系统内存保护的一大半功劳,来自"地址 0 正好没映射"这么个巧合。

分级

这个我一开始判重了。看见段错误就当严重内存问题,要连夜查。后来把触发条件摊开看:它是保护机制在正常工作,是防线在响,不是防线在破。级别下调——但没归零。

真正该连夜处理的是它没出现的那类:可被外部影响的长度、跟着数据一起传进来的长度字段、释放后还在被引用的指针、用完没清的敏感缓冲区。共同点——不崩,不报,不写日志。

我这么划:

  • 会崩的越界(低危):多半就是个 bug,崩了反而提醒了你。
  • 不崩的越界读,且页内恰好躺着密钥、地址、会话残留(高危):因为它是静默的。
  • use-after-free(高危):同理,而且窗口期不由你决定,取决于那块内存什么时候被复用。

拆弹清单

  1. 长度不跟数据走。外部进来的 len、offset、count,用于拷贝或索引前,跟缓冲区的真实容量比一次。别拿"应该不会超"换这一步。
  2. 解析不可信输入的代码,别跟持有密钥的代码住在同一个进程里。越界读的爆炸半径等于进程边界,切成两个进程,能读走的就从"全部"缩成"那个解析器的局部状态"。
  3. free 之后立即置 NULL。一处置空,处处认为它没了。
  4. 敏感缓冲区别指望"函数返回就没了"——栈不清零。用完自己处理掉,别留着等下一个栈帧复用。
  5. 内存错误的检测放在开发期,别指望运行时。编译器警告全开,边界值灌进模糊测试。运行时那个信号只告诉你最难看的一种,剩下的全静默。

真正的失陷是安静的。你收到告警那次,往往还不是最糟的那次——最糟的那次,你的进程到现在都还在正常应答。

排雷记录:段错误是防线在响。防线没响的那些,才是要查的。