*sshole

當 OpenSSH 的防計時攻擊遇上 musl 的 256 位元組限制:一場跨越 46 年的認證繞過
featured.svg

今天在 Hacker News 上看到一個標題只有一個字(還帶著一顆星號)的項目:*sshole。點進去閱讀 sshhole.com 與作者公開的技術細節,這場跨越數十年的軟體架構演進與邊界條件重疊,相當值得深入探討。

這項漏洞的運作機制十分特殊:在基於 musl libc 且未啟用 PAM 的 Linux 系統(例如 Alpine Linux、OpenWrt、以及 Buildroot 與 Yocto 建置環境)上,當系統開啟密碼認證且 pick_salt() 選中 SHA-crypt salt 時,若目標帳號在 /etc/shadow 中的密碼欄位被設定為 *(原本用意是停用密碼認證),攻擊者輸入長度介於 257 至 1024 位元組的密碼,便可能成功通過密碼雜湊比對。

在實際影響層面上,能否完成登入仍取決於 OpenSSH 其他存取控制與認證要求:

  • 對於 root 帳號,OpenSSH 預設的 PermitRootLogin prohibit-password 會阻斷密碼認證,因此只有在管理者明確設定 PermitRootLogin yes 時才會直接暴露 root 登入。
  • 但對於一般系統帳號(例如僅允許 SSH 金鑰登入的部署帳號、LDAP 整合帳號,或是 Alpine 雲端映像預設的 alpine 帳號),只要密碼欄位為 * 且無其他認證方法限制(如 AuthenticationMethods),便能通過密碼驗證登入。而在許多雲端映像中,預設使用者通常具備免密碼 doas 提權權限,攻擊者登入後即可取得完整系統控制權。

更具工程啟發性的是背後的成因:這不是緩衝區溢位或記憶體損壞,而是多個看似合理、各自出於防禦考量的設計,在歷經 46 年的軟體演進後,於特定條件下產生了預期之外的行為碰撞。

六行虛擬碼說明比對邏輯

作者在專案的說明文件中以六行虛擬碼總結了認證比對的流程:

pw_password = "*"                          # 目標帳號在 shadow 裡為 "*",代表不接受密碼認證
salt = NULL                                # "*" 長度過短,無法作為合法的 salt
salt = pick_salt()                         # OpenSSH 為防計時攻擊,借用有效帳號 bob 的 "$6$abc$"
hash = crypt("AAAA…x300", "$6$abc$")       # musl SHA-crypt 遇長度 > 256 位元組失敗,回傳 "*"
strcmp(hash, pw_password) == 0             # strcmp("*", "*") == 0 -> 比對吻合!
return 1                                   # 通過認證

當比對邏輯走到 strcmp("*", "*") == 0 時,認證便宣告通過。

原本在 Unix 傳統中用來標記「不接受密碼認證」的特殊字元 *(此處為字面值字串,並非萬用字元 wildcard),恰好與 musl libc 在密碼過長時回傳的錯誤替代值 * 完全一致。

以下是該漏洞觸發流程的結構圖:

collision-flow.svg

跨越 46 年的歷史交會

要理解這個現象為何會發生,需要回顧幾個關鍵的歷史節點。

1980 年:第一顆星號(The First Star)

在早期 Unix 時代,使用者密碼的雜湊值直接儲存在所有使用者皆可讀取的 /etc/passwd 中。

1980 年,Bill Joy 在 3BSD 的 /etc/passwd 裡,為系統內建的 daemon 帳號填入了 * 作為密碼欄位。

由於當時 Unix 使用的 DES 雜湊格式固定產出長度為 13 的特定字元集字串,單一字元的 * 絕不可能對應到任何合法的密碼輸入。自此,「在密碼欄位填入 * 代表此帳號不接受密碼登入」成為 Unix 與 Linux 系統廣泛採納的慣例。

1990 年代:OpenSSH 的字串比對與 POSIX 規範

到了 1990 年代,密碼雜湊被移至僅限管理者讀取的 /etc/shadow。當使用者透過 SSH 嘗試密碼登入時,OpenSSH 會呼叫系統提供的 crypt(3) 函式:

encrypted_password = crypt(password, shadow_hash);
if (strcmp(encrypted_password, shadow_hash) == 0) {
    // 登入成功
}

若目標帳號的 shadow_hash 是 *,正常計算出來的雜湊字串(例如現代常用的 SHA-512 雜湊 $6$...)絕不可能等於單一字元 *,比對自然失敗。

但 POSIX 標準(The Single UNIX Specification)對 crypt() 規範了錯誤處理:若 crypt() 無法執行(例如傳入的 salt 指定了不支援的演算法),必須回傳空指標(NULL)。

歷史上部分應用程式在呼叫 crypt() 後未檢查是否為 NULL,便直接將指標傳入 strcmp(),導致空指標解參考引發程式異常終止(segfault)。

2012 年:musl 決定讓 crypt 不再回傳 NULL

輕量化 C 函式庫 musl 的維護者在 2012 年 8 月 9 日提交了一筆修補(commit b3c4cc12)。

為避免呼叫端因未檢查 NULL 而崩潰,musl 決定在 crypt() 發生錯誤時回傳一個特殊字串,而非 NULL。在當時的 DES 實作中,選用的特殊字串為 "*"。

 Note

musl 開發者當時也考慮過安全性。在該次 commit 針對 DES 的實作中加入了一道保護:若傳入的 salt 開頭正是 *,為了避免與目標欄位比對成功,函式會改為回傳 "x":

return (setting[0]=='*') ? "x" : "*";

這項防護建立在一個前提之上:比對用的目標雜湊與傳給 crypt() 的 salt 來自同一個來源。

2012 年(20 天後):musl SHA-crypt 加上 256 位元組上限

在改用特殊錯誤回傳值的 20 天後(2012 年 8 月 29 日,commit 88bf5a8a),musl 新增了 SHA-256 與 SHA-512 的雜湊支援。

由於 SHA-crypt 包含多輪運算,為避免攻擊者藉由超長密碼耗盡伺服器 CPU 資源引發阻斷服務(hash DoS),內部實作加上了長度檢查:

/* 等價示意:musl 內部 sha512crypt helper */
if (strlen(key) > 256) return 0;

當密碼長度超過 256 位元組時,內部的 sha512crypt() 會放棄計算並回傳失敗(0);外層的 __crypt_sha512() 接到失敗後,便遵循 musl 的錯誤慣例,將回傳結果設為 "*",最後由公開的 crypt() 傳回給呼叫端。

此項 256 位元組長度上限是 musl SHA-crypt 的專屬實作,並非所有 libc 或所有雜湊演算法皆有相同限制。

2016 年:OpenSSH 修補計時攻擊(借用鄰居的 salt)

2016 年 7 月,OpenSSH 針對帳號列舉問題釋出了安全性修補(CVE-2016-6210)。

在修補前,OpenSSH 面對不存在的帳號時使用的是帶有 Blowfish 前綴的偽造密碼資料;若系統不支援該演算法,驗證會迅速失敗,而真實存在的帳號則需耗費時間計算 SHA-512 雜湊。攻擊者可藉由失敗路徑與計算延遲的差異,推測帳號是否存在。

OpenSSH 的修補邏輯為:若使用者不存在,或目標帳號的密碼欄位沒有合法的 salt(例如欄位僅為 *),sshd 會透過 pick_salt() 從系統中已有的有效使用者裡搜尋並快取一組可用的 salt(例如其他帳號的 SHA-512 salt),藉此完成一次標準雜湊計算,平衡各路徑的處理時間:

salt = pick_salt(); // 取得有效帳號的 "$6$xyz$..."
encrypted_password = crypt(password, salt);
return strcmp(encrypted_password, pw_password) == 0; // pw_password 依然是目標帳號的 "*"

此設計的隱含假設是:傳入合法的 SHA-512 salt 後,crypt() 計算產生的字串格式必然是以 $6$ 開頭的完整雜湊,絕不可能與只有單一字元的 * 吻合。

兩項各自合理的假設在此處出現分歧:

  1. musl 認為錯誤時回傳 * 不會造成威脅,因為如果 salt 本身以 * 開頭,它會改傳 "x";但未預料到 OpenSSH 傳入的 salt 其實是借來的 $6$...。
  2. OpenSSH 認為給定 $6$ 開頭的 salt,crypt() 的輸出必然是以 $6$ 開頭的雜湊;但未預料到 musl 的 SHA-crypt 在密碼長度超過 256 位元組時會直接中斷並回傳 *。

2021 至 2022 年:Alpine Linux 的 root 密碼設定變更

若系統中沒有密碼欄位為 * 的帳號,該路徑不會被觸發。然而 Alpine Linux 的歷史變更使得 * 帳號在多數環境中普遍存在。

2019 年,Alpine 的 Docker 官方映像曾出現 root 密碼為空的情況(CVE-2019-5021),隨後官方將 root 密碼欄位改為 ! 進行鎖定。

然而在未啟用 PAM 的環境中,shadow 欄位為 ! 不僅會停用密碼,也會導致 SSH key 認證遭系統拒絕。對於依賴預先注入金鑰開機的雲端主機而言,這項限制影響了正常運作。

為了讓帳號在不接受密碼認證的同時仍能正常使用 SSH 金鑰登入,Alpine 在 2021 年 12 月(aports commit fbe926af,出貨於 Alpine 3.16)將 root 欄位自 ! 改為 *。同時,雲端映像的建置腳本中預設的 alpine 使用者也採用了 * 標記。

2026 年:AI agent 差分測試發現異常

這項邊界條件組合在系統中存在了多年。

依研究者 Özgün Kültekin 公開說明,其在探討 OpenSSH 與底層 libc 的邊界行為時,建立了包含 glibc、musl、PAM 啟用與停用等多種組合的差分測試矩陣,並利用 AI agent swarm 生成了 17,600 組邊界測試資料進行自動化比對。

測試結果顯示,在「musl + 無 PAM」的環境下,當輸入超過 256 位元組且不超過 OpenSSH 的 1024 位元組上限時,密碼欄位為 * 的帳號會異常通過比對,該漏洞因而被發現。

檢查系統是否受影響

該漏洞(社群通稱為 *sshole)的觸發需要多項條件同時成立:

  1. 底層為 musl libc:例如 Alpine Linux、OpenWrt、Buildroot、Yocto、postmarketOS(現由 Nura 社群維護)、Void Linux(musl 版本)。使用 glibc 的發行版不受影響。
  2. OpenSSH 未啟用 PAM:Alpine Linux 預設安裝的 openssh-server 套件未包含 PAM 支援,或在設定中指定了 UsePAM no。
  3. 開啟了密碼認證:sshd_config 中啟用了 PasswordAuthentication yes(OpenSSH 預設值)。
  4. 存在密碼欄位為 * 的帳號:例如在設定了 PermitRootLogin yes 的環境中的 root 帳號,或是僅允許金鑰登入的部署帳號、具備 doas 權限的雲端預設帳號(如 alpine)。
  5. 系統中存在可供借用的 SHA-crypt salt:pick_salt() 能夠在系統中搜尋到,並實際選中且快取來自 $5$ 或 $6$ 雜湊的 salt。
  6. 輸入密碼長度介於 257 至 1024 位元組:需超過 musl SHA-crypt 的 256 位元組限制以觸發錯誤回傳,同時不能超過 OpenSSH 在 auth-passwd.c 中定義的 MAX_PASSWORD_LEN(1024 位元組)。

官方專案提供了檢測指令:

curl -sO https://raw.githubusercontent.com/oz9un/sshole/main/check.sh
sudo sh check.sh

OpenSSH 的修補方式

依據 OpenSSH 10.6p2 發布說明 與 上游修補 commit 4353fdc43,該問題已在 OpenSSH 10.6p2 中正式修正。核心驗證邏輯與記憶體管理修補節錄如下:

--- a/auth-passwd.c
+++ b/auth-passwd.c
@@ -68,6 +68,7 @@ extern login_cap_t *lc;
 #define DAY		(24L * 60 * 60) /* 1 day in seconds */
 #define TWO_WEEKS	(2L * 7 * DAY)	/* 2 weeks in seconds */
 
+#define MIN_PASSWORD_HASH_LEN	4
 #define MAX_PASSWORD_LEN	1024
 
...
@@ -198,16 +200,20 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 	struct passwd *pw = authctxt->pw;
+	int ret;
 	char *encrypted_password, *salt = NULL;
...
 	/*
 	 * Authentication is accepted if the encrypted passwords
 	 * are identical.
 	 */
-	return encrypted_password != NULL &&
+	ret = encrypted_password != NULL &&
+	    strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
 	    strcmp(encrypted_password, pw_password) == 0;
+	freezero(pw_password, strlen(pw_password));
+	return ret;
 }

修補重點包含兩項:

  1. 長度門檻檢查:在比對雜湊之前,明確要求 shadow 儲存的雜湊長度必須大於等於 4(MIN_PASSWORD_HASH_LEN)。若帳號欄位僅為 * 或 !,比對直接判定為失敗。
  2. 複本記憶體清除:shadow_pw() 改為透過 xstrdup 複製密碼字串,比對完畢後立即使用 freezero 清除並釋放記憶體複本,同時徹底防範指標殘留。

防禦與暫時緩解措施

對於正在維運相關環境的管理人員,可採行以下處置:

升級 OpenSSH 套件

各發行版已陸續釋出包含 OpenSSH 10.6p2 修補的套件。在 Alpine Linux 上可透過套件管理器更新:

apk update
apk upgrade openssh
rc-service sshd restart

暫時緩解措施:關閉密碼認證

若無法立即升級套件,最直接的緩解措施是停用密碼認證,強制改用 SSH 金鑰登入:

echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd -t
sshd -T -C user=root,host=127.0.0.1,addr=127.0.0.1 | grep -i passwordauthentication
rc-service sshd restart

執行後需透過 sshd -t 確認設定語法無誤,並以 sshd -T -C ... 模擬連線條件,確保 passwordauthentication no 確實生效,未被較早載入的設定檔或 Match 區塊覆蓋。測試時請將 user、host、addr 替換為實際登入帳號、來源主機名稱與來源 IP(host 為客戶端主機名稱,addr 為客戶端 IP),並確認設定檔有透過 Include 載入該 drop-in,若有不同的 Match 條件應分別進行驗證。

架構與工程省思

回顧這項漏洞的演進過程,有幾項值得借鏡的架構思維:

帶內標記(in-band signaling)的長期代價

將控制意義(如「停用認證」或「計算失敗」)編碼在原本正常的資料字串空間中(magic string),容易在日後引發預期外的交互作用。

1980 年使用 * 標記非密碼帳號,2012 年 musl 使用 * 作為替代錯誤回傳值,兩者各自獨立運作時並無大礙,但當系統邊界重疊時便產生了碰撞。以現代語言設計的角度來看,使用明確的錯誤型別(例如 Option 或 Result)區分狀態,往往比依賴特殊字串標記更加健全。

安全性修補的連鎖效應

OpenSSH 在 2016 年為了解決計時攻擊而借用其他帳號 salt 的機制,在單一模組視角下十分直觀,但它改變了一項潛在假設:傳入 crypt() 的 salt 不再與待驗證的目標帳號具備相同的上下文。跨帳號的狀態依賴,最終與底層 libc 的特殊邊界行為產生了非預期的串接。

差分測試與工具輔助的價值

一般程式碼審查通常會關注複雜的演算法與記憶體操作,較少會對標準 API crypt(3) 的回傳值產生疑慮。

透過差分測試矩陣,比對不同 C 函式庫與編譯選項在極端邊界條件下的行為差異,能夠有效檢驗不同軟體層之間的隱含假設,這也展現了自動化測試與 AI 工具在深入挖掘系統交互缺陷時的具體價值。


相機創意外觀與硬體 ISP 色彩管線運作機制

以 JPEG XL 高動態範圍線性影像模擬 16 扇區色彩矩陣、129 點曲線與 5×9×9 3D HSV 空間轉換
featured.svg

當我們拿著現代無反相機拍照時,切換機身內的創意外觀(Creative Look,如 Sony 的 ST、PT、NT、VV、FL、IN、SH 以及後續移植的 FL2 與 FL3)或各種軟片模擬,電子觀景窗(EVF)與機背螢幕能以 60 至 120 fps 的即時幀率,流暢呈現對比鮮明、高光柔和且具備獨特色調的畫面。

在很多人的直覺印象中,相機直出(Straight Out Of Camera,簡稱 SOOC)的風格設定,似乎就像手機修圖 App 裡的濾鏡——只是在已經拍攝完成的影像上套一層調色參數或簡單的對比飽和度增減。然而,如果我們嘗試在電腦上拿一張已經壓縮成 8-bit 的一般 JPEG 照片,暴力拉高暗部曲線或大幅旋轉色相,畫面往往會在幾秒鐘內崩潰:藍天出現一圈圈難看的階調斷層(banding)、陰影處浮現斑駁色噪,高光雲層更會瞬間硬生生「燒成死白」。

為什麼相機在機身內部即時直出的色彩,既能大幅度重塑影調風格,又能保有極為平滑自然的高光滾降與豐富層次?相機硬體影像訊號處理器(Image Signal Processor,簡稱 ISP)在光子撞擊感光元件後,究竟進行了哪些多階段的非線性數學變換?

這篇文章將從數位色彩科學與嵌入式硬體架構出發,拆解現代相機硬體 ISP 的五大核心處理階段;並介紹為什麼 JPEG XL (.jxl) 格式是我們在電腦端精確模擬硬體 ISP 的最佳載體,最後透過即時互動實驗室解構從線性光到成品的端到端色彩轉換管線。

硬體 ISP 與後製濾鏡的根本分歧

要理解創意外觀的運作機制,首先必須釐清「硬體即時 ISP 管線」與「一般軟體後製濾鏡」在資料本質上的分水嶺。

在相機硬體架構中(例如先前我們在分析 Sony BIONZ XR CXD90057 雙處理器架構時所見),感光元件記錄下來的原始信號經過解馬賽克(demosaicing)後,抵達專責相機處理器(CP,運行 TOPPERS/FMP RTOS)時,仍然是完全未壓縮、具備 14-bit 以上動態範圍的 場景線性輻射率 (scene-referred linear radiance)。

在這種線性光空間中,數值大小嚴格對應物理世界中的光子通量(photon flux):數值增加一倍,代表物理進光量實打實增加了一檔(1 stop)。

一般修圖軟體套用後製濾鏡時,面對的往往是已經被壓縮進顯示空間(display-referred)、經過 8-bit 量化並烘焙了非線性 sRGB 伽瑪曲線的成品 JPEG。一旦進入這個階段,原始場景中超過 100% 亮度的動態範圍早被強制剪裁(clipping),每個通道僅剩 256 個離散數值階層,任何劇烈的色調扭曲都會破壞相鄰畫素間的微小梯度,導致視覺可見的階調碎裂。

