网安导实验二教程

网安导实验二:“GDB 使用”的教程

网安导大概是我第一次也是最后一次用 GDB。

最后一次GDB

话不多说,直接上教程。

什么是 GDB,为什么是 GDB?

GDB 是 GNU Debugger 的缩写,是一个强大的程序调试工具。GDB 可以帮助程序员在程序运行时检查程序的内部状态,查看变量的值,查看函数的调用栈,查看寄存器的值等等。

用 GDB 的原因很简单,因为我们的实验要求使用 GDB 调试程序。GDB 提供了多语言、跨平台的支持,并且其功能比你现在想象的应该还要强大的多。

VS Code 的 GDB 调试

VS Code 可以通过 DAP 协议调用 GDB 调试 C 和 C++ 程序,这种界面化的方式简单清晰,非常适合有源码的情况下调试程序。

正常来说安装了 Code Runner 插件可以直接在右上角启动调试,并且不需要额外配置。

Code Runner 插件提供的调试

在 VS Code 编辑区,左侧行号前若鼠标悬停会出现淡色红点,点击即可设置断点。断点,故名思义,就是调试程序在运行至这行代码时会暂停,方便我们检查程序的运行状态。

调试开始后,VS Code 编辑器上方会出现调试工具栏,包括以下功能按钮:

  • 继续(Continue):继续运行程序,直到遇到下一个断点。
  • 逐过程(Step Over):执行当前行代码,但不进入函数内部。
  • 单步调试(Step Into):逐步执行当前行代码,并进入函数内部。
  • 单步跳出(Step Out):执行完当前函数的剩余代码,并返回到调用该函数的地方。
  • 重启(Restart):重新启动调试会话。
  • 停止(Stop):停止调试会话。

这些按钮可以帮助你在调试过程中更好地控制程序的执行流程。

VS Code 最左侧侧边栏中的调试视图中提供了一些调试信息,包括:

  • 变量(Variables):显示当前作用域内的变量及其值。
    • Locals:显示当前函数内的局部变量。
    • Registers:显示 CPU 寄存器的值(下面会详细介绍)。
  • 堆栈(Call Stack):显示当前函数调用栈。
  • 监视(Watch):可以添加需要监视的变量,方便查看其值的变化。
  • 断点(Breakpoints):显示当前设置的断点。

运行和调试视图中的“创建 launch.json 文件”

若不使用 Code Runner 插件,需要手动创建 launch.json 文件,配置调试环境。VS Code 会在调试视图中提示你创建 launch.json 文件,点击即可创建。

对于我来说,我希望对我正在写的程序进行调试,要在 .c 文件的同一目录下编译生成可执行文件,并进行调试。这时,launch.json 文件的配置如下:

.vscode/launch.json
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
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "C Debug",
"type": "cppdbg",
"request": "launch",
"program": "${fileDirname}/${fileBasenameNoExtension}",
"args": [],
"stopAtEntry": false,
"cwd": "${fileDirname}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "build",
"miDebuggerPath": "/usr/bin/gdb",
"logging": {
"trace": true,
"traceResponse": true,
"engineLogging": true
}
}
]
}

在运行和调和视图中运行此配置,会提示缺少 build 任务,点击配置任务,选择 g++ build active file 即可。

.vscode/tasks.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "gcc",
"args": [
"-g",
"${file}",
"-o",
"${fileDirname}/${fileBasenameNoExtension}"
],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": ["$gcc"],
"detail": "Generated task for building C files"
}
]
}

.vscode 文件夹

.vscode 文件夹是 VS Code 的配置文件夹,生成在项目根目录下。其中包含了一些配置文件,如 settings.jsonlaunch.jsontasks.json 等。这些配置文件可以帮助我们配置 VS Code 的一些行为,使得 VS Code 更符合我们的使用习惯。

命令行下的 GDB 调试

在命令行下使用 GDB 调试程序,需要先编译程序时加上 -g 选项,以便生成调试信息。例如:

1
gcc -g -o program program.c

然后使用 GDB 调试程序:

1
gdb program

启动和过程与 VS Code 一样,不过是将所有操作变为命令行操作。

GDB 命令速查表

提供速查表:

断点(Breakpoints)

命令 描述
breakb 设置一个断点
break <line_number> 在特定行号设置断点
break <function_name> 在特定函数设置断点
break <file_name>:<line_number> 在特定文件的特定行号设置断点
break <file_name>:<function_name> 在特定文件的特定函数设置断点
deleted 删除所有断点
delete <breakpoint_number> 删除特定断点
disable b <breakpoint_number> 禁用特定断点
enable b <breakpoint_number> 启用特定断点
info b 列出所有断点

运行(Running)

命令 描述
runr 运行程序
run <args> 带参数运行程序
run < <input_file> 从文件输入运行程序

单步执行(Stepping)

命令 描述
nextn 单步执行(不进入函数)
steps 单步执行(进入函数)
finish 执行到当前函数结束

