逆向之远程线程注入

Method
CreateRemoteThread() Windows中的函数,用于在其他进程中创建一个新的线程,让该线程在其他进程中运行。
NtCreateThreadEx() 使用系统调用在目标进程直接创建线程,提供更高级的线程创建功能
QueueUserAPC() 向目标进程内核对象队列中加入需要运行的用户空间异步过程调用(APC),被线程从APC队列中取出并执行
SetWindowsHookEx() Windows中的函数,运行应用程序注入到其它应用程序的消息循环中,监控或处理这些应用程序收到的消息
RtlCreateUserThread() 来自NTDLL的函数,主要用户底层或者驱动中,支持更多安全参数,可以运行在更高版本和CreateRemoteThread类似
SetThreadContext() Windows中的函数,允许设置一个线程的cpu上下文
Reflective DLL 反射式注入,不需要建立远程线程来加载dll,直接复制dll到目标进程空闲内存区域中。

本文主要讨论第一个函数,简单入门,让我们先看看这个函数的定义吧!

CreateRemoteThread()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<Windows.h>
//函数原型
HANDLE CreateRemoteThread(
Handle hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId);
//参数解释
//1,hProcess -- 目标进程的进程句柄,一般通过获取进程id来获取,HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, 1, pid);pid为进程id
//2,lpThreadAttributes -- 线程安全描述符,一般为NULL
//3,dwStackSize --- 新的线程栈大小,0表示使用默认大小
//4,lpStartAddress --- 新线程的入口函数,需要是可执行代码地址,这个一般用来加载注入的代码,ex:(LPTHREAD_START_ROUTINE)loadLibraryA
//5,lpParameter --- 传给新线程的参数
//6,dwCreationFlags --- 新线程创建标志,一般为0
//7,lpThreadId --- 接收新线程的ID地址
//return HANDLE --- 返回值,如果成功返回新线程的句柄,失败返回NULL.

目标程序

我们来个简单的例子,我们自己写一个程序,然后注入线程,修改函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <Windows.h>
#include <iostream>

void function() {
std::cout << "normal working state on:" << *function << std::endl;
}

int main() {

SetConsoleTitle(TEXT("TESTAPP"));

std::cout << "program start:" << std::endl;
while (true) {
Sleep(1000);
function();
}
getchar();
return 0;
}

这个就是我们需要注入的应用,很简单对吧,就是循环调用一个函数,打印函数地址,并且还设置了项目的控制台窗口的标题文本,方便我们等会进行查找,那我们注入的目的,就是修改这个unction函数,替换为我们自己的函数。

​ 这个地址是每次运行都会变的,我们还需要找到这个函数的基地址(简单来说就是编译后,每次运行这个地址都不会变),相信大家都用过什么xx修改器吧,我记得我小时候玩游戏用过,好像是烧饼修改器,大伙应该都很熟悉,例如游戏金币为30,然后打开修改器搜索这个数字,肯定会有一大串出来,然后去改变这个值,增加或者减少,来个几次,就会发现只剩下一个(ps:那会反逆向还不是很厉害,基本都能通过这个检测,稍微复杂点的也就是修改完后,还会有个小小的对比检测,这里不深入讨论),然后我们通过修改这个值就可以修改金币的数字了,但是这个有个缺点,就是每次运行,这个地址都会改变,只有使用基地址,那么每次运行都可以使用,相信大家已经有了一个大概的印象了吧,非常简单的一个概念。

​ 那我们这里就不使用那么复杂的去找了,我们直接使用我们的vs工具进行查看(ps:也可以使用ollydbg,IDA,或者X64dbg…),在function()函数前面打一个断点,然后点击调试,然后程序就会停在断点处,这个时候我们右键,转到反汇编。

就会看到这样的画面,我们找到function,然后看到那个call,后面的:function(),括号里面的就是function的基地址,记住这个值,等会我们需要用到。

注入内容

下面我们需要编写的就是,根据地址,我们去修改内容,让function去执行我们自己的函数。这一部分可能比较复杂,请耐心阅读。

这次我们要编写的是动态库,需要vs右键项目属性,配置类型改为动态库.dll

这次我们就不全部放在一起,我们进行拆开,每一部分单独解释

1
2
3
DWORD functionAddress = 0x411320;
//这个就是我们上一步拿到的地址,我们需要hook的目标函数的地址
//DWORD 就是
1
2
3
4
void hook() {
std::cout << "Hooked" << std::endl;
}
//我们自己实现的hook函数,用来替换上面的function函数,打印Hooked

