逆向工程核心原理-27

逆向工程核心原理-第27章-代码注入

参考了SYJ学长的博客:https://bbs.pediy.com/thread-267065.htm

27.1 代码注入

代码注入是一种向目标进程插入独立运行代码并是指运行的技术, 它一般调用CreateRemoteThread()API以远程线程形式运行插入的代码, 所以也被称为线程注入.

下图是代码注入技术的实现原理

Untitled

首先向target.exe插入代码与数据, 此过程中:

  • 代码以线程过程(Thread Procedure)形式插入
  • 数据以线程参数的形式插入

也就是说, 代码与数据是分别注入的.

27.2 DLL注入与代码注入

请看下面的简单代码, 其作用是弹出Windows消息框

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
DWORD WINAPI ThreadProc(LPVOID lParam)
{
MessageBoxA(NULL, "www.reversecore.com", "ReverseCore", MB_OK)
}

这里讲一下MessageBoxA函数

作用: 弹出Windows消息框

函数原型:
int MessageBoxA(
[in, optional] HWND hWnd,
[in, optional] LPCSTR lpText,
[in, optional] LPCSTR lpCaption,
[in] UINT uType
);

参数解析:
hWnd: 该消息框的所有者窗口的句柄, 即该窗口的父窗口句柄, 如果为NULL, 则该窗口没有所有者窗口

lpText: 要显示的文本字符串指针
LPCSTR类型L代表long, P代表pointer, C代表const, STR代表string, 前面也记过笔记但是又忘记了

lpCaption: 消息框的标题字符串指针

uType: 对话框的内容和行为, 比如有无按钮之类
以下为该参数可取的值
MB_ABORTRETRYIGNORE 包含三个按钮Abort, Retry和Ignore
MB_CANCELTRYCONTINUE 包含三个按钮取消, 重试, 继续
MB_HELP 消息框添加帮助按钮
MB_OK 包含一个OK按钮
MB_OKCANCEL 包含两个按钮 OK和Cancel
MB_RETRYCANCEL 包含两个按钮 重试和取消
MB_YESNO 包含两个按钮 Yes和No
MB_YESNOCANCEL 包含三个按钮 Yes, No和Cancel

DLL注入

如果是DLL注入技术, 则会将上述代码放入某个DLL文件, 再通过将DLL注入到目标进程后调用DllMain函数然后再在其中调用这段代码

书中没给DLL注入程序, 所以就直接截书上的图了

Untitled

我们可以看到在DLL注入完成后调用MessageBoxA的整个调用过程

在10001002地址处有一条PUSH 10009290指令, 紧接着是PUSH 1000929C指令.

在OD的Dump窗口中查看地址100092901000929C, 如下图所示

Untitled

可以看到上面的100092901000929C, 在这里都有对应的数据(两个字符串), 所需要的数据都保存在导入的DLL当中

我们再回过头去看看调用MessageBoxA的汇编语句CALL DWORD PTR DS : [100080F0]指令, 该指令调用的是user32.dll中的MessageBoxA()函数, 转到100080F0处查看

Untitled

可以看到该区域为DLL的IAT(导入地址表)上面还有导入的kernel32.dll的LCMapStringW函数和ntdll.dll的RtlSizeHeap函数. 可以看到MessageBoxA的地址数据也存放在DLL当中

我们从上面对DLL注入的分析可以看到, DLL注入的代码(整个调用MessageBoxA过程, 不是MessageBoxA的库函数)与数据都存放在DLL当中, 当DLL被插入到进程内存中时, 代码和数据都进入到了目标进程的内存空间, 所以代码能够正常运行

代码注入

而代码注入则不同, 它不像DLL注入能把代码和数据打包在一起, 如果想要注入的代码能够正常运行, 还需要想办法把代码所需要使用的数据也一同注入进去, 比如上面调用MessageBoxA()所需要使用的两个字符串参数数据, 没有它们MessageBoxA()将无法成功调用

使用代码注入的原因

使用代码注入实现的功能跟DLL注入类似, 却要比DLL注入要麻烦, 那为什么要使用代码注入呢

1. 占用内存少

要注入的代码与数据较少, 就不需要将它们做成DLL的形式再注入. 此时直接采用代码注入的方式同样能获得与DLL注入相同的效果, 且占用的内存会更少

2. 难以查找痕迹

采用DLL注入的方式会再目标进程的内存中留下相关痕迹, 很容易让人判断出目标进程是否被执行过注入操作, 但采用代码注入方式几乎不会留下任何痕迹, 恶意代码中大量使用代码注入技术

