Technical Article

提升 Delphi PDF 簽章器防禦惡意 PKCS#12 的安全性

當您簽署 PDF 時,通常會將簽章金鑰視為您所控制的內容;它存在於您產生的 .pfx 檔案中,並受到您選擇的密碼保護;讀取該檔案的程式碼感覺像是管道,而不是邊界;當憑證不再屬於您時,這種直覺就是錯誤的;允許使用者選擇任何 .pfx 的桌面工具、接受上傳憑證的伺服器、透過網路接收憑證的批次簽章器,都會在產生單個簽章位元組之前,將受攻擊者影響的位元組交給解析器;PKCS#12 讀取器就是一個攻擊層面,就像影像解碼器或字型載入器一樣

本文將引導您了解該讀取器中存在的兩個真實缺陷,兩者都位於匯入簽署憑證的路徑中;它們都不可怕;兩者都源於相同的根本原因,這幾乎影響了使用固定寬度整數之語言編寫的每個二進位解析器:來自檔案的長度或計數被過度信任;其中一個會導致越界讀取,另一個會導致程序掛起直到您將其強制關閉

位元組的傳輸路徑

匯入 .pfx 以簽署檔案並非單一操作,而是一個簡短的管線,且每個階段都會解析攻擊者可能寫入的內容;容器是 RFC 7292 中定義的 PKCS#12 結構,是一組包裝在加密護罩中的 AuthenticatedSafe 袋子,其中保存著私鑰;讀取它意味著遍歷 ASN.1、從密碼衍生金鑰、進行解密,然後將復原的 RSA 金鑰交給建立簽章的程式碼

在 HotPDF 中,這些階段對應到不同的單元;PKCS#12 容器邏輯存在於 HPDFPFX 中;它觸及的每個標記(Tag)、長度和值都由 HPDFASN1 中的 ASN.1 讀取器進行解碼;金鑰衍生和 PBES2 解密與 PBKDF2HMACSHA256 一起置於 HPDFCrypt 中;當金鑰復原後,HPDFRSAHPDFCMS 中的 CMS SignedData 建立器會將其轉換為內嵌在 PDF 中的分離簽章;驅動整個鏈結的公用進入點是單個呼叫

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

在進行任何密碼學操作之前, signer.pfx 的每個位元組都會流經 HPDFASN1HPDFPFX;如果這兩個單元對檔案所宣告的內容不夠小心,下游的密碼學就根本沒有發揮作用的機會

缺陷一:越過防護的 ASN.1 長度環繞

DER 和 BER 中的 ASN.1 將每個元素編碼為一個標記、一個長度和該長度的內容位元組;長度是您必須信任但進行驗證的欄位,因為它告訴解析器要讀取多遠,且它是由製作檔案的人寫入的;X.690 §8.1.3 定義了兩種編碼;簡短形式將 0 到 127 的長度打包到單個位元組中;用於任何更大內容的長格式會花費一個前導位元組(其低 7 位元給出後續長度位元組的計數),然後由該數量的大端(Big-endian)位元組攜帶實際值;因此,四個長度位元組可以宣告接近 4 GB 的內容大小

在解碼此類值後,解析器必須在信任它之前檢查內容是否確實符合緩衝區;最直覺的檢查是確認目前位置加上內容長度不會超出資料的末尾;用顯而易見的方式編寫(位置、內容長度和總量全部保存在 32 位元有符號整數中),該防護就會失效:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

問題在於加法,而不是比較;當 ContentLen 接近 MaxInt (2147483647) 時,Pos + ContentLen 會溢位有符號的 32 位元範圍並環繞為負數;負數和永遠不會大於 Total,因此防護會回報一切正常,並讓解析器繼續處理緩衝區中並不包含的約 2 GB 內容長度;接下來發生的就是破壞:讀取器為該宣告的長度配置一個緩衝區並進行複製(在 SetLength 之後使用 Move 從來源讀取);來源僅剩幾百個位元組,因此複製會讀取遠超出輸入末尾的內容,這種越界讀取最壞的情況會導致崩潰,而最好情況也會將相鄰的程序記憶體洩漏到解析中

唯一正確的防護是在比較之前拓寬中間和,使加法不會溢位其運算的型態;修正方法是將兩個運算元都提升為 Int64

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64 無損地保存了兩個 32 位元值的和,因此比較會看到真實的數字並拒絕偽造的長度;對 ContentLen 的獨立非負檢查關閉了對應的情況(即解碼值本身為負數);在 HotPDF 中,此防護存在於 HPDFASN1ParseNode 中,該函數會產生所有其他輔助工具建置其上的節點;因為 HPDFASN1Content 直接根據節點的內容長度調整其 SetLengthMove 的大小,通過不良防護的節點將會毒害從中進行的每次讀取;在解碼點修正邊界是使上游輔助工具安全的原因

缺陷二:被用作武器的 PBKDF2 反覆運算次數