相機內部的硬體 ISP 則完全不同:它是在純粹的線性光空間中執行白平衡與原色校正,隨後透過專用的硬體加速電路(如色彩矩陣單元、樣條曲線單元與 3D 色彩網格單元)並行運算,將高動態範圍以數學方式平滑映射至目標色域,最後才交由編碼器壓縮儲存。這正是相機直出 JPEG 或 HEIF 能具備「底片質感」卻毫不生硬的硬體基礎。

為什麼選擇 JPEG XL 作為模擬實驗載體?

如果我們想在電腦端驗證並重現相機硬體 ISP 的全部數學運算,我們需要一種合適的數位影像檔案格式作為實驗媒介:

  1. 傳統 JPEG 的限制:每通道只有 8-bit、色彩空間被鎖死在狹窄的 sRGB、且強制使用 4:2:0 破壞性色度抽樣與 8×8 離散餘弦變換(DCT)區塊。拿它來模擬多階段 ISP,只會放大檔案本身的壓縮瑕疵。
  2. 相機 RAW 原檔的限制:RAW 檔(如 Sony 的 ARW、Nikon 的 NEF 或 Adobe DNG)雖然保留了感光元件的 14-bit 線性資料,但由於包含 Bayer 濾鏡陣列的原始馬賽克結構,不同解碼器(LibRaw、Rawpy、Adobe Camera Raw)在去馬賽克階段的插值演算法差異甚大,且缺乏通用的色彩標記,難以直接作為跨平台純量運算的標準容器。

這正是 JPEG XL (.jxl) 展現價值的完美舞台。作為新一代現代影像標準,JPEG XL 具備幾項對於色彩科學模擬極為關鍵的硬實力:

  • 原生支援 16-bit 浮點數與整數線性光空間:JPEG XL 允許直接儲存未經伽瑪壓縮的 16-bit 線性資料(Linear Light Rec.2020 或 Display P3),能夠完整容納高動態範圍的場景輻射率,為後續的多級曲線壓縮與色相旋轉提供充足的運算精度餘裕,從根源杜絕階調斷層。
  • 超廣色域相容性:在硬體 ISP 中,當我們為了模擬復古色調而大幅度調移特定色相的飽和度時,色彩數值經常會暫時被推移至標準 sRGB 邊界之外;JPEG XL 能無縫封裝 Rec.2020 或 ACEScg 廣色域,避免邊緣色彩提早遭到裁切。
  • 感知均勻的 XYB 內部編碼:JPEG XL 內部原生基於人類視覺受體(LMS 錐細胞)的非線性感知空間進行色度與亮度分離,搭配自適應量化機制,即便在極高壓縮率下,也能以極細緻的保真度儲存微對比與細節紋理。

換言之,一張包含 16-bit 浮點線性場景資料的 JPEG XL 影像,就是我們在 PC 端高精度模擬硬體 ISP 的最佳「數位負片」。

Stage 0: 感光元件線性場景輻射率與 EV 動態範圍分佈 (Sensor Linear Radiance & EV Stops) STAGE 0 · RAW / JXL

硬體 ISP 色彩管線全景架構

在 Sony BIONZ XR 世代的韌體實作中,當 CP(相機處理器)接收到來自 AP(應用處理器)派發的創意外觀配置參數後,硬體 ISP 會依照嚴格的硬體流水線依序執行各個運算單元。

下圖呈現了相機內部從感光元件 RAW 讀出、歷經硬體 ISP 各級變換,直到最終直出編碼的完整管線架構(在後續的實驗與電腦端模擬中,我們使用 16-bit 線性 JPEG XL 作為場景線性光資料載體):

creative-look-isp-pipeline.svg

整個管線可以清晰地拆解為五大循序漸進的運算階段:

  1. 線性白平衡與暗部基準增益 (cc1)
  2. 16 扇區色相相依色彩矩陣 (block13)
  3. 129 點分段樣條色調映射 (gamma[129])
  4. 5×9×9 3D 極坐標 HSV 色彩網格 (grid[3][405])
  5. 空間微對比與細節濾波 (Clarity & Detail)

接下來,我們深入剖析每一階段的具體數學定義與在相機內部扮演的角色。

深入五大硬體 ISP 運算階段

線性白平衡與暗部基準增益 (cc1)

色彩管線的第一站,必須發生在完全未經非線性轉換的線性光空間。

在感光元件讀出訊號時,各感光畫素存在微小的暗電流雜訊(dark current),硬體首先扣除黑電平基準(pedestal)。接著,根據當前環境色溫與白平衡設定(對應韌體結構中的 s_kelvin 與 s_adobekelvin,以及 cc1[3][8] 結構中的通道增益),對三原色光電倍增訊號進行線性對稱放大:

$$\begin{bmatrix} R_{wb} \\ G_{wb} \\ B_{wb} \end{bmatrix} = \begin{bmatrix} k_R & 0 & 0 \\ 0 & k_G & 0 \\ 0 & 0 & k_B \end{bmatrix} \left( \begin{bmatrix} R_{in} \\ G_{in} \\ B_{in} \end{bmatrix} - \mathbf{p}_{\text{black}} \right)$$
 階段 1 硬體運算流向

輸入訊號:未校正場景線性輻射率 $\mathbf{C}_{in} = [R_{in}, G_{in}, B_{in}]^T$ 核心運算:扣除感光元件暗電流基準 $\mathbf{p}_{\text{black}}$ ➔ 套用通道乘法對稱縮放 $\operatorname{diag}(k_R, k_G, k_B)$ 硬體暫存器:s_kelvin、s_adobekelvin、cc1[3][8] 輸出特性:線性能量平衡原色 $\mathbf{C}_{wb}$(中性灰階無色偏,維持光子通量比例)

通道增益 $k_R$ 與 R_gain 的實作關聯與組成

在數學模型中的對角矩陣增益 $k_R, k_G, k_B$,與數值解剖儀中顯示的 R_gain, G_gain, B_gain 本質上代表完全相同的物理量:

  • 理論公式與硬體實作記號:在對角矩陣中,$k_R$ 代表紅通道的線性放大乘數;在韌體暫存器與模擬程式碼中,它對應單通道運算 $C_{wb} = \max(0, C_{linear} - p_{\text{black}}) \times \text{Gain}_c$ 當中的 R_gain(或 rGain)。
  • $k_R$ (R_gain) 的兩層權重組成:在硬體 ISP 與創意外觀中,$k_R$ 是由「風格外觀基礎偏差」與「環境色溫動態調變」兩者乘積所決定: $k_R \equiv \text{R\_gain} = k_{R, \text{base}} \times f_R(T_{\text{kelvin}})$
    • 外觀基礎偏差 ($k_{R, \text{base}}$):來自創意外觀預設(例如 FL 膠卷底片風格預設微幅偏暖,基礎值 wbGains[0] = 1.120)。
    • 色溫動態調變 ($f_R(T)$):相對於標準日光($5500\text{ K}$)即時補償。當場景色溫較高($T > 5500\text{ K}$,冷調陰影)時,調高紅通道增益以補充暖光平衡;色溫較低($T < 5500\text{ K}$,黃昏或鎢絲燈)時,則適度收斂以防止高光溢紅。
  • 綠通道基準錨點 ($k_G = 1.000$):在 Bayer 感光元件中,綠色畫素數量佔 50% 且最貼近人眼明度(Luminance)響應。相機 ISP 的 cc1 暫存器通常固定 $k_G = \text{G\_gain} = 1.000$ 作為中性亮度錨點,由 $k_R$ (R_gain) 與 $k_B$ (B_gain) 對稱向兩側展開調節紅藍色溫。

為什麼白平衡必須在最前端的線性空間執行?

如果我們先套用了非線性色調曲線或伽瑪映射,原色數值與物理光子通量之間的比例關係就會被徹底破壞。此時若再調整白平衡,高光區域與暗部區域的色彩偏差將不再一致,導致高光泛黃而暗部偏藍的非線性色偏(crossover distortion)。只有在線性空間下做乘法純量縮放,中性灰色的色度座標在各種曝光級數下才能維持嚴格守恆。

Stage 1: 線性白平衡與黑電平基準扣除 (cc1 Linear WB & Black Pedestal) STAGE 1 · CC1
白平衡色溫 (WB Temp) 5500 K

16 扇區色相相依色彩矩陣 (block13)

完成白平衡後,三原色通道達到能量平衡,但此時的 RGB 座標系仍然是感光元件濾色鏡的專屬光譜響應(camera native sensor primaries),必須轉換至標準色彩空間。

傳統影像處理通常採用單一組 $3 \times 3$ 色彩矩陣:

$$\vec{C}_{out} = \mathbf{M}_{3 \times 3} \cdot \vec{C}_{in}$$

然而,單一全域 $3 \times 3$ 矩陣在色彩設計上存在無法調和的物理矛盾:當我們想要模仿膠卷底片的特徵,把天空中的藍色往青綠色(Cyan / Teal)旋轉、並提高綠色植被的純度時,一個全局矩陣會無可避免地把人臉中的黃色與紅色一同牽動,導致原本健康紅潤的膚色變得枯黃或蠟黃。

為了解決這個難題,現代相機硬體 ISP(在 BIONZ XR 中對應 block13[3][16][6] 結構)導入了 16 扇區色相調適矩陣 機制:

  1. 色相圓盤 16 等分:硬體管線即時計算畫素的色相角 $\theta = \operatorname{atan2}(U, V)$,將 $360^\circ$ 的色彩圓盤劃分為 16 個色相扇區(每個扇區跨度 $22.5^\circ$)。
  2. 基底校正矩陣與差分係數:以基礎矩陣 $\mathbf{M}_{base}$ 為底,每個扇區定義了 6 組差分修正係數(differential coefficients),獨立微調該色相區間的交叉通道貢獻量。
  3. 鄰近扇區連續線性插值:當畫素介於兩相鄰扇區中心時,硬體根據其色相偏移角度對兩組矩陣進行平滑加權混合,確保色相過渡平順、絕不出現邊界色斑跳變。

透過 16 扇區色彩矩陣,相機工程師能夠做到「在大幅旋轉冷色調的同時,將黃橙色至紅色的膚色區間牢牢鎖定在自然範圍內」。

Stage 2: 16 扇區色相相依向量矩陣圓盤 (block13 Hue Wheel & Deviations) STAGE 2 · BLOCK13
 階段 2 硬體運算流向

輸入訊號:白平衡後原色 $\mathbf{C}_{wb}$ 核心運算:計算色相角 $\theta = \operatorname{atan2}(V, U)$ ➔ 定位相鄰扇區 $s$ 與 $s+1$ ➔ 線性權重插值差分矩陣 $\mathbf{M}_{base} + \Delta \mathbf{M}(\theta)$ 硬體暫存器:block13[3][16][6] 輸出特性:目標色域原色(例如天空藍向青綠旋轉,膚色區間完全鎖定保護)

129 點分段樣條色調映射 (gamma[129])

色彩基礎校正完成後,接著迎來決定畫面明暗對比與「影調骨架」的核心階段——色調映射(Tone Mapping)。

許多人習慣用單一的伽瑪公式 $Y_{out} = Y_{in}^{1/\gamma}$ 來理解亮度映射,但在實際相機硬體 ISP 中,這種固定冪次公式完全無法滿足現代攝影對高動態範圍的苛刻要求。在韌體逆向分析中,我們看到的是一整張由 129 個節點組成的分段曲線表格:gamma[129]。

硬體 ISP 透過分段三次樣條(Cubic Spline)或高階單調插值,在 129 個錨點之間構建起連續平滑的映射曲線:

  • 暗部微抬升與灰調啞光 (Matte Fade / Pedestal Lift):在原廠 FL 或 Fujifilm Classic Chrome 風格中,曲線在接近 $Y = 0$ 的最暗端點並未直接落在純黑,而是微微抬升約 $1.5\% \sim 3\%$ 的輸出亮度。這種暗部灰調模擬了傳統底片銀鹽未曝光區域的基礎片基密度(D-min),賦予暗部沉穩而通透的復古啞光感。
  • 中灰對比坡度 (Midtone Contrast Slope):在中間調(通常對應 18% 灰板的曝光基準點附近),曲線擁有更陡峭的斜率,以增強視覺焦點的立體感與階調反差。
  • 高光軟滾降 (Soft Highlight Rolloff):傳統數位拍照最令人詬病的,就是在過曝邊界出現斷崖式的硬剪裁(hard clipping),雲層邊緣會形成突兀的色帶。硬體 ISP 的 129 點樣條在高光區間採用了漸近 S 曲線(shoulder curve),讓亮度在逼近 1.0 時平順地減緩上升斜率,將極端高光的反差柔和壓縮,營造如同大片幅膠卷般的優雅高光過渡。
  • 嚴格單調性約束 (Isotonicity):所有 129 個節點必須滿足嚴格的單調遞增條件 $Y_{out}[i+1] \ge Y_{out}[i]$,否則局部斜率倒轉將直接引發嚴重的太陽化(solarization)光斑異常。

三項式混合模型與光學攝影意圖

在韌體設計與後續的互動模擬實作中,這條 129 點樣條曲線由三個各自承載光學與感光化學意圖的物理項加權疊加而成:

$Y_{out}(x) = Y_{toe}(x) + Y_{mid}(x) \cdot w_{mid} + Y_{shoulder}(x) \cdot w_{shoulder}$

  • 暗部趾部抬升項 (Toe Lift / Film Base D-min): $Y_{toe}(x) = \text{lift} \cdot \max(0, 1 - 3.5x)^{2.2}$ 傳統銀鹽黑白與彩色負片中,未曝光區域依然具備基礎膠片片基的光學密度(Minimum Density, $D_{\min}$)。在數位影像中若直接將數值映射至絕對純黑(0),暗部陰影容易顯得生硬扁平。透過指數為 2.2 的非線性冪次衰減,該項僅作用於最深處約 5% 的暗部階調,營造出底片特有的啞光灰黑質感與空氣呼吸感。
  • 中間調 Sigmoid S 曲線項 (Midtone Contrast Punch): $Y_{mid}(x) = \frac{1}{1 + \exp\left( -\gamma \cdot (\ln x - \ln x_{mid}) \right)}$ 以 RAW 感光元件線性中灰錨點 $x_{mid} \approx 0.105$(保留約 3 級高光動態範圍裕度)為中心,利用對數空間下的 Sigmoid 函數塑造平滑且對稱的階調反差斜率 $\gamma$。這能有效鎖定視覺核心主題的立體感與衝擊力,同時維持中灰基準點的明度穩定。
  • 高光肩部漸近滾降項 (Shoulder Highlight Rolloff): $Y_{shoulder}(x) = 1 - \exp\left( -x^{\alpha} \cdot 1.8 \right)$ 數位感光元件到達飽和電容時會發生嚴重的硬剪裁(hard clipping),導致天空白雲出現邊緣斷層。肩部滾降項採用指數漸近收斂,在亮度逼近 1.0 時溫和壓縮高光動態範圍,模擬大片幅膠卷在過曝時銀鹽晶粒逐漸耗盡的優雅軟滾降。
Stage 3: 129 點硬體分段樣條色調映射曲線 (gamma[129] Spline Curve) STAGE 3 · GAMMA[129]
暗部抬升 (Pedestal Lift) 2.5%
中灰對比坡度 (Contrast) 2.20×
 階段 3 硬體運算流向

輸入訊號:色彩矩陣校正後線性 RGB 核心運算:提取明度 $Y_{in}$ ➔ 129 點分段三次樣條查表(暗部抬升 + 中灰對比坡度 + 高光漸近滾降)➔ 等比例縮放原色 $\mathbf{C} \cdot (Y_{out} / Y_{in})$ 硬體暫存器:gamma[129] 曲線節點表格 輸出特性:高動態範圍壓縮後影像(底片啞光灰黑、通透高光、嚴格色度座標守恆)

5×9×9 3D 極坐標 HSV 色彩網格 (grid[3][405])

如果說 129 點曲線塑造了相片的骨骼,那麼 5×9×9 3D 極坐標 HSV 色彩網格 (在韌體中對應 grid[3][405] 與 405-point 3D lattice) 就是賦予創意外觀獨特靈魂的魔法之源。

下圖展示了這座立體色彩網格的空間幾何拓撲與三線性插值運作方式:

hsv-lattice-transformation.svg

405 個立體網格點的幾何結構

在三維空間中,網格將色彩空間組織為一個柱狀極坐標系(Cylindrical Polar Coordinates):

  • 5 層垂直明度階 (Luma Slices):沿著垂直亮度軸 $Y$,劃分為 5 個離散節點($Y \in \{0, 1, 2, 3, 4\}$),分別對應極暗部(Deep Shadows)、陰影(Shadows)、中灰(Midtones)、高光(Highlights)與極高光(Super Highlights)。
  • 9 圈徑向飽和度環 (Saturation Rings):從中性灰軸心向外輻射,劃分為 9 個飽和度半徑階層($U \in \{-4, -3, \dots, 0, \dots, +4\}$),涵蓋從完全黑白中性、低飽和啞光到高飽和純色的過渡。
  • 9 條角向色相射線 (Hue Rays):圍繞色度圓盤以 $40^\circ$ 為間隔,劃分出 9 個主要的色相取樣方向($V \in \{-4, -3, \dots, 0, \dots, +4\}$),精確錨定紅、橙黃、綠、青、藍、洋紅等關鍵光譜。

三者相乘,構成了立體空間中恰好 $5 \times 9 \times 9 = 405$ 個控制節點。在硬體儲存中,每個節點儲存一組 signed 7-bit 整數增量向量:

$$\mathbf{g} = \left( \Delta H, \Delta S, \Delta V \right) \in [-64, 63]^3$$

空間單元三線性插值原理 (Trilinear Interpolation)

當任何一個畫素進入該單元時,硬體計算其所在的空間連續座標 $(y, u, v)$,並定位包覆該畫素的相鄰 8 個網格頂點構成的微型晶胞(sub-cube):

$$\mathbf{C} = \{ (y_0, u_0, v_0), (y_0, u_0, v_1), \dots, (y_1, u_1, v_1) \}$$

令分數偏移量為 $f_y = y - y_0$、$f_u = u - u_0$、$f_v = v - v_0$,三線性插值在 8 個角點上分別施加空間權重:

$$w_{i,j,k} = \left( i \cdot f_y + (1-i)(1-f_y) \right) \left( j \cdot f_u + (1-j)(1-f_u) \right) \left( k \cdot f_v + (1-k)(1-f_v) \right)$$

最終該畫素獲得的色彩修正量為 8 個角點增量的加權總和:

