Python二進位制接口(ABI)的兼容性有多難?

摘要:知名安全機構 TrailofBits 近日開發了一種新的 Python 工具,用於檢查 Python 包是否存在 CPython 應用程序二進位制接口(ABI)違規,名叫 abi3audit。abi3audit 已經發現了數百個不一致和錯誤標記的包分發,每一個都是因未檢測到 ABI 違規而導致崩潰和可利用記憶體損壞的潛在來源。它在開源許可證下公開可用,因此您可以立即使用它!

連結:https://blog.trailofbits.com/2022/11/15/python-wheels-abi-abi3audit/

作者 | Trail of Bits

編譯 | 楊紫豔

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 擴展和打包

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

refl1d 是 NIST 的反射軟體包。NIST 發佈了幾個標記為 Python 3.2 的穩定 ABI(絕對最低)的版本,而實際上目標是發佈 Python 3.11 的穩定 ABI (絕對最高-甚至還沒有發佈!)

hdbcli

hdbcli

hdbcli 是 SAP HANA的專有客戶端,由 SAP 自己發佈。它被標記為 abi,這很酷!然而不幸的是,它實際上並不兼容 abi3:

這再次表明,構建時沒有正確的宏。我們可以通過源程式碼了解更多資訊,但這個包是完全專有的。

gdp 與 pifacecam

雖然這是兩個較小的包,但很有趣的是,它們都存在穩定的 ABI 違規,這種違規不僅僅是引用/計數助手 API:

Dockerfile

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 可以在這些輪子錯誤成為公共索引的一部分之前捕獲它們。

相關文章

Python 將移除 GIL!

Python 將移除 GIL!

支持真正並行的 Python 終於要來了。近日,外媒 infoworld 全面剖析了無 GIL 的 Python 是怎麼實現的。 原文地址:...

Python 3.13 或將引入 JIT!

Python 3.13 或將引入 JIT!

Python 或將迎來新功能 JIT。與完整的 JIT 不同,這裡引入的 Copy-and-Patch JIT 技術的優點是開發者無需手寫彙...

Python 真的很糟糕嗎?

Python 真的很糟糕嗎?

隨著 AI 的發展,憑藉易學易用的語法、豐富的庫和框架,Python 在機器學習、深度學習、自然語言處理和資料科學等領域有著廣泛的應用。然而...

Python 與 JavaScript 做比較公平嗎?

Python 與 JavaScript 做比較公平嗎?

在討論應該使用 Python 還是 JavaScript 構建項目時,一般我們都不會說只使用一種程式語言來構建所有的元件。 在現代軟體開發中...

Python 雖已登峰,但尚未造極!

Python 雖已登峰,但尚未造極!

本文來自 CSDN 策劃的《2022 年技術年度盤點》欄目。本欄目將圍繞程式語言、開源、雲端運算、人工智慧、架構服務、資料庫、晶片、開發工具...

如何減輕 Python 打包之痛

如何減輕 Python 打包之痛

本文主要介紹 Python 包管理的問題和解決方法,以及在安裝和運行 Python 時應遵循的策略和步驟。 原文連結:https://www...