通过UDRL看前置式反射DLL注入
字数 7669
更新时间 2026-10-25 15:13:42

通过UDRL看前置式反射DLL注入

一句话结论:UDRL(User Defined Reflective Loader)不是新技术,而是反射 DLL 注入(RDI)的演进版。它解决的不是「怎么把 shellcode 送进去」,而是「进去以后 beacon.dll 怎么被加载」——用自定义加载器替换 Beacon 内置的默认 Reflective Loader,改掉内存里的 PE 映射痕迹,从而绕开安全厂商对标准 RDI 的特征捕获。本文以 CS 泄露套件里的 UDRL 为例,用 VS 调试把前置式反射加载的十个阶段逐一跑通。

排版说明:为规避排版转义问题,文中代码里的字符串统一用单引号书写,正文引用一律使用「」。

一、UDRL 介绍

  • UDRL 并不是一种新技术,而是反射 DLL 注入(RDI)的演进版本;
  • 随着安全厂商对标准 RDI 的特征(如固定的内存分配大小、特定的 API 调用顺序、内存中的 MZ/PE 头残留)的精准捕获,RDI 在一些场景下已经无法实现免杀;
  • UDRL 允许攻击者编写或定制自己的反射加载逻辑,自定义核心植入物(beacon.dll)在内存中的展开和装载过程。本质是由用户自己实现 PE 文件的内存加载流程,从而替代默认的 RDI 加载器。

Loader 和 UDRL 的区别

Loader 的作用是负责将 shellcode 送入目标执行环境,并完成执行前的准备工作(读取/解密 shellcode、申请内存、写入数据、创建线程等)。它解决的是「如何把东西送进去并启动」的问题,本质属于投递和执行入口控制层。以 Cobalt Strike 为例,Loader 可以将 .bin(shellcode)加载到内存,但它通常并不负责完整解析 Beacon PE 的结构,后续仍然依赖 Beacon 自带的 Reflective Loader 完成 beacon.dll 的 PE 映射。

UDRL 实现的不是把 shellcode 送入内存,而是替换 Beacon 内部默认的 Reflective Loader,控制 PE(beacon.dll)在内存中的加载过程(自定义内存分配、Section 映射、重定位处理、Import 解析、内存权限设置等)。

日常对 .bin 做加密、混淆、压缩,本质上只是改变了 shellcode 的存储和传输形式,并没有改变 Beacon 最终的加载路径——解密后的 .bin 仍然会进入原本的反射 DLL 注入流程。而 UDRL 从「外部包装 shellcode」转变为「改变 Beacon 自身如何被加载」:

// 传统:外部Loader → 解密 beacon.bin → 默认 Reflective Loader → 加载 Beacon.dll
// UDRL:外部Loader → beacon.bin → 自定义 Reflective Loader → 加载 Beacon.dll

核心区别:普通 Loader + shellcode 加密改变的是「怎么送进去」;UDRL 改变的是「进去以后怎么加载」。

二、UDRL 基本使用

  1. 用 VS 打开解决方案(arsenal-kit 的 kits/udrl-vs/udrl-vs.sln);
  2. 将解决方案配置设置成 Release;
  3. 设置解决方案属性,禁用 library 和 obfuscation-loader 的优化;
  4. 右键 obfuscation-loader 编译生成;
  5. 编译成功后,在 CS 中加载插件(bin/obfuscation-loader/prepend-udrl.cna);
  6. 加载完插件后生成原生 shellcode。

三、反射与前臵

3.1 反射 DLL 注入

以 CS 为例,核心植入物 beacon 实际上是一个 dll,必须被加载才能运行。Stephen Fewer 在 2008 年首次公开的 Reflective DLL Injection 提供了一种完全在内存中加载 DLL 的方式,其反射加载器大致实现了:

分配内存;将目标 DLL 复制到该内存中
解析目标 DLL 的 imports、加载所需 module、解析 function address
对 DLL 进行 rebase,即修正 relocations(重定位)
定位并调用入口

