重构SRDI
2026-07-18 18:48:00 # 武器化

项目地址:https://github.com/onedays12/Convert2Shellcode
文章首发:https://xz.aliyun.com/news/92534

时隔一年,我终于有动力开始重构Convert2Shellcode项目了。重构的原因主要有以下几点:

  1. 在编写Rust Beacon时,我突发奇想,将Rust Beacon的EXE转换为Shellcode后运行,结果居然报错了。这让我意识到当前项目在兼容性和稳定性方面仍有不少问题。
  2. 我想放弃在C Beacon内部导出ReflectiveLoader的设计,改为将ReflectiveLoader外置于Beacon DLL。这样一来,就可以在ReflectiveLoader中实现更多有趣且灵活的功能。
  3. 这个项目最初只是我学习SRDI的产物,早期编写时设计还比较稚嫩,并没有充分考虑未来的扩展性。例如,如何传递用户参数、如何支持x86,以及如何兼容更多类型的PE文件等问题,当时都还没有完善处理。

这篇文章将围绕Convert2Shellcode项目的完整重构方案展开,重点记录这次重构过程中遇到的问题、设计取舍以及对应的解决思路。主要内容包括:

  1. 运行上下文的构造与管理
  2. 用户参数传递机制的设计
  3. Rust 程序无法直接转换为 Shellcode 的问题分析:TLS Data
  4. DLL 指定导出函数的调用支持
  5. 旧 PE 数据的清理方案
  6. 映射后 PE 特征的抹除思路
  7. x86 EXE 转 Shellcode 的支持
  8. Convert2Shellcode与 Iris Beacon睡眠混淆冲突的解决思路

一 、运行上下文的构造与管理

1.1 RDI2 Header

在SRDI Loader中,我定义 RDI2 Header 的二进制布局。RDI2 Header的价值在于把shellcode生成阶段和SRDI loader运行阶段解耦。生成器只负责拼接并填写header,loader只需要拿到header指针,就可以解析raw PE、user data和export调用信息。

HDR_* 定义的是 RDI2_HEADER 的字段偏移。这个header由 Convert2Shellcode.c 在生成shellcode时写入,loader入口处通过RCX/ECX 拿到header指针后,再根据这些偏移解析raw PE、user data、flags和 export hash。

img

大概等价于C结构体:

1
2
3
4
5
6
7
8
9
10
11
typedef struct _RDI2_HEADER {
uint32_t Magic; // 0x00: "RDI2"
uint16_t Version; // 0x04
uint16_t Flags; // 0x06
uint32_t PeOffset; // 0x08
uint32_t PeSize; // 0x0C
uint32_t UserOffset; // 0x10
uint32_t UserSize; // 0x14
uint32_t ExportHash; // 0x18
uint32_t Reserved; // 0x1C
} RDI2_HEADER; // 0x20 bytes

各字段含义如下:

  • Magic:用于识别RDI2 payload。当前常量0x32494452按小端序存储后对应ASCII字符串 "RDI2"。若要替换Magic,生成器与四个SRDI Loader必须同步修改。
  • Version:Header协议版本。目前生成器写入版本 2,用于未来扩展和兼容性判断。
  • Flags:功能开关位。当前仅定义 bit 0:RDI_FLAG_EXPORT (0x0001)。当该位被设置且 ExportHash 非零时,请求执行指定DLL导出函数。
  • PeOffset:raw PE相对于RDI2 Header起始地址的偏移。当前生成器固定写入0x20,但使用偏移而非绝对地址,使front/post两种静态布局共享同一套loader逻辑。
  • PeSize:raw PE文件大小。loader将其作为源PE长度元数据,并在ClearSourcePe阶段清理原始PE数据。
  • UserOffset:用户数据相对于Header起始地址的偏移;没有用户数据时为0
  • UserSize:用户数据长度。导出模式下,loader将用户数据地址和长度传递给目标导出函数。
  • ExportHash:目标具名导出函数的ROR13 hash。Header中不保存导出函数名,loader在映射后的目标 PE 导出表中按该 hash 查找。
  • Reserved:保留字段。当前生成器写入0,供后续Header扩展使用。

1.2 CTX上下文

除了Header之外,SRDI Loader还需要在栈上定义CTX区域作为loader的临时状态中心。

v1版本时我以 [rbp+offset] 临时槽位保存状态的早期手工映射loader。这三个值贯穿整个loader的整个加载流程:映射PE、重定位、修复导入表、调整节权限、执行TLS Callback、调用入口点,全都围绕这三个栈槽展开。 这种设计在功能较少时还能够接受,但随着后续要支持用户参数传递、指定导出函数调用、raw PE清理、mapped PE特征抹除、Rust程序所需的Static TLS Data初始化、以及x86架构时,原有结构就会变得难以扩展。

1
2
3
[rbp+8] = 旧 DOS 头地址,也就是原始 PE 基址  
[rbp+16] = 新 DOS 头地址,也就是 VirtualAlloc 后的新映射基址
[rbp+24] = 新 NT 头地址

v2的上下文以 rbp 作为稳定锚点,在栈上开出 CTX_SIZE 大小的上下文区,用来保存解析后的header信息、源 PE 地址、映射后的image 地址、NT Header、重定位delta、用户参数、已解析的API地址以及TLS处理过程中需要的临时状态。它解决的是loader 执行过程中如何管理状态的问题。

img

对应的C结构体大致是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <stdint.h>
#include <stddef.h>

#pragma pack(push, 1)

