返回

GDB 调试 setarch 启动的程序?3种方法搞定!

Linux

GDB 调试 setarch 启动的程序?搞得定!

遇到的麻烦

想用 GDB 调试一个比较特殊的程序?具体来说,这个程序得通过 setarch 启动,并且加上 --addr-no-randomize--read-implies-exec 参数。目标是在一个基于 Docker 的挑战环境里部署这玩意儿。

命令大概长这样:

setarch "$(uname -m)" --addr-no-randomize --read-implies-exec ./my_program my_args

这命令的作用是给 ./my_program 这个进程设定一个特定的“个性”(personality),主要是关掉地址空间布局随机化(ASLR)和允许数据段可执行。这对于某些特定的开发或安全研究场景(比如复现或分析某些漏洞利用)挺有用的。

问题来了:直接用 GDB 搞不定。

试了第一种法子,用 setarch 来启动 GDB,想着 GDB 启动的子进程(也就是我们要调试的 ./my_program)能继承这个“个性”:

setarch "$(uname -m)" --addr-no-randomize --read-implies-exec gdb ./my_program -ex "set args my_args" ...

结果发现,子进程该崩溃还是崩溃(比如尝试在数据段执行代码时收到 SIGSEGV 信号),说明 setarch 的效果没作用到目标子进程上。这条路不通。

那换个思路,让 GDB 去启动 setarch,再由 setarch 去启动目标程序:

gdb setarch -ex "set args x86_64 --addr-no-randomize --read-implies-exec ./my_program my_args" ...

用这种方式启动,之前那个依赖特殊环境的程序(比如在数据段执行代码的 exploit)确实能跑起来了,说明 setarch 的参数生效了!但麻烦的是,GDB 现在调试的是 setarch 这个程序,而不是我们真正关心的 ./my_program。因为我们没有 setarch 的源码和调试符号(而且假设不能自己编译 setarch),并且也没法直接让 GDB 关注到 setarch 启动的那个 ./my_program 子进程。在 GDB 里给 ./my_program 的函数下断点?根本停不下来,因为 GDB 根本没在调试它。

假设 GDB 没有啥内建的、专门用来控制进程“个性”的功能,那问题可以更普遍地成:

我们能用 GDB 调试一个有源码、有调试符号的程序(./my_program),但这个程序必须通过另一个我们无法控制(没有源码)的启动器(比如 setarch)来间接启动吗?

为啥会这样?

这得从 GDB 和进程如何工作说起。

  1. setarch ... gdb ./my_program 为啥不行?
    当你运行 setarch ... gdb ... 时,setarch 命令会创建一个新进程,设置好它要求的环境(比如关闭 ASLR),然后执行 gdb。现在,GDB 进程本身是运行在 setarch 创建的环境里的。但 GDB 在启动要调试的程序(inferior process)时,通常会 fork() 一个子进程,然后子进程再 execve() 目标程序 (./my_program)。关键在于,GDB 启动子进程的这个过程,并不会自动或者默认地将 GDB 自身 所处的特殊环境(由 setarch 设置的)完全传递给 子进程。子进程的执行环境更多是由 GDB 和操作系统默认行为决定的。结果就是,./my_program 并没有得到 setarch 的“照顾”。

  2. gdb setarch ... ./my_program 为啥也别扭?
    当你运行 gdb setarch ... 时,GDB 正常启动,并将 setarch 作为它的直接调试目标。GDB 内部执行 run 命令时,会 fork() 一个子进程,这个子进程 execve() 执行了 setarch x86_64 --addr-no-randomize --read-implies-exec ./my_program my_args 这条命令。
    这个 setarch 进程接收到参数后,它会先按照要求设置好进程环境(关闭 ASLR 等),然后,它 自己execve() 执行 ./my_program my_args
    问题就在这里:GDB 只知道它直接 forkexecve 的那个进程是 setarch。当 setarch 进程内部再 execve./my_program 时,从 GDB 的视角看,它调试的那个进程(PID 没变)虽然“变身”了,但 GDB 最初加载的是 setarch 的符号(如果 GDB 能找到的话,这里假设找不到),而不是 ./my_program 的符号。GDB 的调试上下文、符号表、断点等都是针对 setarch 的。所以你给 ./my_program 里的函数下断点,自然没用。GDB 没“看见”那个最终跑起来的 ./my_program 的内部结构。

