f10@t's blog

逆向工程课程小结

字数统计: 4.7k阅读时长: 17 min
2019/12/21

逆向工程课程小结,授课老师:孙聪

一些基础概念

什么是逆向工程?软件逆向工程?具体阶段?

1
2
3
4
5
6
答:从对象中提取知识或设计信息,并重建对象或基于对象信息的任意事物的过程

一种分析目标系统的过程,其目的是识别出系统的各个组件以及他们之间的关系,并在较高的抽象层次上以
另一种形式创建对系统的表示,从而理解目标系统的结构和行为。

主要有两个阶段:收集信息和抽象信息阶段。前者涉及有解释器、调试器等,后者主要用于构造可理解的高层模型(动态或静态)

逆向工程主要的活动?

1
答:反汇编(撤销汇编过程)、分析、可能的修改

逆向工程中恶意代码分析的静态方法和动态方法区别?

1
2
答:静态方法:分析但不运行代码;相比较动态方法更为安全;使用如IDA Pro等的反汇编器
动态方法:检查进程执行过程中寄存器、内存值的实时变化;允许操作操作进程;要使用到调试器

举几个软件逆向工程的主要应用?

1
答:恶意代码分析、闭源软件的漏洞分析、闭源软件的互操作性分析、衡量编译器性能

说说逆向工程的困难性?

1
答:编译过程中造成的信息损失、由于编译属于多对多操作,所以可能反编译的源文件有偏差、不同编程语言的反编译器不同

什么是x86系统?什么是x64系统?

1
2
答:x86是基于Intel 8086/8088处理器的一系列向后兼容的指令体系结构(ISA)的总称
x64又称为x86-64,是x86体系结构的扩展。是与x86(IA-32)兼容的64位CPU体系结构

小端序大端序?

1
2
答:小端序是低位字节储存在内存中的低位地址,效率较高(Intel使用),而大端序是低位字节储存在内存中高位地址,符合一般的思维逻辑
RISC架构处理器使用(MIPS和PowerPC采用)

保护模式下用户程序运行在哪一层?

1
答:Ring3层,即最低权限级别,程序金科以读写系统设置的一个子集

IA-32内存模型有哪几种?

1
2
答:平面内存模型(代码数据和栈在线性地址空间中,大小为0~2^31 - 1)
分段内存模型(代码、数据和栈在不同的段中)

什么是反汇编?

1
答:将二进制文件中的机器码转化为汇编代码或等价的中间语言代码

反汇编器分为?

1
2
3
4
5
6
7
答:动态反汇编器和静态反汇编器
前者会执行二进制码,记录执行路径,并对记录执行路径进行解码
优点:精确,可对混淆的二进制进行反汇编
缺点:记录路径需要时间,且一次只能反汇编一条执行路径
后者不会执行文件,而是直接反汇编
优点:快,能覆盖多于一条执行路径
缺点:复杂,难以处理代码混淆

静态反汇编的挑战?

1
答:变长指令集、数据嵌入在代码中、间接跳转/调用

可执行程序中的元信息都可以有啥?

1
答:符号表(源代码的符号信息,由链接器和调试器使用)、重定位信息、调试信息(由调试器生成)

ELF文件由啥构成?

1
2
3
4
5
答:ELF头:包含如指令集体系结构、32/64位格式、大端序/小端序、执行入口点、程序头表和节头表等信息
程序头表(PHT):用于执行视图,告诉系统如何在内存中创建一个进程
有以下几个段类型:PHDR(保存程序头表本身的位置和大小), LOAD(代表该类型的段会被装载到内存中), INTERP(指向可执行文件的动态连接器的指针), DYNAMIC(保存指向动态链接信息的指针), NOTE(保存操作系统相关的规范信息)
节头表(SHT):用于链接视图,包含名称和类型、请求的内存位置、权限
有下面这几个重要节类型:.interp(程序解释器的路径名), .text(程序代码), .data(已初始化的全局数据), .rodata(只读数据), .bss(未初始化的全局数据), .init(用于进程初始化的可执行指令序列), .fini(用于进程终止的可执行指令序列), .plt(保存过程链接表,其中包含动态连接器调用共享库函数的必须代码), .rel.<x>(节<x>的重定位信息), dynamic(动态链接信息)

ELF文件怎么加载到内存?

1
2
答:1. 装载器使用程序头表,将所有LOAD类型的段映射到内存,并赋以权限位
2. 调用动态连接器(INTERP段内指定),传入可执行文件相关信息

