窗口隐藏与反截屏系列 · 第 2 期
工具:IDA · CE · Hawkeye · Cursor MCP
目录
- 背景与系列规划
- Visual 是什么
- 跨进程 Visual 与 window_hijack
- CVisual 隐藏与 HasProtectedContent
- 开源说明
1. 背景与系列规划
本系列共三篇,本篇为第二期:
- 第 1 期:
SetWindowDisplayAffinity 原理与检测
- 第 2 期(本文):Visual 层隐藏原理与检测
- 第 3 期:基于 BitBlt 的「超级截屏」方案
与 DisplayAffinity 不同:Affinity的技术细节主要基于 CWindowContext 对象;Visual 相关技术细节在 dwmcore 的合成节点上(CVisual对象)。
2. Visual 是什么
在进入正题之前,我觉得有必要先稍微介绍下 Visual:
DWM 在 dwm.exe / dwmcore.dll 里维护一棵全局 Visual 树。树上每个 Visual(CVisual类) 是一个合成节点;许多 top-level 窗口在这个全局 Visual 树中对应一棵子树,子树根节点往往可关联到 HWND,其下子 Visual 共同描述该窗口在桌面上的画面。DWM 根据窗口 Z 序、节点层级、遮挡与各类标志,在合成阶段将这些 Visual 融合成最终桌面帧。我们所看到显示器上的画面,本质上是这棵全局 Visual 树融合取舍后的结果。
由此可见,Visual 才是 DWM 所管理的最小单位。并非窗口。
3. 不靠谱的模型:跨进程 Visual 与 window_hijack
正如上面介绍的那样,DWM 眼睛里,他管理的更多的是 Visual,即,某一个 Visual 属于哪个窗口,属于哪个进程其实并不关心。事实也是如此。基于 window_hijack 源码可以发现,某进程把自己创建的 Visual 融合到其他进程窗口这个异常行为也就是简简单单的几个判断进行阻止。判断逻辑在:
win32kfull!NtUserCreateDCompositionHwndTarget
└─ TestWindowForCompositionTarget
一旦突破之后,系统会像没事人一般的,愉快的工作着,再也不会校验某个 Visual 是否异常。
其实,到这里也没啥,一个 Visual 在哪显示其实都一样。只不过,如果这个 Visual 带有反截屏技术加持,那就比较棘手了。假设你是个游戏反作弊工程师,你会很诧异,明明作弊透视方框绘制在游戏窗口上,竟始终找不到绘制源。
4. CVisual 隐藏与 HasProtectedContent
基于对 dwmcore 的深入研究,得出几个结论:每个 Visual 对应一个 CVisual 对象;CVisual 类派生自 CResource 类;CWindowNode 是 CVisual 的子类,用来表示绑定了窗口句柄的子树根节点 Visual.
基于第一期的启发,我们尝试:
!probe -find:has,protect -mod:dwmcore,dwmredir,同名函数在 dwmredir 与 dwmcore 各有一路。
如上图,在使用 Hawkeye 联合查询的时候,不仅发现 dwmredir 中存在,竟然 dwmcore 中也存在如此抓眼球的同名函数:
CWindowContext::HasProtectedContent(dwmredir)
CVisual::HasProtectedContent(dwmcore)
看来微软是打算把君子坦荡荡贯彻到底了。
反汇编 dwmcore!CVisual::HasProtectedContent 非常直接:
读取 [rcx+0x6A],SHR 7 后返回,即检测该字节的 bit 7。
根据上一期的经验,很容易会联想到 CVisual 对象的 +0x6A 偏移处,bit 7 就是关键标志位。
定位 CVisual 实例时,前 8 个字节理论上也应该是 CVisual 虚表指针(本机示例如下):

!probe -find:cvisual,vftable -mod:dwmcore,定位 CVisual vftable。
此刻的虚表指针地址:0x7FF91D4F7F78
vftable 地址在 dwmcore.dll 内,可用于在 DWM 堆上扫描对象。
对此地址数值 CE 中进行搜索和排查。发现 CVisual 对象前 8 个字节确实是其虚表指针。且偏移 +0x6A 处的 bit 7 为是否反截屏保护的关键标志位。
这似乎还不够,第一期中,我们得到了 PID、窗口句柄,甚至还可以得到窗口的大小(可自行挖掘)。而 Visual 这边能拿到的就比较少了(或者说如果想拿,得做好兼容工作)。
先说,基于 Visual 能拿到窗口句柄么?
能,正如上文介绍的那样,如果这个 Visual 就是个子树根节点,就可以直接拿。具体参考 CVisual::GetHwnd。如果这个 Visual 就是一个子节点,那只能手动找到父节点,直到某个父节点是子树根节点,就能直接通过 CVisual::GetHwnd 拿到窗口句柄。其中涉及到的兼容性问题是客观存在的。
基于 Visual 能拿到其所属的进程 PID 么?
可以。由于上文已经提及,CVisual 派生自 CResource,因此:
!probe -find:processid,own -mod:dwmcore,定位 CResource::GetOwningProcessId。
反汇编 dwmcore!CResource::GetOwningProcessId:
handle 在 visual+0x38;有效时 PID 在 handle+0x1C。
实现逻辑很直观。兼容性上,偏移宜从指令动态解析,不宜硬编码。
可疑 Visual 与 PID 一旦对应上,整条排查链路也就通了。
5. 开源说明
第 1 期 DisplayAffinity 检测已在 Hawkeye Community 完整开源(!hidden_windows)。Visual 层检测目前仍偏研究向、适用面相对较窄,对应检测思路本次暂未在 Community 开源。文中复现所需的 !probe、符号加载与内存分析工具仍可在 Community 获取,读者可按上文思路在本地自行验证。
系列下一篇:BitBlt 的「超级截屏」方案,已在 Hawkeye Community 开源。
- 源码仓库:github.com/hawkeye-Leo/hawkeye-community
- 预编译包:GitHub Releases(latest)