总结一下:我们需要一种方法,让 ./my_program 确实在 setarch 配置好的环境下启动,并且 GDB 还能准确地附加上去,加载正确的符号,让我们能够调试 ./my_program 本身。

怎么办?几种搞定它的法子

好消息是,有好几种办法可以绕过这个限制,让 GDB 成功调试由 setarch 启动的目标程序。

法子一:先跑起来,再 GDB 挂上去 (Attach)

这是最直接的想法:既然 GDB 直接启动不好使,那就让 setarch 先把 ./my_program 启动起来,让它在正确的环境下运行。一旦它跑起来了,它就是一个普通的进程,我们再用 GDB 的附加(attach)功能挂到这个已经运行的进程上。

怎么玩:

  1. 后台启动目标程序: 在终端里,用 setarch 启动你的程序,并把它放到后台运行(用 &)。

    $ setarch x86_64 --addr-no-randomize --read-implies-exec ./my_program my_args &
    [1] 12345  # 这里的 12345 是示例 PID
    
  2. 找到目标进程的 PID: 程序启动后,需要知道它的进程 ID (PID)。有几种方法:

    • 如果你用 & 启动,很多 shell 会直接打印出后台任务的 Job ID 和 PID(如上例中的 12345)。
    • 如果错过了或者不确定,可以用 pidof 或者 pgrep 命令来查找:
      $ pidof my_program
      12345
      # 或者
      $ pgrep -f my_program # -f 参数可以匹配完整命令行,更精确
      12345
      
    • 小技巧: 如果你的程序启动后很快就做事然后退出,可能来不及附加。可以在 ./my_program 的代码早期(比如 main 函数开头)加一个短暂的 sleep() 或者一个等待输入的 getchar(),给自己留出附加 GDB 的时间。
  3. 用 GDB 附加: 知道了 PID,就可以启动 GDB 并附加到该进程了。

    # 方式一:先启动 GDB,再附加
    $ gdb
    (gdb) attach 12345
    (gdb) symbol-file ./my_program # 手动加载符号
    ...
    
    # 方式二:启动 GDB 时直接指定 PID 和程序文件(推荐,能自动加载符号)
    $ gdb ./my_program -p 12345
    # 或者
    $ gdb -p 12345 ./my_program
    

    加载符号后,你就可以像平时一样设置断点、查看变量、单步执行了。程序会在你附加后暂停,你可以用 continue (或 c) 命令让它继续执行。

为啥行: 这个方法绕过了 GDB 启动子进程的复杂性。setarch 确保了 ./my_program 在正确的环境下运行。GDB 只是连接到一个已经存在的、环境配置好的进程上,然后接管它的调试控制。

注意点:

  • 权限: 附加到进程通常需要足够的权限。你要么是 root 用户,要么 GDB 和目标进程由同一个用户运行。在某些系统上,可能需要特定的权能(capabilities)或配置(比如 ptrace_scope sysctl 设置)。在 Docker 容器里,如果以非 root 用户运行,需要确保容器启动时有 SYS_PTRACE 能力 (比如 docker run --cap-add=SYS_PTRACE ...)。
  • 时机: 如果程序启动非常快就执行到你想调试的关键代码区域,可能在你找到 PID 并附加 GDB 之前,那段代码就已经执行完了。上面提到的加 sleepgetchar 是应对策略。
  • 僵尸进程: 如果目标程序退出了,GDB 会报告进程消失。

法子二:给 GDB 配个 '启动器' (set exec-wrapper)

GDB 其实提供了一个更优雅的、内建的方式来处理这类“需要用包装器启动程序”的场景,那就是 set exec-wrapper 命令。你可以告诉 GDB:“别直接运行目标程序,用我指定的这个包装命令来启动它。”

怎么玩:

  1. 正常启动 GDB,指定你的目标程序文件(为了加载符号):

    $ gdb ./my_program
    
  2. 设置执行包装器: 在 GDB 提示符下,使用 set exec-wrapper 命令,后面跟上你的包装命令,包括 setarch 和它的参数。注意,不需要在这里写目标程序名 (./my_program) 或它的参数 (my_args)。

    (gdb) set exec-wrapper setarch x86_64 --addr-no-randomize --read-implies-exec
    
  3. 设置目标程序的参数: 如果 ./my_program 需要参数,用 set args 命令设置。

    (gdb) set args my_args  # 如果有多个参数,像平时一样写: set args arg1 arg2 "arg with spaces"
    
  4. 运行: 使用 run (或 r) 命令启动调试。

    (gdb) run
    

    GDB 内部会执行类似 setarch x86_64 --addr-no-randomize --read-implies-exec ./my_program my_args 的命令来启动子进程,但 GDB 会正确地跟踪到最终的 ./my_program 进程,并对其进行调试。