在原始实现中,用于将 DLL 加载到内存的代码会被编译进 DLL,并作为导出函数提供(检查 Beacon 导出函数时能找到 ReflectiveLoader())。以这种方式实现反射加载时,通常还会在 PE 文件开头(覆盖 DOS header)写入一个小型 shellcode stub,确保执行流能正确跳转到 ReflectiveLoader(),使其具备 position independent(位置无关)特性。

3.2 前置式反射 DLL 注入

2017 年,Shadow Brokers 泄露的 Double Pulsar User Mode Injector 分析文章展示了另一种反射加载方法:加载器并不编译进 DLL,而是被 prepended(前置)在 DLL 前面,使其能够反射加载任意 DLL。同年发布的 sRDI(Shellcode Reflective DLL Injection)也采用了类似方法,可将任意 PE 转化为 position independent。

两者的关键区别在于 ReflectiveLoader() 的位置:传统 RDI 把加载器放在 DLL 内部靠导出函数;前置式把加载器字节放在原始 DLL 文件字节之前。

四、前置式反射 DLL 注入(UDRL 代码调试)

本次分析使用 CS 泄露的 arsenal-kit 套件中的 UDRL 作为案例(为让效果更直观,用 AI 改过部分代码)。

4.1 整体形态

Release 前置载荷在内存中的形态大致如下(低地址在上):

低地址
┌──────────────────────────────┐
│ 前置反射加载器 UDRL          │ ← CPU 从这里开始执行
│ ReflectiveLoader + 辅助函数  │
├──────────────────────────────┤
│ 原始 DLL 文件                │ ← 仍然是磁盘文件布局
│ MZ / PE / .text / .data ...  │
└──────────────────────────────┘
高地址

关键地址(后续调试使用):

L = loaderStart            前置加载器入口地址
R = rawDllBaseAddress      后置原始 DLL 文件地址
D = loadedDllBaseAddress   手工映射产生的新映像基址
E = entryPoint             目标 DLL 入口地址

执行流程:

CPU 从 L 开始
  → 加载器取得自己的地址 L
  → 加载器找到后面的原始 DLL:R
  → 解析 R 处的 MZ、PE 头和节表
  → VirtualAlloc 分配 SizeOfImage 大小的新区域 D
  → 把 R 的磁盘文件布局转换为 D 的内存映像布局
  → 修复 D 中的 IAT
  → 按 D 与偏好 ImageBase 的差值修复基址重定位
  → 计算 E = D + AddressOfEntryPoint
  → 调用 E,进入测试 DLL 的 DllMain

何为前置式:加载器字节放在原始 DLL 文件字节之前,外层执行器从整段缓冲区第一个字节开始执行,所以加载器先运行;加载器知道自己的结束位置,可以直接取得紧随其后的 DLL 地址。

4.2 环前准备与入口设置

VS 顶部选 Debug-x64,右键 default-loader 设为启动项目并生成。

阶段一(进入前置加载器):在 default-loader/ReflectiveLoader.cpp 第 45 行下断点,F5 会停在 ReflectiveLoader 开头。之所以进入这里,是因为 loader.props 中有如下定义,让链接器使用类似 /ENTRY:ReflectiveLoader 的设置:

<EntryPointSymbol>ReflectiveLoader</EntryPointSymbol>

正常 C/C++ 编译的 EXE 一般先进入 CRT 启动代码再由 CRT 调用 main;但本项目把 PE 的 AddressOfEntryPoint 改成了 ReflectiveLoader,所以 Windows 完成 EXE 基础映射与初始化后,第一个进入的项目代码就是它,不会先调用 main(准确说,ReflectiveLoader 是第一个执行的本项目入口代码,而不是整个进程生命周期的第一条 CPU 指令)。

