0x01 线程的创建
- 线程不同于进程,Windows 中的进程是拥有 ‘惰性’ 的,本身并不执行任何代码,而执行代码的任务转交给主线程,列如使用 CreateProcess 创建一个进程打开 Cmd 程序,实际上是这个进程的主线程去执行打开 Cmd 程序的任务,也就是说创建一个进程必然有一个主线程与之对应
- 当然 Windows 下也可以也使用 CreateThread 创建属于当前进程或者线程的额外线程,返回值是一个线程句柄,示例程序如下图所示
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <process.h>
using namespace std;
// 用于线程调用的函数
DWORD WINAPI ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
DWORD ThreadId = NULL;
HANDLE MyThread1 = CreateThread(
NULL, // 第一个参数表示传入的用于设置安全标志的结构体对象
NULL, // 第二个参数用于设置线程运行时申请多大的堆栈空间
ThreadFun1, // 第三个参数是线程调用的函数名称
NULL, // 第四个参数是传递给调用函数的参数
NULL, // 第五个参数是设置如何执行线程
&ThreadId // 第六个参数返回线程 ID
);
// 等待线程
WaitForSingleObject(MyThread1, INFINITE);
return 0;
}
DWORD WINAPI ThreadFun1(PVOID pvParam)
{
cout << "ThreadFun1 is Start" << endl;
return 0;
}
- 值得注意的是 CreateThread 的第二个参数会设置线程运行时堆栈的大小(因为每一个线程都有独立的空间供线程使用),如果无需特殊设置,可以将第二个参数设置为 NULL,表示默认分配 1MB 大小的堆栈空间,如果在运行过程中超出了 1MB 大小,系统也会分配额外的空间,所以不必担心内存泄漏的问题
- 第三个参数就是线程的调用函数,且函数的类型必须是 DWORD WINAPI ThreadFun1(PVOID pvParam),WINAPI 就等于 __stdcall,为什么一定要以这种方式调用呢,原因是 CreateThread 内部维护了一个函数指针指向了 ThreadFun1
DWORD (__stdcall * PTHREAD_START_ROUTINE) (LPVOID pvParam)
提示:__stdcall 是函数的一种调用方式,用于规定函数调用前后如何平衡堆栈,大多数的 Windows 内核函数都会使用 __stdcall,也就是 WINAPI
- 第五个参数表示如何执行线程,如果设置为 NULL,则立刻执行
DWORD ThreadId = NULL;
HANDLE ThreadId = CreateThread(NULL, NULL, ThreadFun1, NULL, NULL, &ThreadId);
- 如果设置为 CREATE_SUSPENDED,表示先将线程挂起,之后调用 ResumeThread 函数执行,这也是挂起线程的通常做法
DWORD ThreadId = NULL;
HANDLE ThreadId = CreateThread(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, &ThreadId);
ResumeThread(MyThread1);
提示:CREATE_SUSPENDED 的最终目的就是将线程的内核计数 +1,而 ResumeThread 则是将线程的内核计数 -1,这样的话就相当于线程挂起后执行。也可以多次挂起,再多次调用 ResumeThread
- 至于为什么在最后调用 WaitForSingleObject 来等待,是因为线程不同于进程,虽然说线程有独立的空间,但是线程的所有空间都是在进程地址空间上分配的,属于相对独立而已。如果线程在 main 函数之前没有执行完那么线程的所有空间都会被回收,相当于判处线程死刑且立即执行,而用 WaitForSingleObject 的好处是保证线程能够完整的执行后再从 main 函数返回
0x02 线程终止
- 假如正在运行的线程由于 IO 操作时间过长或者由于某种未知原因导致死锁,那么可以选择将线程终止,一般不提倡这种做法,因为会导致内存泄漏,比如线程中的类构造函数动态申请了堆空间,但是由于终止了线程导致类析构函数无法执行,这样堆空间得不到及时释放,给后面的利用埋下了安全隐患
- 终止线程非常简单,通常利用两个函数即可完成,ExitThread 和 TerminateThread 函数,两者的不同在于 ExitThread 只可以终止本线程(除了返回错误代码以外没有其他参数),而 TerminateThread 可以终止任何线程
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <process.h>
using namespace std;
// 用于线程调用的函数
DWORD WINAPI ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
DWORD ThreadId = NULL;
HANDLE MyThread1 = CreateThread(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, &ThreadId);
ResumeThread(MyThread1);
// 终止线程
TerminateThread(MyThread1, NULL);
// 等待线程
WaitForSingleObject(MyThread1, INFINITE);
return 0;
}
DWORD WINAPI ThreadFun1(PVOID pvParam)
{
// 终止线程
ExitThread(NULL); // 示例程序而已
cout << "ThreadFun1 is Start" << endl;
return 0;
}
- 所以为了保证程序发生不必要的 Bug,尽量不要使用这两个函数终止线程
0x03 查询线程存活状态
- 通常情况下查询线程是否终止可以使用 GetExitCodeThread 函数,根据返回的第二个参数即可判断,示例如下所示,其中调用了 ErrorCodeTransformation 函数,这个函数是根据错误代码打印出详细的错误信息,方便检查出程序的错误,在进行 Windows 核心编程时应该经常使用
- 链接:https://docs.microsoft.com/zh-cn/windows/desktop/api/processthreadsapi/nf-processthreadsapi-getexitcodethread
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <process.h>
#include <strsafe.h>
using namespace std;
DWORD WINAPI ThreadFun1(PVOID pvParam);
DWORD WINAPI ThreadFun2(PVOID pvParam);
// 全局句柄,用于线程间共享
HANDLE MyThread1; HANDLE MyThread2;
// 全局变量,表示某一个线程的运行状态
DWORD WaitFile;
// 将错误代码打印为错误信息,这个函数非常有用
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);
int main(int argc, char *argv[])
{
// 创建两个线程
MyThread1 = CreateThread(NULL, NULL, ThreadFun1, NULL, NULL, NULL);
MyThread2 = CreateThread(NULL, NULL, ThreadFun2, NULL, NULL, NULL);
// 等待两个线程执行完毕
HANDLE Threads[2] = { 0 };
Threads[0] = MyThread1; Threads[1] = MyThread2;
WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
// 关闭句柄
CloseHandle(MyThread1); CloseHandle(MyThread2);
return 0;
}
DWORD WINAPI ThreadFun1(PVOID pvParam)
{
while (true)
{
// 每隔 200 毫秒,调用 GetExitCodeThread 显示函数运行状态
Sleep(200);
if (GetExitCodeThread(MyThread2, &WaitFile))
{
// STILL_ACTIVE 表示线程尚未终止
if (WaitFile == STILL_ACTIVE)
{
cout << "程序尚未终止" << endl;
}
else
{
// 进程终止就结束 while 循环
cout << "线程已经终止" << endl;
break;
}
}
else
{
// GetExitCodeThread 调用失败就打印具体错误信息
DWORD res = GetLastError();
ErrorCodeTransformation(res);
}
}
return TRUE;
}
DWORD WINAPI ThreadFun2(PVOID pvParam)
{
// 随眠 3 秒
Sleep(3000);
return TRUE;
}
// 如果返回错误,可调用此函数打印详细错误信息
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode)
{
LPVOID lpMsgBuf; LPVOID lpDisplayBuf; DWORD dw = ErrorCode;
// 将错误代码转换为错误信息
FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&lpMsgBuf, 0, NULL
);
lpDisplayBuf = (LPVOID)LocalAlloc(LMEM_ZEROINIT, (lstrlen((LPCTSTR)lpMsgBuf) + 40) * sizeof(TCHAR));
StringCchPrintf((LPTSTR)lpDisplayBuf, LocalSize(lpDisplayBuf), TEXT("错误代码 %d : %s"), dw, lpMsgBuf);
// 弹窗显示错误信息
MessageBox(NULL, (LPCTSTR)lpDisplayBuf, TEXT("Error"), MB_OK);
LocalFree(lpMsgBuf); LocalFree(lpDisplayBuf); ExitProcess(dw);
}
- 运行起来的效果就像这样
0x03 关于线程安全
- 因为 C 运行库函数最初不是为了多线程设计的,所以在使用一些 C 运行库全局变量时应该注意任何线程都可以修改全局变量(比如 errno),在单线程情况下肯定没有问题,但是多线程就会出现混乱,比如一个线程前脚刚设置了 errno 准备查看,后脚就被另外一个线程改了
- 这个就是 C/C++ 线程安全的由来,解决的方法就是为每个线程都分配一个独立的 C 运行库全局变量空间,当然这么复杂的工作并不需要我们来做,使用线程安全函数 _beginthreadex 就可以,真的是很方便,这个函数在内部会自动分配 C 运行库全局变量,分配完之后再调用 CreateThread 创建线程,所以以后创建线程只需要用 _beginthreadex 就足够了
- 示例如下,包含的头文件为 process.h,除了参数类型不一样,其他的包括参数类别和参数顺序与使用 CreateThread 是一模一样的,美中不足的是将线程调用函数的返回值由 DWORD 变成了 unsigned
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
unsigned WINAPI ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
// 用于接收线程的 ID
DWORD ThreadId;
HANDLE MyThread = (HANDLE)_beginthreadex(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, (unsigned *)&ThreadId);
ResumeThread(MyThread);
//_endthreadex(); // 如果需要终止线程使用 _endthreadex 即可,该函数内部会释放申请的 C 运行库全局变量空间
// 等待线程执行完毕
WaitForSingleObject(MyThread, INFINITE);
// 关闭句柄
CloseHandle(MyThread);
return 0;
}
unsigned WINAPI ThreadFun1(PVOID pvParam)
{
cout << "ThreadFun1 is start" << endl;
return TRUE;
}
- 其实 _beginthreadex 和 _endthreadex 函数还有一个相对简单的版本叫 _beginthread 和 _endthread;区别是 _beginthread 函数不可以返回线程 ID,也不可以设置安全标志(如继承等),_endthread 不可以返回线程退出代码,总之差别很大,示例如下
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
void __cdecl ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
// 用于接收线程的 ID
DWORD ThreadId;
HANDLE MyThread = (HANDLE)_beginthread(ThreadFun1, NULL, NULL);
ResumeThread(MyThread);
//_endthread(); // 如果需要终止线程使用 _endthread 即可
// 等待线程执行完毕
WaitForSingleObject(MyThread, INFINITE);
return 0;
}
void __cdecl ThreadFun1(PVOID pvParam)
{
cout << "ThreadFun1 is start" << endl;
}
- 除此以外,还有一个鲜为人知的问题,就是调用完 _beginthread 之后会释放线程句柄(MyThread),也就是说创建的线程句柄在线程执行完毕之后就不可以使用了,如果再调用 CloseHandle 函数关闭句柄的话就会引发异常,如下所示
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
void __cdecl ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
DWORD ThreadId;
HANDLE MyThread = (HANDLE)_beginthread(ThreadFun1, NULL, NULL);
ResumeThread(MyThread);
WaitForSingleObject(MyThread, INFINITE);
// 关闭了已经关闭了的句柄 MyThread
CloseHandle(MyThread);
return 0;
}
void __cdecl ThreadFun1(PVOID pvParam)
{
cout << "ThreadFun1 is start" << endl;
}
- 这样做的好处是方便了调用者(菜鸟们不需要再关闭句柄及使用其他复杂的操作),坏处是对线程(句柄)的控制能力降低了
0x04 了解线程
- 不论是对于进程还是线程,对其句柄的操作都非常重要,获取句柄也是家常便饭,微软为了方便获取句柄,提供了 GetCurrentProcess 和 GetCurrentThread 这两个函数来获取进程和线程的句柄(两个函数没有任何的参数),只不过获取的是伪句柄,并非正真的句柄
- 有如下示例,GetProcessTimes 和 GetThreadTimes 的第一个参数都可以传伪句柄
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
unsigned WINAPI ThreadFun1(PVOID pvParam);
int main(int argc, char *argv[])
{
// 获取当前进程计时信息
FILETIME ftCreationTime, ftExitTime, ftKernelTime, ftUserTime;
// 这里就可以用伪句柄代替真正的句柄
GetProcessTimes(GetCurrentProcess(), &ftCreationTime, &ftExitTime, &ftKernelTime, &ftUserTime);
// 创建一个新的线程,查询线程计时信息
DWORD ThreadId;
HANDLE MyThread = (HANDLE)_beginthreadex(NULL, NULL, ThreadFun1, NULL, NULL, (unsigned *)&ThreadId);
WaitForSingleObject(MyThread, INFINITE);
CloseHandle(MyThread);
return 0;
}
unsigned WINAPI ThreadFun1(PVOID pvParam)
{
// 获取当前线程及时信息
FILETIME ftCreationTime, ftExitTime, ftKernelTime, ftUserTime;
// 传递的是伪句柄
GetThreadTimes(GetCurrentThread(), &ftCreationTime, &ftExitTime, &ftKernelTime, &ftUserTime);
return TRUE;
}
- 某些函数可以传递伪句柄,但如果必须使用真正的句柄,或者某些情况下无法获得真正的句柄怎么办呢,这里可以使用 DuplicateHandle 函数来将伪句柄转换为真正的句柄;DuplicateHandle 函数真正的作用是进程间的句柄复制,句柄转换只是 DuplicateHandle 函数的一个功能而已,而且知道的人并不多
- 示例如下
#include <Windows.h>
#include <iostream>
#include <stdio.h>
using namespace std;
int main(int argc, char *argv[])
{
HANDLE DupProcessHandle = NULL;
BOOL res = DuplicateHandle(
GetCurrentProcess(), // 复制句柄的进程,这里是当前进程
GetCurrentProcess(), // 复制的句柄,这里复制当前进程伪句柄
GetCurrentProcess(), // 复制到哪一个进程,这里复制到当前进程
&DupProcessHandle, // 将复制的句柄传递给一个 HANDLE 变量,如果第二个参数传递的是伪句柄,那么这个函数会把它转换成真实的句柄
0, FALSE, DUPLICATE_SAME_ACCESS
);
// 由于只是把当前进程的伪句柄复制到当前进程,所以只是使用了 DupProcessHandle 函数转换伪句柄的功能,并没有用进程间复制句柄的功能
if (res)
{
cout << "[*] 当前进程的真实句柄为: " << DupProcessHandle << endl;
cout << "[*] 当前进程的伪造句柄为: " << GetCurrentProcess() << endl;
}
return(0);
}