解压后如下,点开html
直接F12
1 | function(c){ |
String.fromCharCode方法返回Unicode对应字符串;
charcodeAt()方法返回指定位置的字符的Unicode编码。
分析一下代码大概就知道为啥是13和26两个关键数字,就是将该字符替换为后13位的字符,例如A替换为N,N替换为A
直接修改网页源代码,alert(rotFlag)
就可以得flag{ClientSideLoginsAreEasy@flare-on.com}
解压后如下,点开html
直接F12
1 | function(c){ |
String.fromCharCode方法返回Unicode对应字符串;
charcodeAt()方法返回指定位置的字符的Unicode编码。
分析一下代码大概就知道为啥是13和26两个关键数字,就是将该字符替换为后13位的字符,例如A替换为N,N替换为A
直接修改网页源代码,alert(rotFlag)
就可以得flag{ClientSideLoginsAreEasy@flare-on.com}
自己的想法和题解差不多,但是实现比较繁琐(不是很好看,并且有点绕),看了题解简直醍醐灌顶!
1 | int search(int* nums, int numsSize, int target){ |
如果是C++采用容器的话,只需要修改如下一行
1 | int high = nums.size() - 1; |
1.先直接拖到IDA32中,直接查看一下字符串,看到下方字符串,有点像flag的样子
2.跟到代码的地方,看到sprintf函数将19999和0按照绿色字体的格式输出到String中。所以flag应该是flag{1999902069a45792d233ac}
加壳主要分为两部分工作,第一部分是主题程序,主要将原PE文件读入内存,然后对该文件各部分进行加工,主要包括压缩各区段数据,将输入表、重定位变形,将外壳部分与处理好的主题文件拼合。另一部分是外壳,主要包括加壳后程序执行时的引导段,它模拟PE装载器处理输入表、重定位表,最后跳转到原程序执行。
.pediy部分,以ShellStart为界,之前的部分以非压缩形式存在,之后的部分以压缩方式存在。新程序入口点指向ShellStart0开始的部分,外壳先执行这部分,这部分主要是在内存中将ShellStart开始处真正的代码解压缩,并初始化一些数据。初始化完成后继续转到ShellStart执行,该部分开始处的代码是真正的外壳部分,主要功能是还原程序(.text, .data等区块数据),一个重要功能则是阻止破解者的跟踪和脱壳。所以一般来说这段代码会比较长,里面会有各种调试器、反DUMP代码。
PE文件第一个字节位于MS-DOS头部,称作IMAGE_DOS_HEADER,结构如下。
关键字段:e_magic值为5A4Dh, e_lfanew是真正的PE文件头的RVA,位于从文件开始偏移3Ch处。
紧跟着的是IMAGE_NT_HEADERS(PE头)。
、
ASCII码字符为“PE00”
SizeofOptionalHeader:对于32位PE文件,这个域通常是00E0h,64位PE通常为00F0h。
下图为32位PE文件的相应结构体,64与32有一些区别,譬如PE32的BaseOfdata域不存在与PE32+,PE32的Magic域为010Bh,PE32+为020Bh,以及部分变量的类型不同。
DataDirectory字段是数据目录表,由数个相同的IMAGE_DATA_DIRECTORY组成,指向输入表、输出表、资源块数据。
紧跟IMAGE_NT_HEADERS的是区块表,是一个IMAGE_SECTION_HEADER结构数组。每个这样的结构都包含了所关联的区块信息,该数组的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSection指出。
常见的区块表如下
=====================================================================================
Note:由于该实现代码最终需要集成在某个项目工具上,所以这里并没有判断文件是否为PE文件以及是多少位的文件。
判断文件格式也比较简单:1.判断文件的第一个字是否为IMAGE_DOS_SIGNATURE,即5A4Dh;
2.通过e_lfanew找到IMAGE_NT_HEADERS,判断Signature字段是否为00004550h,即IMAGE_NT_SIGNATURE,如果是就认为是PE文件。
3.判断是否可以加壳,如果只有一个区块或者如果入口点的值大于第二个区块的虚拟地址,就认为已经被加壳。
4.校验FileHeader结构中的Characteristics字段的值,判断是EXE还是DLL。
加壳的主要流程:
- 加密代码段,增加区段
- 加密/压缩被加壳程序,将stub代码移植到新区段
- 将原始程序OEP记录下来并设置到新区段,修改OEP,在原始OEP之前执行解密代码
- 去掉随机基址,保存为新文件
外壳部分的代码都封装到一个DLL中,使用加壳部分的代码将DLL中的代码和数据植入加壳后的PE文件。
在加壳过程中,有一个加壳器程序和stub.dll两个文件,加壳器程序会把原文件(要加壳的文件)以文件方式读取到堆内存,它还是以文件对齐粒度(200h)对齐的,而stub.dll是以不处理的方式读取到了内存中,它是以内存粒度(1000h)对齐的。
使用LoadLibraryExA加载DLL并且第三个参数使用DONT_RESOLVE_DLL_REFERENCES的时候,他就不会对这个文件进行重定位等操作,是以原始形态加载到内存。
stub.dll里的.text段里面的数据需要先进行重定位修复,修复完成后再移植过去,这样壳区段才能正常运行起来。
首先根据stub.dll的重定位表获取出stub.dll中.text段需要重定位的数据,然后把该数据
- 减去原始基址(Nt->OptionalHeader.ImageBase)
- 减去原始代码段RVA(Nt->OptionalHeader.ImageBase)
- 加上新基址(exe目标文件)
- 加上新RVA(exe中新添加的区段RVA)
1 | void FixStubReloc(char* hModule,DWORD dwNewBase,DWORD dwNewSecRva) |
《加密与解密》如下处理也同样
1 | void CPE::FixReloc(PBYTE lpImage, PBYTE lpCode, DWORD dwCodeRVA) |
1 | typedef struct _IMAGE_BASE_RELOCATION { |
VirtualAddress:指向PE文件中需要重定位数据的RAV,由于每个重定位结构体只负责描述0x1000字节大小区域的重定位信息,因此这个字段的值总是0x1000的倍数。
SizeOfBlock:描述IMAGE_BASE_RELOCATION结构体与重定位数组TypeOffset的体积总大小(IMAGE_SIZEOF_BASE_RELOCATION+2*n)(n==代码中的dwCount)
TypeOffset 是一个数组,数组每项大小为两个字节(16位),它由高 4位和低 12位组成,高 4位代表重定位类型,低 12位是重定位地址,它与 VirtualAddress 相加即是指向PE 映像中需要修改的那个代码的地址。
将工程设置release版本,如果不想代码被优化,可以禁止优化。
大概流程如下(后三部分没有):
1 | //把数据段融入代码段 |
为方便迁移代码和数据,将两段融合,.text段也就是壳代码,因为加完壳后,在壳代码中无法使用导入表,因此,需要自己动态获取需要使用的API函数的地址。
只要获取到LoadLibraryExA和GetProcAddress两个函数的地址,我们就可以根据LoadLibraryExA来获取任意模块dll的基地址,再使用GetProcAddress函数获取到任意API函数的地址了。
根据kernel32基址可获取到GetProcAddress地址。
1 | //获取内核模块基址 |
上面代码部分为32位获取Kernel32.dll地址,可以在WinDbg中调试,定位TEB与PEB,定位Ldr,定位LDR_DATA_TABLE_ENTRY然后确定kernel32.dll基址。(这里罗嗦一下,具体说一下
TEB结构体部分成员
1 | struct _TEB |
PEB包含在其中,fs寄存器指向当前活动线程的PEB结构,所以fs:[0x30]可以获取到PEB地址,PEB结构体部分成员
1 | struct _PEB |
在偏移0xC的位置,存在struct _PEB_LDR_DATA* Ldr; ,这个_PEB_LDR_DATA结构体指针存储进程已加载的模块信息,就是包含了加载的DLL的信息。
_PEB_LDR_DATA结构如下
1 | //0x30 bytes (sizeof) |
这里有3个_LIST_ENTRY结构体,它们每个的意义是不一样的。其实这三个链表都可以找到Kernel32地址。
1 | 操作系统规定,每当为本进程装入一个dll模块时, |
_LDR_DATA_TABLE_ENTRY 部分成员
1 | //0x78 bytes (sizeof) |
_PEB_LDR_DATA中的3个字段InLoadOrderModuleList、InMemoryOrderModuleList、和InInitializationOrderModuleList,它们分别指向**_LDR_DATA_TABLE_ENTRY** 结构体上的InLoadOrderModuleLinks、InMemoryOrderModuleLinks、和InInitializationOrderModuleLinks字段。
而偏移0x18 ,VOID* DllBase 就是dll在该进程的基地址。我们要的就是这个,但是要找的kernel32的。
1 | //获取 DllBase |
64位的地址会有些不一样
1 | ULONGLONG GetKernel32Addr() |
用Windbg随便附加一个进程试试(**Note:**在X64环境下进行的)
然后是获取GetProcAddress函数地址,遍历kernel32模块输出表,找到函数地址即可
1 | ULONGLONG MyGetProcAddress() |
Stub项目应该设置为静态编译模式(配置属性-c/c++-代码生成-运行库),将MD改为MT
参考链接(x86):https://bbs.pediy.com/thread-250960.htm
参考书籍(x64):《加密与解密》
摘要:CONTI是去年最活跃的勒索软件家族之一。其中一个臭名昭著的攻击发生在HSE医疗(https://threatpost.com/conti-ransomware-fail-costly/166263/),攻击者要求2000万美元的赎金。正如Cybereason在https://www.cybereason.com/blog/cybereason-vs.-conti-ransomware上提到的,Conti在地下论坛上被作为RaaS(勒索软件作为服务)出售,它已经被TrickBot团伙部署。其中一个主要的担忧是,勒索软件集团采用了双重勒索的方法:他们还从受害者那里窃取敏感数据,并要求赎金。
该恶意软件可以运行不同的参数,如“-p”,“-m”,“-size”,“-log”和“-nomutex”,并创建一个互斥锁“YUIOGHJKCVVBNMFGHJKTYQUWIETASKDHGZBDGSKL237782321344”,以确保在同一时间只有一个实例的勒索软件运行。该恶意软件使用wmic和COM对象删除计算机上的所有卷影副本。勒索软件的目标是系统上所有有效的驱动器和可以访问的SMB共享。使用自定义ChaCha8实现对文件进行加密,该实现使用随机生成的密钥(32字节)和nonces(8字节),这些密钥由硬编码的公共RSA密钥加密。加密非常快,因为示例使用多个线程加密文件,并避免完全加密较大的文件(>5MB)。
可以看到是一些注册表的操作,最后带参数执行了一个WerFault.exe以及自身的删除操作。但是明显不太对,猜测是不是有反检测技术,去微步沙箱看,发现确实是这样。我们IDA分析看看能不能把反检测部分给nop掉。(WerFault.exe是Windows自带的主要用于错误报告的程序。但是网上没找到这个参数是什么意思,沙箱分析该进程行为主要是枚举进程和提权)
先看导入函数,有一个IsDebuggerPresent
(但是我在OD下了断点并没有触发)。
我们根据IDA静态地址推出函数入口地址00358470,下断点,直接运行到这里。(进程有很多这样的混淆字符串的操作)
该进程通过调用GetCommandLineW
函数来获取命令行字符串
CommandLineToArgvW
(00357E20中被调用)被用来获取一个指向命令行参数的指针数组(类似于C中的argv和argc值)
调试可以发现依次会有”-p”,”-m”,”-log”,”-size”,”-nomutex”,它解析程序使用的参数,并将它们与列表进行比较
-p directory:使用单个线程加密目录
-m local:使用多个线程加密可用驱动器
-m net:使用多个线程加密网络共享
-m all:用多个线程加密可用驱动器和网络共享
-m backups:未实现(可能加密备份目录?)
-size chunk:加密大文件的块模式
-log logfile:日志记录模式(在日志文件中记录不同的活动)
-nomutex:没有创建互斥对象
下图是恶意软件混淆大多数堆栈字符串方面的部分,用于解密它们的解密算法每次都在改变(操作和常量有一些变化)。
这里创建了一个互斥锁,保证只有一个实例在运行(如果它使用“-nomutex”参数运行,则不会创建互斥锁)。使用上面的算法解密互斥对象的名称。重要的API是在运行时导入的,而不是将它们全部包含在IAT(导入地址表)中。有一个哈希算法003447C0
用于确定应该导入哪些函数(第一个参数是哈希值,第二个参数表示偏移量)
GetNativeSystemInfo
用于获取当前系统的信息,如下图所示
恶意进程会创建2个(处理器的数量)处理加密的新线程(执行了两次,后面细说)。
CreateToolhelp32Snapshot
用于检索所有进程的快照(0x2 = TH32CS_SNAPPROCESS),紧接着该二进制文件通过调用Process32FirstW
和Process32NextW
例程枚举系统上的进程。
恶意软件还在寻找“explorer.exe”进程,并将其进程ID保存在缓冲区(检索进程以及寻找explorer.exe都是在0035BE90实现的)
紧接着下一个函数中,CoInitializeEx例程用于在当前线程上初始化COM库。
CoInitializeSecurity用于注册和设置进程的默认安全值(0x3 = RPC_C_IMP_LEVEL_IMPERSONATE):
该恶意软件使用COM对象和wmic删除所有卷的影子副本。它调用CoCreateInstance
用CLSID {dc12a687-737f-11cf-884d-00aa004b2e24}(COM对象的类标识符)创建IWbemLocator
对象:
使用相同的函数,用CLSID {44aca674-e8fc-11d0-a07c-00c04fb68820}创建一个新的IWbemContext
接口
使用ConnectServer
函数连接到本地的” ROOT\CIMV2 “命名空间,并获取一个指向IWbemServices
对象的指针
CoSetProxyBlanket
设置用于在代理上调用的认证信息(0xA = RPC_C_AUTHN_WINNT - NTLMSSP, 0x3 = RPC_C_AUTHN_LEVEL_CALL和0x3 = RPC_C_IMP_LEVEL_IMPERSONATE)
勒索软件通过执行以下查询SELECT * FROM Win32_ShadowCopy
获得所有影子副本的枚举数
下面使用自定义算法解密的字符串将用于删除系统上的所有影子副本
Wow64DisableWow64FsRedirection
用于禁用当前线程的文件系统重定向
该恶意软件会创建一个新进程来删除指定ID对应的影子副本,如下所示
当前线程的文件系统重定向通过调用Wow64RevertWow64FsRedirection
来恢复
系统上的所有有效驱动器都是恶意软件的目标,它使用GetLogicalDriveStringsW
来检索它们,这里就两个,分别是C:\和D:\
WSAStartup
例程启动当前进程对Winsock DLL的使用,如下图.
通过调用WSASocketW
(0x2 = AF_INET, 0x1 = SOCK_STREAM, 0x6 = IPPROTO_TCP)创建一个新的套接字
WSAIoctl
被用来检索一个指向相关服务提供商支持的扩展函数的指针(0xc8000006 = SIO_GET_EXTENSION_FUNCTION_POINTER)
通过调用gethostname
函数来提取本地计算机的标准主机名
本地机器的本地IP地址是通过调用gethostbyname例程来获取的
CreateIoCompletionPort
被用来创建一个I/O(输入/输出)完成端口,它没有与文件句柄相关联(0xffffffff = INVALID_HANDLE_VALUE)
从本地机器提取ARP表项,并以MIB_IPNETTABLE结构返回信息
十六进制格式的IP地址从每个条目转换成ASCII字符串(点分十进制格式的IP地址)
将IP地址与以下字符串进行比较:” 172.”、“192.168.”、“10.”和“169”。下面是一个这样的两个比较(就不一一放图了)
通过调用CreateThread
创建了2个新的线程
恶意软件将I/O完成包发送到前面创建的I/O完成端口
这一部分线程主要完成网络连接等操作。
发送完数据包后会执行waitForSingalObject
函数,此时先到线程sub_0035AF90。CreateTimerQueue
函数用于为计时器(指定在特定时间调用回调函数的对象)创建队列。
线程通信是使用由主线程创建的I/O完成端口完成的。GetQueuedCompletionStatus函数用于接收由主线程发送的缓冲区(对应前面那个函数)。
线程通过调用WSASocketW
例程(0x2 = AF_INET, 0x1 = SOCK_STREAM, 0x6 = IPPROTO_TCP和0x1 = WSA_FLAG_OVERLAPPED)创建一个新的套接字。
bind
将本地地址与新创建的套接字关联起来
CreateIoCompletionPort
函数将之前创建的TCP套接字与现有的I/O完成端口关联起来(它允许进程接收涉及到套接字句柄的I/O操作完成的通知)。
使用ntohs
例程将0x1bd(445)从网络字节顺序(大端序)转换为主机字节顺序(小端序)
该进程试图连接端口445(192.168.10.x和192.168.164.x),通过LPFN_CONNECTEX
回调函数(这里我没有看出来)
CreateTimerQueueTimer
用于创建一个计时器队列计时器。基本上,当计时器过期时(在我们的例子中0x7530 = 30秒),就会调用回调函数(0x010BAF60)
Process Monitor抓取到的网络行为
setsockopt
用于设置socket的SO_UPDATE_CONNECT_CONTEXT选项(0xffff = SOL_SOCKET, 0x7010 = SO_UPDATE_CONNECT_CONTEXT)(调快了一步…没截到)
为了检查连接是否成功,恶意软件调用getsockopt
函数,参数为0x700c = SO_CONNECT_TIME
这个线程主要实现加密
使用CryptAcquireContextA
获取Microsoft RSA和AES加密提供程序的句柄,如下所示
CryptImportKey
用于导入RSA公钥
该进程在它加密的每个目录中创建勒索通知
以下4字节常量表明加密算法是ChaCha8(主要还是看网上有介绍,这里有一篇论文)
WriteFile
写进去
该恶意软件使用FindFirstFileW
和FindNextFileW
枚举目标目录中的文件
调用PathIsDirectoryW
来检查文件路径是否为有效目录
下面的扩展名/文件被恶意软件跳过(“.LSNWX”是加密文件的扩展)
CryptGenRandom
生成32(0x20)个随机字节,将用作ChaCha8密钥
CryptGenRandom
生成8(0x8)个随机字节,将用作ChaCha8 nonce(只能被使用一次的随机数)
使用RSA公钥对ChaCha8密钥和nonce进行加密,如图所示
CreateFileW
函数用于打开将要加密的文件
将文件扩展名与多个目标扩展名的列表进行比较(整个列表很长就不一一贴了)
需要指出的是,如果文件扩展名不属于这些列表,这并不意味着该文件不会被加密。例如,作为例子的txt文件将被勒索软件加密,然而,它遵循不同的执行流程,将在几段中详细介绍。加密后的ChaCha8密钥和nonce是加密文件的一部分,如下所示
文件扩展名为”.LSNWX”,此时加密活动完成
网上博客分析说文件大小不同,加密的结果也不一样,主要分为小于1MB的,1MB到5MB的,大于5MB的。
恶意进程检索有关成功访问的IP上的SMB共享的信息,如下图所示(在这里是虚拟机本地IP地址进行调试),后面直接没有调用了,猜测估计是因为IP没访问成功(因为我后面把SMB打开了也没成功。
这个线程就是执行前面提到的参数命令。
拖进去发现代码非常之多,看了一下CFG就是一条路走到底,但是呢更像是在执行某个循环加密的函数,因为往下走的图会发现代码都是相同的(只是把for循环去掉了)。运行一下程序,试着在IDA中找一下这些字符串,试图找到获取输入的地方。但是并没有看到,所以我们用x64dbg动态调一下。下面是IDA分析出来的关键代码,会执行好几次。
这里是获取输入的地方
会跳转到这里,第一个函数点进去查看可以看到是很明显的tea加密
在这里生成delta,调试发现是0x33445566
我用了IDA7.0和7.5分析出来的程序长得都不一样,并且这两个都看不到中文的字符串,所以还是动态搞一下。
网上wp说跟着调试可以在内存中得到比较数据,但是x64dbg还不是很熟练,IDA动态调试数据窗口也还不太熟悉,先留着,稍后更新。
显示无壳,然后拖进IDA
实际上程序运行会首先弹出个string:
,我们搜索字符串,然后ctrl+x
找到read
函数位置,
从unk_403040地址开始作为int数组赋值给v4,由于intel是小端存储,所以每四个字节,从后往前查看字节,也可以在IDA,选中后按D
键,不勾选dup。
查看一下主要的函数vm_operad()
1 | int __cdecl vm_operad(int *a1, int a2) |
首先第一次是执行case 10, 这个指令就是read
函数操作:读取输入并且检测字符长度是否为15,这个输入就是正确的flag。然后再进行一系列操作,然后在case 7的时候将v4中的值一个一个地与unk_403040的下一个比特一个个比较(**我们可以打印出v10的值去对比一下a1数组中的值:)**)
把代码修改一下
调代码的时候可以看到v4的值其实都是v5的值,v5是由flag和unk_403040算出来的,所以反着算就知道flag啦
1 | int decode(int * opcode, int len_114) |
flag{757515121f3d478}
sub/subs汇编指令是进行减法运算指令。一般在函数内部第一句就是sub指令,是用来开辟内存空间。之前就说过栈空间是从高地址向低地址扩展的,而且是一块连续的空间,sp之前介绍寄存器的时候就说过是栈顶,所以第一条指令就是sp向低地址移动一段距离,也就是开辟一块栈空间。比如
sub esp,10h
就是在栈上分配0x10个字节的空间。
我们再说一下IDA7.5和7.0的区别,在分析的时候遇到这样的情况,第一张图是7.5,第二张是7.0,汇编代码都是一样的,都是先取一个数组地址然后将ebp + var_18
的值加上(相当于取数组下标),7.5是解析成为一个数组,前100字节和后100字节分别用来存储不同数据,所以意思都一样,注意理解就好。
异常主要分为软件和硬件(CPU引起的)两种。除了CPU能够捕获一个事件引发一个硬件异常以外,在代码中可以主动采用RaiseException
引发一个软件异常
Windows正常启动后,将运行在保护模式下,当有终端或者异常发生时,CPU会通过IDT(中断描述符表)来寻找处理函数。所以,IDT是CPU(硬件)和操作系统(软件)交接中断和异常的关口。每一个中断或异常向量在这个系统表中有对应的中断或异常处理程序入口地址。
IDT包含三种类型的中断描述符:任务门、中断门、陷阱门。
任务门:主要用于CPU的任务切换(当中断信号发生时,必须取代当前进程的那个进程的TSS(任务状态描述符表)选择符存放在任务门中)。
中断门:主要用于描述中断处理程序的入口。
陷阱门:主要用于描述异常处理程序的入口。
当中断或者异常发生时,CPU会更具中断类型号转而执行对应的中断处理程序。各个异常处理函数除了针对本异常的特定处理外,通常会将异常信息进行封装,以便后续处理。
封装内容一部分是异常记录,另一部分是陷阱帧。
异常记录的结构定义如下:其中第一个字段定义了异常产生原因,当然也可以使用RaiseException
函数自己定义
陷阱帧准确描述了发生异常时线程的状态(Windows任务调度是基于线程的),_KTRAP_FRAME
该结构与处理器高度相关,不同平台有不同定义。(每个线程在内核中都有一个 _KTrap_Frame
结构体,包含每个寄存器的状态,用于操作系统对线程的管理,该结构体时操作系统进行维护的。)
当需要把控制权交给用户注册的异常处理程序时,会将上述结构转换成一个名为CONTEXT
的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存线程运行环境。其中第一个变量ContextFlags
表示结构体中哪些部分(这些部分包括:控制寄存器、(整数)通用寄存器、段寄存器、浮点寄存器、调试寄存器、扩展寄存器)有效。
1 | typedef struct _CONTEXT |
异常处理函数会进一步调用内核的nt!KiDispatchException
函数来处理异常,第一个参数和第二个参数正是前面封装的两个结构
当PreviousMode
为KernelMode
,KiDispatchException
按照以下步骤分发异常:
nt!RtlDispatchException
nt!RtlDispatchException
没有处理该异常,系统会给调试器第2次处理机会KeBugCheckEx
产生错误码(0x0000008E)的BSOD(蓝屏)当PreviousMode
为UserMode
,KiDispatchException
按照以下步骤分发异常:
检测当前系统是否正在被内核调试器调试(一般不予理会)
如果发生异常的程序正在被调试,那么将异常信息发送给正在调试它的用户态调试器;若否则跳过
如果没有用户态调试器或者没有处理该异常,那么在栈上放置 EXCEPTION_RECORD
和CONTEXT
两个结构,并将控制权返回用户态ntdll中的 KiUserExceptionDispatcher
函数,由它调用ntdll!RtlDispatchException
函数进行用户态的异常处理。(涉及SEH和VEH)
如果ntdll!RtlDispatchException
未能处理该异常,那么异常处理过程会再次返回 nt!KiDispatch Exception
,它将再次把异常信息发送给用户态的调试器,给调试器第2次处理机会。如果没有调试器存在,则不会进行第2次分发,而是直接结束进程。
如果第2次机会调试器仍不处理,nt!KiDispatchException
会再次尝试把异常分发给进程的异常端口进行处理
在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常处理过程所获得的清理未释放资源的最后机会,此后程序就终结了。
**SEH( Structured Exception Handling,结构化异常处理)**是 Windows操作系统用于自身除错的一种机制,也是开发人员处理程序错误或异常的强有力的武器。SEH是一种错误保护和修复机制,它告诉系统当程序运行出现异常或错误时由谁来处理,给了应用程序一个改正错误的机会。从程序设计的角度来说,就是系统在终结程序之前给程序提供的一个执行其预先设定的回调函数的机会。
**TIB( Thread Information Block,线程信息块)是保存线程基本信息的数据结构。在用户模式下,它位于TEB( Thread environment Block,线程环境块)**的头部,而TEB是操作系统为了保存每个线程的私有数据创建的,每个线程都有自己的TEB。
在x86平台的用户模式下,Windows将FS段选择器指向当前线程的TEB数据,即TEB总是由**fs:[0]指向的(在x64平台上,这个指向关系变成了gs:[0]。关于x64平台上的异常处理,会在85节详细讲述)。而当线程运行在内核模式下时, Windows将FS段选择器指向内核中的 KPCRB结构( Processor Control Region Block,处理器控制块)**,该结构的头部同样是上述的 NT_TIB
结构。
该结构主要用于描述线程异常处理过程的地址,多个该结构的链表描述了多个线程异常处理过程的嵌套层次关系。
异常处理链表示意图如下(其中ERR指的就是该结构)
由于TEB是线程的私有数据结构,相应地,每个线程也都有自己的异常处理链表,即SEH机制的作用范围仅限于当前线程。从数据结构的角度来讲,SEH链就是一个只允许在链表头部进行增加和删除节点操作的单向链表,且链表头部永远保存在fs:[0]
处的TEB结构中。
这两个结构分别描述了异常发生的异常相关信息和线程状态信息,在前面已经介绍过。
当一个异常发生时,在没有调试器干预的情况下,操作系统会将异常信息转交给用户态的异常处理过程。实际上,由于同一个线程在用户态和内核态使用的是两个不同的栈,为了让用户态的异常处理程序能够访问与异常相关的数据,操作系统必须把与本次异常相关联的EXCEPTION_RECORD
结构和 CONTEXT
结构放到用户态栈中,同时在栈中放置一个EXCEPTION_POINTERS
结构,它包含两个指针,一个指向 EXCEPTION_ RECORD
结构,另一个指向 CONTEXT
结构,示例如下。
由于**fs:[0]**总是指向当前异常处理程序的链表头,当程序中需要安装一个新的SEH异常处理程序时,只要填写一个新的 EXCEPTION_REGISTRATION_ RECORD
结构,并将其插入该链表的头部。
根据SEH的设计要求,它的作用范围与安装它的函数相同,所以通常在函数头部安装SEH异常处理程序,在函数返回前卸载。可以说,SEH是基于栈帧的异常处理机制。在安装SEH处理程序之前,需要准备一个符合SEH标准的回调函数,然后使用如下代码进行SEH异常处理程序的安装。
理解了SEH的安装过程,再看SEH的卸载就比较简单了,只要把刚才保存的fs:[0]的原始值填回并恢复栈的平衡即可,相当于从链表头部删掉了一个节点,示例如下。
因为没有找到示例的EXE,所以这里直接记录原文。
示例程序 seh.exe演示了一个结构异常处理的例子。由于 Windbg结合符号文件可以非常清楚地显示各类数据结构,这里使用 Windbg进行调试。用 Windbg打开该程序,输入“g”
命令让 seh. exe运行,程序将在0x00401038处停住,具体如下。
可以知道这是一个内存访问异常,因为程序试图从esi寄存器指向的0地址处读取数据,并且是第1次给调试器处理机会( First Chance)为了跟踪系统对异常的处理过程,需要在系统的异常处理代码处下断点,示例如下。
然后,在 WinDbg的命令窗口中输入“g”
,继续执行。四命令表示不处理异常并继续执行。接下来,异常会被交给用户态的 ntdll!KiUserExceptionDispatcher
函数进行进一步分发,所以在这个函数的开头会发生中断,示例如下。
可以看到,此时栈顶就是 EXCEPTION_POINTERS
结构,它包含 EXCEPTION_RECORD
和CONTEXT
两个数据成员,具体如下。
Exception_Record =0012fcd00
Context Record = 0012fcec
程序当前的esp为00012c8,可以看出它们之间的位置关系如下。
esp指向_ EXCEPTION_ POINTERS结构,其大小为8字节。
Exception Record=esp+8,也就是说, EXCEPTION_POINTERS
结构之后紧跟 EXCEPTION_RECORD
。观察 EXCEPTION_RECORD
结构,具体如下。
该结构已经明确指出了异常代码( Exception Code)和发生异常的位置( Exceptionaddress)。
EXCEPTION_RECORD
结构的最后一个元素 Exceptioninformation
的大小是不固定的,实际上这是由前一个元素 Numberparameters
决定的,因此,在当前例子中, EXCEPTION RECORD的实际大小如下。o
0x14 + 2 * sizeof(ULONG)=0x1c
ExceptionRecord + 0x1C = 0012fcd0 + 0x1c = 0012fcec ( ContextRecord的位置)
下面看一下CONTEXT
结构,其中esi和eip与上面图中一样
接下来从源代码分析一下(手脱yoda最开始有这样的程序例子)
重新加载程序,执行到0x00401036处时,SEH异常回调函数安装完毕。观察此时的TIB结构,具体如下。
可以看到,当前SEH链的第1个节点在0x0012ffb8处,该节点的 Handler为0x00401000。对0x00401000设断,然后输入“g”命令让程序运行。当执行0x00401038一句读取线性地址0时会产生异常,程序跳转到0xo0401000( Handler)处继续执行。因此,只有提前在 Handler地址处设置断点,才能正常跟踪SEH代码的运行,否则代码就有可能跟“飞”了。
异常处理的过程实际上是系统将异常发送到各个异常处置单元进行处理的过程,也叫异常分发。
用户态的异常分发是从 ntdll!KiUserException_Dispatcher
函数开始的。此时,栈中有 EXCEPTION_RECORD
和 CONTEXT
两个结构。该函数的主要流程可以用以下代码表示。
可以看到ntdll!RtlDispatchException
函数用来具体分发异常,我们详细看一下这个函数的代码
①调用VEH ExceptionHandler进行异常处理,若返回继续执行,则直接返回,否则继续进SEH部分的处理。
②遍历当前线程的异常链表,逐一调用 RtlpExecuteHandlerForException
。该函数会调用SEH异常处理函数,根据不同的返回值进行不同的处理。
对 ExceptionContinueExecution
,结束遍历并返回(对标记为 EXCEPTION_NONCONTINUABL
的异常不允许再次恢复执行,会调用 RtlRaiseException
)
对 ExceptionContinuesearch
,继续遍历下一个节点。
对 ExceptionNestedException
,从指定的新异常开始继续遍历。只有正确处理 ExceptionContinueExecution
才会返回“TRUE”,其他情况都返回“ FALSE”。
③调用 VEH ContinueHandler进行异常处理。
其实就是指SEH异常处理,因为SEH的整体设计思路就是基于线程的。
回调函数原型定义如下
pExcept:指向前面介绍的包含异常处理信息的 EXCEPTION_RECORD
结构的地址
pFrame:指向SEH链中当前 EXCEPTION_REGISTRATION
结构的地址。
pContext:指向与线程相关的寄存器映像 CONTEXT
结构的地址。
pDispatch:该域用于内嵌异常的处理,读者可以暂时忽略它。
回调函数要做的就是通过 EXCEPTION_RECORD
结构中的信息判断当前异常是不是自己能够处理的。如果能,那么需要根据异常产生的原因进行相应的修正,必要时会修改 CONTEXT
结构,然后恢复执行;如果不能,则告诉系统去寻找下一个处理程序。
ExceptionContinueExecution:表示回调函数处理了异常,可以从异常发生出继续执行。
ExceptionContinueSearch:回调函数不能处理异常,需要SEH回调函数链表中其他回调函数来处理。
ExceptionNestedException:回调函数在试图处理该异常的时候再次发生了异常(嵌套异常)。这种情况是比较糟糕的。如果这种情况发生在内核中,则会直接 Bugcheck,停止系统的运行。如果这种情况发生在应用层,系统会尝试从嵌套异常的事发地点重新分发和处理嵌套异常。
ExceptionCollidedUnwind:返回该值表示回调函数在进行异常展开操作时再次发生了异常,其中展开操作可以简单理解为恢复发生事故的第一现场,并在恢复过程中对系统资源进行回收。与上一个返回值一样,这也是非常严重的错误。但是,由于展开操作一般是由系统在处理异常的过程中进行的,用户自定义的回调函数通常不返回这个值。
后两个返回值只见于系统内部的处理过程。
我们已经知道,传递给回调函数的 EXCEPTION_RECORD
结构的 Exceptionflags
域有3个可选值,分别是0、1和2。0表示可修复异常,前面的例子都是可修复异常;1代表不可修复异常,这在应用程序中不多见,只有在异常处理中又发生了异常或者系统内核发生严重错误时才可能导致这种情况;2代表展开操作。
一般情况下,只有在系统终结程序之前,栈展开才会发生,最主要目的是给程序清理未释放资源的机会。
还有一种情况会使用栈展开(如下图介绍)
Windows提供了进行栈展开的API函数RtlUnwind
EXCEPTION_REGISTRATION
的指针,即希望在哪个回调函数前展开调用停止,其对应的 EXCEPTION_REGISTRATION
结构的指针就作为该参数使用(在大部分情况下是引发调用的回调函数所对应的 EXCEPTION_REGISTRATION
结构的指针,也可以不是)Rtlunwind
返回后应执行指令的地址。如果为0,则自然返回 Rtlunwind
调用后的下一条指令,与正常的AP调用相同。EXCEPTION_RECORD
结构,可以直接使用在异常中传递给回调函数的该参数。 ①在栈上生成一个·EXCEPTION POINTES
结构,并将其保存到[ebp-10]处。
②获取当前的Trylevel
,判断其值是否等于-1。若等于,则表示当前不在Try
块中,返回ExceptionContinueSearch
,继续寻找其他异常处理程序。
③若 Try Level
的值不等于-1,并根据 Try Level
在 Scope Table中找到相应的 SCOPTETABLEENTRY
,判断 Filter Func
是否为“NULL”。若为“NUL”,说明是_try/finally
组合。因为该组合不直接处理异常,所以也返回 ExceptionContinueSearch
④若 Filterfunc
不为“NUL”,说明是_try/_except
组合,那么执行 FilterFunc
,然后判断其返回值(也就是前面讲到的3种返回值),根据返回值的不同执行不同的动作。 EXCEPTION_CONTINUE_SEARCH
和 EXCEPTION_ CONTINUE_EXECUTION
的意义比较明确,就不多介绍了。若返回值是 EXCEPTION_EXECUTE_ HANDLER
,就是去执行 HandlerFunc
,执行完毕会跳转到当前Try
块的结束位置,同时表示本次异常处理结束,此时_except_handler3
将不返回。
⑤如果异常没有被处理,最后会由系统默认的异常处理函数进行处理,它在展开时会调用finally
块的代码。
因为几乎所有由MSC编译生成的sys, dll, exe文件都需要使用 _except_handler3
异常处理函数,并且都需要进行SEH的安装和卸载,所以编译器把这部分代码提取出来,形成了两个独立的函数,分别叫作 SEH_prolog
和 SEH_epilog
。它们的主要作用就是把_except_handler3
安装为SEH处理函数及卸载,这也是在反汇编那些使用了SEH的系统API时总会看到如下代码的原因。
不过现在已经更新,SCOPE_TABLE
增加了 Security Cookie的相关内容,这是微软为了防止缓冲区溢出而设置的栈验证机制。在函数开头会对栈中的 ScopeTable
使用 Cookie作为密钥进行加密,异常处理函数也变成了_ except_handler4
。在该函数中,除了增加了对 Security Cookie
和 ScopeTable
的验证之外,整体流程与_ except_handler3
完全一致。
顶层(Top- -level)异常处理是系统设置的一个默认异常处理程序,所有在线程中发生的异常只要没有被线程异常处理过程或调试器处理,最终均交由顶层异常回调函数处理。Q:SEH不是基于线程的吗?为什么这个 Toplevel异常处理程序能够处理所有线程的异常呢?
一般程序开始创建任意线程运行前默认设置一个顶层异常处理程序。所谓的顶层异常处理意思是,当程序的异常通过SEH链一直传递到最后一个我们自己设置的SEH链结点都没有处理此异常时,会调用这个顶层异常处理函数。
1 | _try |
UnhandledExceptionFilter
,此函数会在内部调用全局变量kernel32!BasepCurrentTopLeveFilter
保存的函数地址,此函数可以干预UnhandleExceptionFilter
的返回值。而我们可以调用SetUnhandledExceptionFilter
函数改变此全局变量的值为自定义函数的地址。所以顶层异常处理是基于进程的(顶层异常过滤函数返回值干预函数的值是全局变量保存的)。Kernel32!_except_handlerX
,Kernel32!_except_handlerX
会先调用UnhandleExceptionFilter
过滤函数,而如果我们在UnhandleExceptionFilter
过滤函数中已经把此异常处理了则就不会执行默认的异常处理,否则默认的异常处理就是结束应用程序。UnhandledExceptionFilter
过滤函数,此函数又会调用ZwQueryInformationProcess
(函数先判断是否有调试器存在,有的话会直接返回进行异常的二次分发(一般就是会结束进程)。ZwQueryInformationProcess
函数没有检测到调试器的存在的话其将会执行默认异常处理。而一般默认异常处理是默认的终止程序的函数。(如果没有调试器的话异常是不会进行二次分发的,这点很重要)UnhandledExceptionFilter
进行干预从而修改其返回值让其正常返回,而不执行默认的异常处理终止程序。此函数就是SetUnhandledExceptionFilter
,此函数具有唯一的参数就是设置用来干预UnhandledExceptionFilter
过滤函数的回调函数的地址,UnhandledExceptionFilter
会在内部调用这个函数。(一般称这个函数为顶级异常处理函数,我认为这么称是不准确的。因为真正的顶级异常处理函数是默认的异常处理函数,此函数应该称为顶级过滤干预函数)所以一般会利用此顶层异常处理函数的特性,主动产生异常,然后调用
SetUnhandledExceptionFilter
设置顶层过滤函数的干扰函数来处理异常,如果用户调式程序的话,因为顶层异常处理会检测到有调试器,因此不会调用顶层过滤干扰函数来处理异常,导致异常无法处理从而终止运行。(需要我们改变ZwQueryInformationProcess
函数的返回值来骗过UnhandledExceptionFilter
函数让他以为无调试器)
Detail:详细看《加密与解密》,后面准备逆向一个程序详细分析一下这个异常处理
为了防止比如某些溢出攻击时,栈中的SEHandler可能被覆盖为非法过程的情况,微软提供了SafeSEH机制、SEHOP机制。
编译器提供SEH基础数据,由操作系统在产生异常时进行验证。首先编译器在编译PE文件时加入了一个 SafeSEH
开关。如果编译时打开了这个开关,那么编译器会在PE头的 DllCharacteristics中加入一个标志,并在编译阶段提取所有异常处理程序的相对虚拟地址(RVA),将它放入一个表。这个表的位置是由PE头部 IMAGE_OPTIONAL_HEADER
结构中数据目录的第10项指定的,相关定义如下。
Sehandlertable
是指向一个SEH处理函数Rva的表格, Sehandlercount
是这个表格的项数,它指出了有几个有效的 SEHandler。当PE被载人时,PE的基址、大小、 Sehandlertable
(表格的地址)、Sehandlercount
(长度)会保存在ntdl的一个表格中。当异常发生时,系统会根据每个PE的基址和大小检查当前 Sehandler处理函数属于哪一个PE模块,然后取出相应的表格地址和长度。在载入时就已经取出,载入后 Sehandlertable
和 Sehandlercount
就没有用处了,所以对它进行修改不会影响系统对 SEHandler的验证结果。
系统在对栈及栈中的 EXCEPTION_REGISTRATION_ RECORD
结构进行初步验证之后,会调用 RtlIsValidHandler
对异常回调函数的有效性进行验证。该函数的伪代码如下。
1 | BOOL RtlIsValidHandler(handler) |
分析三种情况的可行性:
另外突破SafeSEH的方法:
SEH覆写保护机制,用于检测SEH是否被覆写
ntdll!FinalExceptionHandler
。突破SEHOP方法
不去攻击SEH,而是攻击函数返回地址或虚函数等
利用未启用SEHOP的模块
伪造SEH链
向量化异常处理的基本理念与SEH相似,也是注册一个回调函数,当发生异常时会被系统的异常处理过程调用。可以通过API函数 AddVectoredExceptionHandler
注册VEH回调函数,其原型如下。
VEH回调函数也形成了一个链表。若参数 FirstHandler
的值为0,则回调函数位于VEH链表的尾部;若参数 FirstHandler
为非零值,则置于VEH链表的最前端,当有多个VEH回调函数存在时,这将影响回调函数被调用的顺序。应将该函数的返回值保存下来,用于卸载回调函数。VEH回调函数所在的模块被卸载之后,系统不能自动将回调函数地址从VEH链表上移除,需要程序在退出前自己完成卸载工作,可以使用如下API实现。
该函数只有一个参数,即 Vectoredhandlerhandle
(就是前面保存的 AddVectoredExceptionHandler
的返回值)。回调函数的参数 PEXCEPTION_POINTERS
与在顶层异常处理回调函数中用到的参数是一致的,即指向 EXCEPTION_POINTERS
结构的指针。回调函数合理的返回值只有两个,分别是 EXCEPTIONCONTINUE EXECUTION
和 EXCEPTION_ CONTINUE_SEARCH
,其意义与SEH回调函数的意义相同。
当异常发生时,VEH会优先于SEH获得控制权(但如果有调试器,调试器会优先于VEH回调函数),系统会自动调用 AddVectoredExceptionHandler
注册的VEH回调函数。如果回调函数修复了异常,则返回 EXCEPTION_CONTINUE_EXECUTION
,在异常发生处以 CONTEXT
指定的线程环境继续运行,此时SEH处理过程将被跳过。如果回调函数不能处理异常,回调函数应返回 EXCEPTION_CONTINUE_SEARCH
,系统会釆取与SEH大致相同的策略遍历VEH链表。如果整个链表搜索完毕,没有回调函数对异常进行处理,则将控制权转移给系统,由系统继续遍历SEH链表上注册的回调函数,其过程如SEH机制所示。
与X86的区别是,SEH的相关数据结构以及存储位置发生了改变。因为栈是动态变化的,很容易被缓冲区溢出等操作破坏, SafeSEH和 SEHOP机制是在必须保证兼容性的情况下不得不采取的增强保护措施,属于无奈之举。所以,到了新的x64平台上,不仅统一了函数调用约定为 _fastcall,对SEH的相关数据结构也重新进行了定义,主要设计思路如下。
在具体实施上,编译器主要做了以下工作。提取所有函数的起始地址、结束地址、函数的“序幕”操作(包括栈操作和寄存器操作)、异常处理信息等,生成两个表。其中一个表可以称作函数信息表,包含当前程序中所有函数在内存中的位置信息(除了叶函数,即那些既不调用其他函数,也不包含异常处理的函数)。这些信息被放在一个单独的区段 .pdata
中,该区段的位置可以从PE头部的数据目录 IMAGE_DIRECOTRY_EXCEPTION_DIRECOTY
(定义为3)中找到,数据定义如下.
在该表中,每一条函数的信息被称为一个 Function Entry
,所有 Function Entry
都是按照RVA的大小升序排列的,这样便于使用二分法快速查找。除了函数的起始和结束位置,上述结构中还包括 UnwindData
数据,它也是一个RVA,所指向的结构则包括函数的“序幕”操作(包括栈操作和寄存器操作)和异常处理信息等。这个表叫作**UNWIND_DATA
表**,示例如下。
OD加载程序,发现入口处有PUSHAD指令,尝试一下ESP定律
断在了这里,我们可以看到这里将给SEH链添加一个新的节点(**注意看fs:[]**),接着通过JMP指令跳转到下面引发一个异常,我们跟到JMP指令处。点击view-SEH chain查看,可以看到刚刚安装的异常处理程序入口地址为46590B。
不出意外的话到达该异常处理程序,随后就会到达OEP,这里OEP为4271B0(其实和之前的exe一样,只是加壳程序不一样)
和之前一样,我们看一下数据窗口以及find reference,看到这些IAT项目都和之前提到的IAT重定向一样,下图中00590000处的大小在壳执行之前长度为3000字节,但是壳执行以后,该区段变成了47000字节,即可壳将该区段增大了,并且增大的部分供自己使用。
右键Follow,看看被重定向到了哪里
我们先试一下Imprec自带的trace能否修复,show invalid—右键Trace Level1,可以看到修复了一部分
先让程序停在OEP处,然后随便定位一个重定向过的IAT项。我们给460ECC这一项设置硬件写入断点,接着如果我们重启OD的话,硬件断点依然存在。重启运行起来,断在了这里
EDX的值460ECC,即指向了之前我们设置了硬件断点的那个重定向的IAT项,这里其被写入的是正确的值,随后会被修改为重定向过的值,我们继续往下跟,看看会发生什么。
到了这里,460ECC处的值有了变化,这里460ECC中被写入重定向过的值,ESI此时保存的就是重定向过的值。
也就是说正常的IAT项只会被写入一次,而需要被重定向的项将被写入两次。正常的IAT项在46577F的跳转处会直接跳转到4657B1,这里我们可以看到46577F这一处JMP指令,其将跳过红色箭头标注的设置为重定向值的指令,所以这里是关键跳,之前还有几处条件跳转,这里我们来尝试NOP掉写入重定向值的指令,看看效果如何。
但是我们首先需要给465799这条指令设置硬件执行断点,这里我们并不向想其被写入重定向的值,所以给其上一行设置硬件执行断点。然后重新加载程序运行起来,断了下来,我们将写入重定向值的指令NOP掉。
删除硬件断点后,运行起来发现行不通,再次回到关键跳处,可以看到46577D处的条件跳转将直接跳到下面,这样46577F处的关键跳将得不到执行,接着将被写入重定向的值,我们尝试将46577D这处条件跳转NOP掉,让其直接执行46577F处的JMP指令,我们来看看效果。
发现依旧不行,那么我们来看看IAT吧,我们可以看到IAT项都被修复了,都是正确的。这里我们有两种选择: 一,再开一个OD,加载该CrackMe的另一个实例,直接跟到OEP处,不修改任何东西,然后将当前我们这个实例的正确的IAT复制出来,覆盖掉新开的这个实例的IAT,注意是二进制复制,这个方法比较简单。二,直接用IMP REC定位到CrackMe的进程,此时IAT全部被写入正确的值了,但是还未到达OEP处,我们直接填上OEP,RVA,SIZE等数据,没有到达OEP处没有关系。以此来进行修复。
每次我们定义了一个新的SEH异常处理回调函数,EXCEPTION_REGISTRATION结构的prev字段都被要求填写上一个EXCEPTION_REGISTRATION结构的地址,随着应用程序对模块的调用一层层深入下去的时候,那么最后回调函数会形成一个SEH链.
从数据结构的角度来讲,SEH链就是一个只允许在链表头部进行增加和删除节点操作的单向链表,且链表头部永远保存在fs:[0]
处的TEB结构中。
详细请看SEH笔记。
cs是代码段寄存器
1 | 存放当前正在运行的程序代码所在段的段基址,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移量则由IP提供。 |
ds是数据段寄存器
1 | 当前程序使用的数据所存放段的最低地址,即存放数据段的段基址. |
ss是堆栈段寄存器
1 | 当前堆栈的底部地址,即存放堆栈段的段基址。 |
es是扩展段寄存器
1 | 当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段 |
fs是标志段寄存器
1 | fs是80386起增加的两个辅助段寄存器之一,在这之前只有一个辅助段寄存器ES |
gs是全局段寄存器
1 | gs是80386起增加的两个辅助段寄存器之一,在这之前只有一个辅助段寄存器ES |
**ps:在x86平台的用户模式下,Windows将FS段选择器指向当前线程的TEB数据,即TEB总是由**fs:[0]**指向的,在x64平台上,这个指向关系变成了**gs:[0]**。**
这章的内容主要是bitarts 5.0和telock0.98两款壳。
先将bitarts5.0拖进OD,可以看到和之前的两个壳不一样,并不是先pushad保存寄存器环境,我们F7往下走。
走到PUAHAD处,按下F7,采用ESP定律等方法都不可,应该是和操作系统有关系,所以拖到了win7里面调试,采用OD自带的查找OEP的方式,断在了OEP处,但是需要修复一下数据:analysis-> analysis code
看一下这个函数属于的dll,在数据区段不同的dll函数用00隔开
直接拉到底部标红的地方是最后一个IAT项,右键find references
后面的内容就不是IAT的内容了(可以右键find reference试一下,是空的),所以IAT结束地址是460F28,往上拉我们可以看到80000008这样的值,明显不属于任何一个dll,右键find referenc也没有,所以IAT起始地址是460818
IMP REC需要的三条数据我们都有了:
OEP = 271B0(RVA)
IAT起始地址 = 60818(RVA)
IAT 大小 = 710
有一些垃圾数据,我们直接show invalid-右键cut thunk,最后成功
照常用OD自带的功能找到OEP处,程序其实和前面的一样,只是壳不一样。先看看程序调用了那些API函数,右键选择-Search for-All intermodular calls。
可以看到都被重定向到了其他段,这些间接CALL并不是去调用系统DLL的中API函数,而是转向了2Axxxx这类地址的一个区段
其中有一部分是直接调用API函数的,我们选择其中一个跟随,然后查看数据窗口,拉到最下面可以看到IAT的结束地址为460F28,往上看可以看到一些不一样的数据
这些就是IAT中重定向的一些项,当程序运行起来时,壳的解密例程会覆盖掉IAT中的某些项,将这些项重定向到解密例程中,我们拿4038A6这处为例,我们在前面程序知道这里是GetVersion,但是目前不知道,所以跟进去看看是什么
该地址不属于源程序区段,如果我们重启OD断在入口点处时,会发现这个时候该区段并不存在。**因此,这个内存块是在壳解密例程运行过程中创建的,**我们来看看它是什么时候被创建的。
这里我们可以在区段列表窗口中看看刚刚创建的这个内存块,可以看到该内存块被标记为了Priv(私有的),也就是说该内存块是壳自己创建的。
往下跟几步,可以看到这里的PUSH指令将GetVersion的地址压入到堆栈中,接着RET将会返回到GetVersion的入口处,这样就可以达到间接调用API函数的目的。
也就是说,壳会将GetVersion的IAT项替换成自己创建的内存单元中的地址,起到了一个重定向的作用。因此我们在定位IAT的起始和结束位置的时候,不仅仅要判断是否为系统DLL中的地址,还是需要判断其是否为重定向过的地址。
下面我们继续来定位IAT的起始和结束位置。往上看可以看到80000000,明显不属于任何一个内存单元。,并且前面的0006xxxx右键find referrence并没有东西。
所以IAT的起始地址为460818,长度为710,OEP为4271B0。
OEP = 271B0(RVA)
IAT起始地址 = 60818(RVA)
IAT长度 = 710
可以看到有无效的IAT项,接下来进行修复
第一种是手动修复(非常麻烦),第二种是可以采用Imrpec的插件tElock1,这种方法会有一些遗留未修复,并且只针对特定的壳,第三种方法是imprec自带的功能(右键trace level123),这几个选项只能对比较简单的壳起作用。
还有一种方法叫关键跳法,这种方法就是定位壳填充IAT的时机,看看何时填充正确IAT项,何时填充重定向过的IAT项。
我们重新加载程序,首先定位到第一个函数Getversion,460ADC中的值为3D830870,在壳的解密例程执行过程中会将重定向过的值2706F7填充到460ADC这个内存单元中。为了能够定位何时该IAT项被写入,我们可以对460ADC设置内存写入断点,让壳在此处写入重定向过的IAT值的时候断下来。
发现断在了这里。这里并不是是我们要定位的点,因为我们按F8键执行这一行会发现重定向过IAT值并没有被写进来,我们继续运行。过程中虽然460ADC处的值有变化,但是都不是我们想要的值2706F7
我们可以看到当前ECX寄存器的值正好等于重定向过的IAT值2706F7。ECX = 2706F7,将被保存到EAX = 460ADC指向的IAT中,这里就是我们要定位的地方。我们按下F8键,2706F7就会被保存到460ADC中,这只是第一步,接下来我们需要定位关键跳转。
达到了46651A处JE指令处,我们看看寄存器窗口中EBX寄存器的值,当前EBX正好指向了GetVersion的函数名称字符串。继续往下我们可以看到会调用GetProcAddress获取GetVersion这个API函数的地址。
F8执行后,我们可以看到此时EAX保存了GetVersion这个API函数的地址。
(下面的图是我自己的忘记保存了,直接截取了教程的)
继续往下我们可以看到会调用GetProcAddress获取GetVersion这个API函数的地址。
我们可以在堆栈窗口中看到参数情况,按F8键执行这个CALL。
我们可以看到此时EAX保存了GetVersion这个API函数的地址,我们继续往下跟。
这里有一个条件跳转,这个条件跳转是成立的,我们不跟过去,直接单击鼠标右键选择-Follow。
这里我们可以看到GetVersion的函数地址并不是被填充到IAT中,而是被写入到EDI指向的内存单元中,当前EDI为:
保存了该API函数的地址以后,紧接着我们到了JMP指令处。
这里通过这个JMP我们又将回到这个过程的开始处,这里是一个循环,我们单击鼠标右键选择-Follow。
这里接下来又要将第二个重定向过的IAT值填充的下一个IAT项中,然后通过GetProcAddress获取对应的API函数地址,并将获取到的API函数地址保存到某处,接着又是第三个重定向过的IAT值,循环往复,直到所有IAT项都被处理完毕为止。
接下来的任务就是需要将IAT项被重定向的流程修改为正确IAT项的处理流程。
我们在460BAC处设置断点(因为这不是重定向),对460BAC这4个字节设置内存写入断点,运行起来,看看壳何时会将正确的IAT项填充进来。断在了下面这里,正确的IAT值将被写入。
我们从循环的起始位置开始跟,到这里跟之前IAT项被重定位的处理有明显区别,跳过的中间的大片代码。
可以看到这里是一个长跳转,跳转与否取决于是IAT项是否被重定向,也就是说这里是决定是IAT项是否被重定向的关键跳,我们可以将这个条件跳转替换为JMP。
但是这个跳转在程序一开始的时候并不存在,我们重启程序。我们可以看到在程序刚开始的时候,这里都是一些垃圾指令,壳会随后的某个时间点写入实际的功能代码。
我们对460818~460f28这片IAT区域设置内存写入断点。这里我们对所有的IAT项都设置上了内存写入断点,当第一个IAT项被写入的时候就会断下来。
断下来后继续执行,直到整个IAT都被填充完毕为止,此时关键跳已经存在了。我们将该关键跳转JE替换成JMP,接着清除之前设置的内存断点,接着跟踪到OEP处。
到了OEP后的IAT:
我们可以看到所有的IAT项都是正常的,我们成功修复了IAT,现在我们可以进行dump了。dump之后再用Imprec即可