打印(Printing)

命令 描述
printp 打印变量值
print <expression> 打印表达式的值
display 每次程序停止时打印变量值
undisplay 停止打印变量值
x 检查内存
x/<n><format><size> 以特定格式和大小检查内存

格式说明符(Format Specifiers)

格式 描述
x 十六进制
d 十进制
u 无符号十进制
o 八进制
t 二进制
f 浮点数
a 地址
c 字符
s 字符串
i 指令
m 十六进制带 ASCII

大小说明符(Size Specifiers)

大小 描述
b 字节
h 半字(2 字节)
w 字(4 字节)
g 巨字(8 字节)

寄存器(Registers)

命令 描述
info registers 列出所有寄存器
info registers <register> 列出特定寄存器

堆栈(Stack)

命令 描述
backtracebt 打印当前堆栈跟踪
frame 打印当前帧
frame <frame_number> 选择特定帧
up 向上移动堆栈跟踪
down 向下移动堆栈跟踪

内存(Memory)

命令 描述
info proc mappings 列出内存映射
info proc status 列出内存状态
info files 列出文件

线程(Threads)

命令 描述
info threads 列出所有线程
thread <thread_number> 选择特定线程

命令(Commands)

命令 描述
commands 列出在断点命中时运行的命令
commands <breakpoint_number> 列出在特定断点命中时运行的命令
commands <breakpoint_number> <command> 添加在特定断点命中时运行的命令
end 结束命令列表

杂项(Miscellaneous)

命令 描述
quitq 退出 GDB
helph 列出所有命令
help <command> 显示特定命令的帮助信息

进阶

如果你已经尝试了命令行版的 GDB,你或许会觉得这是个反人类的工具。但是它的设计蕴含着它的用途。下面,我们将介绍 GDB 的进阶用法。

汇编调试

汇编调试包含两种情况,编译型语言在某些情况下只有汇编调试才能解决问题,以及无源码的情况下进行 RE。本实验旨在帮助同学们接触了解汇编调试,不会深入讲解。

编译过程

编译型程序,以 C 语言为例,有如下的编译过程,以 1.c 为例:

这里只是以 gcc (GNU Compiler Collection) 的编译过程为例,其他编译器的过程类似。

1.c
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

#define hello "Hello world."
#define max(a, b) ((a) > (b) ? (a) : (b))

int main()
{
int a = 1, b = 2;
printf("%s\n", hello);
printf("%d\n", max(a, b));
return 0;
}
1
gcc -g -o 1 1.c

它的编译过程如下:

  1. 预处理:gcc -E 1.c -o 1.i-E 选项表示只进行预处理,不进行编译。此步骤会将 #include 的头文件内容插入到源文件中,将宏展开,将注释去除等。1.i 是个很大的文件,其中 main 函数的内容如下:
1.i 节选
1
2
3
4
5
6
7
8
...
int main()
{
int a = 1, b = 2;
printf("%s\n", "Hello world.");
printf("%d\n", ((a) > (b) ? (a) : (b)));
return 0;
}
  1. 编译:gcc -S 1.i -o 1.s。此步骤会将 C 语言源代码编译成汇编代码。1.s 文件内容如下:
1.s
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
	.file	"1.c"