typedef struct _RDI_RUNTIME_CONTEXT_X64_FRONT {
uint8_t Reserved000[0x08];

uint64_t Header; // 0x008, RDI2_HEADER 指针
uint64_t SrcBase; // 0x010, shellcode 内 raw PE 地址
uint64_t DstBase; // 0x018, 映射后 PE 基址
uint64_t SrcNt; // 0x020, raw PE NT Header
uint64_t DstNt; // 0x028, mapped image NT Header
uint64_t Delta; // 0x030, DstBase - ImageBase

uint64_t UserPtr; // 0x038, 用户数据地址
uint64_t UserLen; // 0x040, 用户数据长度
uint64_t Flags; // 0x048, RDI2 flags
uint32_t ExportHash; // 0x050, 目标导出函数 ROR13 hash
uint32_t Reserved054; // 0x054, 对齐/保留

uint32_t OldProtect; // 0x058, VirtualProtect 旧权限
uint32_t Reserved05C; // 0x05C, 对齐/保留

uint64_t Tmp; // 0x060, 通用临时值/导出函数地址

uint64_t ApiVirtualAlloc; // 0x068, VirtualAlloc
uint64_t ApiVirtualProtect; // 0x070, VirtualProtect
uint64_t ApiLoadLibraryA; // 0x078, LoadLibraryA
uint64_t ApiGetProcAddress; // 0x080, GetProcAddress
uint64_t ApiRtlAddFunctionTable; // 0x088, RtlAddFunctionTable
uint64_t ApiNtFlushInstructionCache; // 0x090, NtFlushInstructionCache

uint64_t Entry; // 0x098, header scrub 前保存的 OEP/DllMain
uint8_t Reserved0A0[0x08];

uint64_t NtdllBase; // 0x0A8, ntdll.dll 基址
uint64_t ApiRtlGetVersion; // 0x0B0, RtlGetVersion
uint64_t WinBuild; // 0x0B8, Windows build number
uint8_t Reserved0C0[0x08];

uint64_t LdrpTls; // 0x0C8, LdrpHandleTlsData 地址
uint8_t PatternLen; // 0x0D0, x64 TLS 特征码长度
uint8_t Reserved0D1[0x0F];

uint8_t PatternBuf[0x10]; // 0x0E0, x64 TLS 特征码
uint8_t Reserved0F0[0x10];

uint8_t VersionBuf[0x130]; // 0x100, OSVERSIONINFOEXW 临时空间
uint8_t FakeLdr[0x120]; // 0x230, fake LDR_DATA_TABLE_ENTRY
uint8_t Reserved350[0x0B0]; // 0x350 ~ 0x3FF

} RDI_RUNTIME_CONTEXT_X64_FRONT;
#pragma pack(pop)

简单来说,HDR_* 是payload协议,负责描述输入是什么CTX_* 是运行时上下文,负责保存加载过程中发生了什么。前者让生成器和loader解耦,后者让loader的内部状态管理更加清晰,这正是v2相比v1更容易扩展的核心原因。

二、用户参数传递机制和调用DLL指定导出函数的设计

在v1版本中,loader的目标只是尽可能把一个PE映射起来并跳转到入口点,因此并没有设计额外的用户参数传递机制。无论是EXE还是DLL,最终都只会走默认入口即PE文件头定义的AddressOfEntryPoint:EXE调用OEP,DLL调用DllMain

但是在v2版本中,我希望Convert2Shellcode 不只是把PE转成shellcode,还能让调用者向被加载的DLL传入一段自定义参数。因此,v2在RDI2 Header 中增加了UserOffsetUserSize两个字段,用来描述用户参数在shellcode中的位置和长度。

2.1 参数的编码方式

--user-data表示从文件中读取一段原始字节数据,适合传递较大的配置文件或二进制结构。

--user-data-hex表示用户直接在命令行中传入十六进制字符串,工具会将其转换成真实的 byte buffer。例如:

1
--user-data-hex 01020304

最终会被转换成:

1
01 02 03 04

无论用户选择哪种输入方式,进入shellcode拼接阶段后,它们都会被统一看作一段原始字节数组:

1
2
uint8_t* user;
DWORD user_size;

也就是说,loader并不关心这段数据原本来自文件,还是来自命令行十六进制字符串。对loader来说,它只是一段连续的字节buffer。

这样设计的好处是简单、通用。参数内容可以是字符串、JSON、TLV、二进制结构体,甚至是加密后的配置块。Convert2Shellcode 本身不强行规定参数格式,只负责把这段数据附加到最终shellcode中,由PE文件自行消费。

2.2 参数在 shellcode 中的位置

在v2的布局中,用户参数被放在raw PE后面:

1
[RDI2 Header][raw PE][user data]

对应到RDI2_HEADER,主要由两个字段描述:

1
2
uint32_t UserOffset;
uint32_t UserSize;

其中,UserOffset表示用户数据相对于RDI2 Header起始地址的偏移,UserSize表示用户数据长度。

如果没有用户数据:

1
2
UserOffset = 0;
UserSize = 0;

如果存在用户数据:

1
2
UserOffset = sizeof(RDI2_HEADER) + PeSize;
UserSize = user_data_size;

loader 运行时只需要根据 header 指针计算:

1
2
user_ptr = header + UserOffset;
user_len = UserSize;

需要注意的是,UserOffset 是相对于 RDI2 Header 的偏移,而不是相对于shellcode起始地址的偏移。这样做是为了统一 front/post 两种布局。无论RDI code在raw PE前面还是后面,只要bootstrap最终把 RCX/ECX 指向 RDI2 Header,loader就可以通过相同的方式找到用户数据。

2.3 指定导出函数的触发与调用流程

有了用户参数之后,还需要解决另一个问题:这段参数最终应该交给谁来消费。

在默认加载路径下,EXE会调用OEP,DLL会调用DllMain。但这两个入口都不太适合作为通用的参数入口。EXE的OEP本身没有统一的参数约定,而DllMain的函数签名又是固定的:

1
2
3
4
5
BOOL WINAPI DllMain(
HINSTANCE hinstDLL,
DWORD fdwReason,
LPVOID lpReserved
);