监视表达式验证:loaderStart == (void*)&ReflectiveLoader,但由于 Debug 构建启用了增量链接,链接器在真正函数体前生成了一个跳转入口(E9 rel32),loaderStart 指向的是入口跳板,VS 根据 PDB 显示的 &ReflectiveLoader 指向真正函数体。读取 loaderStart 处的跳转指令、取出相对位移,计算得 loaderStart + 5 + 0xBC 正好等于 &ReflectiveLoader,说明控制流先到达加载器入口跳板、再立即跳入真正函数体。

4.3 阶段二:找到加载器后面的原始 DLL

library/Utils.cpp 第 46 行(FindBufferBaseAddress)用条件编译区分 Debug 与 Release:

#if _DEBUG
    return (ULONG_PTR)debug_dll;
#elif _WIN64
    return (ULONG_PTR)&LdrEnd + 1;

两个分支解决的是同一个问题:得到原始 DLL 起始地址 R。Debug 中的 debug_dll 是什么:构建测试 DLL 后,项目运行 udrl.py xxd,把整个 lab-test.dll 文件逐字节转成 C++ 数组,debug_dll[] 保存的是完整 DLL 文件字节(而不是已加载好的内存映像)。检查前两个字节:Windows PE 文件都以 DOS 头开始,第一个字段 e_magic 签名字符是 MZ(4D 5A),监视 debug_dll[0] 与 debug_dll[1] 就是判断是不是 4D 5A 开头。本阶段证明:加载器取得了一个位于它后面的原始 DLL 地址 R,且 R 确实以合法 MZ 签名开始。

4.4 阶段三:解析原始 DLL 的 PE 头

将 rawDllBaseAddress 转为 PIMAGE_DOS_HEADER 获取 DOS 头,再通过 e_lfanew 找到 NT Header。监视 rawDllDosHeader->e_magic、e_lfanew、(void*)(rawDllBaseAddress + rawDllDosHeader->e_lfanew) 与 rawDllNtHeader,确认加载器按预期找到原始 DLL 地址、正确解析 DOS Header 和 NT Header,并验证 PE 签名、节数量、ImageBase、SizeOfImage、入口点 RVA 等关键字段。

4.5 阶段四:通过 PEB 动态解析 API

为什么要通过 PEB 解析:Release 编译产物只提取 EXE 的 .text 作为裸加载器,不携带原 EXE 的完整 PE 头、导入表和加载状态。裸 .text 如果直接按普通 EXE 方式调用(如 VirtualAlloc),编译器通常要通过原 EXE 的 IAT 取函数地址,但裸代码被复制到任意地址后,原预期的 IAT 就不存在了。所以加载器需先自行找到 LoadLibraryA、GetProcAddress、VirtualAlloc、NtFlushInstructionCache 等 API,保存在 WINDOWSAPIS winApi 结构里。

x64 Windows 当前线程通过 GS 段访问 TEB,GS:[0x60] 保存了当前进程的 PEB 地址:

// 获取当前进程的 PEB 地址:x64 下通过 GS:[0x60] 读取并转为 _PPEB 指针
return (_PPEB)__readgsqword(0x60);

GetProcAddressByHash:它不知道自己要找的是 kernel32.dll 或 LoadLibraryA,只知道两个数字哈希值,通过遍历模块和导出函数,将当前遇到的名字计算哈希,如果哈希相等就认为找到了目标 API。代码没有保存明文目标模块名和函数名,而是比较预先计算的哈希(这本身也是一种免杀:静态看不出加载了哪个 API)。遍历模块时从 ldrData->InMemoryOrderModuleList 取得已加载模块链表,匹配模块后解析导出目录 AddressOfNames、AddressOfNameOrdinals、AddressOfFunctions。

4.6 阶段五:为目标 DLL 分配新映像

ReflectiveLoader.cpp 第 124 行是 VirtualAlloc():

VirtualAlloc(
    NULL,                       // 让系统自己选可用地址
    0x7000,                     // 按 PE 的 SizeOfImage 分配完整映像空间
    MEM_RESERVE | MEM_COMMIT,   // 同时保留地址 + 分配物理内存
    PAGE_EXECUTE_READWRITE);    // 整块可读可写可执行

