vatt'ghern jaskier's ballads

Thomas Ptacek 說 SAML「it really is mostly straightforward to understand, but it's built on a foundation of sand, bone dust, and ash」。本文想追問的是:驗證簽章的程式碼,跟產生使用者身份的程式碼,是不是真的在看同一份文件。

SAML 的簽章為什麼簽不住

收到一封 SSO 登入請求,對接收端(SP)來說,劇本通常長這樣:使用者被導去 IdP,輸入密碼,IdP 把一份 XML 文件送回來,裡面有一個或多個 Assertion,宣稱「這個人是誰」。這份文件附了一段 XML Signature,SP 的第一個動作是驗證這段簽章——簽章對得上,SP 才願意信任裡面寫的身份。

在往下讀之前,先把「驗證簽章」這件事的地基攤開。數位簽章要成立,通常需要兩個條件同時滿足:內容從簽署那一刻起沒有被更動過,而且簽的人確實是他自己。SP 收到一份 SAML Assertion,驗證器回報「簽章正確」,理論上代表這兩個條件都成立。這兩個條件只對「被驗證的那段位元組」成立——應用程式邏輯最後讀出結論的,如果不是驗證器實際檢查過的那段位元組,這兩個條件就悄悄失效,而驗證器完全不會發現,因為它檢查的那份確實沒被動過手腳。

SAML(Security Assertion Markup Language)是 OASIS(Organization for the Advancement of Structured Information Standards)2002 年制定的一種 XML 安全宣告標記語言:「SAML is an XML-based markup language for security assertions」。它是好幾家廠商的提案拼起來的:「the following intellectual property was contributed to the SSTC: Security Services Markup Language (S2ML) from Netegrity; AuthXML from Securant; XML Trust Assertion Service Specification (X-TASS) from VeriSign; Information Technology Markup Language (ITML) from Jamcracker」,四家廠商的提案合併進 OASIS 的委員會,拼出了 SAML——規格書的體積,從一開始就是好幾份提案疊出來的,不是單一團隊從頭精簡設計的結果。差不多同一段時間,JSON 也剛「discovered」:「JSON was 'discovered' in 2001, but this was right around the time the SAML committee was meeting」,但委員會選的是 XML,一種標籤、屬性、命名空間、schema、CDATA、DOCTYPE 一應俱全的格式,複雜度跟 JSON 的 key、value、object、list 不是同一個量級,而 SAML 的簽章驗證,恰好建立在這整套複雜度之上。

為什麼簽好的 XML 也能被動手腳——envelope 與 canonicalization 的縫

問題出在簽章跟資料放在一起的方式。現在常見的 JWT,簽章是 detached(分離的)——JSON payload 跟簽章分開放,中間用一個句點分隔,拿到 JWT,payload 是什麼、簽章是什麼一望即知,兩者互不干擾。SAML 的做法不一樣:Signature 元素直接插進(enveloped,內嵌)它要簽的 Assertion 元素裡面:「the Signature element is inserted ('enveloped') in the Assertion element」,簽章本身變成了被簽資料的一部分。

(游標停在兩個詞上可以看到定義)SAML 用的是enveloped signatureSignature 元素直接插進它要簽的 Assertion 元素裡面,簽章本身變成被簽資料的一部分(相對於 JWT 用句點分開的 detached signature)。,要驗證這段簽章,得先靠canonicalization(C14N)把 XML 轉成一個雙方都同意的一致位元組表示;SP 跟 IdP 對「等價」的判斷只要有落差,位元組就對不齊,簽章就驗不過。把 XML 轉成一致的位元組表示,這兩個機制中間的縫,正是下面要講的攻擊類別長出來的地方。

這個設計立刻帶出一個技術難題:要驗證簽章,你得先把 XML 轉成一個雙方都同意的、一致的位元組表示——這個轉換過程叫 canonicalization。原文講得很直接:「if the SP and IdP cannot agree on a consistent representation of the XML data, then the bytes won't line up, the signatures won't match, and your authentication fails」。因為簽章內嵌在被簽的資料裡,任何要修改這份資料的操作(包括驗證前的正規化)都得同時維持位元組級別的一致:「it is very difficult to get a byte-for-byte, canonically equivalent representation of the data when you're also modifying it!」。