$\Delta \vec{C} = \sum_{i,j,k \in \{0, 1\}} w_{i,j,k} \cdot \frac{\mathbf{g}_{y_i, u_j, v_k}}{256}$

Stage 4: 5×9×9 3D 極坐標 HSV 色彩網格立體切片 (grid[3][405] 3D Lattice) STAGE 4 · GRID[3][405]
3D 網格陰影冷調 (Teal Delta) +12 Δ

為什麼 3D 網格具有無可替代的表現力?

傳統的 1D LUT 只能單獨調整 R、G、B 各自的曲線,無法做到「根據亮度與飽和度的組合來決定色相偏移」。

3D 網格則實現了完全有界(locally bounded)的多維度精準調色:

  • 暗部冷調青綠、高光微暖:我們可以在 $Y \in \{0, 1\}$(陰影階)將綠色和青色扇區的 $\Delta H$ 向藍綠色偏移,同時在 $Y \in \{3, 4\}$(高光階)保持完全中立或微增暖黃,創造出電影等級的冷暖影調分離。
  • 人像膚色高光保護:在橙黃色相射線上,僅在 $Y \ge 2$ 的高明度區間提升 $\Delta V$ 並微降 $\Delta S$,讓人物臉頰高光呈現白皙通透的粉潤感,而完全不影響畫面其他區域的飽和度。
  • 深色植被消光:在暗部高飽和綠色節點上單獨降低 $\Delta S$,消除數位相機拍攝森林時常見的突兀螢光綠,營造沉穩靜謐的日系膠卷氛圍。
 階段 4 硬體運算流向

輸入訊號:色調映射後影像 核心運算:柱狀極坐標定位 $(y, u, v) \in [0, 4] \times [0, 8] \times [0, 8]$ ➔ 晶胞 8 頂點三線性空間插值 ➔ 疊加有界增量 $\Delta \vec{C}$ 硬體暫存器:grid[3][405] 405 個立體網格點節點 輸出特性:多維度微調色彩(陰影冷調青綠、人像高光粉潤提亮、深綠植被消光)

空間微對比與細節濾波 (Clarity & Detail)

最後一個硬體階段聚焦於空間頻率域。

相機中的清晰度(Clarity)與銳利度(Sharpness)調節,本質上是透過雙邊濾波(Bilateral Filter)或導向濾波(Guided Filter)將影像分解為 基底低頻層 (Base Layer) 與 高頻細節層 (Detail Layer)。

在不產生光暈(halo artifacts)的前提下,硬體對局部邊緣梯度進行非線性增益放大,並在低對比度紋理處施加微幅提升,讓織物毛髮、石磚紋理在維持柔和色彩的同時,依舊保有清晰可辨的輪廓觸感。

Stage 5: 空間雙邊濾波頻率分離與微對比增益 (Clarity & Edge Detail) STAGE 5 · FILTER
微對比清晰度 (Clarity) +0.45×
 階段 5 硬體運算流向

輸入訊號:3D 色彩網格處理後影像 核心運算:空間邊緣濾波分解 ➔ 提取基底低頻層與高頻細節層 ➔ 局部梯度非線性增益放大 硬體暫存器:空間濾波器係數與清晰度閾值 輸出特性:層次分明、無白邊光暈的相機直出成品影像

硬體 ISP 創意外觀即時互動實驗室

理論與公式固然嚴謹,但唯有親手切換管線各階段、即時觀察影像與向量空間的聯動變化,才能真正領略硬體 ISP 的精妙之處。

下方提供了一套完全運行於瀏覽器記憶體中的 相機硬體 ISP 創意外觀即時互動實驗室。這套模擬器基於前述五大階段演算法建構,具備以下核心功能:

  1. 六階段循序演繹 (Pipeline Stepper):點擊上方階段標籤,或點擊「▶ 自動流水線演示」,即可親眼見證一張 16-bit 線性負片如何歷經白平衡、色相旋轉、樣條映射、3D 空間微調與細節濾波,逐步綻放為獨特的直出風格。
  2. 多款經典風格預設 (Creative Looks):可在 FL (Film Look)、ST (Standard)、VV (Vivid) 與 Classic Neg 間任意切換,觀察不同風格在色調曲線、陰影偏色與膚色保護上的策略差異。
  3. 雙面板向量幾何解剖 (Mathematical Inspector):右側畫布即時渲染當前階段的數學拓撲圖,包含動態範圍 EV 直方圖、16 扇區色相圓盤、129 點分段樣條曲線、3D HSV 空間切片與頻率分離曲線。
  4. 捲簾滑桿與原位閃切 (Comparison):支援左右拖曳捲簾滑桿對比原始線性光與直出成品,亦可按住「原位按住閃切」即時檢視單一階段帶來的視覺增量。
  5. 底層硬體參數微調:展開下方控制面板,能即時拉動色溫、暗部抬升比例、中灰對比坡度與 3D 陰影青綠冷調,實時運算呈現畫布結果。
相機硬體 ISP 創意外觀即時互動實驗室 BIONZ XR PIPELINE SIMULATOR
測試場景:
檢視模式:
即時階段渲染畫布 (Live Stage View) 900 × 600 (3:2) · 16-bit 浮點渲染
風格外觀預設: 沉穩平靜的日系膠卷感,陰影具備呼吸感,膚色通透。
Stage 0 RAW 線性光負片
Stage 1 (cc1) 白平衡與黑基準
Stage 2 (block13) 16 扇區色彩矩陣
Stage 3 (gamma) 129 點分段樣條
Stage 4 (grid) 5×9×9 3D 網格
Stage 5 (filter) 微對比與細節直出
Stage 0: 感光元件線性場景輻射率 (Sensor Linear Radiance)
Linear Radiance: (R, G, B) ∈ [0.0, 2.2+]
感光元件原始線性光子能量(電腦端模擬以 16-bit 線性 JXL 載入),維持無損寬容度與物理照度比例,為後續膠卷曲線儲備動態餘量。
● 感光元件原生線性輸入:維持感測器未校正光子能量,數值與物理進光量嚴格成正比,無手動暫存器參數。

風格外觀實例解析:ST、FL、FL2、FL3 與經典負片對比

當我們將上述硬體管線套用於實際影像時,不同創意外觀在各階段參數上的微小差異,會產生風格截然不同的化學反應。

在上方即時互動實驗室中,我們已將各風格外觀的「整體直出氛圍」直接整合至預設選單旁,並將 129 點分段樣條曲線(gamma[129])、16 扇區色相矩陣(block13)與 3D 色彩網格(grid[3][405])等各階段硬體特性動態綁定至階段解剖儀說明欄位中,讀者可隨時在實驗室中切換預設與階段即時對照。

以下特別針對相機硬體韌體逆向工程中提取的真實 FL2、FL3 數據進行深入拆解:

FL2:柔和低反差電影質感與啞光陰影

從韌體提取的真實硬體參數分析,FL2(Film Look 2)是專為追求溫潤柔和、低刺激視覺體驗所調校的電影外觀:

  • 抬升暗部基底 (Toe Lift):其 129 點曲線的起步節點 gamma[1] 高達 1158(約為 3.2%),幾乎是 FL(594,約 1.8%)的兩倍。這使得深暗陰影完全不會出現死黑裁切,而是呈現出如同 35mm 電影負片般的厚實片基質感(Matte Shadow)。
  • 收斂中間調反差:對比坡度從 FL 的 2.2 降至 1.88,階調過渡極為連綿平滑。
  • 平緩的高光滾落 (Soft Highlight Roll-off):高光肩部係數縮減至 0.78(gamma[96] 為 118,863,低於 FL 的 122,524),能有效抑制烈日逆光與金屬反射的生硬邊緣,讓高光散景呈現柔順的擴散過渡。
  • 復古植被與啞光青空:在 16 扇區矩陣中,綠葉扇區微幅增加綠色分量並抑制藍色,將鮮豔的翠綠轉化為沉穩的橄欖綠與鼠尾草色;天空藍扇區則降低飽和度,散發靜謐內斂的膠卷氛圍。

FL3:通透高光清亮膠卷與純淨藍調

與低反差的 FL2 形成鮮明對比,FL3(Film Look 3)走的是明亮、輕盈且富有空氣感的現代高調風格:

  • 暗部至中灰的空氣感躍升:在 129 點曲線中,FL3 的暗部到中灰過渡區段(如 gamma[32])數值高達 40,624(正規化約 0.31),在所有創意外觀中居於首位(遠高於 ST 的 30,329 與 FL 的 36,042)。這代表即使在背光陰影處,畫面依然能維持極佳的明亮度與通透感,毫無沉重壓抑之感。
  • 純淨湛藍的天空表現:在 16 扇區矩陣中,藍天扇區(Sectors 10 ~ 13)維持極高的藍通道增益($\Delta B > 0$),避免了 FL 那種濃重的墨綠偏轉,呈現如初夏晴空般的乾淨清透湛藍。
  • 粉嫩透亮的人像膚色:膚色扇區(Sectors 1 ~ 3)在保持健康暖意之餘,微幅提升紅藍明度,使皮膚呈現白皙粉潤的通透光澤,特別適合陽光下的人像與旅拍紀錄。

在 16-bit 線性 JPEG XL 的加持下,這些複雜的曲線與空間網格運算完全不會在漸層處留下任何鋸齒與斷層。即使我們將相機直出的 JPEG 與這套在 PC 上運算出的 JXL 進行殘差比對(residual comparison),兩者在可見光譜內的色調匹配度依然高達 99% 以上。

結語:數位暗房與硬體工程的交會

探索相機硬體 ISP 的創意外觀管線,是一次極具啟發性的跨領域工程體驗:

  • 打破濾鏡的玄學迷思:相機直出的獨特色彩並不是神秘的黑魔法,而是一套由「線性能量平衡 ➔ 色相分區矩陣 ➔ 分段樣條曲線 ➔ 3D 極坐標微調」嚴密交織而成的空間幾何系統。
  • JPEG XL 作為現代影像橋樑:透過 16-bit 浮點數線性容器與廣色域支援,JPEG XL 成功彌合了「笨重且難以通用的 RAW 檔」與「貧乏脆弱的 8-bit 老 JPEG」之間的巨大鴻溝,成為在現代電腦端高精度探索影像科學的絕佳載體。
  • 從逆向分析到正向創造:當我們在底層看清了 cc1、block13、gamma[129] 與 grid[3][405] 的資料排布與數學含義,我們不僅能深入理解手中的相機,更能反過來在電腦端打造屬於自己的專屬色彩矩陣與立體網格,甚至將其打包注入相機韌體,讓這套硬體管線真正為自己的攝影美學所用。

Sony A7R V 第三方韌體逆向與本地編譯實戰

剖析 BIONZ XR 雙處理器架構、FL2/FL3 風格外觀移植、Unicorn 虛擬 CPU 驗證與 Android Fastboot 信任鏈
featured.svg

Sony Alpha 7R V(ILCE-7RM5,機種代碼 0x11030080,內部代號 R5)憑藉 6,100 萬畫素的感光元件與獨立 AI 處理單元,在畫質細節與自動對焦表現上幾乎無可挑剔;但只要聊到機身直出(SOOC)的色彩選擇,許多 R5 用戶心裡多少都有個小遺憾:明明與隨後推出的 A7C II、ZV-E1、FX3 採用同世代的 BIONZ XR 影像處理器,Sony 卻在後續機型上新增了廣受好評的 FL2 與 FL3 兩種風格外觀(Creative Look),而身為高階機身的 R5 即使升級到官方 v4.00 韌體,依然停留在原本的 10 種預設外觀。

更進一步來說,許多攝影師希望能將 Fujifilm 經典的軟片模擬(如 Classic Chrome、Classic Neg、Nostalgic Neg、Astia、Velvia、Acros、Reala Ace)直接寫入相機的硬體 ISP 管線,在電子觀景窗(EVF)取景與直出 JPEG / HEIF 時即時套用,而不必每次都進 Lightroom 從 RAW 檔慢慢調色。

近期開源社群出現了針對 R5 v4.00 韌體的逆向工程專案(sony-a7r5-custom-firmware v4.34,韌體標頭版本號編碼為 BCD 0x34, 0x04)。出於對嵌入式系統架構與相機底層運作的好奇,我花了一段時間將整套專案與 Sony 官方 v4.00 的 BODYDATA.DAT(754,525,608 bytes)徹底拆解開來,在 Windows 環境下搭配 LLVM Clang 23.1.4 與 uv 從頭走完解包、雙目標交叉編譯、Unicorn 虛擬 CPU 模擬測試與 RSA-2048 重簽章封裝。這篇文章就來完整記錄 Sony BIONZ XR 相機內部的軟硬體架構,以及這套第三方韌體背後的每一個位址、結構與工程細節。

這套自製韌體做了什麼?

在進入記憶體位址與組語之前,先看看這套 R5 v4.34 第三方韌體在功能層面完成了哪些擴充:

  1. 開啟隱藏的 FL2 與 FL3 風格外觀: 原廠 R5 內建 10 組風格外觀(ST、PT、NT、VV、VV2、FL、IN、SH、BW、SE)。這套韌體將新世代 BIONZ XR 機型(A7C II v2.00、ZV-E1 v2.00、FX3 v6.00)原生的 FL2(沉穩柔和的底片感)與 FL3(通透高光與清亮色調)完整移植回 R5,將機身內的 Creative Look 槽位從 10 組擴充為 12 組。
  2. 支援從 SD 卡動態匯入自訂濾鏡與 3D LUT: 使用者只要在插槽 1 或插槽 2(/mnt/sd0 或 /mnt/sd1)根目錄放置一個 29,552 bytes 的 CUSTOM_F.BIN 或 SONY_LUT.BIN(採用 BIN v4 格式,4-byte 魔術字元為 R5F4,版本號 4),就能在選單中一鍵觸發匯入,直接覆寫全部 12 組 Creative Look 在 RAM 與硬體 ISP 中的底層色彩結構。專案隨附了經過實機 ISP 校準的 12 組 Fujifilm 軟片模擬組合:
    • PROVIA(標準)、VELVIA(高飽和風景)、ASTIA(柔和人像)、CC(Classic Chrome 經典正片)
    • PRO_NG(PRO Neg.Hi)、PRO_NS(PRO Neg.Std)、CL_NEG(Classic Neg. 經典負片)、NOSTAL(Nostalgic Neg. 懷舊負片)
    • ETERNA(電影感)、ET_BLC(Eterna Bleach Bypass 跳漂)、ACROS(高質感黑白)、REALA(Reala Ace)
  3. 完整的原生 UI、動態圖示與 Fn 選單整合: 不同於早期單純替換常數表的硬改做法,v4.34 會在拍攝資訊畫面(HUD)與轉盤上顯示自訂的 6 字元簡稱,例如 CL_NEG、NOSTAL,並在 Creative Look 選單列表中顯示 32 字元完整名稱,同時支援動態解析選單圖示(修正了早期 v4.33 誤將部分槽位索引至說明文字資源的問題,確保 ST 顯示原生 ST 圖示且其餘槽位動態對應正確圖示)。此外,它將「匯入自訂濾鏡」直接註冊為可指派的 Fn 快捷選單項目(項目 ID 0x26e / 0x1f8)。
  4. 出廠備份範本與自動白平衡微調: 一併修補了 original.tar 內全部 12 個地區的出廠設定備份範本(0110_backup/SYSIPSX-DSLR/GL/CX89200_*.bin 與 CX89201_*.bin):將 Creative Look 啟用位元遮罩(bitmask)從 10-bit(0x03ff)擴充為 12-bit(0x0fff),在偏移量 0x76c5d 填入 FL2 與 FL3 的 43-byte 預設參數紀錄,並將自動白平衡(AWB)調整為 White Priority = 97(0x61)與 Ambience Priority = -1(0xff),改善 Sony 原廠在室內暖光下容易偏冷或偏綠的傾向。

BIONZ XR (CXD90057) 的雙處理器與雙作業系統架構

要理解為什麼在相機裡「多加兩個濾鏡槽位」需要動到兩套截然不同的編譯器與修補管線,我們必須先看懂 Sony BIONZ XR(晶片代號 CXD90057)內部的異質雙處理器架構。

檢視從韌體解出的 Device Tree Blob(0800_appli/setting/cxd90057.dtb)、Linux 核心映像(0700_part_image/dev/nflasha3:/vmlinux.bin)以及開源授權清單(0650_prfile/PMBP/LICENSE/CP_TOPRS.txt),可以清楚看見 Sony 的「雙腦分工(split-brain)」設計——將「使用者介面與檔案管理」以及「硬體 ISP 即時影像管線」嚴格切分在兩顆不同架構的處理器與兩套作業系統上:

bionz-xr-architecture.svg

AP 應用處理器:64 位元 SCEL Linux 與 97.3 MB 的 appFw.so

第一顆處理器是應用處理器 AP (Application Processor),在 Device Tree 中宣告為 sony,cxd90057,內部搭載四核心 64 位元 ARM Cortex-A35 (arm,cortex-a35,ARMv8-A 架構) 叢集,並配備 arm,gic-400 中斷控制器、arm,pl011 UART、arm,pl022 SPI 與 arm,pl330 DMA 控制器。

從 nflasha3 分區內的 /vmlinux.bin 提取出的核心版本字串如下:

Linux version 4.9.337-scel (jp10239@dic-build-scm-u20-006)
(gcc version 6.4.0 (20201211 (Sony CE Linux 14.0.1.0)) )
#1 SMP PREEMPT Fri Oct 31 13:51:16 JST 2025

AP 運行的是簡稱 SCEL 的 Sony CE Linux 14.0.1.0。最令人印象深刻的是它的應用程式架構:在 nflasha15(一個大小為 410,652,672 bytes、約 391.6 MB 的 ext2 檔案系統分區)中,幾乎整台相機的 GUI 選單樹、轉盤與 Fn 事件派發、組態屬性儲存(對應 nflasha2 / nflasha6)、記憶卡 I/O 以及與底層硬體的 IPC 通訊,全部被編譯進單一一個高達 97,368,304 bytes(97.3 MB)的 64 位元 ARM64 ELF 共享函式庫——/lib/appFw.so。

0.5 秒瞬間開機的秘密:nflasha5 與 WBI2 休眠快照陷阱

拆解分區時,我們注意到一個非常關鍵的機制:如果讓一顆 Cortex-A35 從頭冷啟動 Linux 4.9 kernel、掛載 nflasha15 並動態連結載入 97.3 MB 的 /lib/appFw.so,開機時間至少需要好幾秒鐘,這對抓拍優先的專業相機來說是不可接受的。