! ! ! 下面是重点,也是难点

1
2
3
4
5
6
7
8
std::cout<<"[+] hookAddress : "<<*hook<<std::endl;

const int instructionSize = 5;

DWORD relativeAddress = ((DWORD)hook - (DWORD)functionAddress) - instructionSize;

std::cout<<"[+] relativeAddress : "<<relativeAddress<<std::endl;

首先我们先看看我们注入函数的地址,然后定义一个instructionSize,可能有同学就要好奇了,为啥要是5,不能是10吗,那下面我就来仔细解释下。

  • step1

我们使用的是x86编译器,也就是32位的程序,所以我们的指令也要匹配32位,大家可能对汇编不是很熟悉,没关系,在这里只需要了解就行,就像刚才我们反汇编第一个程序那样,都基本可以看到这些指令

1
2
3
4
5
6
7
8
9
//基本汇编指令
00xxxxxx push ebp
00xxxxxx move ebp,esp
00xxxxxx sub esp,8
00xxxxxx move eax,1
00xxxxxx add eax, [ebp-4]
00xxxxxx call sub_401090
00xxxxxx jmp short loc_401056
00xxxxxx retn

在这里,我们只需要关注jmp指令就行。

1
2
//jmp
E9 xx xx xx xx

这就是在x86下,jmp指令使用的相对地址,后面为地址,在32位应用程序中,寻址为32位,就是后面那4个字节,再加上前面那个指令,刚好就为5个字节,符合指令格式,多或者少,都不行,这就是为啥要5个字节了。

  • step2
1
DWORD relativeAddress = ((DWORD)hook - (DWORD)functionAddress) - instructionSize;

这句代码就是计算hook函数相对于要HOOK的函数的相对偏移地址

因为这个时候代码已经注入,hook函数和functionAddress在同一片地址空间,就需要计算相对偏移地址,然后再减去需要的指令格式大小,上一步已经具体说明。

  • step3

下面需要修改内存页的保护属性,将要写入HOOK代码的内存页权限改为可执行,可读写。

因为默认内存页可能只是只读或只执行,我们需要改成可写才能修改代码,并且在写入后,要恢复内存页原始的保护属性,这可以避免留下可执行且可写的内存,减少安全风险。还有一些安全防护需要内存属性修改才能绕过。如果不修改权限,直接写入执行代码会失败并造成崩溃。

1
2
3
4
5
6
7
8
9
10
11
//virtualProtect
BOOL VirtualProtect(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
);
//lpAddress --- 要设置权限的内存起始地址指针
//dwSize --- 内存区域的大小,即从起始地址开始的字节数
//flNewProtect --- 希望设置的新内存权限,ex:PAGE_EXECUTE_READWRITE,可读写,可执行
//lpfloldProtect --- 用于保存原始内存权限的指针变量地址
1
BOOL pageChange = VirtualProtect((LPVOID)functionAddress, instructionSize, PAGE_EXECUTE_READWRITE, &defaultProtection);

所以上面这段代码应该就能看懂了吧,函数的起始地址,区域为内存jmp指令,把这段内存改成可执行,可读写,然后保存原版的内存权限到defaultProtection.

  • step4
1
2
3
4
5
6
7
byte jmpInstruction[instructionSize];

jmpInstruction[0] = 0xE9; // jmp
jmpInstruction[1] = relativeAddress;
jmpInstruction[2] = (relativeAddress >> 8);
jmpInstruction[3] = (relativeAddress >> 16);
jmpInstruction[4] = (relativeAddress >> 24);

我们可以查询到,jmp的操作码就为0xE9,所以我们构造跳转指令,然后就要构造偏移地址,首先就是,必须把32位的地址拆分存储到这4个字节中了,如果我们只设置那个jmpInstruction[1],那么32位就会丢失后面的24位,所以我们要把地址拆分,逐个放在这个指令字节中。

好了,这一步,我们已经构造好了jmp指令,要跳转的地址也写入了,下面就是写入到内存中。

  • step5
1
2
3
SIZE_T bytesWritten;

int lpWrite = WriteProcessMemory(GetCurrentProcess(), (LPVOID)functionAddress, jmpInstruction, instructionSize, &bytesWritten);
1
2
3
4
5
6
7
8
9
10
11
12
BOOL WriteProcessMemory(
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T *lpNumberOfByteWriteen
);
//hProcess --- 要写入的目标进程句柄
//lpBaseAddress --- 目标进程地址空间中的写入地址
//lpBuffer --- 包含要写入数据的缓冲区地址
//nSize --- 要写入的数据大小
//lpNumberOfBytewritten --- 返回实际写入的字节数