虽然理论上可以把用户参数塞进lpReserved,但这样会破坏DllMain原本的语义,也不利于后续扩展。因此在v2中,我选择增加一种更明确的调用模式:指定DLL导出函数作为用户入口

在生成shellcode 时,用户可以通过两种方式指定目标导出函数:

1
2
--export-name <name>
--export-hash <value>

--export-name表示直接传入导出函数名,由Convert2Shellcode 在生成阶段计算对应的 ROR13 hash。

--export-hash则允许用户直接传入已经计算好的hash。最终,无论使用哪种方式,都会写入RDI2_HEADER.ExportHash 字段中。

同时,只要ExportHash不为 0,生成器就会在header的Flags字段中设置RDI_FLAG_EXPORT。也就是说,是否进入指定导出函数调用模式,并不是由UserOffset/UserSize决定的,而是由FlagsExportHash共同决定的。用户参数只负责提供数据,RDI_FLAG_EXPORT才负责告诉loader,这次不要只走默认入口,而是调用指定导出函数。

loader进入 CallImageEntry 后,会先检查 Flags 中是否存在 RDI_FLAG_EXPORT。如果没有这个标志,就直接走默认入口分发逻辑;如果存在,则继续读取 CTX_EXPORT_HASH,并通过 ResolveExportByHash 在映射后的DLL导出表中查找目标函数。

img

指定导出函数解析成功后,loader会将映射后的PE基址、用户参数指针和用户参数长度传给目标函数。x64下对应的调用约定如下:

1
2
3
4
5
typedef void (*RDI_EXPORT_ENTRY)(
void* image_base,
void* user_data,
uint32_t user_size
);

也就是:

1
2
3
RCX = mapped image base
RDX = user data pointer
R8D = user data length

这样一来,DLL就可以导出一个专门的业务入口,例如:

1
2
3
4
5
extern "C" __declspec(dllexport)
void Start(void* image_base, void* user_data, uint32_t user_size)
{
// user_data 可以是字符串、JSON、TLV、二进制配置块等
}

这种设计比只依赖DllMain 更灵活。DllMain 可以保持轻量,只完成模块级初始化;需要参数的初始化、配置解析和启动等业务逻辑,则放在指定导出函数中执行。

当前实现中,指定导出函数不会替代DllMain。加载器完成TLS data初始化和TLS callback调用后,会先解析目标导出函数地址;随后清除mapped image header,调用DllMain(DLL_PROCESS_ATTACH),最后再调用指定导出函数。因此,导出函数可以依赖DllMain已初始化的全局状态。

导出函数通过名称的ROR13 hash查找,适用于按名称导出的函数;名称大小写会影响hash结果。是否进入 export 模式由Flags 中的 RDI_FLAG_EXPORT决定;生成payload时,ExportHash = 0通常表示没有指定目标导出函数,且不应设置该标志。

2.4 一个简单的传递用户参数调用导出函数的例子

为了验证v2架构下的参数传递与指定导出函数调用机制,下文将展示支持多参数切分的测试DLL,我们进行如下的规定:DLL内部管道符 | 作为边界符,通过 Convert2Shellcode.exe 工具将多个参数通过十六进制方式压缩并注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <windows.h>
#include <stdint.h>

// 辅助函数:在内存中就地切分参数(不依赖复杂 CRT,保持 Payload 轻量)
void SplitParameters(char* buffer, uint32_t size, char** arg1, char** arg2) {
*arg1 = buffer; // 参数 1 指向缓冲区开头
*arg2 = nullptr;

for (uint32_t i = 0; i < size; i++) {
if (buffer[i] == '|') {
buffer[i] = '\0'; // 将管道符替换为 C 风格字符串截断符
if (i + 1 < size) {
*arg2 = &buffer[i + 1]; // 参数 2 指向分隔符后的下一个字节
}
break;
}
}
// 兜底防御:若未传入分隔符,则参数 2 保持与参数 1 相同
if (*arg2 == nullptr) {
*arg2 = buffer;
}
}

// 核心业务导出函数
// x86 模式下必须显式指定 __cdecl 调用约定
#ifdef _M_IX86
extern "C" __declspec(dllexport) void __cdecl TestMessageBox(void* image_base, void* user_data, uint32_t user_size)
#else
extern "C" __declspec(dllexport) void TestMessageBox(void* image_base, void* user_data, uint32_t user_size)
#endif
{
if (user_data == nullptr || user_size == 0) {
MessageBoxA(NULL, "未检测到用户参数!", "SRDI v2 错误", MB_OK | MB_ICONWARNING);
return;
}

// 在堆上申请空间拷贝用户数据,避免直接在 Shellcode 只读/清零区域进行内存写操作
char* local_buf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, user_size + 1);
if (!local_buf) return;

CopyMemory(local_buf, user_data, user_size);
local_buf[user_size] = '\0';

char* title = nullptr;
char* content = nullptr;

// 解析并切分多参数
SplitParameters(local_buf, user_size, &title, &content);

// 格式化弹出框标题,顺便直观打印出映射基址
char final_title[256];
wsprintfA(final_title, "%s [ImageBase: 0x%p]", title, image_base);

// 执行弹窗
MessageBoxA(NULL, content, final_title, MB_OK | MB_ICONINFORMATION);

// 释放临时堆
HeapFree(GetProcessHeap(), 0, local_buf);
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved) {
return TRUE;
}

使用 Convert2Shellcode.exe 生成工具,将 messagebox_export.x64.dll 转成shellcode。

1
.\Convert2Shellcode.exe --arch x64 --type front --input example\bin\messagebox_export.x64.dll --output msgbox_x64_front.bin --export-name TestMessageBox --user-data-hex 546573745469746c657c48656c6c6f2046726f6d2053524449207632

其中 --user-data-hex