段和节的区别?

1
2
3
答:1. 段是程序执行的必要组成部分,每个段划分为不同节
2. 程序内存分布的描述是由程序头表来完成的
3. 每个ELF目标文件都有节,但不一定有节头(对程序执行非必须。主要用于链接和调试)

PE32文件由啥构成?

1
2
3
答:大体分为PE头,PE体和NULL填充
前者由DOS头、Dos存根(可选)、NT头、节区头(各节区在文件或内存中的大小,位置,属性等)
后者由节区构成(包括.text, .data, .rsrc)

IA-32指令集

(待补充)

静态反汇编算法

1. 线性扫描算法
2. 递归遍历算法

线性扫描算法

主要原理:从代码节区的入口点开始扫描,顺序逐一解码指令,直到代码节区末尾;如果此过程中遇到解码出非法指令的情况,则停止.例:gdb, objdump都是使用的该算法。(ps:其实就是编译原理中的下推自动机那种)

算法伪代码:

1
2
3
4
5
6
7
8
9
10
//code: 输入代码节区中的字节
//codeSize: 代码节区的大小
currentSet = {}
currentP = 0
while(currentP < codeSize){ //没到结尾
(instr, size) = decode(code, currentP); //解码存放结果
current = current ∪ {(currentP, instr, size)}; //结果集合并
currentP += size;
}
buildCFG(instrSet) //构建基本块, 加边, 从而生成CFG(Control-Flow Graph)

优点:简单易实现 缺点:如果代码区有数据可能会误认为是代码

递归遍历算法

主要原理:沿着反汇编过程中生成的控制流图对指令进行反汇编 例:IDA Pro使用该算法 伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
worlist = {0};    //可理解为待消费队列
processed = {}; //可理解为消费者队列
while(worklist <> {}){
offset = removeOneNode(Worklist); //取出一个工作量,记下偏移
processed = processed ∪ {offset}; //将该偏移加入工作队列
(instr, size) = decode(code, offset);//解码
switch (instr): //根据解码的内容进行对应的流程
case non-control-flow-inst : add(offset+size);
case unconditional-jmp(dest : add(dest));
case cond-jmp(dest1, dest2 : add(dest1 ; add(dest2)));
...
}

Procedure add (offset):
if (offs t ∉ processe ) then worklist = worklist ∪ {offset}
优点:能够容忍数据嵌入在代码节区中的情况

缺点:对于间接跳转/调用(indiret jumps/calls),很难决定控制流边到底指向哪里

常用Windows核心API

  1. CreateRemoteThread:创建一个在另一个进程的地址空间中运行的线程
1
2
3
4
5
6
7
8
9
10
HANDLE CreateRemoteThread(
HANDLE [hProcess], //处理进程来创建线程
LPSECURITY_ATTRIBUTES [lpThreadAttributes], //指向线程安全属性的指针
DWORD [dwStackSize], //初始线程堆栈大小,以字节为单位
LPTHREAD_START_ROUTINE [lpStartAddress], //指向线程函数的指针
LPVOID [lpParameter参数], //指向新线程的参数的指针
DWORD [dwCreationFlags], //创建标志
LPDWORD [lpThreadId] //指向返回的线程标识符
);
// 典型用法CreateRemoteThread(hProcess(处理线程的进程句柄), NULL, 0, ThreadProc, Paramter, 0 NULL)
  1. OpenProcess 返回现有进程对象的句柄
1
2
3
4
5
6
HANDLE OpenProcess(
DWORD [dwDesiredAccess], //访问标志
BOOL [bInheritHandle], //处理继承标志
DWORD [dwProcessId] //进程标识符en
);
// 典型用法:OpenProcess(PROCESS_ALL_ACCESS, false, dwPID);
  1. CreateThread 创建一个在调用进程的地址空间内执行的线程
1
2
3
4
5
6
7
8
9
CreateThread(
LPSECURITY_ATTRIBUTES [lpThreadAttributes],//指向线程安全属性的指针,若为NULL则句柄不可被继承
DWORD [dwStackSize], //初始线程堆栈大小,以字节为单位,若为0则使用默认栈的大小
LPTHREAD_START_ROUTINE [lpStartAddress], //指向线程函数的指针
LPVOID [lpParameter], //新线程参数
DWORD [dwCreationFlags], //创建标志
LPDWORD [lpThreadId] //指向返回的线程标识符
);
// 典型用法:CreateThread(NULL, 0, ThreadProc, Paramter, 0 NULL), 就比CreateRemoteThread少了一个第一个的进程句柄参数
  1. VirtualAllocEx 翻转、提交或更改指定进程虚拟空间中一个内存区域的状态
