第28章 使用汇编语言编写注入代码
本文最后更新于:2022年5月19日 晚上
第28章使用汇编语言编写注入代码
本章将学习使用汇编语言编写注入代码的相关知识与技术。
28.1 目标
首先借助OllyDbg的汇编功能,使用汇编语言编写注入代码(ThreadProc()函数)。使用汇编语言能够生成比C语言更自由、更灵活(非标准化的)的代码(如:直接访问栈、寄存器的功能),然后将纯汇编语言编写的ThreadProc()函数注入notepad.exe进程。学习本章要注意与前面讲过的(用C语言编写的)ThreadProc()比较,了解它们的不同点。
28.2 汇编编程
大家通常使用C/C++语言编写程序,其中具有代表性的开发工具有Microsoft Visual C++与Borland C++ Builder。使用汇编语言编写程序时,常用的开发工具(Assembler)有MASM(Microsoft Macro Assembler)、TASM (Borland Turbo Assembler )、FASM (Flat Assembler)等。
就我个人而言,用C/C++语言编写程序时使用MS Visual C++开发工具,用汇编语言编写程序时使用MASM编译器。MASM编译器支持多样化的Macro函数以及库文件,编程时使用起来非常方便,其便捷程度几乎与C语言相当。
设置好MASM编译器后,就可以正常进行汇编编程(Assembly Programming)了。当然还可以在类似Visual C++的C语言开发工具中使用“内联汇编”(Inline Assembly),将汇编指令插入C语言代码。这是非常适合开发入员的方式。本章将向各位介绍一种更适合代码逆向分析的方式,就是使用OllyDbg中的汇编功能编写汇编程序。
OllyDbg的汇编功能支持简单的汇编语言编程,这在代码逆向分析中相当有用(因为调试时经常需要修改各处代码)。
28.3 OllyDbg的汇编命令
本节将使用OllyDbg的汇编命令编写汇编语言程序。首先使用OllyDbg打开asmtest.exe示例文件(asmtest.exe是为进行汇编语言编程测试而编写的可执行文件(无任何功能))。
从代码区域的顶端部分( 401000 )开始看起,先向大家介绍一个OllyDbg的新命令New origin here,它把EIP更改为指定地址。在OllyDbg的代码窗口中移动光标到401000地址处,在鼠标右键菜单中选择New origin here(Ctrl+Gray*)菜单命令,如图28-2所示。
执行菜单命令后,EIP地址变为401000。
调试时常常需要更改EIP值,所以New origin here菜单非常有用,希望各位记住它。
New origin here命令仅用来改变EIP值,与直接通过调试方式转到指定地址是不一样的,因为寄存器与栈中内容并未改变。
在401000地址处执行汇编命令(快捷键:Space),将弹岀输入汇编命令的窗口,如图28-4所示。
接下来就可以在OllyDbg中编写简单的汇编语言程序了
建议大家在图28-4中取消(uncheck)对“Fill with NOP’s”的复选。OllyDbg的汇编命令用来向相应地址输入用户代码。若“Fill with NOP’s”项处于复选状态,输入代码长度短于已有代码时,剩余长度会填充为NOP (No Operation)指令,以整体对齐代码(Code Alignment)。为便于本章说明,我取消了对“Fill with NOP’s”项目的复选。
28.3.1 编写 ThreadProc()函数
下面使用汇编语言编写ThreadProc()函数。与前面使用C语言编写的ThreadProc()函数相比,其不同之处在于,需要的Data (字符串)已包含在Code中。请各位参考图28-5输入汇编指令。各条汇编指令的作用将在后面讲解(输入时注意取消对“Fill with NOP’s”的复选,若出现误录,转到相应地址处重新输入即可)。
自上而下依次输入汇编指令,直到40102E地址处的CALL指令为止,各位的输入都正确吗?接下来,继续输入字符串。请先关闭Assemble窗口,在OllyDbg代码窗口中,移动光标至401033地址处,打开Edit窗口(快捷键:Ctrl+E),如图28-6所示。
在图28-6的Edit窗口向ASCII项输入“ReverseCore”。因字符串必须以NULL结束,故在HEX项的最后输入00值(取消Keep size选项)。像这样完成全部输入后,在OllyDbg中査看代码,如 图28-7所示。
图28-7中灰色部分即是“ReverseCore”字符串区域,可以看到字符串使用非常奇怪的指令进行显示。这样显示的原因在于,OllyDbg的Disassembler (反汇编器)将字符串误认为IA-32指令了。其实,这是由于输入者在Code位置输入字符串引起的,是输入者的错,而不能怪罪OllyDbg的反汇编器。
调试时常常会遇到这种反汇编(Disassemble)问题,有些反调试技术正是利用了这一点,后面讲解反调试时再向大家介绍。
如图28-7所示,选中字符串后再执行Analysis命令(快捷键;Ctrl+A ),得到图28-8。
OllyDbg的Analysis命令用来再次分析代码,再分析Unpack (解码的)代码时经常用到。
图28-8中的代码是执行了Analysis命令之后的形式。在401033地址处可以清晰地看到前面输入的字符串“ReverseCore”,但是401000地址之后的指令却解析有误(OllyDbg 2.0也无法将代码与数据100%区分开来。事实上,机器本身很难分清它是字符串还是指令)。在图28-8中难以查看代码,使用鼠标右键菜单中的Analysis-Remove analysis from module命令可以将代码恢复原样。使用Remove analysis命令恢复代码,如图28-9所示。
接下来,使用汇编命令从40103F地址处(位于401033地址的“ReverseCore”字符串后面的地址)开始继续输入指令如图28-10所示。
然后使用编辑命令,在401044地址处输入字符串(“www.reversecore.com”,不要忘记在最后添上NULL)如图28-11所示。
再次使用汇编命令从401058地址处开始输入指令,如图28-12所示。
至此已将ThreadProc()代码全部输入。图28-13显示了所有输入的代码,请各位对照查看自己输入的代码是否正确
401033、401044地址中的内容不是指令,而是字符串。由于OllyDbg会将字符串识别为指令,所以字符串看上去有些怪异。
28.3.2 保存文件
编好代码后要保存。在OllyDbg代码菜单中,选择鼠标右键Copy to executable-All modifications菜单(参考图28-14)。
如图28-15所示,弹岀确认消息框,单击“Copy all”按钮。
然后弹出窗口,显示所有修改内容,在鼠标右键菜单中选择Save file项目,如图28-16所示。
之后弹岀保存文件对话框,输入文件名称(asmtest_patch.exe )后保存。
Assemble(Space):输入汇编代码。
Analysis(Ctrl+A):再次分析代码。
New origin here(Ctrl+Gray*):更改 EIP。
28.4 编写代码注入程序
本节将使用前面创建好的汇编代码编写代码注入程序(Injector)。
28.4.1 获取ThreadProc()函数的二进制代码
首先,使用OllyDbg工具打开前面创建的asmtest_patch.exe文件。我们前面编写的ThreadProc()函数地址为401000,在内存窗口中转到401000地址处(转移命令Ctrl+G),如图28-17所示。
ThreadProc()函数的地址区间为401000~401061。如图28-17所示,选中该地址区域,在鼠标
右键菜单中依次选择Copy-To file项目(参考图28-18 )。
接着,使用文本编辑器打开刚刚保存的文件(我使用的文本编辑器为GVIM,各位也可以使用自己熟悉的文本编辑器)。
图28-19中显示的文本内容即为以Hex值形式表示的ThreadProc()函数,它们其实是一系列的IA-32指令,也是要注入目标进程的代码。
IA-32指令解析方法请参考第49章。
如图28-20所示编辑文本文件,去除不必要的部分,每个字节前面加上前缀0x,各字节以逗号(,)分隔。适当应用文本编辑器的编辑功能(选择列、修改字符串)将带来很大便利。
观察图28-20中编辑的文本内容,它们看上去就像C语言中的字节数组,这就是要注入的Hex代码(CodeInjection2.cpp文件中会用到)。
28.4.2 Codelnjection2.cpp
本节看看代码注入程序的源代码(CodeInjection2.cpp )。从代码28-1中可以看到,图28-20中使用文本编辑器编辑的Hex代码被保存到名为g_InjectionCode的字节数组。
以下源代码使用 MS Visual C++2010 Express Edition 编写而成,在 Windows XP/7 32位操作系统中通过测试。为便于说明,代码中的返回值检查与异常处理代码均已省略。
1 |
|
代码28-1中的注入代码与前面讲过的Codelnjection.cpp代码类似(调用的API也一样)。但它们最大的不同在于,代码28-1中的注入代码本身同时包含着代码所需的字符串数据。所以_THREAD_PARAM结构体中并不包含字符串成员,并且图28-20中的指令字节数组(g_InjectionCode )替代了用C语言编写的ThreadProc()函数。若程序编写得更巧妙一些,甚至都可以不用_THREAD_PARAM结构体。每个人的具体实现细节都是不同的,可以根据自己的需要调整。重要的是,方法的实质都是一样的,即编写汇编代码,将生成的指令内联在注入程序的源代码中,进而
将其注入目标进程。将代码成功注入目标进程后,下面我们将通过调试来分析各汇编代码的含义。
将上面的CodeInjection2.cpp与上一章中的Codeinjection.cpp源代码比较,可以明显看出它们的不同之处。
28.5 调试练习
本节将notepad.exe进程注入使用汇编语言编写的代码,并通过调试了解其工作原理。
28.5.1 调试 notepad.exe
使用OllyDbg工具打开notepad.exe文件开始调试。按F9运行键,使notepad.exe处于Running (运行)状态。
一定要使notepad.exe处于Running (运行)状态!!
28.5.2 设置OllyDbg 选项
进行代码注入时要在目标进程创建新线程,如图28-22所示,设置好OllyDbg的选项即可从注入的线程代码开始调试。
这样,notepad.exe进程中有新线程生成时,调试器就会暂停在相应线程函数的开始代码处。
28.5.3 运行 Codelnjection2.exe
首先,使用Process Explorer查看notepad.exe进程的PID,如图28-23所示。
以PID值作为参数,在命令行窗口中运行CodeInjection2.exe(以管理员身份运行),如图28-24所示。
28.5.4 线程起始代码
运行CodeInjection2.exe程序完成代码注入后,调试器会暂停在被注入的线程代码的起始位置,如图28-25所示。
不同运行环境下代码起始地址( 1C0000 )不同。
下面详细分析图28-25中的代码。
28.6 详细分析
28.6.1 生成栈帧
1 |
|
上面是2条典型的生成栈帧指令,对它们感到陌生的朋友可以趁此机会记住: 55 8BEC。后面出现的指令使用压字符串入栈的技术,生成栈帧就可以在ThreadProc()函数终止时将栈清理干净。
28.6.2 THREAD_PARAM 结构体指针
1 |
|
生成栈帧后,[EBP+8]是传入函数的第一个参数,这里指THREAD_PARAM结构体指针。下面是THREAD_PARAM结构体的定义,它的成员是2个函数指针,分别用来保存LoadLibraryA()与GetProcAddress()的函数指针(谁获取了函数指针并保存呢?对,就是前面讲过的CodeInjection2.exe程序,它获取了函数的指针,向notepad.exe注入完成并运行线程时以参数形式保存)。
1 |
|
执行完1C0003地址处的MOV ESI,DWORD PTR SS:[EBP+8]
指令后,进入ESI寄存器存储的地址查看,如图28-26所示。
寄存器ESI中存储的地址为1A0000,该地址是CodeInjection2.exe为THREAD_PARAM结构体在notepad.exe进程内存空间中分配的内存缓冲区地址。
不同用户系统环境下THREAD_PARAM结构体地址( 1A0000 )不同。
观察图28-26中的内存窗口可以看到,280000地址处存储着2个4字节的值,它们就是函数LoadLibraryA()与GetProcAddress()的起始地址。为了更直观地查看函数的起始地址,需要设置OllyDbg内存窗口的视图选项。先移动光标到内存窗口,然后在鼠标右键菜单中依次选择Long-Address项目,如图28-27所示。
选中图28-27中的菜单后,OllyDbg的内存窗口显示形式改变,如图28-28所示。
函数地址如图显示就更直观了。并且,Comment栏中与各行地址对应的API名称也一同出现(在鼠标右键菜单中选择Hex-Hex/ASCII(16bytes)菜单,可以重新显示为Hex形式)。
28.6.3 “User32.dll” 字符串
1 |
|
上面3行代码将“User32.dll”字符串压入栈,这种独特技术仅用于使用汇编语言编写的程序。地址1C0006处的PUSH 6C6C指令用来将6C6C压入栈,其中6C是ASCII码,对应字母1
,所以该指令最终压入栈的是字符串\0\0ll
。紧接着,1C000B与1C0010地址处的PUSH指令分别将字符串d.23
与resu
压入栈。由于x86 CPU采用小端序标记法,再加上栈的逆向扩展特性,所 以字符串被逆向压入栈,请重点注意这个调试时必须掌握的内容。
自上而下跟踪代码到1C0015地址处,查看栈,如图28-29所示。
像这样,使用PUSH指令可以把指定字符串压入栈。并且,注入代码时不必另外注入字符串数据,只要把它们包含到代码中,只注入代码即可。
还有一种将字符串数据包含进代码的方法,后面会单独介绍。
32位的OS中,PUSH指令一次只能将4字节大小的数据压入栈。
28.6.4 压入“user32.dll”字符串参数
1 |
|
LoadLibraryA() API拥有1个参数,用来接收1个字符串的地址,该字符串是其要加载的DLL文件的名称。
1 |
|
从图28-29中可知,当前ESP的值为A3FF7C,它是“user32.dll”字符串的起始地址。地址1C0015处的PUSH ESP
指令用来将“user32.dll”字符串的起始地址(A3FF7C)压入栈(参考图28-30 )。
28.6.5 调用 LoadLibraryA( “user32.dll”)
1 |
|
如图28-28所示,ESI寄存器中存储的地址值为1A0000,该地址中保存着LoadLibraryA() API的起始地址( 77E2DC65 ),请看图28-31。
对汇编语言内存引用语法感到陌生的朋友,可以借此机会记住它。为了帮助大家更好地理解这一个过程,我们把上面的CALL指令展开,形式如下([]类似于C语言中的指针引用):
*[ESI]=[470000]=77E2DC65 (address of kernel32.LoadLibraryA)
=存储在280000地址中的值
*CALL[ESI]=CALL[470000]=CALL 77E2DC65 =CALL Kernel32.LoadLibraryA
执行位于1C0016地址处的CALL DWORD PTR DS:[ESI]指令后,就会调用LoadLibraryA() API,同时加载作为参数传入的user32.dll文件。由于notepad.exe进程运行时已经加载了user32.dll,所以它只会返回加载的地址
函数的返回地址保存在EAX中,所以从图28-32中可以看到EAX=77D10000。选择OllyDbg菜单中的View-Executable modules[ALT+E]菜单项,可以查看加载到进程内存的所有DLL,如图28-23所示。可以清楚看到,user32.dll的加载地址就是77D10000。
28.6.6 “MessageBoxA” 字符串
上面3条PUSH指令将字符串“MessageBoxA”压入栈(与前面将字符串“user32.dll”压入栈的方法相同)。调试到1C0022地址处的PUSH指令,字符串“MessageBoxA”被存储到栈中,如图28-34所示。
1 |
|
上面3条PUSH指令将字符串“MessageBoxA”压入栈(与前面将字符串“user32.dll”压入栈的方法相同)。调试到1C0022地址处的PUSH指令,字符串“MessageBoxA”被存储到栈中,如图28-34所示。
28.6.7 调用 GetProcAddress(hMod, “MessageBoxA”)
1 |
|
当前ESP的值为95FF70(参考图28-34 ),所以1C0027地址处的PUSH ESP
指令用来将‘MessageBoxA”字符串的地址(95FF70)压入栈(该字符串的地址被用作GetProcAddress() API的第二个参数,在1C0029地址处调用此API)。而当前EAX的值为77D10000,它是user32.dll模块的加载地址(参考图28-32),所以1C0028地址处的PUSH EAX
指令用来将user32.dll的起始地址(hMod)压入栈(该字符串的地址被用作GetProcAddress() API的第一个参数,在1C0029地址处调用此API)。调试至此查看栈,如图28-35所示。
ESI寄存器的值为1A0000,所以可以将[ESI+4]如下展开(参考图28-28、图28-31 ):
*[ESI+4]=[1A0004]=77E2CC94(address of kernel32.GetProcAddress)
=存储在280004地址的值
*CALL [ESI+4]=CALL [1A0004]=CALL 77E2CC94=CALL Kernel32.GetProcAddress
所以1C0029地址处的 CALL DWORD PTR DS:[ESI+4]指令用来调用 GetProcAddress(77D10000,“MessageBoxA”)API函数。执行该条CALL指令后,user32.MessageBoxA() API的起始地址就会保存到EAX寄存器(系统环境不同,地址会有所不同。在我的系统环境下,EAX=7793EA71 ),如图28-36所示。
28.6.8 压入 MessageBoxA()函数的参数1 - MB_OK
1 |
|
PUSH 0指令将0压入栈,0为MessageBoxA() API (后面会调用该API)的第四个参数(uType )MessageBoxA() API共有4个参数,函数原型如代码28-3所示。
1 |
|
uType值为0,表示弹出的消息对话框为MB_OK,仅显示一个OK (确定)按钮。
28.6.9 压入 MessageBoxA()函数的参数2 - “ReverseCore”
1 |
|
下面介绍“使用CALL指令将包含在代码间的字符串数据地址压入栈”的技术,该技术也仅能用在使用汇编语言编写的程序中。很明显,1C0033~1C003E地址区域是程序代码区域,但其内容实为“ReverseCore”字符串数据。也就是说,“ReverseCore”字符串的首地址为1C0033,它被用作MessageBoxA() API的第三个参数(lpCaption )。
将字符串作为参数传递给函数前,需要先把字符串地址压入栈,那么采用哪种方式好呢?继续调试位于1C002E地址处的CALL指令(StepIn(F7)),查看栈,如图28-37所示。
从栈中可以看到,“ReverseCore”字符串的起始地址1C0033被压入其中。也就是说,MessageBoxA()的第三个参数被压入栈。这个“花招”巧妙运用了CALL指令的“动作原理”。执 行1C0033地址处的CALL指令后,函数(1C003F )终止并将返回地址( 1C0033 )压入(PUSH) 栈,然后再跳转到(JMP)相应的函数地址(1C003F)。也就是说,执行一条CALL指令相当于执行了PUSH与JMP两条指令。但1C003F实际并不是函数,不具有以RETN指令返回的形态。此处的CALL指令只是用来将紧接其后的“ReverseCore”字符串地址压入栈,然后转到下一条代码指令。大家现在应该理解这个很有意思的CALL指令用法了。
28.6.10 压入 MessageBoxA()函数的参数3 - “www.reversecore.com”
1 |
|
与“ReverseCore”字符串类似,上面的代码将MessageBoxA() API的第二个参数lpText字符串 (“www.reversecore.com”)压入栈。上述代码中的1C0044~1C0057地址区域并非代码指令,而是字符串数据(“www.reversecore.com”)。
1C003F地址处的CALL指令(与前面说明的一样)将紧接其后的“www.reversecore.com”字符串的地址(1C0044 )压入栈,然后转到下一条指令的地址处(1C0058 )(参考图28-38 )。
28.6.11 压入 MessageBoxA()函数的参数4 -NULL
1 |
|
上面这条指令将MessageBoxA() API的第一个参数hWnd压入栈,该参数用来确定消息对话框所属的窗口句柄,这里压入NULL值,创建一个不属于任何窗口的消息对话框。
28.6.12 调用 MessageBoxA()
1 |
|
上面这条CALL指令调用MessageBoxA() API。指令中的EAX寄存器存储着MessageBoxA()API的起始地址(77D6EA11),该地址是前面调用GetProcAddress()后返回的值(参考图28-36、图28-38)。调试1C005A地址处的CALL EAX
指令后,查看寄存器与栈,如图28-39所示。
执行CALL EAX
指令即可弹出消息对话框,如图28-40所示。
28.6.13 设置ThreadProc()函数的返回值
1 |
|
注入notepad.exe进程的代码(ThreadProc()线程函数)执行完之前,还需要做一些准备工作,即用XOR EAX,EAX
指令将线程函数的返回值设置为0。前面学过函数的返回值使用EAX寄存器,各位还记得吧?
XOR EAX,EAX
指令能够又快又好地将EAX寄存器初始化为0(对CPU而言,它比使用MOV EAX,0指令更简单快捷)。
28.6.14 删除栈帧及函数返回
1 |
|
最后,删除ThreadProc()函数开始时生成的栈帧,并使用RETN命令返回函数。栈帧在ThreadProc()函数中非常重要。对于前面使用PUSH指令压入栈的字符串,我们不需要费力地用POP命令逐个弹岀,只要使用上面几条删除栈帧的指令即可快速恢复原状。
28.7 小结
对使用汇编语言编写的注入代码的说明到此结束。使用汇编语言编写程序要比使用c语言更加灵活自由,强烈建议大家尝试使用汇编语言编写更多更具创意的代码。对于刚接触汇编语言不久的朋友,我建议使用OllyDbg中的汇编指令,用它编写汇编代码更容易。
参考
《逆向工程核心原理》 第28章