Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 578 – Python 运行时审计钩子

作者:
Steve Dower <steve.dower at python.org>
BDFL 委托
Christian Heimes <christian at python.org>
状态:
最终版
类型:
标准跟踪
创建日期:
2018年6月16日
Python 版本:
3.8
发布历史:
2019年3月28日,2019年5月7日

目录

重要

本 PEP 是一份历史文档。最新的、规范的文档现在可以在 审计事件表 中找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

本 PEP 描述了对 Python API 的添加以及 CPython 实现的特定行为,这些行为使 Python 运行时采取的操作对审计工具可见。对这些操作的可见性为测试框架、日志框架和安全工具提供了监控并选择性限制运行时所采取操作的机会。

本 PEP 提议添加两个 API,以提供对运行中的 Python 应用程序的洞察:一个用于任意事件,另一个专门用于模块导入系统。这些 API 旨在所有 Python 实现中可用,尽管此处未指定具体使用的消息和值,以允许实现者自由决定如何最好地向其用户提供信息。提供了一些 CPython 中可能使用的示例,以供解释。

请参阅 PEP 551,了解关于利用这些审计 API 增强 Python 运行时安全性的讨论和建议。

背景

Python 提供了对许多常见操作系统上广泛的低级功能的访问。虽然这对“一次编写,随处运行”的脚本非常有用,但也使得监控用 Python 编写的软件变得困难。由于 Python 直接使用原生系统 API,现有的监控工具要么上下文有限,要么审计被绕过。

有限上下文是指系统监控可以报告某个操作发生了,但无法解释导致该操作的事件序列。例如,操作系统层面的网络监控可能能够报告“在端口 5678 上开始监听”,但可能无法提供进程 ID、命令行、父进程或触发该操作时程序中的本地状态。防止此类操作的防火墙控制也同样受限,通常仅限于进程名称或某些全局状态(如当前用户),并且在任何情况下都很少提供与应用程序其他消息相关的有用日志文件。

当用于某个操作的典型系统工具通常会报告其使用情况,但通过 Python 访问 API 时不会触发报告,就会发生审计绕过。例如,在审计系统中,调用“curl”进行 HTTP 请求可能会被专门监控,但 Python 的“urlretrieve”函数则不会。

在一个长时间运行的 Python 应用程序中,尤其是处理用户提供的信息(如 Web 应用程序)的应用程序中,存在意外行为的风险。这可能是由于代码中的错误,也可能是恶意用户故意引起的。在这两种情况下,正常的应用程序日志可能会被绕过,导致没有任何迹象表明发生了异常情况。

此外,Python 的一个独特之处在于,通过操作导入系统的搜索路径或将文件放置在路径上比预期更早的位置,很容易影响应用程序中运行的代码。当开发人员创建与他们打算使用的模块同名的脚本时,经常会看到这种情况——例如,一个尝试导入标准库 random 模块的 random.py 文件。

这并非沙盒,因为本提案不试图阻止恶意行为(尽管它确实启用了新的选项来实现这一点)。有关进一步讨论,请参阅下面的为何不是沙盒部分。

变更概述

这些更改旨在使应用程序开发人员和系统管理员都能够将 Python 集成到其现有的监控系统中,而无需规定这些系统的外观或行为。

我们提出了两项 API 更改来实现这一点:审计钩子和已验证的打开钩子。两者都可以从 Python 和原生代码中访问,允许纯 Python 代码编写的应用程序和框架利用额外的消息,同时也允许嵌入者或系统管理员部署始终启用审计的 Python 构建版本。

只有 CPython 必须提供此处描述的原生 API。其他实现应提供纯 Python API,并可根据其底层运行时提供适当的原生版本。审计事件也同样被认为是实现特定的,但受限于正常的特性兼容性保证。

审计钩子

为了观察运行时(代表调用者)执行的操作,需要一个 API 来在某些操作中发出消息。这些操作通常位于 Python 运行时或标准库的深处,例如动态代码编译、模块导入、DNS 解析或使用 ctypes 等特定模块。

以下新的 C API 允许嵌入者和 CPython 实现者发送和接收审计钩子消息

