精确定位堆相关问题:OllyDbg2 off-by-one 故事
引言
昨天下午,我在平静地编写一些代码,但代码无法正常工作。像往常一样,在这种情况下,我启动了调试器以了解底层发生了什么。有点奇怪的是,我在内联x86汇编中故意插入了一个int3指令,就在我认为有问题的汇编代码之前。一旦我的文件加载到OllyDbg2中,我按F9快速到达内联汇编代码中的int3。进行了一些单步调试,然后BOOM,我遇到了一个严重的崩溃。这种情况有时会发生,这很不酷。
然后,我重新启动我的二进制文件并尝试重现这个错误:相同的操作,再次BOOM。好吧,这次很酷,我在OllyDbg2中得到了一个可重现的崩溃。我喜欢这种事情发生在我身上(记得我在OllyDbg/IDA中找到的崩溃:PDB Ain’t PDD),这对我来说总是一个很好的练习,我需要:
- 在应用程序中精确定位错误:通常当它是一个真实/大型应用程序时,这并不简单
- 逆向工程涉及错误的代码,以弄清楚为什么发生(有时我有源代码,有时像这次我没有)
在这篇文章中,我将向您展示我如何使用GFlags、PageHeap和WinDbg精确定位错误的位置。然后,我们将逆向工程有问题的代码,以理解错误发生的原因,以及如何编写一个干净的触发器。
目录
- 引言
- 崩溃
- 精确定位堆问题:介绍完整PageHeap
- 深入OllyDbg2内部
- 家庭重现
- 有趣的事实
- 结论
崩溃
我做的第一件事是启动WinDbg来调试OllyDbg2,以调试我的二进制文件(是的。)。一旦OllyDbg2启动,我重现了与之前完全相同的步骤来触发错误,以下是WinDbg告诉我的:
1
2
3
4
5
6
7
8
|
HEAP[ollydbg.exe]: Heap block at 00987AB0 modified at 00987D88 past requested size of 2d0
(a60.12ac): Break instruction exception - code 80000003 (first chance)
eax=00987ab0 ebx=00987d88 ecx=76f30b42 edx=001898a5 esi=00987ab0 edi=000002d0
eip=76f90574 esp=00189aec ebp=00189aec iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200202
ntdll!RtlpBreakPointHeap+0x23:
76f90574 cc int 3
|
我们收到了堆分配器的调试消息,通知我们进程在其堆缓冲区之外进行了写入。问题是,这个消息和断点不是在错误写入时触发的,而是在之后,当进行了另一个分配器调用时触发的。此时,分配器正在检查块是否正常,如果看到一些奇怪的东西,它会输出消息并中断。堆栈跟踪应该确认这一点:
1
2
3
4
5
6
7
8
9
10
|
0:000> k
ChildEBP RetAddr
00189aec 76f757c2 ntdll!RtlpBreakPointHeap+0x23
00189b04 76f52a8a ntdll!RtlpCheckBusyBlockTail+0x171
00189b24 76f915cf ntdll!RtlpValidateHeapEntry+0x116
00189b6c 76f4ac29 ntdll!RtlpFreeHeap+0x5d
00189c60 76ef34a2 ntdll!RtlFreeHeap+0x142
00189c80 75d8537d KERNELBASE!GlobalFree+0x27
00189cc8 00403cfc ollydbg!Memfree+0x3c
...
|
正如我们上面所说,堆分配器的消息可能是在OllyDbg2想要释放一块内存时触发的。基本上,我们问题的关键在于我们不知道:
这使得我们的错误在没有合适工具的情况下调试起来不简单。如果您想了解更多关于高效调试堆问题的信息,您绝对应该阅读《高级Windows调试》中的堆章节(向Ivan致敬)。
精确定位堆问题:介绍完整PageHeap
简而言之,完整PageHeap选项对于诊断堆问题非常强大,至少有两个原因:
- 它将保存每个堆块的分配位置
- 它将在我们的块末尾分配一个保护页(因此当错误写入发生时,我们可能会有一个写入访问异常)
为此,此选项稍微改变了分配器的工作方式(它为每个堆块添加了更多元数据等);如果您想了解更多信息,请尝试在家中有/无页面堆分配东西并比较分配的内存。以下是当启用PageHeap完整时堆块的样子:
要为ollydbg.exe启用它,这很简单。我们只需启动gflags.exe二进制文件(它在Windbg的目录中),然后勾选您想要启用的功能。
现在,您只需在WinDbg中重新启动目标,重现错误,以下是我现在得到的内容:
1
2
3
4
5
6
7
8
9
|
(f48.1140): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=000000b4 ebx=0f919abc ecx=0f00ed30 edx=00000b73 esi=00188694 edi=005d203c
eip=004ce769 esp=00187d60 ebp=00187d80 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010246
ollydbg!Findfreehardbreakslot+0x21d9:
004ce769 891481 mov dword ptr [ecx+eax*4],edx ds:002b:0f00f000=????????
|
哇,这非常酷,因为现在我们确切地知道哪里出了问题。让我们现在获取更多关于堆块的信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
0:000> !heap -p -a ecx
address 0f00ed30 found in
_DPH_HEAP_ROOT @ 4f11000
in busy allocation
( DPH_HEAP_BLOCK: UserAddr UserSize - VirtAddr VirtSize)
f6f1b2c: f00ed30 2d0 - f00e000 2000
6e858e89 verifier!AVrfDebugPageHeapAllocate+0x00000229
76f90d96 ntdll!RtlDebugAllocateHeap+0x00000030
76f4af0d ntdll!RtlpAllocateHeap+0x000000c4
76ef3cfe ntdll!RtlAllocateHeap+0x0000023a
75d84e55 KERNELBASE!GlobalAlloc+0x0000006e
00403bef ollydbg!Memalloc+0x00000033
004ce5ec ollydbg!Findfreehardbreakslot+0x0000205c
004cf1df ollydbg!Getsourceline+0x0000007f
00479e1b ollydbg!Getactivetab+0x0000241b
0047b341 ollydbg!Setcpu+0x000006e1
004570f4 ollydbg!Checkfordebugevent+0x00003f38
0040fc51 ollydbg!Setstatus+0x00006441
004ef9ef ollydbg!Pluginshowoptions+0x0001214f
|
通过这个非常方便的命令,我们得到了很多相关信息:
- 这个块的大小为0x2d0字节。因此,从0xf00ed30到0xf00efff。
- 错误写入现在有意义了:应用程序尝试在其堆缓冲区之外写入4字节(我猜是无符号数组的off-by-one)。
- 内存是在ollydbg!Memalloc中分配的(由ollydbg!Getsourceline调用,与PDB相关?)。我们将在帖子后面研究该例程。
- 错误写入发生在地址0x4ce769。
深入OllyDbg2内部
我们有点幸运,涉及此错误的例程相当简单,可以逆向工程,Hexrays工作得就像魔法一样。以下是有问题函数的C代码(至少是有趣的部分):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
//ollydbg!buggy @ 0x004CE424
signed int buggy(struct_a1 *u)
{
int file_size;
unsigned int nbchar;
unsigned __int8 *file_content;
int nb_lines;
int idx;
// ...
file_content = (unsigned __int8 *)Readfile(&u->sourcefile, 0, &file_size);
// ...
nbchar = 0;
nb_lines = 0;
while(nbchar < file_size)
{
// 做一些事情来计算文件中的所有字符和所有行
// ...
}
u->mem1_ov = (unsigned int *)Memalloc(12 * (nb_lines + 1), 3);
u->mem2 = Memalloc(8 * (nb_lines + 1), 3);
if ( u->mem1_ov && u->mem2 )
{
nbchar = 0;
nb_lines2 = 0;
while ( nbchar < file_size && file_content[nbchar] )
{
u->mem1_ov[3 * nb_lines2] = nbchar;
u->mem1_ov[3 * nb_lines2 + 1] = -1;
if ( nbchar < file_size )
{
while ( file_content[nbchar] )
{
// 消耗一行,递增东西直到找到'\r'或'\n'序列
// ..
}
}
++nb_lines2;
}
// BOOM!
u->mem1_ov[3 * nb_lines2] = nbchar;
// ...
}
}
|
所以,让我解释一下这个例程的作用:
这个例程在OllyDbg2找到您的二进制文件的PDB数据库时被调用,更准确地说,当在这个数据库中找到您的应用程序源代码的路径时。当您调试时,拥有这些信息很有用,OllyDbg2能够告诉您当前在C代码的哪一行。
- 在第10行:“u->Sourcefile"是一个指向您的源代码路径的字符串指针(在PDB数据库中找到)。例程只是读取整个文件,给出其大小,以及现在存储在内存中的文件内容的指针。
- 从第12行到第18行:我们有一个循环,计算您的源代码中的总行数。
- 在第20行:我们有我们块的分配。它分配12*(nb_lines + 1)字节。我们之前在WinDbg中看到块的大小是0x2d0:这应该意味着我们正好有((0x2d0 / 12) - 1) = 59行在我们的源代码中:
1
2
|
D:\TODO\crashes\odb2-OOB-write-heap>wc -l OOB-write-heap-OllyDbg2h-trigger.c
59 OOB-write-heap-OllyDbg2h-trigger.c
|
好。
从第24行到第39行:我们有一个类似于前一个的循环。它基本上再次计算行数,并用一些信息初始化我们刚刚分配的内存。
在第41行:我们有我们的错误。 somehow,我们可以设法以"nb_lines2 = nb_lines + 1"退出循环。这意味着第41行将尝试在我们的缓冲区之外写入一个单元格。在我们的情况下,如果我们有"nb_lines2 = 60"并且我们的堆缓冲区从0xf00ed30开始,这意味着我们将尝试写入(0xf00ed30+60*4)=0xf00f000。这正是我们之前看到的。
在这一点上,我们已经完全解释了错误。如果您想进行一些动态分析以跟踪重要例程,我做了几个断点,以下是它们:
1
2
3
4
5
|
bp 004CF1BF ".printf \"[Getsourceline] %mu\\n[Getsourceline] struct: 0x%x\", poi(esp + 4), eax ; .if(eax != 0){ .if(poi(eax + 0x218) == 0){ .printf \" field: 0x%x\\n\", poi(eax + 0x218); gc }; } .else { .printf \"\\n\\n\" ; gc; };"
bp 004CE5DD ".printf \"[buggy] Nbline: 0x%x \\n\", eax ; gc"
bp 004CE5E7 ".printf \"[buggy] Nbbytes to alloc: 0x%x \\n\", poi(esp) ; gc"
bp 004CE742 ".printf \"[buggy] NbChar: 0x%x / 0x%x - Idx: 0x%x\\n\", eax, poi(ebp - 1C), poi(ebp - 8) ; gc"
bp 004CE769 ".printf \"[buggy] mov [0x%x + 0x%x], 0x%x\\n\", ecx, eax * 4, edx"
|
在我的环境中,它给了我类似的东西:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
[Getsourceline] f:\dd\vctools\crt_bld\self_x86\crt\src\crt0.c
[Getsourceline] struct: 0x0
[...]
[Getsourceline] oob-write-heap-ollydbg2h-trigger.c
[Getsourceline] struct: 0xaf00238 field: 0x0
[buggy] Nbline: 0x3b
[buggy] Nbbytes to alloc: 0x2d0
[buggy] NbChar: 0x0 / 0xb73 - Idx: 0x0
[buggy] NbChar: 0x4 / 0xb73 - Idx: 0x1
[buggy] NbChar: 0x5a / 0xb73 - Idx: 0x2
[buggy] NbChar: 0xa4 / 0xb73 - Idx: 0x3
[buggy] NbChar: 0xee / 0xb73 - Idx: 0x4
[...]
[buggy] NbChar: 0xb73 / 0xb73 - Idx: 0x3c
[buggy] mov [0xb031d30 + 0x2d0], 0xb73
eax=000000b4 ebx=12dfed04 ecx=0b031d30 edx=00000b73 esi=00188694 edi=005d203c
eip=004ce769 esp=00187d60 ebp=00187d80 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200246
ollydbg!Findfreehardbreakslot+0x21d9:
004ce769 891481 mov dword ptr [ecx+eax*4],edx ds:002b:0b032000=????????
|
家庭重现
- 在这里下载最新版本的OllyDbg2,解压文件
- 从odb2-oob-write-heap下载三个文件,将它们放在与ollydbg.exe相同的目录中
- 启动WinDbg并打开最新版本的OllyDbg2
- 设置您的断点(或不设置),按F5启动
- 在OllyDbg2中打开触发器
- 当二进制文件完全加载时按F9
- BOOM :)。请注意,您可能没有可见的崩溃(记住,这就是使我们的错误在没有完整页面堆的情况下调试起来不简单的原因)。尝试用调试器四处戳:重新启动二进制文件或关闭OllyDbg2应该足以在您的调试器中获取堆分配器的消息。
有趣的事实
您甚至可以用仅二进制文件和PDB数据库触发错误。技巧是篡改PDB,更准确地说,是它保存源代码路径的地方。这样,当OllyDbg2加载PDB数据库时,它将读取相同的数据库,就像它是应用程序的源代码一样。太棒了。
结论
这些类型的崩溃总是学习新事物的机会。要么它很容易调试/重现,您不会浪费太多时间,要么不是,您将在真实示例上提高您的调试器/逆向工程技能。所以去做吧!
顺便说一句,我怀疑这个错误是可利用的,我甚至没有尝试利用它;但如果您成功了,我会非常高兴阅读您的 write-up!但如果我们假设它是可利用的,您仍然需要将PDB文件、源文件(我猜它会比PDB给您更多控制)和二进制文件分发给您的受害者。所以没什么大不了的。
如果您太懒于调试您的崩溃,请将它们发送给我,我可能会看一看!
哦,我差点忘了:我们仍在寻找有动力的贡献者来写很酷的帖子,传播世界。