如同你看到的那样,把jmp指令写入到functionaddress函数地址后,每次call这个函数的时候,执行到后面jmp指令,就会直接跳转到我们自己的hook函数地址,中间这段也就是属于原本函数的指令不执行。这样我们就完成了内存写入!

!!!但是,别着急,还有一步,记得我们上一步修改了一段内存的属性吧,现在我们要将这段内存的属性修改回去

1
2
DWORD oldProtect;
pageChange = VirtualProtect((LPVOID)functionAddress, instructionSize, defaultProtection, &oldProtect);

原理和上面类似,这里就不多赘述了。

step6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool WINAPI DllMain(HINSTANCE hinstDLL,	DWORD fdwReason, LPVOID lpReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
CreateThread(0, 0, thread, hinstDLL, 0, 0);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return true;
}

DllMain函数就是DLL被加载和卸载时会被调用的函数,用来做初始化和清洁的工作。

1
2
3
4
5
6
7
8
9
10
11
12
BOOL WINAPI DLLMain(
HINSTANCE hinstDLL,
DWORD fdwReason,
LPVOID lpvReserved
);
// hinstDll --- 该DLL的实例句柄
// fdwReason --- 调用原因:
//DLL_PROCESS_ATTACH -- DLL被进程加载时调用
//DLL_THREAD_ATTACH -- 线程加载DLL时调用
//DLL_THREAD_DETACH -- 线程卸载DLL时调用
//DLL_PROCESS_DETACH -- 进程卸载DLL时调用
//lpvReserved --- 保留参数,一般为NULL

使用这个函数,我们就能在dll被调用的时候进行注入代码了

1
2
3
4
5
6
7
8
9
10
DWORD WINAPI thread(LPVOID param) {

std::cout << "[+] Thread started" << std::endl;
writeTheHook();
while (true) {
Sleep(100);
}
FreeLibraryAndExitThread((HMODULE)param, 0);
return 0;
}

这段就是上面那个createThread调用的线程代码,就是执行我们的注入比较简单,这里就提一下,只要注意结束的时候进行线程清理。

好了,到这里,DLL的功能已经制作完成,下面给上完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <Windows.h>
#include <iostream>

DWORD functionAddress = 0x411320; //修改为你自己的函数地址


void hook() {

std::cout << "Hooked" << std::endl;
}

void writeTheHook() {

std::cout<<"[+] hookAddress : "<<*hook<<std::endl;

const int instructionSize = 5;

DWORD relativeAddress = ((DWORD)hook - (DWORD)functionAddress) - instructionSize;

std::cout<<"[+] relativeAddress : "<<relativeAddress<<std::endl;

DWORD defaultProtection;
BOOL pageChange = VirtualProtect((LPVOID)functionAddress, instructionSize, PAGE_EXECUTE_READWRITE, &defaultProtection);

if (!pageChange)
{
std::cout<<"[-] Fail to VirtualProtect"<<std::endl;
return;
}

byte jmpInstruction[instructionSize];

jmpInstruction[0] = 0xE9; // jmp
jmpInstruction[1] = relativeAddress;
jmpInstruction[2] = (relativeAddress >> 8);
jmpInstruction[3] = (relativeAddress >> 16);
jmpInstruction[4] = (relativeAddress >> 24);

SIZE_T bytesWritten;

int lpWrite = WriteProcessMemory(GetCurrentProcess(), (LPVOID)functionAddress, jmpInstruction, instructionSize, &bytesWritten);

if (!lpWrite)
{
std::cout<<"[-] Fail to WriteProcessMemory"<<std::endl;
return;
}

std::cout<<"[+] Jump writed at 0x%x"<<functionAddress<<std::endl;

DWORD oldProtect;
pageChange = VirtualProtect((LPVOID)functionAddress, instructionSize, defaultProtection, &oldProtect);

if (!pageChange)
{
std::cout << "[-] Fail to VirtualProtect" << std::endl;
return;
}
}

DWORD WINAPI thread(LPVOID param) {
std::cout << "[+] Thread started" << std::endl;
writeTheHook();
while (true) {
Sleep(100);
}
FreeLibraryAndExitThread((HMODULE)param, 0);
return 0;
}

bool WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
CreateThread(0, 0, thread, hinstDLL, 0, 0);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;

}
return true;
}

然后进行编译生成

注入器

我们只是编写好了DLL的代码,但是我们想要注入的那个程序是不可能调用我们这个DLL的,所以我们还需要一个注入器

