ptrace 与调试接口

本页目录

ptrace 让 tracer 观察并控制受权限规则约束的 tracee 线程,例如在停止点读取寄存器、检查内存、继续运行或单步。Linux 原生调试器和系统调用跟踪器常使用它,但整个进程、多线程组与单个被跟踪线程不是同一个对象。

/proc/PID/mem、process_vm_readv 和 seccomp user notification 各解决部分问题,不能直接视为可互相替换的“新一代 ptrace”。

建立跟踪关系后,还要等到可操作状态

PTRACE_TRACEME 由 tracee 调用,请求由父进程跟踪,并不表示任意其他进程此后都有权限。PTRACE_ATTACH 附加并触发相应停止流程,tracer 还需 wait 确认;PTRACE_SEIZE 建立关系而不立即停止,之后可使用 PTRACE_INTERRUPT 请求停止。

ptrace 请求通常以 TID 指定线程。一个线程只能有一个 tracer,附加某个 TID 不会自动把所有现有和未来线程都完整纳入控制。需要枚举现有线程,并按需求设置 clone、fork、exec、exit 等事件选项。

一些读写请求要求 tracee 已处于合适的 ptrace-stop。仅看到进程“似乎不运行”或刚刚调用了 ATTACH,不足以保证马上读取寄存器成功。ptrace(2)

停止状态是调试器的核心

调试循环的第一步:识别停止原因,再决定怎样继续 waitpid 返回 tracee 状态 退出、终止,还是停止? 系统调用停止 读取 entry / exit 信息 信号递送停止 决定是否转交原信号 ptrace 事件或其他停止 按事件处理线程与执行状态 SIGTRAP 不是唯一一种停止原因;不能假定每两次 wait 就恰好是一次 syscall 的入口和出口。

系统调用入口和出口之间可能夹杂信号、exec、退出或其他事件。真实循环要先区分 WIFEXITED、WIFSIGNALED、WIFSTOPPED,再解释具体停止类型,而不是无条件把两次 wait 当作一对。

PTRACE_O_TRACESYSGOOD 可让系统调用停止的信号标识带上 0x80,便于与普通 SIGTRAP 区分。支持时 PTRACE_GET_SYSCALL_INFO 能取得更明确的入口、出口等信息,减少直接依赖架构寄存器布局;仍要处理重启系统调用和事件转换。

信号递送停止后,以 0 继续可能抑制该信号;传入原信号则让其继续递送。不能对所有停止一律传 0,然后认为跟踪对程序语义完全透明。group-stop、ptrace 事件停止和普通信号停止的处理也不同。

系统调用配对属于线程,其他停止不能翻转入口/出口状态 TID 11:entry read 记录当前线程的调用 TID 12:entry write 另建一份状态 TID 11:signal stop 识别信号,不当成 exit TID 12:exit write 只结束线程 12 的配对 TID 11:exit read 只结束线程 11 的配对 图示是离线事件序列;真实采集还要处理 syscall restart、exec、线程退出和失配。

分类与配对要分别做

先按 wait 状态区分退出和停止,再从停止信息识别事件;不要先翻转“进入/退出”布尔值,再想办法忽略信号。使用 PTRACE_GET_SYSCALL_INFO 时也要检查返回的 op,只有明确的 ENTRY/EXIT 才进入相应处理,SECCOMP 与 NONE 不能硬塞进普通配对。ptrace 停止与 syscall info

下面是已经分类好的离线事件模型,不调用 ptrace。它演示两个线程交错时,一个全局 in_syscall 布尔值为什么不够;线程结束但没有普通 syscall exit 时,将尚未完成的观察另行记录。

pending = {}
completed = []
incomplete = []
signal_stops = []
events = [
    (11, "entry", "read"),
    (12, "entry", "write"),
    (11, "signal", "SIGUSR1"),
    (12, "exit", 3),
    (11, "exit", 8),
    (12, "entry", "exit_group"),
    (12, "terminated", 0),
]
for tid, kind, value in events:
    if kind == "entry":
        assert tid not in pending
        pending[tid] = value
    elif kind == "exit":
        assert tid in pending
        completed.append((tid, pending.pop(tid), value))
    elif kind == "signal":
        signal_stops.append((tid, value))
    elif kind == "terminated":
        if tid in pending:
            incomplete.append((tid, pending.pop(tid)))
