Following system colour scheme Selected dark colour scheme Selected light colour scheme

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.py 文件尝试导入标准库 random 模块。

这不是沙盒,因为此提案不尝试阻止恶意行为(尽管它启用了某些新的选项来做到这一点)。请参阅下面的 为什么不是沙箱 部分以获取更多讨论。

更改概述

这些更改的目的是使应用程序开发人员和系统管理员能够将 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 点分名称,并在名称中避免编码特定细节。例如,带有模块名称 spam 作为参数的 import 事件优于没有参数的 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 钩子的情况。
compileexecevalPyAst_CompileStringPyAST_obj2mod compile (code, filename_or_none) 检测动态代码编译,其中 code 可以是字符串或 AST。请注意,这将针对源代码的常规导入调用,包括使用 open_code 打开的那些导入。
execevalrun_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_GenericSetAttrcheck_set_special_type_attrobject_set_classfunc_set_codefunc_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 上,access 可能已从 protflags 参数计算得出。
sys._getframe sys._getframe (frame_object,) 检测代码何时直接访问帧。
sys._current_frames sys._current_frames 检测代码何时直接访问帧。
socket.bindsocket.connectsocket.connect_exsocket.getaddrinfosocket.getnameinfosocket.sendmsgsocket.sendto socket.address (socket, address,) 检测对网络资源的访问。地址未从原始调用中修改。
member_getfunc_get_codefunc_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 是否在“安全”或“已审计”模式下运行。这将允许应用程序检测某些功能何时启用或何时添加了钩子,并相应地修改其行为。

目前,我们不知道程序在存在审计钩子的情况下以不同方式运行的任何合法理由。

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

认为这是“安全通过模糊”的说法是有效的,但无关紧要。安全通过模糊只有在没有其他保护机制时才成为问题;将模糊作为避免攻击的第一步是强烈推荐的(有关讨论,请参阅 这篇文章)。

此想法被拒绝,因为应用程序没有合适的理由根据这些 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