為了達成撥開電源開關後「0.5 秒內進入可拍攝狀態」,Sony 在 nflasha5 分區(243,789,184 bytes,約 232.5 MB)中存放了一份預先壓縮好的記憶體休眠快照,開頭 8 bytes 為魔術字元 WBI2\x00\x00\x00\x00(即 Warm Boot Image 2,十六進位 57 42 49 32)。平常開機時,bootloader 完全跳過正常的 Linux 初始化流程,直接將 nflasha5 的 LZO 壓縮快照解壓縮回 DRAM,瞬間恢復已經載入好 appFw.so 的執行緒狀態!

這在韌體逆向工程中構成了一個極易踩中的陷阱:如果你只修改了 nflasha15 裡的 /lib/appFw.so 與 nflasha8 裡的 cpapp-b.bin,刷機重開後,相機依然會從 nflasha5 的 舊版 WBI2 休眠快照 醒來,導致 DRAM 裡跑的還是未修補的舊版 appFw.so,甚至因為與新版 CP 韌體狀態不一致而引發異常。

解決方案卻優雅得令人會心一笑——只需要在建置管線中修改 nflasha5 開頭 0x0 偏移量的 1 個 byte:

Offset 0x00000000:  57 42 49 32 ("WBI2")  ➔  77 62 69 32 ("wbi2")

把大寫 W(0x57)改為小寫 w(0x77)後,刷機完成後的 第一次開機 時,bootloader 檢查 nflasha5 發現 WBI2 魔術字元失效,便會自動放棄快速喚醒,改從 nflasha3:/vmlinux.bin 與 nflasha15:/lib/appFw.so 執行完整的冷開機(cold boot),並在開機完成後自動將包含新版修補程式的 DRAM 狀態重新寫入 nflasha5,產生全新的 WBI2 快照。從第二次開機起,相機就再次恢復 0.5 秒瞬間喚醒的速度。

CP 相機處理器:32 位元 Cortex-R5 與 TOPPERS/FMP RTOS

第二顆處理器是相機處理器 CP (Camera Processor),採用 32 位元 ARM Cortex-R5 (ARMv7-R 架構),上面運行的是由日本名古屋大學高田實驗室主導開發、符合 μITRON 規範的對稱多處理即時作業系統——TOPPERS/FMP。

CP 的韌體存放於 nflasha8(一個 24,531,968 bytes 的 FAT12 分區)內的 /CPAPP-B.BIN,檔案大小為 17,617,008 bytes(約 16.8 MB)。它是一個無檔頭的純二進位映像(flat binary),在開機時被直接載入至 CP 記憶體空間的基底位址 0x67800000。

CP 負責所有嚴格要求硬體即時性(hard real-time)的底層工作:控制 CMOS 感光元件讀出、驅動 BIONZ XR 硬體 ISP 管線、套用 gamma / tone 曲線與 5×9×9 3D HSV 色彩調整格,以及即時自動對焦(AF)、自動曝光(AE)與快門控制。

拆解 R5 v4.00 原廠 BODYDATA.DAT 與雙態 AES 加密層

要從零建置自製韌體,第一步是從 Sony 官方釋出的 R5 v4.00 更新檔 BODYDATA.DAT(754,525,608 bytes,SHA-256 為 51e1176a255a9a4b4782dfbcfa6c5f332b71173bdfe9ed2bcf4fca6bcbc6c2e2)解出乾淨的原廠分區與二進位基底。

外層 UFU 容器格式

BODYDATA.DAT 以 8-byte 魔術字元 \x89UFU\r\n\x1a\n 開頭(刻意仿照 PNG 檔頭的設計,可用於偵測 FTP / HTTP 文字模式傳輸造成的換行損毀),隨後是由 big-endian 的 (uint32 size, char kind[4]) 組成的五個 chunk:

  1. DATV (4 bytes):容器格式版本號(0x00010000)。
  2. PROV (4 bytes):provisioning 旗標。
  3. UDID (20 bytes):目標機種與更新識別碼。
  4. FDAT (754,525,456 bytes):加密後的韌體本體,由 736,841 個 1,024-byte 加密區塊(共 754,525,184 bytes)加上尾端 272 bytes trailer (16-byte AES-CBC IV + 256-byte RSA-2048 數位簽章) 組成。
  5. DEND (4 bytes):big-endian CRC32 校驗碼,涵蓋從檔案偏移 0 一直到 DEND chunk header 之前的所有位元組。

FDAT 的 1,024-Byte 區塊結構與雙態 AES 加密 (CXD90057_k8)

FDAT 內部的明文串流總長度為 751,577,088 bytes。在打包加密時,Sony 將明文每 1,020 bytes 切為一塊,並在前面加上 4 bytes 的標頭,組成固定 1,024 bytes 的區塊:

struct FdatBlock {
    uint16_t sum16;       // Little-endian: 後續 511 個 uint16_le words 的無號總和 (& 0xFFFF)
    uint16_t sizeAndEnd;  // 低 15 bits 為有效 payload 長度 (1..1020);
                          // Bit 15 (0x8000) 在最後一個區塊 (#736,841) 設為 1,其餘為 0
    uint8_t  payload[1020]; // 不足 1,020 bytes 處以 0xFF 填充
};

接著對這 736,841 個區塊(754,525,184 bytes)進行兩階段加密:

  • 前 512 bytes (Block 0 的前半部):使用 Sony 外層通用金鑰(constants.key_aes)以 AES-128-ECB 模式加密。
  • 剩餘所有位元組 (Offset 512 .. 754,525,184):使用 BIONZ XR 世代專屬金鑰(constants.key_cxd90057_k8)與存放在 signature_offset - 16 處的 16-byte IV,以 AES-CBC 模式加密。

當我們解開全部 736,841 個區塊、驗證每個區塊的 sum16 並剝除 4-byte 標頭後,重組出的 751,577,088 bytes 明文串流(original.fdat)包含三個緊密相連的區段:

  1. Bytes 0 .. 512 (FdatHeader):記錄機種代號 model = 0x11030080、region = 0、版本號 versionMajor = 4、versionMinor = 0x00(自製韌體將偏移 0x20..0x21 改為 BCD 0x34, 0x04 代表 v4.34)、fs.offset = 512、fs.size = 1,253,376、firmwareOffset = 1,253,888、firmwareSize = 750,323,200,以及位於偏移 8..12、涵蓋 bytes 12..512 的標頭 CRC32。
  2. Bytes 512 .. 1,253,888 (updater.img):大小 1,253,376 bytes 的 SquashFS 4.0 映像檔,內含更新流程腳本 /bin/startupdate.sh、/bin/endupdate.sh、/bodylib/libupdaterbody.so 以及 CP 更新韌體 /cp/udtrcp.bin。
  3. Bytes 1,253,888 .. 751,577,088 (original.tar):大小 750,323,200 bytes 的 POSIX tar 封裝檔,內含各個 NAND 分區映像與校驗檔。

我們從 BODYDATA.DAT 依序解出全部 9 個原廠輸入檔案,並與專案的 firmware/r5/inputs.lock.json 進行 SHA-256 比對,100% 吻合:

輸入鍵值 (Input Key) 檔案名稱 大小 (Bytes) SHA-256 雜湊值
original_dat original.DAT 754,525,608 51e1176a255a9a4b...bc6c2e2
original_fdat original.fdat 751,577,088 b3af8557d6279d8b...cfcb0ff
original_tar original.tar 750,323,200 8b03700edb87d293...6565813
updater updater.img 1,253,376 c4b18a82eea52a50...dcce4a9
stock_nflasha5 nflasha5 243,789,184 15020d1afe7f1cf4...1363e8
stock_nflasha8 nflasha8 24,531,968 a625468fbbd6b0e0...1f8f46
stock_nflasha15 nflasha15 410,652,672 197fc934db37ed9b...638fa5
stock_ap appFw.so 97,368,304 41bd8f4cda1f44c7...9ce7c7
stock_cp cpapp-b.bin 17,617,008 3269bf6242633979...7ee7c8

深入 AP 修補工程:appFw.so 的 40.6 KB Code Cave 與 17 處 Branch Hooks

AP 端的建置管線(apply_recipe + build_ap_image.py + build_ap_module.py)對 nflasha15 內的 /lib/appFw.so 執行了兩階段改造:

第一階段:基底 FL2 / FL3 配方修補 (ap_baseline)

首先套用 baseline_recipes.json 中的 42 處二進位修補,將 appFw.so 的檔案大小從原廠的 97,368,304 bytes 擴充至 97,452,032 bytes(在 ELF 可執行區段增加了 +83,728 bytes),打通原廠 Creative Look 表格中對 FL2 與 FL3 的基礎支援。

第二階段:C / ARM64 執行期模組注入 (build_ap_module.py)

接著將 3 個 C 語言原始碼檔案(r5_stage413_transfer_core.c、r5_filter_Menu.c、r5_filter_renderer423.c)與 2 個 ARM64 組語跳板檔(r5_stage413_trampoline.S、r5_filter_menu_trampoline.S)編譯為位置無關(PIC)的執行期 payload,並精準釘選(pin)在 appFw.so 的兩個 ELF 虛擬位址區段:

  • RX code cave (可執行程式碼區段):位於虛擬位址 vaddr = 0x03c97ef8(對應檔案偏移 file_offset = 0x03c97ef8),模組本身佔用 40,600 bytes(連同第一階段基底 cave 合計為 63,096 RX bytes)。
  • BSS state region (執行期狀態緩衝區):位於虛擬位址 vaddr = 0x05cf18c0,大小為 136,384 bytes(0x214c0 bytes),用於在 RAM 中存放從 SD 卡解析出來的 .BIN 濾鏡包、當前啟用的 6/32 字元外觀名稱,以及準備傳送給 CP 的 IPC 傳輸緩衝區。

為了讓原本 appFw.so 的生命週期與選單事件流進這塊 code cave,建置腳本在 appFw.so 內植入了 17 處 ARM64 B / BL call-site hooks:

Hook 名稱 檔案偏移量 (File Offset) 原廠指令 (Hex) 修補後跳轉指令 (Hex) 攔截時機與用途
startup 25,170,660 (0x18012a4) 60a26e39 0544da15 系統啟動時跳入初始化跳板
initial 55,422,568 (0x34daae8) 60c21091 69dd6615 Creative Look 子系統初始狀態建立
completed 55,464,256 (0x34e4dc0) 60c21091 fab46615 屬性載入完成後同步自訂濾鏡狀態
media 54,279,380 (0x33c38d4) a003168b 5b3a6b15 監控 SD 卡掛載狀態(/mnt/sd0、/mnt/sd1)
inbound 55,921,996 (0x35549cc) 20008052 86f66415 攔截並注入跨核心 IPC 濾鏡資料
power 56,306,296 (0x35b26f8) fd7bbba9 a57f6315 電源關閉或進入休眠前保存狀態
menu / UI hooks (×11) 多處 UI 分派點 原生選單指令 跳轉至 r5_filter_Menu 註冊 Fn 項目、繪製 6/32 字元名稱、查詢 v4.34 圖示 (icon_query434) 等 11 處選單攔截點

原地 Ext2 區塊整合 (integrate())

由於 nflasha15 是一整個 410,652,672 bytes 的 ext2 檔案系統映像,把變大後的 appFw.so(97,452,032 bytes)寫回 nflasha15 時不能隨便重新打包整個檔案系統(否則 inode 順序或時間戳記改變可能引發非預期行為)。

建置腳本透過 nflasha15_layout 先為 /lib/appFw.so 的 inode 擴充對應的資料區塊鏈(block chain),接著 integrate() 直接將新版 appFw.so 的內容覆寫進對應的 ext2 data blocks,並在最後執行嚴格的位元級斷言:確保 nflasha15 分區中除了 /lib/appFw.so 佔用的區塊與對應的 ext2 metadata 之外,其餘每一個 byte 都與原廠映像 100% 完全相同。

深入 CP 修補工程:cpapp-b.bin 表格重定位與 Unicorn 虛擬 CPU 模擬

看完了 AP 端,接著來看更刺激的 CP 端(nflasha8:/CPAPP-B.BIN,基底位址 0x67800000)。

為什麼必須重定位 12 組 Look 參數表?

在原廠 R5 v4.00 的 cpapp-b.bin 中,CP 將 10 組 Creative Look(ST..SE)的 ISP 參數存放在固定長度為 10 的連續陣列裡,主要包含三大結構:

  • s_kelvin 與 s_adobekelvin 色溫補償表(每組 0xDC bytes,10 × 0xDC = 2,200 bytes)。
  • tone / gamma / 色彩矩陣結構(每組 0x16B4 = 5,812 bytes)。
  • 5×9×9 3D HSV 色彩調整格:每組 184 bytes,由 5 個明度階層 luma levels × 9 個飽和度環 saturation rings × 9 個色相扇區 hue sectors 組成。

問題在於:在 cpapp-b.bin 的記憶體佈局中,這幾個 10-entry 陣列的屁股後面緊接著其他關鍵的 RTOS 全域變數!如果我們直接在原位址把陣列從 10 組硬撐到 12 組(+FL2, +FL3),多出來的 2 組資料就會直接蓋掉後面的 RTOS 全域變數,導致相機一開機就崩潰。

因此,建置管線(build_cp.py)採取了 完整重定位 (table relocation) 策略:

  1. 在 cpapp-b.bin 後段找到一塊乾淨的未使用記憶體區段 0x688df740(其中可執行區段位於 RX = 0x68995080,包含 7,308 bytes 的 C 執行期程式碼 cp_transfer.c / cp_stage413_probe.c,以及 522,152 bytes 的擴充表格與狀態空間)。
  2. 將擴充為 12 組的完整 look 參數表搬移至 0x688df740。
  3. 自動掃描整個 cpapp-b.bin,一舉修補了 167 處 ARM32 指令——包含將所有查表函式的 literal-pool 基底位址改指到 0x688df740 新表,以及將所有的陣列邊界檢查從 cmp rX, #10 修改為 cmp rX, #12。

刷機前的虛擬 CPU 守門員:Unicorn Engine (getter_gates.py)

一口氣修改了裸機 RTOS 裡的 167 處 ARM32 指令,要怎麼確定沒有任何一條指令改錯、或者不小心破壞了呼叫慣例(calling convention)?

這套專案設計了一道極為嚴謹的防線:src/gamma/getter_gates.py。在每次打包韌體前,它會使用 Unicorn Engine 建立一顆 32 位元 ARM 虛擬 CPU(UC_ARCH_ARM, UC_MODE_ARM),將 17.6 MB 的 cpapp-b.bin 實際映射載入至虛擬位址 0x67800000:

  1. 啟用硬體浮點與向量協同處理器 (VFP / NEON): 因為 CP 的色彩查表與插值函式內部使用了 ARM VFP 指令,模擬器在啟動時會先透過協同處理器暫存器啟用 CP10 與 CP11:
    mrc p15, 0, r0, c1, c0, 2
    orr r0, r0, #0x00f00000   ; 啟用 CP10 / CP11 (VFP/NEON 完全存取權限)
    mcr p15, 0, r0, c1, c0, 2
    mov r0, #0x40000000       ; EN bit in FPEXC
    vmsr fpexc, r0
  2. 遍歷模擬 3 種狀態 × 12 組 look 索引: 模擬器直接設定 PC 呼叫 cpapp-b.bin 內真實的 getter 函式(如 0x684b5148、0x684b5220、0x684b527c 等),針對 look 索引 1..12 依序測試三種執行期狀態:
    • 狀態 A:cold boot 未初始化預設狀態
    • 狀態 B:載入內建 FL2 / FL3 預設值狀態
    • 狀態 C:由 SD 卡 .BIN 濾鏡覆寫全部 12 組槽位狀態
  3. 位元級與暫存器完整性斷言:
    • 針對索引 1..10(原廠 10 種外觀),比對修補後韌體回傳的結構體每一個 byte,必須與未修改的原廠 cpapp-b.bin 100% 逐位元一致。
    • 針對索引 11..12(FL2、FL3),驗證回傳的指標與內容精準對應 0x688df740 重定位表中的資料。
    • 檢查函式返回時的 callee-saved 暫存器(r4..r11 與 sp),確保堆疊與暫存器零污染。
unicorn-emulation-gate.svg

Windows + LLVM Clang 23.1.4 本地建置調適與跨工具鏈比對

上游專案原本是在 macOS 環境下使用 Apple Clang 21(clang 21.1.x)開發,並在腳本中寫死了 /usr/bin/clang 與 /usr/bin/openssl 等路徑。當我們將建置管線移植到 Windows 11 並使用 LLVM Clang 23.1.4 與 uv(cpython-3.12.11)執行本地編譯時,解決了三個關鍵的跨平台與編譯器差異:

  1. Clang 23 新增的全域變數靜態分析警告: LLVM Clang 23 引入了更嚴格的 -Wunused-but-set-global 檢查,在開啟 -Werror 時會攔截 r5_filter_renderer423.c:23 中的診斷計數器 static uint32_t allocation_failures。我們在 build_ap_module.py 與 build_cp.py 的交叉編譯旗標中補上了 -Wno-unused-but-set-variable 與 -Wno-unused-but-set-global。
  2. 0701_part_image_sum/part_image.sum 的 454-Byte CRLF 陷阱: 在打包 original.tar 前,build.py 會重新計算 dev/nflasha5、dev/nflasha8 與 dev/nflasha15 的 CRC32 校驗碼並更新 part_image.sum。因為該建置管線採用原地覆寫 tar 成員的策略,part_image.sum 的檔案大小必須嚴格維持在 454 bytes。 在 Windows 上,Python 的 Path.write_text() 預設會將換行符號 \n 轉換為 \r\n(CRLF),導致這份 6 行校驗檔瞬間膨脹為 460 bytes 並觸發 tar 區塊大小 AssertionError!將寫入方式改為 sums.write_bytes(text.encode('ascii')) 後,便完美保持了 UNIX LF 的 454 bytes 結構。
  3. Apple Clang 21 與 LLVM Clang 23 的產出比對: 我們將 Windows + LLVM Clang 23.1.4 編譯出的成品與原作者 macOS + Apple Clang 21 的參考輸出進行了逐檔案比對:
    • 所有非編譯型資料產出(包含 nflasha5、12 個地區的 CX8920*.bin 出廠備份範本與 backup.sum)的 SHA-256 雜湊值 100% 相同,例如 nflasha5 皆為 eac727efd7af2142ddd9b8ae22387614eaaaba9dc9f04ce0434bf8e7c766d693。
    • 經過 C 編譯器產出的 appFw.so(97,452,032 bytes)與 cpapp-b.bin(17,617,008 bytes)不僅檔案大小完全一致,內部的 RX / BSS 區段尺寸(AP 端為 40,600 B / 136,384 B;CP 端為 7,308 B / 522,152 B)與 17 個 hook 跳板位址也分毫不差,僅在 C 函式內部有 Clang 21 與 Clang 23 之間的微幅指令排程與暫存器分配差異,且全數通過 Unicorn 模擬測試。