# Add an auditing hook
typedef int (*hook_func)(const char *event, PyObject *args,
                         void *userData);
int PySys_AddAuditHook(hook_func hook, void *userData);

# Raise an event with all auditing hooks
int PySys_Audit(const char *event, PyObject *args);

用于接收和引发审计钩子的新 Python API 是

# Add an auditing hook
sys.addaudithook(hook: Callable[[str, tuple]])

# Raise an event with all auditing hooks
sys.audit(str, *args)

通过随时从 C 调用 PySys_AddAuditHook()(包括在 Py_Initialize() 之前),或从 Python 代码调用 sys.addaudithook() 来添加钩子。钩子不能被移除或替换。对于 CPython,从 C 添加的钩子是全局的,而从 Python 添加的钩子仅适用于当前解释器。全局钩子在解释器钩子之前执行。

当发生感兴趣的事件时,代码可以从 C 调用 PySys_Audit()(在持有 GIL 的情况下)或 sys.audit()。字符串参数是事件的名称,元组包含参数。给定的事件名称应具有固定的参数模式,这应被视为公共 API(对于每个 x.y 版本发布),因此只能在特性发布之间进行更改,并更新文档。为了最小化开销并简化原生代码钩子实现中的处理,不支持命名参数。

为了最大兼容性,使用与参考解释器 CPython 中事件相同名称的事件应尽一切努力使用兼容的参数。在特定于实现的事件名称中包含实现的名称或缩写也有助于防止冲突。例如,pypy.jit_invoked 事件与 ipy.jit_invoked 事件明显区分开来。从 Python 模块引发的事件应在事件名称中包含其模块或包名称。

虽然事件名称可以是任意 UTF-8 字符串,但为了跨实现的一致性,建议使用有效的 Python 点分名称,并避免在名称中编码特定细节。例如,一个 import 事件,其中模块名称 spam 作为参数,优于一个没有参数的 spam module imported 事件。避免使用嵌入的空字符,否则可能会让那些使用 C 实现钩子的人感到不快。

当一个事件被审计时,每个钩子都会按照添加的顺序(尽可能)被调用,传递事件名称和参数。如果任何钩子返回时设置了异常,则后续钩子将被忽略,并且通常Python 运行时应该终止——钩子中的异常不打算被处理或视为预期情况。这允许钩子实现者决定如何响应任何特定事件。典型的响应将是记录事件、通过异常中止操作,或通过操作系统退出调用立即终止进程。

当事件被审计但未设置钩子时,audit() 函数应施加最小的开销。理想情况下,每个参数都是对现有数据的引用,而不是仅为审计调用计算的值。

由于钩子可能是 Python 对象,它们需要在解释器或运行时最终化期间释放。这些不应在任何其他时间触发,并且应引发事件钩子以确保观察到任何意外调用。

在下面的建议的审计钩子位置中,我们推荐了一些应引发审计事件的重要操作。一般来说,事件应在尽可能低的级别引发。在从 Python 代码或原生代码引发事件之间做出选择时,应优先从原生代码引发。

Python 实现应记录哪些操作会引发审计事件,以及事件模式。有意将 sys.addaudithook(print) 作为显示所有消息的简单方法。

已验证的打开钩子

大多数操作系统都有区分可执行文件和不可执行文件的机制。例如,这可能是权限字段中的执行位、文件内容的已验证哈希以检测潜在的代码篡改,或文件系统路径限制。这些是重要的安全机制,用于确保只有经过给定环境批准的代码才能执行。

大多数内核提供了限制或审计内核加载和执行的二进制文件的方法。Python 拥有的文件类型显示为常规数据,这些功能不适用。此打开钩子允许 Python 嵌入者在启动脚本或导入 Python 代码时与操作系统支持集成。

已验证的打开钩子的新公共 C API 是

# Set the handler
typedef PyObject *(*hook_func)(PyObject *path, void *userData)
int PyFile_SetOpenCodeHook(hook_func handler, void *userData)

# Open a file using the handler
PyObject *PyFile_OpenCode(const char *path)

