反调试 - 多种技术
一、介绍
反分析技术是防止安全分析师分析恶意软件、查找静态或动态签名和 IoC 的措施。因为这些信息用于在下一次在环境中发现它时检测样本,恶意软件分析师会通过分析可疑二进制文件收集数据。一般来说,恶意软件分析师总是会找到逆向工程恶意软件的方法,因此反分析技术的目标是让分析过程更加耗时。
二、实现
(1)使用 IsDebuggerPresent 检测调试器
最简单的反调试技术之一是使用 WinAPI IsDebuggerPresent。此函数如果检测到调试器附加到调用进程,则返回 TRUE;如果未检出调试器,则返回 FALSE。以下代码片段展示了检测调试器的函数。
if (IsDebuggerPresent()) {
printf("[i] IsDebuggerPresent 检测到调试器 \n");
// 运行无害代码。
}
(2)IsDebuggerPresent 替代方法
调用 IsDebuggerPresent WinAPI 可疑,即使它通过 API 哈希进行了良好的隐藏。该 WinAPI 被认为是检测调试程序的一种非常基础的方法,可以使用 ScyllaHide(https://github.com/x64dbg/ScyllaHide) 等工具来绕过它,这是一个适用于 xdbg 的反反调试插件。
更好的方法是创建 IsDebuggerPresent WinAPI 的自定义版本,PEB结构 BeingDebugged 成员,当进程正在被调试时将该成员设置为 1。一个简单的 IsDebuggerPresent WinAPI 替换涉及检查 BeingDebugged 值,如下面的自定义函数所示。
如果 BeingDebugged 元素被设置为 1返回 TRUE。
BOOL IsDebuggerPresent2() {
// 获取 PEB 结构
#ifdef _WIN64
PPEB pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
PPEB pPeb = (PEB*)(__readfsdword(0x30));
#endif
// 检查 'BeingDebugged' 元素
if (pPeb->BeingDebugged == 1) {
return TRUE;
}
return FALSE;
}
(3)替代 IsDebuggerPresent 方法(2)
另一种自定义 IsDebuggerPresent WinAPI 版本的方法是利用未记录的 NtGlobalFlag 标志,它也位于 PEB 结构中。如果进程正在被调试,则 NtGlobalFlag 成员被设为十六进制 0x70,否则为 0。需要注意的是,只有当进程由调试器创建时,NtGlobalFlag 元素才被设为 0x70。因此,如果在执行之后附加了调试器,此方法将无法检测到调试器。
#include <iostream>
#include <windows.h>
#include<winternl.h>
BOOL Debugger_Through_NtGlobalFlag() {
PPEB pPeb = (PPEB)(__readgsqword(0x60));
if ((PBYTE)pPeb + 0x0bc == (PBYTE)0x70) {
return TRUE;
}
return FALSE;
}
(4)通过硬件断点检测调试器
此方法仅在调试期间设置硬件断点时有效。硬件断点(也称为硬件调试寄存器)是现代微处理器的特性,它在触发特定内存地址或事件时暂停进程的执行。硬件断点在处理器中实现,因此比普通软件断点更快、更高效。普通软件断点依赖于操作系统或调试器定期检查程序执行。
设置硬件断点后,特定寄存器的值会发生改变。这些寄存器的值可用于确定是否将调试器附加到进程。如果寄存器 Dr0、Dr1、Dr2 和 Dr3 包含非零值,则设置了硬件断点。以下示例使用 xdbg 调试器对 NtAllocateVirtualMemory 系统调用放置了一个硬件断点。
Dr0 的值已从零更改为 NtAllocateVirtualMemory 的地址
代码实现如何获取Dr0寄存器呢,可以使用 GetThreadContext WinAPI,GetThreadContext 用于检索指定线程的上下文。上下文以 CONTEXT 结构返回。此结构还包括 Dr0、Dr1、Dr2 和 Dr3 寄存器值
HardwareBpCheck 函数通过检查上述寄存器值来检测调试器的存在。如果检测到调试器,该函数返回 TRUE。
BOOL HardwareBpCheck() {
// 定义 CONTEXT 结构体并设置 ContextFlags
CONTEXT Ctx;
Ctx.ContextFlags = CONTEXT_CONTROL;
// 获取当前线程的上下文信息
if (!GetThreadContext(GetCurrentThread(), &Ctx)) {
printf("\t[!] GetThreadContext 失败。错误代码:%d \n", GetLastError());
return FALSE;
}
// 检查调试寄存器,是否设置了硬件断点
if (Ctx.Dr0 != NULL || Ctx.Dr1 != NULL || Ctx.Dr2 != NULL || Ctx.Dr3 != NULL) {
return TRUE; // 检测到硬件断点
}
return FALSE; // 没有检测到硬件断点
}
(5)通过黑名单数组检测调试器
检测调试进程的另一种方法是检查当前运行进程的名称,并与已知的调试器名称列表进行对比。此名称的“黑名单”存储在硬编码的数组中。如果进程名称与黑名单匹配,则表明系统上正在运行一个调试器应用程序
对于此方案,将使用 CreateToolhelp32Snapshot 进程枚举技术。
黑名单数组表示如下:
#define BLACKLISTARRAY_SIZE 5 // 数组中元素的数量
WCHAR* g_BlackListedDebuggers[BLACKLISTARRAY_SIZE] = {
L"x64dbg.exe", // xdbg 调试器
L"ida.exe", // IDA 反汇编器
L"ida64.exe", // IDA 反汇编器
L"VsDebugConsole.exe", // Visual Studio 调试器
L"msvsmon.exe" // Visual Studio 调试器
};
黑名单数组应尽可能包含更多调试器名称,以便检测更多种类的调试器。此外,应使用字符串哈希处理字符串,因为二进制文件中的调试器名称可用作 IOC(入侵指标)
BlackListedProcessesCheck 函数使用 g_BlackListedDebuggers 数组作为黑名单进程数组。如果进程名称与 g_BlackListedDebuggers 的元素匹配,它将返回 TRUE:
BOOL BlackListedProcessesCheck() {
HANDLE hSnapShot = NULL;
PROCESSENTRY32W ProcEntry = { .dwSize = sizeof(PROCESSENTRY32W) };
BOOL bSTATE = FALSE;
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
if (hSnapShot == INVALID_HANDLE_VALUE) {
printf("\t[!] CreateToolhelp32Snapshot 失败,错误代码: %d \n", GetLastError());
goto _EndOfFunction;
}
if (!Process32FirstW(hSnapShot, &ProcEntry)) {
printf("\t[!] Process32FirstW 失败,错误代码: %d \n", GetLastError());
goto _EndOfFunction;
}
do {
// 遍历 `g_BlackListedDebuggers` 数组并将其每个元素与
// 从快照中获取的当前进程名称进行比较
for (int i = 0; i < BLACKLISTARRAY_SIZE; i++) {
if (wcscmp(ProcEntry.szExeFile, g_BlackListedDebuggers[i]) == 0) {
// 检测到调试器
wprintf(L"\t[i] 找到了 Pid 为 %d 的 \"%s\" \n", ProcEntry.szExeFile, ProcEntry.th32ProcessID);
bSTATE = TRUE;
break;
}
}
} while (Process32Next(hSnapShot, &ProcEntry));
_EndOfFunction:
if (hSnapShot != NULL)
CloseHandle(hSnapShot);
return bSTATE;
}
(6)通过 GetTickCount64 检测断点
断点用于在程序运行的特定点暂停执行,以供分析内存、寄存器状态、变量等
可以通过使用 GetTickCount64 WinAPI 检测执行暂停。此函数会检索自系统启动以来经过的毫秒数。通过分析处理器在两次 GetTickCount64 调用之间花费的时间,可以指示恶意软件是否正在调试。如果花费的时间超出了预期,则可以安全地假定恶意软件正在被调试。
可以通过计算 T1 - T0 的平均值并将其存储为硬编码值来检测断点。当 T1 - T0 的输出超过此值时,延迟可能是由于断点造成的。例如,如果 T1 - T0 主机上的输出为 20 秒,但在运行时输出大于该值,则 T1 - T0 之间的延迟很可能是由于断点引起的。应略微增加原始值以考虑可能会更慢的处理器。
BOOL TimeTickCheck1() {
DWORD dwTime1 = NULL,
DWORD dwTime2 = NULL;
dwTime1 = GetTickCount64();
/*
其它代码
*/
dwTime2 = GetTickCount64();
printf("\t[i] (dwTime2 - dwTime1) : %d \n", (dwTime2 - dwTime1));
if ((dwTime2 - dwTime1) > 50) {
return TRUE;
}
return FALSE;
}
(7)通过 DebugBreak 检测调试器
DebugBreak 会导致在当前进程中发生断点异常 EXCEPTION_BREAKPOINT。如果调试器已附加到当前进程,则该异常应由调试器处理。该技术的原理是触发异常并查看调试器是否尝试处理此异常。
我们将使用 __try 和 __except 代码块来处理来自 DebugBreak 调用的异常,并使用 GetExceptionCode 调用来获取在这种情况下生成的异常代码,这有两种可能的情况:
1、如果获取的异常为 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_EXECUTE_HANDLER,这表示该异常未被调试器处理。
2、如果异常不是 EXCEPTION_BREAKPOINT,则表示调试器处理了引发的异常(而不是我们的 try-except 代码块),然后执行 EXCEPTION_CONTINUE_SEARCH,这会强制调试器负责处理引发的异常。
BOOL DebugBreakCheck() {
__try {
DebugBreak();
}
__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
// 如果异常等于 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_EXECUTE_HANDLER 并且函数返回 FALSE
return FALSE;
}
// 如果异常不等于 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_CONTINUE_SEARCH 并且函数返回 TRUE
return TRUE;
}
通过 OutputDebugString 检测调试器
另一种可用于检测调试器的 WinAPI 是 OutputDebugString。此函数用于向调试器发送要显示的字符串。如果存在调试器,则 OutputDebugString 将成功执行其任务。
可以运行 OutputDebugString 并使用 GetLastError 检查其是否失败;如果失败,则 GetLastError 将返回一个非零错误代码。在这种情况下,非零错误代码等同于没有调试器正在运行。如果 GetLastError 返回零,则 OutputDebugString 成功向调试器发送了一条字符串。
OutputDebugStringCheck 函数使用上述逻辑,如果 OutputDebugStringW 成功,则返回 TRUE。此外,它使用 SetLastError 将最后一个错误值设置为 1。这仅仅是为了确保在调用 OutputDebugString 之前它是一个非零值,以减少误报
BOOL OutputDebugStringCheck() {
SetLastError(1);
OutputDebugStringW(L"MalDev Academy");
// 如果 GetLastError 为 0,则 OutputDebugStringW 成功了
if (GetLastError() == 0) {
return TRUE;
}
return FALSE;
}
(8)自我删除(Self-Deletion)
上面介绍了多种技术来阻止研究人员和恶意软件分析人员检查恶意软件,防止他们理解该恶意软件的功能或创建签名,本节使用Self-Deletion通过恶意软件自我删除来发挥作用。
NTFS 文件系统
在深入了解自删除之前,了解新技术文件系统 (NTFS) 的工作原理非常重要。NTFS 是一种专有文件系统,作为 Windows 操作系统的首要文件系统实施。它通过提供文件和文件夹权限、压缩、加密、硬链接、符号链接和事务操作等功能超越了其前身 FAT 和 exFAT。NTFS 还提供了增强的可靠性、性能和可扩展性。
NTFS 文件系统还支持 备用数据流。NTFS 文件系统中的文件除了默认流 :$DATA 之外,还可以拥有多个数据流。:$DATA 存在于每个文件中,提供了访问它们的备用方式
删除正在运行的二进制文件
在 Windows 系统中,无法删除当前正在运行的进程的二进制文件,因为正常的删除文件操作要求没有其他进程正在使用该文件。下图显示了在文件夹内打开了一个文件时,删除“Release”文件夹的失败尝试。
另一个示例使用 DeleteFile WinAPI,它用于删除现有文件。DeleteFile WinAPI 失败并返回 ERROR_ACCESS_DENIED 错误。
#include <iostream>
#include <windows.h>
int main(int argc,char* argv[]) {
printf("[*]Delete:%s \n",argv[0]);
if (!DeleteFileA(argv[0])) {
printf("[*]DeleteFileA Faild Error:%d \n",GetLastError());
}
getchar();
}
解决此问题的一种方法是将默认数据流 :$DATA 重命名为另一个随机名称,该名称表示一个新的数据流。之后,删除新重命名的 data 流将导致二进制文件从磁盘中删除,即使它仍在运行。
检索文件句柄
流程的第一步是检索目标文件的句柄,即本地实现的文件句柄。可以使用 CreateFile WinAPI 检索文件句柄。必须将 访问标志 设置为“DELETE”,以提供文件删除权限。
重命名数据流
删除正在运行的二进制文件的下一步是重命名 :$DATA 数据流。这可以通过使用带有 FileRenameInfo 标志的 WinAPI SetFileInformationByHandle 来实现。
BOOL SetFileInformationByHandle(
[in] HANDLE hFile, // 要更改信息的的文件的句柄。
[in] FILE_INFO_BY_HANDLE_CLASS FileInformationClass, // 指定要更改的信息类型的标志值
[in] LPVOID lpFileInformation, // 指向包含要更改的信息的缓冲区的指针
[in] DWORD dwBufferSize // 以字节为单位的 'lpFileInformation' 缓冲区的大小
);
FileInformationClass 参数应为 FILE_INFO_BY_HANDLE_CLASS 枚举值。当 FileInformationClass 参数设置为 FileRenameInfo 时,lpFileInformation 必须是指向 FILE_RENAME_INFO 结构的指针
FILE_RENAME_INFO 结构如下所示:
typedef struct _FILE_RENAME_INFO {
union {
BOOLEAN ReplaceIfExists;
DWORD Flags;
} DUMMYUNIONNAME;
BOOLEAN ReplaceIfExists;
HANDLE RootDirectory;
DWORD FileNameLength; // 'FileName' 的字节大小
WCHAR FileName[1]; // 新名称
} FILE_RENAME_INFO, *PFILE_RENAME_INFO;
需要设置的两个成员是 FileNameLength 和 FileName。Microsoft 的文档解释了如何定义新的 NTFS 文件流名称。
因此,FileName 应以冒号开头的宽字符字符串。
删除数据流
最后一步是删除 :$DATA 流,以从磁盘中擦除该文件。为此,将使用具有不同标志 FileDispositionInfo 的相同 SetFileInformationByHandle WinAPI。当关闭该流的句柄时,此标志会将文件标记为删除。这是 Microsoft 在 示例部分 中使用的标志。
当使用 FileDispositionInfo 标志时,lpFileInformation 必须是指向 FILE_DISPOSITION_INFO 结构的指针
FILE_DISPOSITION_INFO 结构如下所示。
typedef struct _FILE_DISPOSITION_INFO {
BOOLEAN DeleteFile; // 设置为 'TRUE' 以标记文件要删除
} FILE_DISPOSITION_INFO, *PFILE_DISPOSITION_INFO;
只需将 DeleteFile 成员设置为 TRUE 即可删除文件。
刷新文件数据流
在首次调用 SetFileInformationByHandle 为文件重命名 NTFS 文件流后,应关闭此文件句柄,并通过其他 CreateFile 调用重新打开它。执行此操作的目的是刷新文件数据流,以便新句柄包含新的数据流。
自删除最终代码
以下所示的 DeleteSelf 函数使用所述过程在运行时从磁盘中删除文件。
此函数用于检索包含指定模块的文件的路径。如果第一个参数设置为 NULL(如下面的代码片段所示),则它会检索当前进程的可执行文件路径。
#include <iostream>
#include <windows.h>
#define NEW_STREAM L":Maldev"
BOOL DeleteSelf() {
WCHAR szPath[MAX_PATH * 2] = { 0 };
FILE_DISPOSITION_INFO Delete = { 0 };
HANDLE hFile = INVALID_HANDLE_VALUE;
PFILE_RENAME_INFO pRename = NULL;
const wchar_t* NewStream = (const wchar_t*)NEW_STREAM;
SIZE_T StreamLength = wcslen(NewStream) * sizeof(wchar_t);
SIZE_T sRename = sizeof(FILE_RENAME_INFO) + StreamLength;
// 为 'FILE_RENAME_INFO' 结构分配足够的缓冲区
pRename = (PFILE_RENAME_INFO)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sRename);
if (!pRename) {
printf("[!] HeapAlloc Failed With Error : %d \n", GetLastError());
return FALSE;
}
// 清理一些结构
ZeroMemory(szPath, sizeof(szPath));
ZeroMemory(&Delete, sizeof(FILE_DISPOSITION_INFO));
//----------------------------------------------------------------------------------------
// 标记文件以进行删除(在第 2 个 SetFileInformationByHandle 调用中使用)
Delete.DeleteFile = TRUE;
// 在 'FILE_RENAME_INFO' 结构中设置新的数据流名称缓冲区和大小
pRename->FileNameLength = StreamLength;
RtlCopyMemory(pRename->FileName, NewStream, StreamLength);
//----------------------------------------------------------------------------------------
// 用于获取当前文件名
if (GetModuleFileNameW(NULL, szPath, MAX_PATH * 2) == 0) {
printf("[!] GetModuleFileNameW Failed With Error : %d \n", GetLastError());
return FALSE;
}
//----------------------------------------------------------------------------------------
// 重命名
// 打开当前文件的句柄
hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW [R] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[i] Renaming :$DATA to %s ...", NEW_STREAM);
// 重命名数据流
if (!SetFileInformationByHandle(hFile, FileRenameInfo, pRename, sRename)) {
printf("[!] SetFileInformationByHandle [R] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[+] DONE \n");
CloseHandle(hFile);
//----------------------------------------------------------------------------------------
// 删除
// 打开当前文件的新句柄
hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW [D] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[i] DELETING ...");
// 在关闭文件句柄后标记为删除
if (!SetFileInformationByHandle(hFile, FileDispositionInfo, &Delete, sizeof(Delete))) {
printf("[!] SetFileInformationByHandle [D] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[+] DONE \n");
CloseHandle(hFile);
//----------------------------------------------------------------------------------------
// 释放已分配的缓冲区
HeapFree(GetProcessHeap(), 0, pRename);
return TRUE;
}
int main(int argc,char* argv[]) {
DeleteSelf();
}
本文来自博客园,作者:aoaoaoao,转载请注明原文链接:https://www.cnblogs.com/websecyw/p/18708018
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构