返回 2026-07-16
⚙️ 工程

关闭显示控制面板时出现无效函数指针的案例The case of the invalid function pointer when shutting down the display control panel

文章分析了在关闭显示控制面板时遇到无效函数指针错误的底层原因和调试过程。通过深入剖析 Windows 系统的内部机制,追踪了函数指针失效的具体路径。结论是深入理解操作系统底层机制对于解决复杂的系统级 Bug 至关重要。

Raymond Chen

显示控制面板中最常见的崩溃看起来是这样的:

rax=ffffffff924bbde0 rbx=0000000000000001 rcx=0000000000030440
rdx=0000000000000002 rsi=0000000000030440 rdi=0000000080006011
rip=00007ffac835cd1e rsp=000000155e48e3f8 rbp=000000155e48e749
 r8=0000000000000000  r9=0000000000000000 r10=007fffffffe41b69
r11=00007df502390000 r12=0000000000000000 r13=0000000000000000
r14=0000000000000002 r15=0000000000000000
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
ntdll!LdrpDispatchUserCallTarget+0xe:
00007fff`924acd1e mov     r11,qword ptr [r11+r10*8] ds:04007df5`0159db48=????????????????
0:000> k
Call Site
ntdll!LdrpDispatchUserCallTarget+0xe
user32!UserCallWinProcCheckWow+0x2bd
user32!DispatchClientMessage+0x9c
user32!__fnDWORD+0x33
ntdll!KiUserCallbackDispatcherContinue
win32u!ZwUserDestroyWindow+0x14
comctl32!_RealPropertySheet+0x36d
comctl32!_PropertySheet+0x47
Display!PropertySheetW+0x5d
Display!AdvancedSettingSheetHelper+0x3be
Display!ShowAdapterSettings+0x89
rundll32!CallRunDllFunction+0x1c
rundll32!wWinMain+0x2bf
rundll32!__wmainCRTStartup+0x1c9
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21

从堆栈来看,我们有一个显示适配器设置属性表。我们正在销毁它,但在尝试验证窗口过程地址时崩溃了。

我们之前看到过,你可以通过检查提取出这个错误地址。

0:000> u .-e .
ntdll!LdrpDispatchUserCallTarget:
00007fff`924acd10 mov     r11,qword ptr [ntdll+0x001813a8]
00007fff`924acd17 mov     r10,rax
00007fff`924acd1a shr     r10,9
00007fff`924acd1e mov     r11,qword ptr [r11+r10*8]

作为移位源的寄存器是 rax,所以那是函数指针。从寄存器转储中,我们看到地址是

rax=ffffffff924bbde0

是的,那个地址看起来不像一个有效的函数指针。

在 64 位系统上,用户模式指针具有低地址(以 0000 开头),而内核模式指针具有高地址(以 ffff 开头)。因此,这个函数指针对于用户模式显然是无效的。

也许我们可以修复它,让它再次有效。让我们看看这个进程中哪些代码地址是有效的。

0:000> lm
start             end                 module name
00000001`80000000 00000001`80043000   contoso
00007ff6`44570000 00007ff6`44587000   rundll32
00007fff`6a4f0000 00007fff`6a6b7000   d3d9
00007fff`6e600000 00007fff`6e6a9000   comctl32_7fff6e600000
00007fff`6f5d0000 00007fff`6f5e5000   pcacli
00007fff`753b0000 00007fff`753c1000   sfc_os
...
00007fff`91020000 00007fff`910f0000   comdlg32
00007fff`912b0000 00007fff`915e6000   combase
00007fff`91600000 00007fff`91794000   user32
00007fff`917a0000 00007fff`91852000   kernel32
00007fff`918e0000 00007fff`91989000   SHCore
00007fff`91990000 00007fff`91ae6000   ole32
00007fff`91af0000 00007fff`91b16000   gdi32
00007fff`91b20000 00007fff`91bc3000   advapi32
00007fff`91bd0000 00007fff`91c67000   sechost
00007fff`91c70000 00007fff`91cc2000   shlwapi
00007fff`91cd0000 00007fff`91ced000   imagehlp
00007fff`91d50000 00007fff`921c0000   setupapi
00007fff`92220000 00007fff`92355000   msctf
00007fff`92420000 00007fff`92610000   ntdll
...

