没几天,你的程序出炉了,加以时日,回归测试也完成了,你兴冲冲地想,啊哈,终于可以上线给东家挣钱啦!
有一天,你的程序 core dump 了,光荣地被谋杀在战场的某个小小小角落。你战战兢兢地打开了 GDB,妄图勘察现场,习惯性地按下 bt,看到了下面的一幕:
#0 0x0000000000000078 in ?? () #1 0x000014473a087d0a in ?? () #2 0x00007f66db85b351 in ?? () #3 0x0000000000000025 in ?? ()
WTF! 栈乱掉了,满眼的疑问号:你的小宝贝她从哪里来?要到哪里去?当时在搞什么?在被谁搞?
怎么样?现在知道栈是多么伟大多么重要的一个东西了吧。它只在程序运行时候被创建,保存了函数的参数、局部变量和调用关系,以至于在进程异常死亡时 OS 把他留下来,存到磁盘上。现如今,它被糟践成这副德性,怪得了谁呢?要么是你的编译器抽抽了,要么就是你脏心烂肺内存写越界了。不信,请 call 一下 foo:
1 2 3 4 | void foo() { int a[1]; memset(a, 0, 1024); } |
能恢复吗?抱歉,IA32 下,调用链比较规整,根据情况 hack 一下 core 文件,还有一线生机。但是,在 IA64 里,rbp 可能已做它用,无力回天了,反正我耗费一个周末研究 ELF 和 DWARF 最终放弃了。
还有其他招儿吗?有,但要看运气,我就属于那种运气好的:
(gdb) x/8a $rsp 0x7f66d4534a10: 0x3a087d0a47140002 0x0 0x7f66d4534a20: 0x0 0x0 0x7f66d4534a30: 0x523846d0 0x43eae5 <check_server_status(uint32_t)+613> 0x7f66d4534a40: 0xebfd50 0x7f66d4534af0 (gdb) disas 0x43eae0,+6 Dump of assembler code from 0x43eae0 to 0x43eae6: 0x000000000043eae0 <check_server_status(uint32_t)+608>: callq 0x4a0f40 <is_alive(uint64_t)> 0x000000000043eae5 <check_server_status(uint32_t)+613>: test %al,%al End of assembler dump.
运气好的话,is_alive() 就是程序生前调用的最内层函数了。看是不是它做错了什么吧:
1 2 3 4 5 6 7 8 9 10 11 12 | bool is_alive(uint64_t server_id) { ... int fd = socket(...); set_nonblock(fd); fd_set set; FD_ZERO(&set); FD_SET(fd, &set); connect(fd, ...); select(fd + 1, NULL, &set, NULL, &timeout); ... } |
is_alive 实现了一个超时时间可控的 connect。如果你现在已经明白怎么回事了,请务必留下您的大名,我可是花了两周零五分钟才看出来的。
对,就是万恶的 select,FD_SET 可能越界,因为 fd_set 中只有 FD_SETSIZE 个 bit 来标识 fd。FD_SETSIZE 是一个宏,定义在 /usr/include/sys/select.h。查看该文件,FD_SET 并未对 fd 做参数检查,因此当 fd 大于 1024 时,FD_SET 就写了它不该写的地方了,写到谁,有没有影响,有多大影响,什么时候触发,程序会不会 core 掉,什么时候 core 掉,这些都要看造化了。比如这次是写到栈里的 return address 了,导致程序跑飞。另外一个 core dump 里面,某个类的 this 从 0x1e41500 变成了 0x11e41500,当时定位不出写越界,只好认为世界末日前宇宙射线爆发导致硬件异常,聊以自慰。
吐个槽,Linux Kernel 就不能增加一个带超时的 connect 调用?glibc 里面,FD_SETSIZE 只用在 fd_set 里的位域定义和 FD_ZERO,FD_SET 没有做任何检查;Kernel 里面的 select 实现,申请内核态 fd_set 的时候,完全依据 fd 的大小,并没有大小的限制(如果我没有漏掉某些逻辑的话)。
总结:
如果出现 stack corruption,几乎一定是栈的缓冲区写越界了;基本不可能是用户态的堆内存越界,因为除了主线程,每个线程的栈空间两侧又有一个 page 的虚拟空间做 gap,这些 gap,不可读,不可写,不可执行。主线程的 stack 是按需向下生长的,在 IA64 环境下,也不可能和 mmap 区域无缝相邻。当然,跳着写堆内存,或者偏移量算错就另说了。
这篇文章中 FD_SET 写越界只篡改了一个 bit,而且被篡改的那个 bit 可能本来就是 1,非常难重现,而且 stack 的破坏程度较轻,通过查看栈内容,可以大致知道函数的调用关系。如果是类似上文 foo() 中的 memset 越界,栈可能真的面目全非了,但这时候栈内容通常是有规律可循的。
根据栈内容判断函数调用关系,需要知道 call 指令的语义,栈中保存的是 caller 中 call 指令的下一条指令,这条指令的前一条才是 callee。
That’s All.