assert completed == [(12, "write", 3), (11, "read", 8)]
assert signal_stops == [(11, "SIGUSR1")]
assert incomplete == [(12, "exit_group")]
assert not pending

模型的 assert 是为了暴露输入中断裂的配对。实际 tracer 不能遇到附加时已在系统调用内、exec 或重启路径就直接当作程序错误退出;需要按观察起点和事件语义重建状态,并给无法可靠关联的记录加标记。耗时统计也只能覆盖实际配对的区间,不能把缺失出口的调用记为零耗时。

停止分类适用动作不应照搬的动作
明确 syscall entry/exit读取相应调用信息,按 TID 关联每次 SIGTRAP 都翻转配对
signal-delivery-stop读取信号信息,决定是否转交所有信号一律丢弃
ptrace event更新线程、exec 或退出状态把事件码当作普通系统调用结果
group-stop按所用附加方式处理作业控制无条件 CONT,改变原有停止语义
已退出/被终止回收线程状态与未完成记录继续读已不存在的寄存器

一次信号停止,会打乱 syscall 配对吗?

让 A 进入 syscall,随后让 B 进入,再给 A 加一次信号停止,最后先结束 B 的调用。两条调用仍能正确配对;如果用全局进入/退出开关,第二条入口就会被误认成出口。线程终止时未配对的记录应标为不完整,不能记为零耗时。

正在呈现知识画面
一次信号停止,会打乱 syscall 配对吗?

syscall entry/exit 需要先分类再按 TID 配对。信号停止不完成调用,线程退出会留下未完成观察。

系统调用观察与扰动

PTRACE_SYSCALL 请求在相关系统调用边界停止。入口可读调用号和参数,出口可读返回值;raw 返回的负 errno 与 libc 包装层的 -1 + errno 不同。x86-64 的系统调用第 4 参数在 r10,不是普通 C 调用的 rcx;arm64 有自己的 ABI。

每个停止都涉及内核处理、调度 tracer 和读取信息,频繁系统调用会被明显扰动。不能把开销固定成精确四次上下文切换,更不能用被跟踪运行时间直接替代正常性能。

strace 可过滤显示内容,但显示过滤与是否仍发生 ptrace 停止不是同一层次;某些优化模式会借助 seccomp 减少无关停止。性能热点可以先用采样或 BPF 聚合定位,再用细粒度跟踪验证具体行为。

读寄存器与内存

PTRACE_GETREGS 并非所有架构都提供相同接口。PTRACE_GETREGSET 配合相应 note 类型和 iovec 更适合按寄存器集取状态,仍需依据目标架构解释字节布局。

PEEK 类操作成功读出的数据本身可能等于 -1,因此必须结合 errno:

#include <errno.h>
#include <stddef.h>
#include <sys/ptrace.h>
#include <sys/types.h>

int peek_word(pid_t tid, const void *address, long *out) {
    errno = 0;
    long value = ptrace(PTRACE_PEEKDATA, tid, address, NULL);
    if (value == -1 && errno != 0) return -1;
    *out = value;
    return 0;
}

调用方须先获得权限并使目标停在适当状态。按机器字读内存还涉及长度、对齐、页边界和目标字长;一个 word 成功不能推出旁边页也可读。

软件断点与硬件断点

在 x86 上,软件断点常用单字节 INT3 替换目标指令开头。命中后调试器识别断点、恢复原指令,并按架构规则调整 PC(INT3 停止位置通常已越过该字节),执行原指令后再重新插入断点。

只写“恢复后继续”会漏掉重新插入和多线程竞争:另一线程可能在断点暂时移除期间经过该位置。调试器需要暂停协调、单步或其他策略。arm64 的 BRK 编码及 PC 处理不同,不能把 0xCC 泛化到所有目标。

硬件执行断点、观察点依架构调试资源实现,有数量、长度和对齐限制。优化还可能让一个源码行对应多个地址,或某个变量没有可读取的内存位置;这不是单纯读错寄存器。

/proc/PID/mem 与 process_vm_readv