我怀疑函数指针被截断成了 32 位值,然后又被符号扩展回 64 位值。因此,我们正在寻找形式为 xxxxxxxx`924bbde0 的有效函数指针。在上面的有效代码地址列表中,低位在 92xxxxxx 范围内的地址的高 32 位全都是 00007fff,所以让我们把它代入,看看是否能得到一个窗口过程。

0:000> ln 7fff924bbde0
(00007fff`924bbde0)   ntdll!NtdllButtonWndProc_A   |  (00007fff`924bbdf0)   ntdll!NtdllButtonWndProc_W

中大奖了。

所以调用者可能对窗口进行了子类化,然后试图恢复原始的窗口过程,但搞砸了,只恢复了低 32 位。

但那会是谁呢?

0:000> k
Call Site
ntdll!LdrpICallHandler+0xf
ntdll!RtlpExecuteHandlerForException+0xf
ntdll!RtlDispatchException+0x219
ntdll!KiUserExceptionDispatch+0x2e
ntdll!LdrpDispatchUserCallTarget+0xe
user32!UserCallWinProcCheckWow+0x2bd
user32!DispatchClientMessage+0x9c
user32!__fnDWORD+0x33
ntdll!KiUserCallbackDispatcherContinue
win32u!ZwUserDestroyWindow+0x14
comctl32!_RealPropertySheet+0x36d
comctl32!_PropertySheet+0x47
Display!PropertySheetW+0x5d
Display!AdvancedSettingSheetHelper+0x3be
Display!ShowAdapterSettings+0x89
rundll32!CallRunDllFunction+0x1c
rundll32!wWinMain+0x2bf
rundll32!__wmainCRTStartup+0x1c9
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21

这是一个属性表,所以我们应该能够提取属性表的页面。(注意:需要 Microsoft 内部符号,所以你无法在家里做到这一点。)

0:000> .frame d
09 00000017`85a7e820 00007fff`86e60349 Display!AdvancedSettingSheetHelper+0x3be
0:000> dv
     hwndParent = <value unavailable>
            psh = struct _PROPSHEETHEADERW_V2
      szMonitor = wchar_t [140] "Generic PnP Monitor"
         rPages = struct _PSP *[100]
        iResult = 0n0

桌面背景控制面板是可扩展的,插件向桌面背景控制面板添加页面的方式是处理 IShellPropSheetExt::AddPages 方法,并使用 HPROPSHEETPAGE 调用提供的“页面添加函数”。该函数的作用是将 HPROPSHEETPAGE 添加到属性表的页面中。(我们可以看到 rPages 中有容纳 100 个页面的空间。)

而 psh 就是 PROPSHEETHEADER。

0:000> ?? psh
struct _PROPSHEETHEADERW_V2
   +0x000 dwSize           : 0x60
   +0x004 dwFlags          : 0x2000001
   +0x008 hwndParent       : 0x00000000`000401aa HWND__
   +0x010 hInstance        : 0x00007fff`86e50000 HINSTANCE__
   +0x018 hIcon            : (null)
   +0x020 pszCaption       : 0x00000017`85a7f100  "Generic PnP Monitor and Contoso Chipset"
   +0x028 nPages           : 4
   +0x030 nStartPage       : 0
   +0x038 ppsp             : 0x00000017`85a7ec70 _PROPSHEETPAGEW
   +0x038 phpage           : 0x00000017`85a7ec70  -> 0x000001d5`4e1aac90 _PSP

我们看到有四个页面,所以我们可以检查 rPages 中的前四个 HPROPSHEETPAGE。

嘿,看,我们有一个 HPROPSHEETPAGE 结构数组

0:000> ?? psh.phpage[0]
struct _PSP * 0x000001d5`4e1aac90
0:000> ?? psh.phpage[1]
struct _PSP * 0x000001d5`4e19e470
0:000> ?? psh.phpage[2]
struct _PSP * 0x000001d5`4e19e520
0:000> ?? psh.phpage[3]
struct _PSP * 0x000001d5`4e1d26d0

HPROPSHEETPAGE 是一个不透明结构,但我们可以转储它并寻找有趣的东西,仅供娱乐。

0:000> dps 0x000001d5`4e1aac90 l4
000001d5`4e1aac90  000001d5`4e1aac60
000001d5`4e1aac98  00000000`00000000
000001d5`4e1aaca0  00004088`00000068
000001d5`4e1aaca8  00007fff`88d70000 deskadp
0:000> dps 0x000001d5`4e19e470 l4
000001d5`4e19e470  000001d5`4e19e440
000001d5`4e19e478  00000000`00000000
000001d5`4e19e480  00004088`00000068
000001d5`4e19e488  00007fff`893e0000 deskmon
0:000> dps 0x000001d5`4e19e520 l4
000001d5`4e19e520  000001d5`4e19e4f0
000001d5`4e19e528  00000000`00000000
000001d5`4e19e530  000040c8`00000068
000001d5`4e19e538  00007fff`86e30000 colorui
0:000> dps 0x000001d5`4e1d26d0 l4
000001d5`4e1d26d0  000001d5`4e1bcb30
000001d5`4e1d26d8  000001d5`4e1d26a0
000001d5`4e1d26e0  0000008a`00000068
000001d5`4e1d26e8  00000001`80000000 contoso

这里有一堆 HMODULE,它们可能是属性表页面所属的模块。前三个随 Windows 一起提供。最后一个显然是 Contoso。让我们把注意力集中在最后一个上。

在前两个值(看起来像指针)之后,我们有 0x00000068,这并非巧合,它正是 sizeof(PROPSHEETPAGE),所以我猜测这是系统存储创建句柄所用的 PROPSHEETPAGE 的地方。

注意:请注意这是一个实现细节,应仅用于调试目的。请不要编写依赖于此的程序,因为它可能会发生变化。¹

0:000> dt comctl32!_PROPSHEETPAGEW  000001d5`4e1d26e0
   +0x000 dwSize           : 0x68
   +0x004 dwFlags          : 0x8a
   +0x008 hInstance        : 0x00000001`80000000 HINSTANCE__
   +0x010 pszTemplate      : 0x00000000`00000589  "--- memory read error at address 0x00000000`00000589 ---"
   +0x010 pResource        : 0x00000000`00000589 DLGTEMPLATE
   +0x018 hIcon            : 0x00000000`000503b9 HICON__
   +0x018 pszIcon          : 0x00000000`000503b9  "--- memory read error at address 0x00000000`000503b9 ---"
   +0x020 pszTitle         : 0x000001d5`4e19cde0  "?????"
   +0x028 pfnDlgProc       : 0x00000001`800047ac contoso+0x47ac
   +0x030 lParam           : 0n2015682301296
   +0x038 pfnCallback      : (null)
   +0x040 pcRefParent      : (null)
   +0x048 pszHeaderTitle   : (null)
   +0x050 pszHeaderSubTitle : (null)
   +0x058 hActCtx          : (null)
   +0x060 hbmHeader        : (null)
   +0x060 pszbmHeader      : (null)

对话框过程是 0x00000001`800047ac。我希望能对它进行足够的逆向工程,以找出它错误地对按钮进行子类化的地方。

00000001`800047ac mov     [rsp+8],rbx
00000001`800047b1 mov     [rsp+10h],rbp
00000001`800047b6 mov     [rsp+18h],rsi
00000001`800047bb push    rdi
00000001`800047bc sub     rsp,30h
00000001`800047c0 mov     rdi,r9                ; rdi = r9 = lParam
00000001`800047c3 mov     rbp,r8                ; rbp = r8 = wParam
00000001`800047c6 mov     esi,edx               ; esi = edx = message
00000001`800047c8 mov     rbx,rcx               ; rbx = rcx = hdlg
00000001`800047cb cmp     edx,110h              ; Q: WM_INITDIALOG?
00000001`800047d1 jne     00000001`800047e2     ; N: Skip
00000001`800047d3 mov     r8,[r9+30h]           ; Y: r8 = ((PROPSHEETPAGE*)r9)->lParam
00000001`800047d7 mov     edx,0FFFFFFEBh        ; edx = -21
                                                ; ecx = hdlg (unchanged)
00000001`800047dc call    [00000001`8002b4a0]   ; mystery function 1

00000001`800047e2 mov     edx,0FFFFFFEBh        ; edx = -21
00000001`800047e7 mov     rcx,rbx               ; rcx = hdlg
00000001`800047ea call    [00000001`8002b480]   ; mystery function 2
00000001`800047f0 test    rax,rax               ; Q: Failed?
00000001`800047f3 je      00000001`8000480b     ; Y: Bail out
00000001`800047f5 mov     r9,rbp                ; param4 = wParam
00000001`800047f8 mov     r8d,esi               ; param3 = message
00000001`800047fb mov     rdx,rbx               ; param2 = hdlg
00000001`800047fe mov     rcx,rax               ; param1 = from mystery function 2
00000001`80004801 mov     [rsp+20h],rdi         ; param5 = lParam
00000001`80004806 call    00000001`800045fc     ; mystery function 3
00000001`8000480b mov     rbx,[rsp+40h]         ; restore registers
00000001`80004810 mov     rbp,[rsp+48h]
00000001`80004815 mov     rsi,[rsp+50h]
00000001`8000481a add     rsp,30h
00000001`8000481e pop     rdi
00000001`8000481f ret                            ; done

我们知道 WM_INITDIALOG 消息的 lParam 参数是作为“参数”传递给 CreateDialogParam 等函数的值,特别是对于属性表,它是指向 PROPSHEETPAGE 的指针。我们从上面的结构转储中看到,偏移量 0x30 就是 lParam。

从这个函数的结构来看,很明显那个魔数 -21 就是 GWLP_USERDATA,神秘函数 1 是 SetWindowLongPtr,而神秘函数 2 是 GetWindowLongPtr。这是对话框函数的标准模式,通常使用包装函数。

真正的对话框过程是第三个神秘函数,让我们来看看它。

00000001`800045fc mov     [rsp+8],rbx
00000001`80004601 mov     [rsp+10h],rbp
00000001`80004606 mov     [rsp+18h],rsi
00000001`8000460b push    rdi
00000001`8000460c push    r12
00000001`8000460e push    r13
00000001`80004610 sub     rsp,20h
00000001`80004614 mov     rsi,[rsp+60h]     ; rsi = lParam
00000001`80004619 mov     rbp,r9            ; rbp = wParam
00000001`8000461c mov     ebx,r8d           ; ebx = message
00000001`8000461f mov     r13,rdx           ; r13 = hdlg
00000001`80004622 mov     rdi,rcx           ; rdi = this
00000001`80004625 cmp     r8d,2Bh           ; Q: WM_DRAWITEM?
00000001`80004629 jne     00000001`80004685 ; N: Skip

在最初的寄存器溢出和保存之后,它检查消息是否为 0x2B: WM_DRAWITEM。这对我们来说不是特别有趣,所以我们假设它不是。

00000001`80004685 sub     ebx,2             ; Q: WM_DESTROY?
00000001`80004688 je      00000001`8000470f

哦,WM_DESTROY 消息很有趣。它可能在其 WM_DESTROY 处理程序中恢复原始的窗口过程,而这正是我们希望找到截断的地方。

00000001`8000470f mov     rcx,[rdi+110h]        ; rcx = something
00000001`80004716 movsxd  rbx,dword ptr [00000001`80039c50] ; rbx = something
00000001`8000471d mov     edx,668h              ; ecx = some number
00000001`80004722 call    [00000001`8002b4e0]   ; mystery function 4
00000001`80004728 mov     r8,rbx                ; r8 = something
00000001`8000472b mov     edx,0FFFFFFFCh        ; edx = -12
00000001`80004730 mov     rcx,rax               ; rcx = function 4 retval
00000001`80004733 call    [00000001`8002b4a0]   ; mystery function 1 again

收到 WM_DESTROY 消息后,代码首先从 this 指针中获取一些东西(我们在序言中看到它被保存在 rdi 中),并从全局变量中加载一些其他东西。

接下来,它调用神秘函数 00000001`8002b4e0,并将 0x668 作为第二个参数。不确定那是什么,但我们会记住它。

接下来,我们为另一个函数调用做准备,而这个我们认得:00000001`8002b4a0 是 SetWindowLongPtr 的导入地址表条目。我们在静态对话框过程中见过它。

参数是从神秘函数 4 获取的窗口句柄、常量 -12,以及我们从 00000001`80039c50 加载的 32 位值。神秘函数 4 可能是 GetDlgItem。既然我们已经弄清楚被调用的函数是 SetWindowLongPtr,那么值 -12 就是 GWLP_WNDPROC。

被设置的值是第三个参数,它是通过 movsxd dword ptr 加载的,这是一个从 32 位到 64 位的符号扩展加载。这是一个问题,因为窗口过程是一个 64 位的值。

我打赌他们加载值的方式不正确。

0:000> dp 00000001`80039c50 l1
00000001`80039c50  00007fff`924bbde0

嘿,看,这就是我们本应该使用的完整 64 位指针,只是我们搞砸了并截断了指针。

C++ 源代码可能看起来像这样:

SetWindowLongPtr(GetDlgItem(m_hdlg, 0x668),
    GWLP_WNDPROC, (LONG)g_originalWndProc);

转换为 LONG 就是导致截断和符号扩展的原因。它应该转换为 LONG_PTR。

在查阅了处理器指令编码文档后,我们可以将其修补到二进制文件中。

原始指令是

00000001`80004716 48631d33550300  movsxd  rbx,dword ptr [00000001`80039c50]

文档说 movxsd r64, r/m32 的编码是“REX.W + 63 /r”。

我们想要的是 mov rbx, [00000001`80039c50],而文档说 mov r64, r/m64 的编码是“REX.W + 8B /r”。

所以让我们把 63 修补为 8b。

0:000> eb 00000001`80004717 8b
0:000> u 00000001`80004716 l1
00000001`80004716 488b1d33550300  mov     rbx,qword ptr [00000001`80039c50]

这字面上就是一个单字节的错误修复。

下次,我们将推测这个 bug 是如何产生的。

额外阅读:诱饵控制面板。

¹ 早在 20 世纪 90 年代末,我们发现了一个程序,它对 Windows 95 属性表管理器的内部数据结构进行了逆向工程,以至于它没有传递由 CreatePropertySheetPage 函数创建的 HPROPSHEETPAGE,而是创建了它在内存中手动构建的伪造 HPROPSHEETPAGE。这使得添加对 Unicode 属性表的支持变得困难得多,因为 HPROPSHEETPAGE 的内部结构为了同时支持 ANSI 和 Unicode 属性表页面而发生了变化,而它们传递的是旧版本。属性表管理器必须识别出它被赋予了一个伪造的 HPROPSHEETPAGE,并即时将其转换为真实的 HPROPSHEETPAGE。

作者

Raymond 参与了 Windows 的发展演变超过 30 年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他的想象,这一发展至今仍让他感到有些发怵。这个网站催生了一本书,巧合的是书名也叫《The Old New Thing》(Addison Wesley 2007 年出版)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲一些毫无实用信息的故事。

需要完整排版与评论请前往来源站点阅读。