1
2
3
4
5
6
7
8
LPVOID VirtualAllocEx(
HANDLE [hProcess], //在其中分配内存的进程
LPVOID [lpAddress], //所需的分配起始地址
DWORD [dwSize], //要分配的区域的大小(以字节为单位)
DWORD [flAllocationType], //分配类型
DWORD [flProtect] //访问类型保护
);
**典型的,第二个参数为NULL,flAllocationType为MEM_COMMIT,flProtect为PAGE_REA DWRITE**
  1. WriteProcessMemory 在指定的进程中写入内存。要写入的整个区域必须可访问,否则操作失败
1
2
3
4
5
6
7
BOOL WriteProcessMemory(
HANDLE [hProcess], //处理内存被写入的句柄
LPVOID [lpBaseAddress], //开始写入地址
LPVOID [lpBuffer], //指向缓冲区的指针写入数据
DWORD [nsize], //要写入的字节数
LPDWORD [lpNumberOfBytesWritten] //实际写入的字节数
);
  1. GetProcAddress 返回指定的导出动态链接库(DLL)函数的地址
1
2
3
4
FARPROC GetProcAddress(
HMODULE [HMODULE], //目标DLL模块
LPCSTR [lpProcName] //函数名称
);
  1. WaitForSingleObject 等待指定的对象超时或被信号通知
1
2
3
4
DWORD WaitForSingleObject(
HANDLE [hHandle], //处理对象等待
DWORD [dwMilliseconds] //超时间隔(以毫秒为单位)
);
  1. GetModuleFileName 返回指定模块的的文件的完整路径(前提是已经加载)
1
2
3
4
5
DWORD GetModuleFileName(
HMODULE [HMODULE], //处理模块以查找文件名
LPTSTR [lpFileName], //指向缓冲区的模块路径
DWORD [nsize] //缓冲区的大小,以字符为单位
);
  1. GetModuleHandle 返回指定模块文件的句柄
1
2
3
HMODULE GetModuleHandle(
LPCTSTR [lpModuleName] //返回句柄的模块名称的地址
);
  1. Closehandle 关闭一个句柄对象
1
2
3
BOOL CloseHandle(
HANDLE [hObject] //指定一个打开的句柄对象
);
  1. SetWindowsHookEx 将用户定义的钩子加入到钩子链中
1
2
3
4
5
6
HHOOK SetWindowsHookEx(
INT [idHook], //安装类型的钩子
HOOKPROC [lpfn], //钩子程序的地址
HINSTANCE [HMOD], //应用程序实例的句柄
DWORD [dwThreadId] //安装钩子的线程的身份
);

HHOOK WINAPI setWindowsHookEx(WH_KEYBOARD(钩取键盘消息), KeyBoardProc(回调函数), g_Instance(HINSTANCE类型的dll句柄), 0)

  1. CallNextHookEx 将钩子信息传递给当前钩子链中的下一个钩子过程
1
2
3
4
5
6
LRESULT CallNextHookEx(
HHOOK [HHK], //处理当前挂钩
INT [NCODE], //钩子代码传递给钩子程序
WPARAM [wParam中], //值传递给hook过程
LPARAM [lParam的] //值传递给hook过程
);
  1. UnhookWindowsHookEx 移除由SetWindowsHookEx安装到钩子链中的钩子
1
2
3
BOOL UnhookWindowsHookEx(
HHOOK [HHK] //被移除的钩子的句柄
);

DLL注入的基本概念和方法

DLL注入是指向运行中的其他进程强制插入特定的DLL文件,即,从进程的外部向目标发出条用LoadLibrary()函数的命令,装载我们的DLL文件,强制调用执行该DLL文件中的DllMain()函数

DLL文件中可以有DllMain()函数,当程序装载该Dll文件时会执行该函数,可以将希望执行的放入DllMain()函数中。 基本方法:

  1. 使用CreateRemoteThread()函数创建远程线程
  2. 修改注册表中AppInit_DLLsLoadAppInit_DLLs两个表项来实现
  3. 使用SetWindowsHookEx()函数进行消息钩取

方法一:CreateRemoteThread()