为什么需要重定位:如果 loadedDllBaseAddress == rawDllNtHeader->OptionalHeader.ImageBase 为 false,说明实际基址 D 不等于编译时偏好基址(0x180000000),DLL 中所有记录了偏好绝对地址的位置都必须按差值修正。

4.7 阶段六:复制 PE 头和各节,完成布局转换

加载器不是把整个 DLL 文件原样复制到 D,而是根据每个节的 PointerToRawData 读取文件内容、根据 VirtualAddress 放入内存映像位置。每个节的目标地址与源地址:

dstSection  = D + sectionHeader->VirtualAddress;    // 写入目标内存地址
srcSection  = R + sectionHeader->PointerToRawData;  // 原始文件中的字节偏移
sizeOfData  = sectionHeader->SizeOfRawData;         // memcpy 拷贝长度

六个节的映射实例(R 到 D):

.text: R+0x400  → D+0x1000
.rdata:R+0x600  → D+0x2000
.data: R+0xC00  → D+0x3000
.pdata:R+0xE00  → D+0x4000
.rsrc: R+0x1000 → D+0x5000
.reloc:R+0x1200 → D+0x6000

此时 D 已经具有 PE 预期的虚拟布局。

4.8 阶段七:修复目标 DLL 的导入地址表 IAT

IAT 可以把每个槽理解成一个待填写的函数指针格子:编译后的 DLL 不会把系统函数的最终地址固定写死(因为每个进程中系统 DLL 的实际基址可能不同),如果不修复,目标 DLL 执行 MessageBoxW 时会把一个原本的 RVA 当成函数地址,发生访问异常。

修复流程(ResolveImports):从可选头的 DataDirectory 找到导入目录项 → 根据 RVA + dstAddress 定位导入描述符表(IMAGE_IMPORT_DESCRIPTOR)→ 逐个描述符取模块名 RVA 计算真实地址(第一次循环得到 KERNEL32.dll)→ 用 winApi.LoadLibraryA 加载依赖模块取得基址 → 定位 INT(OriginalFirstThunk,dstAddress + 0x2470)与 IAT(FirstThunk,dstAddress + 0x2000)→ 用 winApi.GetProcAddress 取得真实函数地址(如 GetEnvironmentVariableW)→ 把地址写入对应 IAT 槽。

修复前:格子中保存导入名称结构的 RVA
修复后:格子中保存当前进程里的真实 API 地址

对 MessageBoxW,需切换到 USER32.dll 导入描述符(LoadLibraryA 一个 USER32.dll),对应 IAT 槽在 dstAddress + 0x2028。全部完成后,目标 DLL 已具备调用五个系统 API(GetEnvironmentVariableW、GetStdHandle、WriteFile、OutputDebugStringW、MessageBoxW)的能力。

4.9 阶段八:执行重定位

为什么节已复制完还必须重定位:测试 DLL 编译时希望加载到 ImageBase = 0x180000000,但 VirtualAlloc(NULL, ...) 实际分配得到的是 D,不等于偏好值。测试 DLL 中的 g_message 磁盘上保存的是编译时绝对地址(0x180000000 + kMessage RVA 0x2040),映射到 D 后必须变成 D + 0x2040,否则 MessageBoxW 会收到错误的字符串指针。

先算偏移量 delta = dstAddress - 首选 ImageBase;再取 IMAGE_DIRECTORY_ENTRY_BASERELOC 目录项(VirtualAddress + dstAddress 得到第一个 IMAGE_BASE_RELOCATION 块)。项目只处理 DIR64:

relocation->type == 10
// IMAGE_REL_BASED_DIR64,Windows SDK 固定定义为 10
// 表示目标位置保存的是一个 64 位绝对地址,需要把基址偏移量 delta 加上去

