为什么我的 WinDbg 内存断点失效了?

0x01. 问题描述

周末在测试一个程序时,发现其莫名其妙的 Crash 在了一个系统自带 DLL 的某个函数里面,而且很难直观地看出来 Crash 的原因,分析之后发现是不当使用 C 语言 setjmplongjmp 两个函数导致的。那么这和文章标题有什么联系呢?笔者在分析的过程中使用了 WinDbg 的内存断点( Processor Breakpoint / Data Breakpoint )来跟踪一个栈变量的读写操作,理论上这个断点会多次命中,但实际上只命中了一次,而这个现象正是由于不当使用 setjmplongjmp 导致 espebp 寄存器的值的非预期改变所导致的。

0x02. setjmp / longjmp

在分析具体的问题之前,先简单了解一下 C 语言中这两个不太常见的函数 setjmplongjmp

在 C 语言中,可以使用 goto 语句来实现函数内部的任意跳转,而 setjmplongjmp 则可以实现函数间的任意跳转,和 goto 语句一样,配合使用 setjmplongjmp 可以实现简单的异常处理。这两个函数的声明位于头文件 setjmp.h 中,声明如下:

int __cdecl setjmp(
_Out_ jmp_buf _Buf
);

__declspec(noreturn) void __cdecl longjmp(
_In_ jmp_buf _Buf,
_In_ int _Value
);

函数 setjmp 接收类型为 jmp_buf 的参数 _Buf (通常命名为 env ),这里 jmp_buf 是通过 typedef 定义的一个别名,表示元素个数为 16int 数组(和定义函数指针时一样,这里 typedef 的写法很不直观)。

#define _JBLEN  16
#define _JBTYPE int
typedef _JBTYPE jmp_buf[_JBLEN];

调用 setjmp 函数可以保存 setjmp 返回时的一些上下文信息(前面提到参数通常命名为 env 会显得更加直观),类似 GetThreadContext 保存线程的上下文信息一样,只不过 setjmp 保存的信息更少。直接调用 setjmp 时其返回值总是为 0

函数 longjmp 则跳转回调用 setjmp 的地方,并将 setjmp 的返回值设置为 longjmp 第二个参数所指定的值,这样调用完 setjmp 之后可以通过返回值判断跳转来自哪里。

下面通过一个简单的例子来说明 setjmplongjmp 的用法:编译以下代码,不难理解程序的输出为 1337

#include <stdio.h>
#include <setjmp.h>

#define NOINLINE __declspec(noinline)

NOINLINE void bar(jmp_buf* env)
{
longjmp(*env, 1337);
printf("this line will never be printed\n");
}

NOINLINE void foo()
{
jmp_buf env;
int result = setjmp(env);
// __asm int 3;
if (result)
{
printf("result = %d\n", result);
return;
}

bar(&env);
}

int main(int argc, char** argv)
{
foo();

return 0;
}

这里暂时不深究 setjmp 的底层实现细节,只需要知道 jmp_buf 中有一个元素保存了 setjmp 返回之后的 eip 即可。

(2a54.3334): Break instruction exception - code 80000003 (!!! second chance !!!)
eax=00000000 ebx=00fb8000 ecx=00000000 edx=010ffb6c esi=002a9df0 edi=0131d650
eip=0029106e esp=010ffb6c ebp=010ffbb0 iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
Test!foo+0x1e:
0029106e cc int 3

0:000> ub eip
Test!foo+0x6 [c:\users\test\source\repos\test\test\test.cpp @ 13]:
00291056 a104902a00 mov eax,dword ptr [Test!__security_cookie (002a9004)]
0029105b 33c5 xor eax,ebp
0029105d 8945fc mov dword ptr [ebp-4],eax
00291060 8d45bc lea eax,[ebp-44h]
00291063 6a00 push 0
00291065 50 push eax
00291066 e875090100 call Test!__setjmp3 (002a19e0)
0029106b 83c408 add esp,8 ; eip after setjmp's return

0:000> dd ebp-44 L10
010ffb6c 010ffbb0 00fb8000 0131d650 002a9df0
010ffb7c 010ffb60 0029106b 010ffbec 00000000 ; env[5] = eip
010ffb8c 56433230 00000000 010ffbb8 010ffbb8
010ffb9c 0029117c 00000000 00294fc0 00291300

0x03. Crash 分析

介绍完 setjmplongjmp 之后,让我们回到正题。问题模型简化后的代码如下所示:

#include <stdio.h>
#include <setjmp.h>

#define NOINLINE __declspec(noinline)

