手写一个微型调试器:用 Python 追踪程序执行
limi 拘束型作者
本文距离上次更新已过去 0 天,部分内容可能已经过时,请注意甄别。

手写一个微型调试器:用 Python 追踪程序执行

在软件开发中,调试器是我们最常用的工具之一。当你设下断点、单步执行、查看变量时,是否好奇过这些功能背后是如何实现的?

今天,我们将从零开始,用 Python 手写一个微型调试器。通过不到 200 行代码,你将理解调试器的核心原理:ptrace 系统调用、断点注入、寄存器读写、内存操作。

注意:本文示例在 Linux x86-64 系统上运行,需要 root 权限或允许 ptrace 的配置。所有代码仅用于学习目的。


一、调试器的核心:ptrace

在 Linux 系统中,调试功能主要通过 ptrace 系统调用实现。它允许一个进程(调试器)观察和控制另一个进程(被调试程序)的执行,并读取和修改其寄存器和内存。

ptrace 的主要能力:

  1. 跟踪系统调用PTRACE_TRACEME
  2. 单步执行PTRACE_SINGLESTEP
  3. 继续执行PTRACE_CONT
  4. 读取/写入寄存器PTRACE_GETREGS, PTRACE_SETREGS
  5. 读取/写入内存PTRACE_PEEKDATA, PTRACE_POKEDATA
  6. 附加到运行中的进程PTRACE_ATTACH

Python 的 ctypes 库让我们可以直接调用这些系统调用。


二、环境准备

2.1 导入必要的库

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
import ctypes
import ctypes.util
import struct
import sys
import os
import signal

# 定义常量(来自 <sys/ptrace.h> 和 <sys/user.h>)
PTRACE_TRACEME = 0
PTRACE_PEEKDATA = 2
PTRACE_POKEDATA = 4
PTRACE_CONT = 7
PTRACE_SINGLESTEP = 9
PTRACE_GETREGS = 12
PTRACE_SETREGS = 13
PTRACE_ATTACH = 16

# 寄存器结构(x86-64)
class user_regs_struct(ctypes.Structure):
_fields_ = [
("r15", ctypes.c_ulonglong),
("r14", ctypes.c_ulonglong),
("r13", ctypes.c_ulonglong),
("r12", ctypes.c_ulonglong),
("rbp", ctypes.c_ulonglong),
("rbx", ctypes.c_ulonglong),
("r11", ctypes.c_ulonglong),
("r10", ctypes.c_ulonglong),
("r9", ctypes.c_ulonglong),
("r8", ctypes.c_ulonglong),
("rax", ctypes.c_ulonglong),
("rcx", ctypes.c_ulonglong),
("rdx", ctypes.c_ulonglong),
("rsi", ctypes.c_ulonglong),
("rdi", ctypes.c_ulonglong),
("orig_rax", ctypes.c_ulonglong),
("rip", ctypes.c_ulonglong),
("cs", ctypes.c_ulonglong),
("eflags", ctypes.c_ulonglong),
("rsp", ctypes.c_ulonglong),
("ss", ctypes.c_ulonglong),
("fs_base", ctypes.c_ulonglong),
("gs_base", ctypes.c_ulonglong),
("ds", ctypes.c_ulonglong),
("es", ctypes.c_ulonglong),
("fs", ctypes.c_ulonglong),
("gs", ctypes.c_ulonglong),
]

# 加载 libc 以便调用 ptrace
libc = ctypes.CDLL(ctypes.util.find_library("c"))
ptrace = libc.ptrace
ptrace.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p]
ptrace.restype = ctypes.c_long

三、调试器核心类设计

3.1 断点实现

断点的原理是在目标地址写入 0xCC(即 int 3 指令)。当 CPU 执行到这个指令时,会触发 SIGTRAP 信号,调试器就能捕获这个事件。

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
class Breakpoint:
def __init__(self, pid, address):
self.pid = pid
self.address = address
self.enabled = False
self.saved_data = None

