dotnet 6 在 Win7 系統證書鏈錯誤導致 HttpWebRequest 記憶體洩露

lindexi發表於2022-05-09

本文記錄我將應用遷移到 dotnet 6 之後,在 Win7 系統上,因為使用 HttpWebRequest 訪問一個本地服務,此本地服務開啟 https 且證書鏈在此 Win7 系統上錯誤,導致應用記憶體洩露問題。本文記錄此問題的原因以及調查過程

核心原因

核心原因是在 CRYPT32.dll 上的 CertGetCertificateChain 方法存在記憶體洩露,更底層的原因未知

在 .NET 6 裡,更新了 https 訪問方法邏輯,詳細請看 Announcing .NET 6 - The Fastest .NET Yet - .NET BlogWhat's new in .NET 6 Microsoft Docs

核心問題是呼叫進入 ChainPal.BuildChain 時,將會呼叫 Crypt32.CertGetCertificateChain 方法的呼叫邏輯有所變更,此進入邏輯和 .NET Framework 4.5 有所不同。準確來說,此差異不是 .NET 6 與 .NET Framework 4.5 的差異,而是 .NET Framework 4.6 以及更高版本與 .NET Framework 4.5 的差異

在 .NET Framework 4.6 時引入 Switch.System.Net.DontEnableSchUseStrongCrypto 變更是導致此問題的關鍵,在 .NET Framework 4.5 下,預設是 true 的值,但是在 .NET Framework 4.6 和更高版本下都是 false 的值。這就導致了整體邏輯的行為差異。此邏輯差異只和 SDK 相關,而和使用者端所安裝的執行時無關

但是此差異是否一定導致記憶體洩露,這是未知的。但記憶體洩露必定走了此呼叫邏輯

解決方法

如 SDK 提示,使用 WebRequest.Create 等方法建立 HttpWebRequest 用來進行網路請求邏輯是一個過時的方法,應該換用 HttpClient 等代替。經過實際的測試,換用 HttpClient 即可完美解決記憶體洩露問題,順帶提升了不少的效能

也就是說此記憶體洩露從業務上說是使用了一個過時的 API 導致的問題

調查過程

在開始記錄調查過程之前,還請看一下背景

如上一篇部落格 記將一個大型客戶端應用專案遷移到 dotnet 6 的經驗和決策 - lindexi - 部落格園 我在完成了遷移了此大型應用到 dotnet 6 釋出到內測使用者端,有內測小白鼠反饋說第二天過來就看到應用掛掉了

一開始沒有認為這是一個問題。等到第二個使用者反饋時才開始認為這是一個坑,開始進行調查

以下除錯過程非新手友好,請新手一定不要閱讀下文,如果閱讀了也一定不要在除錯記憶體洩露使用下面的方法

通過分析應用本身的日誌,瞭解到應用是被閃退的。詢問內測的使用者瞭解到,應用閃退的時候,都是在晚上掛機的時候,這時候沒有任何的使用者動作。為了儘可能幹掉環境問題帶來的干擾,我搭建了虛擬機器,使用 cn_windows_7_ultimate_with_sp1_x64_dvd_u_677408.iso 安裝了純淨的系統,再加上 KB2533623 補丁讓 dotnet 6 應用跑起來,最後部署上應用,進行掛機

十分符合預期的,第二天應用掛掉了,而且系統提示 Xx 應用停止工作。通過 系統日誌 可以看到存在應用錯誤異常,異常資訊是 CLR Exception E0434352 也就是在 CLR 層面出現異常

我錯誤認為這是升級到 dotnet 6 時,由於 dotnet 6 和 Win7 的相容性導致的問題,開始著手根據 CLR Exception E0434352 Microsoft Docs 官方文件的方法開始調查,然而卻沒有找到任何有用的資訊

繼續掛機到第三天,我這次採用工作管理員在 Xx 應用停止工作時,對應用抓一個 DUMP 傳到我開發裝置上,使用 VisualStudio 的混合除錯進行除錯,此時發現錯誤資訊和第二天的不相同了,這次顯示的是 OutOfMemory 相關異常。但是我在 Win7 虛擬機器上,使用工作管理員看到的 Xx 應用佔用的記憶體實際上才 250 MB 而已,這一定是在諷刺我

好在我反應過來,工作管理員上面看到的應用佔用 250MB 記憶體,完全不等於應用使用的記憶體是 250MB 的空間。為什麼呢?這是一個複雜的問題,我不想在本文這裡聊 Windows 下的應用記憶體知識,也許後續會另外開一篇很長的部落格來說明。需要了解的是,如果一個應用 OOM 了,那除了系統本身給不到應用足夠的記憶體之外,還有另一個問題就是應用本身用到了平臺限制的最大記憶體數量。別忘了 x86 和 x64 的差異

剛好,此 Xx 應用是一個 x86 應用。在通過系統日誌瞭解到此 Win7 虛擬機器上沒有存在一刻是記憶體不足的情況,而且此純淨的虛擬機器也就跑了 Xx 一個應用,要是記憶體不足,也是 Xx 應用的鍋。回憶一下,使用 x86 應用,預設的程式空間是 4G 大小,其中有 1 到 2G 需要給系統交稅,也就是應用在開啟大記憶體感知時,最大能用到 3G 的記憶體。如果應用在到達 3G 記憶體佔用附近時,依然向系統申請記憶體,那此時就 OOM 了

工作管理員說應用佔用了多少記憶體,實際上如果是以上的申請記憶體超過 x86 平臺限制的導致的問題,那完全必須無視工作管理員說的話。特別是在使用者端,別忘了還有 EmptyWorkingSet 這樣安慰人的方法

我通過拿到 DUMP 檔案的大小,看到 DUMP 檔案是接近 4G 的大小,猜測是 Xx 應用申請記憶體超過 x86 平臺限制。調查此問題需要用到微軟極品工具箱的 VMMap 工具

通過 vmmap 可以看到此時的應用的 Private Data 佔用達到接近 3G 的大小,因此可以定位到 Xx 應用閃退的原因是因為申請記憶體超過 x86 平臺限制

也就是說有兩個分支導致 Private Data 佔用過多,第一個原因就是業務需要申請大量的記憶體空間,第一個原因不算是記憶體洩露問題,只能算是效能優化問題,某個業務邏輯空間複雜度過高。第二個原因就是應用記憶體洩露,應用不斷執行過程中,不斷洩露記憶體,執行的時間長了,自然多少記憶體都不夠用

換句話說,不是所有的 OOM 問題,都是記憶體洩露問題,可能還是業務需要申請大量的記憶體空間問題。但顯然,本次遇到的問題,應該就是記憶體洩露問題了。畢竟只是掛機就讓應用掛掉了,那大概確定是記憶體洩露了。但是這隻能說大概,萬一有一個定時任務是從後臺拉取某個資料,剛好這個資料導致了某個處理業務需要申請大量的記憶體,從而讓應用掛掉。為了確定是哪個方式導致的 OOM 了,可以先使用排除的方式,如果是某個業務申請大量的記憶體導致記憶體洩露,這是非常好也非常方便除錯出來的,只需要使用 dotMemory 工具分析一下即可

