首页
社区
课程
招聘
[原创]为什么在ASLR机制下DLL文件在不同进程中加载的基址相同
发表于: 2023-11-18 23:40 12365

[原创]为什么在ASLR机制下DLL文件在不同进程中加载的基址相同

2023-11-18 23:40
12365

1.1 打开 Visual Studio 创建一个新的 DLL 项目。
1.2 在"dllmain.cpp" 添加以下的代码

1.3 生成 DLL 文件,得到一个名为 "InjectDll.dll" 的 Dll 文件。
1.4 运行以下代码,将 Dll 文件注入到记事本进程中

总结一下 Dll 注入步骤
● 定位目标进程:使用Windows API函数(如FindWindow)或其他技术来获取目标进程的句柄或进程ID;
● 打开目标进程:使用OpenProcess函数打开目标进程,获取进程的句柄,以便后续操作;
● 在目标进程中分配内存:使用VirtualAllocEx函数在目标进程中分配一块内存,用于存储DLL路径或其他数据;
● 将DLL路径写入目标进程:使用WriteProcessMemory函数将DLL路径或其他数据写入目标进程的内存空间;
● 获取函数地址:获取所需函数(例如LoadLibrary)在目标进程所加载的模块中的地址,通常使用GetModuleHandle和GetProcAddress函数;
● 在目标进程中创建远程线程:使用CreateRemoteThread函数在目标进程中创建一个远程线程,该线程执行加载DLL的函数,并将DLL路径作为参数传递;
● 等待远程线程退出:使用WaitForSingleObject函数等待远程线程退出,确保注入操作完成;
● 清理资源:关闭句柄、释放内存等,以确保不会产生资源泄漏。

DLL 注入之所以能够实现是因为获取的LoadLibrary()函数地址能够在目标进程中使用,其背后的原理是kernel32.dll在不同进程中的加载基址是相同的。

ASLR(Address Space Layout Randomization)是一种用于增加系统安全性的技术,它通过随机化内存地址的分配,使攻击者更难以利用已知的内存布局漏洞进行攻击。实际上ASLR的概念在Windows XP时代就已经提出来了,只不过XP上面的ASLR功能很有限,只是对PEB和TEB进行了简单的随机化处理,而对于模块的加载基址没有进行随机化处理,直到Windows Vista出现后,ASLR才真正开始发挥作用。

微软从Visual Studio 2005 SP1开始加入了/dynamicbase链接选项使编译好的程序支持随机基址,只需要在编译程序的时候启用/dynmicbase链接选项。(Visual Studio 2022 可以在项目属性中设置:配置属性——链接器——高级——随机基址)

PE文件在它的PE头将 IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE设置为1,则说明该 PE文件支持ASLR,如下图所示。

ALSR 会随机化的地址包括:
● 堆地址
● 栈地址
● PE文件加载基址
● PEB和TEB地址
·
ALSR 机制能保证在每次系统启动后,系统DLL文件在进程中的加载基址不会是默认的地址0x10000000(EXE文件的默认加载基址是0x00400000),而是一个随机的地址。系统重启后这个加载基址会再次变化,ASLR的出现使得shellcode中的关键跳转只能在系统重启前,甚至只有程序的本次运行时才能执行,这使得exploit的难度大大增加。
·
在ALSR开启的状态下,DLL 注入依然能实现是因为DLL文件在不同进程中加载的基址虽然经过了随机化的处理,但系统DLL文件(如system32目录下的DLL)在各个进程中通常加载地址仍然是相同的,以保证不同进程能互相调用这些系统DLL提供的 API。

其中更深层次的原因是操作系统需要支持写时复制机制(copy-on-write)。写时复制是现代操作系统的一个重要特性。操作系统使用页表(Page Table)来将进程的虚拟地址映射到物理地址。页表是一种数据结构,它存储了虚拟地址和物理地址之间的映射关系。

