第65章 线程本地存储TLS

线程本地存储(Thread Local Storage,TLS)是一种在线程内部共享数据的数据交换区域。每个线程都可以在这个区域保存它们要在内部共享的数据。一个比较知名的例子是C语言的全局变量errno。对于errno这类的全局变量来说,如果多线程进程的某一个线程对其进行了修改,那么这个变量就会影响到其他所有的线程。这显然和实际需求相悖,因此全局变量errno必须保存在TLS中。

为解决这个矛盾,C++ 11标准新增了一个限定符thread_local。它能将指定变量和特定的线程联系起来。由它限定的变量能被初始化,并且会被保存在TLS中。

指令清单65.1 C++11

#include <iostream>
#include <thread>

thread_local int tmp=3;

int main()
{
        std::cout << tmp << std::endl;
};

接下来,我们使用MinGW GCC 4.8.1编译它,不要用MSVC 2012进行编译。

在分析可执行文件的PE头之后就会发现,变量tmp被分配到TLS专用的数据保存区域了。

65.1 线性同余发生器(改)

本书第20章展示的随机数生成函数其实有一个瑕疵:在多线程并发运行时,它是不安全的。原因在于:它有一个内部的变量,它可能会同时被不同的线程读取或者修改。

65.1.1 Win32系统

未初始化的TLS数据

我们可以给这种全局变量增加限定符__declspec(thread),这样它就能被分配到TLS中。请注意下述代码的第9行。

 1  #include <stdint.h>
 2  #include <windows.h>
 3  #include <winnt.h>
 4
 5  // from the Numerical Recipes book:
 6  #define RNG_a 1664525
 7  #define RNG_c 1013904223
 8
 9  __declspec( thread ) uint32_t rand_state;
10
11  void my_srand (uint32_t init)
12  {
13           rand_state=init;
14  }
15
16  int my_rand ()
17  {
18           rand_state=rand_state*RNG_a;
19           rand_state=rand_state+RNG_c;
20           return rand_state & 0x7fff;
21  }
22
23  int main()
24  {
25           my_srand(0x12345678);
26           printf ("%d\n", my_rand());
27  };

用MSVC 2013编译上述程序,再用Hiew打开最后生成的可执行文件。我们可以看到这种文件的PE部分出现了全新的TLS段:

指令清单65.2 优化的MSVC 2013 x86

_TLS SEGMENT
_rand_state DD 01H DUP (?)
_TLS ENDS

_DATA SEGMENT
$SG84851 DB     '%d', 0aH, 00H
_DATA ENDS
_TEXT SEGMENT

_init$ = 8                                    ; size = 4
_my_srand PROC
; FS:0=address of TIB
        mov     eax, DWORD PTR fs:__tls_array ; displayed in IDA as FS:2Ch
; EAX=address of TLS of process
        mov     ecx, DWORD PTR __tls_index
        mov     ecx, DWORD PTR [eax+ecx*4]
; ECX=current TLS segment
        mov     eax, DWORD PTR _init$[esp-4]
        mov     DWORD PTR _rand_state[ecx], eax
        ret     0
_my_srand ENDP

_my_rand PROC
; FS:0=address of TIB
        mov     eax, DWORD PTR fs:__tls_array ; displayed in IDA as FS:2Ch
; EAX=address of TLS of process
        mov     ecx, DWORD PTR __tls_index
        mov     ecx, DWORD PTR [eax+ecx*4]
; ECX=current TLS segment
        imul    eax, DWORD PTR _rand_state[ecx], 1664525
        add     eax, 1013904223                         ; 3c6ef35fH
        mov     DWORD PTR _rand_state[ecx], eax
        and     eax, 32767                              ; 00007fffH
        ret     0
_my_rand ENDP

_TEXT ENDS

参数rand_state现在是位于TLS段中。此后每个线程都会拥有各自的rand_state 。这里表示的是如何寻址:从FS:2Ch调用线程信息块(Thread Information Block,TIB)的地址。如果需要,再增加一个额外的索引,最后再计算TLS段的地址。

这种程序的线程可以通过ECX寄存器访问各自的rand_state变量,因为该变量在各个线程的地址不再相同。

FS段选择器并不陌生。它其实就是TIB的专用指针,用于提高线程数据的加载速度。

GS段选择器是Win64程序使用的额外的索引寄存器。

在下面这个程序里,TLS的地址是0x58。

指令清单65.3 优化的MSVC 2013(64位)

_TLS    SEGMENT
rand_state DD   01H DUP (?)
_TLS    ENDS

_DATA   SEGMENT
$SG85451 DB     '%d', 0aH, 00H
_DATA   ENDS

_TEXT   SEGMENT

init$ = 8
my_srand PROC
        mov     edx, DWORD PTR _tls_index
        mov     rax, QWORD PTR gs:88 ; 58h
        mov     r8d, OFFSET FLAT:rand_state
        mov     rax, QWORD PTR [rax+rdx*8]
        mov     DWORD PTR [r8+rax], ecx
        ret     0
my_srand ENDP

my_rand PROC
        mov     rax, QWORD PTR gs:88 ; 58h
        mov     ecx, DWORD PTR _tls_index
        mov     edx, OFFSET FLAT:rand_state
        mov     rcx, QWORD PTR [rax+rcx*8]
        imul    eax, DWORD PTR [rcx+rdx], 1664525         ; 0019660dH
        add     eax, 1013904223                           ; 3c6ef35fH
        mov     DWORD PTR [rcx+rdx], eax
        and     eax, 32767                                ; 00007fffH
        ret     0
my_rand ENDP

_TEXT ENDS

