0x01. 问题描述
周末在测试一个程序时,发现其莫名其妙的 Crash 在了一个系统自带 DLL 的某个函数里面,而且很难直观地看出来 Crash 的原因,分析之后发现是不当使用 C 语言 setjmp 和 longjmp 两个函数导致的。那么这和文章标题有什么联系呢?笔者在分析的过程中使用了 WinDbg 的内存断点( Processor Breakpoint / Data Breakpoint )来跟踪一个栈变量的读写操作,理论上这个断点会多次命中,但实际上只命中了一次,而这个现象正是由于不当使用 setjmp 和 longjmp 导致 esp 和 ebp 寄存器的值的非预期改变所导致的。
0x02. setjmp / longjmp
在分析具体的问题之前,先简单了解一下 C 语言中这两个不太常见的函数 setjmp 和 longjmp 。
在 C 语言中,可以使用 goto 语句来实现函数内部的任意跳转,而 setjmp 和 longjmp 则可以实现函数间的任意跳转,和 goto 语句一样,配合使用 setjmp 和 longjmp 可以实现简单的异常处理。这两个函数的声明位于头文件 setjmp.h 中,声明如下:
1 | int __cdecl setjmp( |
函数 setjmp 接收类型为 jmp_buf 的参数 _Buf (通常命名为 env ),这里 jmp_buf 是通过 typedef 定义的一个别名,表示元素个数为 16 的 int 数组(和定义函数指针时一样,这里 typedef 的写法很不直观)。
1 |
|
调用 setjmp 函数可以保存 setjmp 返回时的一些上下文信息(前面提到参数通常命名为 env 会显得更加直观),类似 GetThreadContext 保存线程的上下文信息一样,只不过 setjmp 保存的信息更少。直接调用 setjmp 时其返回值总是为 0 。
函数 longjmp 则跳转回调用 setjmp 的地方,并将 setjmp 的返回值设置为 longjmp 第二个参数所指定的值,这样调用完 setjmp 之后可以通过返回值判断跳转来自哪里。
下面通过一个简单的例子来说明 setjmp 和 longjmp 的用法:编译以下代码,不难理解程序的输出为 1337 。
1 |
|
这里暂时不深究 setjmp 的底层实现细节,只需要知道 jmp_buf 中有一个元素保存了 setjmp 返回之后的 eip 即可。
1 | (2a54.3334): Break instruction exception - code 80000003 (!!! second chance !!!) |
0x03. Crash 分析
介绍完 setjmp 和 longjmp 之后,让我们回到正题。问题模型简化后的代码如下所示:
1 |
|
运行这段代码会造成 Crash ,具体的位置为 ntdll!RtlUnwind+0x3c52f ,此时如果尝试继续运行程序,则会相继 Crash 在多个不同的位置。
1 | (12dc.3570): Unknown exception - code c0000029 (!!! second chance !!!) |
如果给 foo 函数下断点,只会命中一次,而如果给 bar 函数下断点,则会命中多次,这很好理解。如果在 foo 函数中给局部变量 value 下内存读写断点,该断点只会命中一次(在变量被初始化为 0 的时候)。理论上来说,进入函数 bar 时,传入的参数 value 的值应该总是 0 ,但是实际上并非如此,而且我们在 foo 函数中下的内存断点也无法监控到 value 的值的改变,在 WinDbg 中可以进行验证:
1 | $$ 分别给两个函数下断点 |
的确,这里 ba 内存断点只命中了一次,但是第二次命中 bar 函数时,参数 value 的值却改变了,难道这是 WinDbg 的 Bug 吗?事实上并不是,而是 bar 函数中直接跳转到 jmp_buf 中的 eip 的写法是有问题的!
当从 foo 进入 bar 时,bar 函数也会建立栈帧:
1 | 0:000> u Test!bar |
而之后在 bar 函数中又直接跳转到 jmp_buf 中的 eip ,此时 esp 和 ebp 寄存器的值没有被恢复,就直接回到了 foo 函数中。因为局部变量 value 是基于 ebp 进行定位的,而此时 ebp 的值已经变了,以至于 foo 再次调用 bar 时,value 不再是原来的 value ,而内存断点监控的仍然是原来存放 value 变量的地址(原有地址上的值也并未改变),因此自然不会再次命中,也无法监控到 value 的值的改变 (改变只是由于 ebp 发生了变化从而读取到了栈上存储的其他值)。
同样,bar 函数的第一个参数 env 所存储的值也会因为 foo 函数中 ebp 的变化而变化,当传递一个非法的 env 给 longjmp 时,进程直接 Crash 掉了。
本小节给出的示例程序,完全是 setjmp 和 longjmp 的一种错误用法,这两个函数应该配套使用,而不应该在中间某个地方直接通过 jmp 或者 call 来跳转到 setjmp 之后的位置。这里给出的示例程序是实际调试过程中遇到的问题的一个简化模型,仅供调试学习之用,读者切勿模仿这里的错误写法。
0x04. 小结
本文通过分析一个 Crash 学习了 setjmp 和 longjmp 的基本用法,并分析清楚了造成 Crash 的根本原因。
除了异常处理之外, setjmp 和 longjmp 还有一些其他场合的应用,比如实现 协程 等,具体可以参考 StackOverflow 上的提问。
最后,基于 setjmp 和 longjmp 还可以实现一定程度的代码混淆(反分析、反调试),这个问题留给读者自行思考和实践。