A 进程和 B 进程共享同一个 DLL 时,它们的虚拟地址空间中的虚拟地址会指向相同的物理内存页。这意味着它们共享同一份物理内存。当 A 进程尝试对 DLL 内存页进行写操作时,操作系统会触发写时复制,将共享的物理内存页复制一份,创建一个新的物理页供 A 进程使用。A 进程会拥有自己的独立副本,而进程B仍然使用原始的物理内存页。
·
Copy-On-Write机制触发并不会影响虚拟地址空间的映射关系。因此,在Copy-On-Write机制中,虚拟地址空间中DLL的加载基址不会发生变化。A 进程仍然可以通过原始的加载基址访问和调用DLL中的代码和数据。
·
当多个进程加载同一个 DLL 文件并且它们的加载基址保持相同时,可以更好地利用 Copy-On-Write 机制。这样可以实现代码和只读数据的共享,延迟数据的复制,并提高内存利用率和性能。如果 DLL 加载地址不一致,Copy-On-Write 无法共享内存页,每个进程都需要单独复制 DLL 的只读内存,失去了内存优化的效果。
·
这里稍微延申一下,并不是多个进程中相同的虚拟地址都能映射到相同的物理地址。前面已经提到,每个进程的虚拟地址和物理地址的映射关系由页表记录,页表由操作系统内核维护。当操作系统决定切换到一个新的进程时,它会保存当前进程的上下文(包括寄存器和其他相关状态),然后加载新进程的上下文。
·
在上下文切换过程中,操作系统会切换页表,即将当前进程的页表替换为新进程的页表。也就是说,不同进程中相同的虚拟地址是通过不同的页表映射到物理地址的,所以虚拟地址相同,物理地址不一定相同。在某些特定情况下,不同进程中的相同虚拟地址可能会映射到相同的物理地址,例如不同进程共享内存。

在 PE 文件中有一个加载基址(Image Base)的字段,它指定了 DLL 文件在内存中的起始地址。操作系统在加载DLL文件时,首先会检查文件头中的标志,确定是否启用了ASLR或者是否存在重定位表。如果存在重定位表,操作系统会遍历重定位表中的每个条目。重定位表中的每个条目包含了两个关键信息:

操作系统使用以下公式计算PE 文件中需要进行重定位的虚拟地址:

在相同的操作系统和相同的加载条件下,相同的DLL文件在不同进程中的重定位计算是一致的。因此,无论 ASLR 是否启用,PE 文件的加载机制决定了 DLL 文件在各个进程中的加载基址是相同的。

综合这三个角度,虽然ASLR在操作系统的进程管理中引入了加载基址的随机化,但由于Copy-On-Write和PE文件加载机制的作用,同一DLL文件在不同进程中的加载基址仍然是相同的。这有助于确保不同进程中的DLL文件内部结构保持一致,同时确保进程间的数据隔离,提高系统的整体安全性和资源使用效率。

参考:
1.dll注入:系统kernel32.dll为什么在每个进程中的基址相同
2.《0 day 安全》
3. 《Windows 内核原理与实现》

// 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:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_THREAD_ATTACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_THREAD_DETACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_PROCESS_DETACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    }
    return TRUE;
}
// 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:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_THREAD_ATTACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_THREAD_DETACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    case DLL_PROCESS_DETACH:
        MessageBoxA(NULL, "您的进程已被注入", "注入警告", NULL);
        break;
    }
    return TRUE;
}
#include <Windows.h>
#include <stdio.h>
 