換句話說,就算兩份 XML 在瀏覽器裡看起來一模一樣——同樣的標籤、同樣的屬性、同樣的內容——只要底層的位元組排列方式有落差,簽章驗證就可能失敗,或者,更麻煩地,驗證的根本不是你以為的那個版本。

canonicalization 到底保證了什麼?下面用動畫示範它的邊界:兩份寫法不同但邏輯相同的 XML,會被磨平成同一份 canonical 表示法;一旦兩者出現真正的內容差異,canonicalization 不會、也不該把它們當成一樣。

按按鈕切換成有實質差異的內容,看 canonicalization 還收不收斂 · 2 種情境

寫法 A <Assertion ID="a1"> <Subject>alice</Subject> </Assertion> 寫法 B <Assertion ID="a1" > <Subject>alice</Subject> </Assertion> canonical 表示法 A、B → 同一份 canonical 表示法 簽章驗證通過:兩份寫法不同,canonical 表示法相同
預設畫面已經是 canonicalization 成功收斂的情況——寫法 A、B 只差在屬性附近的空白;按按鈕切換成有實質差異的內容,兩邊就不再收斂成同一份 canonical 表示法,簽章也就對不上。

canonicalization 本身做的事,就是上面這樣——磨平無意義的寫法差異,抓出有意義的內容差異。它不負責回答另一個問題:如果同一份文件裡「看起來像 Assertion」的元素不只一個,誰說了算?這個問題留到後面,用另一個小工具實際操作一次。

Ptacek 的說法是,SAML 的簽章驗證要成立,得先假設「XML signature validation is reliable」;但現實是「XML signature validation is deeply cursed」——原文給的原因是「is so complicated that most fielded SAML implementations are wrapping libxmlsec, a gnarly C codebase nobody reads」。

這句話背後是一個工程現實:canonicalization 要「猜對」的是每一個實作 XML parser 的人對邊界情況做出的細節決定,規則本身寫得再精確,也管不到每一個實作者的選擇。只要兩邊看到的規則版本有一絲差異,簽章就可能對不上;更危險的情況是,看起來對得上,但驗證的其實不是原本以為的那段內容。

XML Signature Wrapping:讓簽章通過、讓內容說謊

這套 envelope 加 canonicalization 的組合,養出了 SAML 最著名的攻擊類別——XML Signature Wrapping(XSW):「XML signature wrapping (XSW) attacks are the proverbial arrow to SAML's heel」,是這個協定最脆弱的那一點。原文對 XSW 的機制沒有進一步展開;本文對 wrapping(包裝)這個名字的讀法是:攻擊者不需要破解密碼學本身,只需要重新安排 XML 文件的結構,讓簽章驗證邏輯認得出一份有效簽名,同時讓應用程式實際拿去產生身份判斷的,變成另一段內容。

按這個讀法,把想法縮到最小:一份 XML 文件可以同時裝著兩件事——一份被簽章證明過的內容,跟另一份沒被簽、但長得夠像的內容。只要驗證邏輯認的是前者、消費邏輯讀的是後者,攻擊者就贏了,而且贏得乾淨:沒有任何一行密碼學被破解,簽章驗證的每一步都誠實回報「正確」。

這也是這類漏洞特別難根除的原因:防守方得確保簽章驗證邏輯跟應用邏輯,在所有情況下都認同「哪個元素是那個 Assertion」;攻擊者只要找到一種情況讓兩者不同意,就夠了。

2012 年的論文『On Breaking SAML: Be Whoever You Want to Be』把 XSW 攻擊「tested theory against practice and resulted in an automated way to check for XSW attacks」——從個案變成一套自動化檢測方法論。2018 年,Kelby Ludwig 用同一類 canonicalization bug 做出一次 XML comment bypass;原文只點名了這個漏洞,沒有進一步說明機制細節。

這兩起事件,本文的讀法是同一個模式的兩次示範:簽章驗證認可的那個元素,跟應用邏輯後續讀取的那個元素,不保證是同一個。這是本文從 XSW 這個名字與 2012、2018 兩起事件推出的解讀,不是原文逐句主張的內容。