/proc/PID/mem 允许按目标虚拟地址通过文件偏移访问内存,打开受 ptrace 访问模式与其他安全规则约束,并非绕过权限。是否需先停止目标、哪些地址能访问,还取决于一致性要求和具体接口行为。proc_pid_mem(5)

process_vm_readv/writev 可在进程间批量传输内存,Linux 3.2 已提供,并非 5.x 才有。它们可能部分传输,不保证整个向量操作原子,也不替应用冻结目标;读取一个正被修改的链表,可能拼出从未同时存在过的字段组合。process_vm_readv(2)

批量读取适合降低访问开销,但不提供单步、断点或寄存器停止语义。访问权限与页面保护细节也不应按“新版内核就都允许”概括。

正的部分读取结果,只证明一个已复制前缀

假设本地目标缓冲容量 8192,process_vm_readv 返回 4096。只有前 4096 字节是本次成功复制的数据,后半段不能作为新快照解析;“返回非负”不足以证明整个结构完整。应在提交解析结果前,核对实际长度是否覆盖所需字段。

读取策略可以改善什么仍然解决不了什么
按页拆分远端 iovec将未知地址的故障边界限制在较小区域整个对象跨页时的一致性
核对实际传输长度避免解析未读入的尾部目标在两次读取之间修改内容
先读头部再按长度读主体限制分配规模与读取范围头部与主体属于不同版本
停止所有会修改对象的线程在已覆盖范围内减少并发修改其他进程共享映射等外部修改者

process_vm_readv 的部分传输以 iovec 元素边界处理;读不知长度的字符串时,不要让一个远端 iovec 无边界地跨越未知页面。更关键的是,复制过程不提供应用对象的原子快照语义。process_vm_readv 传输约定

例如读取到旧的 length=100,又读到已经被替换的新指针,单独两个字段都可读,拼在一起却不一定曾经存在。若目标程序能够配合,使用目标自己生成并发布的不可变快照更容易定义一致性;若不能配合,就必须在结果中保留采集范围和一致性限制。

seccomp user notification:委托处理部分系统调用

seccomp 过滤器可让匹配的调用向监听者发出通知,由监督进程返回结果、错误,或在支持的情形下继续原调用、注入 fd。它适合特定系统调用的代理与虚拟化,不是通用用户指令调试器。

通知中出现的用户指针所指内存可能被其他线程修改。先读取路径做检查,再允许原系统调用继续使用同一指针,存在 TOCTOU:调用执行时内容可能已不同。通知 ID 有效性检查只能解决一部分生命周期问题,不能证明用户内存内容被冻结。

因此不能把 CONTINUE 方式当作对指针内容做安全授权的通用方案,也不能把监督端“检查/修改/批准”写成原子事务。应按需要复制、代理操作并返回受控资源,遵守接口的具体竞态处理规则。seccomp_unotify(2)

Yama 是附加限制,不替代基础权限

ptrace_scope核心含义
0经典权限检查;不是任何用户可跟踪任何进程
1受限附加,通常要求适当祖先关系或显式授权关系;管理员能力例外仍有条件
2仅具有相应 CAP_SYS_PTRACE 能力的管理者可执行受限跟踪操作
3禁止相应附加与 TRACEME 跟踪;设置后不能再降低该值

具体还要结合凭证、dumpable、用户名字空间、其他 LSM 和 capability。不能把 2 写成“仅后代可跟踪”。现代 Linux 也不存在对 PID 1 在所有权限环境下一概禁止的简单规则。Yama 文档

诊断权限失败时,先确认父子关系、TID、凭证和系统策略,而不是直接修改全局限制。跟踪带特权执行的程序还可能改变 exec 时凭证提升行为,因此调试状态与正常运行状态可能不同。

选择观察接口

断点、单步与受控寄存器访问选调试器/ptrace;批量内存检查可结合 process_vm_readv;统计高频行为可考虑 perf/BPF;系统调用代理则按 seccomp 通知协议设计。所有方案都需明确观察范围和一致性,工具更快不代表证据自动更完整。

关键词:ptrace, tracee, TID, syscall stop, TRACESYSGOOD, GETREGSET, breakpoint, process_vm_readv, seccomp notification, Yama, TOCTOU