主要原理:通过获取要注入的进程的PID,使用OpenProcess()函数来返回目标进程对象的句柄。伪代码:

1
2
3
4
5
6
7
BOOL InjectDll(DWORD dwPID, LPCTSTR srcDllPath){
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS,false, dwPID);
WriteProcessMemory(hProcess); //向目标进程中写入Dll的路径
GETLoadLibraryW(); //获取当前进程中的LoadLibraryW()函数的地址,由Kernel32.dll导入
CreateRemoteThread(); //创建一个新线程执行LoadLibraryW()函数,其参数为自定义的Dll的路径
next(); //关闭创建的线程和目标进程的句柄返回True
}

如果写的再详细一点的话:

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
BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath){		//dwPID --- 要注入的进程的PID, szDllPath --- 要注入的DLL(你自己写的)路径
DWORD dwBufSize = (DWORD)(lstrlen(szDllPath) + 1) * sizeof(TCHAR); //计算要放入内存的内容的大小
LPTHREAD_START_ROUTINE pThreadProc; //要使用的线程函数指针
HANDLE hProcess = NULL, hThread = NULL;
LPVOID pRemoteBuf = NULL;
HMOUDLE hMod = NULL;

//先打开进程获取他的句柄:
hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, dwPID)
if (!(hProcess)){
return false;
}

//在该进程中分配空间:
pRemoteBuf = VirtualAlloceEx(hProcess, NULL, dwBufSize, MEM_COMMIT, PAGE_READWRITE)
if (!(pRemoteBuf){
retun false
}

//向新的内存空间内写入数据:使用一个任意类型指针指向LPCTSTR类型的、要加载的DLL名称,大小为上面开的dwBuSize
WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID)szDllPath, dwBufSize, NULL);

//获得LoadLibraryA函数的地址
hMod = GetModuleHandle(L"kernel32.dll");
pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hMod, L"LoadLibraryA");
//创建线程
pThreadProc = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

CloseHandle(hProcess);
CloseHandle(hThread);
return TRUE; //注入成功
}

方法二:修改注册表项

主要原理:注册表中有当某进程加载User32.dll时,可默认加载一个自定义DLL的表项。

具体为将HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows下的AppInit_DLLs修改为你要注入的DLL的路径,LoadAppInit_DLLs修改为1。

方法三:SetWindowsHookEx()

"消息钩子"就是子事件消息从操作系统向应用程序传递的过程中,获取、查看、修改和拦截这些消息的机制

主要原理:将自定义的钩子加入钩子链中。如钩取键盘消息,将SetWindowsHookEx()函数的第一个参数设置为WH_KEYBOARD。伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//TestHook.c
int _tmain(){
LoadLibraryA("Hook.dll"); //加载自定义的DLL
HookStart();
return True;
}

//Hook.dll的实现代码
BOOL WINAPI DllMain(){
switch(dwReason){
CALL_PROCESS_ATTACH:
//获取句柄
break;
}
}

LRESULT CALLBACK KeyboardProc(){
if (isNotepad){
hook(); //钩取消息
} else{
//传递给下一个钩子
}
}

DLL卸载的基本原理

DLL的卸载只有通过强制目标进程调用FreeLibrary()函数来实现,这是其基本原理

代码注入

主要原理:CreateRemoteThread函数可以更一般化的直接将独立运行的代码插入目标进程中并使之运行,这一技术称为代码注入/线程注入技术。

和DLL注入的区别

1. DLL注入时,其程序代码所使用的数据存在于DLL的数据节区,所以注入时一同被注入进去;而代码注入使用的数据需要预先注入并告知目标代码
2. 相较于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
//核心数据结构
/* 我的理解:
这个结构体是一种你前期准备的函数和后期开干的函数都能使用的结构体
1. 首先,后两个函数指针用于储存两个意图很明显的函数的地址(从kernel32.dll中),LoadLibraryA()函数()和GetProcAddress()函数,(ps:这几乎也是pwn的思路)。前者获取我要向目标进程中加载的dll,后者用于我调用我加载的dll中的指定函数。那么这两个函数的参数在哪里呢?看前两个变量。
2. 其次,前两个变量主要用于设置上述两个函数的参数,顾名思义,moduleName存放dll名称,procName存放要调用的函数的名称
3. 最后,这里牵扯两个函数,InjectCode函数用于初始化真正注入部分的参数(开空间、写内存),ThreadProc函数执行真正的注入调用。
*/
typedef struct _THREAD_PARAM{
char moduleName[128];
char procName[128];
FARPROC pLoadLibrary;
FARPROC pGetProcAddress
} THREAD_PARAM;

