Python中的ABI兼容性:到底有多难?- Trail of Bits博客
作者:William Woodruff
2022年11月15日
审计
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上的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头文件使用非稳定功能或可能内联不兼容的实现细节。
例如,要构建一个cp37-abi3的wheel(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版本),或者可能完全错误。
这种 breakdown 发生是因为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自己发布的SAP HANA的专有客户端。它被标记为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错误成为公共索引的一部分之前捕获它们。
如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News
页面内容 CPython稳定API和ABI CPython扩展和打包 情况到底有多糟? 前进之路 最近文章 我们构建了MCP一直需要的安全层 利用废弃硬件中的零日漏洞 Inside EthCC[8]:成为智能合约审计员 使用Vendetect大规模检测代码复制 构建安全消息传递很难:对Bitchat安全辩论的 nuanced take © 2025 Trail of Bits. 使用Hugo和Mainroad主题生成。