处理时先判断当前条目是否为 IMAGE_REL_BASED_DIR64(类型 10),条件成立后通过「当前页实际地址 + 页内偏移」找到需要修复的位置,读取旧地址,加上 delta,写回原位置(把旧的 0x180002040 修正为 D + 0x2040)。类型 0 的 IMAGE_REL_BASED_ABSOLUTE 只是对齐填充,直接跳过。本重定位块共 12 字节(0xC):8 字节的块头 + 2 字节的 DIR64 有效条目 + 2 字节的 ABSOLUTE 填充条目。

4.10 阶段九:计算目标 DLL 入口

用新映像基址 loadedDllBaseAddress 加上可选头中 AddressOfEntryPoint 的入口点 RVA(当前测试 DLL 为 0x1070),计算得 entryPoint = loadedDllBaseAddress + 0x1070。此时只是计算入口地址,还没调用。随后通过之前解析出的 NtFlushInstructionCache 刷新当前进程(伪句柄 -1)的 CPU 指令缓存,确保 CPU 从新映像入口执行时读到的最新机器指令。

4.11 阶段十:第一次执行 DllMain

ReflectiveLoader.cpp 第 161/162 行是两次入口调用:

// 161 行:第一次调用 DllMain(标准 Windows 初始化)
BOOL processAttachResult = ((DLLMAIN)entryPoint)((HINSTANCE)loadedDllBaseAddress, DLL_PROCESS_ATTACH, NULL);

// 162 行:第二次调用(自定义初始化/通知,reason 为 4)
BOOL privateInitResult = ((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);

在 E 地址下断点可截住控制流真正进入目标 DLL 的瞬间。观察寄存器:RCX 为第一个参数(D)、RDX 为第二个参数(1,即 DLL_PROCESS_ATTACH)、R8 为第三个保留参数(NULL,即 0)、RIP 为下一条要执行的指令地址。可见不仅跳到了正确入口,而且按 DllMain 约定传入了正确参数。

继续执行后,测试 DLL 会把 g_labLastReason 设为 1、输出调试信息、读取环境变量、通过已修复的 IAT 调用 MessageBoxW 弹窗。弹窗是整条手工映射流程成功的综合结果,依赖于:入口代码已复制到 D、入口地址 E 计算正确、IAT 中的 MessageBoxW 地址已修复、内部指针 g_message 已完成重定位、CPU 正确进入了 E。

161 行第一次执行是标准的 Windows DLL 入口调用,目的是告诉 DLL「你已经被加载到这个进程了,现在执行你的初始化代码」;162 行第二次执行的不是 Windows 标准的 DllMain reason,而是 Loader 在完成标准初始化后,又利用同一个入口地址执行了一次自定义初始化/通知流程。

五、总结

阶段 加载器做了什么
一 确认控制流先进入前置加载器入口(E9 跳板 → ReflectiveLoader)
二 取得位于加载器后面的原始 DLL 地址 R(校验 MZ)
三 解析原始 DLL 的 DOS/NT 头,验证签名、节表、SizeOfImage
四 通过 PEB 链表 + 哈希动态解析 API(裸代码无 IAT)
五 VirtualAlloc 按 SizeOfImage 分配 RWX 新映像 D
六 按节把磁盘布局转成内存虚拟布局(PointerToRawData → VirtualAddress)
七 遍历导入描述符,LoadLibraryA + GetProcAddress 修 IAT
八 按 delta 处理 DIR64 重定位,修正绝对地址
九 计算 E = D + EntryPoint,刷新指令缓存
十 两次调用 DllMain(标准 ATTACH + 自定义 reason),弹窗验证成功

前置式反射 DLL 注入的核心价值在于:加载器与 DLL 不再是一体,加载器可自由定制,因而能改掉标准 RDI 在内存分配大小、API 调用顺序、PE 头残留上的固定特征。这正是 UDRL 能在 RDI 被特征捕获后仍然存活的原因。

参考:Cobalt Strike 官方博客 revisiting-the-udrl 系列;项目 arsenal-kit、sRDI、ReflectiveDLLInjection。本文仅用于安全研究与教学。

相似文章
相似文章
小程序二维码
 全屏