1
2
3
4
5
// hex 字符串
546573745469746c657c48656c6c6f2046726f6d2053524449207632

//十六进制数据解码后的完整字符串为
TestTitle|Hello From SRDI v2
1
2
3
4
5
6
7
8
PS C:\Users\Administrator\Desktop\test> .\Convert2Shellcode.exe --arch x64 --type front --input example\bin\messagebox_export.x64.dll --output msgbox_x64_front.bin --export-name TestMessageBox --user-data-hex 546573745469746c657c48656c6c6f2046726f6d2053524449207632
[+] arch: x64
[+] type: front
[+] input PE: 103424 bytes
[+] RDI text: 3373 bytes
[+] user data: 28 bytes
[+] output: msgbox_x64_front.bin (106877 bytes)
PS C:\Users\Administrator\Desktop\test>

随便编写一个shellcode loader用于加载刚刚生成的 msgbox_x64_front.bin

img

不使用用户参数

1
.\Convert2Shellcode.exe --arch x64 --type front --input example\bin\messagebox_export.x64.dll --output msgbox_x64_front.bin --export-name TestMessageBox

乱码是字符集的问题,这无关紧要,正确应该输出:“未检测到用户参数!”

img

三、PE数据生命周期管理与映射后痕迹处理

3.1 清除旧PE数据

在v1版本中,我更多关注的是如何把PE成功映射起来并跳转到入口点,因此对加载完成后的数据残留问题考虑得并不多。到了v2版本,我实现了PE数据生命周期管理与映射后痕迹处理,在shellcode中同时会存在两份和PE相关的数据:一份是最初随shellcode一起携带的raw PE,另一份是经过VirtualAlloc映射后的PE image。

这两份数据的生命周期并不相同。raw PE只是loader的输入数据,作用是提供headers、sections、import table、relocation table、TLS directory等原始信息。一旦MapImage完成,headers和sections已经被复制到新的image内存中,raw PE本身就不再参与后续执行。因此,在映射成功之后,可以通过 ClearSourcePe 将shellcode内部携带的原始PE数据清零。

这样做的意义主要有两个。

  • 第一,减少shellcode中残留的完整PE文件内容,避免同一份PE数据在内存中长期保留两份。
  • 第二,明确raw PE的生命周期:它只是加载阶段的输入,而不是运行阶段需要依赖的数据。对于front和post两种布局来说,raw PE 的实际位置虽然不同,但v2已经通过RDI2 Header 统一保存了 PeOffsetPeSize,SRDI loader只需要根据 CTX_SRC_BASE 和 PE 大小清理对应区域即可,不需要关心当前payload是front还是post布局。

下图的内容的就是SRDI Loader

PixPin_2026-06-14_13-36-42.png

下图就是PE数据了,我们在这里观察,在shellcode执行之后,旧PE数据会不会消失

PixPin_2026-06-14_13-38-06.png

可以看到确实被清除了旧PE数据,不过SRDI loader自身(bootstrap + RDI .text + RDI2 header)仍在内存还保留在内存中,如果觉得这不够OpSec,可以自行设计清除Loader残留数据,或者等我以后再更新相关内容。

PixPin_2026-06-14_13-40-33.png

3.2 清除映射后的PE特征

不过,映射后的PE image header不能在同一时间点做同样的处理。原因在于header在加载流程后半段仍会被多次使用:重定位读取base relocation directory,导入修复读取import directory,TLS初始化读取TLS directory,x64异常处理依赖 .pdata / exception directory。提前清除header会让loader自身失去解析数据目录的依据,导致后续流程失败。

因此v2将mapped image header的处理推迟到所有loader-only逻辑完成后、进入最终入口调用前。ClearSourcePe 发生在映射完成后,清除的是原始PE buffer。ScrubMappedPeHeaders 发生在调用export、DllMain 或EXE OEP前,清除的是映射映像自身的header。两者作用对象不同,时序也不同。

启动windbg,看看映射后的PE特征是否被消除,在front SRDI内部调用VirtualAlloc之前打上一个断点,调用完后,观察RAX所指向的内存地址,这是刚刚申请的新内存,用于PE映射。

PixPin_2026-06-14_14-29-47.png

之后在call CallImageEntry下一个断点,因为按照上面得出的结论:ScrubMappedPeHeaders 发生在调用export、DllMain 或EXE OEP之前。所以我们观察在调用call CallImageEntry之前,还是有明显的PE特征(“MZ”和“PE”、“This program cannot be run in DOS mode”)

PixPin_2026-06-14_14-37-36.png

调用call CallImageEntry之后,从mapped PE首地址开始,清零SizeOfHeaders 大小的空间,这覆盖了DOS header、DOS stub、NT headers、OptionalHeader、section table——但不覆盖DataDirectory指向的目录实体(它们在 .rdata 等节里)

PixPin_2026-06-14_15-02-06.png

再往下看看,.text.data.rdata等节的内容是不被清除的

PixPin_2026-06-14_15-03-33.png

四、Rust 程序无法直接转换为Shellcode的问题分析:TLS Data

4.1 Rust EXE转成shellcode后崩溃

这次重构最开始遇到的一个问题,是我尝试将Rust Beacon的EXE直接转换成shellcode后运行,结果程序在进入Rust运行逻辑前后出现异常。最开始我以为问题可能出在导入表、重定位或者.pdata 异常表处理上,但在补齐这些常规手工映射逻辑之后,问题仍然没有完全解决。

img

后面继续分析PE结构时,我注意到Rust编译出来的EXE里存在TLS Directory。这里的TLS不是简单的TLS Callback问题,而是Static TLS Data的初始化问题。

img

很多早期SRDI/reflective loader在处理TLS时,只会做一件事:遍历 AddressOfCallBacks,然后在调用入口点前依次执行TLS Callback。这个逻辑对于一些普通C/C++程序可能已经够用,但它并不等价于Windows Loader对TLS的完整处理流程。

