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

发布时间:2023年12月27日

线程局部存储

线程局部存储(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

文章来源:https://blog.csdn.net/Sciurdae/article/details/135246220
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。