第二個缺陷不是記憶體錯誤,而是檔案告訴您的 CPU 要工作得多賣力;PKCS#12 使用 PBES2(來自 PKCS#5 且在 RFC 8018 中指定的基於密碼的配置)保護其金鑰資料;PBES2 執行金鑰衍生函數(此處為帶有 HMAC-SHA-256 的 PBKDF2),然後執行密碼(此處為 AES-256-CBC);PBKDF2 接受反覆運算次數,而該次數是檔案中攜帶的參數;它的唯一目的是變慢:更多的反覆運算意味著每次密碼猜測的成本更高,這有利於防範離線攻擊者;RFC 8018 §4.2 明確指出較大的計數對安全性更好,並且刻意不設定上限

當您產生該檔案時,這種開放性是沒有問題的;但當攻擊者產生該檔案時,它就是一種武器;反覆運算次數是受攻擊者控制的工作因子,而受攻擊者控制的工作因子是演算法複雜度阻斷服務;偽造的 .pfx 可以將反覆運算次數編碼為數十億;解析器忠實地讀取它,並為該次數呼叫 PBKDF2 輪數的 HMAC-SHA-256,程序就會陷入一個針對單個提供檔案在數分鐘或數小時內都不會返回的迴圈中;在每請求處理一個憑證的簽章伺服器上,單次精心設計的上傳就會使工作執行緒陷入停頓

在使 CPU 旋轉之前,計數會使環繞變得更糟;反覆運算值作為 ASN.1 INTEGER 存在於檔案中(它沒有固定寬度),而 PBKDF2 最終使用的欄位是 32 位元 Integer;將 INTEGER 直接解碼到該欄位中,大值會被截斷,而精心設計以落在正負號位元上的值會以負數或某些無關的小數返回,因此工作的大小甚至不再是檔案看起來所要求的內容;修正方法是讀取完整寬度的值並在收窄之前對其進行限制:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

讀入 Int64 意指解碼值是真實的值,而不是截斷後的虛影;下限拒絕零和負數計數(這對金鑰衍生而言毫無意義);上限為一億,遠高於任何合法的 PKCS#12 檔案(目前使用數萬到數十萬次反覆運算),同時將最壞情況限制在有界、可承受的工作量中;只有在值通過該範圍之後,它才會收窄為 32 位元欄位,因此截斷不再會使任何人感到意外;在 HotPDF 中,此限制存在於 ParsePBES2Params 中,在其中 PBKDF2 參數在前往 PBKDF2HMACSHA256 的路上被解碼

為什麼這兩個修正方法是同一個修正

這兩個缺陷看起來不同,一個是緩衝區滿溢,另一個是程序掛起,但它們是同一個錯誤;在每種情況下,來自不可信檔案的數字都過早被帶入固定寬度的型態中(在根據實際情況進行檢查之前);在邊界測試之前以 32 位元加入長度;在範圍測試之前將反覆運算次數收窄為 32 位元;兩者都遵循相同的原則:以完整寬度解碼、檢查實際限制,然後才收窄;中間的 Int64 不是一種風格選擇,它是防護能夠看到攻擊者實際寫入之值的唯一寬度;溢位的邊界不是邊界,而沒有上限的計數不是參數,它是對您自己 CPU 的遠端節流閥

簽章管線的實務指南

具體的教訓是,要像驗證任何不可信的上傳一樣驗證不可信的憑證輸入;限制您接受的 .pfx 大小,因為合法的憑證是 KB,而不是 MB;將解析失敗視為例行拒絕的輸入,而不是值得向使用者顯示堆疊追蹤的錯誤;如果您在伺服器上簽署,請在停頓的工作執行緒不會導致服務關閉的地方執行匯入,並在操作周圍設置逾時,以便非預期昂貴的檔案受到實際時間以及反覆運算限制的約束

更廣泛的教訓超出了憑證;解析器強化不是對單一單元的一次性稽核,它是您函式庫讀取其未寫入之位元組的每個地方的屬性;PDF 函式庫從不可信來源解析大量內容:內嵌在檔案中的字型、半打編解碼器中的影像、串流篩選器,以及簽章路徑上的憑證;其中每一個都是攻擊層面,且每一個都值得對每個長度和每個計數抱持相同的懷疑;HotPDF 將匯入和簽章路徑建立在本文所述的強化 HPDFASN1HPDFPFXHPDFCryptHPDFCMS 單元上,以便不論您提供給它的憑證來自何處,在被信任之前都會被防禦性地解析

這些檢查所保護的簽章工作流程在我們在 Delphi 中對 PAdES 數位簽章的逐步說明中進行了完整介紹,而套用到檔案加密的相同防禦態勢(包括共享此程式碼庫的 AES-256 金鑰路徑)在關於 AES-256 加密與安全性的文章中有所描述;所有這一切都作為 Delphi 和 C++Builder 的 HotPDF 元件的一部分隨附,並與本部落格其他地方介紹的載入、編輯、加密和簽章 API 搭配