如何洞察 C# 程式的 GDI 控制程式碼洩露

2023-06-12 15:02:04

一:背景

1. 講故事

前段時間有位朋友找到我,說他的程式介面操作起來很慢並且卡頓等一些不正常現象,從工作管理員看了下 GDI控制程式碼 已經到 1w 了,一時也找不出什麼程式碼中哪裡有問題,讓我幫忙看下,其實這種問題看記憶體dump作用不是很大,主要是寫指令碼很麻煩,這一篇我們就來簡單聊聊如何洞察此類問題。

二:如何洞察洩露

1. 一個測試小案例

在 windows 上gdi的控制程式碼型別有很多,比如:penfontbitmapdevice 等,具體可以網上搜一下,這裡我就造一個 bitmap 的控制程式碼洩露,參考程式碼如下:


        private void button1_Click(object sender, EventArgs e)
        {
            Task.Factory.StartNew(() =>
            {
                Bitmap bmp = new Bitmap(100, 100);

                for (int i = 0; i < 10000; i++)
                {
                    bmp.GetHbitmap();
                    Thread.Sleep(100);
                }
            });
        }

程式碼非常簡單,大概 100ms 洩露一個 bitmap 控制程式碼,接下來把程式跑起來點選 Button_Click 按鈕,然後上瑞士軍刀 WinDbg 附加程序。

2. 如何觀察GDI洩露

觀察 GDI控制程式碼 是否異常,最簡單的方法就是看工作管理員中的 GDI物件 一列,截圖如下:

但這裡有一個問題,你只知道有一個總數,並不知道是哪種控制程式碼型別的洩露,比如是:bitmap? font ?device? 對吧。

那怎麼辦呢?這就需要考驗一點基礎知識了,你要知道 GDI 的控制程式碼表(GDI Shared Handle Table)是維護在使用者態的虛擬地址上,區別於維護在核心中的 ObjectTable,可以用 !address 驗證下。

0:011> !address 

        BaseAddress      EndAddress+1        RegionSize     Type       State                 Protect             Usage
--------------------------------------------------------------------------------------------------------------------------
+      294`d1500000      294`d1681000        0`00181000 MEM_MAPPED  MEM_COMMIT  PAGE_READONLY                      Other      [GDI Shared Handle Table]

0:011> !address 294`d1500000

Usage:                  Other
Base Address:           00000294`d1500000
End Address:            00000294`d1681000
Region Size:            00000000`00181000 (   1.504 MB)
State:                  00001000          MEM_COMMIT
Protect:                00000002          PAGE_READONLY
Type:                   00040000          MEM_MAPPED
Allocation Base:        00000294`d1500000
Allocation Protect:     00000002          PAGE_READONLY
Additional info:        GDI Shared Handle Table


Content source: 1 (target), length: 181000

在這 1.5M虛擬地址段中就雪藏了我們要找的各控制程式碼的統計資訊,但要挖它需要寫指令碼,再配合 GDICELL 結構體,分組其中的 wType 控制程式碼型別。


typedef struct {
  PVOID64 pKernelAddress; // 0x00
  USHORT wProcessId;      // 0x08
  USHORT wCount;          // 0x0a
  USHORT wUpper;          // 0x0c
  USHORT wType;           // 0x0e
  PVOID64 pUserAddress;   // 0x10
} GDICell;                // sizeof = 0x18

雖然可以手工分組出來,但這種問題你肯定不是第一個遇到,早有人寫了一個工具來解決這類問題,它就是 GDIView.exe,大家可以網上搜一下。

開啟 GDIView 之後,可以很清楚的看到 WindowsFormsApp1 程式中各個控制程式碼的統計資訊,並且 type=Bitmap 是非常可疑的,截圖如下:

知道了是 Bitmap 的控制程式碼洩露,定位的範圍一下子就小了很多,長舒一口氣。

3. 如何尋找 Bitmap 的底層函數

熟悉 Windows 的朋友應該都知道 GDI 的邏輯是封裝在底層的 GDI32.dll 中,模組資訊如下:


0:012> lmvm gdi32
Browse full module list
start             end                 module name
00007ff9`b0c80000 00007ff9`b0cab000   GDI32      (deferred)             
    Image path: C:\windows\System32\GDI32.dll
    Image name: GDI32.dll
    Browse all global symbols  functions  data
    Image was built with /Brepro flag.
    Timestamp:        3EE1D71F (This is a reproducible build file hash, not a timestamp)
    CheckSum:         0002B228
    ImageSize:        0002B000
    File version:     10.0.19041.2130
    Product version:  10.0.19041.2130
    File flags:       0 (Mask 3F)
    File OS:          40004 NT Win32
    File type:        2.0 Dll
    File date:        00000000.00000000
    Translations:     0409.04b0
    Information from resource tables:
        CompanyName:      Microsoft Corporation
        ProductName:      Microsoft® Windows® Operating System
        InternalName:     gdi32
        OriginalFilename: gdi32
        ProductVersion:   10.0.19041.2130
        FileVersion:      10.0.19041.2130 (WinBuild.160101.0800)
        FileDescription:  GDI Client DLL
        LegalCopyright:   © Microsoft Corporation. All rights reserved.

