背景
最近在做内存加载DLL时,碰到了一个崩溃,涉及到TLS(线程局部存储),所以记录一下对问题的分析。

问题分析
内存加载原理
内存加载[1],又称自加载、反射加载[2],是指不使用系统的LoadLibrary类函数加载DLL,而是模拟DLL加载过程,手动将DLL加载到内存中。
内存加载的一般过程为:
- 读DLL文件,解析PE头
- 申请DLL image空间:OptionalHeader.SizeOfImage
- 逐个拷贝section内容到image内存
- 解析导入函数,填充IAT表
- 修复重定位
- 调用TLS回调函数
- 调用DLL入口点
存在的问题
上述过程只是模拟了将DLL加载到内存中,并简化了DLL的初始化(只调用了入口点),这对于一般DLL来说已经足够了,但对于带TLS的DLL,就会有问题:
- DllMain和TLS回调函数不会得到线程事件通知:自加载DLL不在模块列表中,线程创建或退出等事件不会通知它[3]
- 静态TLS变量未初始化:模块的TLS变量内存块是加载时系统动态分配的,然后从文件中的TLS模板数据拷贝过去来对简单类型的TLS变量进行初始化[4],同时会更新该模块的
_tls_index
值,通过TEB.ThreadLocalStoragePointer + _tls_index
来得到TLS内存块的地址
第一点还好说,一般DLL中没有TLS回调函数,DllMain中也没有特殊操作,线程事件不通知问题也不大。但是第二点就比较严重了,如果DLL中存在静态TLS变量,因为没有申请TLS内存块,也没有更新_tls_index
,DLL中使用到TLS变量时,结果会出错或崩溃。
问题重现与分析
来看个例子:
cpp
// 静态TLS变量
__declspec(thread) int v0;
__declspec(thread) int v1 = 1;
__declspec(thread) string v2("hello");
extern "C" __declspec(DLLexport) void Test(int n) {
static int v3 = 3; // 局部static变量,简单类型,已初始化
static int v4 = n; // 局部static变量,简单类型,需运行时初始化
static string v5("world"); // 局部static变量,复杂类型,调用string构造函数动态初始化
printf("global: v0=%d, v1=%d, v2=%s\n", v0, v1, v2.c_str());
printf("local : v3=%d, v4=%d, v5=%s\n", v3, v4, v5.c_str());
}
在测试程序中先使用系统加载DLL,然后使用内存加载DLL,调用DLL的导出函数Test(4)
,输出结果如下:
ini
System load:
global: v0=0, v1=1, v2=hello
local : v3=3, v4=4, v5=world
Memory load:
global: v0=0, v1=0, v2=hello
local : v3=3, v4=0, v5=
系统加载的情况下,输出结果符合预期;但是内存加载的情况下,程序大概率会崩溃,上面没有崩溃的输出是使用debug增量编译的,方便看一下不同变量的细微差别。
总的来说,由于内存加载中没有为TLS变量申请空间,也没有更新DLL的_tls_index
,_tls_index
是默认值0,然后基于_tls_index
获取该DLL的TLS内存块时,得到的其实是另一个模块的TLS内存块,读写都会有问题。我的测试环境是win10 22H2,测试程序中_tls_index
为0对应的是kernelbase.DLL
的TLS内存块。
具体来说,不同类型的变量有些差异:
-
v0
: 简单类型无初值,使用默认初值0,其输出结果是kernelbase的TLS内存块对应位置的值 -
v1
: 简单类型有初值,初值在TLS模板中,但是内存加载时没有处理TLS初始化,所以输出结果也是kernelbase的TLS内存块对应位置的值 -
v2
: 复杂类型有初值,需要动态初始化的TLS变量,其初始化函数会在DllMain中被调用,所以内存加载时会执行初始化,但是初始化的位置是kernelbase的TLS内存块,会覆盖掉kernelbase的TLS变量。从下图可以看出,系统加载该DLL时,v2初始化函数的执行时机在DLL入口中。 -
v3
: 局部static变量,简单类型,静态初始化,不需要运行时初始化,不涉及TLS,输出结果正确 -
v4
: 局部static变量,简单类型,需要运行时初始化,为了保证局部static变量只在第一次运行时初始化,编译器额外为v4
后面添加了一个$TSS0
变量标识初始化状态,默认为0,并引入了TLS变量_Init_thread_epoch
,初值是INT_MIN
,当$TSS0 > _Init_thread_epoch
时,才会执行动态初始化,初始化完成后会将$TSS0
置为_Init_thread_epoch
,这样后续的线程就不会重复执行初始化了。但内存加载场景下,读取的_Init_thread_epoch
是kernelbase TLS内存块中的某个位置,可能是0,导致条件不满足,不走v4
的初始化。cpp// 摘自vcruntime的thread_safe_statics.cpp,从wdk中可以找到 static int const epoch_start = INT_MIN; // #define INT_MIN (-2147483647 - 1) extern "C" { int _Init_global_epoch = epoch_start; __declspec(thread) int _Init_thread_epoch = epoch_start; }
-
v5
: 局部static变量,复杂类型,需要运行时初始化,和v4
一样,编译器添加了$TSS1
变量标识其初始化状态,也会引入TLS变量_Init_thread_epoch
。注意,
v4
和v5
,包括编译器添加的$TSS0
和$TSS1
本身还是静态变量,它们都位于静态数据区(.data
),不在TLS内存块中。
在日常开发中,对于显式的使用TLS我们是有意识的,但对于复杂类型的局部static变量引入的隐式TLS,就比较容易忽略,就容易出错。而最常见引入方式,大概就是使用Meyers' Singleton
单例模式了[5],我在文章开头的崩溃,就是因为使用了这种单例模式写法。
cpp
// Meyer's Singleton
class Singleton {
public:
static Singleton& getInstance() {
// 复杂类型局部static变量,引入TLS
static Singleton instance;
return instance;
}
// 删除拷贝构造函数和赋值运算符
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
// 私有构造函数和析构函数
Singleton() = default;
~Singleton() = default;
// 类中含有复杂类型
string a;
vector b;
}
解决方案
- 去除DLL中使用的TLS变量即可,要特别留意复杂类型的局部static变量,会隐式引入TLS,可以考虑改成全局static,但要注意全局static的初始化顺序问题[5]
- 如果有实在不能去除的TLS变量,就放弃使用内存加载,改用系统加载吧
参考
- MemoryModule, https://github.com/fancycode/MemoryModule/blob/master/MemoryModule.c
- ReflectiveDLLInjection, https://github.com/stephenfewer/ReflectiveDLLInjection/blob/master/DLL/src/ReflectiveLoader.c
- 线程初始化过程PK加载DLL过程, https://bbs.kanxue.com/thread-185786.htm
- Windows线程局部存储机制解析, https://mp.weixin.qq.com/s/_vpyCjBAllzG6x1cFTh4mA?token=507367694\&lang=zh_CN
- 为什么Meyers' Singleton是线程安全的, https://blog.csdn.net/m0_59680769/article/details/140094001