初始化的TLS数据

编程人员通常会想给变量rand_state设置一个固定的初始值,以防后期忘记对它进行初始化。如下述代码第9行所示,我们对它进行初始化赋值。

 1  #include <stdint.h>
 2  #include <windows.h>
 3  #include <winnt.h>
 4
 5  // from the Numerical Recipes book:
 6  #define RNG_a 1664525
 7  #define RNG_c 1013904223
 8
 9  __declspec( thread ) uint32_t rand_state=1234;
10
11  void my_srand (uint32_t init)
12  {
13         rand_state=init;
14  }
15
16  int my_rand ()
17  {
18         rand_state=rand_state*RNG_a;
19         rand_state=rand_state+RNG_c;
20         return rand_state & 0x7fff;
21  }
22
23  int main()
24  {
25         printf ("%d\n", my_rand());
26  };

以上的代码看起来并没有什么不同,但是在IDA下我们可以发现:

.tls:00404000 ; Segment type: Pure data
.tls:00404000 ; Segment permissions: Read/Write
.tls:00404000 _tls              segment para public 'DATA' use32
.tls:00404000                   assume cs:_tls
.tls:00404000                   ;org 404000h
.tls:00404000 TlsStart          db    0              ; DATA XREF: .rdata:TlsDirectory
.tls:00404001                   db    0
.tls:00404002                   db    0
.tls:00404003                   db    0
.tls:00404004                   dd 1234
.tls:00404008 TlsEnd            db    0              ; DATA XREF: .rdata:TlsEnd_ptr
...

我们要关注的是这里显示的数1234。每当启动新的线程时,它都会分配一个新的TLS段。此后,包括1234在内的所有数据都会被复制到新建都TLS段。

一个典型的应用场景是:

TLS回调

如果TLS中的变量必须填充为某些数据,并且是以某些不寻常的方式进行的话,该怎么办呢?比如说,我们有以下的这个任务:编程人员忘记调用my_srand()函数来初始化随机数发生器(PRNG),而随机数发生器只有在正确初始化之后才会生成真实意义上的随机数,而不是1234这样的固定值。在这种情况下,可以采用TLS回调。

在采用这种hack之后,本例代码的可移植性(通用性)就变差了。毕竟,本例只是一个演示性质的敲门砖而已。它只是构造一个在进程/线程启动前就被系统调用的回调函数(tls_callback())。这个回调函数用GetTickCount()函数的返回值来初始化随机数发生器(PRNG)。

#include <stdint.h>
#include <windows.h>
#include <winnt.h>

// from the Numerical Recipes book:
#define RNG_a 1664525
#define RNG_c 1013904223

__declspec( thread ) uint32_t rand_state;

void my_srand (uint32_t init)
{
        rand_state=init;
}

void NTAPI tls_callback(PVOID a, DWORD dwReason, PVOID b)
{
        my_srand (GetTickCount());
}

#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK p_thread_callback = tls_callback;
#pragma data_seg()

int my_rand ()
{
        rand_state=rand_state*RNG_a;
        rand_state=rand_state+RNG_c;
        return rand_state & 0x7fff;
}

int main()
{
        // rand_state is already initialized at the moment (using GetTickCount())
        printf ("%d\n", my_rand());
};

我们在IDA中查看一下,代码如下所示。

指令清单65.4 优化的MSVC 2013

.text:00401020 TlsCallback_0    proc near              ; DATA XREF: .rdata:TlsCallbacks
.text:00401020                  call     ds:GetTickCount
.text:00401026                  push     eax
.text:00401027                  call     my_srand
.text:0040102C                  pop      ecx
.text:0040102D                  retn     0Ch
.text:0040102D TlsCallback_0    endp

...

.rdata:004020C0 TlsCallbacks    dd offset TlsCallback_0 ; DATA XREF: .rdata:TlsCallbacks_ptr

...

.rdata:00402118 TlsDirectory    dd offset TlsStart
.rdata:0040211C TlsEnd_ptr      dd offset TlsEnd
.rdata:00402120 TlsIndex_ptr    dd offset TlsIndex
.rdata:00402124 TlsCallbacks_ptr dd offset TlsCallbacks
.rdata:00402128 TlsSizeOfZeroFill dd 0
.rdata:0040212C TlsCharacteristics dd 300000h

在解压过程中使用TLS回调函数,可起到混淆视听的作用。经验不足的分析人员通常会感到晕头转向,无法在分析原始入口/OEP之前就已经运行的回调函数。

65.1.2 Linux系统

我们来看看在GCC下是如何定义线程本地的全局变量的:

__thread uint32_t rand_state=1234;

当然,这不是标准的C/C++修饰符,而是GCC的专用修饰符。

GS段选择器也常用于TLS寻址,但是Linux的实现方法和Windows略有不同。

指令清单65.5 x86下的优化GCC 4.8.1

.text:08048460 my_srand         proc near
.text:08048460
.text:08048460 arg_0            = dword ptr 4
.text:08048460
.text:08048460                  mov      eax, [esp+arg_0]
.text:08048464                  mov      gs:0FFFFFFFCh, eax
.text:0804846A                  retn
.text:0804846A my_srand         endp

.text:08048470 my_rand          proc near
.text:08048470                  imul     eax, gs:0FFFFFFFCh, 19660Dh
.text:0804847B                  add      eax, 3C6EF35Fh
.text:08048480                  mov      gs:0FFFFFFFCh, eax
.text:08048486                  and      eax, 7FFFh
.text:0804848B                  retn
.text:0804848B my_rand          endp

更多的信息可以查看参考书目Dre13。