int main()
{
    // 获取目标进程的句柄
    HWND hWnd = FindWindow(NULL, L"无标题 - Notepad");
    if (hWnd == NULL) {
        printf("未找到目标进程\n");
        return 1;
    }
 
    DWORD processId;
    GetWindowThreadProcessId(hWnd, &processId);
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
    if (hProcess == NULL) {
        printf("无法打开目标进程\n");
        return 1;
    }
 
    // 在目标进程中分配内存
    LPVOID pRemoteBuffer = VirtualAllocEx(hProcess, NULL, MAX_PATH, MEM_COMMIT, PAGE_READWRITE);
    if (pRemoteBuffer == NULL) {
        printf("无法在目标进程中分配内存\n");
        return 1;
    }
 
    // 将DLL路径写入目标进程
    char dllPath[] = "E:\\Test\\InjectDll.dll";
    if (!WriteProcessMemory(hProcess, pRemoteBuffer, dllPath, sizeof(dllPath), NULL)) {
        printf("无法写入目标进程内存\n");
        return 1;
    }
 
    // 获取LoadLibrary函数的地址
    HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
    if (hKernel32 == NULL) {
        printf("未找到kernel32.dll\n");
        return 1;
    }
 
    FARPROC pLoadLibrary = GetProcAddress(hKernel32, "LoadLibraryA");
    if (pLoadLibrary == NULL) {
        printf("未找到LoadLibrary函数\n");
        return 1;
    }
 
    // 在目标进程中调用LoadLibrary函数加载DLL
    HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pLoadLibrary, pRemoteBuffer, 0, NULL);
    if (hThread == NULL) {
        printf("无法在目标进程中创建远程线程\n");
        return 1;
    }
 
    printf("DLL注入成功\n");
 
    // 等待远程线程退出
    WaitForSingleObject(hThread, INFINITE);
 
    // 清理资源
    CloseHandle(hThread);
    VirtualFreeEx(hProcess, pRemoteBuffer, 0, MEM_RELEASE);
    CloseHandle(hProcess);
 
    return 0;
}
#include <Windows.h>
#include <stdio.h>
 
int main()
{
    // 获取目标进程的句柄
    HWND hWnd = FindWindow(NULL, L"无标题 - Notepad");
    if (hWnd == NULL) {
        printf("未找到目标进程\n");
        return 1;
    }
 
    DWORD processId;
    GetWindowThreadProcessId(hWnd, &processId);
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
    if (hProcess == NULL) {
        printf("无法打开目标进程\n");

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2023-11-20 19:09 被ZyOrca编辑 ,原因:
收藏
免费 6
支持
分享
最新回复 (3)
雪    币: 9000
活跃值: (6215)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
2
我觉得这个机制就很鸡肋。修改注册表就能强制让ALSR 失效
2023-11-19 00:22
0
雪    币: 3004
活跃值: (30861)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢分享
2023-11-19 14:58
1
雪    币: 1787
活跃值: (2055)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4

感谢分享。楼主提出的Image ASLR在Windows中很有意义,这里借楼补充一下《Windows Internals》里的说明,正好最近也在做相关的事儿,若有不当还请各位指出。


系统启动时,MiInitializeRelocations根据TSC随机地初始化ImageBias系统全局变量作为ASLR的依据,再初始化ImageBitMap表示为系统Dll加载预留的内存范围。Ntdll.dll作为必定第一个加载的系统Dll在范围内随机加载,剩余Dll随后。若碰到范围底端,则回滚到顶部继续;若范围用完,则只能另找其它位置,重新来一遍基址重定位了。NT6开始,32位下范围是0x50000000~0x78000000,合计640M;64位使用类似的计算方法而ImageBias可随机的范围更广,可能的预留范围非常大


像微软Detours这样的Hook库就考虑了这点。如果先于系统Dll加载而在分配Trampoline时占用了系统预留区域,就会导致被占用的Dll加载时必需重来一次基址重定位加载到别处,而挂钩系统API并在附近寻找Trampoline可用区域时是有不小概率发生的,这一点比很多其它Hook库考虑得周全。遗憾的是Detours已经很久没被更新了,里面对系统预留的Dll范围仍停留在32位XP的0x70000000~0x80000000。最近我打算继续维护Detours时打算把这个问题给修复了,有意向一起维护它的小伙伴欢迎联系我,半成品SlimDetours,目前已实现只依赖Ntdll(依靠我整的另一个活Wintexports)。


以下是我看过的一些参考:

  • 《Windows Internals》第5至最新的第7版都有,但最新的写得最详细。《Memory management》章,《Virtual address space layouts》节,“User address space layout”-“Image randomization”部分,以及它后面提到的VMMap程序的观察结果

  • jdu2600Detours针对上述问题提交的PR #307

对于64位,逆了一下MiInitializeRelocations,看到为ImageBitMap分配的Buffer:

MiAllocatePool(256i64, 0x10000i64, 'iRmM');

保留范围则是0x7FFFFFFF0000~0x7FF800000000,合计32GB(0x10000 * 8 * 64K)。

最后于 2023-12-19 19:37 被Ratin编辑 ,原因:
2023-12-18 20:24
0
游客
登录 | 注册 方可回帖
返回
//