言外之意就是可以在 GDI32 模組中下方法斷點,這時候問題就來了,到底擱哪個方法下呢?這個只能求助 MSDN 了,功夫不負有心人,找到了一篇很老的文章:https://learn.microsoft.com/en-us/archive/msdn-magazine/2003/january/detect-and-plug-gdi-leaks-with-two-powerful-tools-for-windows-xp

從圖中看記載的非常詳細,但我親自觀察下來有些方法找不到,所以只能做個參考吧,不過在 Windbg 中提供了一個非常好的 bm 命令,它可以對方法名進行 模糊斷點,比如 bm gdi32!*Bitmap* 就可以一口氣下 45 個斷點。


0:012> bm gdi32!*Bitmap* "? @$tid; k; gc"
  0: 00007ff9`b0c86f7c @!"GDI32!IsCreateBitmapPresent"
  1: 00007ff9`b0c87216 @!"GDI32!_imp_load_CreateDIBitmap"
  2: 00007ff9`b0c8906c @!"GDI32!_imp_load_DwmCreatedBitmapRemotingOutput"
  3: 00007ff9`b0c86460 @!"GDI32!NtGdiGetBitmapDpiScaleValue"
  4: 00007ff9`b0c8850c @!"GDI32!_imp_load_ClearBitmapAttributes"
  5: 00007ff9`b0c88745 @!"GDI32!_imp_load_CreateDiscardableBitmap"
  6: 00007ff9`b0c84470 @!"GDI32!CreateBitmapStub"
 ...
 42: 00007ff9`b0c8713e @!"GDI32!_imp_load_GetBitmapBits"
 43: 00007ff9`b0c89580 @!"GDI32!GdiConvertBitmapV5"
 44: 00007ff9`b0c89080 @!"GDI32!DwmCreatedBitmapRemotingOutput"
 45: 00007ff9`b0c8aaac @!"GDI32!_imp_load_SetBitmapDimensionEx"

0:007> .bpcmds
bu0 @!"GDI32!IsCreateCompatibleBitmapPresent" "? @$tid; k; gc";
bu1 @!"GDI32!_imp_load_CreateDIBitmap" "? @$tid; k; gc";
bu2 @!"GDI32!_imp_load_DwmCreatedBitmapRemotingOutput" "? @$tid; k; gc";
bu3 @!"GDI32!NtGdiGetBitmapDpiScaleValue" "? @$tid; k; gc";
bu4 @!"GDI32!_imp_load_ClearBitmapAttributes" "? @$tid; k; gc";
bu5 @!"GDI32!_imp_load_CreateDiscardableBitmap" "? @$tid; k; gc";
...

天網恢恢,疏而不漏,肯定會命中其中一個的,接下來繼續 g 讓程式跑起來,你會看到有大量的方法被命中,並且仔細觀察會有一個使用者態函數 <button1_Click>b__1_0,截圖如下:

此時這個託管函數就是重點懷疑物件,也就很輕鬆的找到問題之所在,有些朋友可能要問,這樣重複的資訊是不是會很多,那當然了,大家可以根據輸出資訊做下一步的洞察,比如上面的 gdiplus!CopyOnWriteBitmap::CreateHBITMAP 函數會特別多,這時候可以重新 bp 來縮小範圍,對吧!參考程式碼如下:


0:010> bc *
0:010> bp gdiplus!CopyOnWriteBitmap::CreateHBITMAP "? @$tid; k; gc"

0:010> g
Evaluate expression: 15768 = 00000000`00003d98
 # Child-SP          RetAddr               Call Site
00 000000bb`041febd8 00007ff9`9df0a21f     gdiplus!CopyOnWriteBitmap::CreateHBITMAP
01 000000bb`041febe0 00007ff9`9df0a19a     gdiplus!GpBitmap::CreateHBITMAP+0x3b
02 000000bb`041fec10 00007ff9`72442c61     gdiplus!GdipCreateHBITMAPFromBitmap+0xaa
03 000000bb`041fec50 00007ff9`72439471     System_Drawing_ni+0x72c61
04 000000bb`041fed10 00007ff9`7243940a     System_Drawing_ni!System.Drawing.Bitmap.GetHbitmap+0x51
05 000000bb`041fed70 00007ff9`36d02a75     System_Drawing_ni!System.Drawing.Bitmap.GetHbitmap+0x7a
06 000000bb`041fede0 00007ff9`8d597a47     WindowsFormsApp1!WindowsFormsApp1.Form1.<>c.<button1_Click>b__1_0+0x75
...

三:總結

說實話,找到程式的 GDI控制程式碼洩露 的前因後果難度係數還是蠻高的,在沒有系統科學的工具和基礎知識之前,花費幾天的時間排查這個問題是很正常的,相信這篇文章給後來人少踩坑吧。

圖片名稱