.text
.section .rodata
.LC0:
.string "Hello world."
.LC1:
.string "%d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $1, -8(%rbp)
movl $2, -4(%rbp)
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl -4(%rbp), %edx
movl -8(%rbp), %eax
cmpl %eax, %edx
cmovge %edx, %eax
movl %eax, %esi
leaq .LC1(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
  1. 汇编:gcc -c 1.s -o 1.o。这将汇编代码编译成目标文件。1.o 是目标文件,可以被链接器链接成可执行文件。
  2. 链接:gcc 1.o -o 1。这将目标文件链接成可执行文件。

.so 与链接

.so 文件是共享库文件(Shared Object),在 Linux 系统中用于动态链接。动态链接的优点是可以减少可执行文件的大小,并且多个程序可以共享同一个库文件,从而节省内存。

.so 文件是二进制文件,在编译时,可以使用 -shared 选项生成共享库文件。例如:

1
gcc -shared -o libexample.so example.c

在链接时,可以使用 -l 选项链接共享库文件。例如:

1
gcc -o program program.c -L. -lexample

其中,-L. 表示在当前目录下查找库文件,-lexample 表示链接 libexample.so 文件。

在运行时,需要设置 LD_LIBRARY_PATH 环境变量,以便系统能够找到共享库文件。例如:

1
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH

这样,系统就可以在当前目录下查找共享库文件。

寄存器

汇编代码是机器码的助记符表示,是机器码的文本形式。即,计算机执行程序时,会按照汇编代码的指令执行。

要完成实验内容,我们需要知道:

  • 指令由 CPU 执行。
  • CPU 内部有寄存器,现在的 CPU 寄存器有 64 位(64 bits)。
  • 寄存器的速度很快(1ns),比内存快很多(100-150ns)。

    寄存器为什么比内存快

    来自阮一峰的网络日志:为什么寄存器比内存快?

    为了减少内存访问时间的影响,CPU 内部有一些缓存,它们响应速度依次变慢,但是容量依次变大,用于减少 CPU 在请求资源后的等待时间;CPU 被设计成可中断并且可以在中断后恢复执行,这样可以“同时”执行多个任务。不过,我们在本次实验中,知道寄存器和内存就够了。

本次我们只介绍 x86 和 x86-64(x64) 的汇编代码,即 Intel 汇编代码。为方便后续漏洞的实验,使用的是 Intel 风格语法。

指令集

Intel 和 AMD 是两家 CPU 制造商,它们使用的是 x64 指令集。x86 是 32 位的(这里的位指的是寄存器的位数),x64 是 64 位的;32 位的 CPU 只能运行 32 位系统,64 位的 CPU 可以运行 32 位和 64 位系统。

其他的 CPU 也有自己的指令集,如 ARM 指令集(手机 CPU)、MIPS 指令集(龙芯和一些嵌入式设备)等。

或许你在学校的讲座中听过 RISC 和 CISC。它们是两种指令集设计理念,CISC 希望通过复杂的硬件实现可以执行复杂功能的指令,而 RISC 希望通过简单的硬件实现可以执行简单功能的指令,再通过组合多个简单指令实现复杂功能。x86 是 CISC 指令集,ARM 和 MIPS 是 RISC 指令集。

寄存器分为这样几种:

  • 通用寄存器。名如 raxrbxrcxrdxrsirdirbprsp
  • 标志寄存器。名如 ZFSFOFCFPF。它们只有 1 位,用于标志运算结果,均位于 rflags 寄存器中。
  • 指令寄存器,为 rip,用于存储下一条指令的地址。
  • ……

rax (64 位)是从 eax (32 位)继承而来的,eax 是从 ax (16 位)继承而来的,ax 是从 ahal (8 位)继承而来的。如图所示:

RAX/EAX/AX/AL在 x86-64 构架里的情况

其他寄存器也是类似的命名规则。

寄存器用途

理论上,每个寄存器都可以执行任意的功能,并可以使程序任意的使用。但在实际情况中,编辑器会给寄存器分配特定的功能:

  • rax 用于存储函数的返回值;或是存储累加结果。
  • rbx 数据存取,例如存放数组的基址。
  • rcx 用于存储循环计数器,比如充做 for(int i = 0; i < n; i++) 中的 i
  • rdx 读写数据,例如存放 I/O 端口号。
  • rsi 在进行字符串操作时,存放源字符串的地址。
  • rdi 在进行字符串操作时,存放目的字符串的地址,常与 rsi 配合使用。
  • rbp 用于存储栈帧的基址指针,指向当前栈帧的基址。
  • rsp 用于存储栈指针,指向当前栈顶。

这些功能不用刻意去记,遇到分析不出的代码多查查资料就好了。

Objdump

objdump 用于给出目标文件的汇编代码。此脚本将编译 .c 文件并给出汇编代码。

see_amd64.sh
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
#!/bin/bash
# 一个将 .c 文件编译成 .o 并链接成可执行文件,再输出其汇编代码的脚本
# 用法:./see_amd_intel.sh file.c,在 file.c 下生成 file.s 文件

# 编译成 .o 文件
gcc -c $1

# 链接成可执行文件
gcc -o ${1%.*} $1

# 输出汇编代码,并且是 intel 格式
objdump -d -M intel ${1%.*} > ${1%.*}.s

# 删除 .o 文件
rm ${1%.*}.o

# 输出结果
echo "生成 ${1%.*}.s 文件"

# 若 code 已打开,则在当前窗口打开生成的 .s 文件
if [ -n "$(pgrep code)" ]; then
code ${1%.*}.s
fi

# 删除可执行文件
rm ${1%.*}

# 退出
exit 0

创建文件后,使用 chmod +x see_amd64.sh 添加执行权限。

汇编入门

不知道为什么 Hexo 不给渲染 asm 代码块,将就着看吧。

程序栈

栈是一种数据结构,它是一种先进后出(First In Last Out)的数据结构。栈的特点是只能在栈顶进行操作,即只能在栈顶压入数据和弹出数据。你可以想像它是一个薯片筒,你只能在筒口放入薯片和取出薯片,不能对中间的薯片进行操作。

程序栈是程序运行时的栈,用于存储函数的局部变量、函数的参数、函数的返回地址等。程序栈是由操作系统分配的,每个线程都有自己的程序栈。

程序栈的大小根据操作系统和编译器而不同。若空间用尽,程序栈溢出,会导致程序崩溃。

空函数
empty.c
1
2
3
4
5
6
7
8
9
int return_13754()
{
return 13754;
}

int main()
{
return 0;
}

其对应的汇编代码可以精简成这样,尽管并不能正常运行:

手写空函数
1
2
3
4
5
main:
ret
return_13754:
mov eax, 13754
ret
empty_under_O3_NoFCF.s 部分内容
1
2
3
4
5
6
7
0000000000001040 <main>:
1040: 31 c0 xor eax,eax
1042: c3 ret

0000000000001140 <return_13754>:
1140: b8 ba 35 00 00 mov eax,0x35ba
1145: c3 ret

eax 通常用于存储累加结果,并存储函数返回值。

xor eax, eax 是将 eax 寄存器的值与自己进行异或操作,结果为 0,即将 eax 寄存器清零。 ret 是函数返回指令,它会将栈顶的地址弹出到 rip 寄存器中,即返回到调用函数的下一条指令。

mov (move) 命令名字带有误解性,它实际上是将右边的值赋给左边的寄存器。mov eax, 0x35ba 是将 0x35ba 赋给 eax 寄存器。

empty.s 部分内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0000000000001129 <return_13754>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: b8 ba 35 00 00 mov $0x35ba,%eax
1136: 5d pop %rbp
1137: c3 ret

0000000000001138 <main>:
1138: f3 0f 1e fa endbr64
113c: 55 push %rbp
113d: 48 89 e5 mov %rsp,%rbp
1140: b8 00 00 00 00 mov $0x0,%eax
1145: 5d pop %rbp
1146: c3 ret

endbr 是一种程序保护机制使用的指令,我们不必理会。 这段代码包括了程序函数的基本结构,序言和尾声。

序言
1
2
push %rbp       ; 保存调用者的栈帧
mov %rsp, %rbp ; 设置新的栈帧

push 是将寄存器的值压入栈中,pop 是将栈顶的值弹出到寄存器中。rbp 是基址指针,用于指向当前栈帧的基址。rsp 是栈指针,用于指向当前栈顶。经过序言,原函数的栈低被保存,新函数的栈顶与栈底被设置。

尾声
1
2
pop %rbp       ; 恢复调用者的栈帧
ret ; 返回到调用者

ret 是函数返回指令,它会将栈顶的地址弹出到 rip 寄存器中,即返回到调用函数的下一条指令。

这个过程的栈看上去会是这样的: 空函数栈的结构

在执行到尾声前,程序栈一定会返回右侧栈状态,否则将会发生段错误。

参数传递
add.c
1
2
3
4
5
6
7
8
9
int add(int a, int b)
{
return a + b;
}

int main()
{
return add(1, 2);
}
add.s 部分内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0000000000001129 <add>:
1129: f3 0f 1e fa endbr64
112d: 55 push rbp
112e: 48 89 e5 mov rbp,rsp
1131: 89 7d fc mov DWORD PTR [rbp-0x4],edi
1134: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
1137: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
113a: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
113d: 01 d0 add eax,edx
113f: 5d pop rbp
1140: c3 ret

0000000000001141 <main>:
1141: f3 0f 1e fa endbr64
1145: 55 push rbp
1146: 48 89 e5 mov rbp,rsp
1149: be 02 00 00 00 mov esi,0x2
114e: bf 01 00 00 00 mov edi,0x1
1153: e8 d1 ff ff ff call 1129 <add>
1158: 5d pop rbp
1159: c3 ret

DWORD 是双字,即 4 字节。DWORD PTR 表示指向 4 字节的指针。[rbp-0x4] 表示 rbp 寄存器的值减去 0x4,即栈中的第一个参数。[rbp-0x8] 表示栈中的第二个参数(int 类型占 4 字节)。ediesi 是通用寄存器,用于存储函数的参数。

ediesi 分别存储了 12,随后进入 add 函数,ediesi 被存入 DWORD PTR [rbp-0x4]DWORD PTR [rbp-0x8],即 ab,进行加法运算后返回。

如图所示: add 函数栈的结构

通常,参数传递是通过寄存器传递的,但是当参数过多时,会通过栈传递,即先将参数压入栈中,再进行函数调用,函数内部再将参数读取出来。

局部变量
local.c
1
2
3
4
5
6
7
8
9
10
int add(int a, int b)
{
int c = a + b;
return c;
}

int main()
{
return add(1, 2);
}
local.s 部分内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
0000000000001129 <add>:
1129: f3 0f 1e fa endbr64
112d: 55 push rbp
112e: 48 89 e5 mov rbp,rsp
1131: 89 7d ec mov DWORD PTR [rbp-0x14],edi
1134: 89 75 e8 mov DWORD PTR [rbp-0x18],esi
1137: 8b 55 ec mov edx,DWORD PTR [rbp-0x14]
113a: 8b 45 e8 mov eax,DWORD PTR [rbp-0x18]
113d: 01 d0 add eax,edx
113f: 89 45 fc mov DWORD PTR [rbp-0x4],eax
1142: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1145: 5d pop rbp
1146: c3 ret

0000000000001147 <main>:
1147: f3 0f 1e fa endbr64
114b: 55 push rbp
114c: 48 89 e5 mov rbp,rsp
114f: be 02 00 00 00 mov esi,0x2
1154: bf 01 00 00 00 mov edi,0x1
1159: e8 cb ff ff ff call 1129 <add>
115e: 5d pop rbp
115f: c3 ret

DWORD PTR [rbp-0x14]DWORD PTR [rbp-0x18] 分别存储了 ab 的值,DWORD PTR [rbp-0x4] 存储了 c 的值。[rbp-0x14][rbp-0x18]ab 的存储位置,[rbp-0x4]c 的存储位置。

条件语句
condition.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int max(int a, int b)
{
if (a > b)
{
return a;
}
else
{
return b;
}
}

int main()
{
return max(1, 2);
}
condition.s 部分内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0000000000001129 <max>:
1129: f3 0f 1e fa endbr64
112d: 55 push rbp
112e: 48 89 e5 mov rbp,rsp
1131: 89 7d fc mov DWORD PTR [rbp-0x4],edi
1134: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
1137: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
113a: 3b 45 f8 cmp eax,DWORD PTR [rbp-0x8]
113d: 7e 05 jle 1144 <max+0x1b>
113f: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1142: eb 03 jmp 1147 <max+0x1e>
1144: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
1147: 5d pop rbp
1148: c3 ret

0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push rbp
114e: 48 89 e5 mov rbp,rsp
1151: be 02 00 00 00 mov esi,0x2
1156: bf 01 00 00 00 mov edi,0x1
115b: e8 c9 ff ff ff call 1129 <max>
1160: 5d pop rbp
1161: c3 ret

我们先来详细介绍下 rflags 寄存器。它的结构如图所示:

rflags 寄存器结构

(这实际上是 eflags 寄存器,rflags 的高 32 位现在还没有设计用途)。它的每一位都有特定的含义,并且可以通过 setclear 指令设置和清除。通常,这此位是由 CPU 指令设置的。例如,cmp (compare) 指令会将两个操作数相减(不影响寄存器的值),并根据结果设置 ZFSFOFCF 等位(设指令为 cmp a, b):

  • 无符号时:
    • ZF (zero flag) 为 1 表示两个操作数相等。
    • CF (carry flag) 为 1 表示无符号数相减时发生了借位或错位,相减只能是借位,即 a < b
    • ZFCF 均为 0,则 a > b
  • 有符号时:
    • SF (sign flag) 为 1 表示结果为负数,OF (overflow flag) 为 1 表示发生了溢出。两者均为 1, 则 a < b(a 为负数,b 为正数)。
    • SFOF 均为 0,则 a > b
    • SF 为 1,OF 为 0,a < b
    • SF 为 0,OF 为 1,a > b

jle (jump if less or equal) 指令会根据 ZFSF 位的值跳转。类似的命令还有 jl (jump if less)、jg (jump if greater)、jge (jump if greater or equal) 等。

这样,我们就有了条件语句的实现。

循环语句
loop.c
1
2
3
4
5
6
7
8
9
int sum(int n)
{
int sum = 0;
for (int i = 1; i <= n; i++)
{
sum += i;
}
return sum;
}
loop.s 部分内容
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
0000000000001129 <sum>:
1129: f3 0f 1e fa endbr64
112d: 55 push rbp
112e: 48 89 e5 mov rbp,rsp
1131: 89 7d ec mov DWORD PTR [rbp-0x14],edi
1134: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0
113b: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
1142: eb 0a jmp 114e <sum+0x25>
1144: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1147: 01 45 f8 add DWORD PTR [rbp-0x8],eax
114a: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
114e: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1151: 3b 45 ec cmp eax,DWORD PTR [rbp-0x14]
1154: 7e ee jle 1144 <sum+0x1b>
1156: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
1159: 5d pop rbp
115a: c3 ret

000000000000115b <main>:
115b: f3 0f 1e fa endbr64
115f: 55 push rbp
1160: 48 89 e5 mov rbp,rsp
1163: 48 83 ec 10 sub rsp,0x10
1167: c7 45 f8 64 00 00 00 mov DWORD PTR [rbp-0x8],0x64
116e: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
1171: 89 c7 mov edi,eax
1173: e8 b1 ff ff ff call 1129 <sum>
1178: 89 45 fc mov DWORD PTR [rbp-0x4],eax
117b: b8 00 00 00 00 mov eax,0x0
1180: c9 leave
1181: c3 ret

事情其实比较简单,任何的的循环都可以转化为分支语句与 goto 的结合,使用循环语句不过是为了抽象程序运行流程,使得程序更加易读。

1
2
3
4
5
for (int i = 0; i < 10; i++)
{
// do something
}

1
2
3
4
5
6
7
8
int i = 0;
loop:
if (i < 10)
{
// do something
i++;
goto loop;
}

goto 正对应于 jmp 指令。

小端序与大端序

在计算机中,数据的存储方式有两种:小端序(Little Endian)和大端序(Big Endian)。小端序是指低位字节存储在低地址,高位字节存储在高地址;大端序是指高位字节存储在低地址,低位字节存储在高地址。换句话说,大端序是人类大多数语言的从左到右的读写方式,而小端序反之。

大端序与小端序(来源网络)

是的,这是一个没有用的图片,并且它实际上没解释任何东西,我只是觉得这个地方应该有张图片。

尽管大端序对人类更友好,但小端序有其独特优势。例如,若你想要计算 0x12345678 + 0x12345678,若是大端序,读取顺序为 0x120x340x560x78,然后从 0x78 向前进行运算;而对于小端序,其存储顺序为 0x780x560x340x12,从 0x78 向后进行运算,这样就可以直接进行加法运算,而不需要进行进位操作。

在 x86 架构中,是小端序存储的,字符串的存储方式也是小端序的,例如:

hello.c
1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
char hello[] = "Hello world.";
printf("%s\n", hello);
return 0;
}
hello.s 部分内容
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
0000000000001169 <main>:
1169: f3 0f 1e fa endbr64
116d: 55 push rbp
116e: 48 89 e5 mov rbp,rsp
1171: 48 83 ec 20 sub rsp,0x20
1175: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28
117c: 00 00
117e: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
1182: 31 c0 xor eax,eax
1184: 48 b8 48 65 6c 6c 6f movabs rax,0x6f77206f6c6c6548
118b: 20 77 6f
118e: 48 89 45 eb mov QWORD PTR [rbp-0x15],rax
1192: 48 b8 20 77 6f 72 6c movabs rax,0x2e646c726f7720
1199: 64 2e 00
119c: 48 89 45 f0 mov QWORD PTR [rbp-0x10],rax
11a0: 48 8d 45 eb lea rax,[rbp-0x15]
11a4: 48 89 c7 mov rdi,rax
11a7: e8 b4 fe ff ff call 1060 <puts@plt>
11ac: b8 00 00 00 00 mov eax,0x0
11b1: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
11b5: 64 48 2b 14 25 28 00 sub rdx,QWORD PTR fs:0x28
11bc: 00 00
11be: 74 05 je 11c5 <main+0x5c>
11c0: e8 ab fe ff ff call 1070 <__stack_chk_fail@plt>
11c5: c9 leave
11c6: c3 ret

