GDB的那些奇淫技巧

gdb也用了好几年了,虽然称不上骨灰级玩家,但也有一些自己的经验,因此分享出来给大家,顺便也作为一个存档记录。

多进程调试

最近在调试一个漏洞的exploit时遇到一个问题。目标漏洞程序是一个 CGI 程序,由主进程调起,而且运行只有一瞬的时间;我的需求是想要在在该程序中下断点,在内存布局之后可以调试我的 shellcode,该如何实现?当然目标程序是没有符号的,而且我希望下的断点是一个动态地址。在 lldb 中有--wait-for,gdb 里却没有对应的命令,经过多次摸索,终于总结出一个比较完美的解决方案。

这里构建一个简单的示例来进行实际演示。首先是父进程:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char **argv, char **env) {
    printf("parent started, pid=%d\n", getpid());

    char *line = NULL;
    size_t len = 0;
    ssize_t read;
    while ((read = getline(&line, &len, stdin)) != -1) {
        pid_t pid = fork();
        if (pid == -1) {
            perror("fork");
            break;
        }

        if (pid == 0) {
            printf("1 fork return in child, pid=%d\n", getpid());
            char *const av[] = {"child", line, NULL};
            if (-1 == execve("./child", av, env)) {
                perror("execve");
                break;
            }
        } else {
            printf("2 fork return in parent, child pid=%d\n", pid);
            int status = 0;
            wait(&status);
        }
    }

    return 0;
}

子进程很简单:

#include <stdio.h>
#include <string.h>

void vuln(char *str) {
    char buf[4];
    strcpy(buf, str);
    printf("child buf: %s", buf);
}

int main(int argc, char **argv) {
    puts("child started");
    vuln(argv[1]);
    return 0;
}

这里编译子进程时候指定-no-pie,并且strip掉符号。我们的调试目标是断点在子进程的strcpy中,拓展来说是希望能断点在子进程的任意地址上。

通过搜索可以找到一个 stackoverflow 的回答: gdb break when entering child process。根据其说法,使用 set follow-fork-mode child即可。这是一个 gdb 命令,其目的是告诉 gdb 在目标应用调用fork之后接着调试子进程而不是父进程,因为在 Linux 中fork系统调用成功会返回两次,一次在父进程,一次在子进程。我们来试一下,直接断点在 strcpy 符号中:

gdb child --pid $parent_pid
(gdb) set follow-fork-mode child
(gdb) b strcpy
Breakpoint 1 at 0x4004c0
(gdb) c
Continuing.
Warning:
Cannot insert breakpoint 1.
Cannot access memory at address 0x4004c0

Command aborted.

噢,断点都打不上,理由很简单,因为不同进程之间的虚拟地址空间都不一样。

另外一个回答中说了,虽然不能断在指定地址,但我们可以break main,告诉 gdb 把断点设置在 main 函数。不过我们的子进程是没有符号的,所以break main并没有卵用。

现在已经有了让 gdb 跟着子进程的方法,只不过问题是无法把断点打到子进程上,因为子进程还没有启动,那么用硬件断点可不可以?

gdb child --pid $parent_pid
(gdb) set follow-fork-mode child
(gdb) hb *0x4004c0
Hardware assisted breakpoint 1 at 0x4004c0
(gdb) c
Continuing.
[New process 309]
process 309 is executing new program: /pwn/child

Thread 2.1 "child" received signal SIGABRT, Aborted.
[Switching to process 309]

可以是可以,但是断点压根没有触发,子进程直接拷贝溢出崩溃了都没有停下来!所以硬件断点在这里并没有用。

那么把断点设置在一些起始函数的上呢?根据之前对 ELF 以及动态链接的学习,我们可以断在比如_start或者__libc_start_main上面:

gdb child --pid $parent_pid
(gdb) set follow-fork-mode child
(gdb) b _start
Breakpoint 1 at 0x7fbfb4c30090

实际上该断点也不会触发,因为这个地址是是父进程的地址空间。

