线程局部存储(Thread Local Storage, TLS)技术,实现了线程内部变量的存储访问,在该技术下定义的变量能被同一个线程内部的各个函数所调用,同时,杜绝了其他线程对这些变量的访问。
应用程序由一个或多个进程组成。用简单的术语来讲,进程是系统分配资源的最小单位,是一个执行程序。一个或多个线程在进程的上下文中运行。线程是操作系统分配处理器时间的基本单元。线程可以执行进程代码的任何部分,包括当前由另一个线程执行的部件。
每个进程都提供执行程序所需的资源。进程具有虚拟地址空间、可执行代码、系统对象的开放句柄、安全上下文、唯一进程标识符、环境变量、优先级类、最小和最大工作集大小以及至少一个执行线程。每个进程都使用单个线程(通常称为主线程)启动,但可以从其任何线程创建其他线程。
线程是进程内可计划执行的实体。进程的所有线程共享其虚拟地址空间和系统资源。
在内核中一个进程的创建是从函数 NtCreatProcess
开始的。该函数位于文件ntoskrnl.exe中,该文件位于%windir%\system32。它对用户传入的部分参数进行简单处理,然后交给函数 NtCreateProcessEx
,其创建进程的步骤大致如下:
ObCreatObject
(内核的导出函数)创建Windows执行体内核对象EPROCESS,该对象为新进程的进程对象;该对象归系统所有,并由系统统一管理。ExCreateHandle
在CID句柄表中创建一个进程ID项。CID是客户身份号,一般由进程号和线程号俩部分组成,用于识别某个进程。ObInsertObject
将进程对象插入当前进程的句柄表中。通过上面的步骤,在内核模式下创建了一个进程。但是进程是惰性的,离开了线程,进程的空间只是一个固定在内存中的死空间;知道它的第一个线程被创建和初始化之后,进程中的代码才能被真正的运作起来。
与进程的创建类似,内核中线程的创建是从函数 NtCreateThread
开始的。之后交由函数 PspCreateThread
。其大致步骤如下:
ObCreateObject
创建线程对象ETHREAD,并初始化。ObInsertObject
将新线程加入到进程的线程链表中。KeStartThread
,新线程即可运行。在用户层,创建一个进程通常使用Kernel32.dll中的函数 CreateProcess
来完成。该函数一旦返回成功,新的进程和进程中的第一个线程就建立起来了。其大致过程如下:
CreateProcessW
打开指定的可执行映像文件。NtCreateProcessEx
,该函数利用处理器的陷阱机制切换到内核模式下。在内核模式下,系统服务分发函数 KiSystemService
获得CPU控制权,它利用当前线程指定的系统服务表,调用执行体层的函数 NtCreateProcessEx
,开始内核过程中的进程创建。执行体层的进程对象创建以后,进程的地址空间完成初始化,EPROCESS中的进程环境块(PEB)也完成初始化。NtCreateThread
构造一个可供第一个线程运行的环境,如堆的大小初始化线程栈的大小等。这些值的默认初始值来自于PE文件头部相应的字段,ntdll.dll中的该函数依旧将任务交由执行层的 NtCreateThread
来完成。执行体层的线程对象ETHREAD建立后,线程的ID、TEB也完成了初始化。BaseProcessStart
函数,此时的线程是被挂起的,要等到进程完全初始化才真正开始。KiThreadStartup
函数,之后经过一系列初始化,一直到该函数返回到用户模式,开始调用函数 Ntdll.LdrinitializeThunk
,该函数是PE映像加载器的初始化函数。以上是对进程和线程创建的简单描述(大概了解就好,更详细的参照《Windows内核原理与实现》);接下来的重点是在进程和线程创建中提到的PEB和TEB。
操作系统会为每个进程设置一个数据结构,用来记录进程的相关信息。该结构就是PEB(Process Environment Block,进程环境块),PEB存在于用户地址空间中,记录了进程的相关信息。
在NT中,PEB位于进程空间的FS:[0x30]
处。同时,TEB中的 ProcessEnvironmentBlock
就是PEB结构的地址,其结构的0x30偏移处是一个指向PEB的指针。
因此,访问PEB有俩种方法:
直接获取:
mov eax, dword ptr fs:[30h] ; fs:[30]里存放即是PEB地址
通过TEB获取:
mov eax, dword ptr fs:[18h] ;此时eax里为TEB的指针
mov eax, dword ptr [eax+30h] ;此时eax里为PEB的指针
PEB结构(部分):
其中,BeingDebugged
成员用于指定该进程是否处于被调试状态,该值为0时进程未处于调试状态,若该值为非零值,则进程处于调试状态。(可以使用Windows API,如IsDebuggerPresent
、CheckRemoteDebuggerPresent
函数来访问该成员)
Ldr字段也是一个很重要的成员,该字段指向的结构记录了进程加载进内存的所有模块的基地址,通过Ldr指向的三个链表就可以找到kernel32.dll
的基地址。
**TEB(Thread Environment Block,线程环境块)**同样位于应用层之中。它包含了系统频繁使用的一些与线程相关的数据,进程中的每个线程都有一个自己的TEB。一个进程的所有TEB都存放在从0x7FFDE000开始的线性内存中,每4KB为一个完整的TEB。
在NT中,FS:[0]
的地址指向了TEB结构,这个结构的开头是一个NT_TIB结构,具体(部分)如下:
NT_TIB结构的0x18偏移处是一个Self指针,指向这个结构自身,也就是TEB结构的开头。TEB结构的0x30偏移处是一个指向PEB的指针。
在TEB结构的0xE10偏移处有个字段TlsSlots[]
,是一个无类型的指针数组(TLS存储槽),它的大小是40h字节。也就是说,一个线程同时存在的动态TLS不能超过64项。
可以通过NtCurrentTeb函数调用和FS段寄存器俩种方法来访问TEB结构:
NtCurrentTeb函数调用
FS段寄存器访问
mov eax, dword ptr fs:[18h]
线程局部存储(Thread Local Storage, TLS)技术,实现了线程内部变量的存储访问,在该技术下定义的变量能被同一个线程内部的各个函数所调用,同时,杜绝了其他线程对这些变量的访问。
TLS机制可以让程序拥有全局变量,但在不同的线程里却对应有不同的值。也就是说,进程中的所有线程都可以拥有全局变量,但这些变量其实是特定对某个线程才有意义的。
TLS技术分为俩种:动态线程局部存储技术和静态线程局部存储技术。
分类依据主要是:线程局部存储的数据所用空间在程序运行期,操作系统完成的是动态申请还是静态分配。动态线程局部存储通过四个Win32 API函数实现对线程局部数据的存储;而静态线程局部存储则通过预先在PE文件中声明数据存储空间,由PE加载器加载该PE进入内存后为其预留存储空间,由PE加载器加载该PE进入内存后为其预留存储空间实现。
你可以放弃使用TLS,因为你对自己设计的程序有比较全面的把握。你清楚自己设计的进程里有总共有多少个线程,每个线程都使用了哪些数据结构,内存空间的申请、释放都在你的掌握之下,全局变量的访问全部采用了同步技术,那是没有问题的。如果你是一个DLL的开发者,你无法确定调用这个DLL的宿主程序里到底都多少个线程,每个线程的数据是如何定义的,这时,是你使用动态线程局部存储技术的最佳时机。
动态TLS存在以下四个API函数。
应用程序或DLL在合适的时候会调用这四个函数,通过索引对进程中每个线程的存储区进行统一操作。它们位于动态链接库文件kernel32.dll中。
静态线程局部存储预先将变量定义在PE文件内部,一般使用**.tls**节存储,对相关API的调用由操作系统来完成。
在Visual C++中,一般只需要做出如下声明:
_declspec (thread) int tlsFlag = 1;
为了支持这种编程模式,PE的 .tls 节会包含以下信息:
注意:通过静态方式定义的TLS数据对象只能用于静态加载的映像文件。这使得在DLL中使用静态TLS数据并不可靠,除非你能确定这个DLL,以及静态链接到这个DLL的其他DLL永远不会被动态家长,如通过调用LoadLibrary这个API函数实施的动态加载那样
附件随便拿的2023强网杯的babyre.exe。
使用StudyPE查看:
TLS的文件偏移FOA是AB20。使用WinHex找到offset为AB20处就是TLS数据目录项了。
如果是根据RVA和FOA换算(书上的计算过程):
之前学过一点PE头,TLS数据目录项是位于扩展PE头 IMAGE_OPTIONAL_HEADER的最后一个成员DataDirectory;它是一个结构数据,一般有十六个成员,TLS数据目录项就是第十个。
因此很容易就可以找到:
再根据
换算:1BF20-1A000 + 8C00 = AB20
(这里还有点懵)
线程局部存储数据结构以数据结构IMAGE_TLS_DIRECTORY32开始。该结构的详细定义如下:
各字段的含义:
StartAddressOfRawData
+0000h,双字。表示 TLS 模板的起始地址。这个模板是一块数据,用于对 TLS 数据进行初始化。
EndAddressOfRawData
+0004h,双字。表示TLS 模板的结束地址。TLS的最后一个字节的地址,不包括用于填充的0。
AddressOflndex
+0008h,双字。用于保存 TLS 索引的位置,索引的具体值由加载器确定。这个位置在普通的数据节中,因此,可以给它取一个容易理解的符号名,便于在程序中访问。
AddressOfCallBacks
+000Ch,双字。这是一个指针,它指向由 TLS 回调函数组成的数组。这个数组是以NULL 结尾的,因此,如果没有回调函数的话,这个字段指向的位置处应该是 4 个字节的0。
SizeOfZeroFill
+0010h,双字。TLS 模板中除了由 StartAddressOfRawData和 EndAddressOfRawData字段组成的已初始化数据界限之外的大小(以字节计)。TLS 模板的大小应该与映像文件中TLS数据的大小一致。用0填充的数据就是已初始化的非零数据后面的那些数据。
Characteristics
+0014h,双字。保留未用。
IMAGE_TLS_DIRECTORY32结构中的成员AddressOfCallBacks就是回调函数组成的数组。
程序可以通过PE文件的方式提供一个或多个TLS回调函数,用以支持对TLS数据进行的附加的初始化和终止操作,这种操作类似于面向对象程序设计中的构造函数和析构函数(当一个线程被创建和销毁时,函数会被调用)。
因为回调函数会在入口点(AddressOfentryPoint)之前执行,也就是说,许多病毒或外壳程序会利用这一点进行一些特殊操作。例如,调用Windows API中的 IsDebuggerProcess
检测PEB中的BeingDebugged
成员,来判断程序是否处于调试状态,进行一些反调试的操作。
回调函数的原型和DLL入口点函数参数相同:
typedef VOID (NTAPI *PIMAGE_TLS_CALLBACK){
PVOID DllHandle,
DWORD Reason,
PVOID Reservd
};
参数解释: