Hawkeye Research

Research · Capture · Part 3

BitBlt “super screenshot” (DWM readback)

Hidden windows & anti-capture series Tools: IDA · Cheat Engine · Hawkeye Community

1. TL;DR & scope

A normal BitBlt screenshot enters the kernel, then continues into DWM’s dwmcore!CaptureBitsResponse::RenderForCapture path. During capture, DWM sets a readback flag at g_pComposition+0x196A (CComposition::IsScreenReadBack on Win11-class builds). DrawVisualTree consults that flag when deciding whether content protected by part 1 (DisplayAffinity) and part 2 (Visual hiding) should appear in the output. If the write of 1 is suppressed for one capture frame, BitBlt can return pixels that bypass both protection mechanisms — the “super screenshot” behavior described here.

Test environment: Windows 11 x64, build 26100 (figures below). The capture-path semantics apply to Windows 10 and Windows 11; the patch site encoding and +0x196A offset vary by build — locate RenderForCapture from dwmcore.dll symbols (PDB) and scan for the store pattern rather than hard-coding bytes. Hawkeye Community ships !screensnap, which automates pattern discovery, temporary patching, BitBlt capture, and restore. Scope: reverse-engineering notes on why window-level anti-capture can fail under BitBlt readback manipulation, and what that implies for defenders; not a cheat or evasion guide.

2. Background & series

This series has three parts. This article is part 3:

Parts 1 and 2 documented defender-side detection surfaces in dwm.exe. This part closes the loop: it explains the DWM capture path and why BitBlt readback semantics can bypass both protection styles if an attacker briefly suppresses the readback flag during RenderForCapture. Understanding that gap matters for anti-cheat and forensics teams who treat DisplayAffinity alone as sufficient protection.

3. BitBlt & DWM

Simplified flow:

gdi32!BitBlt
  └─ win32kfull!NtGdiBitBlt
       └─ DwmSyncCaptureSurfaceBits
            └─ LpcSendWaitReceivePort → dwm.exe
                 └─ dwmcore!CaptureBitsResponse::RenderForCapture
                      └─ return to caller

Like DisplayAffinity, a BitBlt call enters the kernel first, then sends an LPC message to dwm.exe. Execution lands in dwmcore!CaptureBitsResponse::RenderForCapture, then returns through the same path.

A BitBlt screenshot is therefore not decided in the kernel — composition and protection outcomes depend on DWM, primarily dwmcore.dll.

4. Display vs Capture

Analysis shows that Display and Capture use separate RenderTargets but one shared pipeline: different surfaces, same global Visual tree and composition logic.

Normal Display path:

CDDisplayRenderTarget::RenderAndPresent
  → BeginFrame(Display RT)
  → DrawVisualTree
  → Present

Capture path (inside RenderForCapture):

BeginFrame(captureRenderTarget)
  → DrawVisualTree
  → read pixels from capturebits scratch
  → section back to kernel

Takeaway: both paths converge on DrawVisualTree. Any semantic flag that changes what that function draws affects Display, Capture, or both.

5. Readback flag (g_pComposition + 0x196A)

IDA analysis of CaptureBitsResponse::RenderForCapture — simplified call order:

CaptureBitsResponse::RenderForCapture
  ├─ CComposition::ShowHideCursors(0)       (Hide cursor Visuals; CExcludeVisualReference::Hide)
  ├─ CDrawingContext::Create                (heap AllocClear; RT not bound yet)
  └─ one frame
       ├─ CDrawingContext::BeginFrame       (pass capture IDeviceTarget; bind RT)
       ├─ CDrawingContext::DrawVisualTree    ← shared by Capture & Display
       └─ CDrawingContext::EndFrame
  (then RestoreCursors; clear g_pComposition+0x196A)

DrawVisualTree is the key step: it draws every Visual that should appear; both Capture and Display paths pass through it.

Enter / leave capture

Enter Capture:  [g_pComposition+0x196A] ← 1
  BeginFrame → DrawVisualTree → EndFrame
Leave Capture:  [g_pComposition+0x196A] ← 0

In RenderForCapture, DWM writes 1 to g_pComposition + 0x196A before capture begins. The matching assembly on build 26100:

RenderForCapture: mov r15d, 1; CDrawingContext::Create; mov [rax+196Ah], r15b.
Figure 1. Enter Capture — after mov r15d, 1, store to [g_pComposition+196Ah].

When capture finishes, the flag is cleared to 0 (r13b is 0 here):

RenderForCapture teardown: mov [rcx+196Ah], r13b.
Figure 2. Leave Capture — write [g_pComposition+196Ah] ← 0, then RestoreCursors.

The whole capture frame is wrapped by this write/clear pair.

Cross-check: IsScreenReadBack

A global IDA search for 196Ah leads to readback-related code; on Win11-class builds the PDB names it CComposition::IsScreenReadBack:

IsScreenReadBack disassembly: mov al, [rcx+196Ah]; retn.
Figure 3. CComposition::IsScreenReadBack — load [rcx+196Ah] and return; i.e. test the readback / capture semantic flag.

In practice, the helper answers “are we in readback/capture state right now?” The symbol is often missing on Win10; resolve the field offset from instruction patterns on the target build instead.

6. Validation

Static analysis only goes so far. The decisive test is in a running dwm.exe: at the site marked in Figure 1, change the write from 1 to 0 and observe whether a normal BitBlt still captures protected windows.

Attach to dwm.exe and patch the Figure 1 site — one byte, changing the immediate in mov r15d, 1 from 01 to 00 (the figure below was taken with Hawkeye Lab memory editing; the same workflow is automated in Community via !screensnap):

Hawkeye Lab Memory Viewer: Edit Memory changing 01 to 00 in mov r15d,1.
Figure 4. Turn mov r15d, 1 into mov r15d, 0 (immediate 0100).

Trigger a normal BitBlt capture afterward — windows and Visuals protected under parts 1 and 2 appear in the screenshot.

Side effect worth noting

Under normal conditions the flag only lives for one capture frame: RenderForCapture sets g_pComposition + 0x196A to 1 before drawing, then clears it to 0 when the frame ends.

The inverse experiment is more surprising: leave the capture code alone, but pin that byte to 1 in memory so DWM stays in readback semantics. Protected windows disappear from the live desktop — not transparency, but the Display path skipping protected content because DrawVisualTree reads the same flag. Trigger another capture and let the normal set/clear cycle run; desktop behavior returns.

Repeatable workflow (research reproduction)

The one-byte patch proves the idea; a reproducible workflow patches the readback writer in dwm.exe remotely before the BitBlt call so g_pComposition + 0x196A stays 0 for that capture frame, then restores the original bytes after BitBlt returns. No injection into dwm.exe, no hooks — only a very short-lived code patch.

  1. Locate the patch site. In dwmcore.dll’s RenderForCapture, find the store to [g_pComposition+196Ah]. On build 26100 it is mov [rax+196Ah], r15b (Figure 1, preceded by mov r15d, 1). Encoding varies by build; scan for the pattern instead of hard-coding an absolute address.
  2. Rewrite the instruction. Turn the “write 1” into “write 0”. Either change the store to mov [rax+196Ah], 0, or flip the immediate in mov r15d, 1 (0100).
  3. Take a normal screenshot. Call BitBlt on the primary display.
  4. Restore the patch. Write the original bytes back immediately.

This bypasses both part 1 (DisplayAffinity) and part 2 (Visual hiding) because capture-path protection is gated on readback semantics, not on the protection bits alone.

7. Reference implementation (Hawkeye Community)

The research workflow in this article is implemented in Hawkeye Community as !screensnap. It resolves RenderForCapture from dwmcore.dll symbols, scans the function for the readback store pattern, applies a temporary patch (mov byte ptr [rax+disp32], 0), captures the primary display via BitBlt, then restores the original bytes. Output PNGs land under the application screensnap/ folder.

Use part 1’s !hidden_windows (run !hidden_windows -init first) to confirm protected windows exist, then run !screensnap to reproduce the readback suppression behavior described above. Validated on most Windows 10 and 11 systems we tested — not every build or configuration. Patch-site discovery is pattern-based rather than hard-coded to build 26100.

Source: github.com/hawkeye-Leo/hawkeye-community (capture/screensnap.cpp)
Binaries: GitHub Releases (latest)

Series complete. Part 1 · Part 2 · Capture index

↑ Contents

← Part 2 Capture series