C++逆向

什么?对象,虚?

PE 文件格式

为什么会有这个?逆向为什么没有这个!

微软官方文档:PE 格式 - Win32 apps

使用 010editor 查看。需要先下载插件:模板->模板库->安装 exe.bt 。alt+4 可以查看PE文件格式

DOS 头

两个重要的变量 e_magic(判断是否为DOS头,固定为5a4d) 和 e_lfanew(NT头的偏移)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER

DOS stub

MS-DOS 存根是在 MS-DOS 下运行的有效应用程序。 它放置在 EXE 映像的前面。 链接器在此处放置一个默认存根,当映像在 MS-DOS 中运行时,该存根输出消息“此程序无法在 DOS 模式下运行”。

NT 头

IMAGE_NT_HEADERS 结构

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;

签名

签名是一个固定的值

1
#define IMAGE_NT_SIGNATURE                  0x00004550  // PE00

FileHeader

文件头包含了很多重要的属性

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 取决于机器,ARM, x86...
WORD NumberOfSections; // Section 数量
DWORD TimeDateStamp; // 文件创建时间
DWORD PointerToSymbolTable; // 指向符号表
DWORD NumberOfSymbols; // 符号表中符号个数
WORD SizeOfOptionalHeader; // 可选头的大小
WORD Characteristics; // 文件属性,是否为DLL等
} IMAGE_FILE_HEADER, * PIMAGE_FILE_HEADER;

OptionalHeader

可选头

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
typedef struct _IMAGE_OPTIONAL_HEADER64 {
WORD Magic; // 魔术字
BYTE MajorLinkerVersion; // linker 版本号
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint; // 程序执行入口RVA
DWORD BaseOfCode; // 代码的节的起始RVA
ULONGLONG ImageBase; // 优先装载地址
DWORD SectionAlignment; // 内存中的节的对齐
DWORD FileAlignment; // 文件中的节的对齐
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics; // DLL文件特性
ULONGLONG SizeOfStackReserve;
ULONGLONG SizeOfStackCommit;
ULONGLONG SizeOfHeapReserve;
ULONGLONG SizeOfHeapCommit;
DWORD LoaderFlags; // 与调试有关
DWORD NumberOfRvaAndSizes; // 数据目录结构的项目数量
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER64, * PIMAGE_OPTIONAL_HEADER64;

ImageBase: 文件在内存中的首选装入地址。如果有可能(也就是说,目前如果没有其他占据这块地址,它是正确对齐的并且是一个合法的地址,等等),加载器试图在这个地址装入PE文件。如果可执行文件是在这个地址装入的,那么加载器将跳过应用基址重定位的步骤。

Section

节的初始化数据由简单的字节块组成。 但是,对于全部为零的节,不需要包含节数据。

每个节的数据位于节头中的 PointerToRawData 字段给出的文件偏移量处。 文件中此数据的大小由 SizeOfRawData 字段指示。 如果 SizeOfRawData 小于 VirtualSize,则其余部分用零填充。

这里的对齐和可选头的 SectionAlignmentFileAlignment 相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 名称 .text .bss 等
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress; // 节区的 RVA 地址
DWORD SizeOfRawData; // 文件对齐后的大小
DWORD PointerToRawData; // FOA
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // 节属性如可读,可写,可执行等
} IMAGE_SECTION_HEADER, * PIMAGE_SECTION_HEADER;

HelloWorld

非常简单的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int func(int a, int b, int c, int d, int e, int f, int g)
{
return a;
}

int main()
{
int a;
std::cout << "hello world" << std::endl;
a = 10;
std::cout << func(a, 2, 3, 4, 5, 6, 7) << std::endl;
return 0;
}

带符号表的反汇编,可以看出其比较长的 namespace 和 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __fastcall main()
{
std::ostream *v0; // rax
unsigned int v1; // eax
__int64 v2; // rax

j___CheckForDebuggerJustMyCode(&_4C11826D_reverse_cpp);
v0 = std::operator<<<std::char_traits<char>>(std::cout, "hello world");
std::ostream::operator<<(v0, std::endl<char,std::char_traits<char>>);
v1 = func(10, 2, 3, 4, 5, 6, 7);
v2 = std::ostream::operator<<(std::cout, v1);
std::ostream::operator<<(v2, std::endl<char,std::char_traits<char>>);
return 0i64;
}

CALL 方式

具体参考:自变量传递和命名约定 | Microsoft Learn****

__cdecl 是 C 和 C++ 程序的默认调用约定。 堆栈由caller清理,函数传递顺序 从右到左

1
void __cdecl method();

