PEP 768 – CPython 的安全外部调试器接口
- 作者:
- Pablo Galindo Salgado <pablogsal at python.org>,Matt Wozniski <godlygeek at gmail.com>,Ivona Stojanovic <stojanovic.i at hotmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2024年11月25日
- Python 版本:
- 3.14
- 发布历史:
- 2024年12月11日
- 决议:
- 2025年3月17日
摘要
本 PEP 提议为 CPython 添加一个零开销的调试接口,允许调试器和分析器安全地附加到正在运行的 Python 进程。该接口提供了安全的执行点,用于附加调试器代码,而无需修改解释器的正常执行路径或增加运行时开销。
此接口的一个关键应用将是使 pdb 能够通过进程 ID 附加到实时进程,类似于 gdb -p
,允许开发人员实时交互式地检查和调试 Python 应用程序,而无需停止或重新启动它们。
动机
在生产和实时环境中调试 Python 进程面临独特的挑战。开发人员通常需要分析应用程序行为,而无需停止或重新启动服务,这对于高可用性系统尤为重要。常见场景包括诊断死锁、检查内存使用情况或调查实时意外行为。
很少有 Python 工具可以附加到正在运行的进程,这主要是因为这样做需要对操作系统调试接口和 CPython 内部结构都有深入的专业知识。虽然像 GDB 和 LLDB 这样的 C/C++ 调试器可以使用众所周知的技术附加到进程,但 Python 工具必须实现所有这些低级机制,并处理额外的复杂性。例如,当 GDB 需要在目标进程中执行代码时,它会:
- 使用 ptrace 分配一小块可执行内存(说起来容易做起来难)
- 写入一小段机器代码——通常是函数序言、所需指令和用于恢复寄存器的代码
- 保存所有目标线程的寄存器
- 将指令指针更改为注入的代码
- 让进程运行,直到它在注入代码的末尾遇到断点
- 恢复原始寄存器并继续执行
Python 工具面临着与代码注入相同的挑战,但又增加了一层复杂性。它们不仅需要实现上述机制,还必须理解并安全地与 CPython 的运行时状态交互,包括解释器循环、垃圾回收器、线程状态和引用计数系统。这种低级系统操作和深层领域特定解释器知识的结合使得实现 Python 调试工具异常困难。
少数尝试这样做的工具(例如 DebugPy 和 Memray)诉诸于次优和不安全的方法,使用 GDB 和 LLDB 等系统调试器强制注入代码。这种方法从根本上说是不安全的,因为注入的代码可以在解释器执行周期的任何时候执行——即使在内存分配、垃圾回收或线程状态管理等关键操作期间。当这种情况发生时,结果是灾难性的:在 malloc()
内部尝试分配内存会导致崩溃,在垃圾回收期间修改对象会破坏解释器的状态,而在错误的时间触摸线程状态会导致死锁。
各种工具试图通过复杂的变通方法来最小化这些风险,例如为注入代码生成单独的线程,或仔细计时其操作,或尝试选择一些好的点来停止进程。然而,这些缓解措施无法完全解决根本问题:如果没有解释器的配合,就无法知道在任何给定时刻执行代码是否安全。即使是精心实现的工具也可能使解释器崩溃,因为它们从根本上是与解释器对抗而不是合作。
基本原理
与其强制工具使用不安全的代码注入来解决解释器限制,不如通过一个适当的调试接口来扩展 CPython,从而保证安全执行。通过添加一些线程状态字段并与解释器现有的评估循环集成,我们可以确保调试操作仅在明确定义的安全点发生。这消除了崩溃和损坏的可能性,同时在正常执行期间保持零开销。
关键的见解是,我们不需要在任意点注入代码——我们只需要向解释器发出信号,表明我们希望在下一个安全机会执行代码。这种方法与解释器的自然执行流程协同工作,而不是对抗它。
在向 PyPy 开发团队描述了这个想法之后,这个提案已经在 PyPy 中实现,证明了其可行性和有效性。他们的实现表明,我们可以在正常执行期间以零运行时开销提供安全的调试功能。所提出的机制不仅降低了当前调试方法相关的风险,还为未来的增强奠定了基础。例如,这个框架可以实现与流行可观测性工具的集成,提供对解释器性能或内存使用的实时洞察。此接口的一个引人注目的用例是使 pdb 能够附加到正在运行的 Python 进程,类似于 gdb 允许用户通过进程 ID (gdb -p <pid>
) 附加到程序。通过此功能,开发人员可以检查运行中应用程序的状态、评估表达式并动态单步执行代码。这种方法将使 Python 的调试功能与其他主要编程语言和支持此模式的调试工具保持一致。
规范
本提案引入了一种安全的调试机制,允许外部进程在明确定义的安全点触发 Python 解释器中的代码执行。关键的见解是,与其通过系统调试器直接注入代码,不如利用解释器现有的评估循环和线程状态来协调调试操作。
该机制的工作原理是,调试器写入目标进程中解释器在正常执行周期中检查的特定内存位置。当解释器检测到调试器要附加时,它只在安全时执行请求的操作——也就是说,在没有持有内部锁并且所有数据结构都处于一致状态时。
运行时状态扩展
新的结构被添加到 PyThreadState 中以支持远程调试
typedef struct {
int debugger_pending_call;
char debugger_script_path[...];
} _PyRemoteDebuggerSupport;
此结构附加到 PyThreadState
,仅添加了在正常执行期间从不访问的几个字段。debugger_pending_call
字段指示何时调试器请求执行,而 debugger_script_path
提供了一个 Python 源文件 (.py) 的文件系统路径,该文件将在解释器到达安全点时执行。该路径必须指向 Python 源文件,而不是编译后的 Python 代码 (.pyc) 或任何其他格式。
debugger_script_path
的大小将是二进制大小和调试脚本路径大小之间的权衡。为了限制每个线程的内存开销,我们将将其限制为 512 字节。此大小也将作为调试器支持结构的一部分提供,以便调试器知道可以写入多少。如果将来需要,可以扩展此值。
调试偏移表
Python 3.12 引入了一个放置在 PyRuntime 结构开头的调试偏移表。此部分包含 _Py_DebugOffsets
结构,允许外部工具可靠地查找关键运行时结构,而不管 ASLR 或 Python 是如何编译的。
本提案扩展了现有的调试偏移表,增加了对调试器支持的新字段
struct _debugger_support {
uint64_t eval_breaker; // Location of the eval breaker flag
uint64_t remote_debugger_support; // Offset to our support structure
uint64_t debugger_pending_call; // Where to write the pending flag
uint64_t debugger_script_path; // Where to write the script path
uint64_t debugger_script_path_size; // Size of the script path buffer
} debugger_support;
这些偏移量允许调试器在目标进程的内存空间中定位关键调试控制结构。eval_breaker
和 remote_debugger_support
偏移量相对于每个 PyThreadState
,而 debugger_pending_call
和 debugger_script_path
偏移量相对于每个 _PyRemoteDebuggerSupport
结构,允许找到新结构及其字段,而不管它们在内存中的位置。debugger_script_path_size
通知附加工具缓冲区的大小。
附加协议
当调试器要附加到 Python 进程时,它遵循以下步骤:
- 在进程中找到
PyRuntime
结构- 在进程内存中找到 Python 二进制文件(可执行文件或 libpython)(OS 相关进程)
- 从二进制文件的格式(ELF/Mach-O/PE)中提取
.PyRuntime
节偏移量 - 通过将偏移量重新定位到二进制文件的加载地址来计算运行进程中实际的
PyRuntime
地址
- 通过读取
PyRuntime
结构开头的_Py_DebugOffsets
来访问调试偏移信息。 - 使用偏移量找到所需的线程状态
- 使用偏移量找到该线程状态中的调试器接口字段
- 写入控制信息
- 大多数调试器会在写入进程内存之前暂停进程。这是 GDB 等工具的标准做法,它们使用 SIGSTOP 或 ptrace 来暂停进程。这种方法可以防止在写入进程内存时出现竞争条件。不想停止进程的分析器和其他工具仍然可以使用此接口,但它们需要处理可能的竞争条件。这是分析器的正常考虑。
- 将 Python 源文件 (.py) 的文件路径写入
_PyRemoteDebuggerSupport
中的debugger_script_path
字段。 - 将
_PyRemoteDebuggerSupport
中的debugger_pending_call
标志设置为 1 - 在
eval_breaker
字段中设置_PY_EVAL_PLEASE_STOP_BIT
一旦解释器到达下一个安全点,它将执行调试器指定文件中包含的 Python 代码。
解释器集成
解释器的常规评估循环已经包含对 eval_breaker
标志的检查,用于处理信号、周期性任务和其他中断。我们通过仅在设置 eval_breaker
时检查调试器待处理调用来利用此现有机制,从而确保正常执行期间的零开销。此检查没有开销。事实上,使用 Linux perf
进行的分析表明,此分支具有高度可预测性——在正常执行期间从不执行 debugger_pending_call
检查,从而使现代 CPU 能够有效地推测其通过。
当调试器同时设置了 eval_breaker
标志和 debugger_pending_call
时,解释器将在下一个安全点执行提供的调试代码。这一切都发生在完全安全的上下文中,因为每当检查评估中断器时,解释器都保证处于一致状态。
debugger_pending_call
的唯一有效值最初将是 0 和 1,其他值保留供将来使用。
在代码执行之前将引发一个审计事件,允许系统管理员根据需要审计或禁用此机制。
// In ceval.c
if (tstate->eval_breaker) {
if (tstate->remote_debugger_support.debugger_pending_call) {
tstate->remote_debugger_support.debugger_pending_call = 0;
const char *path = tstate->remote_debugger_support.debugger_script_path;
if (*path) {
if (0 != PySys_Audit("debugger_script", "%s", path)) {
PyErr_Clear();
} else {
FILE* f = fopen(path, "r");
if (!f) {
PyErr_SetFromErrno(OSError);
} else {
PyRun_AnyFile(f, path);
fclose(f);
}
if (PyErr_Occurred()) {
PyErr_WriteUnraisable(...);
}
}
}
}
}
如果正在执行的代码引发任何 Python 异常,它将在执行代码的线程中作为不可引发的异常处理。
Python API
为了支持在远程进程中安全执行 Python 代码,而无需在每个工具中重新实现所有这些步骤,本提案通过一个新函数扩展了 sys
模块。此函数允许调试器或外部工具在指定 Python 进程的上下文中执行任意 Python 代码。
def remote_exec(pid: int, script: str|bytes|PathLike) -> None:
"""
Executes a file containing Python code in a given remote Python process.
This function returns immediately, and the code will be executed by the
target process's main thread at the next available opportunity, similarly
to how signals are handled. There is no interface to determine when the
code has been executed. The caller is responsible for making sure that
the file still exists whenever the remote process tries to read it and that
it hasn't been overwritten.
Args:
pid (int): The process ID of the target Python process.
script (str|bytes|PathLike): The path to a file containing
the Python code to be executed.
"""
API 的示例用法如下:
import sys
import uuid
# Execute a print statement in a remote Python process with PID 12345
script = f"/tmp/{uuid.uuid4()}.py"
with open(script, "w") as f:
f.write("print('Hello from remote execution!')")
try:
sys.remote_exec(12345, script)
except Exception as e:
print(f"Failed to execute code: {e}")
配置 API
为了允许重新分发者、系统管理员或用户禁用此机制,将提供几种方法来控制解释器的行为
将提供一个新的 PYTHON_DISABLE_REMOTE_DEBUG
环境变量来控制运行时的行为。如果设置为任何值(包括空字符串),解释器将忽略任何使用此机制附加调试器的尝试。
此环境变量将与一个新的 -X disable-remote-debug
标志一起添加到 Python 解释器中,以允许用户在运行时禁用此功能。
此外,configure
脚本将添加一个新的 --without-remote-debug
标志,以允许重新分发者在需要时构建不支持远程调试的 Python。
将通过调试偏移量提供一个指示远程调试状态的新标志,以便工具可以查询远程进程是否已禁用该功能。这样,工具可以提供有用的错误消息,解释为什么它们无法工作,而不是认为它们已附加但其脚本从未运行。
多线程考虑
整体执行模式类似于 Python 内部处理信号的方式。解释器保证注入的代码只在安全点运行,绝不会中断解释器内部的原子操作。这种方法确保调试操作不会破坏解释器状态,同时在大多数实际场景中仍能提供及时执行。
然而,通过此接口注入的调试代码可以在任何线程中执行。此行为与 Python 处理信号的方式不同,因为信号处理程序只能在主线程中运行。如果调试器想要将代码注入到每个运行线程中,它必须将其注入到每个 PyThreadState
中。如果调试器想要在第一个可用线程中运行代码,它需要将其注入到每个 PyThreadState
中,并且该注入的代码必须检查它是否已被另一个线程运行过(可能通过在某个模块的全局变量中设置某个标志)。
请注意,当注入的代码运行时,全局解释器锁 (GIL) 仍像往常一样控制执行。这意味着如果目标线程当前正在执行持续持有 GIL 的 C 扩展,则注入的代码将无法运行,直到该操作完成且 GIL 可用。然而,该接口除了注入代码本身所需的 GIL 争用之外,不引入任何额外的 GIL 争用。重要的是,该接口与 Python 的自由线程模式完全兼容。
对于注入了一些代码的调试器来说,接下来发送一些预先注册的信号给进程可能是有用的,这可以中断任何阻塞的 I/O 或等待外部资源的睡眠状态,并提供一个运行注入代码的安全机会。
向后兼容性
此更改对现有 Python 代码或解释器性能没有影响。添加的字段仅在调试器附加期间访问,并且检查机制利用了现有的解释器安全点。
安全隐患
此接口不会引入新的安全问题,因为它只能由已能写入给定进程中的任意内存并在机器上执行任意代码(以便创建包含要执行的 Python 代码的文件)的进程使用。
此外,代码的执行受解释器的审计钩子控制,审计钩子可用于在敏感环境中监视或阻止代码的执行。
现有的操作系统安全机制可有效防范攻击者获取任意内存写入权限。尽管本 PEP 没有指定如何将内存写入目标进程,但在实践中,这将通过标准系统调用完成,这些系统调用已被其他调试器和工具使用。一些示例如下:
- 在 Linux 上,process_vm_readv() 和 process_vm_writev() 系统调用用于从另一个进程读取和写入内存。这些操作受 ptrace 访问模式检查的控制——这些检查也控制调试器附加。只有在具有适当权限(通常需要 root 或 CAP_SYS_PTRACE 功能,尽管安全性较低的发行版可能允许任何以相同 uid 运行的进程附加)的情况下,进程才能读取或写入另一个进程的内存。
- 在 macOS 上,该接口将通过 Mach 任务系统利用 mach_vm_read_overwrite() 和 mach_vm_write()。这些操作需要
task_for_pid()
访问,这由操作系统严格控制。默认情况下,访问仅限于以 root 身份运行的进程或由 Apple 安全框架授予特定权限的进程。 - 在 Windows 上,ReadProcessMemory() 和 WriteProcessMemory() 函数提供类似的功能。访问通过 Windows 安全模型控制——进程需要 PROCESS_VM_READ 和 PROCESS_VM_WRITE 权限,这通常需要相同的用户上下文或适当的特权。这些是调试器所需的相同权限,确保了跨平台的一致安全语义。
所有机制都确保
- 只有授权进程才能读取/写入内存
- 适用于传统调试器附件的相同安全模型
- 除了操作系统为调试提供的功能之外,不暴露任何额外的攻击面
- 即使攻击者可以写入任意内存,除非他们已经拥有文件系统访问权限,否则他们无法将其升级为任意代码执行
内存操作本身已经成熟,并在 GDB、LLDB 和各种系统分析器等工具中安全使用了数十年。
重要的是,任何通过此机制附加到 Python 进程的尝试都将可以通过系统级监控工具以及 Python 审计钩子检测到。这种透明性提供了额外的问责层,允许管理员在敏感环境中审计调试操作。
此外,严格依赖操作系统级别的安全控制确保了现有系统策略保持有效。对于企业环境,这意味着管理员可以继续使用标准工具和策略强制执行调试限制,而无需额外的配置。例如,利用 Linux 的 ptrace_scope 或 macOS 的 taskgated
来限制调试器访问将同样管理拟议的接口。
通过保持与现有安全框架的兼容性,这种设计确保了采用新接口不需要更改既定框架。
安全场景
- 对于外部攻击者来说,在进程中写入任意内存的能力已经是一个严重的安全问题。此接口不引入任何新的攻击面,因为攻击者已经能够在进程中执行任意代码。此接口的行为与现有调试器完全相同,不引入任何新的额外安全风险。
- 对于已获得进程任意内存写入权限但未获得任意代码执行权限的攻击者,此接口不允许他们升级权限。需要计算并写入特定内存位置的能力,这在不损害 Python 进程外部的其他机器资源的情况下是无法获得的。
此外,要执行的代码受解释器审计钩子的限制,这意味着代码的执行可以由系统管理员监控和控制。这意味着即使攻击者已经破坏了应用程序和文件系统,利用此接口进行恶意目的对于攻击者来说也是一个非常危险的提议,因为他们有暴露其行为给系统管理员的风险,系统管理员不仅可以检测到攻击,还可以采取行动阻止它。
最后,需要注意的是,如果攻击者对进程具有任意内存写入权限并已破坏文件系统,他们已经可以使用其他现有机制升级到任意代码执行,因此此接口在这种情况下不会引入任何新的风险。
如何教授此内容
对于工具作者来说,此接口将成为实现调试器附件的标准方式,取代不安全的系统调试器方法。Python 开发者指南中的一个部分可以描述该机制的内部工作原理,包括 debugger_support
偏移量以及如何使用系统 API 与它们交互。
最终用户无需了解此接口,只需受益于改进的调试工具稳定性和可靠性。
参考实现
一个带有原型以添加对 pdb
远程支持的参考实现可以在此处找到。
被拒绝的想法
将 Python 代码写入缓冲区
我们选择让调试器将包含 Python 代码的文件路径写入远程进程中的缓冲区。这被认为比将要执行的 Python 代码本身写入远程进程中的缓冲区更安全,因为这意味着即使攻击者在进程中获得了任意写入权限但没有任意代码执行或文件系统操作权限,也无法通过此接口升级到任意代码执行。
然而,这确实要求附加调试器在创建包含要执行的代码的文件时密切注意文件系统权限。如果攻击者能够覆盖该文件,或替换文件路径中的符号链接以指向攻击者控制的位置,这将使他们能够强制执行其恶意代码,而不是调试器打算运行的代码。
使用单一运行时缓冲区
在对本 PEP 的审查过程中,有人建议在运行时级别使用一个单一的共享缓冲区来处理所有调试器通信。虽然这看起来更简单,需要的内存更少,但我们发现它实际上会阻止多个调试器需要在不同线程之间协调操作,或者单个调试器需要协调复杂调试操作的场景。单一共享缓冲区会强制所有调试操作串行化,从而使调试器无法在不同线程上独立工作。
尽管在高度线程化的应用程序中存在内存开销,但每个线程缓冲区的方法通过允许每个调试器独立地与其目标线程通信来启用这些重要的调试场景。
致谢
我们感谢 CF Bolz-Tereick 在讨论此提案时提出的富有洞察力的评论和建议。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0768.rst