动态调试基础
动态调试基础
本文适合
CTF 逆向工程 入门学习者。学完你能:选择关键断点,观察寄存器和内存,验证输入如何影响比较、分支和运行时解密结果
静态分析是看程序长什么样,动态调试是看程序实际怎么跑。逆向题里,很多猜想只有跑起来才能确认。
一句话判断
当静态分析已经找到可疑函数、比较点、成功失败分支或运行时生成数据,但还不能确定真实输入和执行路径时,就该上动态调试。
动态调试的目标不是从入口逐条单步,而是在关键时刻暂停程序,看到“输入、目标值、返回值和跳转条件”。
题目中常见信号
- 伪代码看起来像校验函数,但变量名、类型或优化结果不可信。
- 程序运行时才出现关键字符串,静态 strings 搜不到。
- 有
strcmp、memcmp、strlen、read、scanf、fgets等适合下断点的函数。 - 成功失败分支明显,但不知道条件由哪个值决定。
- 输入经过多轮变换,手读汇编容易漏掉中间值。
- 程序有反调试、壳、异常控制流或运行时解密,需要实际观察内存。
核心概念
动态调试围绕三个对象展开:
- 断点:让程序在关键位置暂停。
- 状态:寄存器、栈、内存、函数参数、返回值、标志位。
- 路径:不同输入会走向哪个分支,哪里进入成功或失败。
一个好断点必须服务于问题。例如“我要知道 strcmp 比较了什么”,就断在 strcmp;“我要知道解密后明文在哪里”,就断在解密函数返回或内存写入点。
最小分析流程
- 静态先找候选点:输入函数、比较函数、校验函数、成功失败分支。
- 在候选点下断点,不从
_start逐条单步。 - 程序停住后先看参数寄存器和栈,再看当前指令。
- 用两组不同输入重复运行,比较寄存器、内存和跳转方向。
- 在函数返回后看
rax/eax或返回寄存器,确认返回值含义。 - 只临时 patch 分支验证假设,最终仍要还原真实输入或算法。
最小验证示例
目标程序疑似调用 memcmp(input, expected, len):
gdb ./challenge
break memcmp
run命中断点后:
x/32xb $rdi
x/32xb $rsi
p/d $rdx
finish
p/d $rax如果 $rdi 指向你的输入,$rsi 指向目标字节,$rdx 是比较长度,且 rax=0 时进入成功分支,就已经完成动态最小验证。接下来把 $rsi 的目标字节提取出来,或继续追踪它是如何生成的。
常见利用 / 解题路线
路线总览:
- 比较函数断点路线:断
strcmp/memcmp/strncmp,直接观察输入和目标。 - 输入后追踪路线:断
read/scanf/fgets返回处,观察输入缓冲区后续被谁读取。 - 返回值路线:断校验函数出口,看
rax/eax如何影响test/cmp和条件跳转。 - 内存断点路线:对目标缓冲区设置 watchpoint,捕获运行时解密或变换位置。
- 分支验证路线:临时修改
ZF或跳转条件确认成功路径是否真实可用。 - 插桩路线:遇到难调程序,用 Frida hook 关键函数参数和返回值。
动态调试是什么
动态调试是在程序运行时观察和控制它。
你可以让程序停在某个位置,查看寄存器、内存、栈、函数参数和返回值。
常见工具包括 gdb、x64dbg、lldb、IDA debugger、Ghidra debugger、Frida 等。
断点是什么
断点是让程序执行到某个位置时暂停。
常见断点位置:
- 主函数入口(如
main、_start)。 - 输入函数之后(如
scanf、read、fgets返回处)。 - 字符串比较函数(如
strcmp、memcmp、strncmp)。 - 加密或校验函数入口和出口。
- 成功失败分支前的条件跳转指令。
断点的目的不是”让程序停住”,而是让你在关键时刻观察状态。
# GDB 断点操作
gdb ./challenge
# 在函数名上下断点
break main
break strcmp
break *0x401234 # 在指定地址下断点
# 条件断点:当第一个参数等于特定值时中断
break strcmp if $rdi != 0
# 硬件断点(不修改代码,适合代码段被保护的场景)
hbreak *0x401234
# 查看和删除断点
info breakpoints
delete 2 # 删除编号为 2 的断点
# 运行程序
run
run arg1 arg2 # 带参数运行
run < input.txt # 用文件作为输入
# 继续执行
continue # 运行到下一个断点单步执行
单步执行可以逐条指令观察程序变化。
- step into (si):执行一条指令,如果是
call则进入函数内部。 - step over (ni):执行一条指令,如果是
call则执行完整个函数再停。 - step out (finish):执行到当前函数返回。
- reverse stepi / reverse nexti:反向执行,回到上一条指令(需要
record或rr支持)。
逆向题中,不必每个函数都进去。库函数、初始化函数、无关 UI 逻辑可以跳过。
# GDB 单步命令
stepi # 执行一条机器指令,进入函数
nexti # 执行一条机器指令,跳过函数
step # 执行一行源码(有调试信息时)
next # 执行一行源码,跳过函数
finish # 运行到当前函数返回
# 查看当前执行位置的汇编
x/5i $rip # 显示当前地址往后 5 条指令
layout asm # 切换到汇编布局模式
# 反向调试(需要 rr 或 GDB record)
# 使用 rr 录制并回放:
rr record ./challenge
rr replay
# 然后就可以用 reverse-stepi 等命令反向执行寄存器和内存
调试时常看:
rax:返回值或临时值,函数返回后结果通常在这里。rdi、rsi、rdx、rcx、r8、r9:x86-64 Linux 函数调用约定的前 6 个参数。rsp:栈顶指针,观察栈帧变化。rbp:栈帧基址,用于访问局部变量。rip:当前执行位置。eflags:标志寄存器,ZF(零标志)常用于判断比较结果。
内存窗口能看到输入缓冲区、字符串、数组和运行时解密的数据。
# GDB 查看寄存器
info registers # 查看所有通用寄存器
print/x $rax # 以十六进制打印 rax
print/d $rax # 以十进制打印 rax
set $rax = 1 # 修改寄存器值
# 查看内存
x/20xb $rsp # 查看栈顶 20 字节(十六进制)
x/10xg $rsp # 查看栈顶 10 个 8 字节值
x/s 0x402000 # 查看字符串
x/10i $rip # 查看当前 10 条指令
# 查看函数参数(刚进入函数时)
info args # 查看当前函数参数(需要调试信息)
print (char*)$rdi # 打印第一个参数(字符串)
# 查看栈回溯
backtrace # 查看调用栈
info frame # 查看当前栈帧信息常见动态技巧
在 strcmp、memcmp、strncmp 附近下断点,观察程序拿你的输入和什么比较。
在成功失败跳转前改标志位或跳转条件,验证成功分支里是否有真实 flag。
在解密函数返回后查看内存,观察明文是否已经出现。
用不同输入运行多次,比较执行路径差异。
# 在 strcmp 下断点,查看比较的两个字符串
gdb ./challenge
break strcmp
run
# 命中断点后:
x/s $rdi # 第一个参数(你的输入)
x/s $rsi # 第二个参数(目标字符串)
# 改标志位绕过检查(将 ZF 设为 1 使 je 跳转)
set $eflags |= 0x40
# 修改内存中的字符串
set {char [6]} 0x601040 = "flag{"
# 使用 GDB 的 watchpoint 监视内存变化
watch *0x601040 # 当该地址被写入时中断
rwatch *0x601040 # 当该地址被读取时中断
# 使用 x64dbg(Windows)常用快捷键:
# F2 - 设置/取消断点
# F7 - 单步步入
# F8 - 单步步过
# F9 - 运行
# Ctrl+F9 - 运行到返回
# Ctrl+G - 跳转到地址GDB 高级命令
条件断点
# 条件断点:当表达式为真时中断
break strcmp if $rdi != 0
break main if argc > 2
break *0x401234 if *(int*)($rsp) == 0x42
# 条件观察点
watch *(int*)0x601040 if *(int*)0x601040 > 100
# 忽略断点前 N 次命中
ignore 1 100 # 忽略断点1的前100次命中
# 命中次数统计
break main
continue & # 运行并统计命中次数内存断点
# 硬件观察点(不修改代码)
watch *0x601040 # 写入时中断
rwatch *0x601040 # 读取时中断
awatch *0x601040 # 读写时中断
# 内存访问断点(适合代码段保护的场景)
# 使用硬件断点
hbreak *0x401234
# 对内存范围设断点
# 需要 GDB 插件或 Python 脚本GDB Python 脚本
# GDB Python 脚本示例
import gdb
class DumpString(gdb.Command):
"""自动 dump 指定地址的字符串"""
def __init__(self):
super().__init__("dumpstr", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
addr = int(arg, 16)
inferior = gdb.selected_inferior()
data = inferior.read_memory(addr, 256)
s = bytes(data).split(b'\x00')[0].decode('utf-8', errors='replace')
print(f"String at {hex(addr)}: {s}")
DumpString()
# 在 GDB 中加载 Python 脚本
# source script.py
# dumpstr 0x402000GDB 插件
# GEF (GDB Enhanced Features)
# 安装
bash -c "$(curl -fsSL https://gef.blah.cat/sh)"
# 常用 GEF 命令
telescope $rsp 20 # 查看栈(带注释)
vmmap # 查看内存映射
xinfo $rip # 详细地址信息
heap chunks # 查看堆块
heap bins # 查看 bin 列表
# Pwndbg
# 安装
git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh
# 常用 Pwndbg 命令
stack 20 # 查看栈
heap # 堆信息
vmmap # 内存映射
bins # 堆 bin
telescope $rsp 20 # 带注释的栈查看x64dbg 技巧
常用快捷键
F2 设置/取消断点
F7 单步步入
F8 单步步过
F9 运行
Ctrl+F9 运行到返回
Alt+F9 运行到用户代码
Ctrl+G 跳转到地址
Space 修改指令
; 添加注释
; 设置标签
Ctrl+N 查找符号名
Ctrl+F 搜索二进制模式x64dbg 内存操作
1. 内存映射(Alt+M):
- 查看所有内存段
- 双击跳转到指定段
- 右键可以修改属性
2. 断点类型:
- 软件断点(F2):修改指令为 INT3
- 硬件断点:使用调试寄存器(DR0-DR3)
- 内存断点:监视内存访问
3. 跟踪功能:
- Trace Record:记录执行轨迹
- 条件跟踪:满足条件时记录
- 导出跟踪结果x64dbg 脱壳技巧
1. 找 OEP 的方法:
- 单步跟踪法:F8 跟踪大循环
- ESP 定律:在 popal 后的跳转处下断点
- 内存断点:对代码段设写断点
- API 断点:在 VirtualProtect 返回处下断点
2. 使用 Scylla 插件:
- Plugins -> Scylla
- IAT Autosearch -> Get Imports -> Fix Dump
3. 使用 SharpOD 插件:
- 反调试绕过
- 隐藏调试器Frida 动态插桩
基础 hook
// hook 函数入口和出口
Interceptor.attach(Module.findExportByName(null, "strcmp"), {
onEnter: function(args) {
console.log("strcmp called");
console.log(" arg1: " + Memory.readUtf8String(args[0]));
console.log(" arg2: " + Memory.readUtf8String(args[1]));
},
onLeave: function(retval) {
console.log(" return: " + retval);
}
});
// hook 指定地址
var targetAddr = Module.findBaseAddress("challenge").add(0x1234);
Interceptor.attach(targetAddr, {
onEnter: function(args) {
console.log("Hit target at 0x" + this.context.pc.toString(16));
}
});内存读写
// 读取内存
var baseAddr = Module.findBaseAddress("challenge");
var value = Memory.readInt(baseAddr.add(0x2000));
console.log("Value: " + value);
// 写入内存
Memory.writeInt(baseAddr.add(0x2000), 42);
// 读取字符串
var str = Memory.readUtf8String(baseAddr.add(0x3000));
console.log("String: " + str);
// 搜索内存
var pattern = "48 89 e5 48 83 ec";
Memory.scan(baseAddr, 0x10000, pattern, {
onMatch: function(address, size) {
console.log("Found at: " + address);
},
onComplete: function() {
console.log("Scan complete");
}
});LLDB 调试
LLDB 基础命令
# 启动调试
lldb ./challenge
# 断点
breakpoint set --name main
breakpoint set --address 0x100001234
breakpoint set --name strcmp --condition '$x0 != 0'
# 运行
run
run arg1 arg2
# 单步
step instruction # si
next instruction # ni
finish # 运行到返回
# 查看寄存器
register read
register read $x0
register write $x0 42
# 查看内存
memory read --size 4 --format x $sp
memory read --size 80 --format c $x0
# 查看调用栈
bt
frame select 1LLDB Python 脚本
# LLDB Python 脚本
import lldb
def print_string(debugger, command, result, internal_dict):
"""打印指定地址的字符串"""
target = debugger.GetSelectedTarget()
process = target.GetProcess()
addr = int(command, 16)
error = lldb.SBError()
data = process.ReadMemory(addr, 256, error)
s = data.split(b'\x00')[0].decode('utf-8', errors='replace')
print(f"String: {s}")
def __lldb_init_module(debugger, internal_dict):
debugger.HandleCommand('command script add -f script.print_string print_string')rr 录制回放
rr 基础用法
# 录制程序运行
rr record ./challenge arg1 arg2
# 回放录制
rr replay
# 在回放中可以:
# - 反向执行(reverse-stepi, reverse-nexti)
# - 反向继续(reverse-continue)
# - 在任意时间点设断点
# - 查看任意时刻的寄存器和内存
# 常用 rr 命令
(rr) reverse-stepi # 反向执行一条指令
(rr) reverse-nexti # 反向执行一条指令(跳过函数)
(rr) reverse-continue # 反向继续到上一个断点
(rr) replay # 正向回放常见失败原因
- 一上来从入口逐条单步:先静态定位关键函数,再下断点,初始化代码通常不值得逐行跟。
- 不知道断点为什么下在那里:每个断点都写下要回答的问题,例如“看比较参数”或“看返回值”。
- 把修改跳转当成解题完成:patch 只是验证成功分支,最终还要给出真实输入或还原算法。
- 只看反编译结果:优化和类型恢复会误导,关键判断要用寄存器和内存验证。
- 断在库函数但看错参数:先确认平台调用约定,Linux x64 和 Windows x64 参数寄存器不同。
- 程序多线程或 fork 后断点不命中:检查
set follow-fork-mode child、线程列表和实际进程。 - 看到反调试就放弃:先识别检测点,再考虑 patch、hook、隐藏调试器或换动态插桩。
迷你案例
一个程序静态看不出目标字符串,只看到 memcmp。用 GDB:
break memcmp
run
x/16xb $rdi
x/16xb $rsi
p/d $rdx第一次输入 aaaaaaaaaaaaaaaa,命中后 $rdi 是 16 个 0x61,$rsi 是:
66 6c 61 67 5f 64 62 67 5f 74 65 73 74 00 ...这些字节转成 ASCII 是 flag_dbg_test。重新运行输入该字符串,程序进入成功分支。这个案例的闭环是:静态找比较点 -> 动态读参数 -> 提取目标 -> 运行验证。