摘要:知名安全机构 TrailofBits 近日开发了一种新的 Python 工具,用于检查 Python 包是否存在 CPython 应用程序二进制接口(ABI)违规,名叫 abi3audit。abi3audit  已经发现了数百个不一致和错误标记的包分发,每一个都是因未检测到 ABI 违规而导致崩溃和可利用内存损坏的潜在来源。它在开源许可证下公开可用,因此您可以立即使用它!

链接:https://blog.trailofbits.com/2022/11/15/python-wheels-abi-abi3audit/

声明:本文为 CSDN 翻译,未经授权,禁止转载。

作者 | Trail of Bits

编译 | 杨紫艳

出品 | CSDN(ID:CSDNnews)

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 或更低版本上加载和运行。

这是第一次出现这种情, 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

PyQt6 和 sip 都是 Qt 项目的一部分,并且都存在 ABI 版本冲突:多个轮子被标记为 CPython 3.6(cp36-abi3),但 API 仅在 CPython 3.7 中稳定。

此外,sip 还有一些完全违反 ABI 的轮子,全部来自内部 _Py_DECREF API:

refl1d

refl1d 是 NIST 的反射软件包。NIST 发布了几个标记为 Python 3.2 的稳定 ABI(绝对最低)的版本,而实际上目标是发布 Python 3.11 的稳定 ABI (绝对最高-甚至还没有发布!)

hdbcli

hdbcli 是 SAP HANA的专有客户端,由 SAP 自己发布。它被标记为 abi,这很酷!然而不幸的是,它实际上并不兼容 abi3:

这再次表明,构建时没有正确的宏。我们可以通过源代码了解更多信息,但这个包是完全专有的。

gdp 与 pifacecam

虽然这是两个较小的包,但很有趣的是,它们都存在稳定的 ABI 违规,这种违规不仅仅是引用/计数助手 API:

Dockerfile

最令人满意的是这个,因为它是用 Go 编写的 Python 扩展,而不是、C++ 或 Rust!

维护人员的思路是正确的,但没有将 Py_LIMITED_API 定义为任何特定值。Python 的头文件“非常有用”地解释了这一点:

唯一的希望是列表中大多数极受欢迎的软件包都没有=存在 ABI 违规或版本不匹配问题。例如,加密技术和 bcrypt 都没有出现,这表明在这一方面有强大的开发控制。其他相对流行的软件包也存在版本冲突,但它们一般都很小(例如:期望一个仅在 3.7 中稳定的函数,但从 3.3 开始就一直存在)。

然而,总的来说,这些结果并不好。这些结果表明:(1)PyPI 上的“abi3”轮子的很大一部分根本不兼容 abi3(或者与声称的不同版本兼容);(2)维护人员不完全理解控制 abi3 标记的不同旋钮(并且这些旋钮实际上不会修改开发本身)。

总之,实验结果表明这需要更好的控件、更好的文档以及 Python 不同打包组件之间更好的互操作。然而,尽管几乎所有软件包的维护人员都试图改进,但并没有找到实际构建 abi3 兼容的轮子所需的额外步骤。此外,除了改进这里的包端工具之外,审核也是自动化的。设计 abi3audit 的部分目的是为了证实 PyPI 可以在这些轮子错误成为公共索引的一部分之前捕获它们。