3. 其他

不需要另外的DLL文件, 只要有代码注入程序即可.

总结: DLL注入技术主要用在代码量大且复杂的时候, 而代码注入技术则适用于代码量小且简单的时候(想想, 如果要用到100个数据是使用代码注入, 数据的注入会非常麻烦)

27.3 练习示例

示例为CodeInjection.exe用于代码注入, 它向notepad.exe进程注入简单的代码, 注入后会弹出消息框

27.3.1 运行notepad.exe

首先运行notepad.exe 然后使用ProcessExplorer查看notepad.exe进程的PID

Untitled

可以看到notepad的PID为29592

27.3.2 运行CodeInjection.exe

在命令行窗口中输入命令与参数, 注意要用管理员身份打开, 否则会The token does not have the specified privilege.报错

Untitled

运行成功后发现notepad.exe弹出了一个窗口

Untitled

7.4 CodeInjection.cpp

下面的代码略去了异常处理部分

27.4.1 CodeInjection.exe的main()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char *argv[])//接收的参数数目以及参数本身,  在这里我们有两个在命令行输入的参数
{ //argv[0] = "CodeInjection.exe",argv[1] = "29592"
DWORD dwPID = 0;

if ( argc != 2 )//检查我们输入的参数个数是否正确
{
printf("\n USAGE : %s pid\n", argv[0]);
}

//代码注入
dwPID = (DWORD)atol(argv[1]);//我们输入的PID原本的形式是字符串, 使用atol转换成了整数
InjectCode(dwPID);//调用注入代码函数, 参数是代码注入的目标进程的PID

return 0;
}

27.4.2 ThreadProc()函数

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
//定义用于线程参数传递的线程参数结构体
typedef struct _THREAD_PARAM
{
FARPROC pFunc[2]; //FARPROC类型为指向WINAPI的**函数指针**,
//用来存LoadLibraryA(), GetProcAddress()两个函数的指针
char szBuf[4][128]; /*存放参数
"user32.dll", "MessageBoxA",
"www.reversecore.com", "ReverseCore"*/
} THREAD_PARAM, *PTHREAD_PARAM;

//定义了LoadLibraryA的函数指针, 定义这个类型的作用是到时候是使用函数指针来调用函数
typedef HMODULE(WINAPI *PFLOADLIBRARYA)
(
LPCSTR lpLibFileName//HMODULE为返回值,调用约定为WINAPI,指针名叫PFLOADLIBRARYA,参数为字符串指针lpLibFileName
);//LPCSTR在上面分析过, 是一个字符串指针类型

//同理GetProcAddress()的函数指针, 方便后面调用GetProcAddress()函数
typedef FARPROC(WINAPI *PFGETPROCADDRESS)
(
HMODULE hModule,//模块句柄
LPCSTR lpProcName//查找的函数名字符串指针
);

//MessageBoxA的函数指针, 关于MessageBoxA的解析看最上面的代码块
typedef int (WINAPI *PFMESSAGEBOXA)
(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);

DWORD WINAPI ThreadProc(LPVOID lParam) //线程过程,用来存放我们的注入代码
//传入的是参数结构体的指针,这个指针是无类型, 后面会转换类型并赋值
{
PTHREAD_PARAM pParam = (PTHREAD_PARAM)lParam; //将传入的指针强制类型转换为我们定义的线程参数结构体类型的指针,其实指针本质都是相同的,只是语言的语法要求
HMODULE hMod = NULL; //定义一个hMod来存放模块句柄
FARPROC pFunc = NULL; //定义一个FARPROC类型的变量来存放GetProcAddress的返回值

hMod = ((PFLOADLIBRARYA)pParam->pFunc[0])(pParam->szBuf[0]); // LoadLibrary("user32.dll")
//((PFLOADLIBRARYA)pParam->pFunc[0])先强制类型转换了成了LoadLibrary函数类型指针
//然后使用参数结构体中的LoadLibrary的地址, 调用了LoadLibrary()函数,加载模块
if (!hMod)//返回了user32.dll模块的句柄
return 1;

pFunc = (FARPROC)((PFGETPROCADDRESS)pParam->pFunc[1])(hMod, pParam->szBuf[1]); // GetProcAddress(hMod, "MessageBoxA");同理调用了GetProcAddress

if (!pFunc)//获得了MessageBoxA的函数指针
return 1;

((PFMESSAGEBOXA)pFunc)(NULL, pParam->szBuf[2], pParam->szBuf[3], MB_OK); // MessageBoxA(NULL, "www.reversecore.com", "ReverseCore", MB_OK);
//使用该指针调用了函数MessageBoxA, 可以看到所用的参数均在参数结构体中

return 0;
}