BOOL WINAP ThreadProc(LOVOID IParam){
hack(); //使用传入的结构体中的内容进行注入
}

BOOl InjectCode(LPCTSTR procName){
setData(); //初始化结构体内容:要注入的dll名称,要调用的函数名称,以及LoadLibraryA和GetProcAddress函数的地址
InjectMemory_1(); //在目标进程中开辟内存单元存放上述参数
InjectMemory_2(); //在目标进程中开辟内存单元存放真正注入的函数的函数指针,类型为LPVOID
CreateRemoteThread();//核心步骤,创建一个线程,调用你真正的注入函数,参数为InjectMemory_1中写入的内容
return TRUE;
}

int _tmain(){
InjectCode(_T("notepad.exe")); //指定目标进程
return TRUE;
}

调试方式的API钩取

一共有三种方式进行API的钩钩取:调试方式的钩取、修改IAT(Import Address Table)表、修改API代码

调试方式的钩取

下面函数实现的是当被调试函数触发了事件后,钩取writeFile函数的代码,并将notepad.exe要保存到文件的内容后移一个字母保存 一般步骤: 1. 将需要被钩取的进程设置为被调试者 2. 将被调试者的API的第一个字节改为0xCC 3. 在被调试者进程中该API调用时,被调试者挂起,控制权转入调试器 4. 调试器执行预定义操作 5. 脱钩(将第一字节值改回去),恢复被调用者进程,使其执行正常API功能 6. 调试器再次钩取该API,将第一个字节值改为0xCC 7. 转3

  流程变化图    伪代码:

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
BYTE INT3 = 0xCC
BYTE OriginalByte = 0

BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde){
//获取WriteFile()函数的地址
LPVOID pWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile");

GEThProcess(); //获取被调试者的进程句柄
GEThThread(); //获取被调试者的初始线程句柄

ReadProcessMemory(OriginalByte); //保存原来的字节
WriteProcessMemory(INT3); //写入0xCC
return TRUE;
}

BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde){
hook(); //执行相应的钩取动作
}

int main(int argc, char *argv[]){
DWORD dwPID; //设置要被调试的进程的PID
DEBUG_EVENT de; //设置调试触发事件的结构体DEBUG_EVENT
if (argc != 2){
printf("请输入要钩取API的进程的PID\n");
}
dwPID = atoi(argv[1]);
//设置被调试的进程的PID
if (!DebugActiveProcess(dwPID))
return 1;
//一直等待事件的发生
while(WaitForDebugEvent(&de)){
//被调试进程创建或被关联到调试器执行
if (CREATE_PROCESS_DEBUG_EVENT == de.dwDebugEventCode){
OnCreateProcessDebugEvent(&de);
}
//异常事件发生
else if (EXCEPTION_DEBUG_EVENT == de.dwDebugEventCode){
if (OnExceptionDebugEvent(&de))
continue;
}
else if (EXIT_PROCESS_DEBUG_EVENT == de.dwDebugEventCode){
break; //调试器终止
}
}
return 0;
}

  下面是上述的代码的简单流程图:   流程变化图

修改IAT表

通过更改PE文件的IAT表,将API函数的地址修改为钩取函数的地址,自己定义的钩取函数中进行完要执行的操作后再调用kernel32.dll中的正常API功能

修改API代码

DLL映射进目标进程的地址空间后,直接查找目标API的实际地址,直接修改其代码

CATALOG
  1. 1. 一些基础概念
    1. 1.1. IA-32指令集
    2. 1.2. 静态反汇编算法
      1. 1.2.1. 线性扫描算法
      2. 1.2.2. 递归遍历算法
    3. 1.3. 常用Windows核心API
    4. 1.4. DLL注入的基本概念和方法
      1. 1.4.1. 方法一:CreateRemoteThread()
      2. 1.4.2. 方法二:修改注册表项
      3. 1.4.3. 方法三:SetWindowsHookEx()
      4. 1.4.4. DLL卸载的基本原理
    5. 1.5. 代码注入
    6. 1.6. 调试方式的API钩取
      1. 1.6.1. 调试方式的钩取
      2. 1.6.2. 修改IAT表
      3. 1.6.3. 修改API代码