Windows进程和线程and线程局部存储TLS---notes

线程局部存储

线程局部存储(Thread Local Storage, TLS)技术,实现了线程内部变量的存储访问,在该技术下定义的变量能被同一个线程内部的各个函数所调用,同时,杜绝了其他线程对这些变量的访问。

Windows进程和线程

什么是进程和线程

应用程序由一个多个进程组成。用简单的术语来讲,进程是系统分配资源的最小单位,是一个执行程序。一个或多个线程在进程的上下文中运行。线程是操作系统分配处理器时间的基本单元。线程可以执行进程代码的任何部分,包括当前由另一个线程执行的部件。

每个进程都提供执行程序所需的资源。进程具有虚拟地址空间、可执行代码、系统对象的开放句柄、安全上下文、唯一进程标识符、环境变量、优先级类、最小和最大工作集大小以及至少一个执行线程。每个进程都使用单个线程(通常称为主线程)启动,但可以从其任何线程创建其他线程。

线程是进程内可计划执行的实体。进程的所有线程共享其虚拟地址空间和系统资源。

进程和线程的创建

内核模式下

在内核中一个进程的创建是从函数 NtCreatProcess开始的。该函数位于文件ntoskrnl.exe中,该文件位于%windir%\system32。它对用户传入的部分参数进行简单处理,然后交给函数 NtCreateProcessEx,其创建进程的步骤大致如下:

  1. 调用函数 ObCreatObject(内核的导出函数)创建Windows执行体内核对象EPROCESS,该对象为新进程的进程对象;该对象归系统所有,并由系统统一管理。
  2. 获取内存区对象的指针,根据传入的端口参数初始化新进程对象的相应字段。
  3. 如果父进程不为空,则创建一个全新的地址空间。
  4. 调用函数对新进场对象的基本优先级,访问权限,地址空间等进行初始化,并将映像映射到地址空间;加载Ntdll.dll。
  5. 调用函数 ExCreateHandle 在CID句柄表中创建一个进程ID项。CID是客户身份号,一般由进程号和线程号俩部分组成,用于识别某个进程。
  6. 审计本次创建欣慰,并构造PEB,将新进程加入全局进程链表PsActiveProcessHead中。
  7. 调用函数 ObInsertObject 将进程对象插入当前进程的句柄表中。
  8. 设置进程基本优先级,访问权限,创建时间。

通过上面的步骤,在内核模式下创建了一个进程。但是进程是惰性的,离开了线程,进程的空间只是一个固定在内存中的死空间;知道它的第一个线程被创建和初始化之后,进程中的代码才能被真正的运作起来。

与进程的创建类似,内核中线程的创建是从函数 NtCreateThread 开始的。之后交由函数 PspCreateThread。其大致步骤如下:

  1. 根据进程句柄获取进程对象并将其放到局部变量中。
  2. 调用函数 ObCreateObject 创建线程对象ETHREAD,并初始化。
  3. 获取进程的RundownProtect锁,以免线程创建过程中该进程被当掉。
  4. 创建线程环境块(TEB),初始化。
  5. 锁住进程,并将进程的活动线程数量加1,然后调用函数 ObInsertObject 将新线程加入到进程的线程链表中。
  6. 调用函数 KeStartThread,新线程即可运行。
用户模式