mov rax,QWORD PTR fs:0x28 是一种程序保护机制,我们可以先忽略它。movabs 是将立即数赋给寄存器,在这里,你可以把它当作 mov 解读。lea 是将地址赋给寄存器,rdi 是函数参数寄存器,call 是函数调用指令。

这里,我们触发了函数使用栈传递参数的情况,rdi 寄存器存储了字符串的地址,puts 函数会输出字符串。0x6f77206f6c6c6548 对应于字符串 ow olleH0x2e646c726f7720 对应于字符串 .dlrow

118e119c 中的 rbp-0x15rbp-0x10 相隔 5,而 rax 的大小是 8 个字节,两者重复并覆盖的部分对应于 wo 三个字符。

编译器令其有重叠的原因是不重叠没有办法正确表示它。Hello world.12 个字符,rax8 字节,重叠可以正确表示字符串,即区别 Hello world.Hello world.\0\0\0\0

另一种情况是编译器将多个变量重叠以节省空间,例如存在两个字符串 Hello world.world.,编译器会将它们重叠,节省空间。

IDA Pro

IDA(Interactive Disassembler)是一款反汇编工具,它可以将二进制文件反汇编成汇编代码,由 Hex-Rays 公司开发。IDA Pro 是其商业版本,支持商业许可、Python 脚本等功能。