__stdcall 调用约定用于调用 Win32 API 函数。callee清理堆栈,函数传递方式 从右到左。

1
2
#define WINAPI __stdcall
void __stdcall method();

__fastcall 调用约定指定尽可能在寄存器中传递函数的自变量。 此调用约定仅适用于 x86 体系结构。参数传递

  • x86 从左到右的顺序找到前两个 DWORD 或更小自变量将在 ECX 和 EDX 寄存器中传递;所有其他自变量在堆栈上从右向左传递。
  • x64 下是 rcx, rdx, r8, r9

__thiscall 的调用约定用于 x86 体系结构上的 C++ 类成员函数,特定于 Microsoft

比如说 call func 的 汇编代码,我在x64下是 fastcall

1
2
3
4
5
6
7
8
9
.text:0000000140011CEE                 mov     [rbp+0F0h+a], 0Ah
.text:0000000140011CF5 mov [rsp+130h+g], 7 ; g
.text:0000000140011CFD mov [rsp+130h+f], 6 ; f
.text:0000000140011D05 mov [rsp+130h+e], 5 ; e
.text:0000000140011D0D mov r9d, 4 ; d
.text:0000000140011D13 mov r8d, 3 ; c
.text:0000000140011D19 mov edx, 2 ; b
.text:0000000140011D1E mov ecx, [rbp+0F0h+a] ; a
.text:0000000140011D21 call j_?func@@YAHHHHHHHH@Z ; func(int,int,int,int,int,int,int)

但是在 x86 就是 cdecl call

1
2
3
4
5
6
7
8
9
.text:0041253C                 push    7               ; g
.text:0041253E push 6 ; f
.text:00412540 push 5 ; e
.text:00412542 push 4 ; d
.text:00412544 push 3 ; c
.text:00412546 push 2 ; b
.text:00412548 mov eax, [ebp+a]
.text:0041254B push eax ; a
.text:0041254C call j_?func@@YAHHHHHHHH@Z ; func(int,int,int,int,int,int,int)

但是不变的是 rax 为函数返回值

Class

测试代码

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
#include <iostream>
#include <vector>

class Person {
private:
int _age;
public:
Person() {
_age = 0;
std::cout << "person" << std::endl;
}
virtual ~Person() {
std::cout << "~person" << std::endl;
}
virtual int GetAge() = 0;
virtual void SetAge(int age) = 0;
};

class Boy : public Person {
public:
Boy() {
std::cout << "boy" << std::endl;
}
virtual ~Boy() {
std::cout << "~boy" << std::endl;
}
int GetAge() {
std::cout << "boy getage" << std::endl;
return 114;
}
void SetAge(int age) {
std::cout << "boy setage" << std::endl;
}
};

int main() {
std::vector<Boy> boys;
Boy b;
boys.push_back(b);
auto f = [](int a, int b) -> int {
std::cout << "lambda" << std::endl;
return a + b;
};
std::cout << f(1, 2) << std::endl;
return EXIT_SUCCESS;
}

C++ 名称粉碎机制:举个例子 std::cout 名字是 __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A:qword

  • 更好的重载

MSVC 反编译出来,优化比较严重,因此使用g++

方法

析构函数和构造函数

构造函数调用:函数声明时;析构函数:生命周期结束时

反汇编时,方法第一个参数一般是 this 指针

继承

在构造时,构造顺序为先构造父类,后构造子类。

1
2
3
4
5
6
7
8
9
void __fastcall Boy::Boy(Boy *this)
{
__int64 v1; // rax

Person::Person(this);
*(_QWORD *)this = off_4CC8; // 虚指针
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "boy");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
}

在析构时,析构顺序为先析构子类,后析构父类。

1
2
3
4
5
6
7
8
9
void __fastcall Boy::~Boy(Boy *this)
{
__int64 v1; // rax

*(_QWORD *)this = off_4CC8; // vfptr
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "~boy");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
Person::~Person(this);
}

虚函数

虚函数也是函数,体现了CPP的多态性。

根据对象的实际类型来调用成员函数,而不是根据指针的类型来对该块内存进行解释并调用属于该指针类型所属的成员函数,简化代码实现多态性

  • 一个很好的解释:父类声明子类Set<String> m = new HashMap<String>,但是调用方法是子类的

cpp中使用一个指针和一个表格来进行实现的。这个表格称为虚表vftable ,这个指针称为虚表指针。

  • 如果一个类中有虚函数,编译器则会在对象的首部加入虚表指针,以存放所属于该对象的虚表

虚表指针的填充时机:只有在构造函数和析构函数中会对虚表指针重新赋值,其他位置没有对该指针的操作

  • 虚函数在类的首部