在用户层,创建一个进程通常使用Kernel32.dll中的函数 CreateProcess来完成。该函数一旦返回成功,新的进程和进程中的第一个线程就建立起来了。其大致过程如下:

  1. 调用函数 CreateProcessW 打开指定的可执行映像文件。
  2. 调用 ntdll.dll 中的存根函数 NtCreateProcessEx,该函数利用处理器的陷阱机制切换到内核模式下。在内核模式下,系统服务分发函数 KiSystemService 获得CPU控制权,它利用当前线程指定的系统服务表,调用执行体层的函数 NtCreateProcessEx,开始内核过程中的进程创建。执行体层的进程对象创建以后,进程的地址空间完成初始化,EPROCESS中的进程环境块(PEB)也完成初始化。
  3. PEB创建完之后,意味着进程的创建告一段落。这时候需要为进程创建第一个线程,调用函数 NtCreateThread 构造一个可供第一个线程运行的环境,如堆的大小初始化线程栈的大小等。这些值的默认初始值来自于PE文件头部相应的字段,ntdll.dll中的该函数依旧将任务交由执行层的 NtCreateThread来完成。执行体层的线程对象ETHREAD建立后,线程的ID、TEB也完成了初始化。
  4. 进程的第一个线程的启动函数是 kernel32.dll 中的 BaseProcessStart函数,此时的线程是被挂起的,要等到进程完全初始化才真正开始。
  5. kernel32.dll 向Windows子系统发送一个消息,该消息包含了刚建立的进程和线程相关信息;Windows子系统在收到后,开始在子系统内部建立进程环境。
  6. 当Windows子系统已经知道并登记了新进程和线程后,先前挂起的初始线程被允许恢复执行。在内核中,新线程的启动例程是 KiThreadStartup函数,之后经过一系列初始化,一直到该函数返回到用户模式,开始调用函数 Ntdll.LdrinitializeThunk,该函数是PE映像加载器的初始化函数。
  7. 初始化后,加载PE映像中的任何必要的DLL,并调用这些DLL的入口函数。
  8. 最后线程开始在用户模式下执行,它调用用户站中的线程启动函数。开始执行用户空间中的代码。

以上是对进程和线程创建的简单描述(大概了解就好,更详细的参照《Windows内核原理与实现》);接下来的重点是在进程和线程创建中提到的PEBTEB

进程环境块PEB

操作系统会为每个进程设置一个数据结构,用来记录进程的相关信息。该结构就是PEB(Process Environment Block,进程环境块),PEB存在于用户地址空间中,记录了进程的相关信息。

在NT中,PEB位于进程空间的FS:[0x30]处。同时,TEB中的 ProcessEnvironmentBlock 就是PEB结构的地址,其结构的0x30偏移处是一个指向PEB的指针。

因此,访问PEB有俩种方法:

  1. 直接获取:

    mov eax, dword ptr fs:[30h]   ; fs:[30]里存放即是PEB地址
    
  2. 通过TEB获取:

    mov eax, dword ptr fs:[18h]     ;此时eax里为TEB的指针
    mov eax, dword ptr [eax+30h]	;此时eax里为PEB的指针
    

PEB结构(部分):
image-20231226195733857

其中,BeingDebugged成员用于指定该进程是否处于被调试状态,该值为0时进程未处于调试状态,若该值为非零值,则进程处于调试状态。(可以使用Windows API,如IsDebuggerPresentCheckRemoteDebuggerPresent函数来访问该成员)

Ldr字段也是一个很重要的成员,该字段指向的结构记录了进程加载进内存的所有模块的基地址,通过Ldr指向的三个链表就可以找到kernel32.dll的基地址。

线程环境块TEB

**TEB(Thread Environment Block,线程环境块)**同样位于应用层之中。它包含了系统频繁使用的一些与线程相关的数据,进程中的每个线程都有一个自己的TEB。一个进程的所有TEB都存放在从0x7FFDE000开始的线性内存中,每4KB为一个完整的TEB。

在NT中,FS:[0]的地址指向了TEB结构,这个结构的开头是一个NT_TIB结构,具体(部分)如下:

image-20231226201240784

NT_TIB结构的0x18偏移处是一个Self指针,指向这个结构自身,也就是TEB结构的开头。TEB结构的0x30偏移处是一个指向PEB的指针。

在TEB结构的0xE10偏移处有个字段TlsSlots[],是一个无类型的指针数组(TLS存储槽),它的大小是40h字节。也就是说,一个线程同时存在的动态TLS不能超过64项。

