什么是逆向工程?
逆向工程意味着将一个对象分解为更简单的组成部分,以了解其内部工作原理。
什么是GDB?
GDB,即GNU调试器,是一个用于调试用C、C++、Go、Rust等语言编写的程序的工具。它作用于编译源代码后生成的二进制文件。
GDB可以用于在运行时了解程序的底层工作原理。我们可以看到寄存器中存储的值或地址、堆栈中存储的值、下一条指令等。我们甚至可以在程序行之间设置断点,这在调试中非常有用,允许我们在每条指令后查看寄存器中的值,从而分析变化是如何发生的。
一些重要术语
寄存器
寄存器是一种内置于CPU中的计算机内存类型。它们是临时存储,非常快。有不同类型的寄存器:
- RSP – 堆栈指针(指向堆栈顶部)
- RBP – 基址指针(指向堆栈基址)
- RAX, RBX, RCX, RDX – 通用寄存器
- 函数返回值存储在RAX寄存器中。
- RDI – 临时寄存器(用于传递函数的第一个参数)
- RSI – 临时寄存器(用于传递函数的第二个参数)
- RIP – 指令指针寄存器(指向要执行的下一条指令)
堆栈
在汇编中,堆栈向下增长。具有较低地址值的堆栈包含较新的元素。
标志寄存器
标志寄存器包含关于处理器在执行指令后状态的信息。它用于条件分支。
编写C程序
让我们编写一个简单的C程序,然后用GDB调试它。我们还可以看到逆向工程如何允许攻击者从二进制文件中泄露/窃取敏感信息。
#include <stdio.h>
#include <string.h>
int main() {
char inputstr[100];
printf("Welcome to the game \\n");
printf("Enter the password to continue...\\n");
if (scanf("%s", inputstr) != 1) {
fprintf(stderr, "Error reading input.\\n");
return 1;
}
if (strcmp(inputstr, "mypassword") == 0) {
printf("Your password is correct! Please move forward!");
return 1;
} else {
printf("Incorrect password! Try Again.");
}
return 0;
}
程序解释
这个C程序提示用户输入密码(字符串),然后将用户输入与硬编码的密码进行检查,并输出密码是否正确的信息。
我们可以通过使用gcc
编译上述代码来生成二进制文件。
gcc -o sample sample.c
(假设程序名为sample.c,生成的二进制文件名为sample)
编译C程序并生成二进制文件
现在,假设我们只有一个应用程序的二进制文件而没有源代码。这时,我们可以使用GDB来调试它并了解应用程序的工作原理。编译 C 程序并为其生成二进制文件。
对编译的程序进行逆向工程
使用以下命令 gdb sample
在二进制文件上运行 GDB
disass main
命令用于反汇编main函数并提供其汇编代码。然而,默认情况下提供的汇编代码使用AT&T格式,不易阅读。
我们可以通过设置格式为Intel使其可读:
set disassembly-flavor intel
Dump of assembler code for function main:
0x0000000000001179 <+0>: push rbp
0x000000000000117a <+1>: mov rbp,rsp
0x000000000000117d <+4>: sub rsp,0x70
0x0000000000001181 <+8>: lea rax,[rip+0xe80] # 0x2008
0x0000000000001188 <+15>: mov rdi,rax
0x000000000000118b <+18>: call 0x1030 <puts@plt>
0x0000000000001190 <+23>: lea rax,[rip+0xe89] # 0x2020
0x0000000000001197 <+30>: mov rdi,rax
0x000000000000119a <+33>: call 0x1030 <puts@plt>
0x000000000000119f <+38>: lea rax,[rbp-0x70]
0x00000000000011a3 <+42>: mov rsi,rax
0x00000000000011a6 <+45>: lea rax,[rip+0xe95] # 0x2042
0x00000000000011ad <+52>: mov rdi,rax
0x00000000000011b0 <+55>: mov eax,0x0
0x00000000000011b5 <+60>: call 0x1060 <__isoc99_scanf@plt>
0x00000000000011ba <+65>: cmp eax,0x1
0x00000000000011bd <+68>: je 0x11e9 <main+112>
0x00000000000011bf <+70>: mov rax,QWORD PTR [rip+0x2e7a] # 0x4040 <stderr@GLIBC_2.2.5>
0x00000000000011c6 <+77>: mov rcx,rax
0x00000000000011c9 <+80>: mov edx,0x15
0x00000000000011ce <+85>: mov esi,0x1
0x00000000000011d3 <+90>: lea rax,[rip+0xe6b] # 0x2045
0x00000000000011da <+97>: mov rdi,rax
0x00000000000011dd <+100>: call 0x1070 <fwrite@plt>
0x00000000000011e2 <+105>: mov eax,0x1
0x00000000000011e7 <+110>: jmp 0x1237 <main+190>
0x00000000000011e9 <+112>: lea rax,[rbp-0x70]
0x00000000000011ed <+116>: lea rdx,[rip+0xe67] # 0x205b
0x00000000000011f4 <+123>: mov rsi,rdx
0x00000000000011f7 <+126>: mov rdi,rax
0x00000000000011fa <+129>: call 0x1050 <strcmp@plt>
0x00000000000011ff <+134>: test eax,eax
0x0000000000001201 <+136>: jne 0x121e <main+165>
0x0000000000001203 <+138>: lea rax,[rip+0xe5e] # 0x2068
0x000000000000120a <+145>: mov rdi,rax
0x000000000000120d <+148>: mov eax,0x0
0x0000000000001212 <+153>: call 0x1040 <printf@plt>
0x0000000000001217 <+158>: mov eax,0x1
0x000000000000121c <+163>: jmp 0x1237 <main+190>
0x000000000000121e <+165>: lea rax,[rip+0xe73] # 0x2098
0x0000000000001225 <+172>: mov rdi,rax
0x0000000000001228 <+175>: mov eax,0x0
0x000000000000122d <+180>: call 0x1040 <printf@plt>
0x0000000000001232 <+185>: mov eax,0x0
0x0000000000001237 <+190>: leave
0x0000000000001238 <+191>: ret
End of assembler dump.
现在,我们可以开始分析汇编代码,并将其与原始C源代码进行比较,以理解其工作原理
RBP 是基本指针寄存器,RSP 是堆栈指针寄存器。rsp 的值被复制到 rbp(使用 mov 命令)以设置基本参考,因为 rsp 寄存器值在程序期间不断变化,但 rbp 寄存器值不会。
sub rsp,0x70
命令用于为堆栈分配空间。
在程序中,我们使用了 char inputstr[100];为字符数组分配 100 个字符的长度来存储字符串。因此,程序集为堆栈分配 0x70 个字节,即 112 个字节,并将堆栈的顶部(即 rsp)向下移动 112 个字节。堆栈在装配中向下增长。
源代码中有两个 printf 语句,由汇编中的两个 put 方法调用表示。
对于函数调用,第一个参数以 rdi 寄存器的形式传递,因此我们可以看到 rdi 寄存器在调用 put 方法之前是用 rax 寄存器的值(使用 mov 命令)设置的。
rax 寄存器设置为当前指令 0x80 个字节后的存储器位置地址。此地址将包含要由 put 函数打印的语句。
在程序中,正在调用 scanf 方法来获取用户输入。scanf 函数需要两个参数(格式说明符和用于存储用户输入的内存位置)。
调用函数时的第一个参数是 rdi,第二个参数是 rsi。
scanf 函数将用户输入存储到 rsi 中存储的地址(rbp-0x70 – 表示基本指针下方 112 个字节),并将使用存储在 rdi 寄存器提供的地址中的格式说明符。rdi 寄存器包含地址 rip+0xe95 中存在的格式说明符的地址。函数的返回值存储在 rax 寄存器中,eax 是 rax 寄存器中使用 4 个字节的较小版本。eax 寄存器中的值与 1 进行比较,因为 scanf 函数的输出与源代码中的 1 进行比较。如果该值不是 1,则程序将打印错误消息并退出。
在汇编代码中,如果 eax 的值与 1 匹配(条件为 true),则使用 je 命令将控件移动到其他语句。
如果条件为 false,则控件仅传递到准备调用 fprintf 函数的下一行。
在汇编代码中,fprintf 函数被实现为接受 4 个参数的 fwrite 函数。这里的 4 个参数是寄存器(rdi、edx、esi、ecx)
。RDI 包含要打印的字符串。EDX 包含字符串的大小(0x15 字节),ESI 包含要打印的字符串数 (1),ECX 指向 stderr(标准错误流)。
jmp 0x1237 <main+190>
命令控制返回 main 函数末尾的行以退出程序。
如果 scanf 函数返回值为 1
(表示用户输入成功),则程序将跳转到 je 0x11e9 <main+112>
用户输入的 if 条件之外的 if 值。在下一行中,再次有一个 if 条件,其中正在检查函数 strcmp
的输出。
在这里,调用 strcmp 函数来比较两个字符串——用户输入和程序中的实际硬编码密码。strcmp
函数接受两个参数,因此要设置 RDI(第一个参数)和 RSI(第二个参数)的值。其中一个寄存器将存储硬编码密码,另一个寄存器将存储用户输入。
我们可以在 strcmp 函数调用时设置断点并检查寄存器的值。两者都将具有字符串起始字符的地址。
现在,如果我们在设置断点后再次键入 start 命令,该命令将再次运行,默认断点位于 main。我们可以通过键入 c 命令来继续。程序执行 scanf
函数并请求用户输入,之后程序停止在我们在 strcmp 函数调用时设置的断点处。
我们可以使用该命令 x/5i $rip
来执行接下来的 5 条指令。我们可以看到 strcmp
函数调用在下一条指令中。因此,程序必须设置参数列表以调用包含用户输入和硬编码密码的 strcmp
函数。
GDB 允许我们使用 x/s
命令打印从以下地址中指定的地址开始的字符串字符,直到到达换行符。 x/s $rdi
并 x/s $rsi
可用于打印 RDI
和 RSI
寄存器中两个地址处的字符串值。
因此,即使我们没有源代码,我们也可以看到硬编码的密码。