上述代码实际被注入的部分是ThreadProc()函数, 其中使用了很多的函数指针, 经过整理后会非常简单

1
2
3
hMod = LoadLibraryA("user32.dll");
pFunc = GetProcAddress(hMod, "MessageBoxA");
pFunc(NULL, "www.reversecore.com", "ReverseCore", MB_OK);

上面的代码并不是故意写得那么乱, 而是代码注入的方式所导致的.

重要的是ThreadProc()代码的概念: 代码注入技术的核心内容是注入可独立运行的代码, 为此, 需要同时注入代码与数据.

ThreadProc所用的到的数据都是通过lParam参数传递的.

我们看看普通程序

1
2
3
4
5
6
DWORD WINAPI ThreadProc(LPVOID lparam)
{
MessageBoxA(NULL, "www.reversecore.com", "ReverseCore", MB_OK);

return 0;
}

使用调试器调试生成的文件

Untitled

可以看见, 如果将该段代码直接注入, 则代码无法正常运行, 因为代码中引用的地址(10009290, 1000929C, 100080F0)并不存在于目标进程中, 要使代码能够正常运行, 必须向相应地址同时注入相关字符串以及API地址, 并且通过编程方式使上图代码也能够准确引用被注入数据的地址

为了满足这些条件, ThreadProc函数中使用THREAD_PARAM(线程参数)结构体来接收2个API地址与4个字符串数据. 其中2个API分别是LoadLibraryA与GetProcAddress(), 只要有了这两个API, 就能够调用目标进程加载的库的所有库函数.

大部分用户模式进程都会加载kernel32.dll, 所以直接传递LoadLibraryA()与GetProcAddress()的地址不会有什么问题(因为是固定的).

调试我们CodeInject.exe中的Threadproc

Untitled

可以看到[EBP + 8]就是我们的参数结构体

重要的数据都是从[EBP + 8]中获取的, 所以这个ThreadProc()函数是可以独立运行的代码

27.4.3 InjectCode()函数

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
87
88
89
90
91
92
93
94
95
96
97
98
BOOL InjectCode(DWORD dwPID)
{
HMODULE hMod = NULL;//模块句柄
THREAD_PARAM param = { 0, };//参数结构体
HANDLE hProcess = NULL;//进程句柄
HANDLE hThread = NULL;//线程句柄
LPVOID pRemoteBuf[2] = { 0, }; //void型的长指针,用来指向分配的内存
DWORD dwSize = 0;//大小

hMod = GetModuleHandleA("kernel32.dll");//获取kernel32.dll句柄

//给线程参数结构体赋值, 注意这个参数结构体是CodeInject.exe内存空间中的参数结构体
param.pFunc[0] = GetProcAddress(hMod, "LoadLibraryA");
param.pFunc[1] = GetProcAddress(hMod, "GetProcAddress");
strcpy_s(param.szBuf[0], "user32.dll");
strcpy_s(param.szBuf[1], "MessageBoxA");
strcpy_s(param.szBuf[2], "www.reversecore.com");
strcpy_s(param.szBuf[3], "ReverseCore");

//根据我们在控制台输入的PID获取进程句柄, 参一是权限, 参二是是否继承, 参三是PID, 返回进程句柄
if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS,FALSE,dwPID)))
{
printf("OpenProcess() fail : err_code = %d\n", GetLastError());
return FALSE;
}

//获取参数结构体的大小
dwSize = sizeof(THREAD_PARAM);


if (!(pRemoteBuf[0] = VirtualAllocEx(//该函数作用是在目标进程中申请内存, 示例中是为参数结构体分配内存空间
hProcess,//进程句柄
NULL,//保留页面的内存地址, 一般NULL让系统自动分配
dwSize,//想分配的内存大小
MEM_COMMIT,//分配方式
PAGE_READWRITE//内存属性, 示例是可读可写
))) //返回值为分配内存的首地址, 放在了pRemoteBuf[0]中
{
printf("VirtualAllocEx() fail : err_code = %d\n", GetLastError());
return FALSE;
}