为啥行: exec-wrapper 的设计初衷就是为了处理这类场景。GDB 在 run 的时候,知道需要通过这个 wrapper 来启动。它会执行 wrapper,并把目标程序路径和 set args 设置的参数传递给 wrapper。GDB 的内部机制能确保它正确地“捕获”并调试由 wrapper 最终 execve 的那个目标程序进程,而不是 wrapper 本身。

优点:

  • 比手动附加更方便,流程更顺畅。
  • 不容易有时序问题(race condition)。
  • 是 GDB 标准功能,可移植性好。

进阶使用:

  • 可以将 set exec-wrapperset args 命令写到 GDB 的初始化文件(比如项目目录下的 .gdbinit 或家目录的 ~/.gdbinit)里,这样每次启动 GDB 调试该程序时就不用手动敲了。
    # Example .gdbinit
    set exec-wrapper setarch x86_64 --addr-no-randomize --read-implies-exec
    set args my_args
    b main
    # 可以再加上其他常用设置或断点
    

法子三:利用 gdbserver (远程调试风格)

gdbserver 是一个轻量级的程序,它可以在目标机器上启动你的程序,并监听一个网络端口,等待 GDB 连接过来进行远程调试。即使 GDB 和 gdbserver 在同一台机器上,这种方式也很有用,特别是在容器环境或者权限受限的环境里。

怎么玩:

  1. setarch 启动 gdbserver,让它再启动你的程序:

    # 在目标环境(比如 Docker 容器内)运行:
    # 格式: setarch ... gdbserver <监听地址:端口> <目标程序> [目标程序参数]
    $ setarch x86_64 --addr-no-randomize --read-implies-exec gdbserver :1234 ./my_program my_args
    Process ./my_program created; pid = 12346
    Listening on port 1234
    

    这会在本地(: 表示所有网络接口,也可以用 localhost:1234)监听 TCP 端口 1234gdbserver 会启动 ./my_program,并且使其暂停,等待 GDB 连接。

  2. 启动 GDB 并连接: 在同一台机器或另一台可以访问端口 1234 的机器上启动 GDB。

    $ gdb ./my_program  # 加载符号
    
  3. 在 GDB 中连接到 gdbserver:

    (gdb) target remote localhost:1234  # 如果 gdbserver 在另一台机器,用它的 IP 地址
    Remote debugging using localhost:1234
    0x00007f... in _start () from /lib64/ld-linux-x86-64.so.2
    (gdb) # 现在可以正常调试了,比如下断点,continue
    (gdb) b main
    Breakpoint 1 at 0x...: file my_program.c, line 10.
    (gdb) c
    Continuing.
    ...
    

为啥行: gdbserver 进程是由 setarch 在正确的环境下启动的。然后 gdbserver 负责启动 ./my_program 子进程,这个子进程自然继承了 gdbserver(也就是 setarch 设置好)的环境。GDB 通过网络连接上来时,gdbserver 充当了 GDB 和目标进程之间的桥梁。GDB 发送调试命令(如设置断点、继续执行),gdbserver 在目标进程上执行这些操作,并将结果(如寄存器值、内存内容)返回给 GDB。

优点:

  • 将程序启动和环境设置(由 setarchgdbserver 处理)与 GDB 调试器的运行清晰地分离开。
  • 非常适合 Docker 等容器环境,或者当 GDB 无法直接运行在目标环境(比如嵌入式系统)的情况。
  • 解决了权限问题,因为启动 gdbserver 的用户只需要有执行 setarch./my_program 的权限即可,GDB 本身运行在别处。

注意点:

  • 确保 GDB 能够访问 gdbserver 监听的 IP 地址和端口。防火墙可能会阻止连接。
  • 选择一个未被占用的端口号。

总的来说,想要用 GDB 调试一个必须通过像 setarch 这样的启动器来间接启动的程序,完全是可行的。set exec-wrapper 是 GDB 内建的最方便的方案。如果喜欢手动控制或者脚本化,先启动再附加进程也是个简单粗暴有效的办法。而 gdbserver 则提供了一个更灵活、尤其适合容器和远程场景的强大选项。根据你的具体场景和偏好,选择一个合适的法子开干吧!