Python ABI兼容性:挑战与解决方案

本文探讨Python中CPython稳定ABI的兼容性问题,介绍Trail of Bits开发的abi3audit工具,发现数百个错误标记的包可能导致崩溃和内存损坏,并提供解决方案。

Python中的ABI兼容性:究竟有多难? - Trail of Bits博客

TL;DR:Trail of Bits开发了abi3audit,这是一个新的Python工具,用于检查Python包是否存在CPython应用程序二进制接口(ABI)违规。我们用它发现了数百个不一致和错误标记的包分发,每个都可能由于未检测到的ABI差异而导致崩溃和可利用的内存损坏。该工具在宽松的开源许可证下公开可用,您现在就可以使用它!

Python是最流行的编程语言之一,拥有相应的大型包生态系统:超过60万程序员使用PyPI分发超过40万个独特包,为全球大部分软件提供动力。

Python打包生态系统的年龄也使其与众不同:在通用语言中,它仅比Perl的CPAN晚。这一点,加上打包工具和标准的独立开发,使Python的生态系统成为主要编程语言生态系统中较为复杂的之一。这些复杂性包括:

  • 两种主要的当前打包格式(源分发和轮子),以及一些领域特定和遗留格式(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指定了一个可加载的稳定ABI兼容的CPython扩展模块名为mymod。关键的是,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解释器盲目加载,因此实际和预期解释器ABI之间的任何不匹配都可能导致崩溃(或更糟,可利用的内存损坏)。

因为轮子可以包含预编译的扩展,它们需要标记它们支持的Python版本。这种标记使用PEP 425风格的“兼容性”标签完成:microx-1.4.1-cp37-cp37m-macosx_10_15_x86_64.whl指定了一个为macOS 10.15上的CPython 3.7构建的轮子,意味着其他Python版本、主机操作系统和架构不应尝试安装它。

单独来看,这个限制使CPython扩展的轮子打包有点麻烦:

❌ 为了支持{Python版本、主机操作系统、主机架构}的所有有效组合,打包者必须为每个构建一个有效的轮子。这意味着额外的测试、构建和分发复杂性,以及随着包支持矩阵扩展而呈指数增长的CI增长。 ❌ 因为轮子(默认情况下)绑定到单个Python版本,打包者需要在每个Python次要版本更改时生成一组新的轮子。换句话说:新的Python版本开始时无法访问打包生态系统的很大一部分,直到打包者能够赶上。

这就是稳定ABI变得关键的地方:而不是为每个Python版本构建一个轮子,打包者可以为最低支持的Python版本构建一个“abi3”轮子。这保证了轮子将在所有未来(次要)版本上工作,解决了上述构建矩阵大小和生态系统引导问题。

构建“abi3”轮子是一个两步过程:轮子在本地构建(通常使用与源分发相同的构建系统),然后重新标记为abi3作为ABI标签,而不是单个Python版本(如CPython 3.7的cp37)。

关键的是:这些步骤都没有被验证,因为Python的构建工具没有好的方法来验证它们。这给我们留下了第二个和第三个问题:

要正确构建针对稳定API和ABI的轮子,构建需要将Py_LIMITED_API宏设置为预期的CPython支持版本(或者,对于使用PyO3的Rust,使用正确的构建功能)。这防止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)。

这个标志控制轮子的文件名组件,如下所示:

关键的是,它不影响实际的轮子构建。轮子是根据底层setuptools.Extension认为合适的方式构建的:它可能完全正确,可能有点错误(稳定ABI,但针对错误的CPython版本),或者可能完全错误。

这种 breakdown 发生是因为Python打包的分权性质:构建扩展的代码在pypa/setuptools中,而构建轮子的代码在pypa/wheel中——两个完全独立的代码库。扩展构建被设计为一个黑盒,Rust和其他语言生态系统利用了这一事实(在基于PyO3的扩展中没有Py_LIMITED_API宏可以明智地定义——它都由构建功能单独处理)。

总结一下:

  • 稳定ABI(“abi3”)轮子是打包本地扩展而无需巨大构建矩阵的唯一可靠方式。
  • 然而,控制abi3兼容轮子构建的所有拨号盘都不相互通信:可能构建一个abi3兼容的轮子而不标记为这样,或者构建一个非abi3轮子并错误地标记为兼容,或者将一个abi3兼容的轮子标记为兼容错误的CPython版本。
  • 因此,当前abi3兼容轮子生态系统的正确性是可疑的。ABI违规能够导致崩溃甚至可利用的内存损坏,因此我们需要量化当前状态。

到底有多糟糕?