NOINLINE void bar(jmp_buf* env, int value)
{
if (!value)
{
int env_eip = ((int *)env)[5];
__asm
{
xor eax, eax
mov ebx, env_eip
jmp ebx
}
}

longjmp(*env, 1337);
printf("this line will never be printed\n");
}

NOINLINE void foo()
{
int value = 0;
jmp_buf env;
int result = setjmp(env);

if (result)
{
printf("result = %d\n", result);
return;
}

bar(&env, value);
}

int main(int argc, char** argv)
{
foo();

return 0;
}

运行这段代码会造成 Crash ,具体的位置为 ntdll!RtlUnwind+0x3c52f ,此时如果尝试继续运行程序,则会相继 Crash 在多个不同的位置。

(12dc.3570): Unknown exception - code c0000029 (!!! second chance !!!)
eax=00c1f9c0 ebx=00c1fe70 ecx=00c20000 edx=00c1d000 esi=00b0107d edi=00c1fd5c
eip=774d341f esp=00c1f9a0 ebp=00c1fd3c iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
ntdll!RtlUnwind+0x3c52f:
774d341f 8b4c240c mov ecx,dword ptr [esp+0Ch] ss:002b:00c1f9ac=00c20000

0:000> g
WARNING: Continuing a non-continuable exception
(12dc.3570): Unknown exception - code c0000029 (first chance)
(12dc.3570): Unknown exception - code c0000029 (!!! second chance !!!)
eax=00c1f9c0 ebx=00c1fedc ecx=00c20000 edx=00c1d000 esi=00b0107d edi=00c1fd5c
eip=774d341f esp=00c1f9a0 ebp=00c1fd3c iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
ntdll!RtlUnwind+0x3c52f:
774d341f 8b4c240c mov ecx,dword ptr [esp+0Ch] ss:002b:00c1f9ac=00c20000

0:000> g
WARNING: Continuing a non-continuable exception
(12dc.3570): Unknown exception - code c0000029 (first chance)
(12dc.3570): Unknown exception - code c0000029 (!!! second chance !!!)
eax=00c1f9c0 ebx=00c1fef4 ecx=00c20000 edx=00c1d000 esi=00b0107d edi=00c1fd5c
eip=774d341f esp=00c1f9a0 ebp=00c1fd3c iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
ntdll!RtlUnwind+0x3c52f:
774d341f 8b4c240c mov ecx,dword ptr [esp+0Ch] ss:002b:00c1f9ac=00c20000

0:000> g
WARNING: Continuing a non-continuable exception
(12dc.3570): Unknown exception - code 80000026 (!!! second chance !!!)
eax=00000000 ebx=00000000 ecx=00000000 edx=00000000 esi=00000000 edi=00000000
eip=00b01ffe esp=00c1fd54 ebp=00c1fdec iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
Test!___longjmp_internal+0x4e:
00b01ffe 5e pop esi

0:000> g
(12dc.3570): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00b0107d ebx=c483ffff ecx=1b2b3725 edx=00000000 esi=e8a2110c edi=00000000
eip=00b0263e esp=00c1fd2c ebp=00c1fdec iopl=0 nv up ei pl nz ac pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010216
Test!__local_unwind2+0x48:
00b0263e 8b0cb3 mov ecx,dword ptr [ebx+esi*4] ds:002b:670c442f=????????

如果给 foo 函数下断点,只会命中一次,而如果给 bar 函数下断点,则会命中多次,这很好理解。如果在 foo 函数中给局部变量 value 下内存读写断点,该断点只会命中一次(在变量被初始化为 0 的时候)。理论上来说,进入函数 bar 时,传入的参数 value 的值应该总是 0 ,但是实际上并非如此,而且我们在 foo 函数中下的内存断点也无法监控到 value 的值的改变,在 WinDbg 中可以进行验证:

$$ 分别给两个函数下断点
0:000> bu Test!foo
0:000> bu Test!bar

$$ 继续运行被调试程序,等待第一个断点命中,进入 foo 函数
0:000> g
Breakpoint 0 hit
eax=012062d0 ebx=00c6c000 ecx=00000000 edx=c7664979 esi=00b19df0 edi=01203948
eip=00b010a0 esp=00eff838 ebp=00eff880 iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
Test!foo:
00b010a0 55 push ebp

$$ 等待栈帧建立完毕
0:000> p
eax=012062d0 ebx=00c6c000 ecx=00000000 edx=c7664979 esi=00b19df0 edi=01203948
eip=00b010a1 esp=00eff834 ebp=00eff880 iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
Test!foo+0x1:
00b010a1 8bec mov ebp,esp