不过到现在答案已经呼之欲出了,总结一下,gdb 支持:

  • fork 之后跟踪到子进程
  • 可以设置软断点
  • 子进程有 _start 符号

所以,就有了一个最终方案。

我的最终方案如下:

set detach-on-fork on
set follow-fork-mode child
set breakpoint pending on
b _start
attach $parent_pid
file child
continue

首先告诉 gdb 跟踪子进程;然后设置set breakpoint pending on是为了在设置断点时让 gdb 不强制在对符号下断点时就需要固定地址,这样在b _start时就会 pending 而不是报错;最后再连接到父进程以及加载子进程的符号。

detach-on-fork on是为了在 fork 之后断开父进程,避免 gdb 退出时把父进程杀死,并不是这节的重点。

其中的时序非常重要。如果先 attach 父进程再下断点,那么断点会直接下到父进程空间从而不会触发;如果先读取了子进程的符号再下断点,可能会下在一个错误的虚拟地址上。

这也是我用了很久的一个方法,不过后来我知道了有更官方的解决方式:

set follow-fork-mode child
catch exec

囧,……

Catch Point真是个好东西,支持很多有用的事件:

  • 常规的C++异常事件
  • 系统调用事件(可直接指定系统调用号)
  • 动态库的加载/卸载事件
  • exec/fork/vfork

看来文档搜索能力还有待提高啊。……

多线程调试

在调试大型程序的时候,经常会遇到这么一个问题,即涉及到的线程很多,少则十几个多则上百个线程。在这些线程之间穿梭也是一个常见的困难。

首先最基本的是线程的切换命令:

  • info threads: 查看当前所有的线程
  • thread n: 切换到 id 为n的线程中

对于进程也有类似的命令info inferiors/inferior n,在调试多进程交互的程序时会经常用到。

其次,在对某个线程进行单步调试时,会遇到 CPU 的迷之调度,突然一个next或者nexti就跑到其他线程去了,这个时候有个特殊的参数scheduler-locking可以解决这个问题:

(gdb) help set scheduler-locking
Set mode for locking scheduler during execution.
off    == no locking (threads may preempt at any time)
on     == full locking (no thread except the current thread may run)
          This applies to both normal execution and replay mode.
step   == scheduler locked during stepping commands (step, next, stepi, nexti).
          In this mode, other threads may run during other commands.
          This applies to both normal execution and replay mode.
replay == scheduler locked in replay mode and unlocked during normal execution.

通常设置为step模式可解决单步调试的问题。

程序运行

我经常用到的一个功能是需要使用 gdb 执行某个程序,并且能精确控制程序的参数,包括命令行、标准输入和环境变量等。gdb 的 run 命令就是用来执行程序的。

这里还是先写个示例测试程序:

// demo.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char **argv) {
    int n;
    char buf[10];
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = %s\n", i, argv[i]);

    int nread;
    nread = read(STDIN_FILENO, buf, 10);
    printf("first read: %d\n", nread);
   
    nread = read(STDIN_FILENO, buf, 10);
    printf("second read: %d\n", nread);
    return 0;
}

最基本的,通过 run 命令控制命令行参数:

$ gdb demo
(gdb) run hello world
Starting program: /pwn/demo hello world
argv[0] = /pwn/demo
argv[1] = hello
argv[2] = world

或者在运行前设置args参数:

(gdb) set args hello world
(gdb) run
Starting program: /pwn/demo hello world
argv[0] = /pwn/demo
argv[1] = hello
argv[2] = world

在漏洞挖掘或者 CTF 比赛中经常遇到的情况是某些输入触发了进程崩溃,因此要挂 gdb 进行分析,这时候就需要gdb 挂载的程序能够以指定的标准输入运行。如果标准输入是文件,那很简单:

$ gdb demo
(gdb) run <file

但更多时候为了方便调试,希望能以其他程序的输出来运行,比如:

$ python -c 'print "A"*100' | ./demo

