Python ABI兼容性:到底有多难? - Trail of Bits博客
TL;DR:Trail of Bits开发了abi3audit,这是一个用于检查Python包中CPython应用二进制接口(ABI)违规的新工具。我们使用它发现了数百个不一致和错误标记的包分发版本,每个版本都因未检测到的ABI差异而可能成为崩溃和可利用内存损坏的源头。该工具已在宽松的开源许可证下公开可用,您现在就可以使用它!
Python是最流行的编程语言之一,拥有相应的大型包生态系统:超过60万程序员使用PyPI分发超过40万个独特包,为全球大部分软件提供动力。
Python打包生态系统的历史也使其与众不同:在通用语言中,只有Perl的CPAN比它更早。这一点,加上打包工具和标准的大部分独立开发,使得Python的生态系统成为主要编程语言生态系统中较为复杂的之一。这些复杂性包括:
- 两种主要的当前打包格式(源码分发和wheel),以及一些特定领域和遗留格式(zipapps、Python Eggs、conda自有格式等);
- 一系列不同的打包工具和包规范文件:setuptools、flit、poetry和PDM,以及实际安装包的pip、pipx和pipenv;
- …以及相应的包和依赖规范文件:pyproject.toml(PEP 518风格)、pyproject.toml(Poetry风格)、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本身暴露的"高级"对象和API。
CPython扩展是Python生态系统的基石:它们为Python中的性能关键任务提供了"逃生舱口",并支持从本地语言(如更广泛的C、C++和Rust打包生态系统)进行代码重用。
同时,扩展也带来了一个问题:CPython的API在版本之间会变化(随着CPython实现细节的变化),这意味着默认情况下将CPython扩展加载到不同版本的解释器中是不安全的。这种不安全性的影响各不相同:用户可能幸运地没有任何问题,可能因缺少函数而经历崩溃,或者最糟糕的是,因函数签名和结构布局的变化而经历内存损坏。
为了改善这种情况,CPython的开发者创建了稳定API和ABI:一组宏、类型、函数和数据对象,保证在次要版本之间保持可用且向前兼容。换句话说:为CPython 3.7稳定API构建的CPython扩展也将在CPython 3.8及更高版本上加载和正常运行,但不能保证在CPython 3.6或更早版本上加载和运行。
在ABI级别,这种兼容性被称为"abi3",并可选地在扩展的文件名中标记:例如,mymod.abi3.so指定了一个名为mymod的可加载稳定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打包生态系统对这些问题的解决方案是wheel。Wheel是一种二进制分发格式,这意味着它们可以(但不是必须)提供预编译的二进制扩展和其他共享对象,这些对象可以按原样安装,无需自定义构建步骤。这是ABI兼容性绝对必要的地方:二进制wheel被CPython解释器盲目加载,因此实际和预期解释器ABI之间的任何不匹配都可能导致崩溃(或更糟,可利用的内存损坏)。
因为wheel可以包含预编译的扩展,它们需要标记支持的Python版本。这种标记使用PEP 425风格的"兼容性"标签完成:microx-1.4.1-cp37-cp37m-macosx_10_15_x86_64.whl指定了一个为macOS 10.15上x86-64的CPython 3.7构建的wheel,意味着其他Python版本、主机操作系统和架构不应尝试安装它。
就其本身而言,这个限制使得CPython扩展的wheel打包有点麻烦:
❌ 为了支持{Python版本、主机操作系统、主机架构}的所有有效组合,打包者必须为每个组合构建一个有效的wheel。这意味着额外的测试、构建和分发复杂性,以及随着包支持矩阵扩展而呈指数级增长的CI。 ❌ 因为wheel(默认情况下)绑定到单个Python版本,打包者需要在每个Python次要版本更改时生成一组新的wheel。换句话说:新的Python版本开始时无法访问打包生态系统的很大一部分,直到打包者能够赶上。
这就是稳定ABI变得关键的地方:打包者可以为最低支持的Python版本构建一个"abi3"wheel,而不是为每个Python版本构建一个wheel。这保证了wheel将在所有未来(次要)版本上工作,解决了上述构建矩阵大小和生态系统引导问题。
构建"abi3"wheel是一个两步过程:wheel在本地构建(通常使用与源码分发相同的构建系统),然后使用abi3作为ABI标签重新标记,而不是单个Python版本(如CPython 3.7的cp37)。
关键的是:这两个步骤都没有被验证,因为Python的构建工具没有好的方法来验证它们。这给我们留下了第二个和第三个问题:
要正确构建针对稳定API和ABI的wheel,构建需要将Py_LIMITED_API宏设置为目标CPython支持版本(或者对于使用PyO3的Rust,使用正确的构建功能)。这防止Python的C头文件使用非稳定功能或可能内联不兼容的实现细节。
例如,要将wheel构建为cp37-abi3(CPython 3.7+的稳定ABI),扩展需要在其自己的源代码中#define Py_LIMITED_API 0x03070000,或使用setuptools.Extension构造的define_macros参数进行配置。这些很容易忘记,并且在忘记时不会产生任何警告!
此外,当使用setuptools时,打包者可以选择设置py_limited_api=True。但这并不启用任何实际的API限制;它只是将.abi3标签添加到构建的扩展的文件名中。如您所记得,这目前不被CPython解释器检查,因此这实际上是一个无操作。
要标记wheel为稳定ABI,官方wheel模块和bdist_wheel子命令的用户应使用–py-limited-api=cp37标志,其中37是目标最低CPython版本(此处为3.7)。
此标志控制wheel的文件名组件,如此处所见:
关键的是,它不影响实际的wheel构建。wheel是根据底层setuptools.Extension认为合适的方式构建的:它可能完全正确,可能有点错误(稳定ABI,但针对错误的CPython版本),或者可能完全错误。
这种崩溃发生是因为Python打包的分权性质:构建扩展的代码在pypa/setuptools中,而构建wheel的代码在pypa/wheel中——两个完全独立的代码库。扩展构建被设计为一个黑盒,Rust和其他语言生态系统利用了这一事实(在基于PyO3的扩展中没有Py_LIMITED_API宏可以明智地定义——它全部由构建功能单独处理)。
总结一下:
- 稳定ABI(“abi3”)wheel是打包本地扩展而无需巨大构建矩阵的唯一可靠方式。
- 然而,控制abi3兼容wheel构建的所有旋钮都不相互通信:可能构建一个abi3兼容的wheel而不将其标记为这样,或者构建一个非abi3 wheel并错误地标记为兼容,或者将一个abi3兼容的wheel标记为兼容错误的CPython版本。
因此,当前abi3兼容wheel生态系统的正确性值得怀疑。ABI违规能够导致崩溃甚至可利用的内存损坏,因此我们需要量化当前状况。
情况到底有多糟?
这一切看起来相当糟糕,但这只是一个抽象问题:完全有可能每个Python打包者都正确构建了他们的wheel,并且没有发布任何错误标记(或完全无效)的abi3风格wheel。
为了了解情况到底有多糟,我们开发了abi3audit。Abi3audit的全部存在理由就是找到这类ABI违规错误:它扫描单个扩展、Python wheel(可以包含多个扩展)和整个包历史,报告任何与指定稳定ABI版本不匹配或完全与稳定ABI不兼容的内容。
为了获得可审计包的列表以输入abi3audit,我使用PyPI的公共BigQuery数据集生成了过去21天从PyPI下载的每个包含abi3 wheel的包列表:
|
|
(我选择21是因为在测试时用完了BigQuery配额。查看一年或整个PyPI历史的全量下载列表会很有趣,尽管我预计回报会递减。)
从该查询中,我得到了357个包,已上传为GitHub Gist。保存这些包后,abi3audit的JSON报告只需一次调用:
该审计的JSON也可作为GitHub Gist使用。
首先,一些高级统计数据:
- 从PyPI查询的357个初始包中,339个实际包含可审计的wheel。有些是404(可能创建后删除),而其他一些标记为abi3但实际不包含任何CPython扩展模块(从技术上讲,这使它们abi3兼容!)。其中一些是ctypes风格模块,带有供应商库或加载主机预期包含的库的代码。
- 剩下的339个包共有13650个abi3标记的wheel。最大的(就wheel而言)是eclipse-zenoh-nightly,有1596个wheel(占PyPI上所有abi3标记wheel的近12%)。
- 13650个abi3标记的wheel共有39544个共享对象,每个都是潜在的Python扩展。换句话说:平均每个abi3标记的wheel中有2.9个共享对象,每个都被abi3audit审计。
尝试解析每个abi3标记wheel中的每个共享对象产生了各种奇怪的结果:许多wheel包含无效的共享对象:以垃圾开头的ELF文件(但文件后面包含有效的ELF)、未清理的临时构建工件,以及一些似乎包含手动修改二进制文件的编辑器风格交换文件的wheel。不幸的是,与Moyix不同,我们没有发现任何猫娘。
现在,多汁的部分:
- 在357个有效包中,54个(15%)包含具有ABI版本违规的wheel。换句话说:大约六分之一的包有声称支持特定Python版本但实际上使用较新Python版本ABI的wheel。
- 更严重的是:在相同的357个有效包中,11个(3.1%)包含 outright ABI违规。换句话说:大约三十分之一的包有声称稳定ABI兼容但实际上根本不兼容的wheel!
- 总共有1139个(约3%)Python扩展有版本违规,90个(约0.02%)有 outright ABI违规。这表明两件事:相同的包往往在多个wheel和扩展中有ABI违规,并且同一wheel中的多个扩展往往同时有ABI违规(这是合理的,因为它们应该共享相同的构建)。
以下是我们发现特别有趣的一些:
PyQt6和sip
PyQt6和sip都是Qt项目的一部分,并且都有ABI版本违规:多个wheel标记为CPython 3.6(cp36-abi3),但使用了仅在CPython 3.7中稳定的API。
sip还有一些具有 outright ABI违规的wheel,全部来自内部_Py_DECREF API:
refl1d
refl1d是一个NIST开发的反射测量包。他们做了几个标记为Python 3.2稳定ABI(绝对最低)的发布,而实际目标却是Python 3.11的稳定ABI(绝对最高——甚至尚未发布!)。
hdbcli
hdbcli似乎是SAP HANA的专有客户端,由SAP自己发布。它标记为abi3,这很酷!不幸的是,它实际上并不abi3兼容:
这再次表明构建时没有使用正确的宏。如果有源代码,我们就能找出更多问题,但这个包似乎是完全专有的。
gdp和pifacecam
这是两个较小的包,但它们引起了我的兴趣,因为两者都有稳定ABI违规,而不仅仅是引用/计数辅助API:
dockerfile
最后,我喜欢这个因为它结果是一个用Go编写的Python扩展,而不是C、C++或Rust!
维护者的想法是正确的,但没有将Py_LIMITED_API定义为任何特定值。因此Python的头文件"有帮助地"将其解释为完全不限制:
前进之路
首先,一线希望:列表中大多数非常流行的包没有ABI违规或版本不匹配。例如,cryptography和bcrypt幸免,表明它们有强大的构建控制。其他相对流行的包有版本违规,但通常是次要的(例如:期望一个仅在3.7中稳定但自3.3以来一直存在且相同的函数)。
然而,总体而言,这些结果并不理想:它们表明(1)PyPI上"abi3"wheel的很大部分实际上并不真正abi3兼容(或与它们声称的版本兼容),以及(2)维护者不完全理解控制abi3标记的不同旋钮(并且这些旋钮实际上不修改构建本身)。
更一般地说,结果指出需要更好的控制、更好的文档以及Python不同打包组件之间更好的互操作。在几乎所有情况下,包的维护者都试图做正确的事情,但似乎不知道实际构建abi3兼容wheel所需的额外步骤。除了改进此处的包端工具外,审计也是可自动化的:我们设计abi3audit的部分目的是证明PyPI有可能在wheel成为公共索引的一部分之前捕获这类wheel错误。