def enable(self):
"""在目标地址插入 int3 指令"""
# 读取原来的指令数据
self.saved_data = ptrace(PTRACE_PEEKDATA, self.pid,
ctypes.c_void_p(self.address), None)
# 将最低字节替换为 0xCC
data_with_int3 = (self.saved_data & ~0xFF) | 0xCC
# 写回内存
ptrace(PTRACE_POKEDATA, self.pid,
ctypes.c_void_p(self.address),
ctypes.c_void_p(data_with_int3))
self.enabled = True

def disable(self):
"""恢复原来的指令"""
if self.saved_data:
ptrace(PTRACE_POKEDATA, self.pid,
ctypes.c_void_p(self.address),
ctypes.c_void_p(self.saved_data))
self.enabled = False

3.2 调试器主类

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
class Debugger:
def __init__(self, program_path, pid=None):
self.program_path = program_path
self.pid = pid
self.breakpoints = {} # address -> Breakpoint

def run(self):
"""启动被调试程序"""
pid = os.fork()
if pid == 0: # 子进程:被调试程序
# 允许父进程跟踪自己
ptrace(PTRACE_TRACEME, 0, None, None)
# 执行目标程序
os.execv(self.program_path, [self.program_path])
else: # 父进程:调试器
self.pid = pid
self.debug_loop()

def attach(self):
"""附加到正在运行的进程"""
ptrace(PTRACE_ATTACH, self.pid, None, None)
self.wait_for_signal()

def wait_for_signal(self):
"""等待子进程的信号"""
pid, status = os.waitpid(self.pid, 0)
return status

def get_registers(self):
"""读取所有寄存器"""
regs = user_regs_struct()
ptrace(PTRACE_GETREGS, self.pid, None, ctypes.byref(regs))
return regs

def set_registers(self, regs):
"""写入寄存器"""
ptrace(PTRACE_SETREGS, self.pid, None, ctypes.byref(regs))

def read_memory(self, address, size):
"""读取内存(按字节)"""
result = b""
for offset in range(0, size, 8):
word = ptrace(PTRACE_PEEKDATA, self.pid,
ctypes.c_void_p(address + offset), None)
# 将 64 位整数转换为字节
word_bytes = struct.pack("<Q", word & 0xFFFFFFFFFFFFFFFF)
result += word_bytes[:min(8, size - offset)]
return result

def write_memory(self, address, data):
"""写入内存(按字节)"""
for i in range(0, len(data), 8):
chunk = data[i:i+8]
# 补足 8 字节
if len(chunk) < 8:
# 先读取原来的值
old_word = ptrace(PTRACE_PEEKDATA, self.pid,
ctypes.c_void_p(address + i), None)
old_bytes = struct.pack("<Q", old_word)
chunk = chunk + old_bytes[len(chunk):]
# 转换为整数
word = struct.unpack("<Q", chunk.ljust(8, b'\x00'))[0]
ptrace(PTRACE_POKEDATA, self.pid,
ctypes.c_void_p(address + i),
ctypes.c_void_p(word))

def set_breakpoint(self, address):
"""设置断点"""
if address in self.breakpoints:
return
bp = Breakpoint(self.pid, address)
bp.enable()
self.breakpoints[address] = bp
print(f"[*] 断点已设置于 0x{address:x}")

def remove_breakpoint(self, address):
"""移除断点"""
if address in self.breakpoints:
self.breakpoints[address].disable()
del self.breakpoints[address]
print(f"[*] 断点已移除于 0x{address:x}")

def single_step(self):
"""单步执行"""
ptrace(PTRACE_SINGLESTEP, self.pid, None, None)
status = self.wait_for_signal()
return status

def cont(self):
"""继续执行"""
ptrace(PTRACE_CONT, self.pid, None, None)
status = self.wait_for_signal()
return status

def handle_breakpoint(self):
"""处理断点命中"""
regs = self.get_registers()
# 断点触发时,rip 指向断点之后的指令
# 需要回退到断点地址并恢复原指令
bp_address = regs.rip - 1

