常见的文件特征码识别定位是基于硬编码指令定位、逻辑偏移量等方法,这种方法对于定位字符串等相对固定的数据非常友好。但是,对于编译程序中经常更新的组件,定位其中的指令代码就会因为版本更新而需要同步更新定位方法。文本介绍一种基于控制流程和函数调用链导向的特征码匹配定位方法,同时也从个人有限的角度去分析如何更好地选择特征码。
特征码是一串二进制字符串,可以用来定位数据、判断字段、识别病毒等。特征码通常是从文件或汇编代码中提取。特征码可以被杀毒软件用来扫描病毒,也可以被病毒用来修改或掩码,实现免杀。
通常情况下,特征码的选择需要逆向工程手工分析目标指令段的上下文特征,并从中提取目标部分的指令字节。也有很多工具可以辅助提取文件的特征码。
一个软件厂商一般会随着更新,发布同一个程序的不同编译版本,这些二进制文件可以没有发生结构、指令的修改,也可能因为修复 BUG、扩展功能,而使得文件中被修改数据附近的数据发生显著变化。这就要求选取特征码时候,要尽可能精准,特征码要选取不易于变化的部分,同时又要兼顾与目标的距离。?为了提升效率,一般只会对修补版本进行新的分析,并根据文件版本号差异性定位目标指令。
例如下面的代码,输出10~20之间的数字。
#include <stdio.h>
int main()
{
// for 循环执行
for (int a = 10; a < 20; a++)
{
printf("a: %d \n", a);
}
return 0;
}
这段代码编译之后,在 IDA 中反汇编如下:?
我们可以看到,如果我们想要修改这里循环中的的 call printf 指令,则需要匹配如下的特征码:
8B XX 48 8D XX XX XX XX XX E8 XX XX XX XX
这样子的特征可能存在很多干扰,尤其是对于复杂的程序,类似的函数调用方式可能存在多处。
比如下面的代码:
#include <stdio.h>
int main()
{
printf("Test StringB\n");
printf("Test StringA\n");
// for 循环执行
for (int a = 10; a < 20; a++)
{
printf("a: %d \n", a);
}
for (int b = 10; b < 20; b++)
{
printf("b: %d \n", b);
}
printf("Test StringC\n");
return 0;
}
经过反汇编,我们可以观察到相似的指令序列:
此时,我们可以按照匹配的顺序,来定位我们需要的那个位置,或者以函数的参数来判断我们需要定位的位置,或者结合多处特征码来确定需要的目标位置。
控制流分析需要构建程序执行的控制流程图,并根据程序控制流程中的条件判断、分支、循环语句的特点,模拟程序执行的过程,并根据该过程链的导向,定位函数中目标指令。?
Windows 的 Win32 API 属于导出函数,有很多导出函数是对内部调用细节进行二次封装的接口函数,由于隐藏了内部细节,内部函数可能实现更为复杂的功能,而导出的接口仅提供有限的功能。
提示:本文仅在测试环境下,提供学习目的使用的逆向细节和工具代码,使用未公开的接口和方法是微软不支持的行为,在实际发布软件中使用本文提供的测试代码需要开发者自行斟酌,或引起版权纠纷,本文作者概不负责。(P.S. 其实,我觉得我写的一定程度上还不如一般的模式匹配特征码,只是在学习过程中即兴想到的一种可行的定位方法)
依然是从刚刚的 Demo2 程序挖掘,从这里可以看出 a?的数值是放在 edi 上的,调用 printf 时被复制到 edx 寄存器上,而这段 mov 指令在不同的优化模式下会有什么变化呢?
O2/ O1 优化:mov edx, edi
无优化:mov edx, [rsp+38h+var_18]
我们能够观察到,对于相对固定的 printf 的第一个参数,不同编译模式下都是 lea rcx, aAD.
而这条指令的前一条指令都是 mov 指令,不管你是优先寄存器传参还是从堆栈传参。但是 mov 指令的字节长度可能不同。
此时,我们考虑到存在循环外的 printf 和循环内的 printf,我们可以结合控制流的特征对搜索范围进行限制。如下图所示,我们可以首先在 main 函数内部一定范围内搜索 jmp 指令,并模拟真实的跳转,去计算跳转后的地址,跳转后的地址在目标范围的上届或者下界,这样可以在搜索时候越过一些指令字节。
程序中,跳转地址常常被解释为分支,IDA 可以画出控制流程图:
其实,上面的搜索逻辑结合控制流程和指令的特征,这种搜索其实就是基于对控制流程的理解来完成的。下面一节将分析具体的 Windows?API 以上文解释的方法进行定位的具体实现。
Windows 的消息框由 MessageBox 、MessageBoxEx、MessageBoxTimeout、MessageBoxIndirect 等实现。
而这些接口最终都是对 MessageBoxWorker 函数的封装,这个函数是未导出函数。
本文以 MessageBoxIndirectW 为例,分析如何通过该导出函数获取?MessageBoxWorker 函数的地址。
在 Windows 10/11 上, MessageBoxIndirectW 调用流程如下:
由此可以看出,我们可以通过 jz short 跳转转到调用分支,然后依赖参数传递的特征,比如这里的 lea rcx 指令,对于这种传递结构体指针的函数,基本上这里的传参指令不会变化。
定位 CALL (0xE8) 的顺序查找伪代码如下:
for (nIndex_A = 0; nIndex_A < rSearchLen; nIndex_A++)
{
// Win 10, 11
if (insSeqList[nIndex_A] == 0x74 && insSeqList[nIndex_A + 2] == 0x65)// jz
{
dwTargetPos = SUSPICIOUS_INSTRUCTION_CONTEXT; // 设置错误码
// lea rcx, [rsp+0D8h+var_B8] ; struct _MSGBOXDATA *
// 首先 i + 2 表示 jz short 下一条指令,然后根据 ((BYTE*)Proc)[i + 1] 表示的偏移量计算跳转地址
// 随后越过寄存器存储结构体struct _MSGBOXDATA *的指令,指令长度为 5 字节,找到 E8 Call指令
int nBaseShift = (nIndex_A + 2) + insSeqList[nIndex_A + 1];// E8 @ptr; Call @ptr
for (int k = 0; k < 8; k++)
{
if (insSeqList[++nBaseShift] == 0xE8)
{
dwTargetPos = nBaseShift;
break;
}
}
}
// 如果找到或者发生错误,就跳出外循环
if (dwTargetPos ||
dwTargetPos == SUSPICIOUS_INSTRUCTION_CONTEXT) break;
}
当得到 CALL 指令的位置后,通过 CALL 指令后面 4 字节表示的偏移量(小端模式),用这个偏移量加上 CALL 指令的后一条指令的地址,就可以得到实际跳转地址。
完整的核算方式为:跳转地址 = 0xE8 的地址 + 5 + 跳转偏移量。
这种方法在适配?Win 8.1/8 时,需要依然额外的处理,但这种处理是可以接受的。
下面看一下 Win 8?的反汇编片段:
可以发现,不再是短跳了,而是长跳, jnz 属于条件跳转指令,最关键的是这个指令跳转到较远的地方,不过跳转地址是一个分支,他会跳回 loc_1800268F9 处的。
此时,我们可以换一种搜索方式,找到距离 jnz 最近的参数返回处,即?48 8B 8C 24 C0 00 00 00 ? ? ? mov ? ? rcx, [rsp+0D8h+var_18]。在他们之间搜索 CALL 指令。
为什么要这样做呢,有人会问,为什么不去定位 lea 指令,因为它的指令长度有不同版本,这会导致定位不准,需要准备多个判断条件,消耗时间,且容易出错,并且观察多个版本就可以知道 48 8B 在短片段内重复出现的频率很低,这提供了很大的容错性。
搜索逻辑如下:
for (nIndex_A = 0; nIndex_A < rSearchLen; nIndex_A++)
{
// Win 8/8.1, 没有 jz 跳转 只有 jnz 跳转,属于大跳且跳到目标函数内部,不适合于定位
// 采用Call指令的上一条寄存器指令来判断,经检索,在一定范围内该指令不会出现重复
if (insSeqList[nIndex_A] == 0x0F && insSeqList[nIndex_A + 1] == 0x85)
{
for (nIndex_B = nIndex_A; nIndex_B < rSearchLen; nIndex_B++)
{
if (insSeqList[nIndex_B] == 0xC3)// retn
{
nIndex_B = static_cast<DWORD>(rSearchLen);
break;
}
if (insSeqList[nIndex_B] == 0x48 &&
insSeqList[nIndex_B + 1] == 0x8B)
{
break;
}
}
// 防止越界
if (nIndex_B >= rSearchLen)
break;
for (nIndex_A; nIndex_A < nIndex_B; nIndex_A++)
{
dwTargetPos = SUSPICIOUS_INSTRUCTION_CONTEXT; // 设置错误码
if (insSeqList[nIndex_A] == 0xE8)
{
dwTargetPos = nIndex_A; // 找到 E8,跳出循环
break;
}
}
// 如果找到或者发生错误,就跳出外循环
if (dwTargetPos ||
dwTargetPos == SUSPICIOUS_INSTRUCTION_CONTEXT) break;
}
}
在 Win 7 和 XP,定位方法和 Win 10 相同,走的短跳路线。
测试运行效果如图:
(1)Win 8:
(2)Win 11:
3.3 完整代码
完整的测试代码我也提供一下。
需要设置附加程序清单:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
测试代码:
#include <iostream>
#include <Windows.h>
#include <TlHelp32.h>
enum CLOSE_BUTTON_FLAGS {
button_gray, // 关闭按钮灰显
button_enable_ID1, // 关闭按钮不灰显
button_enable_ID2 // 关闭按钮不灰显 MB_OK
};
typedef struct _MSGBOXDATA {
MSGBOXPARAMSW mbparams;
HWND hwndOwner; // *(HWND *)(a1 + 8)
WORD wLanguageId; // MessageBox显示语言
BYTE unknown1[2]; // 姑且不明,大概率只是对齐
PDWORD pdwButtonID; // 按钮ID数组的指针,按钮ID同MessageBox返回值。(ID_HELP是9)
LPCWSTR* pszButtonTextTable; // 按钮字符串数组的指针。
DWORD dwButtonSum; // 按钮的数量
DWORD dwButtonDef; // 默认按钮
CLOSE_BUTTON_FLAGS enCloseButtonFlag; // 关闭按钮状态,其实我觉得应该是bool类型才对
DWORD dwMilliseconds; // 窗口等待时间
BYTE unknown2[28]; // 仍然不明
}MSGBOXDATA, * LPMSGBOXDATA;
typedef int(_fastcall* MSGWORKERPROC)(LPMSGBOXDATA);
VOID CALLBACK MsgBoxCallback(LPHELPINFO lpHelpInfo);// 消息盒子帮助回调函数
typedef LONG MYSTATUS;
typedef MYSTATUS* LPMYSTATUS;
size_t WINAPI InstructionConvertToHexString(
_In_ char* pszData,
_In_ size_t inputSize,
_Inout_ char* Buffer,
_Inout_ size_t bufferSize
);
BOOL WINAPI ProcessModuleHandlerWorker(
HMODULE hModule
);
FARPROC WINAPI GetInternalProcAddress(
_In_ HMODULE hModule,
_In_ LPCSTR lpProcName,
_In_ DWORD dwSearchLen,
_Inout_ LPMYSTATUS dwResponse
);
/*
* 一些宏定义,为了便于标识错误
*/
constexpr auto SUSPICIOUS_INSTRUCTION_CONTEXT = 0xFFFFFFFF;
constexpr auto MODULE_NOT_LOADED = 0x8C << 0x18 | 0x1;
constexpr auto ENTRY_ADDRESS_ERROR = 0x8C << 0x18 | 0x2;
constexpr auto HEAP_ALLOC_FAILED = 0x8C << 0x18 | 0x3;
constexpr auto BINARY_SEARCH_ERROR = 0x8C << 0x18 | 0x4;
constexpr auto BUFFER_OVERFLOW_ERROR = 0x8C << 0x18 | 0x5;
int main()
{
MYSTATUS nStatus = 0;
HMODULE hUser32 = 0;
FARPROC pFunMessageBoxWorker = NULL;
CHAR EntryIntV[13] = { 0 };
CHAR hexEntryIntV[37] = { 0 };
MSGBOXDATA mbdata = { 0 };
MSGBOXPARAMSW mbparams = { 0 };
MSGWORKERPROC MessageBoxWorker = NULL;
// 加载目标函数所在模块
hUser32 = LoadLibraryW(L"user32.dll");
if (!hUser32)
{
fprintf(stderr, "加载 user32.dll 动态链接库失败。\n");
return -1;
}
// 检索 MessageBoxWorker 函数地址
pFunMessageBoxWorker =
GetInternalProcAddress(hUser32, "MessageBoxIndirectW", 1000, &nStatus);
if (!pFunMessageBoxWorker)
{
fprintf(stderr, "检索 MessageBoxWorker 函数地址失败。Error: %01X\n", nStatus);
return -1;
}
// 拷贝入口附近指令
if (memcpy_s(EntryIntV, 12,
pFunMessageBoxWorker, 12) != 0)
{
fprintf(stderr, "写入缓冲区失败。\n");
return -2;
}
if (!InstructionConvertToHexString(EntryIntV, 12, hexEntryIntV, 37))
{
fprintf(stderr, "写入缓冲区失败。\n");
return -3;
}
// 输出解析结果
printf("函数入口地址:0x%I64X\n", reinterpret_cast<uint64_t>(pFunMessageBoxWorker));
printf("函数入口指令:%s\n", hexEntryIntV);
// 转换为函数指针
MessageBoxWorker = (MSGWORKERPROC)(pFunMessageBoxWorker);
// 准备调用参数
memset(&mbparams, 0, sizeof(MSGBOXPARAMSW));
mbparams.cbSize = sizeof(MSGBOXPARAMSW);
mbparams.dwStyle = MB_OK | MB_HELP;
mbparams.lpszText = TEXT("Hello World!");
mbparams.lpszCaption = TEXT("Test");
// 设置自定义图标
mbparams.hInstance = GetModuleHandleW(0);
// mbparams.lpszIcon = TEXT("USERICON");
// 设置帮助回文ID和回调函数
mbparams.dwContextHelpId = 0x1;
mbparams.lpfnMsgBoxCallback = MsgBoxCallback;
// 设置显示语言
mbparams.dwLanguageId = MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US);
memset(&mbdata, 0, sizeof(MSGBOXDATA));
memcpy(&mbdata, &mbparams, sizeof(MSGBOXPARAMSW));
int msgResponse = MessageBoxWorker(&mbdata); // 调用目标函数
fprintf(stderr, "返回值:%d.\n", msgResponse);
system("pause");
return 0;
}
// 帮助回调函数
void CALLBACK MsgBoxCallback(LPHELPINFO lpHelpInfo) {
switch (lpHelpInfo->dwContextId) {
case 0x1:
MessageBox(NULL, TEXT("这是0x1的帮助"), TEXT("帮助"), MB_OK);
break;
default:
MessageBox(NULL, TEXT("这是默认帮助"), TEXT("帮助"), MB_OK);
break;
}
}
size_t WINAPI InstructionConvertToHexString(
_In_ char* pszData,
_In_ size_t inputSize,
_Inout_ char* Buffer,
_Inout_ size_t bufferSize
){
size_t needBufferSize = inputSize * 3;
// 检查缓冲区是否足够大
if (bufferSize < needBufferSize) {
// fprintf(stderr, "缓冲区大小不足以存储结果。\n");
bufferSize = inputSize * 3;
return 0;
}
// 遍历输入数组
for (size_t i = 0; i < inputSize; ++i) {
// 将每个字节的数字转换为十六进制,并存储到输出缓冲区
snprintf(Buffer + i * 3, 4, "%02X ", (unsigned char)pszData[i]);
}
// 在字符串末尾添加null终止符
Buffer[needBufferSize - 1] = '\0';
return needBufferSize;
}
BOOL WINAPI ProcessModuleHandlerWorker(HMODULE hModule)
{
HMODULE BaseAddress = NULL;
MODULEENTRY32W me32 = { 0 };
me32.dwSize = sizeof(MODULEENTRY32W);
// 获取指定进程全部模块的快照
HANDLE hModuleSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
if (INVALID_HANDLE_VALUE == hModuleSnap)
{
// 创建快照失败
return FALSE;
}
// 获取快照中第一条信息
BOOL bResponse = Module32FirstW(hModuleSnap, &me32);
while (bResponse)
{
// 模块加载基址
BaseAddress = reinterpret_cast<HMODULE>(me32.modBaseAddr);
if (hModule == BaseAddress) { // 如果地址匹配,则结束
// 关闭句柄
CloseHandle(hModuleSnap);
return TRUE;
}
// 获取快照中下一条信息
bResponse = Module32NextW(hModuleSnap, &me32);
}
// 关闭句柄
CloseHandle(hModuleSnap);
return FALSE;
}
FARPROC WINAPI GetInternalProcAddress(
_In_ HMODULE hModule,
_In_ LPCSTR lpProcName,
_In_ DWORD dwSearchLen,
_Inout_ LPMYSTATUS dwResponse
)
{
LARGE_INTEGER StartingTime = { 0 }, EndingTime = { 0 }, ElapsedMicroseconds = { 0 };
LARGE_INTEGER Frequency;
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&StartingTime);
// 判断模块句柄是否有效
if (!ProcessModuleHandlerWorker(hModule)
|| dwSearchLen > 0x7FFF)
{
// 左移 24 位,构造高位 Magic 值 0x8C,低位表示错误码/偏移量
// 低位一共 16 位,2 字节,可以容纳最大偏移量 32767(`10)
*dwResponse = MODULE_NOT_LOADED;
return NULL;
}
FARPROC lpExternalFunc = NULL;
PBYTE insSeqList = NULL;
size_t rSearchLen = 0;
int dwCalljump = 0;
DWORD dwTargetPos = 0;
DWORD nIndex_A = 0,
nIndex_B = 0;
int64_t ullTargetProc = 0;
lpExternalFunc = // 读取导出函数的地址
GetProcAddress(hModule, lpProcName);
if (lpExternalFunc == NULL)
{
*dwResponse = ENTRY_ADDRESS_ERROR;
return NULL;
}
/*
* 申请用于分析指令的内存
* 大小取决于函数窗口的大小
* */
if (dwSearchLen <= 0)
rSearchLen = 1000;
else
rSearchLen = static_cast<size_t>(dwSearchLen);
insSeqList = (BYTE*)malloc(rSearchLen + 1);
if (insSeqList == NULL)
{
*dwResponse = HEAP_ALLOC_FAILED;
return NULL;
}
memset(insSeqList, 0, rSearchLen);
// 从导出函数的首地址开始拷贝内存
if ( memcpy_s(insSeqList, rSearchLen,
lpExternalFunc, rSearchLen) != 0 )
{
*dwResponse = HEAP_ALLOC_FAILED;
return NULL;
}
// 开始循环搜索
for (nIndex_A = 0; nIndex_A < rSearchLen; nIndex_A++)
{
// Win 10, 11
if (insSeqList[nIndex_A] == 0x74 && insSeqList[nIndex_A + 2] == 0x65)// jz
{
dwTargetPos = SUSPICIOUS_INSTRUCTION_CONTEXT; // 设置错误码
// lea rcx, [rsp+0D8h+var_B8] ; struct _MSGBOXDATA *
// 首先 i + 2 表示 jz short 下一条指令,然后根据 ((BYTE*)Proc)[i + 1] 表示的偏移量计算跳转地址
// 随后越过寄存器存储结构体struct _MSGBOXDATA *的指令,指令长度为 5 字节,找到 E8 Call指令
int nBaseShift = (nIndex_A + 2) + insSeqList[nIndex_A + 1];// E8 @ptr; Call @ptr
for (int k = 0; k < 8; k++)
{
if (insSeqList[++nBaseShift] == 0xE8)
{
dwTargetPos = nBaseShift;
break;
}
}
}
// 如果找到或者发生错误,就跳出外循环
if (dwTargetPos ||
dwTargetPos == SUSPICIOUS_INSTRUCTION_CONTEXT) break;
// Win 8, 没有 jz跳转 只有jnz跳转,属于大跳且跳到目标函数内部,不适合于定位
// 采用Call指令的上一条寄存器指令来判断,经检索,在一定范围内该指令不会出现重复
if (insSeqList[nIndex_A] == 0x0F && insSeqList[nIndex_A + 1] == 0x85)
{
for (nIndex_B = nIndex_A; nIndex_B < rSearchLen; nIndex_B++)
{
if (insSeqList[nIndex_B] == 0xC3)// retn
{
nIndex_B = static_cast<DWORD>(rSearchLen);
break;
}
if (insSeqList[nIndex_B] == 0x48 &&
insSeqList[nIndex_B + 1] == 0x8B)
{
break;
}
}
// 防止越界
if (nIndex_B >= rSearchLen)
break;
for (nIndex_A; nIndex_A < nIndex_B; nIndex_A++)
{
dwTargetPos = SUSPICIOUS_INSTRUCTION_CONTEXT; // 设置错误码
if (insSeqList[nIndex_A] == 0xE8)
{
dwTargetPos = nIndex_A; // 找到 E8,跳出循环
break;
}
}
// 如果找到或者发生错误,就跳出外循环
if (dwTargetPos ||
dwTargetPos == SUSPICIOUS_INSTRUCTION_CONTEXT) break;
}
}
free(insSeqList);
insSeqList = NULL;
if (!dwTargetPos || dwTargetPos == SUSPICIOUS_INSTRUCTION_CONTEXT)
{
*dwResponse = BINARY_SEARCH_ERROR;
return NULL;
}
// 找到 E8 后,解析跳转地址
ullTargetProc =
reinterpret_cast<int64_t>(lpExternalFunc) + dwTargetPos + 1;
// 拷贝 Call 的偏移量(4 字节)
memcpy(&dwCalljump, reinterpret_cast<FARPROC>(ullTargetProc), 4);
// 计算真实地址
ullTargetProc =
reinterpret_cast<int64_t>(lpExternalFunc) + dwTargetPos + 5 + dwCalljump;
// 拷贝 函数入口点上方的字节(4 字节)
if (memcpy_s(&dwCalljump, 4,
reinterpret_cast<FARPROC>(ullTargetProc - 4), 4) != 0)
{
*dwResponse = BUFFER_OVERFLOW_ERROR;
return NULL;
}
// 检查入口点上方 HotPatch 特征
if (!!(dwCalljump ^ 0x90909090) &&
!!(dwCalljump ^ 0xcccccccc))
{
*dwResponse = BINARY_SEARCH_ERROR;
return NULL;
}
*dwResponse = dwTargetPos;
QueryPerformanceCounter(&EndingTime);
ElapsedMicroseconds.QuadPart = EndingTime.QuadPart - StartingTime.QuadPart;
//
// We now have the elapsed number of ticks, along with the
// number of ticks-per-second. We use these values
// to convert to the number of elapsed microseconds.
// To guard against loss-of-precision, we convert
// to microseconds *before* dividing by ticks-per-second.
//
ElapsedMicroseconds.QuadPart *= 1000000;
ElapsedMicroseconds.QuadPart /= Frequency.QuadPart;
printf("SearchTimeCost: %zd ms\n", ElapsedMicroseconds.QuadPart);
return reinterpret_cast<FARPROC>(ullTargetProc);
}
?
使用上文提出的方法,同样可以定位 LoadLibraryExW 函数的内部调用函数 BasepLoadLibraryAsDataFileInternal。最终效果如下面多张图片所示:
(1)独占方式打开文件(测试效果):
(2)加载DLL 的Main函数效果:
(3)ProcExp 监视注入发生时的信息:
还是那句话,本文提出的方法只是作为一种思路参考,不一定比传统的特征码匹配效果好。更多分析见后期补充。如有问题,也欢迎在评论区讨论。
更新于:2024.01.22