其在构造函数和析构函数给vfptr赋值

1
2
3
4
5
6
7
.data.rel.ro:0000000000004CC8 off_4CC8        dq offset _ZN3BoyD2Ev   ; DATA XREF: Boy::Boy(void)+1D↑o
.data.rel.ro:0000000000004CC8 ; Boy::~Boy()+10↑o ...
.data.rel.ro:0000000000004CC8 ; Boy::~Boy()
.data.rel.ro:0000000000004CD0 dq offset _ZN3BoyD0Ev ; Boy::~Boy()
.data.rel.ro:0000000000004CD8 dq offset _ZN3Boy6GetAgeEv ; Boy::GetAge(void)
.data.rel.ro:0000000000004CE0 dq offset _ZN3Boy6SetAgeEi ; Boy::SetAge(int)
.data.rel.ro:0000000000004CE8 public _ZTV6Person ; weak

析构函数为什么也要重新给虚表赋值呢?

  • 编译器无法预知这个子类以后是否会被其他类继承,如果被继承,就成了父类,在执行析构函数时会先执行当前对象的析构函数,然后向祖父类的方向按继承线路逐层调用各类析构函数,当前对象的析构函数开始执行时,其虚表也是当前对象的,所以执行到父类的析构函数时,虚表必须改写为父类的虚表

引用与指针

指针

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
#include <iostream>

int main() {
int a = 100;
printf("a=%d\n", a);
int *p = &a;
*p = 200;
printf("a=%d\n", a);
return 0;
}

push rbp
mov rbp, rsp
sub rsp, 20h
mov rax, fs:28h
mov [rbp+var_8], rax
xor eax, eax
mov [rbp+var_14], 64h ; 'd'
mov eax, [rbp+var_14]
mov esi, eax
lea rax, format ; "a=%d\n"
mov rdi, rax ; format
mov eax, 0
call _printf
lea rax, [rbp+var_14]
mov [rbp+var_10], rax ; *p
mov rax, [rbp+var_10]
mov dword ptr [rax], 0C8h
mov eax, [rbp+var_14]
mov esi, eax
lea rax, format ; "a=%d\n"
mov rdi, rax ; format
mov eax, 0
call _printf

左值引用

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
#include <iostream>

int main() {
int a = 100;
printf("a=%d\n", a);
int &p = a;
p = 200;
printf("a=%d\n", a);
return 0;
}

push rbp
mov rbp, rsp
sub rsp, 20h
mov rax, fs:28h
mov [rbp+var_8], rax
xor eax, eax
mov [rbp+var_14], 64h ; 'd'
mov eax, [rbp+var_14]
mov esi, eax
lea rax, format ; "a=%d\n"
mov rdi, rax ; format
mov eax, 0
call _printf
lea rax, [rbp+var_14]
mov [rbp+var_10], rax
mov rax, [rbp+var_10]
mov dword ptr [rax], 0C8h
mov eax, [rbp+var_14]
mov esi, eax
lea rax, format ; "a=%d\n"
mov rdi, rax ; format
mov eax, 0
call _printf

指针和左值引用:在汇编看起来一模一样,引用也是为了满足C++特性,比如操作符重载

右值引用???

  • 函数参数右值引用和值传递作为参数

lambda 表达式

比较简单的lambda 可以看出来其参数类型与个数

1
2
3
4
5
6
7
8
9
10
v3 = main::{lambda(int,int)#1}::operator()(&v6, 1LL, 2LL);

__int64 __fastcall main::{lambda(int,int)#1}::operator()(__int64 a1, int a2, int a3)
{
__int64 v3; // rax

v3 = std::operator<<<std::char_traits<char>>(&std::cout, "lambda");
std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
return (unsigned int)(a2 + a3);
}

debugger

WinDbg 命令:Common WinDbg Commands
x64dbg 文档: x64dbg documentation

windbg 常见命令,感觉 preview 更加好看

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
// 断点 b
bp: 断点
bl: list
bc: clear

// 查看 display/dump
d{a|b|c|d|D|f|p|q|u|w|W} [Options] [Range]
db: dump byte
dw: word
dd: dword
dq: qword
dt: type

// 单步执行
g: go
t: trace,步入
p: 步过

// 查看寄存器
r: register

// 调用栈
k: dump stack
.frame

// 搜索
s: search

// 修改
e: edit

某些命令

1
2
3
4
5
.echo 
.reboot
.reload
.cls: 清屏幕
.help

x64dbg 操作起来比较容易,可以使用鼠标和右键😋。除了某些命令