在開始使用 dotMemory 之前,還遇到一個小問題,那就是 dotMemory 不能在我的 Win7 虛擬機器上執行,而我又不想去汙染此虛擬機器環境。好在 dotMemory 可以分析 DUMP 檔案,於是我就拿來剛才使用 工作管理員 抓的 DUMP 檔案進行分析。可惜,由於 Win7 虛擬機器採用的是 X64 系統,而應用是 X86 應用,導致工作管理員抓的 DUMP 檔案無法被 dotMemory 識別,只能再次換用專業 ProcDump 工具去抓程式的 DUMP 檔案

換用 ProcDump 工具去抓應用的 DUMP 檔案用起來比工作管理員更加方便,我也推薦使用 ProcDump 去抓 DUMP 檔案,這個工具是十分強大的,本文用到的只是很少的功能。由於這個工具太強大了,要介紹的話,也是另一篇部落格了,本文也不會包含此工具的更多使用方法

在虛擬機器上面使用 procdump -ma <PID> 命令,這裡的 <PID> 就是要抓取的程式的 Id 號,將 Xx 應用抓取 DUMP 檔案,然後再用 7z 壓縮一下,傳回到我的開發裝置上,用 dotMemory 開啟分析。使用 7z 是因為可以很大的壓縮 DUMP 檔案。通過 dotMemory 分析沒有看到有哪個業務使用了大量的記憶體,總的 .NET 記憶體佔用實際上才不到 100MB 大小。因此大概可以確定不是因為某個業務申請大量的記憶體導致記憶體洩露,至少不是申請託管記憶體

繼續回到確定 OOM 導致的原因上,我重新執行 Xx 應用,通過 VMMap 工具不斷按 F5 重新整理,經過三個小時間斷追蹤,可以看到 Private Data 緩慢上漲。通過此,可以判斷是記憶體洩露問題

記憶體洩露通用處理方法就是先抓取洩露點,通過洩露點了解洩露模組。抓取洩露點的通用方法就是對比幾段時間點,有哪些物件被建立且不被回收。依然是使用 ProcDump 工具抓取 DUMP 檔案,然後通過 dotMemory 的匯入 DUMP 功能,以及對比記憶體功能,進行分析

如果要是 dotMemory 可以符合預期的讓我看到業務模組上有哪些物件沒有被釋放,那自然就不會有本文的記錄,畢竟如此簡單就能解決的問題,要是還水一篇部落格就太水了。通過 dotMemory 抓取可以看到不同的時間點上,沒有任何業務程式碼的物件洩露。唯一新建的幾個物件都是 System.Net 名稱空間下的,而且佔用的託管記憶體也特別小,這幾個物件的根引用都是 Ssl 相關的底層模組,看起來似乎沒有問題

也如一開始的調查,洩露的部分似乎不在 .NET 託管上,而是非託管的洩露。對一個純 .NET 應用來說,可以認定所有的非託管洩露都是由託管導致的。但是可惜 Xx 應用是一個複雜的應用裡面包含了其他團隊寫的一點庫邏輯。於是先嚐試定位一下是否遷移過程,修改了部分的 C++\CLI 邏輯導致的記憶體洩露。定位的方法是採用二分法,也就是幹掉這些引入的庫的邏輯。我重新寫了程式碼,用 Fake 的方式重新實現了假邏輯,將所有的其他團隊寫的非 .NET 的庫的檔案都刪掉

可惜刪除了其他團隊寫的非 .NET 的庫之後,依然存在記憶體洩露。也就是說可以確定是在託管層存在記憶體洩露的,此時我特別怕是遷移到 dotnet 6 導致的,和 Win7 的適配問題。而用 dotMemory 也無法給我帶來更多的幫助,用 dotMemory 最預期的能拿到的資訊就是業務端有某些物件被洩露,可惜沒有找到任何業務端的物件洩露。那此時用 VisualStudio 是否有更多資訊?不會有的,放心吧,在除錯記憶體洩露方面,使用 VisualStudio 和 dotMemory 的能力是完全相同的,只是 VisualStudio 的互動做的太過垃圾,完全不如 dotMemory 的互動形式。因此用 dotMemory 沒有帶來更多幫助,同理使用 VisualStudio 也不會有更多幫助

為了確定是否 dotnet 6 底層帶來的問題,我先在 dotnet 開源倉庫 https://github.com/dotnet/runtime/ 裡翻 dotnet 6 的記憶體相關的帖子,好在沒有找到任何有關聯的有幫助的,那就側面證明了,應該是沒有其他人遇到了此問題,這是一個好訊息。但也許不是,那就是我是第一個遇到的人。其次,由於我採用的是 dotnet 6.0.1 版本,分發給使用者端的不敢那麼頭鐵用剛釋出的版本,官方最新的是 dotnet 6.0.4 版本,也許在某個安全更新修復了此問題,安全更新有一些是保密的,也就是說我沒有能找到,如果強行去找,可以用 MVP 許可權去尋找,但這個響應速度就沒有那麼快

接下來可以調查的方向如下

  • 是否 dotnet 6 底層帶來的問題
  • 是否 dotnet 6.0.1 帶來的問題,但在 dotnet 6.0.4 修復了

確認是否 dotnet 6 底層帶來的問題剛好在我這個專案上,沒有那麼麻煩。我對比測試了在 Win10 的裝置上,發現沒有記憶體洩露。剛好 Xx 應用是從 .NET Framework 遷移過來的,現在改改程式碼還能跑 .NET Framework 的版本,於是也就同步在出現問題的 Win7 上跑 .NET Framework 的版本,結果發現在 Win7 上使用 .NET Framework 版本沒有任何問題。於是大概可以確定,這和 dotnet 6 底層是有所關聯,但不能說這是 dotnet 6 底層的鍋

接下來確定是否 dotnet 6.0.1 帶來的問題,但在 dotnet 6.0.4 修復了的問題。我在此出現問題的 Win7 上,使用 dotnet 6.0.4 版本代替原先的 6.0.1 版本,好在 dotnet 6 是不需要安裝的,替換檔案即可。結果依然存在記憶體洩露,這是一個壞訊息。也就是說也許我是第一個遇到此問題的人,或者說這是一個官方也不知道的問題。我就嘗試去面向群程式設計,詢問了幾位大佬是否遇到過此問題,然而所有的回答都和本次遇到的不是相同的問題,且沒有一位大佬遇到 dotnet 6 底層的記憶體洩露問題,這也算是好訊息

回到測試 dotnet 6 底層帶來的問題上,既然對比了 .NET Framework 和 dotnet 6 兩個框架,發現只有在 dotnet 6 框架才出現問題。那可能的原因實際上可以分為三個:

  • 遷移 dotnet 6 過程中,與 .NET Framework 的變更導致的問題
  • 由於 dotnet 6 的機制變更,與 .NET Framework 的不相同,導致的記憶體回收策略變更的記憶體洩露問題,例如之前遇到的委託問題
  • 這就是 dotnet 6 底層與 Win7 適配的問題