原文在講 canonicalization bug 時提到,這類問題常常是 parser differential 或「round-trip」bug 的前兆,而這正是大多數現代 SAML 攻擊使用的手法:「Canonicalization is often a precursor to parser differential and/or 'round-trip' bugs, which are what most modern SAML attacks use」。2025 年同一年,就列了四篇這類研究:libxml2 解析怪癖繞過 GitHub Enterprise 的案例、「Sign in as anyone」,以及同屬 PortSwigger Research 的「SAML roulette: the hacker always wins」與「The Fragile Lock: Novel Bypasses For SAML Authentication」——後兩篇出自同一家研究單位。原文把 XSW 持續存在的說法明確連到最後這一篇:「Despite being front and center in this 2012 paper, XSW is still present today」,連結的正是「The Fragile Lock」。

canonicalization/parser 這類問題會反覆出現——上面提到的 2012、2018、2025,中間隔的是一連串陸續被公開的新變種。下面這條時間軸,把這三個時間點放在一起看。

2012

「On Breaking SAML: Be Whoever You Want to Be」——把 XSW 攻擊測成一套自動化檢測方法。

2018

Kelby Ludwig 利用 canonicalization bug 做出 XML comment bypass。

2025

同一年四篇研究(其中兩篇同屬 PortSwigger Research):libxml2/GitHub Enterprise 繞過、parser differential 繞過、「SAML roulette」、「The Fragile Lock」。

每個節點左側的 since 標記只出現在 2012 年之後:距離 XSW 方法論確立過了幾年,canonicalization/parser 這類問題還在長出新的繞過。

被驗過的元素,不一定是被讀出的元素

XSW 落到實際的 Response 上長什麼樣子?下面這個小工具用三種情境示範同一份文件:驗證器(負責檢查 Signature)跟應用程式(負責讀 NameID、產生登入身份)各自看到什麼。拖動情境,看兩邊的判斷什麼時候還一致、什麼時候已經分岔。

拖動看三種情境下驗證器與應用程式各自讀到誰 · 3 種情境

情境 0:正常
驗證器(檢查 SIGNATURE) 簽章驗證:PASS 驗的元素:Assertion a1 應用程式(讀 NAMEID) 讀到的元素:Assertion a1 NameID:alice@example.com 兩邊一致:alice@example.com 驗證器認可的元素,跟應用程式實際讀取的元素是同一個。
情境 0 是乾淨的 Response;情境 1、2 分別對應本文提到的 wrapping 手法與 2018 年 XML comment bypass 的類別——具體結構是示意,用來把「驗的元素」跟「讀的元素」可能不同這件事畫出來。

情境 1 對應的就是典型的 wrapping 手法——文件裡確實存在一份簽過名、簽章驗證得過的 Assertion,但那不是應用程式邏輯實際讀出來產生身份的那一份。驗證器沒有說謊,它如實回報「簽章正確」;問題出在「簽章正確的是哪個元素」跟「應用程式讀的是哪個元素」,本來就不保證是同一份。情境 2 則是同一個落差的另一種來源:canonicalization 判斷「這份 XML 等不等價」的規則,跟解析內容的程式碼判斷「這段內容是什麼」的規則,只要兩者不完全一致,就會出現類似的落差——原文對 2018 年那次 comment bypass 只給了結論,沒有交代規則衝突的具體細節,這裡畫的是同一類落差的示意,不是那次漏洞本身的還原。

這種分裂會發生,某種程度上是實作上的自然結果:驗證簽章的程式碼,跟從 Assertion 取出 NameID/屬性給應用邏輯用的程式碼,在很多 SAML 函式庫裡並不是同一段——一個管密碼學,一個管資料存取。兩段各自對「這份文件裡哪個元素才是那個 Assertion」做了自己的判斷,只要兩個判斷用的規則不完全一致,縫就出現了。這一段是本文對 XSW 成因的合理推測,不是原文逐句主張的內容——原文本身聚焦在 envelope 與 canonicalization 這兩個機制本身,沒有明講函式庫內部驗證與解析是否共用同一份 parser。