因参考文章和参考资料稀缺,正在我一筹莫展之时,我回想起 IoM进阶系列(1) PELoader&RDI的TLS之殇 - ChainReactor Wiki 这篇文章,里面就说到在编写Rust EXE时遇到的PE转shellcode问题,并顺藤摸瓜找了其SRDI项目 chainreactors/malefic-srdi

img

正常情况下,一个PE被系统加载时,Windows Loader不只是调用TLS Callback。它还会根据TLS Directory中的信息,为模块初始化静态TLS数据。一个典型的x64 TLS Directory里包含这些关键字段:

1
2
3
4
5
6
7
8
typedef struct _IMAGE_TLS_DIRECTORY64 {
ULONGLONG StartAddressOfRawData;
ULONGLONG EndAddressOfRawData;
ULONGLONG AddressOfIndex;
ULONGLONG AddressOfCallBacks;
DWORD SizeOfZeroFill;
DWORD Characteristics;
} IMAGE_TLS_DIRECTORY64;
字段 含义
StartAddressOfRawData TLS 初始数据模板的起始地址
EndAddressOfRawData TLS 初始数据模板的结束地址
AddressOfIndex 指向 TLS index 存放位置
AddressOfCallBacks TLS callback 数组

其中 StartAddressOfRawDataEndAddressOfRawData 描述的是TLS初始数据模板,SizeOfZeroFill 表示后面需要额外清零的区域,AddressOfIndex则指向一个TLS index存放位置。程序运行时,如果代码访问静态TLS变量,最终会依赖这个index到当前线程的TLS slot中取出对应的数据地址。

MSVC版把静态TLS的访问逻辑直接编进了代码(通过TEB->ThreadLocalStoragePointer[tls_index]取数据),而初始化依赖外部完成;GNU版则退化成显式TlsAlloc/TlsGetValue,运行时自管理,不需要Loader做静态TLS 初始化。所以同一份Rust源码,GNU版能被普通PE Loader加载,MSVC版不行——问题精确锁定在Static TLS Data 的初始化缺失

所以Rust EXE无法直接转换为shellcode的核心原因并不是Rust不能shellcode化,而是v1 loader对PE加载语义的模拟还不完整。对于依赖TLS Data的Rust程序来说,仅仅把PE映射起来并跳到OEP,是不够的。

4.2 v2 的解决方案:系统的LdrpHandleTlsData

那么正常系统的LoadLibrary是如何处理静态TLS data的呢?根据前人的研究: https://github.com/paskalian/WID_LoadLibrary 我们可以清楚的了解LoadLibrary的具体流程。

img

如果只关心对TLS data的处理,那么我将目光聚焦于LdrpDoPostSnapWork函数。这是ntdll加载流水线中导入绑定(snap)完成后、模块初始化前的后处理函数,核心职责是TLS Data 初始化 + CFG IAT 修复

img

这个函数的触发时机是 LdrpMapAndSnapDependency(IAT 已填好)之后、LdrpInitializeNode(TLS Callback + DllMain)之前。关键的代码是下图所示

img

  • 如果DllEntry->TlsIndex != 0:已有TLS index,跳过LdrpHandleTlsData,直接进CFG
  • 否则:调LdrpHandleTlsData(DllEntry)

TlsIndex 的具有双重含义

场景 TlsIndex 值 行为
模块无TLS Directory 0 调LdrpHandleTlsData,ntdll内部发现没TLS直接返回成功
模块有TLS,首次加载 0(还没分配) 调 LdrpHandleTlsData,ntdll 分配 index、拷贝 raw data、写 TEB、把 index 写回 DllEntry->TlsIndex
模块有TLS,已处理过 非 0 跳过LdrpHandleTlsData

LdrpHandleTlsData 做了什么呢?

  1. 解析DllEntry->DllBase对应PE的TLS Directory
  2. RtlFindClearBitsAndSet(LdrpTlsBitmap, 1, 0) 分配一个小于64的静态TLS index
  3. 把index写入PE的 AddressOfIndex
  4. 分配TLS数据块,拷贝StartAddressOfRawData ~ EndAddressOfRawData + SizeOfZeroFill 清零
  5. TEB->ThreadLocalStoragePointer[index]
  6. 构造TLS_ENTRY,挂到全局 LdrpTlsList

img

img

如果走系统加载路径,尽量复用系统loader内部的TLS处理逻辑。也就是在ntdll中定位LdrpHandleTlsData,构造一个最小化的 LDR_DATA_TABLE_ENTRY(只填 DllBase),并将当前手工映射出来的image base填进去,再交给 LdrpHandleTlsData 去处理TLS数据。这个思路的好处是兼容性更接近系统加载器,因为TLS index分配、TLS entry初始化等细节可以尽量交给ntdll自己完成。

想法是很美好的,但是LdrpHandleTlsData是内部的、未导出的函数,也就是说你在ntdll的导出表是找不到该函数的地址,常规的PEB+导出表动态获取函数地址的方法直接失效。

img

不过参考文章和参考项目 chainreactors/malefic-srdi 中给出了一个非常精妙的的寻找方案:

1
2
3
4
5
RtlGetVersion → dwBuildNumber

├── build >= 21996 (Win11+) → 字符串交叉引用路径

└── build < 21996 (Win7-10) → 硬编码字节特征路径

Win11的ntdll中 LdrpHandleTlsData 函数体内嵌了调试日志字符串 "LdrpInitializeTls" 。这个字符串在版本间极其稳定,比指令字节特征可靠得多。