由於 Xx 應用是一個足夠複雜的大型應用,不好定位以上的三個原因。於是採用對比測試法,先建立一個空白的 dotnet 6 的 WPF 應用,在此 Win7 上執行。十分符合預期的,沒有記憶體洩露問題。這能證明,不是那麼簡單的 dotnet 6 的底層的問題。假如使用空的 dotnet 6 的 WPF 應用也能存在記憶體洩露,那就能快速定位是 dotnet 6 底層的問題,接下來的步驟就是看是否 WPF 的問題還是 dotnet 更底層的問題,畢竟這個 WPF 是我定製的版本,改了不少的內容

再定位是否遷移 dotnet 6 過程中,與 .NET Framework 的變更導致的問題,我尋找了所有的變更邏輯,逐個還原,或者使用 Fake 邏輯,幹掉對應的功能。這個過程相當於一個二分,也就是說如果在幹掉了某些功能之後,沒有出現記憶體洩露,那就能定位記憶體洩露和被幹掉的功能相關。完成之後,同時構建出 dotnet 6 和 .NET Framework 兩個版本,在此 Win7 上執行。結果依然是 dotnet 6 版本存在記憶體洩露,而 .NET Framework 版本沒有記憶體洩露

這就證明了原因可能就是 由於 dotnet 6 的機制變更,與 .NET Framework 的不相同,導致的記憶體洩露。但經過以上的測試,不能說明一定是 記憶體回收策略變更的記憶體洩露問題

到這裡,其實基本沒有了通用套路可以定位的方法了。除了使用二分法,使用二分法逐個模組幹掉,看幹掉到哪個模組就不存在記憶體洩露問題。但在此 Xx 應用上使用二分法是一個大工程,再加上記憶體洩露的判斷是需要等待一段時間的。而不是快速就能定位出來,需要通過 VMMap 經過一段時間,按照小時為單位,看 Private Data 的佔用,才能瞭解到是否記憶體洩露。以上的測試都是可以並行多個同時開始的,儘管每個測試都需要佔用半天的時間,好在多個測試並行,以上的測試都在一天內完成。但如果採用二分,那就意味著需要進行序列測試,在上次沒有測試完成之前,是無法進行下一個二分的。我就將二分作為最後的方法,繼續找找其他的方法

回顧一下,使用 .NET Framework 沒有問題,只有 dotnet 6 版本存在記憶體洩露。通過 dotMemory 和 DUMP 沒有找到業務物件的記憶體洩露,只有某幾個 System.Net 名稱空間下的物件存在,這些物件不確定是否洩露。更新了 dotnet 6.0.4 也沒有解決,也沒有搜到帖子,問了大佬們也沒有遇到相同的問題,也就是說不是 dotnet 的官方已知問題

既然看到了存在 System.Net 名稱空間下的物件存在,那可以猜測是和網路相關的問題,剛才的 dotnet 6 的空 WPF 測試應用只能證明和基礎的 dotnet 6 無關,但沒有證明和網路模組無關。繼續寫一個訪問網路的 demo 專案,執行發現沒有記憶體洩露問題,看起來此記憶體洩露問題也不是那麼簡單能復現,一半是好訊息,一半是壞訊息。剛好 waterlv 大佬有空回覆我了,他告訴我,記憶體不會無緣無故上漲的,一定是有某些業務邏輯在跑。於是另一個方向是放棄記憶體的方向,而是調查空閒的時候執行了哪些邏輯

調查某個應用在某段時間執行了哪些邏輯,這是一個 CPU 效能除錯問題,相當於調查一段時間內,有哪些邏輯佔用了 CPU 資源。調查這個問題最好用的工具就是 dotTrace 工具了。我準備在此 Win7 使用 dotTrace 工具抓 Xx 應用的資訊,可惜 dotTrace 工具無法在此 Win7 執行,原因有兩個,一個是需要 .NET Framework 4.7 的環境,另一個就是 ETW 準備失敗。其中 ETW 準備失敗也就無法抓取資訊,於是我放棄了 dotTrace 工具

剛好 dotnet 系裡面有 dotnet trace 工具,此工具可以完美在 Win7 執行。於是我換用 dotnet trace 工具去抓取,雖然是抓取到了資訊,但是 dotnet trace 工具比 dotTrace 工具還是差太遠了,差距大概是一個是記事本,一個是 SublimeText 的差距,我沒有成功分析出來什麼,反而又過去了一天

那換一個方式,通過 DUMP 抓取瞬時的執行緒呼叫堆疊,可以看到有很多執行緒存在,但是基本上都是不在執行的執行緒。唯一一個看起來稍微相關的堆疊如下

> ntdll.dll!_ZwWaitForMultipleObjects@20() Unknown
  KERNELBASE.dll!_WaitForMultipleObjectsEx@20()  Unknown
  kernel32.dll!_WaitForMultipleObjectsExImplementation@20()  Unknown
  kernel32.dll!_WaitForMultipleObjects@16()  Unknown
  winhttp.dll!HANDLE_OBJECT::IsInvalidated(void)  Unknown
  winhttp.dll!OutProcGetProxyForUrl(class INTERNET_SESSION_HANDLE_OBJECT *,unsigned short const *,struct WINHTTP_AUTOPROXY_OPTIONS const *,struct WINHTTP_PROXY_INFO *) Unknown
  winhttp.dll!_WinHttpGetProxyForUrl@16()  Unknown
  cryptnet.dll!InetGetProxy(void *,void *,unsigned short const *,unsigned long,struct WINHTTP_PROXY_INFO * *) Unknown
  cryptnet.dll!InetSendAuthenticatedRequestAndReceiveResponse(void *,void *,unsigned short const *,unsigned short const *,unsigned char const *,unsigned long,unsigned long,struct WINHTTP_PROXY_INFO *,struct _CRYPT_CREDENTIALS *,struct _CRYPT_RETRIEVE_AUX_INFO *)  Unknown
  cryptnet.dll!_InetSendReceiveUrlRequest@32() Unknown
  cryptnet.dll!CInetSynchronousRetriever::RetrieveObjectByUrl(unsigned short const *,char const *,unsigned long,unsigned long,struct _CRYPT_BLOB_ARRAY *,void (**)(char const *,struct _CRYPT_BLOB_ARRAY *,void *),void * *,void *,struct _CRYPT_CREDENTIALS *,struct _CRYPT_RETRIEVE_AUX_INFO *) Unknown
  cryptnet.dll!_InetRetrieveEncodedObject@40() Unknown
  cryptnet.dll!CObjectRetrievalManager::RetrieveObjectByUrl(unsigned short const *,char const *,unsigned long,unsigned long,void * *,void *,struct _CRYPT_CREDENTIALS *,void *,struct _CRYPT_RETRIEVE_AUX_INFO *) Unknown
  cryptnet.dll!CryptRetrieveObjectByUrlWithTimeoutThreadProc(void *)  Unknown
  kernel32.dll!@BaseThreadInitThunk@12() Unknown