IDA 普通版包括了我们本次实验的需要的所有功能,但还是提供 Pro 版本。要激活,请在其安装文件夹下运行提供的 keygen2.py 即可。

其单键快捷键有些混乱,如下:

  • C Code
  • D Data
  • N Rename
  • U Undefine
  • A String
  • X Xrefs
  • R Charactor
  • S Segment

GDB 拓展

这里想要介绍一些 GDB 的拓展功能,包括 GDB 脚本、GDB Python、GDB 插件。

GDB 脚本

GDB 支持脚本拓展,虽然原生语法并不太友好,但至少可以实现一些简单的功能。脚本文件以 .gdb 结尾。

script.gdb
1
2
3
file a.out
b main
r

脚本运行方法为 gdb -x script.gdb-x 选项表示执行脚本。也可以先启动 GDB,然后在 GDB 命令行中使用 source script.gdb 命令。

若你有兴趣可以自行查阅文档写带条件或循环的脚本,不过感觉没什么必要,因为 GDB 的语法非常特殊,不类似任何一种语言(稍微有点像 bash)。此外,参数是通过文本替换来处理的,这意味着处理包含空格或特殊字符的参数会非常困难(甚至不可能)。

用户定义命令

以下内容译自 GDB 官方文档 23.3.1 节:

