摘要:知名安全机构 TrailofBits 近日开发了一种新的 Python 工具,用于检查 Python 包是否存在 CPython 应用程序二进制接口(ABI)违规,名叫 abi3audit。abi3audit 已经发现了数百个不一致和错误标记的包分发,每一个都是因未检测到 ABI 违规而导致崩溃和可利用内存损坏的潜在来源。它在开源许可证下公开可用,因此您可以立即使用它!
链接:https://blog.trailofbi
ts.com/2022/11/15/python-wheels-abi-abi3audit/
声明:本文为 CSDN 翻译,未经授权,禁止转载。
Python 是最受欢迎的编程语言之一,具有相应的大型程序包生态系统:超过 600,000 名程序员使用 PyPI 分发超 400,000 个独特的包,为世界上的许多软件提供动力。Python 打包生态系统的时代也使它与众不同:在通用语言中,只有 Perl CPAN 模块比它早。与打包工具和大部分标准的独立开发相结合,使 Python 的生态系统成为主要编程语言生态系统中较为复杂的一个。这些复杂性包括:• 当前两种主要的打包格式(源分布和轮子),以及少量的特定领域和遗留格式( zipapps , Python Eggs , conda 自带格式等。);• 一组不同的封装工具和封装规范文件:setuptools、flit、poetry 和 PDM,以及用于实际安装封装的 pip、pipx 和 Pipenv;• …以及相应的封装和依赖规范文件:pyproject.toml ( PEP 518-style )、pyproject.toml ( Poes-style )、setup.py、setup.cfg、Pipfile、requirements.txt、MANIFEST.in 等。本文只介绍 Python 封装复杂性的一小部分:CPython 稳定的 ABI。将展示什么是稳定的 ABI,为什么存在,如何集成到 Python 封装中的,以及为使 ABI 违规更易意外出现,每一个部分是如何严重报错。
CPython 稳定的 API 和 ABI
与许多其他参考实现不同,Python 的参考实现(CPython)是用 C 编写的,并提供了两种本机交互机制:• C 应用程序编程接口(API),允许 C 和 C++ 程序员利用CPython的公共标头进行编译,并使用任何公开的功能;• 应用程序二进制接口(ABI),允许任何 C ABI 语言支持(如 Rust 或 Golang )链接到 CPython 运行,并使用相同的内部构件。开发人员可以使用 CPython API 和 ABI 来编写 CPython 扩展。这些扩展与普通 Python 模块完全相同,但与解释器的实现细节直接交互,而不是 Python 本身中公开的“高级”对象和 APIs。CPython 扩展是 Python 生态系统的基石:它们为 Python 中的关键性能任务提供了一个“逃生通道”,并支持本地语言(如更广泛的 C、C++和 Rust 打包生态系统)的代码重用。然而,扩展带来了一个问题:CPython APIs 在不同版本之间发生了变化(随着 CPython 实现细节的变化),这意味着默认情况下,将 CPython 扩展加载到不同版本的解释器中是无法预测的。换句话说:用户可能会很幸运,完全没有问题,但是可能会因为缺少函数而崩溃,甚至最糟糕的是,可能由于函数签名和结构布局的变化而导致内存损坏。为了改善这种情况,CPython 的开发人员创建了 stable API 和 ABI:一组宏、类型、函数和数据对象,它们可保证在小版本之间保持可用和向前兼容。换句话说:为 CPython 3.7 stable API 构建的 CPython 扩展也可在 CPython 3.8 和更高版本上正确加载和运行,但不能保证在 CPython3.6 或更低版本上加载和运行。在 ABI 级别,这种兼容性被称为“abi3”,并且可以在扩展的文件名 mymod.abi3.so 中进行标记,例如,指定一个名为 mymod 的可加载的 stable ABI 兼容 CPython 扩展模块。重要的是,Python解释器不使用此标记执行任何操作。这是第一次出现这种情, CPython 不知道扩展是否与 ABI 兼容。接下来,将展示这种状况与 Python 打包状态的组合产生的问题。
CPython 扩展和打包
CPython 扩展本身只是一个简单的 Python 模块。为了对其他模块有用,它需要像所有其他模块一样打包和分发。
对于源发行版,打包 CPython 扩展很简单(对于一些简单的定义):源发行版的构建系统(通常是 setup.py)描述了生成本机扩展所需的编译步骤,并且包安装程序会在安装期间运行这些步骤。
例如,以下是如何使用 setuptools 定义 microx 的本机扩展(microx_core):
通过源代码分发 CPython 扩展具有优点(✅) 和缺点(❌):✅ API 和 ABI 的稳定性没有问题:程序包可以在安装过程中构建,也可以不构建,并且在构建时,它运行的解释器与构建时使用的解释器相同。✅ 源代码构建对用户来说是一种负担:它们需要 Python 软件的最终用户安装 CPython 开发标头文件,并维护与扩展目标语言或生态系统相对应的本地工具链。这意味着在每台部署机器上都需要一个 C/C++(以及越来越多的 Rust)工具链,从而增加了规模和复杂性。❌ 源代码构建从根本上来说是脆弱的:编译器和本机依赖关系不断变化,最终用户(充其量是 Python 专家,而不是编译语言专家)只能调试编译器和链接器错误。Python 打包生态系统解决这些问题的方法是轮子。轮子是一种二进制分发格式,这意味着它们可以(但不需要)提供预编译的二进制扩展和其他共享对象,这些对象可以按原样安装,而无需自定义构建步骤。这就是 ABI 兼容性绝对重要的地方:CPython 解释器盲目加载二进制轮,因此实际和预期的解释器 ABIs 之间的任何不匹配都可能导致崩溃(甚至更糟的是,可利用的内存损坏)。因为轮子可以包含预编译的扩展,所以需要为支持的 Python 版本标记轮子。此标记使用 PEP 425-style “兼容性”标记:microx-1.4.1-cp37-cp37m-macosx_10_15_x86_64.whl 指定了为 macO10.15 x86-64 系统的 CPython 3.7 构建的轮子,这意味着其他 Python 版本、主机 OS 和体系结构不应尝试安装它。就其本身而言,这种限制使 CPython 扩展的轮子包装有点麻烦:❌ 为了支持{Python 版本、主机操作系统、主机体系结构}所有有效组合,打包者必须为每个组合构建一个有效的轮子。这导致了额外的测试、构建和分发复杂性,以及随着软件包支持矩阵的扩展而呈指数级的 CI 增长。❌ 因为轮子(默认情况下)会绑定到一个 Python 版本,所以打包人员需要在每个 Python 次要版本更改时生成一组新的轮子。换言之:新的 Python 版本一开始只能访问打包生态系统的一小部分,直到打包者及时更新。这就是稳定的 ABI 至关重要的原因:版本打包者可以为最低支持的 Python 版本构建一个“abi3”轮子,而不是为每个 Python 构建一个轮子。这就保证了轮子将在所有未来(次要)版本上运行,解决了构建矩阵大小问题和上述生态系统自扩展问题。构建“abi3”轮子有两步:轮子在本地构建(通常使用与源发行版相同的构建系统),然后使用 abi3 重新标记为 ABI 标记,而不是单个 Python 版本(如 CPython 3.7 的 cp37)。关键的是,这两个步骤都没有得到验证,因为 Python 的构建工具没有很好的方法来验证它们。这出现了更大的难题:为了依靠稳定的API和ABI正确构建轮子,构建时需要将 Py_LIMITED_API 宏设置为预期的 CPython 支持版本(或者,对于 Rust with PyO3,使用正确的构建功能)。这可以防止 Python 的 C 标头使用不稳定的功能或潜在地内联不兼容的实现细节。例如,要将轮子构建为 cp37-abi3(CPython 3.7+的稳定 ABI),扩展需要在其自己的源代码中#define Py_LIMITED_API 0x03070000,或者使用 setuptools.Extension 构造的 define_macros 参数来配置它。这些很容易遗忘,然而遗忘时不会产生任何警告!此外,在使用 setuptools 时,打包者可以选择设置py_limited_api=True。但这并没有实现任何实际的 API 限制;它只是将.abi3 标记添加到构建的扩展名的文件中。CPython 解释器当前没有检查这一点,因此这实际上是一个禁忌。要为稳定的 ABI 标记轮子,官方轮子模块和 bdist_wheel 子命令的用户需要使用--py-limited api=cp37 标志,其中 37 是最低 CPython 目标版本(此处为 3.7)。关键的是,它不会影响实际的轮子构造。轮子是由底层设置工具构建的。扩展看起来很适宜:它可能完全正确,可能有点错误(稳定的 ABI,但适用于错误的 CPython 版本),也可能完全错误。这种崩溃是因为 Python 打包的下放性质:构建扩展的代码在 pypa/setuptools 中,而构建轮子的代码在 pypa/swheel 中——两个完全独立的代码库。扩展构建被设计为一个黑盒子,Rust 和其他语言生态系统利用了这一事实(在基于 PyO3 的扩展中,没有 Py_LIMITED_API 宏可以明智地定义——所有这些都由构建特性单独处理)。• 稳定的 ABI(“abi3”)轮子是打包本地扩展的唯一可靠方式,无需大量构建矩阵。• 然而,所有控制 abi3 兼容车轮构建的控制盘都无法相互关联:可能是构建一个 abi3 兼容的车轮,却没有如此标记。或者构建一个非 bi3 车轮并将其错误地标记为兼容;或者错误地将 abi3 兼容轮标记为与不合适的 CPython 版本兼容。• 因此,当前 abi3 兼容轮子生态系统的正确性值得怀疑。ABI 违规可能会导致崩溃,甚至是可利用的内存损坏,因此我们需要量化当前的状态。
实际有多糟糕?
这一切看起来都很糟糕,但这只是一个抽象的问题:也有可能每个 Python 打包者都正确地构建了轮子,并且没有发布任何错误标记(或完全无效)的 abi3 样式轮子。为了了解事情到底有多糟糕,开发了一个审计系统。Abi3audit 的存在的理由是发现这些类型的 ABI 违规错误:它扫描单个扩展、Python 轮子(可以包含多个扩展)和整个包历史记录,报告任何与所指定的稳定 ABI 版本不匹配或与稳定 ABI 完全不兼容的内容。为了获得一个可审计包的列表,将其输入到 abi3audit 中,使用 PyPI 的公共 BigQuery 数据集生成了过去 21 天内从 PyPI 下载的每个包含 abi3 轮子的包的列表:(此处选择了 21,因为在测试时突破了 BigQuery 配额。尽管预计回报会逐渐减少,看到一年内的完整下载列表或 PyPI 的整个历史会很有趣。)从这个查询中,得到了 357 个包,这些包是作为 GitHub Gist 上传的。保存了这些包后,只需一次调用即可获得来自 abi3audit 的 JSON 报告:该审计的 JSON 也可以作为 GitHub Gist 提供。• 在从 PyPI 查询的 357 个初始包中,有339 个实际审计的轮子。有些是 404s(可能是一开始创建然后删除的),而另一些是用 abi3 标记的,但实际上不包含任何 CPython 扩展模块(从技术上讲,这确实使它们与 abi3 兼容!)。其中有几个是 ctypes 风格的模块,要么有一个供应商提供的库,要么有加载主机预期所包含库的代码。• 剩下的 339 个包裹之间总共有 13650 个贴有标签的轮子。最大的(以轮子计)是 eclipse-zenoh-nightly,有 1596 个轮子(占 PyPI 上所有 abi3 标记轮子的近 12%)。• 13650 个 abi3 标记的轮子总共有 39544 个共享对象,每个共享对象之间都有一个潜在的 Python 扩展。换句话说:平均每个 abi3 标记的轮子中有 2.9 个共享对象,每个对象都由 abi3audit 审核。• 如果试图解析每个 abi3 标记的轮子中的每个共享对象会发现各种奇怪的结果:许多轮子包含无效的共享对象:以废话开头的ELF文件(但在文件后面包含一个有效的ELF)、未清理的临时构建工件,以及少数轮子似乎包含手动修改二进制文件的编辑器样式交换文件。不幸的是,与 Moyix 不同,我们没有发现任何猫耳。在 357 个有效包中,有54 个(15%)包含违反 ABI 版本的轮子。换句话说:大约六分之一的包中有轮子声称支持特定的 Python 版本,但实际上使用的是较新 Python 版本的 ABI。更严重的是:在 357 个有效的程序包中,11 个(3.1%)包含了完全违反 ABI 的行为。换言之:大约三十分之一的包中有轮子声称与 ABI 兼容,但根本不兼容!总共有 1139 个(约 3%)Python 扩展存在版本冲突,90 个(约 0.02%)存在完全的 ABI 冲突。这说明了两件事:同一个包在多个轮子和扩展中往往会违反 ABI,而同一轮子中的多个扩展往往会同时违反 ABI。PyQt6 和 sip 都是 Qt 项目的一部分,并且都存在 ABI 版本冲突:多个轮子被标记为 CPython 3.6(cp36-abi3),但 API 仅在 CPython 3.7 中稳定。
此外,sip 还有一些完全违反 ABI 的轮子,全部来自内部 _Py_DECREF API:refl1d 是 NIST 的反射软件包。NIST 发布了几个标记为 Python 3.2 的稳定 ABI(绝对最低)的版本,而实际上目标是发布 Python 3.11 的稳定 ABI (绝对最高-甚至还没有发布!)hdbcli 是 SAP HANA的专有客户端,由 SAP 自己发布。它被标记为 abi,这很酷!然而不幸的是,它实际上并不兼容 abi3:虽然这是两个较小的包,但很有趣的是,它们都存在稳定的 ABI 违规,这种违规不仅仅是引用/计数助手 API:最令人满意的是这个,因为它是用 Go 编写的 Python 扩展,而不是、C++ 或 Rust!维护人员的思路是正确的,但没有将 Py_LIMITED_API 定义为任何特定值。Python 的头文件“非常有用”地解释了这一点:
前进之路
唯一的希望是列表中大多数极受欢迎的软件包都没有=存在 ABI 违规或版本不匹配问题。例如,加密技术和 bcrypt 都没有出现,这表明在这一方面有强大的开发控制。其他相对流行的软件包也存在版本冲突,但它们一般都很小(例如:期望一个仅在 3.7 中稳定的函数,但从 3.3 开始就一直存在)。然而,总的来说,这些结果并不好。这些结果表明:(1)PyPI 上的“abi3”轮子的很大一部分根本不兼容 abi3(或者与声称的不同版本兼容);(2)维护人员不完全理解控制 abi3 标记的不同旋钮(并且这些旋钮实际上不会修改开发本身)。总之,实验结果表明这需要更好的控件、更好的文档以及 Python 不同打包组件之间更好的互操作。然而,尽管几乎所有软件包的维护人员都试图改进,但并没有找到实际构建 abi3 兼容的轮子所需的额外步骤。此外,除了改进这里的包端工具之外,审核也是自动化的。设计 abi3audit 的部分目的是为了证实 PyPI 可以在这些轮子错误成为公共索引的一部分之前捕获它们。