看起來和系統的 cryptnet.dll 有幾毛錢關係,也許這是 Win7 一個已知的問題,也許更新了某個補丁能解決。到這裡想要繼續就只能通過 WinDbg 了,玩 WinDbg 工具需要花太多的時間,於是我先掛著 WinDbg 在 Win7 系統上,拉符號檔案,將我本機的符號資料夾共享給他。拉取符號和共享符號資料夾需要半天的時間,我也不能摸魚。似乎走 CPU 分析這個路是不可行的。繼續回到分析記憶體的方法

繼續猜測是網路相關問題,好在使用的是虛擬機器,我聽了 waterlv 大佬的方法,禁用了網路卡,跑了一個晚上,沒有記憶體洩露。那基本可以定位和網路問題是強相關了。於是開啟 Fiddler 準備抓資料,預設的 Fiddler 是沒有抓 Https 的請求的,我分為兩個階段,先抓 http 的請求,結果發現 Xx 應用沒有任何 http 請求。開啟 Fiddler 的抓取 https 請求,結果發現有某些請求發出,但是此時詭異的是 Xx 應用不再有記憶體洩露了

我根據 Fiddler 抓 Https 請求的原理猜測是因為 Fiddler 為了抓取 Https 安裝的證書導致 Xx 應用的行為和之前不同,從而沒有記憶體洩露問題。於是做對比測試,關掉 Fiddler 的抓 https 功能,重啟 Xx 應用,跑了半天,記憶體洩露

大概可以定位到和證書相關,繼續定位是和請求哪個連結相關,從程式碼裡面進行二分邏輯,從 Fiddler 裡面抓到的各個請求的程式碼,逐個幹掉,終於被我定位到核心的問題所在。我的另一個本機的服務應用,這是一個在本機開啟的程式服務,通過 Https 進行 IPC 本機跨程式通訊。業務模組和這個本地服務應用有心跳通訊,每次通訊都是記憶體洩露。那為什麼這個本地服務應用的通訊會讓 Xx 應用記憶體洩露,根據 Fidder 的證書問題我猜測和證書相關。重新閱讀這個服務應用的程式碼,以及請教了 lsj 證書相關知識點之後,瞭解到這個服務應用,採用的證書有點問題,這個服務應用的證書鏈是不完整的,剛好在此 Win7 系統上,證書也都沒有更新

解決的方法有幾個:

  • 換用 http 通訊,都是本機了,還用什麼 https 通訊
  • 換用 HttpClient 通訊,預設明確丟擲 System.Security.Authentication.AuthenticationException: The remote certificate is invalid because of errors in the certificate chain: PartialChain 異常

換用 HttpClient 通訊時,可以使用如下程式碼忽略證書錯誤問題,但是此方式是不受推薦的

var handler = new HttpClientHandler()
{
   ServerCertificateCustomValidationCallback = delegate { return true; }
};
var httpClient = new HttpClient(handler);

於是我將 Https 換成 Http 的方式,再次測試,跑了一段時間,沒有記憶體洩露。看起來就是證書導致的問題

邏輯上也是對的,一次對本機的服務應用訪問,不需要建立任何業務端的物件,全部使用的都是 System.Net 的物件,這就是使用 dotMemory 工具失敗的原因,而且請求的速度也足夠快,無法讓 DUMP 抓到資訊,再加上非同步是沒有 DUMP 的執行緒堆疊,這就讓上面使用 DUMP 除錯的方法掛掉。其實要是 dotTrace 能跑起來,是可以快速定位到此模組的,可惜 dotnet trace 還是比較渣。在瞭解到是這個模組的時候,我換用 PerfView 去除錯 dotnet trace 抓的檔案,其實依然能看到這個模組的邏輯,可惜如果沒有了解到是這個模組的問題時,應該是無法通過 PerfView 定位的。也就是說,實際上 dotnet trace 是具備此定位的能力的,能收集到足夠的資訊,但上層的分析工具卻是渣的很,無論是 VisualStudio 還是 PerfView 工具,在介面和互動上都渣

不過說 VisualStudio 還是 PerfView 工具渣,我還是需要和 dotTrace 對比一下。和這個本地服務應用的通訊模組,在我的開發裝置上也是相同執行的,和在 Win7 系統上一樣,差別只是我的開發裝置上沒有記憶體洩露。但是如上文,其實只是調查某段時間的 CPU 佔用,和記憶體洩露沒有關係。我在開發裝置上開啟 dotTrace 工具,抓了 Xx 應用,果然迅速就看到了和這個本地服務應用的通訊模組的執行邏輯。也就是說如果有 dotTrace 工具一開始就能跑起來,應該可以半天內搞定

噴完了 VisualStudio 工具渣,剛好此時 WinDbg 的符號也下載完成了,可以繼續調查更底層的邏輯,依然從記憶體的角度調查。在 VMMap 工具上,通過 Private Data 的資料可以看到堆上有很多大小相同的資料,根據 Win32 記憶體除錯的套路,基本上可以確定這就是某個相同的模組申請的,而且也沒有釋放

為了確定是哪個模組申請了某個非託管記憶體,我使用了 gflags 工具的輔助,這個工具就放在 WinDbg 所在的資料夾裡面,在命令列執行下面命令,執行的時候將會提示管理員許可權,執行完成之後是不會有任何介面的

gflags.exe /i Xx.exe +ust

使用以上命令,即可讓 gflags 輔助抓取 Xx 應用的記憶體申請的呼叫堆疊。以上命令的 Xx.exe 是不需要也不能使用絕對路徑的,只是一個程式的檔名即可,因為實際上的抓取邏輯還是在 WinDbg 下執行。詳細請看 官方文件

接下來是將 Xx 應用跑起來,由於 Xx 應用是在空閒的時候,沒有使用者互動,就出現記憶體洩露,為了減少 WinDbg 的複雜除錯,我在應用跑起來,啟動完成,才使用 WinDbg 附加除錯

儘管知道是某個大小的資料佔用了 Private Data 記憶體,但我對 VMMap 工具不夠熟悉,不敢作為結果使用,但是可以作為方向。我重新通過 WinDbg 定位是否某個模組申請了記憶體沒有釋放,步驟就是先找到哪個記憶體在變更,對應的堆裡面的內容,是否某個大小的資料是在不斷洩露的,這些大小的資料的申請的呼叫堆疊是什麼

先通過 !heap -s 命令多次執行,瞭解是那個記憶體在變更

按照慣例是執行至少兩次進行對比,對於大型應用,基本上都推薦是三次以上。不過我通過 VMMap 工具大概瞭解到方向了,於是就只使用三次。首次執行的命令和輸出如下