这一切似乎很糟糕,但它只是一个抽象问题:完全有可能每个Python打包者都正确构建了他们的轮子,并且没有发布任何错误标记(或完全无效)的abi3风格轮子。

为了了解事情到底有多糟糕,我们开发了abi3audit。Abi3audit的全部存在理由就是找到这些类型的ABI违规错误:它扫描单个扩展、Python轮子(可以包含多个扩展)和整个包历史,报告任何与指定稳定ABI版本不匹配或完全与稳定ABI不兼容的内容。

为了获得可审计包的列表输入到abi3audit,我使用PyPI的公共BigQuery数据集生成了一个列表,包含过去21天从PyPI下载的每个包含abi3轮子的包:

1
2
3
4
5
6
7
8
#standardSQL
SELECT DISTINCT file.project
FROM `bigquery-public-data.pypi.file_downloads`
WHERE file.filename LIKE '%abi3%'
  -- Only query the last 21 days of history
  AND DATE(timestamp)
    BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 21 DAY)
    AND CURRENT_DATE()

(我选择21是因为我在测试时用完了BigQuery配额。看到一年或整个PyPI历史的完整下载列表会很有趣,尽管我预计回报会递减。)

从该查询中,我得到了357个包,我已将其上传为GitHub Gist。有了这些包保存,abi3audit的JSON报告只需一次调用:

该审计的JSON也可用作GitHub Gist。

首先,一些高级统计:

  • 从PyPI查询的357个初始包中,339个实际包含可审计的轮子。一些是404(可能创建后删除),而其他一些标记为abi3但实际不包含任何CPython扩展模块(从技术上讲,这使它们abi3兼容!)。其中一些是ctypes风格的模块,带有供应商库或加载主机预期包含的库的代码。
  • 剩下的339个包总共有13650个abi3标记的轮子。最大的(就轮子而言)是eclipse-zenoh-nightly,有1596个轮子(或 nearly 12 percent of all abi3-tagged wheels on PyPI)。
  • 13650个abi3标记的轮子总共有39544个共享对象,每个都是一个潜在的Python扩展。换句话说:平均每个abi3标记的轮子有2.9个共享对象,每个都被abi3audit审计。

尝试解析每个abi3标记轮子中的每个共享对象产生了各种 curious 结果:许多轮子包含无效的共享对象:以垃圾开始的ELF文件(但文件后面包含有效的ELF)、未清理的临时构建工件,以及一些似乎包含用于手动修改二进制文件的编辑器风格交换文件的轮子。不幸的是,与Moyix不同,我们没有发现任何猫娘。

现在,多汁的部分:

  • 在357个有效包中,54个(15 percent)包含具有ABI版本违规的轮子。换句话说:大约六分之一的包有轮子声称支持特定Python版本,但实际上使用了较新Python版本的ABI。
  • 更严重的是:在相同的357个有效包中,11个(3.1 percent)包含 outright ABI违规。换句话说:大约三十分之一的包有轮子声称是稳定ABI兼容,但根本不是!
  • 总共,1139个(大约3 percent)Python扩展有版本违规,90个(大约0.02 percent)有 outright ABI违规。这表明两件事:相同的包往往在多个轮子和扩展上有ABI违规,并且同一轮子内的多个扩展往往同时有ABI违规(这有道理,因为它们应该共享相同的构建)。

以下是一些我们觉得特别有趣的:

PyQt6和sip

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

sip还有一些轮子有 outright ABI违规,全部来自内部_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”轮子的 significant portion 实际上不是abi3兼容(或与它们声称的版本兼容),(2)维护者不完全理解控制abi3标记的不同旋钮(并且那些旋钮实际上不修改构建本身)。

更一般地,结果指出需要更好的控制、更好的文档和Python不同打包组件之间更好的互操作。在几乎所有情况下,包的维护者都试图做正确的事情,但似乎不知道实际构建abi3兼容轮子所需的额外步骤。除了改进包端工具外,审计也是可自动化的:我们设计abi3audit部分是为了证明PyPI有可能在轮子成为公共索引的一部分之前捕获这些类型的轮子错误。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容 CPython稳定API和ABI CPython扩展和打包 到底有多糟糕? 前进之路 最近的帖子 Trail of Bits的Buttercup在AIxCC挑战赛中获得第二名 Buttercup现在是开源的! AIxCC决赛:磁带故事 攻击者的提示注入工程:利用GitHub Copilot 在NVIDIA Triton中 uncovering 内存损坏(作为新员工) © 2025 Trail of Bits. 使用Hugo和Mainroad主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计