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
中,声明如下:
int __cdecl setjmp( |
函数 setjmp
接收类型为 jmp_buf
的参数 _Buf
(通常命名为 env
),这里 jmp_buf
是通过 typedef
定义的一个别名,表示元素个数为 16
的 int
数组(和定义函数指针时一样,这里 typedef
的写法很不直观)。
|
调用 setjmp
函数可以保存 setjmp
返回时的一些上下文信息(前面提到参数通常命名为 env
会显得更加直观),类似 GetThreadContext
保存线程的上下文信息一样,只不过 setjmp
保存的信息更少。直接调用 setjmp
时其返回值总是为 0
。
函数 longjmp
则跳转回调用 setjmp
的地方,并将 setjmp
的返回值设置为 longjmp
第二个参数所指定的值,这样调用完 setjmp
之后可以通过返回值判断跳转来自哪里。
下面通过一个简单的例子来说明 setjmp
和 longjmp
的用法:编译以下代码,不难理解程序的输出为 1337
。
|
这里暂时不深究 setjmp
的底层实现细节,只需要知道 jmp_buf
中有一个元素保存了 setjmp
返回之后的 eip
即可。
(2a54.3334): Break instruction exception - code 80000003 (!!! second chance !!!) |
0x03. Crash 分析
介绍完 setjmp
和 longjmp
之后,让我们回到正题。问题模型简化后的代码如下所示:
|
运行这段代码会造成 Crash ,具体的位置为 ntdll!RtlUnwind+0x3c52f
,此时如果尝试继续运行程序,则会相继 Crash 在多个不同的位置。
(12dc.3570): Unknown exception - code c0000029 (!!! second chance !!!) |
如果给 foo
函数下断点,只会命中一次,而如果给 bar
函数下断点,则会命中多次,这很好理解。如果在 foo
函数中给局部变量 value
下内存读写断点,该断点只会命中一次(在变量被初始化为 0
的时候)。理论上来说,进入函数 bar
时,传入的参数 value
的值应该总是 0
,但是实际上并非如此,而且我们在 foo
函数中下的内存断点也无法监控到 value
的值的改变,在 WinDbg 中可以进行验证:
$$ 分别给两个函数下断点 |
的确,这里 ba
内存断点只命中了一次,但是第二次命中 bar
函数时,参数 value
的值却改变了,难道这是 WinDbg 的 Bug 吗?事实上并不是,而是 bar
函数中直接跳转到 jmp_buf
中的 eip
的写法是有问题的!
当从 foo
进入 bar
时,bar
函数也会建立栈帧:
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
还可以实现一定程度的代码混淆(反分析、反调试),这个问题留给读者自行思考和实践。