用户定义命令是将一系列 GDB 命令赋予一个新名称作为命令。这是通过 define 命令完成的。

用户命令可以接受任意数量的由空格分隔的参数。在用户命令中通过 $arg0...$argN 访问参数。参数是文本替换的,因此它们可以引用变量、使用复杂表达式,甚至执行被调试程序的函数调用。然而,这种文本替换意味着处理某些参数会比较困难。例如,用户无法传递包含空格的参数;而将参数字符串化可以使用类似 "$arg1" 的表达式,但如果参数包含引号,这将会失败。对于更复杂和健壮的命令,我们建议使用 Python 编写;参见第 23.3.2.21 节 [Python 中的 CLI 命令],第 463 页。

一个简单的例子:

1
2
3
define adder
print $arg0 + $arg1 + $arg2
end

要执行该命令,使用:

1
adder 1 2 3

这定义了命令 adder,它打印其三个参数的和。

此外,$argc 可以用来确定传递了多少参数。

1
2
3
4
5
6
7
8
define adder
if $argc == 2
print $arg0 + $arg1
end
if $argc == 3
print $arg0 + $arg1 + $arg2
end
end

结合 eval 命令(参见 [eval],第 398 页)可以更容易地处理可变数量的参数:

1
2
3
4
5
6
7
8
9
define adder
set $i = 0
set $sum = 0
while $i < $argc
eval "set $sum = $sum + $arg%d", $i
set $i = $i + 1
end
print $sum
end