if bp_address in self.breakpoints:
bp = self.breakpoints[bp_address]

# 恢复原指令
bp.disable()

# 回退 rip
regs.rip = bp_address
self.set_registers(regs)

print(f"\n[!] 命中断点 at 0x{bp_address:x}")
self.print_current_context()

# 等待用户命令
self.command_loop()

# 重新启用断点(如果还要继续)
if bp.enabled is False:
# 需要单步执行原指令
self.single_step()
# 重新插入断点
bp.enable()
# 继续执行
self.cont()

def print_current_context(self):
"""打印当前执行上下文"""
regs = self.get_registers()
print(f" RIP: 0x{regs.rip:x} RAX: 0x{regs.rax:x} RBX: 0x{regs.rbx:x}")
print(f" RCX: 0x{regs.rcx:x} RDX: 0x{regs.rdx:x} RSI: 0x{regs.rsi:x}")
print(f" RDI: 0x{regs.rdi:x} RBP: 0x{regs.rbp:x} RSP: 0x{regs.rsp:x}")

# 显示当前指令附近的字节
inst_bytes = self.read_memory(regs.rip, 8)
hex_bytes = ' '.join(f'{b:02x}' for b in inst_bytes)
print(f" [指令]: {hex_bytes}")

3.3 交互式命令循环

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
def command_loop(self):
"""简单的命令解释器"""
while True:
cmd = input("(debugger) ").strip()
if not cmd:
continue

if cmd == "c" or cmd == "continue":
# 重新启用当前断点
for bp in self.breakpoints.values():
if not bp.enabled:
bp.enable()
return

elif cmd == "s" or cmd == "step":
self.single_step()
self.print_current_context()

elif cmd.startswith("b "):
# b 0x400512
try:
addr = int(cmd[2:], 16)
self.set_breakpoint(addr)
except ValueError:
print("[-] 地址格式错误")

elif cmd.startswith("rm "):
try:
addr = int(cmd[3:], 16)
self.remove_breakpoint(addr)
except ValueError:
print("[-] 地址格式错误")

elif cmd == "reg":
self.print_current_context()

elif cmd == "q" or cmd == "quit":
sys.exit(0)

else:
print("""可用命令:
c/continue - 继续执行
s/step - 单步执行
b <addr> - 设置断点 (如 b 0x400512)
rm <addr> - 移除断点
reg - 显示寄存器
q/quit - 退出""")

def debug_loop(self):
"""主调试循环"""
while True:
status = self.wait_for_signal()

# 检查是否因为断点停止
if os.WIFSTOPPED(status) and os.WSTOPSIG(status) == signal.SIGTRAP:
self.handle_breakpoint()
elif os.WIFEXITED(status):
print(f"[*] 程序退出,状态码: {os.WEXITSTATUS(status)}")
break

四、测试调试器

4.1 编写测试程序

首先创建一个简单的 C 程序作为调试目标:

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

int add(int a, int b) {
return a + b;
}

int main() {
int x = 5;
int y = 10;
int z = add(x, y);
printf("Result: %d\n", z);
return 0;
}

编译(需要保留调试信息,但断点地址依赖实际指令):

1
gcc -o test test.c -no-pie  # 禁用 PIE 以便地址固定

查看入口点和 add 函数地址:

1
2
3
4
objdump -d test | grep -E '<main>|<add>'
# 输出类似:
# 0000000000401126 <add>:
# 0000000000401142 <main>:

4.2 运行调试器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 使用示例
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"用法: {sys.argv[0]} <程序路径> [pid]")
sys.exit(1)

debugger = Debugger(sys.argv[1])

if len(sys.argv) == 3:
# 附加到现有进程
debugger.pid = int(sys.argv[2])
debugger.attach()
debugger.debug_loop()
else:
# 启动新进程
debugger.run()