可以通过NtCurrentTeb函数调用和FS段寄存器俩种方法来访问TEB结构:

  1. NtCurrentTeb函数调用

    image-20231226202035720

  2. 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函数。

  • TlsAlloc
  • TlsGetValue
  • TlsSetValue
  • TlsFree

应用程序或DLL在合适的时候会调用这四个函数,通过索引对进程中每个线程的存储区进行统一操作。它们位于动态链接库文件kernel32.dll中。

静态线程局部存储

静态线程局部存储预先将变量定义在PE文件内部,一般使用**.tls**节存储,对相关API的调用由操作系统来完成。

在Visual C++中,一般只需要做出如下声明:

_declspec (thread) int tlsFlag = 1;

为了支持这种编程模式,PE的 .tls 节会包含以下信息:

  • 初始化数据
  • 用于每个线程初始化和终止的回调函数
  • TLS索引

注意:通过静态方式定义的TLS数据对象只能用于静态加载的映像文件。这使得在DLL中使用静态TLS数据并不可靠,除非你能确定这个DLL,以及静态链接到这个DLL的其他DLL永远不会被动态家长,如通过调用LoadLibrary这个API函数实施的动态加载那样

TLS的定位

附件随便拿的2023强网杯的babyre.exe。

使用StudyPE查看:

image-20231227095749339

TLS的文件偏移FOA是AB20。使用WinHex找到offset为AB20处就是TLS数据目录项了。

如果是根据RVA和FOA换算(书上的计算过程):

之前学过一点PE头,TLS数据目录项是位于扩展PE头 IMAGE_OPTIONAL_HEADER的最后一个成员DataDirectory;它是一个结构数据,一般有十六个成员,TLS数据目录项就是第十个。

因此很容易就可以找到:

image-20231227100826590

再根据

image-20231227100904984

换算:1BF20-1A000 + 8C00 = AB20

(这里还有点懵)

TLS目录结构IMAGE_TLS_DIRECTORY32

线程局部存储数据结构以数据结构IMAGE_TLS_DIRECTORY32开始。该结构的详细定义如下:

image-20231227101225109

各字段的含义:

  • 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,双字。保留未用。

TLS回调函数

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
};

参数解释:

  • Reserved:预留,为0
  • Reason:调用该回调函数的时机。
  • DllHandle:DLL的句柄

image-20231227102728687

  • 34
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Windows下,C语言可以通过Windows API来实现线程本地存储(Thread-Local Storage,TLS)。以下是一个简单的示例代码来演示如何在Windows下使用C语言实现线程本地存储。 ```c #include <windows.h> // 声明线程本地存储变量 __declspec(thread) int tls_variable; // 线程函数 DWORD WINAPI ThreadFunction(LPVOID lpParam) { // 设置线程本地存储变量的值 tls_variable = 42; // 在线程内部访问线程本地存储变量的值 printf("Thread local variable value: %d\n", tls_variable); return 0; } int main() { HANDLE hThread; // 创建一个线程 hThread = CreateThread(NULL, 0, ThreadFunction, NULL, 0, NULL); if (hThread == NULL) { printf("Failed to create thread\n"); return 1; } // 等待线程结束 WaitForSingleObject(hThread, INFINITE); // 关闭线程句柄 CloseHandle(hThread); return 0; } ``` 在上面的示例代码中,我们使用`__declspec(thread)`关键字来声明一个线程本地存储变量`tls_variable`。在`ThreadFunction`线程函数中,我们设置了线程本地存储变量的值为42,并在线程内部打印了变量的值。 需要注意的是,使用`__declspec(thread)`关键字声明的线程本地存储变量只能是静态或全局变量,不能是局部变量。 编译运行上述代码后,你会看到输出中显示了线程本地存储变量的值为42。这证明了在线程之间,每个线程都有自己独立的线程本地存储空间。 这只是一个简单的示例,实际应用中,你可以根据需要在线程函数中使用线程本地存储存储和访问特定于每个线程的数据。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sciurdae.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值