IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    记一次栈缓冲区溢出的调试

    dutor发表于 2013-09-23 14:55:54
    love 0

      没几天,你的程序出炉了,加以时日,回归测试也完成了,你兴冲冲地想,啊哈,终于可以上线给东家挣钱啦!
      有一天,你的程序 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.



沪ICP备19023445号-2号
友情链接