可惜 gdb 不支持这种管道,不过可以通过下面的方法实现:

$ gdb demo
(gdb) run < <(python -c 'print "A"*100')
Starting program: /pwn/demo < <(python -c 'print "A"*100')
argv[0] = /pwn/demo
first read: 10
second read: 10

或者:

$ gdb demo
(gdb) run <<<$(python -c 'print "A"*100')
Starting program: /pwn/demo <<<$(python -c 'print "A"*100')
argv[0] = /pwn/demo
first read: 10
second read: 10

后者实际上是 shell 命令 here string的一种形式。这两种方式是有区别的,注意示例程序中 read 调用会提前返回,所以如果我们想要第一次读取3个字符,第二次读取4个字符的话,就不能一次性全部输入。比如下面这样就不符合预期了:

$ gdb demo
(gdb) run < <(echo -n 1112222)
Starting program: /pwn/demo < <(echo -n 1112222)
argv[0] = /pwn/demo
first read: 7
second read: 0

正确的方式应该是这样:

$ gdb demo
(gdb) run < <(echo -n 111; sleep 1; echo -n 2222)
Starting program: /pwn/demo < <(echo -n 111; sleep 1; echo -n 2222)
argv[0] = /pwn/demo
first read: 3
second read: 4

值得注意的是,这种情况下,使用here string是没用的,因为该字符串是计算完再一次性传给命令:

(gdb) run <<<$(echo -n 111; sleep 1; echo -n 2222)
Starting program: /pwn/demo <<<$(echo -n 111; sleep 1; echo -n 2222)
argv[0] = /pwn/demo
first read: 8
second read: 0

而且这里是8字节,因为末尾还带了个回车。 所以我更偏向于使用第一种方式。

对于运行程序而言,还有个重要的参数来源是环境变量,比如在调试 CGI 程序的时候。这在 gdb 中可以使用environment参数,不过需要注意的是该参数的设置是以空格为切分而不是传统的以=对环境变量赋值。

(gdb) help set environment
Set environment variable value to give the program.
Arguments are VAR VALUE where VAR is variable name and VALUE is value.
VALUES of environment variables are uninterpreted strings.
This does not affect the program until the next "run" command.

还有要注意的是这个参数要求变量是uninterpreted strings,也就是说只能指定可打印字符。如果我们要传输一个的 payload 或者 shellcode 还要用 gdb 调试怎么办呢?我一般使用的方式是在调用 gdb 时指定,比如:

$ env CONTENT_TYPE="$(python -c "print 'A'*10 + '\x04\x03\x02\x01'")" gdb demo
(gdb) run

后记

对于二进制研究人员来说,gdb 是一个锋利的好工具,支持X86、ARM、MIPS、RISCV、Xtensa等各种常用和不常用的系统架构,对其熟练使用有时候可以达到事半功倍的效果,在文末的附录中我也列举了一些比较常用的命令。由于 gdb 本身支持 python 接口,因此现实中使用通常结合一些拓展使用,比如:

这几个我都用过,各有千秋。现在工作中使用更多的是gef,因为安装太方便了,一个文件搞定。

上面这几个拓展可能大家可能都不陌生,但还有另外一个我比较常用的是 gdb-dashboard,其功能更为简单,而且使用的是 gdb 原本的信息,所以支持的指令集更多。比如下面的截图就是我曾经用 gdb + OpenOCD 来调试 ESP32固件的示例:

esp32
Xtensa指令集调试

ESP32是比较少见的Xtensa指令集架构,上面的拓展都不支持,不过 gdb 本身支持,因此配合使用的效果绝佳。

附录: gdb命令表

gdb 还有其他一些小技巧,可以参考awesome-cheatsheets/tools/gdb.txt中的列表。该列表最初由韦神创建,我时不时也会添加一些上去。当然为了方便大家的查阅,这里直接给出汇总表格附录:

