使用 Windbg 进行黑客攻击 - 第一部分(记事本)
使用 windbg 进行汇编检查和黑客攻击
引言
调试是软件开发中一项非常重要的技能,每个软件开发人员都曾遇到过不得不调试自己编写的代码或他人编写的代码。根据你工作的平台,有很多工具可以帮助你进行调试,其中 Windbg 是 Windows 上最流行的调试工具之一。Windbg 是我日常工作中经常使用的工具,我认为它是为 Windows 编写的最强大的工具之一。网上有很多教程介绍了 Windbg 的调试入门,所以本系列将不侧重于此。相反,我们将通过几个例子,演示如何使用 Windbg 进行一些逆向工程和黑客攻击,并在此过程中了解 Windbg 的各种功能。虽然本部分特别关注通过 Windbg 攻击记事本,但你可以使用类似的技术来攻击其他软件。在后续部分中,我将展示一些高级黑客攻击和逆向工程,包括一些内核模式的例子。
背景
虽然我会尽量讲解这里使用的每一个调试命令,但本教程并不旨在作为学习 Windbg 或汇编的起点。假定读者对 x64 汇编和 Windows 编程有一定的了解。我使用“Windbg”来指代 GUI 工具,但如果你使用的是 CDB 或 NTSD,相同的命令同样适用。
黑客攻击记事本
在我看过的许多 Windbg 教程中,记事本似乎都是最受欢迎的演示应用程序,所以在第一部分,我们将继续使用它。我们试图在这里攻击的行为,你可能经常看到,并且它非常烦人。几年前,我看到过一个类似的演示,当时是别人做的,但我记不清他们是怎么做的了,所以我就自己重新尝试了一下。
假设你打开了记事本,然后点击“文件”->“打开”,会弹出一个对话框,让你选择要打开的文件。你可能注意到,当这个对话框打开时,你无法将实际的编辑窗口带到前台。点击编辑区域会发出警报音,但不会将该窗口带到前台。
本教程的其余部分将侧重于如何使用 Windbg 攻击记事本来改变这种行为,并在两个窗口同时打开时,在它们之间来回切换。
第一步是从 Windbg 中打开记事本,这样我们就可以控制和改变它的行为。你可以启动 Windbg,然后选择“文件”->“打开可执行文件”->“**<记事本.exe 的路径>**”,或者如果记事本.exe 已经运行,则选择“文件”->“**附加到进程**”->“**记事本.exe**”。
这两个选项的区别在于:第一个选项(打开可执行文件),调试器在实际进程开始执行其 main
例程之前中断。调试器应用程序基本上调用 CreateProcess
API,并带有一个特殊的标志,该标志允许它在应用程序加载到内存中但尚未实际开始执行后返回控制。在第二个选项(附加到进程)中,调试器调用 DebugActiveProcess
API,该 API 将调试器附加到已运行并中断的进程。
调试器如何与被调试进程进行通信,超出了本教程的范围,但好奇的读者可以在 **高级 Windows 调试** 一书中阅读相关内容。
一旦我们中断,下一步的逻辑是首先找出我们要攻击的函数。例如,当我们从文件菜单点击“打开”对话框时,会调用哪个函数。为此,我们需要记事本.exe 的符号,幸运的是,Microsoft 公共服务器有它的公共符号。
你可以使用 .symfix
命令将符号路径指向 Microsoft 公共服务器,然后执行 .reload
来实际加载公共符号。
0:011> .symfix
0:011> .reload
如果我们不知道确切的函数名,我们就不知道要搜索什么,但我们可以做一个合理的猜测。如果你是编写记事本代码的开发人员,你会如何命名点击“打开”对话框时调用的函数?嗯,我希望它至少包含“open”这个词!
因此,使用 x
命令,我们可以搜索记事本.exe 中包含“open”这个词的函数。下面是它的样子:
0:011> x notepad!*open*
00007ff7`0307182c notepad!ShowOpenSaveDialog (<no parameter info>)
00007ff7`03092310 notepad!<wbr />pszEDPFileOpenErrorHeader = <no type information>
00007ff7`0308a6f8 notepad!_imp_GetOpenFileNameW = <no type information>
00007ff7`030923d8 notepad!szOpenCaption = <no type information>
00007ff7`0308b190 notepad!CLSID_FileOpenDialog = <no type information>
00007ff7`03074860 notepad!NpOpenDialogHookProc (<no parameter info>)
00007ff7`0308a890 notepad!_imp_OpenSemaphoreW = <no type information>
00007ff7`0308aad0 notepad!_imp_OpenClipboard = <no type information>
00007ff7`0308ace0 notepad!_imp_OpenPrinterW = <no type information>
00007ff7`030842d4 notepad!TraceFileOpenComplete (<no parameter info>)
00007ff7`03094ac0 notepad!szOpenFilterSpec = <no type information>
00007ff7`0308a688 notepad!_imp_RegOpenKeyExW = <no type information>
00007ff7`03092300 notepad!g_ftOpenedAs = <no type information>
00007ff7`0307199c notepad!InvokeOpenDialog (<no parameter info>)
00007ff7`03093438 notepad!g_isFileDragOpen = <no type information>
00007ff7`03092308 notepad!pszEDPFileOpenError = <no type information>
00007ff7`0308a650 notepad!_imp_OpenProcessToken = <no type information>
这里用粗体突出显示的第一个函数 ShowOpenSaveDialog
,看起来正是我们要找的!
让我们看看这是否确实是点击“打开”时调用的函数。为了验证这一点,我们将对其设置一个断点。
0:011> bp notepad!ShowOpenSaveDialog
0:011> g
第一个命令 bp
实际上是在我们的函数上设置软件断点 (int 3
),下一个命令 g
,代表 go,释放调试器控制,记事本开始再次执行。
现在我们点击“文件”->“**打开**”,你应该会看到记事本在此过程中冻结。调试器会中断,提示它命中了我们的断点,此时我们可以检查调用堆栈。
Breakpoint 0 hit
notepad!ShowOpenSaveDialog:
00007ff7`0307182c 48895c2408 mov qword ptr [rsp+8],
rbx ss:00000073`74d2f310=<wbr />0000000000000000
0:000> k
# Child-SP RetAddr Call Site
00 00000073`74d2f308 00007ff7`03071aeb notepad!ShowOpenSaveDialog
01 00000073`74d2f310 00007ff7`030721fa notepad!InvokeOpenDialog+0x14f
02 00000073`74d2f370 00007ff7`030738d6 notepad!NPCommand+0x4a2
03 00000073`74d2f6f0 00007fff`664b6d41 notepad!NPWndProc+0x726
04 00000073`74d2f9f0 00007fff`664b6713 USER32!<wbr />UserCallWinProcCheckWow+0x2c1
05 00000073`74d2fb80 00007ff7`03073bdb USER32!DispatchMessageWorker+<wbr />0x1c3
06 00000073`74d2fc10 00007ff7`03089333 notepad!WinMain+0x27f
07 00000073`74d2fd10 00007fff`68ea3034 notepad!__mainCRTStartup+0x19f
08 00000073`74d2fdd0 00007fff`69073691 KERNEL32!BaseThreadInitThunk+<wbr />0x14
09 00000073`74d2fe00 00000000`00000000 ntdll!RtlUserThreadStart+0x21
太好了!我们相对容易地找到了感兴趣的函数,但现在怎么办?有了这个,我们该做什么?
如果你熟悉 Windows 编程,你会知道 Windows 大量使用了对象和句柄的概念。当你想要使用一个对象时,你总是会得到它的一个句柄。如果我们把打开对话框和编辑器区域看作是两个独立的“窗口”,那么它们就是两个对象。由于当这个 dialogbox
打开时,我们无法将编辑器窗口置于前台,这也暗示了父子关系。在这个阶段,所有这些都只是猜测,因为我们没有记事本的源代码。但如果我们的猜测是真的,那么这个函数将以某种方式知道它的父窗口是编辑器窗口。
因此,基于这个逻辑,我的猜测是,这个函数会接受一个**父窗口的句柄**作为参数来建立这种关系。
如果没有私有符号,你将无法使用 dv
命令转储 local
变量或传递的参数,但如果你熟悉 Microsoft x64 调用约定 (https://msdn.microsoft.com/en-us/library/ms235286.aspx),你就会知道前 4 个参数是通过 rcx
、rdx
、r8
和 r9
寄存器传递的。所以让我们使用 r
命令转储这些寄存器。
0:000> r
rax=0000000000000000 rbx=0000000000000000 rcx=000000000004094e
rdx=00000213f5020968 rsi=000000499cb1f338 rdi=00000213f5021c20
rip=00007ff70307182c rsp=000000499cb1f288 rbp=000000499cb1f2d0
r8=00000213f4ffe9d6 r9=000000499cb1f338 r10=00000ffee060e246
r11=0000000000014140 r12=0000000000000000 r13=0000000000000001
r14=000000000004094e r15=00000000ffffffff
iopl=0 nv up ei pl zr na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
notepad!ShowOpenSaveDialog:
00007ff7`0307182c 48895c2408 mov qword ptr [rsp+8],
rbx ss:00000049`9cb1f290=<wbr />0000000000000000
我在上面的输出中高亮了这 4 个寄存器。如果你使用过以前的 Window
对象(由 HWND
(窗口句柄)表示)并查看过它们的值,你就会立即注意到,只有 rcx
寄存器有一个看起来像 HWND
的值。
但为了完整起见,我们假设你不知道句柄值应该是什么样子。
要检查这一点,我们可以关闭这个记事本实例,然后重新打开 Windbg,这次选择“**打开可执行文件**”选项指向记事本路径。我们想看看当我们实际在记事本中创建一个窗口时返回的 HWND
值是什么。要做到这一点,我们将再次使用我们的朋友 x
来搜索可能类似于创建窗口的函数。
0:000> x notepad!*create*window*
00007ff7`0308a6c8 notepad!_imp_<wbr />CreateStatusWindowW = <no type information>
00007ff7`0308ab78 notepad!_imp_CreateWindowExW = <no type information>
_imp_
表示实际函数位于 _imp_CreateStatusWindowW
的地址内。因此,我们可以转储 _imp_CreateStatusWindowW
中的指针值并反汇编它以获取实际函数。
0:000> x notepad!*create*window*
00007ff7`0308a6c8 notepad!_imp_<wbr />CreateStatusWindowW = <no type information>
00007ff7`0308ab78 notepad!_imp_CreateWindowExW = <no type information>
0:000> dq 00007ff7`0308ab78
00007ff7`0308ab78 00007fff`664a4710 00007fff`664c5780
00007ff7`0308ab88 00007fff`664ae980 00007fff`664a4660
00007ff7`0308ab98 00007fff`664b8e80 00007fff`664b5710
00007ff7`0308aba8 00007fff`664b8540 00007fff`664b3f10
00007ff7`0308abb8 00007fff`664c7c20 00007fff`664a26e0
00007ff7`0308abc8 00007fff`664a8800 00007fff`664c8410
00007ff7`0308abd8 00007fff`664d06c0 00007fff`664d0b20
00007ff7`0308abe8 00007fff`664c1500 00007fff`664cffb0
0:000> u 00007fff`664a4710
USER32!CreateWindowExW:
00007fff`664a4710 4c8bdc mov r11,rsp
00007fff`664a4713 4881ec88000000 sub rsp,88h
00007fff`664a471a 33c0 xor eax,eax
00007fff`664a471c 6689442478 mov word ptr [rsp+78h],ax
00007fff`664a4721 89442470 mov dword ptr [rsp+70h],eax
00007fff`664a4725 c744246800000040 mov dword ptr [rsp+68h],40000000h
00007fff`664a472d 89442460 mov dword ptr [rsp+60h],eax
00007fff`664a4731 488b8424e8000000 mov rax,qword ptr [rsp+0E8h]
如果你查阅 MSDN 中的 CreateWindowExW
,你会发现它返回 HWND
,正是我们想要的!所以让我们在这个函数上设置一个断点,看看我们得到的返回值是什么。一旦我们命中断点,我们需要让这个函数执行并返回到调用者,这样我们就可以使用 gu
返回到调用我们函数的函数栈中的上一级,然后检查此时的返回值。
返回值本身存储在 rax
寄存器中,这也是调用约定的一部分。
0:000> g
ModLoad: 00007fff`66f10000 00007fff`66f3d000 C:\WINDOWS\System32\IMM32.DLL
Breakpoint 0 hit
USER32!CreateWindowExW:
00007fff`664a4710 4c8bdc mov r11,rsp
0:000> k
# Child-SP RetAddr Call Site
00 00000083`2f57f958 00007fff`6670afbe USER32!CreateWindowExW
01 00000083`2f57f960 00007fff`666cefb6 combase!InitMainThreadWnd+0x56
02 00000083`2f57f9d0 00007fff`666ce004 combase!ThreadFirstInitialize+<wbr />0x12a
03 00000083`2f57fa30 00007fff`666cecd6 combase!_CoInitializeEx+0x158
04 00000083`2f57fb30 00007ff7`03073ab1 combase!CoInitializeEx+0x36
05 00000083`2f57fb80 00007ff7`03089333 notepad!WinMain+0x155
06 00000083`2f57fc80 00007fff`68ea3034 notepad!__mainCRTStartup+0x19f
07 00000083`2f57fd40 00007fff`69073691 KERNEL32!BaseThreadInitThunk+<wbr />0x14
08 00000083`2f57fd70 00000000`00000000 ntdll!RtlUserThreadStart+0x21
0:000> gu
combase!InitMainThreadWnd+<wbr />0x56:
00007fff`6670afbe 48890563af2000 mov qword ptr [combase!ghwndOleMainThread (00007fff`66915f28)],
rax ds:00007fff`66915f28=<wbr />0000000000000000
0:000> r@rax
rax=00000000000d071c
正如我们所见,高亮显示的值代表一个 HWND
值,并且根据之前对 ShowOpenSaveDialog
例程的转储,我们可以验证只有 rcx
确实像一个 HWND
值。
那么,我们如何打破这两个窗口对象之间的父子关系呢?一个简单的方法就是清除 rcx
寄存器,使 HWND
值为 0
。当然,这可能会导致崩溃,也可能不会,但我们目前没有更好的方法尝试。
下面是我们如何覆盖寄存器并确认其值确实为 0
。
0:000> r @rcx=0
0:000> r
rax=0000000000000000 rbx=0000000000000000 rcx=0000000000000000
rdx=00000213f5020968 rsi=000000499cb1f338 rdi=00000213f5021c20
rip=00007ff70307182c rsp=000000499cb1f288 rbp=000000499cb1f2d0
r8=00000213f4ffe9d6 r9=000000499cb1f338 r10=00000ffee060e246
r11=0000000000014140 r12=0000000000000000 r13=0000000000000001
r14=000000000004094e r15=00000000ffffffff
iopl=0 nv up ei pl zr na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
notepad!ShowOpenSaveDialog:
00007ff7`0307182c 48895c2408 mov qword ptr [rsp+8],
rbx ss:00000049`9cb1f290=<wbr />0000000000000000
现在我们通过按 g
来让程序继续运行,现在如果你在这个对话框打开时点击编辑器窗口,哇!成功了!假设你正确地遵循了这里的步骤,你应该能够在这两个窗口之间来回切换了。
但是等等!我们不想每次都设置这个断点,也不想每次打开 dialogbox
时都更改 rcx
的值。我们能修改代码让调试器自动完成这个任务吗?当然可以。
为此,我们需要确保当调用者调用 ShowOpenSaveDialog
时,它将 rcx
寄存器清零。这里的调用者是 InvokeOpenDialog
函数。让我们看看它调用 ShowOpenSaveDialog
之前的汇编代码。你可以使用 ub
命令从给定地址向后反汇编。
0:000> k
# Child-SP RetAddr Call Site
00 00000049`9cb1f288 00007ff7`03071aeb notepad!ShowOpenSaveDialog
01 00000049`9cb1f290 00007ff7`030721fa notepad!InvokeOpenDialog+0x14f
02 00000049`9cb1f2f0 00007ff7`030738d6 notepad!NPCommand+0x4a2
03 00000049`9cb1f670 00007fff`664b6d41 notepad!NPWndProc+0x726
04 00000049`9cb1f970 00007fff`664b6713 USER32!<wbr />UserCallWinProcCheckWow+0x2c1
05 00000049`9cb1fb00 00007ff7`03073bdb USER32!DispatchMessageWorker+<wbr />0x1c3
06 00000049`9cb1fb90 00007ff7`03089333 notepad!WinMain+0x27f
07 00000049`9cb1fc90 00007fff`68ea3034 notepad!__mainCRTStartup+0x19f
08 00000049`9cb1fd50 00007fff`69073691 KERNEL32!BaseThreadInitThunk+<wbr />0x14
09 00000049`9cb1fd80 00000000`00000000 ntdll!RtlUserThreadStart+0x21
0:000> ub notepad!InvokeOpenDialog+0x14f
notepad!InvokeOpenDialog+<wbr />0x133:
00007ff7`03071acf 8bd8 mov ebx,eax
00007ff7`03071ad1 85c0 test eax,eax
00007ff7`03071ad3 782c js notepad!InvokeOpenDialog+<wbr />0x165 (00007ff7`03071b01)
00007ff7`03071ad5 4c8b05fc080200 mov r8,qword ptr [notepad!szOpenCaption (00007ff7`030923d8)]
00007ff7`03071adc 4c8bce mov r9,rsi
00007ff7`03071adf 488b5538 mov rdx,qword ptr [rbp+38h]
00007ff7`03071ae3 498bce mov rcx,r14
00007ff7`03071ae6 e841fdffff call notepad!ShowOpenSaveDialog (00007ff7`0307182c)
我们看到 rcx
从上面高亮的 r14
获取值。如果我们将这个 mov
指令替换成 xor ecx,ecx
呢?我们只需要将 ecx
寄存器清零,因为 HWND
是一种 HANDLE 数据类型
,实际上是 32 位值,尽管它被定义为 PVOID
。所以,我们实际上需要类似这样的代码:
mov rdx,qword ptr [rbp+38h]
xor ecx,ecx
call notepad!ShowOpenSaveDialog
我们可以使用 Windbg 中的 a
(assemble) 命令来实现这一点。
0:000> a 00007ff7`03071ae3
00007ff7`03071ae3 xor ecx,ecx
xor ecx,ecx
00007ff7`03071ae5 nop
nop
00007ff7`03071ae6
0:000> ub notepad!InvokeOpenDialog+0x14f
notepad!InvokeOpenDialog+<wbr />0x135:
00007ff7`03071ad1 85c0 test eax,eax
00007ff7`03071ad3 782c js notepad!InvokeOpenDialog+<wbr />0x165 (00007ff7`03071b01)
00007ff7`03071ad5 4c8b05fc080200 mov r8,qword ptr [notepad!szOpenCaption (00007ff7`030923d8)]
00007ff7`03071adc 4c8bce mov r9,rsi
00007ff7`03071adf 488b5538 mov rdx,qword ptr [rbp+38h]
00007ff7`03071ae3 31c9 xor ecx,ecx
00007ff7`03071ae5 90 nop
00007ff7`03071ae6 e841fdffff call notepad!ShowOpenSaveDialog (00007ff7`0307182c)
使用 a
命令时,我们必须注意,紧跟在我们修改的指令之后的下一条指令必须从原始偏移量开始。例如,之前的 mov
指令从 ae3
偏移量开始,call
指令在 ae6
。当我们从 ae3
处汇编 xor ecx,ecx
时,它是一个 2 字节的指令,所以我们需要在 ae5
处填充另一个 1 字节的指令。为此,我们使用 nop
指令,它实际上不做任何事情。
现在再次使用 ub
命令,我们看到我们的代码已成功修改为我们所需。现在我们可以禁用所有断点并恢复记事本。因为我们这次在当前调用中没有更改 rcx
,所以我们还不能在窗口之间来回切换。这次我们可以关闭打开的对话框,从接下来的每次打开时,我们应该能够在这两个窗口之间来回切换。
你应该注意到,这种代码修改是在内存中完成的。所以实际的二进制文件不会受到影响。每当你关闭记事本并重新启动它时,这个黑客攻击就不会存在,所以如果你想再次执行它,你必须重新操作。
结论
在这里,我们看到了如何使用 Windbg 来理解程序执行过程,搜索应用程序中的函数,甚至改变我们没有源代码的应用程序的行为!希望这能让你更深刻地体会 Windbg,了解你可以用它做到的各种事情。在下一部分,我们将选择另一个应用程序并进行更高级的黑客攻击。
历史
- 2019 年 2 月 10 日:初始版本