Rabbit Threads:让线程跳跃






4.96/5 (34投票s)
使用内联汇编强制线程执行上下文外的代码。
- 下载源代码 - 示例 1 - 4.66 KB
- 下载源代码 - 示例 2 - 3.63 KB
- 下载源代码 - 示例 3 - 4.8 KB
- 下载源代码 - 示例 4 - 4.85 KB
- 下载源代码 - 示例 5 - 4.98 KB
引言
对多线程编程的深刻理解对于成功的Windows编程至关重要。虽然微软通过MSDN主题(如进程和线程 [1])提供了标准线程编程的信息,但有时需要更多信息,以便能够利用非过程式编程技术。
Jeffrey Richter 讨论了何时以及为何创建线程,以及何时不创建线程 [2]。本文将讨论如何滥用线程,同时探讨与线程相关的操作系统数据结构。滥用将被称为“Rabbit Threads”(一个由Peter Szor引入的新词),灵感来源于“兔子”(跳跃)一词,用来描述一种寄宿在宿主上的计算机病毒,以替代另一种。
定义
以下是本文中使用的术语的简要摘要。参加过数学会议的人应该都能欣赏其简洁性。
程序
程序是磁盘上的静态进程表示。当程序被执行时,它就变成了一个进程 [3]。Russinovich 和 Solomon 在《Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP, and Windows 2000》 [4] 中详细讨论了进程创建。
进程
Jeffrey Richter 将进程描述为运行程序的实例 [5]。每个进程提供执行程序所需的资源。进程具有虚拟地址空间、可执行代码、系统对象的开放句柄、安全上下文、唯一的进程标识符、环境变量、优先级类、最小和最大工作集大小,以及至少一个执行线程 [6]。
线程
线程是进程中可由操作系统调度的实体 [7]。每个线程维护用户和内核堆栈区域、异常处理程序、调度优先级、线程局部存储、唯一的线程标识符以及系统用于保存线程上下文直到调度的结构集 [8]。在 x86 Windows NT 上,内核堆栈大小为 12 KB,而 x64 平台则享有 24 KB 的内核堆栈 [9]。
纤程
纤程是必须由应用程序手动调度的执行单元。纤程在调度它们的线程的上下文中运行 [10]。Windows 添加了纤程以协助将 UNIX 服务器应用程序移植到 Windows [21]。
PCB
进程控制块或内核进程块。PCB 是一个 `KPROCESS` 结构,它是 `EPROCESS` 数据结构的一部分。该结构由内核用于调度线程。
EPROCESS
`Executive Process Block` 是表示进程的 Windows 内核数据结构 [11]。可以通过 `PsLookupProcessByProcessId` 函数访问此结构 [12]。
ETHREAD
`Executive Thread Block` 是表示线程的 Windows 内核数据结构 [13]。可以通过 `PsLookupProcessByThreadId` 函数访问此结构 [14]。
PEB
进程环境块包含环境变量 [15] 和 TLS(线程局部存储)数组 [16] 等信息。但是,由于 PEB 包含可能被程序修改的信息,因此它位于进程地址空间 [17]。可以通过 `EPROCESS` 块获取指向 PEB 的指针。
TEB
线程环境块是 ETHREAD 数据结构的一部分 [15]。由于 TEB 包含可能被程序修改的信息,因此它位于进程地址空间 [16]。
WinDbg
将使用 WinDbg 和 kd 来研究内核如何管理线程。需要像 windbg 或 kd 这样的内核调试器,因为需要查看用户区域中不可用的对象和结构。由于这些是内核对象,Visual Studio 无法解释它们的数据。此外,Visual Studio 2005 无法很好地处理接下来的技术。最后,该方法会导致 IDA Pro 在构建调用图进行分析时出现“sp-analysis failed”错误。
WinDbg 的基本用法已在《Windows PE 校验和算法分析》 [19] 中介绍。下面将扩展讨论。要检查与进程和线程相关的内核对象,请启动 WinDbg。接受默认参数,因为它们不影响本地调试会话。在单击“确定”**之前**,选择“**本地**”选项卡。否则,WinDbg 将尝试在 COM 端口上建立连接。请参阅图 2 和图 3。请注意,启动内核调试会话将允许使用调试器。否则,必须加载一个可执行文件才能访问命令窗口。
|
|
|
图 2:启动内核调试会话
|
图 3:本地调试
|
本文感兴趣的结构是 `EPROCESS` 和 `ETHREAD`。要查看 `ETHREAD` 结构,请发出 `dt _ethread` 命令。`dt` 是显示类型命令。请参阅图 4。
成员左侧的值是成员的十六进制偏移量。一些成员(如 `Tcb`)是结构。要递归显示子结构,请发出带有递归的相同命令:`dt -r _ethread`。要指定递归级别,请为开关提供一个参数。例如,`-r1` 或 `-r3`。请参阅图 5。
多线程编程
下面的示例是一个典型的多线程程序。该程序创建一个简单的退出工作线程。它还演示了基本的同步以及正确释放获取的线程资源句柄。这将为研究线程行为提供一个基准。可以在 MSDN 中找到 `CretateThread()` 的文档 [17]。
对于那些对 WinDbg 探索不感兴趣或已经理解基本示例的人,请跳至下面的“Rabbit Threads 2”。
int main( ) {
HANDLE hWorkerThread = NULL;
DWORD dwWorkerThreadID = 0;
hWorkerThread = CreateThread( NULL, 0,
reinterpret_cast< LPTHREAD_START_ROUTINE >( ThreadProc ),
NULL, CREATE_SUSPENDED, &dwWorkerThreadID );
// Sanity Check
if( hWorkerThread == NULL ) { return -1; }
// Start Worker
ResumeThread( hWorkerThread );
// Synchronization
WaitForSingleObject( hWorkerThread, INFINITE );
// Cleanup
CloseHandle( hWorkerThread );
return 0;
}
以及相应的 worker 线程过程
DWORD WINAPI ThreadProc( LPVOID lpParameter ) {
return 0;
}
概念上,程序流程可以如图 6 所示。
Rabbit Threads 1
尽管示例一很简单,但它将用于熟悉 WinDbg 下的活动目标,并为后续示例提供背景信息。
要在 WinDbg 中检查程序流程,请从“文件”菜单中打开可执行文件。接下来,在 `main` 上设置断点以跳过非必要的初始化。要确定正确的函数,请发出 `dt RabbitThreads!*main*` 命令。从图 7 可以看出,断点应设置在 `wmain`,因此请发出 `bp RabbitThreads!wmain` 命令。或者,也可以发出 `bp 0x0041000` 或 `bp wmain`。
最后,可以在线程入口点设置断点,如下所示
0:000> bp wmain
0:000> bp ThreadProc
0:000> bl
0 e 00401000 0001 (0001) 0:**** RabbitThreads!wmain
1 e 00401540 0001 (0001) 0:**** RabbitThreads!ThreadProc
按下 **F5** 或在命令窗口键入 '`g`' 将使程序运行直到达到 `wmain` 处的断点。一旦断点触发,WinDbg 还会显示源代码(如果可用)。请参阅图 8。
PEB
在程序执行停止在 `wmain()` 处后,发出 `!peb` 命令以查看进程环境块。图 9 是从**命令浏览器**()发出的命令的输出。
要获得带偏移量的详细输出,可以发出 `dt -r _PEB 0x7FFDC000` 命令来转储 0x7FFDF000 处的内存作为进程环境块(参见图 10)。
TEB
可以使用 `!teb` 命令检查线程环境块。注意 `Self` 字段指定了 0x7FFDF000。请参阅图 11。
要查看原始内存,请发出 `dt -r _TEB 0x7FFDF000` 命令。请参阅图 12。
重新启动程序并在线程创建后中断,会存在两个线程:主线程(执行 `wmain()`)和工作线程(执行 `ThreadProc()`)。线程状态命令 ('~') 显示与因断点而挂起的两个线程相关的信息。请注意,第一个线程左侧有一个点(.)。这表示活动线程,因此发出影响线程的命令将使用线程 0x84C。这称为线程上下文。此外,上下文将在命令窗口提示符处显示:0:000> 表示线程 0,而 0:001> 表示线程 1。请参阅图 13。
0x8F4 (2292) 是进程 ID。第一个线程的 ID 是 0xDE4,第二个线程的 ID 是 0x84C。使用进程资源管理器(如下所示,图 14)可以确认这一点。请注意,0x84C = 2124,而 0xDE4 = 3556。在观察各种内核结构中的 PID 和 TID 时,它们在内部被称为 ClientID (CID)。根据 Russinovich 和 Solomon 的说法,这是因为使用相同的命名空间来生成 PID 和 TID [20]。
此时,发出 `!handle` 命令会显示进程的句柄表(图 15)。
在执行 `main()` 的线程调用 `CloseHandle( hWorkerThread )` 后,工作线程的引用计数将减至 0,允许操作系统回收资源。请参阅图 16。
Rabbit Thread 2
第二个示例将演示一个单线程应用程序,它通过 `push`/`ret` 对来访问下一条指令。`push`/`ret` 对由下面的循环表示。该对保持堆栈平衡,因此不需要额外的调整。概念上,图 17 描绘了程序流程。
下面是带有内联汇编的 C++ 源代码,它完成了任务。`LOCATION` 解析为一个地址。当编译器遇到标签 `LOCATION` 时,其名称(LOCATION)和地址将被添加到代码生成表中。
int main( )
{
DWORD dwProcessID = 0;
DWORD dwPrimaryThreadID = 0;
DWORD dwLocation = NULL;
dwProcessID = GetCurrentProcessId();
cout << _T("Process ID = 0x");
HEXADECIMAL_OUTPUT(4);
std::tcout << dwProcessID << endl;
dwPrimaryThreadID = GetCurrentThreadId();
cout << _T("Primary thread ID = 0x");
HEXADECIMAL_OUTPUT(4);
cout << dwPrimaryThreadID << endl;
__asm {
push eax
mov eax, LOCATION
mov dwLocation, eax
pop eax
}
cout << _T("Target return address = 0x");
HEXADECIMAL_OUTPUT(8);
cout << dwLocation << endl;
__asm {
push LOCATION
ret
}
LOCATION:
return 1;
}
使用带有 `GetThreadContext()` 和 `SetThreadContext()` 的 `CONTEXT` 结构比使用标签进行内联汇编要困难得多。在获取或设置上下文时,线程必须被挂起。此外,使用这些函数没有保障,因为通过使用 `CONTEXT` 结构而隐式地放弃了可移植性。
下面的汇编代码不是程序执行所必需的。它是必需的,因为当尝试将 `LOCATION` 直接发送到 `cout` 时,编译器会发出 C2451 错误。
__asm {
push eax
mov eax, LOCATION
mov dwLocation, eax
pop eax
}
图 18 显示了使用 WinDbg 验证程序执行的正确性。右上角的窗口是源代码窗口。在调试器下,内联汇编的 `ret` 指令将是下一个要执行的指令。下方的命令窗口显示程序在控制转移后将开始执行的地址(0x004011C7)。最后一个窗口是反汇编窗口。请注意,从反汇编窗口可以看出,程序在 0x004011C7 位置开始准备返回值为 1。
IDA Pro 在分析程序流程时成功地对其进行了图形化处理。但是,IDA Pro 错误地将代码段标记为 `loc_4011C9`,而不是 `loc_4011C7`。在图 19 中,较大的节点是 `main()`,较小的节点是等同于 `return 1` 的代码。右侧图示是对代码的不当标记。
|
|
|
图 19:IDA Pro 分析
|
Rabbit Threads 3
第三个示例结合了前两个示例。该示例将演示一个工作线程“跳跃”到主线程的代码来执行。它还将通过主线程的代码(通过 `main()`)退出,而不是通过右侧所示的自己的 `ThreadProc()`。概念上,这在图 20 中进行了展示。
int main( )
{
HANDLE hWorkerThread = NULL;
DWORD dwProcessID = 0;
DWORD dwPrimaryThreadID = 0;
DWORD dwWorkerThreadID = 0;
DWORD dwExitArea = NULL;
__asm {
push eax
mov eax, EXITAREA
mov dwExitArea, eax
pop eax
}
hWorkerThread = CreateThread( NULL, 0,
reinterpret_cast< LPTHREAD_START_ROUTINE >( ThreadProc ),
reinterpret_cast< LPVOID> ( &dwExitArea ),
CREATE_SUSPENDED, &dwWorkerThreadID );
// Sanity Check
if( hWorkerThread == NULL ) { return -1; }
// Start Worker
ResumeThread( hWorkerThread );
// Synchronization
WaitForSingleObject( hWorkerThread, INFINITE );
// Cleanup
CloseHandle( hWorkerThread );
EXITAREA:
return 1;
}
此代码中最值得注意的添加是在调用 `CreateThread()` 之前确定执行位置的内联汇编。这是必需的,因为位置作为参数传递给工作线程的 `ThreadProc`。为了符合 `ThreadProc` 的原型,正在将位置的指针传递给 `ThreadProc`,而不是位置本身。下面是工作线程的代码
DWORD WINAPI ThreadProc( LPVOID lpParameter )
{
DWORD dwLocation = NULL;
if( NULL == lpParameter ) { return -2; }
dwLocation = * ( reinterpret_cast< DWORD* > ( lpParameter ) );
__asm {
push dwLocation
ret
}
return 1;
}
代码的执行情况符合预期——工作线程在 `ret` 之后开始执行位于 `main()` 中的代码。下面是 IDA 中 `main()` 的图形。为了便于可视化,添加了额外的标签。同样,也发生了一个轻微的错误标记。请参阅图 21。
IDA Pro 声称在生成 `ThreadProc`(图 22)的图形时 SP-Analysis 失败。
Rabbit Threads 4
第四个演示将在第三个演示的基础上进行,以便工作线程在“轻微放荡”后使用 `ThreadProc` 退出。当工作线程在 `main()` 中时,它会打印一条消息说明这一点,然后“跳回”到自己的代码上下文。其描述如图 22 所示。
下面是主线程的代码。有一个添加:一个测试以确定当前执行的是哪个线程。如果当前执行线程是工作线程,则执行 `ret`。
if( GetCurrentThreadId() == dwWorkerThreadID )
{
_asm ret
}
返回将在 `ThreadProc` 中进行详细检查。
int main( )
{
HANDLE hWorkerThread = NULL;
DWORD dwProcessID = 0;
DWORD dwPrimaryThreadID = 0;
DWORD dwWorkerThreadID = 0;
DWORD dwCurrentThreadID = 0;
DWORD dwLandingArea = NULL;
__asm {
push eax
mov eax, LANDINGAREA
mov dwLandingArea, eax
pop eax
}
hWorkerThread = CreateThread( NULL, 0,
reinterpret_cast< LPTHREAD_START_ROUTINE >( ThreadProc ),
reinterpret_cast< LPVOID> ( &dwLandingArea ),
CREATE_SUSPENDED, &dwWorkerThreadID );
// Sanity Check
if( hWorkerThread == NULL ) { return -1; }
// Start Worker
ResumeThread( hWorkerThread );
// Synchronization
WaitForSingleObject( hWorkerThread, INFINITE );
// Cleanup
CloseHandle( hWorkerThread );
LANDINGAREA:
dwCurrentThreadID = GetCurrentThreadId();
cout << _T("Executing main function thread ID = 0x");
HEXADECIMAL_OUTPUT(4);
cout << dwCurrentThreadID << endl;
if( GetCurrentThreadId() == dwWorkerThreadID )
{
_asm ret
}
return 1;
并且,下面显示了相应的 `ThreadProc` 函数。为了准备在 `main()` 中执行 `ret`,在工作线程仍处于 `ThreadProc` 时,将期望的返回地址推送到工作线程的堆栈上。
DWORD WINAPI ThreadProc( LPVOID lpParameter )
{
DWORD dwExitAddress = NULL;
if( NULL == lpParameter ) { return -2; }
dwExitAddress = * ( reinterpret_cast< DWORD* > ( lpParameter ) );
__asm {
push RETURNAREA
push dwExitAddress
ret
}
RETURNAREA:
DWORD dwCurrentThreadID = GetCurrentThreadId();
cout << _T("Exiting worker function thread ID = 0x");
HEXADECIMAL_OUTPUT(4);
cout << dwCurrentThreadID << endl;
return 2;
}
图 23 是运行示例四的结果。请注意,两个线程都通过 `main()` 退出。问题在于 `dwWorkerThread`。`dwWorkerThread` 是一个内存位置。编译器生成的代码隐式地期望该位置在主线程上下文中处于某个**相对**地址。工作线程使用工作线程的堆栈检索了 `dwWorkerThread` 的值,结果证明这是一个错误的值。
图 24 显示了导致程序结果不正确的生成代码。
根据 WinDbg,`dwWorkerThread` 位于 EBP-8 的相对基地址。如果堆栈是主线程的堆栈,则情况就是如此。转储 EBP-8 处的线程内存会显示值为 0x0050FFAC。取消引用该值得到 004012D8。这显然不是线程 ID——它似乎是进程空间中的一个地址。为了完整起见,对 0x004012D8 进行了第二次取消引用,结果是 300015FF。请参阅图 25。
进一步调查表明,0x004012D8 是指令序列 FF1500304000 的第一个字节—— `call dword ptr [GetCurrentThreadId]`。
为了解决这个问题,测试将被更改为以下内容。尽管如此,仍有很小的可能性会产生不正确的结果。然而,不像之前的测试那样经常产生不正确的结果——之前的测试几乎每次都会返回不正确的结果。
if( GetCurrentThreadId() != dwPrimaryThreadID )
另一个解决方案可以是使 `dwWorkerThread` 成为一个全局变量。但是,正如计算机科学的大一新生所知道的,全局变量是一种不恰当的解决方案。
出于与 `dwWorkerThread` 不正确相同的原因,以下代码会错误地覆盖工作线程堆栈中的一个值。这是因为 `dwCurrentThread` 将基于为主线程生成的代码(相对于主线程的 EBP)。
dwCurrentThread = GetCurrentThreadId();
if( dwCurrentThread != dwPrimaryThreadID )
...
图 26 显示了修改后的代码结果。请注意,每个线程都声称“正在执行 main 函数”,并且工作线程从其过程退出,主线程通过 `main` 退出。
与前面的示例一样,IDA 未能完成 `main` 的可视化。此外,标签本应是 `loc_4013F9` 而不是 `loc_4013FE`。`ThreadProc` 也发生了类似的情况。
Rabbit Threads 5
示例 5 通过提供线程 ID 的正确值来消除示例 4 的歧义。它通过镜像主线程的堆栈帧来实现这一点。请注意,在比较帧时,变量的值会有所不同(例如返回地址)。重要的是局部变量的分配。
DWORD WINAPI ThreadProc( LPVOID lpParameter ) { HANDLE hWorkerThread = NULL; // Mirroring Stack Frame Structure DWORD dwProcessID = 0; // of the code which executes main() DWORD dwPrimaryThreadID = 0; // DWORD dwWorkerThreadID = 0; // DWORD dwCurrentThreadID = 0; // DWORD dwLandingArea = NULL; // // The only variables of interest // for this thread DWORD dwExitAddress = NULL; dwWorkerThreadID = GetCurrentThreadId(); ... }
在镜像帧就位后,可以执行之前的测试并确信结果。
if( GetCurrentThreadId() == dwWorkerThreadID )
{
asm ret
}
使用此方法有一个警告:编译器不会按变量声明的顺序预留堆栈空间。图 28 下面演示了此警告。请注意,变量按声明的顺序初始化,但 RVAs 并非按此顺序:0x12FF64、0x12FF70、0x12FF68。
图 29 代表 WinDbg 下的可执行文件。WinDbg 显示相对基址寻址,这充分说明了问题。同样,变量按声明的顺序初始化,但在线程的堆栈上并非按声明的顺序排列。
编译器分配问题意味着程序员必须了解分配策略,并在调试器或符号工具下验证结果。
下载次数
- 下载源代码 - 示例 1 - 4.66 KB
- 下载源代码 - 示例 2 - 3.63 KB
- 下载源代码 - 示例 3 - 4.8 KB
- 下载源代码 - 示例 4 - 4.85 KB
- 下载源代码 - 示例 5 - 4.98 KB
致谢
- Ken Johnson, Microsoft MVP
修订
- 2007 年 11 月 14 日 - 扩展了 PEB 信息。
- 2007 年 11 月 7 日 - 添加了示例 5。
- 2007 年 11 月 1 日 - 首次发布。
参考文献
- Microsoft 网站,进程和线程,访问于 2007 年 10 月。
- J. Richter, Programming Applications for Microsoft Windows, 4ed., Microsoft Press, 1005, pp. 182-184, ISBN 1-5723-1996-8。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 6, ISBN 0-7356-1917-4。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 6, ISBN 0-7356-1917-4。
- J. Richter, Programming Applications for Microsoft Windows, 4ed., Microsoft Press, 2005, p. 69, ISBN 1-5723-1996-8。
- Microsoft 网站,关于进程和线程,访问于 2007 年 10 月。
- Microsoft 网站,多线程,2007 年 10 月。
- Microsoft 网站,关于进程和线程,访问于 2007 年 10 月。
- Microsoft 网站,如何防止我的驱动程序耗尽内核模式堆栈?,访问于 2007 年 10 月。
- Microsoft 网站,纤程,访问于 2007 年 10 月。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 289, ISBN 0-7356-1917-4。
- Microsoft 网站,`PsLookupProcessByProcessId`,访问于 2007 年 10 月。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 289, ISBN 0-7356-1917-4。
- Microsoft 网站,`PsLookupThreadByThreadId`,访问于 2007 年 10 月。
- Microsoft 网站,环境变量,访问于 2007 年 10 月。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 291, ISBN 0-7356-1917-4。
- Microsoft 网站,`CreateThread` 函数,访问于 2007 年 10 月。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 289, ISBN 0-7356-1917-4。
- J. Walton, Windows PE 校验和算法分析,访问于 2007 年 10 月。
- M. Russinovich and D. Solomon, Microsoft Windows Internals, Fourth Edition: Windows Server 2003, Windows XP and Windows 2000, Microsoft Press, 2005, p. 12, ISBN 0-7356-1917-4。
- J. Richter, Programming Applications for Microsoft Windows, 4ed., Microsoft Press, 2005, p. 417, ISBN 1-5723-1996-8。