0:024> !heap -s
LFH Key                   : 0x5327c840
Termination on corruption : ENABLED
  Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                    (k)     (k)    (k)     (k) length      blocks cont. heap 
-----------------------------------------------------------------------------
00420000 00000002   48768  43096  48768   1929   715    16    0      3   LFH
006b0000 00001002    1088    680   1088      8    21     2    0      0   LFH
00e30000 00001002     256    204    256      2    21     1    0      0   LFH
00df0000 00041002     256      4    256      2     1     1    0      0      
01170000 00001002    1088    196   1088     16     8     2    0      0   LFH
05970000 00041002     256      4    256      2     1     1    0      0      
05920000 00001002     256    160    256      3     7     1    0      0   LFH
083a0000 00001002     256    172    256    118     3     1    0      0      
0b240000 00001002     256    168    256      5    10     1    0      0   LFH
0a3f0000 00041002     256     16    256      5     1     1    0      0      
0e510000 00011002     256     12    256      9     6     1    0      0      
0ec10000 00001002     256    148    256      6     5     1    0      0   LFH
0ee20000 00001002     256    256    256    111    11     1    0      0   LFH
0ed10000 00001002      64     52     64      7     3     1    0      0      
0f990000 00001002     256      4    256      1     2     1    0      0      
0fdb0000 00001002   12096   4048  12096   2601    32     8    0      0   LFH
    External fragmentation  64 % (32 free blocks)
08700000 00001002      64      4     64      2     1     1    0      0      
-----------------------------------------------------------------------------

在 WinDbg 按下 g 命令讓應用繼續執行一段時間

0:024> g
(7c0.1874): CLR exception - code e0434352 (first chance)
(7c0.1874): CLR exception - code e0434352 (first chance)
(7c0.e64): Break instruction exception - code 80000003 (first chance)
eax=fff9c000 ebx=00000000 ecx=00000000 edx=7743f7ea esi=00000000 edi=00000000
eip=773b000c esp=0a5efe4c ebp=0a5efe78 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!DbgBreakPoint:
773b000c cc              int     3

可以看到存在一些 CLR 異常,這就是本文開頭所抓到的 CLR 異常的部分,但不是相同的異常資訊。這些是可以忽略的,而且我也大概定位到方向,加上前幾天也嘗試定位了 CLR 異常沒有收穫,就沒有繼續定位

讓 Xx 應用跑了一段時間,在 WinDbg 工具按下暫停,繼續執行 !heap -s 命令

0:007> !heap -s
LFH Key                   : 0x5327c840
Termination on corruption : ENABLED
  Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                    (k)     (k)    (k)     (k) length      blocks cont. heap 
-----------------------------------------------------------------------------
00420000 00000002   81152  67244  81152   1992   723    18    0      3   LFH
006b0000 00001002    1088    680   1088      8    22     2    0      0   LFH
00e30000 00001002     256    204    256      2    21     1    0      0   LFH
00df0000 00041002     256      4    256      2     1     1    0      0      
01170000 00001002    1088    196   1088     16     9     2    0      0   LFH
05970000 00041002     256      4    256      2     1     1    0      0      
05920000 00001002     256    160    256      3     7     1    0      0   LFH
083a0000 00001002     256    172    256    118     3     1    0      0      
0b240000 00001002     256    168    256      5    10     1    0      0   LFH
0a3f0000 00041002     256     16    256      5     1     1    0      0      
0e510000 00011002     256     12    256      9     6     1    0      0      
0ec10000 00001002     256    148    256      6     5     1    0      0   LFH
0ee20000 00001002     256    256    256    111    11     1    0      0   LFH
0ed10000 00001002      64     52     64      7     3     1    0      0      
0f990000 00001002     256      4    256      1     2     1    0      0      
0fdb0000 00001002   12096   4048  12096   2601    32     8    0      0   LFH
    External fragmentation  64 % (32 free blocks)
08700000 00001002      64      4     64      2     1     1    0      0      
-----------------------------------------------------------------------------

大概可以看到 00420000 的大小從 4876881152 的大小

使用 !heap -stat -h 00420000 瞭解這個記憶體裡面的資料分佈情況

0:007> !heap -stat -h 00420000
 heap @ 00420000
group-by: TOTSIZE max-display: 20
    size     #blocks     total     ( %) (percent of total busy bytes)
    27994 71 - 117aa54  (37.88)
    269f8 6f - 10bf288  (36.29)
    fdcc 67 - 661d14  (13.83)
    10 7560 - 75600  (0.99)
    1c 2fec - 53dd0  (0.71)
    49a9c 1 - 49a9c  (0.62)
    390 e3 - 328b0  (0.43)
    711 68 - 2dee8  (0.39)
    284 108 - 29820  (0.35)
    618 64 - 26160  (0.32)
    40 934 - 24d00  (0.31)
    20 11f8 - 23f00  (0.30)
    70 49e - 20520  (0.27)
    50 639 - 1f1d0  (0.26)
    60 4b2 - 1c2c0  (0.24)
    dce0 2 - 1b9c0  (0.23)
    84 2d7 - 176dc  (0.20)
    15f13 1 - 15f13  (0.19)
    15eee 1 - 15eee  (0.19)
    30 6c5 - 144f0  (0.17)

可以看到大小為 27994 的資料有 0x71 個,而大小為 269f8 的資料有 0x6f 個。其實這兩個不能說明問題,繼續讓 Xx 應用執行一段時間,再輸入 !heap -s 命令

0:019> !heap -s
LFH Key                   : 0x5327c840
Termination on corruption : ENABLED
  Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                    (k)     (k)    (k)     (k) length      blocks cont. heap 
-----------------------------------------------------------------------------
00420000 00000002   97344  91356  97344   2082   730    19    0      3   LFH
006b0000 00001002    1088    680   1088      9    22     2    0      0   LFH
00e30000 00001002     256    204    256      2    21     1    0      0   LFH
00df0000 00041002     256      4    256      2     1     1    0      0      
01170000 00001002    1088    196   1088     17     9     2    0      0   LFH
05970000 00041002     256      4    256      2     1     1    0      0      
05920000 00001002     256    160    256      3     7     1    0      0   LFH
083a0000 00001002     256    172    256    118     3     1    0      0      
0b240000 00001002     256    172    256      5    11     1    0      0   LFH
0a3f0000 00041002     256     16    256      5     1     1    0      0      
0e510000 00011002     256     12    256      9     6     1    0      0      
0ec10000 00001002     256    148    256      6     5     1    0      0   LFH
0ee20000 00001002     256    256    256    111    11     1    0      0   LFH
0ed10000 00001002      64     52     64      7     3     1    0      0      
0f990000 00001002     256      4    256      1     2     1    0      0      
0fdb0000 00001002   12096   4048  12096   2601    32     8    0      0   LFH
    External fragmentation  64 % (32 free blocks)
08700000 00001002      64      4     64      2     1     1    0      0      
-----------------------------------------------------------------------------

可以看到 00420000 佔用的記憶體更加多了,使用 !heap -stat -h 00420000 檢視