命令 含义 备注
gdb object 正常启动,加载可执行
gdb object core 对可执行 + core 文件进行调试
gdb object pid 对正在执行的进程进行调试
gdb 正常启动,启动后需要 file 命令手动加载
gdb -tui 启用 gdb 的文本界面(或 ctrl-x ctrl-a 更换 CLI/TUI)
命令 含义 备注
help 列出命令分类
help running 查看某个类别的帮助信息
help run 查看命令 run 的帮助
help info 列出查看程序运行状态相关的命令
help info line 列出具体的一个运行状态命令的帮助
help show 列出 GDB 状态相关的命令
help show commands 列出 show 命令的帮助
命令 含义 备注
break main 对函数 main 设置一个断点,可简写为 b main
break 101 对源代码的行号设置断点,可简写为 b 101
break basic.c:101 对源代码和行号设置断点
break basic.c:foo 对源代码和函数名设置断点
break *0x00400448 对内存地址 0x00400448 设置断点
info breakpoints 列出当前的所有断点信息,可简写为 info break
delete 1 按编号删除一个断点
delete 删除所有断点
clear 删除在当前行的断点
clear function 删除函数断点
clear line 删除行号断点
clear basic.c:101 删除文件名和行号的断点
clear basic.c:main 删除文件名和函数名的断点
clear *0x00400448 删除内存地址的断点
disable 2 禁用某断点,但是部删除
enable 2 允许某个之前被禁用的断点,让它生效
rbreak {regexpr} 匹配正则的函数前断点,如 ex_* 将断点 ex_ 开头的函数
tbreak function/line 临时断点
hbreak function/line 硬件断点
ignore {id} {count} 忽略某断点 N-1 次
condition {id} {expr} 条件断点,只有在条件生效时才发生
condition 2 i == 20 2号断点只有在 i == 20 条件为真时才生效
watch {expr} 对变量设置监视点
info watchpoints 显示所有观察点
catch exec 断点在exec事件,即子进程的入口地址
命令 含义 备注
run 运行程序
run {args} 以某参数运行程序
run < file 以某文件为标准输入运行程序
run < <(cmd) 以某命令的输出作为标准输入运行程序
run <<< $(cmd) 以某命令的输出作为标准输入运行程序 Here-String
set args {args} ... 设置运行的参数
show args 显示当前的运行参数
cont 继续运行,可简写为 c
step 单步进入,碰到函数会进去
step {count} 单步多少次
next 单步跳过,碰到函数不会进入
next {count} 单步多少次
CTRL+C 发送 SIGINT 信号,中止当前运行的程序
attach {process-id} 链接上当前正在运行的进程,开始调试
detach 断开进程链接
finish 结束当前函数的运行
until 持续执行直到代码行号大于当前行号(跳出循环)
until {line} 持续执行直到执行到某行
kill 杀死当前运行的函数
命令 含义 备注
bt 打印 backtrace
frame 显示当前运行的栈帧
up 向上移动栈帧(向着 main 函数)
down 向下移动栈帧(远离 main 函数)
info locals 打印帧内的相关变量
info args 打印函数的参数
命令 含义 备注
list 101 显示第 101 行周围 10行代码
list 1,10 显示 1 到 10 行代码
list main 显示函数周围代码
list basic.c:main 显示另外一个源代码文件的函数周围代码
list - 重复之前 10 行代码
list *0x22e4 显示特定地址的代码
cd dir 切换当前目录
pwd 显示当前目录
search {regexpr} 向前进行正则搜索
reverse-search {regexp} 向后进行正则搜索
dir {dirname} 增加源代码搜索路径
dir 复位源代码搜索路径(清空)
show directories 显示源代码路径
命令 含义 备注
print {expression} 打印表达式,并且增加到打印历史
print /x {expression} 十六进制输出,print 可以简写为 p
print array[i]@count 打印数组范围
print $ 打印之前的变量
print *$->next 打印 list
print $1 输出打印历史里第一条
print ::gx 将变量可视范围(scope)设置为全局
print 'basic.c'::gx 打印某源代码里的全局变量,(gdb 4.6)
print /x &main 打印函数地址
x *0x11223344 显示给定地址的内存数据
x /nfu {address} 打印内存数据,n是多少个,f是格式,u是单位大小
x /10xb *0x11223344 按十六进制打印内存地址 0x11223344 处的十个字节
x/x &gx 按十六进制打印变量 gx,x和斜杆后参数可以连写
x/4wx &main 按十六进制打印位于 main 函数开头的四个 long
x/gf &gd1 打印 double 类型
help x 查看关于 x 命令的帮助
info locals 打印本地局部变量
info functions {regexp} 打印函数名称
info variables {regexp} 打印全局变量名称
ptype name 查看类型定义,比如 ptype FILE,查看 FILE 结构体定义
whatis {expression} 查看表达式的类型
set var = {expression} 变量赋值
display {expression} 在单步指令后查看某表达式的值
undisplay 删除单步后对某些值的监控
info display 显示监视的表达式
show values 查看记录到打印历史中的变量的值 (gdb 4.0)
info history 查看打印历史的帮助 (gdb 3.5)
命令 含义 备注
file {object} 加载新的可执行文件供调试
file 放弃可执行和符号表信息
symbol-file {object} 仅加载符号表
exec-file {object} 指定用于调试的可执行文件(非符号表)
core-file {core} 加载 core 用于分析
命令 含义 备注
info signals 打印信号设置
handle {signo} {actions} 设置信号的调试行为
handle INT print 信号发生时打印信息
handle INT noprint 信号发生时不打印信息
handle INT stop 信号发生时中止被调试程序
handle INT nostop 信号发生时不中止被调试程序
handle INT pass 调试器接获信号,不让程序知道
handle INT nopass 调试起不接获信号
signal signo 继续并将信号转移给程序
signal 0 继续但不把信号给程序
命令 含义 备注
info threads 查看当前线程和 id
thread {id} 切换当前调试线程为指定 id 的线程
break {line} thread all 所有线程在指定行号处设置断点
thread apply {id..} cmd 指定多个线程共同执行 gdb 命令
thread apply all cmd 所有线程共同执行 gdb 命令
set schedule-locking ? 调试一个线程时,其他线程是否执行
set non-stop on/off 调试一个线程时,其他线程是否运行
set pagination on/off 调试一个线程时,分页是否停止
set target-async on/off 同步或者异步调试,是否等待线程中止的信息
命令 含义 备注
info inferiors 查看当前进程和 id
inferior {id} 切换某个进程
kill inferior {id...} 杀死某个进程
set detach-on-fork on/off 设置当进程调用fork时gdb是否同时调试父子进程
set follow-fork-mode parent/child 设置当进程调用fork时是否进入子进程
命令 含义 备注
info registers 打印普通寄存器
info all-registers 打印所有寄存器
print/x $pc 打印单个寄存器
stepi 指令级别单步进入 si
nexti 指令级别单步跳过 ni
display/i $pc 监控寄存器(每条单步完以后会自动打印值)
x/x &gx 十六进制打印变量
info line 22 打印行号为 22 的内存地址信息
info line *0x2c4e 打印给定内存地址对应的源代码和行号信息
disassemble {addr} 对地址进行反汇编,比如 disassemble 0x2c4e
命令 含义 备注
show commands 显示历史命令 (gdb 4.0)
info editing 显示历史命令 (gdb 3.5)
ESC-CTRL-J 切换到 Vi 命令行编辑模式
set history expansion on 允许类 c-shell 的历史
break class::member 在类成员处设置断点
list class:member 显示类成员代码
ptype class 查看类包含的成员 /o可以看成员偏移,类似pahole
print *this 查看 this 指针
define command ... end 定义用户命令
<return> 直接按回车执行上一条指令
shell {command} [args] 执行 shell 命令
source {file} 从文件加载 gdb 命令
quit 退出 gdb