已验证的打开钩子的新公共 Python API 是

# Open a file using the handler
io.open_code(path : str) -> io.IOBase

io.open_code() 函数是 open(abspath(str(pathlike)), 'rb') 的即插即用替代品。其默认行为是打开文件以进行原始二进制访问。要更改行为,应设置新的处理程序。处理程序函数只接受 str 参数。C API PyFile_OpenCode 函数假定 UTF-8 编码。路径必须是绝对路径,调用者有责任确保完整路径正确解析。

可以通过随时从 C 调用 PyFile_SetOpenCodeHook() 来设置自定义处理程序,包括在 Py_Initialize() 之前。但是,如果已经设置了钩子,则调用将失败。当设置了钩子调用 open_code() 时,钩子将传递路径,其返回值将直接返回。返回的对象应该是一个支持读取原始字节的打开文件类对象。这明确旨在允许 BytesIO 实例,如果打开处理程序已经将整个文件读入内存。

请注意,这些钩子可以在 CPython 上导入并调用 _io.open() 函数,而不会触发它们自己。它们还可以使用 _io.BytesIO 返回一个使用内存缓冲区的兼容结果。

如果钩子确定不应加载文件,它应引发所选择的异常,并执行任何其他日志记录。

所有涉及来自文件的代码的导入和执行功能都将无条件地更改为使用 open_code()。值得注意的是,对 compile()exec()eval() 的调用不会通过此函数——包含来自这些调用的代码的审计钩子是验证从文件中读取的代码的最佳机会。鉴于 Python 中导入和执行当前的分离,大多数导入的代码将通过 open_code()compile 的日志钩子,因此应注意避免重复验证步骤。

不打算有意执行代码的文件访问预计不会使用此函数。这包括加载 pickle、XML 或 YAML 文件,其中代码执行通常被认为是恶意的,而不是有意的。这些操作应提供自己的审计事件,最好区分正常功能(例如,Unpickler.load)和代码执行(Unpickler.find_class)。

一些例子:如果文件类型通常需要执行位(在 POSIX 上)或在标记为从互联网下载时发出警告(在 Windows 上),则它可能应该使用 open_code() 而不是普通的 open()。使用 ZipFile 类打开 ZIP 文件应使用 open(),而通过 zipimport 打开它们应使用 open_code() 来表示正确的意图。在特定上下文中使用了错误函数的代码可能会绕过钩子,这在 CPython 和标准库中应被视为错误。在存在任意代码的情况下,必须结合使用 open_code 钩子和审计钩子来跟踪所有已执行的源。

没有提供 Python API 来更改打开钩子。要从 Python 代码修改导入行为,请使用 importlib 提供的现有功能。

API 可用性

虽然此处添加的所有函数都被视为公共且稳定的 API,但函数的行为是特定于实现的。此处的大多数描述都指的是 CPython 实现,尽管其他实现应提供这些函数,但没有要求它们的行为相同。

例如,sys.addaudithook()sys.audit() 应该存在但可能什么都不做。这允许代码调用 sys.audit() 而无需测试其存在,但不应假定其调用会产生任何效果。(在安全关键代码中包含存在性测试会提供另一个绕过审计的途径,因此最好让函数始终存在。)

io.open_code(path) 至少应始终返回 _io.open(path, 'rb')。使用该函数的代码不应再对可能发生的事情做出进一步假设,并且 CPython 以外的实现不需要允许开发人员使用钩子覆盖此函数的行为。

建议的审计钩子位置

sys.audit()PySys_Audit() 的调用位置和参数将由各个 Python 实现确定。这是为了最大限度地允许实现暴露与其平台最相关的操作,并避免或忽略可能昂贵或嘈杂的事件。

表 1 既是所有实现上应触发审计事件的操作建议,也是事件模式的示例。

表 2 提供了不需要但可能在 CPython 中可用的进一步示例。

请参阅与您的 Python 版本相关的文档,以了解哪些操作提供审计事件。