下面是 LdrpInitializeTls 内部的部分汇编指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.text:000000018000439C loc_18000439C:                          ; CODE XREF: LdrpInitializeTls+E9↑j
.text:000000018000439C lea rax, [rsi+48h]
.text:00000001800043A0 mov [rsp+88h+var_58], rbp
.text:00000001800043A5 mov [rsp+88h+var_60], rax
.text:00000001800043AA lea r8, aLdrpinitialize_5 ; "LdrpInitializeTls"
.text:00000001800043B1 lea rax, aDllWzHasTlsInf ; "DLL \"%wZ\" has TLS information at %p\n"
.text:00000001800043B8 mov r9d, 2
.text:00000001800043BE mov edx, 296h
.text:00000001800043C3 mov [rsp+88h+var_68], rax
.text:00000001800043C8 lea rcx, aMinkernelNtdll_2 ; "minkernel\\ntdll\\ldrtls.c"
.text:00000001800043CF call LdrpLogInternal
.text:00000001800043D4 xor r9d, r9d
.text:00000001800043D7 mov [rsp+88h+var_68], r14
.text:00000001800043DC lea r8, [rsp+88h+var_48]
.text:00000001800043E1 mov rdx, rsi
.text:00000001800043E4 mov rcx, rbp
.text:00000001800043E7 call LdrpAllocateTlsEntry
.text:00000001800043EC test eax, eax
.text:00000001800043EE js loc_18000436F
.text:00000001800043F4 mov eax, 0FFFFh
.text:00000001800043F9 mov [rsi+6Eh], ax
.text:00000001800043FD jmp loc_18000434B

以上面的反汇编代码为例,win11系统上的思路就很简单了,首先确认ntdll基址,之后找LdrpInitializeTls函数内部的字符串 “LdrpInitializeTls”,得到字符串的绝对地址 string_addr
img

在ntdll .text 段中搜索 4C 8D 05lea r8, aLdrpinitialize_5),并验证RIP-relative计算后目标等于 string_addr,最终确定LEA指令绝对地址 lea_addr

img

lea_addr 开始,向后扫描最多0x30字节,找第一个 E8call rel32),这个就是 call LdrpLogInternal,不是我们要的,但它是定位锚点,最终得到绝对地址 call1_addr

img

call1_addr + 5 开始,继续找下一个call指令 E8,这个就是 call LdrpAllocateTlsEntry,最终得到绝对地址 call2_addr

img

E8 rel32 指令是相对RIP偏移跳转,rel32 是有符号偏移,从call指令的下一条指令开始算,call2_addr + 5 + rel32 = alloc_tls_addr 最终可以得到 LdrpAllocateTlsEntry 的绝对地址 alloc_tls_addr

img

img

为什么要找到 LdrpAllocateTlsEntry 的绝对地址?LdrpAllocateTlsEntry 在ntdll里只被两个函数调用:

  • LdrpInitializeTls
  • LdrpHandleTlsData(我们要找的)
    img

现在已知LdrpAllocateTlsEntry的地址。在整个.text段中逐字节扫描E8指令,解码每个call的目标,找到所有调用LdrpAllocateTlsEntry的位置。我们需要利用已知的 call2 调用点来排除 LdrpInitializeTls,从所有调用LdrpAllocateTlsEntry 的地点中,跳过已知属于LdrpInitializeTlscall2

img

最终得到的call_xref_addr位于LdrpHandleTlsData 函数体内部,并非函数入口。ntdll是x64 PE,其Optional Header的 IMAGE_DIRECTORY_ENTRY_EXCEPTION 指向.pdata异常处理目录。.pdataRUNTIME_FUNCTION表项组成,每一项记录一个函数的 [BeginAddress, EndAddress) RVA范围。

call_xref_addr - ntdll_base 转换为RVA,遍历.pdata,找到满足:

1
2
BeginAddress <= call_xref_rva &&
call_xref_rva < EndAddress

的表项。该表项的 BeginAddress 加上 ntdll_base,即为包含该调用点的函数入口。

在Windows 11 22631(我本机)的示例中,call_xref_addr = ntdll+0x4654,命中的 RUNTIME_FUNCTION 范围为 [0x4528, 0x4A3C),因此最终得到:

1
LdrpHandleTlsData = ntdll + 0x4528

相比依赖编译器填充字节,.pdata 是该x64模块实际使用的函数范围元数据,对此类具备unwind信息的内部函数更可靠。

img

img

win11以下的版本没有调试日志字符串 "LdrpInitializeTls" ,要如何处理呢?最简单粗暴的方法就是根据硬编码字节特征匹配了。

LdrpHandleTlsData 函数体内有一段稳定的指令序列,这段序列在不同Windows 版本间不同(因为编译器版本、优化选项、指令调度不同),但在同一版本内固定。

SelectLdrpTlsPattern 按 build number 选取一组参数:

Build 范围 选择条件 特征长度 特征字节
7600 - 9199 win_build >= 7600 11 41 B8 09 00 00 00 48 8D 44 24 38
9200 - 9599 win_build >= 9200 8 48 8B 79 30 45 8D 66 01
9600 - 15062 win_build >= 9600 9 44 8D 43 09 4C 8D 4C 24 38
15063 - 17133 win_build >= 15063 6 74 33 44 8D 43 09
17134 - 18361 win_build >= 17134 6 74 33 44 8D 43 09
18362 - 21995 win_build >= 18362 6 74 33 44 8D 43 09

win10 v18363
img

win10 v10586
img

win8 v9200
img

win7 v7601
img

Win7-Win10的x64路径仍根据系统Build选择硬编码字节特征,字节特征命中后得到的是函数体内地址match_addr,将其转换为match_rva = match_addr - ntdll_base,再查询ntdll的 .pdata 异常处理目录。

.pdata 中的每个 RUNTIME_FUNCTION 表项记录函数的 [BeginAddress, EndAddress) RVA 范围。找到满足:

1
BeginAddress <= match_rva && match_rva < EndAddress

的表项后,函数入口就是:

1
function_start = ntdll_base + BeginAddress;

img