执行效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ sudo python debugger.py ./test
[*] 断点已设置于 0x401142
[!] 命中断点 at 0x401142
RIP: 0x401142 RAX: 0x401126 RBX: 0x0
RCX: 0x0 RDX: 0x0 RSI: 0x0 RDI: 0x0
RBP: 0x7ffde4b1f3a0 RSP: 0x7ffde4b1f380
[指令]: 55 48 89 e5 48 83 ec 20
(debugger) s
RIP: 0x401145 RAX: 0x401126 RBX: 0x0
RCX: 0x0 RDX: 0x0 RSI: 0x0 RDI: 0x0
RBP: 0x7ffde4b1f3a0 RSP: 0x7ffde4b1f380
[指令]: 48 89 e5 48 83 ec 20 89 7d
(debugger) reg
RIP: 0x401148 RAX: 0x401126 RBX: 0x0
RCX: 0x0 RDX: 0x0 RSI: 0x0 RDI: 0x0
RBP: 0x7ffde4b1f3a0 RSP: 0x7ffde4b1f380
[指令]: 48 83 ec 20 89 7d ec 89
(debugger) c
Result: 15
[*] 程序退出,状态码: 0

五、原理解析

5.1 断点的工作原理

  1. 插入断点:将目标地址的指令第一个字节替换为 0xCCint 3
  2. 触发断点:CPU 执行到 0xCC 时,产生 SIGTRAP 信号
  3. 处理断点
    • 调试器捕获信号
    • 恢复原指令
    • 将指令指针回退
    • 让用户交互
  4. 继续执行
    • 单步执行原指令
    • 重新插入断点
    • 继续运行

5.2 单步执行

PTRACE_SINGLESTEP 利用 CPU 的硬件特性(x86 的 TF 标志位)让 CPU 执行一条指令后自动停止,并产生 SIGTRAP

5.3 寄存器与内存

  • 寄存器读写:直接通过 PTRACE_GETREGS/PTRACE_SETREGS 操作
  • 内存读写:按字(word)进行,需要处理非对齐访问

六、扩展与优化

这个微型调试器虽然能运行,但还有很多可以改进的地方:

6.1 功能扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1. 条件断点
class ConditionalBreakpoint(Breakpoint):
def __init__(self, pid, address, condition):
super().__init__(pid, address)
self.condition = condition # 可以是表达式字符串

def should_stop(self, debugger):
# 评估条件
return eval_expression(self.condition, debugger)

# 2. 监视点(Watchpoint)
def set_watchpoint(self, address, size=4, mode='rw'):
"""使用硬件调试寄存器实现"""
# 需要操作 DR0-DR7 寄存器
pass

# 3. 反向执行(Reverse Execution)
# 需要记录执行轨迹,比较高级

6.2 更好的用户体验

  • 添加源代码级调试(需要 DWARF 调试信息解析)
  • 支持 Python 脚本扩展
  • 图形界面(可以用 PyQt 包装)

6.3 完整代码获取

完整的代码文件可以在我的 GitHub 仓库找到(示例链接),你也可以基于这个框架继续开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 完整的调试器骨架
class FullDebugger(Debugger):
def __init__(self):
super().__init__()
self.symbols = {} # 符号表
self.source_files = {} # 源代码映射
self.history = [] # 执行历史

def load_symbols(self, elf_path):
"""解析 ELF 文件的符号表"""
import elftools
# 使用 pyelftools 解析调试信息

def disassemble(self, address, count=10):
"""反汇编"""
import capstone
# 使用 Capstone 引擎反汇编

七、总结

通过实现这个微型调试器,我们深入理解了:

  • ptrace 如何提供进程控制能力
  • 断点 如何通过 int 3 实现
  • 寄存器/内存 的读写方法
  • 调试事件 的处理流程

虽然现代调试器如 GDB、LLDB 功能强大到令人眼花缭乱,但它们的核心原理与这里展示的代码并无二致。当你下次在 GDB 中输入 break mainnext 时,希望你能想起背后那些 0xCCptrace 调用。


如果你对操作系统底层、调试技术或二进制分析感兴趣,欢迎在评论区交流!

 打赏作者
 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务