下面這段是示意用的簡化結構,幫助把「Signature 內嵌在 Assertion 裡」這件事具體化——不是文章裡引用的原始碼,只是把上面描述的結構寫成看得懂的樣子:

<Response>
  <Assertion ID="a1">         // 原本簽過名的 Assertion
    <Signature>
      <SignedInfo>...</SignedInfo>
      <SignatureValue>...</SignatureValue>
    </Signature>
    <Subject>NameID: alice@example.com</Subject>
  </Assertion>
</Response>

// wrapping 之後:多一份未簽章的 Assertion,
// 插到應用程式邏輯實際會先讀到的位置
<Response>
  <Assertion ID="a2">         // 應用程式讀這份
    <Subject>NameID: attacker@evil.com</Subject>
  </Assertion>
  <Assertion ID="a1">         // 驗證器驗的是這份,簽章依然有效
    <Signature>...</Signature>
    <Subject>NameID: alice@example.com</Subject>
  </Assertion>
</Response>

拖動上面的滑桿再看一次:情境 0 到情境 1,唯一變的是「應用程式讀到的是哪個元素」;驗證器那一欄的判斷,三種情境都一樣是 PASS。

換掉它,或者先把能接受的形狀收窄

對照組是 OIDC。原文給的差異很具體:「OIDC assumes HTTP, whereas SAML is transport independent」。原文對這件事的判斷是:「SAML came about at a time when HTTP + TLS was not yet the dominant backbone of web service communication, and it has never reconciled with this modern landscape」——SAML 誕生在 HTTP+TLS 還沒成為網路服務主流骨幹的年代,而且原文說它至今沒有跟這個現代環境和解過。原文接著點名了另一個沒跟上的地方:「SAML failed to anticipate the mobile, SPA, and IoT revolutions, and had no answers when these technologies arrived on the scene in the late aughts」——SAML 沒預料到行動裝置、SPA、IoT 這些技術,等它們真的出現時,SAML 拿不出因應辦法。這個對比不只在傳輸層:OIDC 用的是 JWT,走本文一開始提到的 detached signature,簽章跟內容分開放——本文的推論是,這也代表 canonicalization 這一步直接不需要存在(原文沒有明講這點)。更關鍵的是演化模式:OIDC 從 2014 年發布 1.0 規格之後,靠一串漸進的 RFC 持續長大——PKCE(RFC 7636,2015 年)、行動與原生應用版本的 PKCE(RFC 8252,2017 年)、給 IoT 裝置用的 device authorization grant(RFC 8628,2019 年)、給 MFA 流程用的 DPoP(RFC 9449,2023 年)。原文對這個對比的說法是:「OIDC grew organically over the years, whereas SAML was largely designed up front」,而且「SAML was designed in a different era for a different time and has not received necessary updates」。

這個差異,本文的讀法是會落在日常維運上:OIDC 的 token debug 起來相對直觀;SAML 的 canonicalization 規則不好懂,判斷簽章驗證失敗是攻擊還是單純格式落差,門檻更高——這是本文的推論,原文沒有討論兩者的除錯體驗。

Schwager 給了一個具體的量化說法:「Any given SAML authentication you would encounter in the wild today probably avoids 90% of the specification」——按這個說法,單一一次 SAML 驗證,可能有九成的規格內容根本用不到。換一個角度說,「99% of modern SAML implementations use a very similar data shape and subset of the specification」,幾乎所有現代 SAML 實作,用的都是同一個很窄的資料形狀跟子集。原文對這兩個數字的結論是:「This adds significant complexity for largely unused features」——規格書裡大量用不到的功能,加的是複雜度,不是簡化了什麼。本文從這兩個數字延伸出的另一個讀法是:真正會出現在你日誌裡的 SAML 訊息,長相可能高度收斂——這也是為什麼後面 Ptacek 那條「檢查訊息形狀」的建議可行:如果單次驗證可能有九成規格用不到,額外檢查訊息像不像那一成常見形狀,付出的成本可能遠比重寫整套 XML 解析邏輯低。

就算單次驗證可能避開九成規格,也不代表「不需要正確處理那九成」——一套符合規格的 SAML 函式庫,還是得把整份規格都實作出來,因為攻擊者能刻意去用你以為沒人會用的那一小角。