除了使用系统的 LdrpHandleTlsData 处理 TLS data 外,理论上也可以自行模拟该函数的功能(即手动完成 TLS 目录解析、TlsIndex 分配、ThreadLocalStoragePointer 槽位写入、raw data 拷贝和 zero-fill 等操作)。但这真的有必要实现吗?

在 Convert2Shellcode 项目中,我没有选择手动实现 LdrpHandleTlsData,而是直接定位并调用 ntdll 内部的原版函数。这样做有两个好处:

  1. 体积更小:省去了完整的 TLS 初始化逻辑代码
  2. 正确性更高:系统原版函数会正确处理所有边界情况(多模块共享 TLS bitmap、回调链、LdrpTlsList 全局链表维护等),手写实现难以覆盖所有 Windows 版本的内部结构差异。

当然,这种方案的代价是依赖 ntdll 内部未导出函数的地址定位(通过字符串交叉引用或版本特征码),如果目标环境的 ntdll 被大幅修改或混淆,可能需要额外的适配工作。但对于绝大多数实战场景,直接调用系统函数是最简洁可靠的选择。

在RefLoader调试工程中,我额外实现了完整的手动模拟路径作为fallback,用于验证手动方案的可行性和正确性,但这部分代码并未引入SRDI shellcode。光是在 Windows 11 22631上模拟 LdrpHandleTlsData 就需要约 200 行C代码,把它们塞到SRDI里面是弊大于利。

img

img

五、PE32 支持:x86 下手工映射流程的差异适配

5.1 统一协议,分离实现

Convert2Shellcode 对x64和x86 复用同一套RDI2协议:RDI2 Header、user data、export hash,以及 front/post的入口语义保持一致。

PE32 与 PE32+ 的结构和运行时约定差异较大,因此分别维护x86、x64 ASM,比在同一份代码中混合分支更容易维护。

5.2 x86 入口约定

x86的front和post均以ECX传递RDI2 Header地址:

1
2
front: [x86 bootstrap][RDI .text][RDI2 header][raw PE][optional user data]
post: [x86 post bootstrap][RDI2 header][raw PE][optional user data][RDI .text]

因此,RDI主逻辑只需从ECX指向的Header中取得raw PE和user data,不关心当前是front还post。

5.3 仅支持 PE32

x86 loader检查IMAGE_NT_OPTIONAL_HDR32_MAGIC0x10B),只接受PE32:支持32位loader映射 32 位PE32。不支持64位进程直接执行32位shellcode。

5.4 PE32 的字段与重定位

PE32和PE32+的Optional Header 布局不同。x86 loader使用PE32对应偏移:

1
2
3
4
5
ImageBase           -> [NT + 0x34]
Export Directory -> [NT + 0x78]
Import Directory -> [NT + 0x80]
Base Relocation Dir -> [NT + 0xA0]
TLS Directory -> [NT + 0xC0]

重定位类型也不同:

1
2
x64: IMAGE_REL_BASED_DIR64   (10)
x86: IMAGE_REL_BASED_HIGHLOW (3)

因此,x86独立处理PE32的映射、重定位、导入表修复和TLS。

5.5 不注册目标 PE 的 .pdata

x64映射PE后需要通过RtlAddFunctionTable 注册异常函数表;x86 PE32不需要这一步,因此x86 loader不包含目标PE的 .pdata 注册逻辑。

不过,这不代表x86不处理TLS。x86仍会解析RtlGetVersion,按系统版本选择特征码定位 ntdll!LdrpHandleTlsData,并构造fake LDR entry交给系统完成Static TLS Data初始化。

5.6 x86 TLS:调用系统路径

对于包含TLS Directory的PE,srdi loader定位并调用系统的 LdrpHandleTlsData,由ntdll完成TLS index分配、TLS数据复制和当前线程TLS vector更新。

随后,srdi loader再调用TLS callback,最后进入EXE OEP,或执行DLL的 DllMain(DLL_PROCESS_ATTACH)

5.7 x86 导出函数调用约定

x86 export 模式使用 __cdecl

1
2
3
4
5
push user_len
push user_ptr
push dst_base
call eax
add esp, 0Ch

对应的导出函数应声明为:

1
2
extern "C" __declspec(dllexport)
void __cdecl Start(void* image_base, void* user_data, uint32_t user_size);

不要使用WINAPI__stdcall,否则被调用函数和loader会重复清理参数,导致栈不平衡。DLL的DllMain 与TLS callback仍按Windows约定使用 __stdcall

六、Convert2Shellcode 与 Iris Beacon 睡眠混淆冲突的解决思路

一次意味的发现,当我使用自己编写的shellcode loader加载经过SRDI shellcode化后的beacon.exe(v0.1.3版本之前的C-Beacon),我想看一下睡眠混淆是否成功,不探究还好,一探究就引发后续一系列的对于睡眠混淆功能模块的重新思考和设计。

正常情况下,我们运行stagerless的beacon,可以上线,且能加密/解密beacon image。
img

不过使用shellcode loader加载加载经过SRDI shellcode化后的beacon.exe,在内存视图窗口观察睡眠混淆功能是否正确将这块区域内存加密/解密,出乎意料的是,没有加密/解密,难道是睡眠混淆出了问题吗?我前期已经验证过,单独运行beacon.exe,睡眠混淆能够正常加密/解密beacon.exe的映像区域。

随后,我仔细阅读了v0.1.2睡眠混淆的代码,获取需要加密的内存基址这部分代码让我疑惑:通过GetModuleHandle是返回当前进程的映像基址,而sleep混淆将其作为加密/解密的起始点,如果是运行beacon.exe,这没有任何问题,但是如果 SRDI + beacon.exe 的shellcode模式呢,GetModuleHandle返回的是loader的映像基址,这样加密/解密就是loader.exe的内存区域。

img

img