0:000> p
eax=012062d0 ebx=00c6c000 ecx=00000000 edx=c7664979 esi=00b19df0 edi=01203948
eip=00b010a3 esp=00eff834 ebp=00eff834 iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
Test!foo+0x3:
00b010a3 83ec48 sub esp,48h

$$ 局部变量 value 存放于 ebp-48 处,下内存读写断点监控
0:000> ba w4 ebp-48

0:000> bl
0 e Disable Clear 00b010a0 0001 (0001) 0:**** Test!foo
1 e Disable Clear 00b01040 0001 (0001) 0:**** Test!bar
2 e Disable Clear 00eff7ec w 4 0001 (0001) 0:****

$$ 继续执行,value 初始化时第一次命中内存断点
0:000> g
Breakpoint 2 hit
eax=00eff7f0 ebx=00c6c000 ecx=00000000 edx=c7664979 esi=00b19df0 edi=01203948
eip=00b010ba esp=00eff7ec ebp=00eff834 iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
Test!foo+0x1a:
00b010ba 6a00 push 0

0:000> ub eip L1
Test!foo+0x13 [c:\users\test\source\repos\test\test\test.cpp @ 27]:
00b010b3 c745b800000000 mov dword ptr [ebp-48h],0

$$ 继续执行,第一次命中 bar 函数
0:000> g
Breakpoint 1 hit
eax=00eff7f0 ebx=00c6c000 ecx=00000000 edx=00eff7f0 esi=00b19df0 edi=01203948
eip=00b01040 esp=00eff7e0 ebp=00eff834 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
Test!bar:
00b01040 55 push ebp

$$ 传入的 value 参数的值为 0
0:000> dd esp L3
00eff7e0 00b010f1 00eff7f0 00000000

$$ 继续执行,第二次命中 bar 函数
0:000> g
Breakpoint 1 hit
eax=00eff798 ebx=00b010c2 ecx=00000014 edx=00eff7f0 esi=00b19df0 edi=01203948
eip=00b01040 esp=00eff7cc ebp=00eff7dc iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
Test!bar:
00b01040 55 push ebp

$$ 传入的 value 参数的值为 0x20!!!
0:000> dd esp L3
00eff7cc 00b010f1 00eff798 00000020

的确,这里 ba 内存断点只命中了一次,但是第二次命中 bar 函数时,参数 value 的值却改变了,难道这是 WinDbg 的 Bug 吗?事实上并不是,而是 bar 函数中直接跳转到 jmp_buf 中的 eip 的写法是有问题的!

当从 foo 进入 bar 时,bar 函数也会建立栈帧:

0:000> u Test!bar
Test!bar [c:\users\test\source\repos\test\test\test.cpp @ 7]:
00b01040 55 push ebp
00b01041 8bec mov ebp,esp

而之后在 bar 函数中又直接跳转到 jmp_buf 中的 eip ,此时 espebp 寄存器的值没有被恢复,就直接回到了 foo 函数中。因为局部变量 value 是基于 ebp 进行定位的,而此时 ebp 的值已经变了,以至于 foo 再次调用 bar 时,value 不再是原来的 value ,而内存断点监控的仍然是原来存放 value 变量的地址(原有地址上的值也并未改变),因此自然不会再次命中,也无法监控到 value 的值的改变 (改变只是由于 ebp 发生了变化从而读取到了栈上存储的其他值)。

同样,bar 函数的第一个参数 env 所存储的值也会因为 foo 函数中 ebp 的变化而变化,当传递一个非法的 envlongjmp 时,进程直接 Crash 掉了。

本小节给出的示例程序,完全是 setjmplongjmp 的一种错误用法,这两个函数应该配套使用,而不应该在中间某个地方直接通过 jmp 或者 call 来跳转到 setjmp 之后的位置。这里给出的示例程序是实际调试过程中遇到的问题的一个简化模型,仅供调试学习之用,读者切勿模仿这里的错误写法。

0x04. 小结

本文通过分析一个 Crash 学习了 setjmplongjmp 的基本用法,并分析清楚了造成 Crash 的根本原因。

除了异常处理之外, setjmplongjmp 还有一些其他场合的应用,比如实现 协程 等,具体可以参考 StackOverflow 上的提问。

最后,基于 setjmplongjmp 还可以实现一定程度的代码混淆(反分析、反调试),这个问题留给读者自行思考和实践。

请作者喝杯咖啡☕