0:019> !heap -stat -h 00420000
 heap @ 00420000
group-by: TOTSIZE max-display: 20
    size     #blocks     total     ( %) (percent of total busy bytes)
    27994 b1 - 1b60f54  (39.25)
    269f8 af - 1a67088  (37.85)
    fdcc a6 - a49248  (14.75)
    10 757a - 757a0  (0.66)
    1c 2ff4 - 53eb0  (0.47)
    49a9c 1 - 49a9c  (0.41)
    711 97 - 42b07  (0.37)
    618 86 - 33090  (0.29)
    390 e3 - 328b0  (0.28)
    284 108 - 29820  (0.23)
    40 935 - 24d40  (0.21)
    20 1236 - 246c0  (0.20)
    70 4a2 - 206e0  (0.18)
    50 63a - 1f220  (0.17)
    60 4b2 - 1c2c0  (0.16)
    dce0 2 - 1b9c0  (0.15)
    84 2d7 - 176dc  (0.13)
    15f13 1 - 15f13  (0.12)
    15eee 1 - 15eee  (0.12)
    30 6c5 - 144f0  (0.11)

可以看到前面兩個變更了,也就是大小為 27994 的資料和大小為 269f8 的資料的數量變更了

原先:
    27994 71 - 117aa54  (37.88)
    269f8 6f - 10bf288  (36.29)
當前:
    27994 b1 - 1b60f54  (39.25)
    269f8 af - 1a67088  (37.85)

也就是說大小 Size 為 27994 的存在很多重複項

接下來就是獲取到這些被分配記憶體的地址,使用命令 !heap -flt s 27994 過濾其它的記憶體塊,只顯示大小為 27994 的記憶體塊資訊

0:019> !heap -flt s 27994
    _HEAP @ 420000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        05fd2880 4f34 0000  [00]   05fd2888    27994 - (busy)
        06020c20 4f34 4f34  [00]   06020c28    27994 - (busy)
        0614cc18 4f34 4f34  [00]   0614cc20    27994 - (busy)
        08a719d0 4f34 4f34  [00]   08a719d8    27994 - (busy)
        08b05028 4f34 4f34  [00]   08b05030    27994 - (busy)
        08b9e4f0 4f34 4f34  [00]   08b9e4f8    27994 - (busy)
      .....  
        0b493108 4f34 4f34  [00]   0b493110    27994 - (busy)
      .....  
        0b366408 4f34 4f34  [00]   106b9378    27994 - (busy)
      .....  
        1e2abff8 4f34 4f34  [00]   1e2ac000    27994 - (busy)
        1e31a178 4f34 4f34  [00]   1fa93750    27994 - (busy)
        1e3782f0 4f34 4f34  [00]   1e3782f8    27994 - (busy)
        1e3d6468 4f34 4f34  [00]   2004dc80    27994 - (busy)
    _HEAP @ 6b0000
    _HEAP @ e30000
    _HEAP @ df0000
    _HEAP @ 1170000
    _HEAP @ 5970000
    _HEAP @ 5920000
    _HEAP @ 83a0000
    _HEAP @ b240000
    _HEAP @ a3f0000
    _HEAP @ e510000
    _HEAP @ ec10000
    _HEAP @ ee20000
    _HEAP @ ed10000
    _HEAP @ f990000
    _HEAP @ fdb0000
    _HEAP @ 8700000

輸出的內容太多了,我忽略了一些資訊

剛才開啟了 GFlags 工具,可以通過 !heap -p -a <UserPtr> 瞭解記憶體塊的申請呼叫堆疊,也就是哪個模組申請的記憶體。此命令的 <UserPtr> 請替換為 UserPtr 這一列的記憶體地址。需要抓幾個記憶體塊地址來進行統計才能瞭解是哪個模組申請而且洩露的

我先抓取了 2004dc80 地址的資訊

!heap -p -a 2004dc80
    address 2004dc80 found in
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        2004dc68 4f36 0000  [00]   2004dc80    27994 - (busy)
        7741df42 ntdll!RtlAllocateHeap+0x00000274
        76874ec3 KERNELBASE!LocalAlloc+0x0000005f
        76424b84 CRYPT32!PkiAlloc+0x00000032
        764516b3 CRYPT32!ChainCreateCyclicPathObject+0x000000b8
        764515c7 CRYPT32!ExtractEncodedCtlFromCab+0x000001b0
        7645142c CRYPT32!ExtractAuthRootAutoUpdateCtlFromCab+0x00000041
        764504d3 CRYPT32!CCertChainEngine::GetAuthRootAutoUpdateCtl+0x000001f8
        764c047c CRYPT32!CChainPathObject::GetAuthRootAutoUpdateUrlStore+0x00000082
        76469850 CRYPT32!CChainPathObject::CChainPathObject+0x000003d0
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76437da9 CRYPT32!CCertIssuerList::AddIssuer+0x0000006c
        764387ac CRYPT32!CChainPathObject::FindAndAddIssuersFromStoreByMatchType+0x0000018b
        764386bd CRYPT32!CChainPathObject::FindAndAddIssuersByMatchType+0x00000096
        7643bbc6 CRYPT32!CChainPathObject::FindAndAddIssuers+0x00000063
        764697e0 CRYPT32!CChainPathObject::CChainPathObject+0x0000035b
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76438c8d CRYPT32!CCertChainEngine::CreateChainContextFromPathGraph+0x000001ae
        76438a6e CRYPT32!CCertChainEngine::GetChainContext+0x00000046
        76436d42 CRYPT32!CertGetCertificateChain+0x00000072

然後再選中間的 1fa93750 地址

0:042> !heap -p -a 1fa93750
    address 1fa93750 found in
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        1fa93738 4f36 0000  [00]   1fa93750    27994 - (busy)
        7741df42 ntdll!RtlAllocateHeap+0x00000274
        76874ec3 KERNELBASE!LocalAlloc+0x0000005f
        76424b84 CRYPT32!PkiAlloc+0x00000032
        764516b3 CRYPT32!ChainCreateCyclicPathObject+0x000000b8
        764515c7 CRYPT32!ExtractEncodedCtlFromCab+0x000001b0
        7645142c CRYPT32!ExtractAuthRootAutoUpdateCtlFromCab+0x00000041
        764504d3 CRYPT32!CCertChainEngine::GetAuthRootAutoUpdateCtl+0x000001f8
        764c047c CRYPT32!CChainPathObject::GetAuthRootAutoUpdateUrlStore+0x00000082
        76469850 CRYPT32!CChainPathObject::CChainPathObject+0x000003d0
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76437da9 CRYPT32!CCertIssuerList::AddIssuer+0x0000006c
        764387ac CRYPT32!CChainPathObject::FindAndAddIssuersFromStoreByMatchType+0x0000018b
        764386bd CRYPT32!CChainPathObject::FindAndAddIssuersByMatchType+0x00000096
        7643bbc6 CRYPT32!CChainPathObject::FindAndAddIssuers+0x00000063
        764697e0 CRYPT32!CChainPathObject::CChainPathObject+0x0000035b
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76438c8d CRYPT32!CCertChainEngine::CreateChainContextFromPathGraph+0x000001ae
        76438a6e CRYPT32!CCertChainEngine::GetChainContext+0x00000046
        76436d42 CRYPT32!CertGetCertificateChain+0x00000072