step1

加载一些库函数和找到目标进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DWORD pid;

//加载library
HMODULE kernel32 = LoadLibrary("kernel32.dll");

LPVOID loadLibraryA = GetProcAddress(kernel32, "LoadLibraryA");

HWND hwnd = FindWindow(0, TEXT("TESTAPP"));

GetWindowThreadProcessId(hwnd, &pid);

HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, 1, pid);


首先加载win函数库,我们需要loadLibraryA去加载库文件,所以我们要先引入kernel32.dll

然后用GetProcAddress得到函数地址

现在知道为啥在第一步中设置那个title了吧,我们就可以直接使用TESTAPP来得到窗口,

然后再调用函数得到PID

step2

加载我们的DLL

1
2
3
4
5
const char* path = "D:\\lib.dll";
//分配内存空间
LPVOID pathAddress = VirtualAllocEx(hProc, NULL, strlen(path), (MEM_COMMIT | MEM_RESERVE), 0x40);
SIZE_T bytesWritten = 0;
int lpWrite = WriteProcessMemory(hProc, pathAddress, path, strlen(path), &bytesWritten);

我们要先传入dll的路径,并且分配内存空间

1
2
3
4
5
6
7
8
9
10
11
12
13
LPVOID VirtualAllocEx(
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
//hProcess --- 要在其地址空间分配内存的进程句柄
//lpAddress --- 分配的内存大小
//dwSize --- 分配的内存大小
//flProtect --- 内存保护属性
//flAllocation --- 内存类型,如MEM_COMMIT
//此函数可以向打开的目标进程地址安全地分配可控内存块,是DLL注入中非常重要的函数

下面那个writeProcessMemory我们上面分析过,这里略过

  • step3

创建远程线程注入

终于到了最后了,也是我们开头提到的远程线程注入

1
HANDLE remoteProc = CreateRemoteThread(hProc, NULL,	0, (LPTHREAD_START_ROUTINE)loadLibraryA, pathAddress, 0, NULL);

简单解释下,就是在hProc进程中创建了一个线程,去执行LoadLibraryA,也就是加载dll库的win函数,然后参数就是dll的路径。

下面是这一部分的完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <Windows.h>
#include <iostream>

int main()
{
DWORD pid;

//加载library
HMODULE kernel32 = LoadLibrary("kernel32.dll");

LPVOID loadLibraryA = GetProcAddress(kernel32, "LoadLibraryA");

HWND hwnd = FindWindow(0, TEXT("TESTAPP"));

if (!hwnd) {
std::cout<<"[-] Fail to FindWindow"<<std::endl;
return 1;
}
GetWindowThreadProcessId(hwnd, &pid);

std::cout<<"[+] PID of target : "<<pid<<std::endl;

HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, 1, pid);

if (hProc == INVALID_HANDLE_VALUE) {
std::cout<<"[-] Fail to OpenProcess"<<std::endl;
return 1;
}


const char* path = "D:lib.dll"; //换成你自己的路径

//分配内存空间
LPVOID pathAddress = VirtualAllocEx(hProc, NULL, strlen(path), (MEM_COMMIT | MEM_RESERVE), 0x40);

if (!pathAddress)
{
std::cout<<"[-] Fail to VirtualAllocEx"<<std::endl;
return 1;
}

std::cout<<"[+] pathAddress in target : "<<pathAddress<<std::endl;

SIZE_T bytesWritten = 0;

int lpWrite = WriteProcessMemory(hProc, pathAddress, path, strlen(path), &bytesWritten);

if (!lpWrite)
{
std::cout<<"[-] Fail to WriteProcessMemory"<<std::endl;
return 1;
}

std::cout<<"[+] Bytes written : "<<bytesWritten<<std::endl;

HANDLE remoteProc = CreateRemoteThread(hProc, NULL, 0, (LPTHREAD_START_ROUTINE)loadLibraryA, pathAddress, 0, NULL);
if (!remoteProc)
{
std::cout<<"[-] Fail to CreateRemoteThread"<<std::endl;
return 1;
}

std::cout<<"[+] Injected"<<std::endl;

CloseHandle(hProc);

getchar();

return 0;
}

演示结果

我们先运行我们要注入的那个程序,走起!

正常打印,这个函数的地址

下面见证奇迹的时刻,我们来运行注入器,一定要确保dll的路径设置正确。

看到了吧,这就是逆向中的远程线程注入,好了,这篇博客也就到这了,希望大家都能有所收获,我会将代码上传到GitHub上,有需要的可以下载学习。

GitHub代码链接