表 1:建议的审计钩子
API 函数 事件名称 参数 基本原理
PySys_AddAuditHook sys.addaudithook 检测何时添加新的审计钩子。
PyFile_SetOpenCodeHook cpython.PyFile_SetOpenCodeHook 检测任何尝试设置 open_code 钩子的行为。
compile, exec, eval, PyAst_CompileString, PyAST_obj2mod compile (code, filename_or_none) 检测动态代码编译,其中 code 可以是字符串或 AST。请注意,这将用于源代码的常规导入,包括那些用 open_code 打开的导入。
exec, eval, run_mod exec (code_object,) 检测代码对象的动态执行。这仅发生在显式调用时,对于正常的函数调用不会引发。
import import (module, filename, sys.path, sys.meta_path, sys.path_hooks) 检测模块何时被导入。这在模块名称解析为文件之前引发。除模块名称外的所有参数如果未使用或不可用,则可能为 None
open io.open (path, mode, flags) 检测文件即将被打开。如果可用,pathmodeopen 的常见参数,而在某些情况下,flags 代替 mode 提供。
PyEval_SetProfile sys.setprofile 检测代码何时注入跟踪函数。由于实现的原因,从钩子引发的异常将中止操作,但不会在 Python 代码中引发。请注意,threading.setprofile 最终会调用此函数,因此该事件将针对每个线程进行审计。
PyEval_SetTrace sys.settrace 检测代码何时注入跟踪函数。由于实现的原因,从钩子引发的异常将中止操作,但不会在 Python 代码中引发。请注意,threading.settrace 最终会调用此函数,因此该事件将针对每个线程进行审计。
_PyObject_GenericSetAttr, check_set_special_type_attr, object_set_class, func_set_code, func_set_[kw]defaults object.__setattr__ (object, attr, value) 检测类型和对象的猴子补丁。此事件针对 __class__ 属性和 type 对象上的任何属性引发。
_PyObject_GenericSetAttr object.__delattr__ (object, attr) 检测对象属性的删除。此事件针对 type 对象上的任何属性引发。
Unpickler.find_class pickle.find_class (module_name, global_name) 在解封时检测导入和全局名称查找。
表 2:潜在的 CPython 审计钩子
API 函数 事件名称 参数 基本原理
_PySys_ClearAuditHooks sys._clearaudithooks 通知钩子它们正在被清理,主要是在事件意外触发时。此事件无法中止。
code_new code.__new__ (bytecode, filename, name) 检测代码对象的动态创建。这仅发生在直接实例化时,对于正常编译不会引发。
func_new_impl function.__new__ (code,) 检测函数对象的动态创建。这仅发生在直接实例化时,对于正常编译不会引发。
_ctypes.dlopen, _ctypes.LoadLibrary ctypes.dlopen (module_or_path,) 检测何时使用原生模块。
_ctypes._FuncPtr ctypes.dlsym (lib_object, name) 收集从原生模块检索到的特定符号的信息。
_ctypes._CData ctypes.cdata (ptr_as_int,) 检测代码何时使用 ctypes 访问任意内存。
new_mmap_object mmap.__new__ (fileno, map_size, access, offset) 检测 mmap 对象的创建。在 POSIX 上,访问权限可能已从 protflags 参数计算得出。
sys._getframe sys._getframe (frame_object,) 检测代码何时直接访问帧。
sys._current_frames sys._current_frames 检测代码何时直接访问帧。
socket.bind, socket.connect, socket.connect_ex, socket.getaddrinfo, socket.getnameinfo, socket.sendmsg, socket.sendto socket.address (socket, address,) 检测对网络资源的访问。地址与原始调用未修改。
member_get, func_get_code, func_get_[kw]defaults object.__getattr__ (object, attr) 检测对受限属性的访问。此事件针对所有标记为受限的内置成员以及可能允许绕过导入的成员引发。
urllib.urlopen urllib.Request (url, data, headers, method) 检测 URL 请求。

性能影响

重要的性能影响是事件正在引发但没有附加钩子的情况。这是不可避免的情况——一旦开发人员添加了审计钩子,他们就明确选择了用性能换取功能。此处不关注添加钩子后的性能影响,因为这是可选功能。