文章給的具體建議分成兩邊。如果你是正在把 SSO 整合進新系統的 SP,原文的說法很直接:「Just support OIDC. Abandon SAML.」作者引用 Thomas Ptacek(2024)的說法佐證:「We've managed to hold the line on OIDC so far. So has Tailscale.」——原文把這段話放在「Fly.io and Tailscale are already doing it」之後,所以這裡的「我們」指的是 Fly.io,不是本文原作者自己的公司。Ptacek 也提醒:「Remember, as a vendor, you're often competing with companies that don't do real SSO integration at all」——很多要接 SAML 的場景,對手廠商本來就沒做真正的 SSO 整合。如果你是 IdP,該做的是排一個階段性淘汰計畫:「develop a deprecation plan, communicate it to customers, stop onboarding new customers to SAML integrations, provide existing SAML customers with equivalent OIDC configurations, set a sunset date」——公開排定的時程,本身就是講給還在觀望的客戶聽的訊號。

Ptacek 原本講的情境是新接 SAML 的人,不是既有系統的維運:「If I was adding SAML support to something new, I'd consider beyond all the standard SAML checks also rejecting any message that doesn't have the same shape as what Okta, Onelogin, Google, or Shib generates」。本文的延伸讀法是:這個檢查邏輯,對已經在維運既有 SAML SP、沒辦法立刻整個換掉的團隊,同樣值得考慮——但原文本身沒有把這條建議用在既有系統的維運上。額外檢查訊息長得像不像 Okta、Onelogin、Google、Shibboleth 這幾家實際會產生的形狀,做法上是把攻擊者能操作的空間,壓縮到跟你信任的那幾家 IdP 實際會產生的形狀一樣窄。形狀檢查本身不會告訴你驗證邏輯跟應用邏輯是不是讀了同一個元素,但合理的推測是,想確認這件事,可以去看你用的 SAML 函式庫:驗證簽章的函式,跟取出 NameID 的函式,讀的是不是同一次 XML 解析出來的同一個節點——如果不是,兩者就有可能對「哪個才是 Assertion」做出不同判斷。

把三種身份各自這週能動的第一步,攤開放在一起看——起點不同,但都不用等到整個系統換血才能開始。

三種身份的具體下一步(依據原文的建議與引語)
對象 這週能做的事 依據
SP(新導入 SSO) 直接支援 OIDC,不碰 SAML 「Just support OIDC. Abandon SAML.」
SP(既有、走不開——本文延伸) 額外檢查訊息形狀像不像 Okta、Onelogin、Google、Shibboleth Ptacek 原話針對「新接 SAML」:「...rejecting any message that doesn't have the same shape as what Okta, Onelogin, Google, or Shib generates」
IdP 排定 sunset date、停收新客戶、給既有客戶對應 OIDC 設定 「develop a deprecation plan...set a sunset date」
三條路徑,起點不同:換協定、緊縮清單、排時程——都不需要重寫整套 XML 簽章驗證邏輯才能開始,也都繞著本文讀出的同一個問題轉:驗的元素,是不是讀的元素。

文章最後的評價是:「SAML had a good 25 year run」,這句話承認它二十多年的貢獻,同時判斷這個階段性任務已經完成,該交棒了。

這也是為什麼文章的建議分成兩層:一層是結構性的——換成 OIDC,整個 envelope/canonicalization 問題就不存在;另一層是戰術性的——形狀檢查、staged deprecation,都是在真正換掉之前,先把暴露的縫收小。

把這篇文章放進更長的時間軸看,故事的骨架其實很簡單:XSW 十三年前就有了名字跟自動化檢測方法,而它牽動的 canonicalization/parser 問題,靠著 XML 生態系裡持續冒出的新 parser、新函式庫,一次次換了皮膚重新出現。

Take-away:本文從 XSW 攻擊類別讀出的結論是——SAML 的簽章能不能保護你,要看驗證簽章的那段程式碼,跟真正拿去產生身份的那段程式碼,讀的是不是同一個元素;SAML 的規格本身,沒有明講這個保證存不存在。