密碼學簽章驗證、Partition 1 (nflasha1) 信任鏈與 Android Fastboot

當我們修改完 nflasha5、nflasha8 與 nflasha15,並重新打包、以 CXD90057_k8 加密出 736,841 個 FDAT 區塊後,就來到了整個韌體工程最後、也最關鍵的一道安全防線:數位簽章驗證。

為什麼不能直接把改好的 BODYDATA.DAT 放進原廠相機更新?

在 BODYDATA.DAT 的 FDAT 區段最尾端(檔案偏移 754,525,332,緊接在 736,841 個加密區塊與 16-byte IV 之後),存放著一段 256 bytes 的 RSA-2048 數位簽章,採用 RSASSA-PKCS1-v1_5 填充與 SHA-256 雜湊,簽章範圍涵蓋了前面整整 754,525,200 bytes 的加密串流與 IV。

當你在原廠未改機的 R5 上透過 SD 卡執行韌體更新時,相機其實是重新開機進入 Partition 1 (nflasha1) 的更新環境:

  • nflasha1 是一個 25,100,288 bytes(約 23.9 MB)的 FAT12 復原分區,裡面放著 /boot/initrd.img(一個以 zlib 壓縮的 SquashFS 4.0 根檔案系統)以及記錄檔案 MD5 的 /p1chksum。
  • 在 /boot/initrd.img 內部,/etc/VerificationKey.pem(451 bytes,SHA-256 為 8281413b5607446939613e07d020b2c7d8ee2371349099de56bc8288f7be0a43)存放著 Sony 官方的 RSA-2048 公鑰。
  • 更嚴密的是,initrd.img 裡負責韌體更新的四個核心共享函式庫:
    • /usr/lib/libupdaterapi.so
    • /usr/lib/libsdfileinputupdaterapi.so
    • /usr/lib/libseninputupdaterapi.so
    • /usr/lib/libuudfileinputupdaterapi.so 在執行更新時會進行 雙層驗證:
    1. 第一層公鑰指紋驗證 (_ZN7Updater21SignatureVerification9verifyKeyEPKc):即 Updater::SignatureVerification::verifyKey(char const*),先計算 /etc/VerificationKey.pem 的指紋,檢查它是否等於寫死在 .so 裡的 Sony 官方公鑰雜湊!這意味著就算你只換掉 .pem 公鑰檔,第一層檢查也會直接報錯拒絕。
    2. 第二層密碼學簽章驗證 (_ZN7Updater21SignatureVerification15VerifySignatureEPKhjS2_j):使用 /etc/VerificationKey.pem 對 BODYDATA.DAT 的 FDAT 區段執行標準的 RSA-2048 PKCS#1 v1.5 SHA-256 數位簽章驗證。

一次性硬體引導:RP2040 UART 與隱藏在相機裡的 Android Fastboot v0.5

因為全世界只有 Sony 擁有對應的 RSA-2048 私鑰,任何想要透過 SD 卡刷入自簽章 BODYDATA.DAT 的相機,都必須先透過硬體除錯介面完成一次性的 Partition 1 (nflasha1) 公鑰替換。

fastboot-trust-chain.svg

這套硬體引導流程的底層機制最早由安全研究員 David Buchanan 在 DavidBuchanan314/ILCE-7M4-RE 中發掘,完整運作過程如下:

  1. 透過 Sony Multi-port UART 進入暫時性 SRAM Fastboot: 使用 Sony Multi-port 轉接板連接至一塊刷有 uart_boot 韌體的 RP2040(接線為 GPIO28 ➔ RX、GPIO29 ➔ TX、GPIO27 ➔ Reset,初始握手鮑率為非標準的 48000 baud),同時將相機 USB-C 接上電腦。在重置瞬間,主機透過 UART 將 Sony 官方簽章過的 BootROM SRAM loader(fastboot.bin)串流寫入相機內部 SRAM 位址 0x92028000。隨後 UART 切換至 115200 baud 並印出:

    [mira] fastboot ready
  2. Sony 的 mira loader 採用標準 Google Android Fastboot v0.5: 當 mira 啟動 USB 控制器後,電腦端看到的 USB 裝置識別碼竟然是 Google 官方的 Android Fastboot ID:

    • VID = 0x18d1 (Google Inc.)
    • PID = 0x4ee0 (Android Fastboot)
    • bInterfaceClass = 0xFF、bInterfaceSubClass = 0x42、bInterfaceProtocol = 0x03

    透過 USB bulk endpoint 發送 getvar:version 會直接回傳 OKAY0.5。它不僅完整支援標準 Android Fastboot 指令(getvar:partition-size:nflasha1、download:<hex_len>、upload、flash:nflasha1、reboot)與標準的 OKAY / FAIL / INFO / DATA%08x 封包協定,甚至原生支援 Android Sparse Image 格式(魔術字元 0xed26ff3a,v1.0,512-byte 磁區區塊,使用 0xCAC1 RAW chunk 寫入有變動的磁區、以 0xCAC3 DONT_CARE chunk 跳過未修改磁區)! 此外,Sony 工程師還在 mira 裡實作了三個專屬的 oem 擴充指令:

    • oem partition:列出內部 SDM2 eMMC 上全部 17 個分區(nflasha1 .. nflasha17)。
    • oem partition dump nflasha1(搭配 upload):將 Partition 1 讀取至 RAM 並透過 USB 完整傾印回電腦。
    • oem dumpbrom:傾印 CXD90057 的晶片內部 BootROM。

原地修補 nflasha1 的 8-Byte verifyKey() 與 Sparse Image 回寫