GDB Python

我们可以通过 Python 脚本扩展 GDB 的功能,例如自动化调试、自定义命令等。这里,我们只是简单介绍一下基础用法。

以下警告译自 GDB 官方文档,不过我们在实验中不会涉及到这些问题。

  • gdb 安装 SIGCHLD 和 SIGINT 的处理程序。Python 代码不能覆盖这些,甚至不能使用 sigaction 更改选项。如果你的程序改变了对这些信号的处理,gdb 很可能会停止正常工作。请注意,不幸的是,GUI 工具包安装 SIGCHLD 处理程序是很常见的。当创建一个新的 Python 线程时,你可以使用 gdb.block_signals 或者 gdb.Thread 来正确地处理这个问题; 请参见第 409 页 23.3.2.2 节 [GDB 中的线程]。

  • gdb 注意将其内部文件描述符标记为 close-on-exec。但是,这并不能在所有平台上以线程安全的方式完成。你的 Python 程序应该意识到这一点,并且应该在创建新的文件描述符时设置 close-on-exec 标志,并在启动子进程之前关闭不需要的文件描述符。

基础用法
1
gdb -q -x script.py

-q 选项表示静默模式,不显示 GDB 的启动信息。-x 选项表示执行脚本。

或者在 GDB 命令行中使用 source script.py 命令。

script.py
1
2
3
4
5
6
7
8
9
import gdb

def main():
gdb.execute("file a.out")
gdb.execute("b main")
gdb.execute("r")

if __name__ == "__main__":
main()

import gdb 导入 GDB 模块。gdb.execute 函数用于执行 GDB 命令。类似地,我们可以用 gdb.execute 完成 GDB 动态调试的自动化。

自动化测试

例如,我们可能会注意到自己的程序在某些情况下会崩溃退出,想要找到崩溃的输入。我们可以通过 Python 脚本自动化这个过程。

crash.c
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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void process_input(char *input)
{
char buffer[10];
strcpy(buffer, input); // 这里存在缓冲区溢出漏洞,实际情况会更复杂
printf("Processed input: %s\n", buffer);
}

void handle_request(char *request)
{
printf("Handling request: %s\n", request);
process_input(request);
}

void server_loop()
{
char *request = (char *)malloc(100 * sizeof(char));
if (request == NULL)
{
fprintf(stderr, "Memory allocation failed\n");
exit(1);
}

while (1)
{
printf("Enter a request: ");
fgets(request, 100, stdin);
request[strcspn(request, "\n")] = '\0'; // 去除换行符

if (strcmp(request, "exit") == 0)
{
break;
}

handle_request(request);
}

free(request);
}

int main()
{
server_loop();
return 0;
}
crash.py
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
import gdb
import string
import itertools

def main():
gdb.execute("file crash")
gdb.execute("set disable-randomization on") # 关闭 ASLR,这样每次运行程序的地址都是固定的
gdb.execute("b handle_request")
gdb.execute("b *0x00005555555553c7")
# 我们想要测试的字符集
charset = string.ascii_letters + string.digits # 可以根据实际情况修改
print(charset)

