今天在 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 在密碼過長時回傳的錯誤替代值 * 完全一致。
以下是該漏洞觸發流程的結構圖:
跨越 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 實作中,選用的特殊字串為 "*"。
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$ 開頭的完整雜湊,絕不可能與只有單一字元的 * 吻合。
兩項各自合理的假設在此處出現分歧:
- musl 認為錯誤時回傳
*不會造成威脅,因為如果 salt 本身以*開頭,它會改傳"x";但未預料到 OpenSSH 傳入的 salt 其實是借來的$6$...。 - 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)的觸發需要多項條件同時成立:
- 底層為 musl libc:例如 Alpine Linux、OpenWrt、Buildroot、Yocto、postmarketOS(現由 Nura 社群維護)、Void Linux(musl 版本)。使用 glibc 的發行版不受影響。
- OpenSSH 未啟用 PAM:Alpine Linux 預設安裝的
openssh-server套件未包含 PAM 支援,或在設定中指定了UsePAM no。 - 開啟了密碼認證:
sshd_config中啟用了PasswordAuthentication yes(OpenSSH 預設值)。 - 存在密碼欄位為
*的帳號:例如在設定了PermitRootLogin yes的環境中的 root 帳號,或是僅允許金鑰登入的部署帳號、具備doas權限的雲端預設帳號(如alpine)。 - 系統中存在可供借用的 SHA-crypt salt:
pick_salt()能夠在系統中搜尋到,並實際選中且快取來自$5$或$6$雜湊的 salt。 - 輸入密碼長度介於 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.shOpenSSH 的修補方式
依據 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;
}
修補重點包含兩項:
- 長度門檻檢查:在比對雜湊之前,明確要求 shadow 儲存的雜湊長度必須大於等於 4(
MIN_PASSWORD_HASH_LEN)。若帳號欄位僅為*或!,比對直接判定為失敗。 - 複本記憶體清除:
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 工具在深入挖掘系統交互缺陷時的具體價值。