if (!WriteProcessMemory(hProcess,//将线程参数(注入代码要使用的数据)结构体写入分配的内存
pRemoteBuf[0],//写入的首地址, 就是上面分配的内存
(LPVOID)&param,//当前进程的参数结构体, 即要写入的数据的地址
dwSize,//要写入的数据大小, 前面赋值为参数结构体的大小
NULL//实际写入数据大小存放的变量, 但是可以填不记录
))//成功则返回非零值, 错误则返回0
{
printf("WriteProcessMemory() fail : err_code = %d\n", GetLastError());
return FALSE;
}

//就是因为这个的原因,生成的时候必须用release
dwSize = (DWORD)InjectCode - (DWORD)ThreadProc;//函数名就是函数的起始地址,在内存中紧跟着ThreadProc函数的就是InjectCode,所以首地址相减就能够得到ThreadProc代码占用的空间

if (!(pRemoteBuf[1] = VirtualAllocEx(hProcess,//返回值是给代码分配的内存的首地址
NULL,
dwSize,
MEM_COMMIT,
PAGE_EXECUTE_READWRITE)))//为注入代码在对应进程分配空间
{
printf("VirtualAllocEx() fail : err_code = %d\n", GetLastError());
return FALSE;
}

//向目标进程写入(注入)代码
if (!WriteProcessMemory(hProcess,//目标进程句柄
pRemoteBuf[1],//在目标进程中留给代码的内存的首地址
(LPVOID)ThreadProc,//函数首地址, 要写入的代码
dwSize,//写入的数据所占内存大小
NULL))//在对应空间中写入代码
{
printf("WriteProcessMemory() fail : err_code = %d\n", GetLastError());
return FALSE;
}

//创建线程来执行在目标进程中注入的代码
if (!(hThread = CreateRemoteThread(hProcess, //创建远程线程执行注入代码
NULL,
0,
(LPTHREAD_START_ROUTINE)pRemoteBuf[1],//线程函数起始地址为在目标进程中注入代码的内存的首地址
pRemoteBuf[0],//传入线程函数的参数
0,
NULL)))
{
printf("CreateRemoteThread() fail : err_code = %d\n", GetLastError());
return FALSE;
}

WaitForSingleObject(hThread, INFINITE);
//关闭线程
CloseHandle(hThread);
//关闭进程
CloseHandle(hProcess);

return TRUE;
}

分析代码注入一些理解

首先是对CreateRemoteThread()有了一些不同的看法, 之前DLL注入的时候对于CreateRemoteThread()的印象是凭空创建了一个线程(注意凭空), 但是看了代码注入后, 发现CreateRemoteThread()并不是凭空, 它有点像是一个IP寄存器, 我们把这个IP寄存器拨到内存的指定位置, 然后创建一个线程来跑这一段代码. 而这一段代码需要数据支撑, 所以它所需要的数据也必须在这个内存当中.

代码注入的大致流程: 给注入的代码在目标进程内分配空间 → 给需要的数据在目标进程内分配空间

                          →调用CreateRemoteThread()创建线程,  执行该代码

27.5 代码注入调试练习

27.5.1 调试notepad.exe

先用OD调试notepad.exe文件, 使用F9让notepad.exe处于”Runing”运行中状态

Untitled

27.5.3 设置OllyDbg选项

代码注入的本质其实就是在程序中创建一个新进程, 所以我们在调试选项中开启在新线程时停止, 就可以在Threadproc(线程过程)函数处停下来了

Untitled

27.5.3 运行CodeInjection.exe

然后使用ProcessExplorer查看进程的PID

Untitled

然后再终端处运行CodeInjection.exe(注意终端要使用管理员身份打开)

Untitled

然后就会在我们注入的代码处停止下来

Untitled

执行到7B0004后面

Untitled

我们查看参数结构体的内存

Untitled

至此完成调试

1
2
3
4
5
6
首先在这里谈谈自己的理解
DLL注入: 调用了CreateRemoteThread(), 创建的线程运行LoadLibraryA(), 加载了DLL后, 运行DllMain
函数, 代码和数据都保存再DLL中, 所以不用考虑所用数据的问题

代码注入: 同样调用了CreateRemoteThread(), 创建的线程运行的是自己写的函数, 所需的数据都在原本的
程序内所以需要以参数的形式传递到函数中去, 而且需要提前保存在目标进程的内存空间中
文章作者: LamのCrow
文章链接: http://example.com/2022/04/09/逆向工程核心原理-第 7317a/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 LamのCrow