最後選了比較前面的地址

0:042> !heap -p -a 106b9378
    address 106b9378 found in
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        106b9360 4f36 0000  [00]   106b9378    27994 - (busy)
        7741df42 ntdll!RtlAllocateHeap+0x00000274
        76874ec3 KERNELBASE!LocalAlloc+0x0000005f
        76424b84 CRYPT32!PkiAlloc+0x00000032
        764516b3 CRYPT32!ChainCreateCyclicPathObject+0x000000b8
        764515c7 CRYPT32!ExtractEncodedCtlFromCab+0x000001b0
        7645142c CRYPT32!ExtractAuthRootAutoUpdateCtlFromCab+0x00000041
        764504d3 CRYPT32!CCertChainEngine::GetAuthRootAutoUpdateCtl+0x000001f8
        764c047c CRYPT32!CChainPathObject::GetAuthRootAutoUpdateUrlStore+0x00000082
        76469850 CRYPT32!CChainPathObject::CChainPathObject+0x000003d0
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76438c8d CRYPT32!CCertChainEngine::CreateChainContextFromPathGraph+0x000001ae
        76438a6e CRYPT32!CCertChainEngine::GetChainContext+0x00000046
        76436d42 CRYPT32!CertGetCertificateChain+0x00000072

可以看到都是 CRYPT32.dll 的 CertGetCertificateChain 函式申請的,對比剛才的 DUMP 抓到的執行緒呼叫堆疊,似乎 CRYPT32.dll 這個系統元件就是有鍋的。而且 CRYPT32.dll 就是處理證書相關的邏輯。 通過官方文件瞭解到 CertGetCertificateChain 就是證書鏈相關邏輯

根據上文使用二分除錯到的,和本地服務應用的通訊模組的證書鏈在 Win7 系統上損壞導致的記憶體洩露。現在根據 WinDbg 可以看到是 CertGetCertificateChain 處理證書鏈申請的記憶體沒有釋放,那就證明一定是證書鏈的問題

剛才通過 WinDbg 抓到的記憶體變更的記憶體塊大小有兩個,接下來再看 269f8 大小的記憶體塊的地址

0:042> !heap -flt s 269f8
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        084e4400 4d42 0000  [00]   084e4418    269f8 - (busy)
        0b810470 4d42 4d42  [00]   0b810488    269f8 - (busy)
        0b8cb7e8 4d42 4d42  [00]   0b8cb800    269f8 - (busy)
        0b90b900 4d42 4d42  [00]   0b90b918    269f8 - (busy)
        0b96b990 4d42 4d42  [00]   0b96b9a8    269f8 - (busy)
        0b9cba20 4d42 4d42  [00]   0b9cba38    269f8 - (busy)
        0ba3f108 4d42 4d42  [00]   0ba3f120    269f8 - (busy)
        105650b8 4d42 4d42  [00]   105650d0    269f8 - (busy)
        10692950 4d42 4d42  [00]   10692968    269f8 - (busy)
        10754ec0 4d42 4d42  [00]   10754ed8    269f8 - (busy)
        107f2630 4d42 4d42  [00]   107f2648    269f8 - (busy)
        10c28f90 4d42 4d42  [00]   10c28fa8    269f8 - (busy)
        10c8d038 4d42 4d42  [00]   10c8d050    269f8 - (busy)
        10cc4670 4d42 4d42  [00]   10cc4688    269f8 - (busy)
        10e0dbd0 4d42 4d42  [00]   10e0dbe8    269f8 - (busy)
        10e5bf90 4d42 4d42  [00]   10e5bfa8    269f8 - (busy)
      .....  
        201783a8 4d42 4d42  [00]   201783c0    269f8 - (busy)
        201ff188 4d42 4d42  [00]   201ff1a0    269f8 - (busy)
        2025d330 4d42 4d42  [00]   2025d348    269f8 - (busy)
        20329698 4d42 4d42  [00]   203296b0    269f8 - (busy)
    _HEAP @ 760000
    _HEAP @ a20000
    _HEAP @ ec0000
    _HEAP @ 1060000
    _HEAP @ 4e50000
    _HEAP @ 1010000
    _HEAP @ bd10000
    _HEAP @ e5c0000
    _HEAP @ e7f0000
    _HEAP @ 11900000
    _HEAP @ 11c10000
    _HEAP @ 12030000
    _HEAP @ 12750000
    _HEAP @ 12880000
    _HEAP @ 13410000
    _HEAP @ 1a2b0000

先隨意選擇 201ff1a0 記憶體地址,通過 !heap -p -a 201ff1a0 瞭解是哪個模組申請

0:042> !heap -p -a 201ff1a0
    address 201ff1a0 found in
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        201ff188 4d42 0000  [00]   201ff1a0    269f8 - (busy)
        7741df42 ntdll!RtlAllocateHeap+0x00000274
        76874ec3 KERNELBASE!LocalAlloc+0x0000005f
        76424b84 CRYPT32!PkiAlloc+0x00000032
        76447489 CRYPT32!ICM_GetListSignedData+0x000000fa
        76447299 CRYPT32!ICM_UpdateDecodingSignedData+0x0000006d
        764475cc CRYPT32!CryptMsgUpdate+0x000001e0
        764464c4 CRYPT32!FastCreateCtlElement+0x00000221
        76446252 CRYPT32!CertCreateContext+0x000000f1
        76451464 CRYPT32!ExtractAuthRootAutoUpdateCtlFromCab+0x000000b0
        764504d3 CRYPT32!CCertChainEngine::GetAuthRootAutoUpdateCtl+0x000001f8
        764c047c CRYPT32!CChainPathObject::GetAuthRootAutoUpdateUrlStore+0x00000082
        76469850 CRYPT32!CChainPathObject::CChainPathObject+0x000003d0
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76437da9 CRYPT32!CCertIssuerList::AddIssuer+0x0000006c
        764387ac CRYPT32!CChainPathObject::FindAndAddIssuersFromStoreByMatchType+0x0000018b
        764386bd CRYPT32!CChainPathObject::FindAndAddIssuersByMatchType+0x00000096
        7643bbc6 CRYPT32!CChainPathObject::FindAndAddIssuers+0x00000063
        764697e0 CRYPT32!CChainPathObject::CChainPathObject+0x0000035b
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76438c8d CRYPT32!CCertChainEngine::CreateChainContextFromPathGraph+0x000001ae
        76438a6e CRYPT32!CCertChainEngine::GetChainContext+0x00000046
        76436d42 CRYPT32!CertGetCertificateChain+0x00000072