使用 Python 性能基准套件 [1] 进行分析显示没有显著影响,绝大多数基准测试显示速度在快 1.05 倍到慢 1.05 倍之间。

我们认为,本 PEP 中描述的审计点的性能影响可以忽略不计。

被拒绝的想法

独立的审计钩子模块

提案是为审计钩子添加一个新模块,假设为 audit。这将把 API 和实现与 sys 模块分离,并允许将 C 函数命名为 PyAudit_AddHookPyAudit_Audit,而不是当前的各种变体。

任何此类模块都必须是内置模块,并保证始终存在。这些钩子的性质是它们必须无条件地可调用,因为任何有条件的导入或调用都提供了拦截和抑制或修改事件的机会。

鉴于 sys 模块是最核心的模块之一,它在一定程度上受到了模块影子攻击的保护。用一个功能足以让应用程序仍然运行的模块替换 sys 比替换一个只有一个感兴趣函数的模块要复杂得多。能够影子 sys 模块的攻击者已经能够从文件运行任意代码,而 audit 模块可以在搜索路径上的任何 .pth 文件中用一行代码替换

import sys; sys.modules['audit'] = type('audit', (object,),
    {'audit': lambda *a: None, 'addhook': lambda *a: None})

对于针对 sysaudit 的猴子补丁攻击,已经存在多层保护,但对 sys.modules 的赋值或插入并未被审计。

这个想法被拒绝了,因为它使得抑制所有对 audit 的调用变得微不足道。

sys.flags 中的标志,用于指示“审计”模式

提案是在 sys.flags 中添加一个值,以指示 Python 何时在“安全”或“审计”模式下运行。这将允许应用程序检测何时启用了某些功能或何时添加了钩子,并相应地修改其行为。

目前,我们不知道程序在存在审计钩子时表现不同的任何正当理由。

无论使用常规 python 入口点还是其他入口点,应用程序级 API sys.auditio.open_code 都始终存在且功能齐全。调用者无法确定是否已添加任何钩子(除非通过执行侧信道分析),也不需要这样做。调用应该足够快,以至于调用者不需要避免它们,并且程序有责任确保任何添加的钩子足够快,以不影响应用程序性能。

“这是靠模糊实现安全”的论点是有效的,但无关紧要。靠模糊实现安全只有在没有其他保护机制时才是个问题;将模糊作为避免攻击的第一步是强烈推荐的(请参阅本文进行讨论)。

这个想法被拒绝了,因为没有合适的理由让应用程序根据这些 API 是否在使用中来改变其行为。

为何不是沙盒

过去曾多次尝试沙盒化 CPython,每次尝试都失败了。根本问题在于,在执行沙盒代码时必须限制某些功能,但这些功能又需要可用于 Python 的正常操作。例如,完全移除将字符串编译为字节码的能力也会破坏从源代码导入模块的能力,如果不能完全移除,那么间接获取该功能的方式太多了。目前还没有任何可行的方法来普遍确定某个操作是否“安全”。更多信息和参考文献可在 [2] 中找到。

本提案不试图限制功能,而只是揭示了该功能正在被使用的事实。特别是对于入侵场景,检测比早期预防(因为早期预防通常会促使攻击者使用替代的、更不易被检测的方法)更为重要。仅凭审计钩子的可用性不会以任何方式改变 Python 的攻击面,但它们使防御者能够以目前无法实现的方式将 Python 集成到他们的环境中。

由于审计钩子能够安全地阻止操作发生,此功能确实能够提供一定程度的沙盒。然而,在大多数情况下,其目的是启用日志记录,而不是创建沙盒。

与 PEP 551 的关系

此 API 最初是作为 PEP 551 Python 运行时中的安全透明性的一部分提出的。

为了简化审查,并且由于这些 API 在安全之外的更广泛适用性,API 设计现在单独呈现。

PEP 551 是一份信息性 PEP,讨论了如何将 Python 集成到安全或审计环境中。

参考资料


来源: https://github.com/python/peps/blob/main/peps/pep-0578.rst

最后修改: 2024-06-03 14:51:21 GMT