取得相機傾印出的 nflasha1 映像後,build_r5_partition1.py 與 build_r5_verifykey_skip.py 會在不改變分區與壓縮區塊幾何結構的前提下進行原地修補:

  1. 替換公鑰:將 initrd.img 內的 /etc/VerificationKey.pem(451 bytes)替換為使用者自己生成的 RSA-2048 public.pem(同樣為 451 bytes)。
  2. 修補 4 個 updater .so 的 verifyKey() 函式入口: 將 _ZN7Updater21SignatureVerification9verifyKeyEPKc 開頭的 8 bytes(兩條 ARM64 指令)替換掉:
    ; 修補前 (Before): fd 7b b2 a9  e0 03 01 aa
    stp x29, x30, [sp, #-0x50]!
    mov x0, x1
    
    ; 修補後 (After):  00 00 80 52  c0 03 5f d6
    mov w0, #0    ; 永遠回傳 0 (代表自訂公鑰指紋合法)
    ret
    這個 8-byte 修補讓 verifyKey() 認可我們放入的自訂 public.pem,同時 完整保留了 VerifySignature() 的所有密碼學檢查,因此相機在透過 SD 卡刷機時,依然會嚴格驗證 BODYDATA.DAT 的 RSA-2048 數位簽章。
  3. 等長重壓縮與 233 磁區 Sparse Image 刷寫: 腳本只重新壓縮 initrd.img 中受影響的 zlib SquashFS fragments,並精確控制壓縮參數使其與原始 fragment 的 byte 長度完全相同;接著更新 /p1chksum 裡的 initrd.img MD5,最後將 25.1 MB 的 nflasha1 中實際發生變動的 233 個 512-byte 磁區封裝成一個不到 125 KB 的 Android Sparse Image,透過 flash:nflasha1 瞬間寫回相機。

本地生成自簽章 BODYDATA.DAT 與 100% 解密驗證

在完成所有分析與工具鏈調適後,我們在本地生成了一組全新的 2048-bit RSA 金鑰對(e = 65537):

  • 私鑰 private.pem(1,679 bytes),用於簽署自製 BODYDATA.DAT。
  • 公鑰 public.pem / VerificationKey.pem(451 bytes,SHA-256 為 9b2ab38babd06c8aea18a4064ddc7bbd4dd9014ed14c16db369df8363fc229c8),用於注入 nflasha1。

接著我們執行完整的 --stage all 自動化建置與簽章管線:

uv run python firmware/r5/build.py --inputs firmware/r5/inputs.local.json --output D:/code/r5-build-all --stage all

整條管線依序完成了以下工作並產出完整驗證報告(D:\code\r5-build-all\verification.json):

  1. 編譯並整合雙處理器映像:產出修補後的 appFw.so、cpapp-b.bin、nflasha5(WBI2 ➔ wbi2)、nflasha8、nflasha15、part_image.sum(精確 454 bytes)以及 12 個地區的 CX8920*.bin 備份檔與 backup.sum,並全數通過 Unicorn Engine 虛擬 CPU 模擬閘門。
  2. 封裝明文 FDAT 串流:組裝出 751,577,088 bytes 的明文 FDAT 映像(SHA-256 為 a46c6bb0811064cec9dce08b47deee40116ea5d51f255faf26d8814684361635)。
  3. 雙態 AES 區塊加密與 RSA-2048 簽章:將明文切分為 736,841 個 1,024-byte 區塊(754,525,184 bytes),以 AES-ECB + AES-CBC(CXD90057_k8, iv) 加密,使用 private.pem 對加密內容與 IV 產生 256-byte RSASSA-PKCS1-v1_5 / SHA-256 簽章,最後計算最外層 DEND 的 CRC32,產出最終的 BODYDATA.DAT(754,525,608 bytes,SHA-256 為 b267a02ba29a7c8f84cbd38fa11413c903772f73d64ffd34e2a61129533cd0f6)。
  4. 雙向密碼學與解密完整性驗證:
    • 同時透過 PyCryptodome 與獨立的 openssl pkeyutl -verify 驗證 BODYDATA.DAT 尾端的 256-byte RSA-2048 簽章,確認回傳 Signature Verified Successfully,並測試竄改簽章後能被正確拒絕(tampered_signature_rejected: true、outer_crc_valid: true)。
    • 將產出的 BODYDATA.DAT 重新丟回解密器,獨立解開全部 736,841 個區塊,確認解出的每一顆 byte 都與打包前的明文分區 100% 吻合。

結語

回頭看這次對 Sony A7R V 第三方韌體 v4.34 的拆解與本地建置,最讓我著迷的不只是最終在相機上多出了 FL2、FL3 與 12 組 Fujifilm 軟片模擬,而是得以深入現代高階數位相機內部的系統工程設計:

  • 一顆 CXD90057 SoC 同時跑著四核 64 位元 SCEL Linux(負責 97.3 MB 的 /lib/appFw.so 單體應用)與 32 位元 Cortex-R5 TOPPERS/FMP RTOS(在 0x67800000 負責硬體即時 ISP 管線),並透過 nflasha5 的 WBI2 LZO 記憶體休眠快照達成 0.5 秒瞬間開機。
  • 面對高風險的雙架構二進位修改,透過原地 ext2 區塊替換、重定位 12 組 ISP 參數表至 0x688df740,並結合啟用 VFP/NEON 的 Unicorn Engine 虛擬 CPU 模擬閘門,將傳統嵌入式修改「刷上去才知道會不會磚」的不確定性,轉化為刷機前就能 100% 自動化驗證的工程管線。
  • 而在底層 BootROM 的 mira SRAM loader(0x92028000)中遇見熟悉的 Google Android Fastboot v0.5(0x18d1:0x4ee0)與 Sparse Image 協定,並只用 8 bytes(mov w0, #0; ret)優雅地將自己的 RSA-2048 金鑰對接入 nflasha1 信任鏈,更是跨領域逆向工程中那種「原來底層都是相通的」最大樂趣。

PHP2Shell

兩個 PHP 核心 CVE 的技術細節,以及給工程師與 AI agent 的防禦實務
featured.svg

這週的資安新聞裡,最讓我停下來多看兩眼的是 PHP2Shell:PHP 核心引擎同時被揭露兩個可以遠端執行程式碼(RCE)的記憶體安全漏洞(CVE-2026-109618 與 CVE-2026-109619),CVSS v4.0 評分為 9.2,並於 2026 年 10 月 26 日正式被列入 CISA KEV(已知遭利用漏洞)清單。

我自己早就不寫 PHP 了,但這個事件有兩件事跟我們每個人都有關:一是漏洞躲在「應用程式碼寫得再乾淨也沒用」的執行引擎層;二是 AI 輔助的漏洞研究與防禦在此次事件中展現了關鍵影響。這篇文章就來拆解它的技術細節,最後整理成人類工程師與 AI agent 各自適用的防禦清單。

 Note

本文依據 PHP Foundation、PHP 官方公告、CISA KEV、加拿大資安中心(AL26-026) 以及 Calif、Wiz、Akamai、AWS 等單位的技術揭露整理。文中的攻擊鏈屬於概括性描述,實際利用細節會依 SAPI 架構、建置編譯選項與記憶體佈局而異。

事件概觀

項目 CVE-2026-109618 CVE-2026-109619
類型 記憶體損壞(OOM 導致未完整初始化結構殘留) use-after-free(跨請求殘留的全域狀態)
CVSS v4.0 9.2 9.2
觸發條件 不需驗證,透過 HTTP 請求觸發 不需驗證,需同一 worker 處理後續請求
SAPI / 觸發路徑 mod_php 可在請求啟動失敗時觸發;FPM 需應用程式層執行路徑 需要同一 worker 存在殘留狀態的 producer 與存取該狀態的 consumer

註:據 Wiz 等單位分析,早至 PHP 5.4 起的多個版本受其中至少一項問題影響。

受影響的執行環境包含 Apache mod_php(風險最高)、PHP-FPM、php-cgi、FrankenPHP 與 LiteSpeed。

官方支援分支的修補版本為 8.5.12、8.4.27、8.3.36 與 8.2.35。針對已結束支援的 PHP 7.4、8.0 與 8.1,PHP 開發團隊本次提供了最小原始碼 backport 修補檔;各大 Linux 發行版與 LTS 維護商亦陸續發布對應的安全套件。

技術細節

問題的根源:zend_bailout 與 longjmp

PHP 對每個請求都有 memory_limit 這道資源配額限制。在受影響的舊版實作中,配額耗盡本身並不代表 heap 元資料已損壞,但 Zend Engine 會呼叫 zend_bailout() 中斷目前執行流程。

在實作上,Zend Engine 使用 zend_try 巨集保存當前的跳躍環境 EG(bailout),並在發生致命錯誤時由 _zend_bailout() 呼叫 C 語言的 longjmp,跳回目前生效的 zend_try 邊界。

longjmp 的特性在於它直接恢復 CPU 暫存器與 stack pointer,完全不執行沿途 C 函式的收尾程式碼:沒有物件解構、沒有 refcount 遞減,也不會把進行到一半的資料結構復原。更關鍵的是,跳回 bailout 邊界後,依 SAPI 架構與失敗發生的階段,後續仍可能進入請求收尾與釋放(request shutdown)流程。此時的清理程式碼仍然假設內部所有結構都是完整且合法的狀態,因而埋下記憶體損壞的隱患。

以下示意未修補版本中一種典型的 OOM 失敗路徑;後續是否執行 request shutdown 或保留 worker 行程,會依不同 SAPI 架構而異:

bailout-flow.svg

官方在修補時重構了錯誤處理架構:將一般 OOM 轉為在虛擬機安全點(VM safe point)處理的 soft OOM;若記憶體用量嚴重超出安全門檻,則直接以 _exit() 終止行程(hard OOM),避免在不一致狀態下繼續運作。

CVE-2026-109618:OOM 時的未完整初始化狀態

第一個漏洞發生在內部資料結構的建立過程中。例如在解析 multipart 請求與註冊欄位時,HashTable 的 bucket 計數可能已經遞增,或是結構已被標記為使用中,隨後的鍵值或緩衝區記憶體配置卻遭遇 OOM。

此時 zend_bailout() 的跳躍略過了錯誤恢復邏輯。隨後在 request shutdown 階段,引擎巡訪該資料結構時,看到標記為使用中或依賴計數走訪 bucket,便將未經正確初始化的殘留記憶體視為有效物件嘗試釋放或存取,進而引發記憶體損壞。

CVE-2026-109619:跨請求殘留的全域狀態

第二個漏洞涉及跨請求的生命週期管理。在 mod_php 或 PHP-FPM 這類長壽命 worker 架構中,單一行程會依序處理大量請求。

若前一個請求因 zend_bailout 提前中斷,或是 shutdown 階段在清理完全域狀態後又由非致命診斷邏輯寫入狀態,指向該請求內部記憶體的指標(如 session 內部結構或錯誤回溯指標)就可能殘留在全域變數中。

當請求結束、per-request 記憶體池被釋放後,該全域指標便成為懸空指標(dangling pointer)。一旦下一個由同一個 worker 處理的請求在重新初始化該狀態前讀取它,便會觸發典型的 use-after-free。若攻擊者能在下一個請求中控制記憶體佈局並佔用該釋放區塊,就有機會影響後續的間接呼叫。

攻擊面:session upload progress 與請求前解析

為什麼這組漏洞能不需應用程式漏洞就觸發?公開分析指出,關鍵在於 session.upload_progress.enabled 在許多預設設定中為開啟狀態。當請求帶有相符的表單欄位(如 PHP_SESSION_UPLOAD_PROGRESS)時,PHP 核心便會在解析 multipart/form-data 時啟動進度追蹤。

這項解析工作發生在應用程式的 PHP 程式碼開始執行之前。

然而,不同 SAPI 的利用條件存在關鍵差異:

  • 在 mod_php 架構下,請求啟動階段(request startup)若因 OOM 失敗,同行程仍會繼續執行 request shutdown,直接使用半初始化的結構,因此即使請求目標是空白 PHP 檔案或完全不處理上傳的端點,依然可能受到波及。
  • 在 PHP-FPM 架構下,若在 startup 階段發生錯誤,child worker 通常會直接結束;已驗證的 FPM 攻擊路徑通常需要請求已成功啟動、並在應用程式層的執行過程中耗盡記憶體。

此外,除了 session upload progress 之外,一般的多重欄位上傳在 HashTable 配置時若遭遇 OOM,亦存在類似的不完整狀態問題。

概念性的利用流程

依照公開的技術分析,一種概念性的利用流程大致如下(不同 SAPI 與目標環境的步驟有所差異):

  1. 透過特定記憶體洩漏或堆積排布探測行程位址,繞過 ASLR。
  2. 對目標伺服器發送精心構造的 multipart 請求(觸發 upload progress 或多欄位註冊)。
  3. 刻意使請求在特定資料結構尚未完成配置時超過 memory_limit,觸發 zend_bailout。
  4. 針對後續請求進行堆積噴灑(heap spraying),將受控資料填入剛被釋放的懸空結構。
  5. 影響內部指標存取或函式指標(例如 HashTable 的 pDestructor),達成控制流程劫持並執行任意指令。

要穩定完成整條攻擊鏈需要高度精準的記憶體對齊與 offset 計算。但亞馬遜威脅情報團隊在報告中指出,在公開揭露前便已在 MadPot 蜜罐系統中觀察到針對該漏洞的利用嘗試。

為什麼應用程式碼難以阻擋

傳統 web 安全著重於應用程式層面的「輸入驗證、參數化查詢、避免危險函式」。然而這兩個漏洞深植於 C 語言編寫的執行引擎內部。部分攻擊路徑可在應用程式碼執行之前觸發;其他路徑則依賴應用程式執行期間的狀態累積與錯誤觸發,但兩者都發生在 runtime 內部,無法單靠撰寫乾淨的業務邏輯來規避。

WAF 規則雖能過濾已知的特徵 payload,但難以完全覆蓋各種變形編碼與記憶體壓力觸發手法。最根本的防禦手段,只有更新 PHP 執行期本身。

用程式碼看懂 setjmp 與 longjmp

文字描述「跳過清理」很抽象,不如直接看最小的 C 語言範例。以下為解說用之核心邏輯節錄,旨在呈現與 PHP2Shell 根因相同的控制流問題。完整的可編譯原始碼(包含 basic.c、volatile.c、leak.c、uaf.c 與 fixed.c)已直接附於本文目錄中,讀者可透過 GCC 與 AddressSanitizer 直接編譯、重現與檢驗。

運作原理

setjmp 與 longjmp 是 C 標準函式庫提供的非區域跳躍(non-local jump)機制,能在函式呼叫鏈中直接跨層跳回先前的儲存點。

  • setjmp(env):將當前 CPU 暫存器(以 x86-64 為例,包含 callee-saved 暫存器、stack pointer 與 instruction pointer)保存至 jmp_buf。初次呼叫時回傳 0。
  • longjmp(env, val):還原這些暫存器,使控制流程直接回到 setjmp 的位置。此時 setjmp 再次回傳,回傳值為 val(若傳入 0 則回傳 1)。
  • 沿途跳過的 stack frames 不會執行任何清理邏輯。此外,它不會備份或還原 stack 與 heap 上的記憶體內容,亦不會撤銷任何已產生的副作用。
#include <setjmp.h>
#include <stdio.h>

static jmp_buf bailout_env;

static void deep(int depth) {
    if (depth == 3) {
        puts("  deep: fatal condition, jumping out");
        longjmp(bailout_env, 1);          /* never returns */
    }
    printf("  deep: depth=%d, entering\n", depth);
    deep(depth + 1);
    printf("  deep: depth=%d, cleanup runs\n", depth);   /* skipped! */
}

int main(void) {
    if (setjmp(bailout_env) == 0) {       /* first return: 0 */
        puts("run request");
        deep(0);
        puts("request finished normally");
    } else {                              /* returns again: 1 */
        puts("bailout: back at the top");
    }
    return 0;
}

執行結果:

run request
  deep: depth=0, entering
  deep: depth=1, entering
  deep: depth=2, entering
  deep: fatal condition, jumping out
bailout: back at the top

可以看到 cleanup runs 從未執行。這正對應了 zend_bailout() 的行為:遇到致命狀況跳回 bailout 邊界時,中間各層函式的收尾程式碼均被略過。

使用上的幾個陷阱

這組 API 存在數個與語言標準相關的未定義行為與平台細節:

區域變數的值不保證保留

依據 C11 標準(§7.13.2.1),在呼叫 setjmp 的函式內,若具有 automatic storage duration 的區域變數在 setjmp 與 longjmp 之間被修改,且未宣告為 volatile,則跳躍後其值為不確定值(indeterminate)。

此程式刻意讀取 indeterminate 的 automatic 變數,依 C11 標準(§7.13.2.1 與附錄 J.2)屬於未定義行為;以下在 GCC -O2 下的執行結果僅為特定編譯器與最佳化選項下的一次觀察,即使在同一工具鏈下亦不能依賴其一致性:

#include <setjmp.h>
#include <stdio.h>
int main(void) {
    jmp_buf env;
    int plain = 0;
    volatile int safe = 0;
    if (setjmp(env) == 0) {
        plain = 1; safe = 1;
        longjmp(env, 1);
    }
    printf("plain=%d  safe=%d\n", plain, safe);
    return 0;
}

在 GCC -O2 下執行結果:

plain=0  safe=1

呼叫 setjmp 的函式不能已經返回

若呼叫 setjmp 的函式已經 return,該 stack frame 便已消滅。此時對其 jmp_buf 呼叫 longjmp 為嚴重的未定義行為,極易導致 stack 損壞與崩潰。

C++ 非平凡 destructor 與未定義行為

依據 C++ 標準規範,若以 longjmp 跳過具有非平凡 destructor(non-trivial destructor)的 automatic 物件,而該物件原本在例外堆疊回溯(stack unwinding)時會被解構,則該跳躍具有未定義行為。RAII 機制在此完全無法提供安全保證。

signal mask 的保存

標準的 setjmp / longjmp 不保證保存與還原 signal mask。在 POSIX 環境中,若需在跳躍時保留信號遮罩,應改用 sigsetjmp(env, savemask)(savemask != 0)與 siglongjmp。

問題如何被引入:標記為使用中,卻還沒配置完

第一個典型錯誤對應 CVE-2026-109618 的「先標記、後配置」。此處示範的是狀態殘留(state leak),而非單純的記憶體洩漏:

typedef struct { int active; char *buf; } session_t;
static session_t *g_session;               /* process-wide, survives requests */

static void handle_request(const char *name) {
    g_session = tracked_alloc(sizeof *g_session);
    g_session->active = 1;                 /* (1) marked active FIRST */
    g_session->buf = NULL;
    g_session->buf = tracked_alloc(64);    /* (2) may longjmp away here */
    strncpy(g_session->buf, name, 63);
    g_session->buf[63] = '\0';
}

若 tracked_alloc 在配額用盡時觸發 longjmp,且恰好在第 (2) 行失敗:

[req-A] start
  bailout: request aborted
after request 0: g_session->active=1 buf=(nil)

active 旗標已被設為 1,但 buf 仍為空指標。後續若有任何清理或存取邏輯看到 active == 1 便認定 buf 有效,就會觸發解參考異常。

問題如何被引入:跨請求殘留的懸空指標

第二個錯誤對應 CVE-2026-109619。下方示範使用 per-request arena 的模型,請求結束時整塊釋放,但全域指標 g_upload 參照了 arena 內的物件:

typedef struct { char *mem; size_t used, cap; } arena_t;
static arena_t arena;                      /* per-request memory pool */
static jmp_buf bailout_env;

typedef struct { void (*on_close)(const char *); char tag[24]; } upload_t;
static upload_t *g_upload;                 /* process-wide, lives across requests */

static void *arena_alloc(size_t n) {
    /* Invariant: arena.used <= arena.cap */
    if (n > arena.cap - arena.used) longjmp(bailout_env, 1);   /* quota exceeded */
    void *p = arena.mem + arena.used;
    arena.used += n;
    return p;
}

static void begin_request(size_t limit) {
    if (g_upload)                          /* "flush the previous upload state" */
        g_upload->on_close(g_upload->tag); /* (3) trusts g_upload blindly */
    arena.mem = calloc(1, limit);
    if (!arena.mem) longjmp(bailout_env, 1);
    arena.cap = limit;
    arena.used = 0;
}

static void parse_upload(size_t payload) {
    g_upload = arena_alloc(sizeof *g_upload);     /* (1) allocated from the arena */
    g_upload->on_close = legit_close;
    strncpy(g_upload->tag, "progress", sizeof(g_upload->tag) - 1);
    g_upload->tag[sizeof(g_upload->tag) - 1] = '\0';
    arena_alloc(payload);                         /* OOM here => longjmp */
}

static void finish_request(void) {                /* normal path only */
    g_upload->on_close(g_upload->tag);
    g_upload = NULL;                              /* (2) reset ... only on this path */
}

static void run(const char *name, size_t limit, size_t payload) {
    if (setjmp(bailout_env) == 0) {
        begin_request(limit);
        parse_upload(payload);
        finish_request();
    } else {
        puts("  bailout: finish_request() skipped");
    }
    if (arena.mem) free(arena.mem);               /* request shutdown frees the arena */
    memset(&arena, 0, sizeof arena);
}

正常情況下,finish_request() 會將 g_upload 重設為 NULL。然而在 OOM 時,longjmp 跳過了 finish_request()。以三個連續請求模擬(正常請求、觸發 OOM 的請求、後續正常請求),使用同目錄下完整提供的 uaf.c 並以 AddressSanitizer 編譯執行:

gcc -g -O0 -fsanitize=address -o uaf uaf.c && ./uaf

輸出結果:

[request 1: normal]
  close(progress)
[request 2: attacker exceeds the limit]
  bailout: finish_request() skipped
[request 3: innocent victim]
=================================================================
ERROR: AddressSanitizer: heap-use-after-free on address 0x7546f95e0080
READ of size 8 at 0x7546f95e0080 thread T0
    #0 in begin_request uaf.c:25
    #1 in run uaf.c:48
    #2 in main uaf.c:65

其因果鏈展示了生命週期管理的失效:

  1. 請求 2 因 longjmp 提前中斷,g_upload 未被清空。
  2. 請求 2 的收尾釋放了 arena,g_upload 成為懸空指標。
  3. 請求 3 由同一個 worker 處理,begin_request 未經重新驗證即存取 g_upload,觸發 heap-use-after-free。

在真實攻擊中,若攻擊者能在釋放後、存取前透過受控配置填入惡意位址,該函式指標呼叫便可能演變為控制流程劫持。

修正:單一出口與最後才發布

修正的核心原則在於:改變架構,讓所有路徑(無論正常或異常)共用單一收尾邏輯,並將全域狀態的發布延後至所有易失敗操作完成之後:

static void end_request(void) {                   /* the single exit, ALL paths */
    g_upload = NULL;                              /* request-scoped globals reset here */
    if (arena.mem) free(arena.mem);
    memset(&arena, 0, sizeof arena);
}

static void parse_upload(size_t payload) {
    upload_t *u = arena_alloc(sizeof *u);         /* build a local first */
    u->on_close = legit_close;
    strncpy(u->tag, "progress", sizeof(u->tag) - 1);
    u->tag[sizeof(u->tag) - 1] = '\0';
    arena_alloc(payload);                         /* may bail out: g_upload untouched */
    g_upload = u;                                 /* publish LAST */
}

static void run(const char *name, size_t limit, size_t payload) {
    if (setjmp(bailout_env) == 0) {
        begin_request(limit);
        parse_upload(payload);
        g_upload->on_close(g_upload->tag);
    } else {
        puts("  bailout");
    }
    end_request();                                /* runs whether we bailed out or not */
}

使用同目錄下完整提供的 fixed.c,再次透過 AddressSanitizer 編譯驗證:

[request 1: normal]
  close(progress)
[request 2: attacker exceeds the limit]
  bailout
[request 3: innocent victim]
  close(progress)

測試乾淨通過無誤。這項範例具體落實了兩項原則:

  • 最後才發布:在區域指標 u 完成所有配置與初始化後,才賦值給全域變數 g_upload。若中途失敗,全域狀態不受污染。
  • 單一出口:無論正常執行或發生 bailout,end_request 均必定執行,確保跨請求狀態獲得一致清理。

這些原則在後面「如何避免在自己的程式裡重蹈覆轍」一節中亦會進一步推廣至通用架構。

發現的過程:AI 輔助研究與協調修補

此事件的發現歷程依據 PHP Foundation 與相關研究單位的時間線記錄如下:

  • 2026 年 8 月 11 日,Calif 透過 AI 輔助分析通報了相關 OOM 錯誤路徑問題。
  • 2026 年 8 月 24 日,Wiz 亦獨立發現了延伸的利用向量並通報 PHP 安全團隊。
  • 2024 年其實已有靜態稽核指出 PHP 在 OOM 處理時存在非典型邏輯,但當時並未證明其危害。現代 AI 工具的長處,正在於能串接分散的狀態錯誤並輔助推導出端到端的驗證鏈。
  • 10 月 20 日,相關漏洞資訊在公開管道提前流出,促使原訂 10 月 22 日發布的協調修補提前於 10 月 21 日正式公開。
  • 在公開發布前,AWS 的 MadPot 蜜罐系統即已監測到早期的利用嘗試。

值得注意的是,現有公開研究足以證明 AI 能有效協助資安研究員分析程式碼與建構測試 PoC,但目前尚無公開證據顯示野外攻擊者已普遍採用 AI 自動化發動入侵。不過可以預見的是,這類工具的普及可能進一步壓縮漏洞修補的時間窗口。

給人類工程師的防禦實務

第一優先:更新 PHP

最有效的防禦是直接升級 PHP 執行環境至修補版本:

  • PHP 8.5 請升至 8.5.12 以上
  • PHP 8.4 請升至 8.4.27 以上
  • PHP 8.3 請升至 8.3.36 以上
  • PHP 8.2 請升至 8.2.35 以上

對於使用容器環境的團隊,僅更新應用程式碼不會改變底層映像。必須重新拉取基礎映像(base image)、重新建置並重新部署。

此外,在主機或容器內執行 php -v 僅能確認 CLI 執行檔版本;必須針對實際提供對外服務的 SAPI(例如 PHP-FPM pool、Apache mod_php)確認其二進位版本、啟用之擴充套件與載入的 php.ini 設定,並重啟對應的服務行程。

針對使用 PHP 7.4、8.0 與 8.1 等已 EOL 版本的系統,若短期內無法升級主版本,應優先套用官方提供的 backport 原始碼修補檔,或升級由 Linux 發行版(如 Ubuntu Pro、Debian LTS)維護的回補套件。

暫時緩解措施

若無法立即完成版本更新,可透過設定縮小曝險面:

; 關閉 session upload progress(除非應用程式明確需要)
session.upload_progress.enabled = 0

; 關閉致命錯誤回溯(僅適用於 PHP 8.5 以上版本)
fatal_error_backtraces = 0

針對這兩項設定需注意以下要點:

  • 必須在 php.ini 或 SAPI pool 設定檔中配置並重啟 worker,確保在任何請求處理前即已生效;無法依賴應用程式執行期的 ini_set()。
  • fatal_error_backtraces 僅存在於 PHP 8.5 及更新版本,舊版無此設定項。
  • session.upload_progress.enabled = 0 僅關閉了其中一個常見入口,若應用程式接受其他大型 multipart 上傳,底層 HashTable 配置仍可能因記憶體耗盡而觸發類似問題。
  • 官方針對 CVE-2026-109618 的另一項緩解建議是將 PHP memory_limit 設為 -1。這能移除 PHP 配額耗盡這個可被精確計算的觸發點,但底層系統配置(如 mmap)若失敗仍可能觸發 bailout。此外,應以管理層設定強制套用並重啟 worker,確認沒有個別設定恢復有限額度;若轉由作業系統層級管控記憶體,應使用 cgroup 記憶體上限,避免使用 LimitAS= 或 LimitDATA=(對應 RLIMIT_AS / RLIMIT_DATA),因為後者會導致系統呼叫配置直接失敗,反而再次落入脆弱路徑。但需注意 cgroup 記憶體限制可能導致 worker,甚至同群組中的其他行程被系統強制終止,需評估請求中斷與服務可用性;這仍不能取代版本修補。

縱深防禦

除了執行環境修補,建議在基礎架構層落實以下防禦:

  • 縮短 worker 壽命:在 PHP-FPM 中將 pm.max_requests 設為 1,可切斷同一 child worker 接續處理下一個請求的利用路徑(但無法防禦單一請求內的狀態損壞,且會帶來顯著的行程建立開銷);一般情況下設定較小的數值,能有效縮短殘留狀態的存活窗口。
  • 淘汰 mod_php:避免使用與 Apache 共用行程空間的 mod_php,改採獨立行程架構的 PHP-FPM,並按站台配置隔離的 pool 與執行帳號。
  • 系統層級權限限縮:透過 seccomp 過濾系統呼叫、以 AppArmor 或 SELinux 限制存取路徑,並透過容器隔離命名空間,同時於網路層配置嚴格的 egress 規則。
  • 請求大小與結構限制:即使請求未宣告 Content-Length(如 chunked 傳輸),反向代理與伺服器端仍須限制實際接收與解碼後的請求總量,並針對 multipart 中的 part 數量、內部 header 長度、欄位總數與巢狀深度設定上限(例如 Nginx 的 client_max_body_size 僅能限制整體 body 大小,仍需搭配內部欄位數與解析限制)。
  • 異常指標監控:監控 Allowed memory size exhausted 錯誤頻率突增、worker 異常崩潰(segfault)次數,以及 PHP 行程是否有衍生非預期子行程(如 /bin/sh、curl)的行為。
  • 全面清點資產:清查內部所有正在運行的 PHP 服務,包含邊緣系統與內部管理工具,避免因遺漏舊版環境而產生防禦缺口。

給 AI agent 的防禦實務

在本次事件中,AI agent 展現了不同面向的應用,以下針對三種角色分別探討防禦要點:

維運與開發助手

當 agent 負責協助伺服器管理、相依性更新或部署作業時:

  • 要求驗證實際服務環境:指示 agent 檢視實際運作中的 SAPI 與設定輸出(如檢驗當前 FPM pool 或檢查套件修訂號),而非單純讀取靜態的 Dockerfile 或 composer.json 即判定修補完成。
  • 先提出評估再請求核准:要求 agent 於執行更新前列出所有受影響主機、容器名稱及當前版本,並在重大變更前維持人類確認機制。
  • 查證設定項版本相容性:避免 agent 盲目將新版設定(如 fatal_error_backtraces)套用至不支援的舊版本,應要求其以實際運作中的 SAPI 與 pool 設定確認有效性,CLI 輸出僅作為輔助參考。
  • 最小權限憑證原則:限制 agent 取得的 API token 與 SSH 金鑰權限範圍與存續時間,降低憑證外洩衝擊。
  • 將外部資安資訊視為不可信輸入:資安分析、PoC 腳本或技術討論可能隱含針對 agent 的 prompt injection。指派任務時應明訂「只分析、不執行」原則,禁止 agent 直接在非受控環境嘗試執行 PoC 程式碼。

程式碼審查者

當運用 AI 進行安全性原始碼審查時:

  • 聚焦錯誤處理與跨請求狀態:指示 agent 特別審視記憶體配置失敗路徑、bailout 或例外發生時的清理邏輯,以及全域或靜態變數在請求結束時是否被徹底重設。
  • 要求產出可重現的檢驗資產:要求 agent 針對疑點提供最小可重現測試、fault injection 腳本或 fuzzing harness,以客觀驗證取代純文字推論。
  • 隔離驗證環境:所有涉及潛在脆弱性驗證的動作,皆應於拋棄式沙箱環境內執行。
  • 人類專業複核:保持審查紀律,agent 的報告作為假設參考,由資深工程師確認問題真實性與修補方案。

防禦保護目標

若 AI agent 系統本身之後台、排程或外圍服務採用 PHP 建置,該服務若遭入侵亦可能波及整體 agent 平台。應落實網路分區與微隔離架構,將 agent 核心模型服務、憑證儲存庫與 web 前端徹底切隔,降低橫向移動的機會與影響範圍。

如何避免在自己的程式裡重蹈覆轍

從 PHP2Shell 的根本成因出發,若在開發自研 runtime、框架或長壽命服務,可借鏡以下工程原則。

設計層:不要讓提早離開破壞不變量

  • 非區域跳躍前維持狀態一致:若架構中使用 longjmp、例外或 panic,務必遵守「兩階段提交」原則。先在區域變數完成所有記憶體配置與欄位初始化,確認無失敗可能後,最後才發布至全域狀態或標記為有效。
  • 善用結構化資源管理:優先採用具語言層面保障的機制(如 C++ RAII 在作用域結束時解構、Rust 的 Drop、Go 在函式返回時執行的 defer)。若使用 Rust 需留意 panic=abort 不會進行 stack unwinding;使用 Go 則需注意 os.Exit 會略過 defer。
  • 區域配置器(arena)隔離:為單一請求配置專屬 arena,請求結束時整塊釋放。但必須嚴格禁止 request-owned 指標逸出至長壽命全域結構中;若確有暫存需要,必須在釋放 arena 前主動撤銷所有外部參照。
  • 明確界定全域狀態生命週期:嚴格列管所有全域與靜態變數,明確規範在請求異常終止時的重設機制,並納入常態審查清單。

驗證層:專門測試錯誤路徑

  • 針對配置器進行 fault injection:在測試套件中模擬記憶體配置失敗,遍歷各配置點,檢驗在提早中斷時系統狀態是否依然一致。
  • 針對協定解析器進行 fuzzing:對所有在身分驗證與核心業務邏輯前執行的協定解析器、上傳剖析器進行專項 fuzzing,並於嚴格的記憶體配額限制下測試邊界行為。
  • 整合 sanitizer 工具鏈:於 CI 環境常態啟用 AddressSanitizer(偵測 use-after-free、buffer overflow 與 double free)、UndefinedBehaviorSanitizer(偵測各類未定義行為)與 MemorySanitizer(追蹤未初始化記憶體讀取)。需注意 ASan 與 MSan 記憶體模型互斥,應拆分至不同的 CI 建置任務中執行。
  • 請求邊界狀態對比:在整合測試中,於每個請求結束後自動檢查全域狀態是否復歸初始值,及早攔截狀態殘留問題。

語言與架構層:縮小爆炸半徑

  • 高風險解析模組優先採用記憶體安全語言:處理不可信輸入的模組(如協定與上傳剖析)優先以 Rust 或 Go 開發,降低記憶體管理失誤機率。
  • 啟用二進位強化編譯旗標:全面開啟 PIE、full RELRO、stack canary、_FORTIFY_SOURCE 與控制流完整性(CFI)防護,提高漏洞利用門檻。
  • 權限分離架構:負責解析外部複雜輸入的處理程序應獨立為低權限行程,避免與敏感憑證或核心業務資料庫共用行程權限。

流程層:提升修補反應速度

面向 實務做法
資產清點 維護完整的軟體物料清單(SBOM),精準掌握各服務使用的 runtime 與其詳細修訂版本
部署演練 建立標準化的緊急升級作業流程,包含映像檔重建、逐步部署與快速回復舊版本機制
生命週期追蹤 定期檢視所有相依元件的 EOL 期限,及早規畫主版本升級路線
情資追蹤 訂閱 CISA KEV 與相關上游安全性公告,將已遭野外利用之威脅列為最高修補等級

把 AI 放進稽核流程,但維持驗證紀律

AI agent 可用來補足傳統靜態分析的盲點:

  • 專責審核例外路徑與狀態清理:讓 agent 聚焦於極端錯誤處理與資源回收流程,並要求提供可重現之測試程式碼。
  • 沙箱安全邊界:所有涉及安全性驗證之動作一律於隔離沙箱執行。
  • 提高稽核頻率:將 AI 輔助分析整合至重要變更與版本發布流程中,常態性掃描潛在狀態管理問題。

小結

PHP2Shell 這組漏洞給我們幾個實際的借鏡:

  • longjmp 這一類非區域跳躍機制本身很常見,但如果在跳躍時沒有把內部狀態復原或安全隔離,在長壽命 worker 運行環境中就容易破壞資料一致性,累積成高危漏洞。
  • 預設啟用的功能特性(如 session upload progress)往往會在不知不覺中擴大攻擊面,不用的功能主動關閉,始終是穩健的維運習慣。
  • 執行引擎層級的弱點無法透過應用程式碼完全防堵,保持執行環境與底層函式庫即時更新,永遠是資安防禦的第一線。
  • AI 工具普及加速了漏洞挖掘與利用鏈合成的節奏,防禦方也該建立自動化測試與嚴謹的驗證流程,讓安全修補能跑在潛在威脅之前。

若目前維運之環境中仍有運行 PHP 服務,建議立即核對實際提供服務之 runtime 版本與設定狀態,確認已落實修補或相應的緩解措施。


從原始碼編譯與客製化 Ioskeley Mono

解剖 Iosevka 參數化造字管線、Patel 幾何引擎與 Nerd Font 封裝實戰
featured.svg

在上一篇〈Berkeley Mono 與 Ioskeley Mono〉中,我們深入比較了 U.S. Graphics Company 的商業字體 Berkeley Mono (TX-02) 與開源社群基於 Iosevka 打造的 Ioskeley Mono,並透過線上互動實驗室對照了兩者在點狀 0、單層 g、平弧括號 () 以及 Normal / SemiCondensed 字寬上的細節表現。

然而,當我把 Ioskeley Mono 換成日常主力字體、在編輯器與 terminal 裡寫了一整天程式碼後,心裡突然冒出一個小小的念頭:如果我想讓數字 7 的斜桿正中央多加一道水平橫槓(crossbar),就像歐洲手寫體或老式工程圖紙上的 7 那樣,有沒有辦法自己動手改?

如果今天面對的是一般用 FontForge 或 Glyphs 手工繪製的傳統字體,這個念頭大概會在五分鐘內被打消——因為一個完整的現代程式字體家族包含 10 種字重(Thin 100 到 Black 900)、2 種字寬(Normal 與 SemiCondensed)以及正體與斜體,光是一個家族就有 40 個 .ttf 字型檔。手動去改 40 個檔案的貝茲曲線,不僅筆畫粗細與斜率難以對齊,還會破壞原有的 TrueType hinting 與連字表。

但 Ioskeley Mono 最迷人的地方就在這裡:它不是一套靜態的向量圖檔,而是一座由程式碼驅動的參數化造字工廠。 昨晚我在 herdr 的 ioskeley-mono workspace 裡,直接從零複製儲存庫、修改一行設定、一路追蹤到底層的 Patel (.ptl) 幾何語言,並寫腳本用 8 個平行 worker 打上完整的 Nerd Font 圖示補丁。這篇文章就來完整紀錄這趟從原始碼編譯與客製化字體的實戰過程,以及途中踩到的兩個精彩陷阱。

為什麼從原始碼編譯字體比想像中更有趣?

打開 ahatem/IoskeleyMono 的儲存庫會發現,它本身並不儲存成千上萬個字元的曲線座標,核心只有一份不到 400 行的 private-build-plans.toml 藍圖檔,搭配上游的 Iosevka 造字引擎(固定於 v34.4.0)。

當我們從原始碼建置時,整個流程其實是一條橫跨編譯器、計算幾何與二進位封裝的八階段管線:

ioskeley-build-pipeline.svg

藍圖結構:四個繼承自同一核心的字體家族

在 private-build-plans.toml 中,作者透過 Iosevka 的繼承機制(inherits = "buildPlans.IoskeleyMono")定義了四個編譯目標(build plans):

  1. 標準版 IoskeleyMono:設定 spacing = "normal"、serifs = "sans" 與 noCvSs = true,包含完整的程式碼連字規則(calt)、10 種字重、2 種字寬(Normal = 600、SemiCondensed = 540)與 11.8° 斜體。
  2. 終端機版 IoskeleyMonoTerm:繼承自標準版,唯一關鍵差異是設定 spacing = "term"。這會強制將所有箭頭、幾何方塊與 box-drawing 符號嚴格限制在單一 terminal 字元格(character cell)寬度內,避免在 Ghostty、Kitty 或 Alacritty 中破格。
  3. 無連字版 IoskeleyMonoNL:設定 noLigation = true,供無法單獨關閉連字的編輯器(如舊版 Xcode)使用。
  4. 網頁子集版 IoskeleyMonoWeb:透過 [buildPlans.IoskeleyMonoWeb.subset.include] 僅保留拉丁字母、標點、箭頭、數學運算子與框線符號的 Unicode 區間,將原本完整版每個約 480–550 KB 的 .woff2 透過字元集子集化(subsetting)大幅縮減至約 94 KB。

此外,所有家族都共享一組精心調校的度量參數(private-build-plans.toml 第 201 至 223 行):

[buildPlans.IoskeleyMono.metricOverride]
xHeight = 520               # 小寫字母高度
cap = 690                   # 大寫字母與數字高度 (CAP)
ascender = 740              # 上伸部高度
sb = 85                     # 左右邊距 (Sidebearing)

accentWidth = 182           # 變音符號寬度與間距參數
accentClearance = 76
accentHeight = 162
accentStackOffset = 208

leading = 1250              # 預設行距
parenSize = 860             # 括號垂直跨度
dotSize = 'blend(weight, [100, 110], [400, 125], [900, 150])'
periodSize = 140

essRatio = 1.03             # 微調 S / s 與問號中央轉折處的水平加寬比例
essRatioUpper = 1.03
essRatioLower = 1.03
essRatioQuestion = 1.03
archDepth = 152
smallArchDepth = 157

注意看 dotSize 那行:blend(weight, [100, 110], [400, 125], [900, 150]) 是一個隨字重動態插值的函數,確保在極細體(Thin 100)時標點與 i 的方點不會太小而消失,而在極粗體(Black 900)時又不會過度膨脹糊成一團。

本地端建置環境準備

要在 Linux 本地端從頭編譯 Ioskeley Mono,只需要準備 Node.js 20+(上游 package.json 最低需求為 >=16.0.0,官方 CI 採用 Node.js 20)與 ttfautohint(用於產生標準解析度螢幕所需的 TrueType hinting 指令):

# 1. 複製 Ioskeley Mono 儲存庫
git clone [email protected]:ahatem/IoskeleyMono.git ioskeley-mono
cd ioskeley-mono

# 2. 複製上游 Iosevka 引擎(鎖定 v34.4.0 確保變體名稱與度量完全一致)
git clone --depth 1 --branch v34.4.0 https://github.com/be5invis/Iosevka.git iosevka-src

# 3. 確認系統已安裝 ttfautohint 與 fontforge(後續打 Nerd Font 補丁需要)
sudo pacman -S --needed ttfautohint fontforge python-fonttools

為什麼一定要鎖定 Iosevka v34.4.0?因為上游 Iosevka 的 main 分支演進速度極快,有時會重新命名部分字形變體或調整內部幾何常數;固定在 v34.4.0 可以確保我們編譯出來的結果與官方發行版完全一致。

一行設定的魔法:為數字 7 加上水平橫槓

現在回到我們最初的目標:把數字 7 改成中間帶有水平橫槓的版本。

打開 private-build-plans.toml 的第 15 行,在 [buildPlans.IoskeleyMono.variants.design] 區塊中可以看到上游預設使用的是無襯線直桿 7:

[buildPlans.IoskeleyMono.variants.design]
one = "no-base"
two = "straight-neck-serifless"
three = "flat-top-serifless"
four = "semi-open-serifless"
five = "oblique-flat-serifless"
six = "open-contour"
seven = "straight-serifless"             # <-- 上游預設:無橫槓的俐落直桿 7
eight = "two-circles"
nine = "open-contour"
zero = "dotted"

在 Iosevka 的命名系統中,數字 7 的變體名稱是由三個正交維度組合而成的:[主幹形狀 body]-[左上角襯線 serif]-[中段橫槓 overlay]:

變體名稱 (seven = ...) 主幹形狀 (body) 左上角垂直短襯線 (serif) 中段水平橫槓 (crossbar) 視覺風格建議
straight-serifless(預設) 直線斜桿 無 無 極簡現代,貼近 TX-02 原生設定
straight-serifless-crossbar 直線斜桿 無 有 最推薦:保留幾何直桿,同時強化 7 與 1/T 的辨識度
straight-serifed-crossbar 直線斜桿 有 有 帶有濃厚打字機與古典印刷感
bend-serifless-crossbar 微彎弧形斜桿 無 有 偏柔和手寫風格,較不適合方正的 TX-02 家族

因為 IoskeleyMonoTerm、IoskeleyMonoNL 與 IoskeleyMonoWeb 全部都透過 inherits = "buildPlans.IoskeleyMono" 繼承字形設定,我們只需要改動 private-build-plans.toml 的第 15 行:

seven = "straight-serifless-crossbar"

接著把修改後的設定檔複製進 iosevka-src/,啟動 Verda 建置系統:

cp private-build-plans.toml iosevka-src/
cd iosevka-src
npm install

# 同時編譯標準版 (IDE 用) 與 Term 版 (終端機用)
npm run build -- contents::IoskeleyMono contents::IoskeleyMonoTerm

Verda 是 Iosevka 作者 Belleve Invis 專門開發的增量平行建置引擎(定義於 iosevka-src/verdafile.mjs)。當我們修改 private-build-plans.toml 並針對單一家族執行建置時,Verda 會自動解析相依圖、調動多核心 CPU 平行更新 614 個建置目標(終端機印出 Finished with 614 targets updated.),短短幾分鐘內就在 iosevka-src/dist/ 下產出全部 40 種樣式的 TTF/(Hinted)、TTF-Unhinted/、WOFF2/ 與 WOFF2-Unhinted/。

深入造字引擎:Patel (.ptl) 如何將一行字串編譯成 TrueType 輪廓?

改一行 TOML 就能讓 40 個字型檔同步長出比例完美的水平橫槓,這背後到底發生了什麼事?我在 ioskeley-mono workspace 中一路追進了 iosevka-src/packages/font-glyphs/src/number/7.ptl,發現 Iosevka 內部的幾何編譯管線設計得極為精妙。

讓我們結合實際從編譯後的 IoskeleyMono-Regular.ttf 抽取出來的 TrueType 頂點座標,一步步拆解 "straight-serifless-crossbar" 是如何變成字型檔裡的二進位曲線(下圖與後文的頂點追蹤圖皆直接擷取自編譯後 TTF 的真實 glyf 輪廓座標):

seven-anatomy.svg

Patel 語法編譯與 SuffixCfg.weave 變體編織

Iosevka 所有的字形都不是手畫的,而是用一種名為 Patel(.ptl)的領域特定語言(Lisp 方言,採用縮排語法)撰寫的程式。由於 Node.js 無法直接執行 .ptl,建置系統第一步會呼叫 patel-c 編譯器,將 7.ptl 轉譯為標準的 JavaScript ES Module(lib/number/7.mjs):

patel-c --strict --esm packages/font-glyphs/src/number/7.ptl -o packages/font-glyphs/lib/number/7.mjs

打開 packages/font-glyphs/src/number/7.ptl 的第 65 至 93 行,可以看到 SuffixCfg.weave 如何動態編織出所有合法的 7 變體組合:

local SevenConfig : SuffixCfg.weave
    object # body
        straight              StraightBody
        bend                  BendBody
        curly                 CurlyBody
    object # serif
        serifless             nothing
        serifed               StartSerif
    function [body] : object # crossbar
        ""                    nothing
        crossbar : match body
            [Just "straight"] Crossbar
            [Just "bend"]     BendCrossbar
            [Just "curly"]    CurlyCrossbar

foreach { suffix { Body Serif Crossbar } } [Object.entries SevenConfig] : do
    create-glyph "seven.lnum.\(suffix)" : glyph-proc
        include : MarkSet.capital
        if Body : include : Body CAP
        if Serif : include : Serif CAP
        if Crossbar : include : Crossbar CAP

    create-glyph "seven.onum.\(suffix)" : glyph-proc
        include : OnumMarks.p
        include : refer-glyph "seven.lnum.\(suffix)"
        include : ShiftDown

select-variant 'seven.lnum' [CodeLnum '7'] (follow -- 'seven')
select-variant 'seven.onum' [CodeOnum '7'] (follow -- 'seven')

當我們在 TOML 中指定 seven = "straight-serifless-crossbar" 時:

  • Body 對映到 StraightBody 幾何程序;
  • Serif 對映到 nothing(不畫左上角垂直襯線);
  • Crossbar 根據 body == "straight" 自動匹配到 Crossbar 程序;
  • 最後 select-variant 將產生的 seven.lnum.straight-serifless-crossbar 綁定到等高數字(Lining Figures)的 Unicode 碼位 U+0037 ('7')(同時也透過 seven.onum 衍生出供 OpenType onum 特徵使用的老式高低數字版本)。

參數化座標求值:StraightBody 與 Crossbar

當 Node.js 在編譯某個特定字重與字寬(例如 Regular 400,字寬 600、大寫高度 CAP = 690、筆畫粗細 Stroke = 73、左右邊距 SB = 85、右邊界 RightSB = 600 - 85 = 515)時,它會把這些度量常數代入 7.ptl 第 13 至 20 行與第 44 至 49 行的幾何公式中:

define SevenXLeft : mix SB RightSB 0.28
define [StraightBody top] : union [HBar.t SB RightSB top] : intersection
    Rect (top - Stroke) 0 SB RightSB
    dispiro
        widths.rhs
        corner RightSB (top - Stroke)
        corner [mix RightSB (SevenXLeft + [HSwToV : 0.75 * Stroke]) 2] [mix (top - Stroke) 0 2]

# ... (第 21–43 行定義 BendBody、CurlyBody 與 StartSerif)

define [Crossbar top] : glyph-proc
    local m : mix SevenXLeft (RightSB - [HSwToV HalfStroke]) 0.5
    include : HBar.m
        m - [HSwToV QuarterStroke] - LongJut * 0.8
        m + [HSwToV QuarterStroke] + LongJut * 0.7
        mix 0 (top - QuarterStroke) 0.5

這幾行 Patel 程式碼藏著字體設計師滿滿的視覺補償細節:

  1. 頂部橫樑 (HBar.t SB RightSB top):在 X = 85..515、Y = 617..690 畫出一道頂部切齊 CAP (690)、厚度為 Stroke (73) 的水平橫樑。
  2. 斜向主幹 (dispiro + Rect 交集裁切): 如果直接畫一條斜線段,斜線的上下兩端切口會垂直於斜線本身(呈現斜角突出)。因此 Iosevka 故意用 mix ... 2 把 dispiro 斜桿往外延伸兩倍長,再用一個 Rect (top - Stroke) 0 SB RightSB(即 Y = 0..617 的矩形遮罩)做布林交集(intersection)!這樣斜桿頂端就會完美貼合在 Y = 617(頂部橫樑下緣),底端則在 Y = 0 基線上切出俐落的水平平頭(X = 180..267)。
  3. 水平橫槓 (Crossbar) 的不對稱視覺平衡:
    • 垂直位置:不是單純取 CAP / 2 = 345,而是 mix 0 (top - QuarterStroke) 0.5 = 336(扣除頂部橫樑視覺重量後的幾何中心,落在 Y = 299..373)。
    • 水平位置 (m):計算斜桿在半高處的水平中心點 m。
    • 左右突出的視覺微調:注意橫槓往左延伸了 0.8 * LongJut(到 X = 158),往右卻只延伸 0.7 * LongJut(到 X = 501)!為什麼左邊比右邊多出 0.1 * LongJut?因為 7 的斜桿是從右上往左下傾斜,左上方的字懷空間比右下方開闊;左端稍微多探出一點,在人眼看來才會覺得橫槓「正好位在斜桿正中央」。

Spiro 螺旋曲線展開與 2D CSG 布林聯集 (typo-geom)

在傳統字體設計中,曲線是由三次貝茲曲線(cubic Bézier)的控制點手拉出來的;而在 Iosevka 中,曲線是由 Raph Levien(同時也是 Google Fonts Inconsolata 與 Zed GPUI 部分文字渲染的先驅)提出的 Spiro 螺線演算法(Euler spiral)計算而成,確保曲率達到 G2 甚至 G4 連續。

經過 spiro-pen-expand.mjs 將一維骨架向兩側展開成二維筆畫多邊形後,重頭戲交給了 typo-geom 幾何引擎:

  • 當我們把頂部橫樑、斜向主幹與水平橫槓透過 union 組合在一起時,三個零件在十字交會處會有內部的重疊邊線。
  • 如果直接把三個重疊的矩形寫進字型檔,在開啟半透明渲染、外框描邊或低解析度光柵化時,交界處會出現破洞(winding rule 衝突)或墨水過深。
  • typo-geom 會執行嚴謹的 2D 建構實體幾何布林聯集運算(CSG Union),自動找出所有交點(例如斜桿與橫槓相交的 (300, 299)、(329, 373)、(417, 373)、(388, 299) 四個內角),徹底剔除內部重疊線段,融合成單一封閉的 15 頂點外輪廓!

我們直接用 Python 的 fontTools 把編譯出來的 IoskeleyMono-Regular.ttf 拆開,印出 seven 字形的 SVG 路徑指令,就能親眼驗證這個結果:

M180 0 300 299H158V373H329L427 617H85V690H515V617L417 373H501V299H388L267 0Z
seven-csg-vertices.svg

順著上圖右半邊標出的 [1] 到 [15] 號頂點走一圈,可以看到 typo-geom 如何把原本三個重疊的幾何零件融合成一條順時針方向(TrueType 外輪廓規範)的一筆畫封閉路徑:從基線左下角 [1] (180, 0) 出發,沿斜桿左緣爬升到橫槓下緣交角 [2] (300, 299),往左折出橫槓左翼 [3]➔[4]➔[5],繼續沿斜桿爬升到頂樑下緣交角 [6] (427, 617),繞過頂部橫樑 [7]➔[8]➔[9]➔[10],再沿斜桿右緣一路折出橫槓右翼 [11]➔[12]➔[13]➔[14] 回到基線右下角 [15] (267, 0) 閉合。整顆字元沒有任何多餘的內部節點,而且當 Verda 編譯 Thin (100)、Bold (700)、Black (900) 或 SemiCondensed (90%) 時,這 15 個頂點都會根據各自的 Stroke 與 Width 參數自動重新計算。

二次貝茲曲線擬合 (Quadify) 與 OpenType 封裝 (ot-builder)

最後兩個底層步驟則是跨格式的幾何轉換:

  1. 三次轉二次貝茲曲線 (Quadification): Iosevka 內部的幾何運算全部採用三次樣條(degree-3 cubic curves),但 TrueType(.ttf)的 glyf 表規範只支援二次貝茲曲線(degree-2 quadratic Bézier,每段曲線只有一個 off-curve 控制點)。packages/geometry/src/quadify.mjs 會呼叫 TypoGeom.Quadify.auto(arc, 1, 8),將所有曲線自動細分並擬合為誤差小於 1 個字體設計單位(1/1000 em)的二次貝茲曲線。
  2. 二進位表格序列化 (ot-builder) 與 ttfautohint: 最後由 ot-builder 將輪廓點座標寫入 glyf 與 loca 表、將 Unicode 映射寫入 cmap 表、將連字與 zero 特徵寫入 GSUB 表,產出 TTF-Unhinted/,再交由 ttfautohint 注入對齊螢幕像素網格的 bytecode 指令產出 TTF/。

為自訂字體打上 Nerd Font 補丁:平行加速與兩個實戰陷阱

在 Linux 桌面環境(尤其是搭配 eza、lazygit、neovim、starship 與 herdr 的 terminal 工作流)中,只有純文字的 .ttf 是不夠的——我們還需要把 FontAwesome、Devicons、Octicons、Material Design Icons 與 Powerline 符號打包進去,做成 Nerd Font 版本。

平行化批次修補腳本 (xargs -P 8)

官方的 Nerd Fonts Patcher 是一個由 FontForge Python 介面驅動的腳本。如果用單執行緒 for 迴圈逐一修補 40 個字型檔,每個檔案約需 15 至 20 秒,跑完一個家族就得花上十幾分鐘。

實務上,我們可以直接下載 FontPatcher.zip(下方範例固定於 v3.5.1 確保可重現性),並利用 xargs -n 2 -P 8 啟動 8 個平行 worker 同時修補:

#!/usr/bin/env bash
set -e

# 1. 下載並解壓縮官方 Nerd Fonts Patcher (v3.5.1)
if [ ! -d "nerd-fonts-patcher" ]; then
    curl -fLO https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/FontPatcher.zip
    unzip -q FontPatcher.zip -d nerd-fonts-patcher
fi

DIST_TERM="iosevka-src/dist/IoskeleyMonoTerm/TTF"
PATCHER="nerd-fonts-patcher/font-patcher"
mkdir -p patched-fonts-term/Normal patched-fonts-term/SemiCondensed

patch_font() {
    local font="$1"
    local outdir="$2"
    fontforge -script "$PATCHER" "$font" \
        --complete --careful --mono --outputdir "$outdir" >/dev/null 2>&1
    echo "Finished $(basename "$font")"
}
export -f patch_font
export PATCHER

# 2. 建立任務佇列(區分 SemiCondensed 與 Normal)
for font in "$DIST_TERM"/IoskeleyMonoTerm-SemiCondensed*.ttf; do
    echo "$font" "patched-fonts-term/SemiCondensed"
done > /tmp/fonts-term-to-patch.txt

for font in "$DIST_TERM"/IoskeleyMonoTerm-*.ttf; do
    [[ "$font" == *Condensed* ]] && continue
    echo "$font" "patched-fonts-term/Normal"
done >> /tmp/fonts-term-to-patch.txt

# 3. 使用 8 個平行 worker 批次修補 40 個字型檔
cat /tmp/fonts-term-to-patch.txt | xargs -n 2 -P 8 bash -c 'patch_font "$@"' _

這裡傳給 font-patcher 的三個參數至關重要:

  • --complete:注入所有開發者圖示集(FontAwesome、Devicons、Octicons、Codicons、Powerline 等)。
  • --careful:若圖示碼位與 Ioskeley Mono 原生已有的字元重疊,保留原生字元而不覆蓋。
  • --mono:將所有注入的圖示嚴格縮放至單一等寬字元格(1-cell),產出家族名稱帶有 Nerd Font Mono 後綴的字型,確保在 terminal 裡圖示不會溢位蓋住後面的空白或文字。

FontForge 清除 post.isFixedPitch 等寬旗標的副作用

當 font-patcher 透過 FontForge 重新匯出 .ttf 字型檔時,有一個歷史悠久的經典陷阱:FontForge 有時會將 TrueType post 表中的 isFixedPitch 欄位重設為 0。

一旦 post.isFixedPitch 不等於 1,部分 terminal 模擬器或作業系統的字體選擇器就會誤以為這是一套「比例字體(proportional font)」,進而拒絕在終端機字體清單中顯示它,或是導致字元間距計算異常。

因此在 font-patcher 跑完之後,務必補上一段幾行就能跑完的 Python fontTools 修復腳本,把所有 .ttf 的 isFixedPitch 強制改回 1:

from pathlib import Path
from fontTools.ttLib import TTFont

fixed = 0
for f in sorted(Path("patched-fonts-term").rglob("*.ttf")):
    font = TTFont(f)
    if "post" in font and font["post"].isFixedPitch != 1:
        font["post"].isFixedPitch = 1
        font.save(f)
        fixed += 1

print(f"Restored isFixedPitch on {fixed} fonts")

為何重刷 fc-cache 後終端機裡的 7 依然沒有橫槓?

這是我昨晚在 ioskeley-mono workspace 裡遇到最有趣的一個實戰插曲。

當我改完 seven = "straight-serifless-crossbar"、編譯完 contents::IoskeleyMono、跑完 40 個檔案的 Nerd Font 修補、複製進 ~/.local/share/fonts/,並執行 fc-cache -fv 重新載入後——我滿懷期待地在 terminal 裡打出 1234567890,結果畫面上的 7 依然是光禿禿的一根直桿,完全沒有水平橫槓!

第一時間我還以為是 terminal 沒重讀字體快取(在 Kitty 可以按 Ctrl + Shift + F5 熱重載,Ghostty 可按 Ctrl + Shift + , 重載設定,Alacritty 則會自動監聽設定檔變更,VS Code 可執行 Developer: Reload Window)。但就算重開整個 terminal 視窗,7 還是沒變。

一查之下才揪出真正的兇手:

  1. IDE 與 Terminal 使用不同的編譯目標 (IoskeleyMono vs. IoskeleyMonoTerm): 檢查我的 ~/.config/ghostty/config、~/.config/kitty/kitty.conf、~/.config/alacritty/alacritty.toml、~/.config/foot/foot.ini 與 ~/.config/fontconfig/fonts.conf 後發現,為了讓 terminal 的格線完美對齊,我所有的終端機設定檔指定的都是 IoskeleyMonoTerm Nerd Font Mono(也就是 Term 家族),而不是標準版的 IoskeleyMono Nerd Font Mono。
  2. ~/.local/share/fonts/ 裡殘留了舊版的 Term 字型檔: 因為我第一輪只編譯並安裝了標準版 contents::IoskeleyMono,而 ~/.local/share/fonts/IoskeleyMono/ 資料夾底下還躺著先前安裝的舊版 IoskeleyMonoTermNerdFontMono-*.ttf!於是當 Fontconfig 解析 IoskeleyMonoTerm Nerd Font Mono 時,毫無懸念地繼續指向那批還沒加上橫槓的舊檔案。

找出原因後,解法就非常俐落了:

  1. 補跑 npm run build -- contents::IoskeleyMonoTerm 編譯帶橫槓 7 的 Term 家族。
  2. 執行 patch-term-fonts.sh 將 40 個 IoskeleyMonoTerm 字型檔打上 Nerd Font 補丁並修復 isFixedPitch = 1。
  3. 清理 ~/.local/share/fonts/ 下舊的散落檔案,統一安裝至 ~/.local/share/fonts/IoskeleyMono/ 與 ~/.local/share/fonts/IoskeleyMonoNerdFont/。
  4. 重刷快取並用 fc-match 驗證最終解析路徑:
# 重新建立 Fontconfig 快取
fc-cache -fv

# 驗證 Fontconfig 實際匹配到的字型檔路徑
fc-match -v "IoskeleyMonoTerm Nerd Font Mono" | grep -E "file:|family:"

當看到輸出明確指向剛出爐的 ~/.local/share/fonts/IoskeleyMonoNerdFont/IoskeleyMonoTermNerdFontMono-Regular.ttf,再重新載入終端機,整個畫面裡的 7 瞬間長出了那道俐落的水平橫槓!

反思與取捨:為什麼在 Ghostty 環境下,我最終移除了 Nerd Font 補丁?

跑通上面整套 FontForge 平行打補丁與 isFixedPitch 修復流程後,雖然成功得到了自訂的 IoskeleyMonoTerm Nerd Font Mono,但在 Ghostty 終端機裡實際使用了一陣子後,我卻決定回過頭下達另一個指令:把所有 Nerd Font 補丁徹底移除,只安裝純淨未修補的原版 Ioskeley Mono 與 Ioskeley Mono Term。

為什麼費工夫打完補丁後,反而選擇回歸純淨字型?關鍵在於當今像 Ghostty(以及 WezTerm、Kitty)這類現代終端機的字型渲染架構已經與幾年前截然不同。把 Nerd Font 圖示直接「焊死」進主字型檔裡,實務上會帶來四個明顯的副作用:

屏蔽了 Ghostty 原生內建的智慧圖示縮放引擎

許多人可能沒注意到,Ghostty 出廠就已經原生內建了完整的 Symbols Nerd Font 作為預設後備字型(fallback),不需要使用者額外安裝任何打過補丁的字體。更重要的是,Ghostty 在渲染引擎底層實作了極為細膩的動態字元格縮放邏輯(constraint-based glyph scaling):

  • 當 Ghostty 在你的主字型(例如純淨的 Ioskeley Mono Term)中找不到某個 Private Use Area (PUA) 圖示碼位時,它會自動切換到內建的 Nerd Font 符號表,並動態檢查該圖示右側是否緊鄰空白字元。
  • 在 eza -l、lazygit、yazi 檔案樹或 starship 提示字元中,圖示後面通常都會跟著一個空白( )。此時 Ghostty 會允許圖示突破單一窄格的限制,以更飽滿、清晰且符合原始長寬比的尺寸渲染。
  • 反之,如果你安裝並指定了預先打上 --mono 補丁的 IoskeleyMonoTerm Nerd Font Mono,因為主字型檔本身已經佔據了所有 PUA 碼位,Ghostty 根本不會觸發內建的 fallback 與動態縮放引擎,而是直接畫出字型檔裡被 FontForge 硬生生壓扁塞進單一窄格(Width = 600 或 540)的微型圖示——導致資料夾、Git 分支與語言圖示縮得像郵票一樣小,白白浪費了 Ghostty 原生圖示渲染器的優勢。

FontForge 二次轉檔對 Hinting 與 OpenType 表格的破壞

Iosevka 編譯器透過 ot-builder 與 ttfautohint 產出的原版字型,在 OpenType 表格結構與像素對齊指令上極為嚴謹,並明確區分給標準螢幕用的 TTF/(Hinted)與給高解析度 HiDPI 螢幕用的 TTF-Unhinted/。

然而,一旦把編譯好的 .ttf 丟進 FontForge 執行 font-patcher,FontForge 會將整顆字型反序列化後再重新匯出。這個過程不僅會如前文所述清掉 post.isFixedPitch 旗標,還會改寫 name 表與原有的 hinting 結構(這也是為什麼官方釋出的 Nerd Font 壓縮包無法提供獨立的 Hinted 與 Unhinted 版本)。不經過 FontForge 二次加工,才能保留 Iosevka 100% 原汁原味的曲線與 hinting 品質。

40 倍的圖示重複塞入與 88 MB 體積膨脹

一套完整的 Nerd Fonts 圖示集包含超過一萬個向量圖形。由於圖示本身並沒有「細體 Light」或「粗斜體 Bold Italic」的差別,將圖示直接打進字型檔,等於在 10 種字重 × 2 種字寬 × 正斜體共 40 個 .ttf 檔案裡重複複製了一萬個完全相同的圖示!

這使得單一家族的字型檔從原本輕巧的十幾 MB 暴增到 88 MB,而且每次修改一行 private-build-plans.toml 重新編譯時,都得額外花上好幾分鐘讓 8 個 FontForge 處理程序把 CPU 燒滿。

更現代的解耦配置:純淨字體 + 終端機內建或單一符號 Fallback

把 Nerd Font 補丁移除、重新執行乾淨的 Verda 建置後:

# 直接編譯純淨的四大家族,不經過 FontForge 二次加工
cd iosevka-src
npm run build -- contents::IoskeleyMono contents::IoskeleyMonoTerm contents::IoskeleyMonoNL contents::IoskeleyMonoWeb

在 Ghostty(~/.config/ghostty/config)中,只需要直接指定純淨的 Ioskeley Mono Term,圖示就會自動由 Ghostty 內建的 Nerd Font 引擎接手渲染:

font-family = "Ioskeley Mono Term"
font-style = Regular
font-size = 11

而如果同時有使用 Alacritty、Foot 或 Waybar 等沒有內建圖示的工具,也完全不需要回頭打補丁——只要在 Linux 系統中安裝單一一個僅約 2.5 MB 的獨立符號字型套件(例如 Arch Linux 官方套件庫的 ttf-nerd-fonts-symbols-mono),讓 Fontconfig 在遇到 PUA 圖示碼位時自動 fallback 到該符號字型即可。這樣一來,文字排版由最純淨的 Ioskeley Mono Term 負責,開發者圖示由終端機內建引擎或單一符號字型負責,職責徹底解耦,建置速度與視覺品質都達到了最理想的狀態。

結語:把字體當作可編譯的基礎設施

從單純「下載別人打包好的 .zip 字型檔」,跨出一步到「自己擁有字體的建置管線」,帶來的掌控感非常奇妙。

透過 Iosevka 的 Patel 參數化引擎與 private-build-plans.toml,字體不再是一顆無法修改的黑盒子:不管你是想幫數字 7 加上水平橫槓、把預設的點狀 0 換成斜線 0、微調行距 leading,還是針對 Ghostty 的渲染特性選擇最純淨的 Ioskeley Mono Term 建置方案,都只需要改幾行設定檔,就能在幾分鐘內編譯出一套橫跨 10 種字重、2 種字寬的專屬字體家族。如果你也在使用 Ioskeley Mono,強烈推薦把儲存庫 clone 下來親手編譯一次,打造真正百分百貼合自己視覺習慣的開發者字體!