依然是 CertGetCertificateChain 申請的,這是一個利好訊息。繼續再隨意找了 10e0dbe8 地址,通過 !heap -p -a 10e0dbe8 瞭解是哪個模組申請

0:042> !heap -p -a 10e0dbe8
    address 10e0dbe8 found in
    _HEAP @ 490000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        10e0dbd0 4d42 0000  [00]   10e0dbe8    269f8 - (busy)
        7741df42 ntdll!RtlAllocateHeap+0x00000274
        76874ec3 KERNELBASE!LocalAlloc+0x0000005f
        76424b84 CRYPT32!PkiAlloc+0x00000032
        76447489 CRYPT32!ICM_GetListSignedData+0x000000fa
        76447299 CRYPT32!ICM_UpdateDecodingSignedData+0x0000006d
        764475cc CRYPT32!CryptMsgUpdate+0x000001e0
        764464c4 CRYPT32!FastCreateCtlElement+0x00000221
        76446252 CRYPT32!CertCreateContext+0x000000f1
        76451464 CRYPT32!ExtractAuthRootAutoUpdateCtlFromCab+0x000000b0
        764504d3 CRYPT32!CCertChainEngine::GetAuthRootAutoUpdateCtl+0x000001f8
        764c047c CRYPT32!CChainPathObject::GetAuthRootAutoUpdateUrlStore+0x00000082
        76469850 CRYPT32!CChainPathObject::CChainPathObject+0x000003d0
        76437934 CRYPT32!ChainCreatePathObject+0x0000005e
        76438c8d CRYPT32!CCertChainEngine::CreateChainContextFromPathGraph+0x000001ae
        76438a6e CRYPT32!CCertChainEngine::GetChainContext+0x00000046
        76436d42 CRYPT32!CertGetCertificateChain+0x00000072

可以看到依然是 CertGetCertificateChain 申請的

現在可以完全證明記憶體洩露問題是證書鏈損壞導致 CertGetCertificateChain 記憶體洩露

但是無法確定 CertGetCertificateChain 記憶體洩露的更底層原因,也無法確定這是否是 Win7 這個版本存在的問題,是否安裝了補丁可以修復,還是因為 dotnet 6 呼叫的問題。我嘗試去搜以上的堆疊,找到了 2013 的帖子 IE crashes due to SSL certificate check - Problem with MSVCR80.dll, - Microsoft Community

看起來和上面說的是相同的一個問題,我預計是有補丁可以解決。而且讓 Win7 修復證書預計也能解決此問題

繼續調查是否因為 dotnet 6 呼叫的問題,從 WinDbg 上看到的堆疊只是到 CertGetCertificateChain 函式,這是因為我沒有載入 dotnet 6 的 sos 因此無法拿到 .NET 層的呼叫資訊。如何載入 dotnet 6 的 sos 請看 WinDbg 載入 dotnet core 的 sos.dll 輔助除錯方法

在除錯到 CertGetCertificateChain 申請的記憶體沒有洩露,後續的除錯我也不用 WinDbg 了,也不需要去載入 dotnet 6 的 sos 了。我通過靜態程式碼分析,閱讀 dotnet 6 的底層程式碼,看到了下面程式碼

internal sealed partial class ChainPal
{
   internal static partial IChainPal? BuildChain()
   {
       // 忽略程式碼
                            if (!Interop.Crypt32.CertGetCertificateChain(storeHandle.DangerousGetHandle(), certificatePal.CertContext, &ft, extraStoreHandle, ref chainPara, flags, IntPtr.Zero, out chain))
                            {
                                return null;
                            }
   }
}

根據官方文件,需要使用 CertFreeCertificateChain 釋放上面程式碼的 chain 變數。然而如上面程式碼,在 CertGetCertificateChain 方法返回 false 值,就返回了,沒有對 chain 呼叫釋放

我不瞭解是否在 CertGetCertificateChain 方法返回 false 值,就不需要呼叫 CertFreeCertificateChain 的問題,我反饋給了 dotnet 官方,詳細請看 CertGetCertificateChain memory leak in pure Windows 7 system · Issue #68892 · dotnet/runtime

通過閱讀 mozilla 的程式碼,看到了 mozilla 在 CertGetCertificateChain 方法返回 false 值,也是立刻返回,沒有呼叫 CertFreeCertificateChain 方法,詳細請看 https://hg.mozilla.org/releases/mozilla-release/rev/d9659c22b3c5#l3.347

但是 Xx 應用的記憶體洩露問題已解決,後續就交給 dotnet 官方

那為什麼 .NET Framework 就不存在問題?我繼續閱讀 dotent 程式碼和考古 .NET Framework 的程式碼,看到了這個邏輯是在 .NET Framework 4.6 變更的,也就是本文開始說的內容。剛好 Xx 應用是從 .NET Framework 4.5 升級到 dotnet 6 的,剛好就踩到這個坑

我回顧了本次的除錯,用了五天,實際上方向錯了。如果開始聽 waterlv 大佬,記憶體不會無緣無故上漲的,一定是有某些業務邏輯在跑,通過除錯 CPU 佔用的方法,是能在一天內完成。而如上文的除錯過程,我除錯的方向都是去除錯記憶體,這是不對的。通過 Fiddler 定位是證書問題和定位是 IPC 使用 Https 通訊且證書鏈損壞,也是定位有哪些業務模組在執行,也就是除錯 CPU 佔用。通過工作管理員可以看到,大概每間隔 3 秒就有 CPU 佔用,也就是說可以認為在 Xx 應用,所有定時任務小於 10 秒的,都是可能導致本次記憶體洩露的邏輯,我再次閱讀 Xx 應用的程式碼,看到了定時任務小於 10 秒的任務,才只有 5 個。通過二分的方法,逐個定時任務幹掉,讓這些定時任務一個個都不跑,看哪個定時任務不跑就沒有記憶體洩露,就可以定位到具體的模組。瞭解到是哪個模組就可以快速瞭解到具體原因。如果開始使用這個方法,可以在一天內完成,而不是花了兩週時間

這就是本次我用 dotnet 6 在 Win7 系統上執行,由於用到了詭異的方式實現的邏輯,導致了觸發了一個系統元件或者是 dotnet 底層的坑,讓應用記憶體洩露了,我記錄了除錯的過程,以及除錯使用的工具,讓大家看的更加無聊

更多請看

ServicePointManager Class (System.Net) Microsoft Docs

無法連線到一臺伺服器升級到.NET Framework 4.6 後使用 ServicePointManager 或 SslStream Api

CLR Exception E0434352 Microsoft Docs

EmptyWorkingSet function (psapi.h) - Win32 apps Microsoft Docs

使用 ProcDump 解決 VMM 服務問題 - Virtual Machine Manager Microsoft Docs

ProcDump - Windows Sysinternals Microsoft Docs

GFlags - Windows drivers Microsoft Docs

CertGetCertificateChain function (wincrypt.h) - Win32 apps Microsoft Docs

相關文章