ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

越界读取:比越界写更隐蔽的崩溃元凶及排查实战

越界读取:比越界写更隐蔽的崩溃元凶及排查实战 2. 越界读取 vs 越界写入为什么读错方向更隐蔽后果却一点都不轻先说一个很多人容易踩的误区一提到内存越界大家第一反应往往是“写坏了别人的数据”。确实越界写buffer overflow在安全圈和崩溃分析里都名声在外但越界读out-of-bounds read才是真正的“隐形杀手”。越界读的意思是程序访问了它不应该访问的内存区域但只是“看”了一下并没有“改”。按常理说只是读一下总不会破坏数据吧但从崩溃现场来看这个想法非常天真。越界读导致崩溃的路径主要有三条每一条都是实打实的读到非法地址直接触发段错误。这是最直观的情况。比如你分配了一个100字节的缓冲区结果指针偏移到了第200字节的位置那块内存可能压根没有映射到进程的地址空间里。当CPU执行mov eax, [rdioffset]这条指令时MMU直接抛出一个page fault内核检查后发现这个地址属于非法访问于是向进程发送SIGSEGV信号。程序如果没有对应的信号处理函数直接就是崩溃。读到了敏感数据结构导致控制流被劫持。这是最微妙也最危险的情况。如果你越界读到的内存恰好在堆上而堆内存里有对象的虚函数表指针vptr、堆元数据chunk header或者函数指针那读出来的值就会变成“合法”的数据参与程序逻辑。轻则逻辑错乱重则把某个指针当函数入口调用——这时候崩不崩溃完全看运气运气好的时候只是计算结果不对运气差的时候直接跳到非法地址。读到了多线程共享数据引发数据竞争和连锁崩溃。这种情况在并发场景里特别常见。一个线程越界读到了另一个线程正在修改的临界区数据于是读到一个“半更新”的状态程序判断逻辑直接跑偏最终走到一个前所未有的分支然后崩溃。这类崩溃最头疼因为它的根因在越界读但表象可能在几十行代码之外。结合网络热搜里那些“Qt崩溃”“Windows资源管理器崩溃”“UE4崩溃”的现象大量崩溃的本质根因都是越界读取。尤其是Windows资源管理器那种“任务栏一碰就崩”“资源管理器动不动就重启”的情况很大概率是Shell扩展或第三方组件读取了被释放的内存或者越界读取了正在变化的数据。这类问题之所以难排查是因为它不像越界写那样“破坏现场”它只是“看错了”而看错之后引发的连锁反应往往离现场十万八千里。3. 一次典型的越界读调试实录从“偶发崩溃”到“真凶落网”纸上谈兵说了这么多我用一个高度还原的案例把完整的排查过程拆开讲一遍。这是一个典型的服务端程序运行在Linux环境C写的负责处理网络请求。现象是程序运行几个小时到几天不等会随机崩溃一次没有任何固定规律。日志里只有一行简单的“Segmentation fault”core dump开了一段时间但没配置好所以没拿到。这类“偶发崩溃”的排查最怕的就是没有现场。所以我的第一步不是找bug而是先把现场能力建起来。3.1 现场重建打开core dump并验证可用性Linux下打开core dump的标准做法# 永久生效写进 /etc/security/limits.conf * soft core unlimited * hard core unlimited # 或者临时生效只对当前shell ulimit -c unlimitedulimit -c unlimited只是让内核允许生成core文件还需要确认core文件的生成路径和命名格式。推荐这样配置# 写入 /etc/sysctl.conf kernel.core_pattern /data/coredump/core_%e_%p_%t sysctl -p%e是程序名%p是PID%t是时间戳。这个配置的关键点在于一定要把core文件放到一个独立的大分区里并且确认目录有写权限。我见过太多人配了core_pattern但目录不存在结果崩溃了只留下一个空的core目录。验证方法很简单找一个进程直接kill掉kill -SEGV 任意进程PID如果/data/coredump下生成了core文件就说明现场能力已经就绪。这一步不做后面全是盲人摸象。3.2 从core dump里抓第一手线索程序再次崩溃后我拿到了core文件。第一步永远是先看崩溃时的调用栈gdb /usr/local/bin/your_program /data/coredump/core_your_program_12345_1690000000 (gdb) bt崩溃栈往往是最直接的线索但必须警惕对于越界读崩溃栈可能完全不相关。因为程序可能在内存损坏后的任意时刻崩溃栈上看到的函数只是“压倒骆驼的最后一根稻草”不是根因。我这次遇到的崩溃栈指向了一个字符串处理函数。从语义上看这个函数在解析一个网络包里的字段。栈上看到的是#0 strlen () at ../sysdeps/x86_64/strlen.S:120 #1 std::char_traitschar::length (__s0xdeadbeef...) #2 std::string::assign () #3 ParsePacketField () #4 ProcessPacket ()注意__s0xdeadbeef...这个值。0xdeadbeef是一个经典的“已释放内存填充值”在很多内存分配器的调试模式下释放后的内存会被填充成固定模式以便检测。如果源字符串指针指向0xdeadbeef开头的内容说明这里在操作一块已经被释放的内存。但我的程序用的不是调试模式分配器这个值反而提示可能有别的问题。继续查看崩溃的上下文看寄存器和附近的栈内容(gdb) info registers (gdb) x/32gx $rsp-128x/32gx可以把栈内存打出来看看崩溃附近的栈里有没有可疑的指针。这招类似刑侦里的“翻垃圾桶”崩溃现场的内存里往往留有之前被越界读污染过的数据残骸。3.3 静态分析让编译器替你做一遍体检动态调试给出方向后我同步用静态分析工具做了一轮扫描。这里推荐两个实用的cppcheck轻量但胜在快适合快速扫明显问题cppcheck --enableall --inconclusive --stdc11 src/ 2 cppcheck_report.txtclang-tidy更强大但配置门槛也高clang-tidy src/*.cpp -checksclang-analyzer-*,-clang-analyzer-alpha* -header-filter.* -- -stdc11 -Iinclude/clang的静态分析器对越界读的检测能力很强尤其是数组下标和迭代器访问这类问题。它能在编译期就发现问题点比运行时崩溃早得多。静态分析的价值不在于“一定能找到bug”而在于“用几分钟时间扫一遍比人工代码审查覆盖面更广”。3.4 AddressSanitizer越界读排查的核武器静态分析扫完没发现明显问题我把重点转向了ASan。这是Google开发的运行时内存错误检测工具配合GCC/Clang使用对越界读的检测能力极其强悍。编译时的做法g -fsanitizeaddress -g -O1 -fno-omit-frame-pointer \ -Iinclude/ src/*.cpp -o your_program_asan然后跑相同的工作负载。ASan的工作原理是在每次内存访问前后插入检查代码配合影子内存shadow memory记录哪些字节是可访问的。一旦发生越界读它会立刻报告而不是等到程序崩溃45231ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018 at pc 0x0000004e3b2a bp ... READ of size 4 at 0x602000000018 thread T3 #0 0x4e3b29 in ParsePacketField src/packet.cpp:188:16 #1 0x4e3e77 in ProcessPacket src/packet.cpp:220:9 ... 0x602000000018 is located 0 bytes to the right of 16-byte region [0x602000000008,0x602000000018) allocated by thread T0 here: #0 0x4e2b09 in operator new(unsigned long) #1 0x4e3a11 in BuildPacket src/packet.cpp:150:33看到这种输出根因已经水落石出了packet.cpp第188行读取了4字节而这4字节恰好位于一个16字节堆块的右边界之外。这个堆块是第150行分配的。“READ of size 4”直接告诉你这是越界读而不是写。定位到代码后才发现问题非常微妙// 简化后的代码 memcpy(ptr, payload, header.data_len 1);data_len字段本身是从网络包头部读出来的一个受信值但代码里做了一个“多读一个字节”的操作本意是为后续字符串处理预留\0终结符。问题是如果payload的缓冲区大小是由另一个字段比如buffer_size控制的而data_len和buffer_size之间存在一个字节的偏差那每次处理小包的时候边界是紧贴着分配的data_len 1就会超出16字节对齐的分配区域一个字节。这正解释了为什么是偶发崩溃——只有在块边界恰好对齐到页面尾部时才触发SIGSEGV。这种“多读一个字节”的写法在实践中非常常见在嵌入式领域和网络协议解析领域尤其多。很多人觉得多读一个字节无伤大雅但堆分配器分配的内存通常按16字节对齐向上取整如果data_len1跨过了取整边界就是一次标准的heap-buffer-overflow。3.5 另一种隐形越界读字符串截断与NUL终结符缺失还有一种非常常见的越界读场景出在从网络或文件读取字符串但没正确处理NUL终结符上。char buf[32]; recv(fd, buf, 32, 0); std::string str(buf); // 危险recv返回的实际字节数可能小于32但buf里没有\0结尾。std::string的构造函数会从buf开始一直向后找\0直到找到为止。如果这块栈内存后面的数据恰好没有\0越界读就会一直持续直到读到某个不可读的内存页才崩溃——而这中间读了多少垃圾数据根本无从知道。正确做法是char buf[33]; ssize_t len recv(fd, buf, 32, 0); buf[len] \0; // 主动添加终结符但必须先判断 len 0 std::string str(buf);更严谨一点直接使用带长度限制的接口std::string str(buf, len);网络编程里的字符串处理一定要养成“带长度传递”的习惯。不要想着“我先转成C字符串再传给std::string”这中间每一步都可能是越界读的温床。4. 越界读常见场景盘点对着清单去排查效率翻倍排查越界读崩溃最怕的是漫无目的地猜测。根据这些年踩坑的经验我整理了一份高频场景清单。每次遇到类似问题我都会对着清单逐项排查效率比从头分析高得多。4.1 数组下标与循环边界最经典的越界来源数组越界读是所有越界读问题里最直白的一种也是最容易犯的一种。// 经典错误1off-by-one int arr[10]; for (int i 0; i 10; i) { // i10时越界 arr[i] 0; } // 经典错误2sign对比导致的越界 std::string s hello; int len s.size() - 1; // 有符号整数 for (int i 0; i len; i) { // 如果len变成负数循环根本不会执行 ... }注意第二个例子里藏着一个更隐蔽的问题s.size()返回的是size_t无符号整型如果直接拿它和负数比较或者做减法后又赋给有符号变量就可能出现隐式的类型转换。这是C/C里最臭名昭著的坑之一——无符号和有符号混合运算。建议的规避方法是// 统一使用无符号类型避免隐式转换 size_t len s.size(); for (size_t i 0; i len; i) { ... }4.2 指针运算和内存边界多跨一步就出事这类问题一般出现在自己管理内存的代码里比如内存池、环形缓冲区、自定义分配器等。char* pool new char[100]; char* p pool; // ... 业务逻辑不断推进p ... // 危险操作没有检查p是否还在pool的合法范围 data_len *(int*)p; // 如果p已经越过pool末尾这里就是越界读排查这类问题时别只看业务逻辑先确认每个指针推进的位置都有对应的边界校验。能写成“指针 长度”同时传递的地方就不要只传指针能传“起始位置 结束位置”的迭代器结构就不要只传一个裸指针。4.3 字符串与序列化永远不要相信外来输入的长度字段网络协议、文件格式、序列化数据的解析是越界读的高发区。核心原则是所有从外部来的长度字段在做边界检查前都是不可信的。// 危险写法直接信任长度字段 uint32_t len ReadUnaligned32(ptr); char* data ptr sizeof(uint32_t); SafeMemcpy(target, data, len); // 如果len非法直接越界读 // 安全写法先校验 if (remaining_size sizeof(uint32_t) len) { // 格式错误拒绝处理 }还有一个很容易被忽视的细节长度字段做完算术运算后可能溢出。比如uint32_t len ...; if (offset len total_size) { // 潜在整数溢出 Read(ptr offset, len); }如果offset len超出了uint32_t的表示范围就会回绕wrap around导致校验形同虚设。正确的做法是if (offset total_size len total_size - offset) { Read(ptr offset, len); }或者用更大的类型做运算if ((uint64_t)offset len total_size) { Read(ptr offset, len); }网络协议解析里这类问题非常隐蔽而且一旦被利用就能越界读到堆上的其他数据。至少在国内的C/C后端圈里这算是必须烂熟于心的实战技能。4.4 对象的生命周期悬空指针和悬空引用悬空指针dangling pointer导致的越界读表面上看起来是“读取了一个对象”但对象其实已经销毁了。经典场景是回调函数和事件循环class Server { void OnClientDisconnect(Client* client) { delete client; // 释放 dispatch_event(disconnected); // 触发外部回调 } }; // 外部回调里还在用client void OnDisconnectedHandler(Server* server) { Client* c get_current_client(); c-Send(...); // c已经被释放了越界读 }排查这类问题时建议先确认对象的释放路径再确认所有引用它的地方是否有对应的生命周期保护。推荐用std::shared_ptr时不要滥用但生命周期管理混乱的场景确实需要依赖智能指针来兜底。4.5 多线程数据竞争读到了“半成品”数据多线程里的越界读有一个独特的表现形式数据本身没有越界但在读取时观测到了另一个线程正在更新的中间状态导致读完的数据自相矛盾。// 伪代码两个线程访问同一个结构体 struct Stats { size_t total_count; size_t valid_count; }; // 线程A更新 stats.total_count; stats.valid_count; // 两行不是原子操作 // 线程B读取 double ratio stats.valid_count / stats.total_count; // 可能读到total_count已更新但valid_count未更新的中间状态这类问题表面上看不是越界读但根因是“读的时候越过了对象的内存边界看到了别人正在更新的数据”。排查手段是给共享变量加锁或用原子变量std::atomic。在崩溃分析时的表现是崩溃现场非常诡异偶尔能抓到互相矛盾的参数或者崩溃所在的函数根本无关紧要。5. 工具链速查越界读崩溃排查工具箱工具不用多但每一样都要用熟。整理一个排查工具箱的说明后面遇到问题直接按流程走。5.1 Linux工具链工具适用场景基本用法gdbcore dump分析、动态调试gdb ./program core -cbt查看调用栈AddressSanitizer越界读写的运行时检测编译加-fsanitizeaddress -g -O1Valgrind内存错误检查比ASan慢但更全valgrind --toolmemcheck ./programcppcheck静态分析cppcheck --enableall src/clang-tidy静态分析更智能见上文命令strace查看系统调用strace -f -o trace.txt ./programperf性能分析排查偶发崩溃时用来抓热点perf record -g ./programASan和Valgrind的选择ASan的特点是编译期插桩运行速度快但只适用于你从头编译的程序。Valgrind是基于虚拟机的动态二进制插桩不需要重新编译但运行速度会慢一个数量级。实际排查中能用ASan就优先用ASan它报告的堆栈信息级别很高Valgrind适合那些ASan不方便启用的第三方库问题。5.2 Windows工具链Windows下排查越界读崩溃场景比Linux多一些因为Windows还有大量闭源的组件。常用的工具是WinDbg微软官方调试器打开dump文件后!analyze -v一键分析。Application VerifierAppVerif.exe微软官方的运行时内存检测工具专治资源泄漏和越界访问。Dr. MemoryWindows下的Valgrind等价物运行慢但检测全面。Windows资源管理器频繁崩溃的那类问题官方社区里给出过不少AppVerif的案例用AppVerif为“explorer.exe”开启基础的内存检测配合WinDbg抓dump一般能定位到具体是哪个第三方Shell扩展在越界读。实际操作时建议先干净启动干净启动禁用第三方Shell扩展看问题是否复现这样可以排除系统组件自身的问题。5.3 监控层面的崩溃捕获在服务端场景里除了事后分析事前监控的自动化也很重要。Linux下可以把崩溃信息直接写入文件让异常现场自动保留。# 用systemd运行服务时追加以下环境变量 EnvironmentLD_PRELOAD/path/to/crash_handler.so更常见的做法是在进程里注册信号处理函数#include signal.h #include execinfo.h #include fcntl.h #include unistd.h void CrashHandler(int sig) { int fd open(/var/log/mylog/crash.log, O_CREAT | O_WRONLY | O_APPEND, 0644); if (fd 0) _exit(1); void* frames[64]; int n backtrace(frames, 64); backtrace_symbols_fd(frames, n, fd); dprintf(fd, \nSignal: %d\n, sig); close(fd); _exit(1); }注意这个信号处理函数里不能调用不安全的函数如malloc、printf只能使用write这类异步信号安全async-signal-safe的函数。_exit而不是exit就是为了跳过C运行时库的清理流程避免二次崩溃。注册方式struct sigaction sa; sa.sa_handler CrashHandler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESETHAND | SA_NODEFER; sigaction(SIGSEGV, sa, NULL);这样程序崩溃时会在日志里留下当时的调用栈配合core dump里的寄存器快照能比较完整地还原现场。6. 崩溃监控与预防为什么“崩溃可以被监控”这件事很重要热搜词里有一条“项目崩溃或是内存崩溃问题是否可以被监控”我觉得这个问题问得非常好。崩溃不仅能被监控而且监控本身就是排查策略的一部分。6.1 监控崩溃的核心指标崩溃监控的核心指标有三个崩溃率单位时间内的崩溃次数/请求总数。这个指标最适合做服务端程序的稳定性监控阈值一般定在0.1%以下。超过阈值就触发告警。崩溃趋势观察崩溃率是否随时间上升。如果崩溃率从发布完成后一直平稳过了一个星期突然上升说明可能和资源占用内存碎片化、句柄泄漏有关而不是代码逻辑的立刻失败。崩溃指纹按崩溃堆栈的hash值聚类把不同崩溃分开统计。这样即使有其他组件在崩你也能一眼看出哪个堆栈占比最高。6.2 监控手段Linux下的常规监控手段systemd服务管理如果服务是用systemd管理的systemctl status能看到崩溃和重启次数journalctl -u your_service能看退出日志。这是最轻量的方案。eBPF工具用bpftrace或bcc可以实时观察进程级崩溃事件比如# bpftrace监控所有被SIGSEGV杀死的进程 bpftrace -e tracepoint:signal:signal_generate /args-sig 11/ { printf(%s killed by SIGSEGV\n, comm); }这种方式的优势是无需在业务代码里埋点。云监控平台如果是上云项目直接使用云厂商的崩溃监控服务它们能自动上报崩溃堆栈并按指纹聚类。6.3 越界读的防御体系建设监控是事后手段防御策略的优先级更高。我建议在项目中把下面这套预防体系做起来编译期开启Fortify Source-D_FORTIFY_SOURCE2可以让GCC自动在memcpy、strcpy这类函数调用里插入边界检查代码。代价是轻微的运行时开销但能拦截大量明显的越界读写。启用ASan到测试环境测试环境用带ASan的构建跑自动化用例CI流水线里设置“只要ASan报错就算测试失败”。这是成本最低、效果最好的一道防线比线上排查省心一万倍。关键数据结构上线前先跑一版ValgrindValgrind在程序启动和关闭阶段对内存的错误尤其敏感很多在业务逻辑中不会暴露的初始化/析构问题Valgrind能一网打尽。强制代码评审时检查“长度来源”凡是涉及外部输入长度字段的代码评审时都要问一个问题“这个长度是哪里来的有没有被校验过”就是这样一个简单的问题能拦住至少一半的越界读场景。7. 关于越界读崩溃我的几点实操心得最后聊几点心得体会不是教科书内容是踩坑踩出来的经验第一越界读崩溃的现场往往不是“案发现场”。排查时别过分盯着崩溃栈看要把视野放宽到“读操作发生后再被别的地方使用”的整条链路。用ASan复现是最高效的方式因为它能在最早发生越界读的地方喊停而不是等程序随机崩溃时再倒推。第二不要盲信“偶发”这个描述。90%以上的“偶发崩溃”在加长测试时间、加大并发、处理真实的网络数据包之后都能稳定复现。如果复现不出来换个思路写一个小demo把协议解析的代码单独拎出来塞一个异常数据集进去往往几秒钟就崩了。协议解析类的越界读特别适合用这种“模糊测试”思路复现。第三崩溃日志和core dump一定要早配置、勤验证。别等到崩溃发生了才开始配那种感觉就像火灾之后才想起逃生路线。我见过太多项目线上崩了几次每次都只有一句“Segmentation fault”连是哪个进程蹦的都不知道更别说调栈了。第四对“多读一个字节”这种操作保持敬畏。很多越界读崩溃追根溯源就是代码里那个“1”“-1”没想清楚。尤其是在网络包解析、二进制格式处理时长度边界差一个字节就是完全不同的结果。建议在代码里把长度校验的意图写清楚比如// data_len 是实际数据长度1 是为了保证后续字符串操作能找到NUL if (data_len payload_capacity - 1) { return kErrorInvalidPacket; }让人一看就知道“为什么是1”这样评审的人才能发现问题才敢接这个代码。第五利用好“崩溃指纹”聚类工具。如果你维护的是一个复杂系统没有崩溃聚类手段就等于在黑暗里找一根针。哪怕是简单的“调用栈前3层做字符串hash”这种土办法也能帮你把几十种崩溃收敛成几个大方向然后一个一个处理。越界读引发的崩溃说难也难说简单也简单。说难是因为它变化多端表面症状千奇百怪说简单是因为只要你有能力拿到完整的崩溃现场又有ASan这种趁手的工具绝大多数问题都能在一个工作日内定位到根因。关键是别慌别一股脑儿去改代码——先把现场拿到手再动手排查。
返回列表