# 主要思想是,我们不断构造输入请求,直到程序崩溃。因此,我们跳过 `server_loop` 函数,直接进入 `handle_request` 函数,进行测试并重复循环。

for length in range(1, 101): # 假设最大输入长度为 100
for candidate in itertools.product(charset, repeat=length): # itertools.product 会生成所有长度为 length 的字符串
test_input = ''.join(candidate)
print(f"\033[1;32mTesting: {test_input}\033[0m", end='\r')
gdb.execute(f"run < <(echo {test_input})", to_string=True)
# 如果程序崩溃,我们就可以看到类似如下的输出:
# Program received signal SIGSEGV, Segmentation fault.
# 因此我们可以通过 GDB 的输出来判断程序是否崩溃
if "SIGSEGV" in gdb.execute("bt", to_string=True):
print(f"Crash found with input: {test_input}")
break
# 若没有崩溃,继续下一个测试。此时程序在 *0x00005555555553c7 处停止,我们可以继续输入下一个测试用例
# gdb.execute("kill")

if __name__ == "__main__":
main()
多线程

以下内容译自 GDB 官方文档 23.3.2.2 节:

GDB 并不是线程安全的。如果你的 Python 程序使用了多个线程,你必须小心,只能在 GDB 线程中调用 GDB 特定的函数。GDB 提供了一些函数来帮助处理这个问题。

gdb.block_signals() [函数]

如前所述(参见第 23.3.2.1 节 [Basic Python]),某些信号必须传递给 GDB 主线程。block_signals 函数返回一个上下文管理器,该管理器将在进入时阻塞这些信号。这可以在启动新线程时使用,以确保信号在新线程中被阻塞,例如:

1
2
with gdb.block_signals():
start_new_thread()
gdb.Thread [类]

这是 Python 的 threading.Thread 类的子类。它重写了 start 方法,以调用 block_signals,使其成为在 GDB 中创建线程的易用替代品。

gdb.interrupt() [函数]

这会使 GDB 反应,就像用户在终端输入了一个 control-C 字符一样。也就是说,如果被调试程序正在运行,它会被中断;如果 GDB 命令正在执行,它会被停止;如果 Python 命令正在运行,会引发 KeyboardInterrupt。与 GDB 中的大多数 Python API 不同,interrupt 是线程安全的。

gdb.post_event(event) [函数]

event(一个不带参数的可调用对象)放入 GDB 的内部事件队列。这个可调用对象将在稍后的某个时间点,在 GDB 的事件处理过程中被调用。使用 post_event 发布的事件将按发布的顺序运行;然而,无法知道它们相对于 GDB 内部的其他事件何时被处理。

与 GDB 中的大多数 Python API 不同,post_event 是线程安全的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(gdb) python
>import threading
>
>class Writer():
> def __init__(self, message):
> self.message = message;
> def __call__(self):
> gdb.write(self.message)
>
>class MyThread1 (threading.Thread):
> def run (self):
> gdb.post_event(Writer("Hello "))
>
>class MyThread2 (threading.Thread):
> def run (self):
> gdb.post_event(Writer("World\n"))
>
>MyThread1().start()
>MyThread2().start()
>end
(gdb) Hello World

这个示例展示了如何使用 gdb.post_event 在多线程环境中安全地调用 GDB 函数。

以下是例子:

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
import gdb
import string
import itertools
import threading

# 测试函数
def test_input(charset, length, start, end):
for candidate in itertools.product(charset, repeat=length):
test_input = ''.join(candidate)
print(f"\033[92mTesting input: {test_input}\033[0m", end='\r')
gdb.execute(f"run < <(echo {test_input})", to_string=True)
if "SIGSEGV" in gdb.execute("bt", to_string=True):
print(f"\033[91mCrash found with input: {test_input}\033[0m")
return
gdb.execute("kill")

def main():
gdb.execute("file crash")
gdb.execute("set disable-randomization on") # 关闭 ASLR,这样每次运行程序的地址都是固定的
gdb.execute("b handle_request")
gdb.execute("b *0x00005555555553c7")
# 我们想要测试的字符集
charset = string.ascii_letters + string.digits # 可以根据实际情况修改
print(charset)

# 创建线程
threads = []
num_threads = 4 # 线程数量,可以根据需要调整
for length in range(1, 101): # 假设最大输入长度为 100
for i in range(num_threads):
start = i * (len(charset) // num_threads)
end = (i + 1) * (len(charset) // num_threads)
thread = threading.Thread(target=test_input, args=(charset[start:end], length, start, end))
threads.append(thread)
thread.start()

# 等待所有线程完成
for thread in threads:
thread.join()

if __name__ == "__main__":
main()

GDB 插件

有不少 GDB 插件可以为 GDB 提供图形化界面、自动化调试等功能,例如 GEF、Pwndbg、gdb-dashboard 等。

GEF github

Pwndbg github

gdb-dashboard github