办法其实很简单,我在v0.1.3版本中,在入口函数中增加基址参数,无论是DLLMain还是WinMain,都需要调用方传递beacon运行的基址。WinMain的启动是通过CRT初始化,会自动传递beacon的运行基址,其hInstance就是Beacon运行的基址。

img

对于stager中下载的stage(beacon.dll),因为是通过反射dll注入的方式运行,所有反射加载器需要将加载dll时自行记录pe映射基址,并在调用入口点时将映射基址传递过去。

img

img

前期我就想将C-beacon项目的反射加载器独立出来,专门做一个高级功能的加载器,支持清除旧PE数据,消除新PE特征,这也是Convert2Shellcode进入v2.0版本的契机,不过我将不传递PE基址的版本作为通用版本,将传递PE基址的版本作为Beacon的专用加载器,这样就可以将C-beacon项目中的inject_reflective.c给剥离出来,让stage beacon.dll作为一个普通的dll,让操作员通过各种改良的反射加载器将其加载到内存中执行。

有了基址之后,睡眠混淆机制就能运行成功了吗?我的答案是:还缺了点东西。再详细看看我之前对于SRDI的设计,为了提高exe/dll shellcode化后的规避能力,我是完全抹除了PE头。而这段代码中,却需要通过DOS、NT头定位到SizeofImage,和.text节的地址和大小,我抹除了PE头,导致下面代码直接失效。
img

解决方案:在patch beacon teampalate时,将自行解析PE文件,将有用的信息patch到profile slot中,这些操作都是在server中完成,牺牲一点点等待时间(可忽略不计),却可以让Beacon能够加密/解密整SizeofImage的内存区域,并恢复.text节的内存属性,我认为是值得的。

patch的结构体大概长这样

1
2
3
4
5
6
typedef struct {
uint32_t image_size; // network / big-endian order
uint32_t text_rva; // network / big-endian order
uint32_t text_size; // network / big-endian order
uint32_t text_protect; // network / big-endian order
} SleepImageLayout;

img

beacon就可以使用配置中定义好的CfgSleepImageLayout结构,正确的加密/解密SizeofImage的内存区域,并恢复.text节的内存属性。
img

为了调试方便,我在C-Beacon的Sleep命令中增加基址回传,也就是说可以在client中下发sleep命令,sleep执行完后会将基址输出到client的控制台上。
img

现在,我们重新让shellcode loader加载执行shellcode化后的beacon.exe,上线后,执行sleep命令获取beacon的运行基址。

shellcode loader的映像内容正常。

img

client控制台输出的hInstance=0000018DF04F0000就是beacon的运行基址

img

0000018DF04F0000~0000018DF04F1000的区域就是PE头,不过,这块区域被SRDI Loader给清零了
img

0000018DF04F1000~0000018DF0526000是.text区域,下面的内存区域是原始未加密的状态。
img

加密中
img

PS:上面所说的方案只适用C-Beacon,对于Rust-Beacon,可能行不通:因为Rust的入口点一般是main,而对于PE头AddressOfEntrypoint的一般说是链接器放入的Rust runtime初始化入口点,如果我们在rust beacon中导出一个函数,参数是基址,这样的模式可能跳过rust runtime的初始化,导致意想不到的错误发生。

如果你还想在rust-beacon中实现睡眠混淆,那么你必须知道beacon的基址在哪里。如果还想使用GetModuleHandleW来获取beacon基址,从而加密beacon代码这个路径是行不通的。与C-Beacon不同,C-Beacon可以通过GetModuleHandleW获取基址并正常工作;但如果用shellcode loader加载执行 shellcode 化后的rust-beacon.exe,在进入睡眠混淆路径后会报错。

1
2
3
4
5
6
7
8
[*] shellcode: .\rustbeacon_new.bin (1821025 bytes)
[*] running shellcode at 00000219B0060000
[*] thread id: 34288
[*] Beacon starting...
[*] Collected Metadata: OS=windows, Arch=x64, User=Administrator, IP=192.168.1.6
[*] BeaconID: 2164271191
[*] Sleeping for 5.15s...
[-] unhandled exception: 0xc0000005 at 00007FFED0A34700 access=FFFFFFFFFFFFFFFF mode=0

我想到了一些解决方案,但转念一想,项目主体仍是C实现的Beacon对象,Rust-Beacon只是用于扩展研究。因此,现阶段继续保持默认清除PE头,rust-beacon不实现睡眠混淆,如果非要实现睡眠混淆,则不要使用SRDI 工具。

引入睡眠混淆会与其他机制产生不同的化学反应。比如本文所述的SRDI与睡眠混淆的冲突,以及在开发Iris Beacon过程中我还遇到的睡眠混淆与远程注入Beacon引发的CFG问题、级联模块的重新设计。

这些问题我会在后续文章中展开记录。但核心的教训是:睡眠混淆不是一个可以独立插入的功能模块,它会穿透加载层、注入层和通信层的边界,迫使你重新审视整个Beacon的运行时架构。 这也是为什么Iris Beacon最终将 RuntimeGateSleepImageLayout、基址传递机制这些基础设施全部打通后才算真正落地。

写在博客上的

连续写作的计划到本文章发出去的时候就已经结束了(还有一篇文章要失约了),4个月写了9篇文章,已让我筋疲力尽,有没有让各位满意呢?我接下来需要沉淀一段时间,将学习的方向转向windows内核、linux内核、AD域以及漏洞挖掘的研究中,等我学有所成之后在分享出来吧,可能是几个月,也可能是几年,我也说不准。

博客还是会不定时更新,虽然脑子的想法还有挺多,但架不住每月都产出一篇文章啊,社畜人的下班时间是很宝贵的哦>.<

P站画师: https://www.pixiv.net/artworks/147294913

祀.png

Prev
2026-07-18 18:48:00 # 武器化