26 / 52 主插件与远程能力

第 26 课:主插件:查注册表

# 第 26 课:主插件:查注册表

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

本课定位:防御分析、源码阅读、注册表风险识别和企业检测工程。
本课只讲注册表插件的结构、数据流、风险面和检测思路,不提供未授权修改注册表、自启动持久化、策略绕改或破坏系统配置的操作步骤。

第 25 课讲的是文件管理模块,它直接处理终端文件系统。本课转向 Windows 另一块关键配置区域:注册表。

注册表保存了大量系统、用户、软件、服务、策略和文件关联配置。合法管理工具会读取注册表做资产盘点和配置核查;攻击工具也常滥用注册表做自启动、策略修改、文件关联劫持和配置隐藏。所以学习这一课时,要把重点放在两个问题上:

  1. 源码如何枚举和修改注册表?
  2. 企业如何把注册表访问行为纳入检测和加固?

本课主线如下:

查注册表插件入口
  -> 创建 CTcpSocket / CUdpSocket
  -> 连接主控端
  -> 创建 CRegeditManager
  -> 构造函数发送 TOKEN_REGEDIT
  -> OnReceive 按注册表命令分发
  -> ParseKey 选择根键
  -> GetRegInfo / GetRegKey 枚举子键和值
  -> CreateKey / CreateKeyEx / DeleteKey / DeleteVal / Rename 修改对象
  -> 企业侧关联进程、API、根键、路径、值类型和网络会话
1
2
3
4
5
6
7
8
9
10

# 1. 本课学习目标

学完本课,你应该能回答这些问题:

问题 本课回答
查注册表插件在工程中做什么 通过 CRegeditManager 接收命令,枚举、创建、删除、写入和重命名注册表对象
为什么注册表模块风险高 注册表控制启动项、服务、策略、文件关联、应用配置和用户环境
REGMSG 起什么作用 描述回包或写入数据里的数量、名称长度和值长度
枚举注册表分成哪两部分 GetRegInfo 枚举子键,GetRegKey 枚举值
写值支持哪些类型 源码覆盖 REG_SZREG_DWORDREG_EXPAND_SZ
企业检测应关注什么 根键、路径、值名、值类型、API、进程、网络会话和结果 token

# 2. 本课涉及源码目录和文件

文件 作用
主插件\查注册表\查注册表.sln 查注册表插件解决方案
主插件\查注册表\查注册表\查注册表.cpp 插件入口、连接选择、创建 CRegeditManager
主插件\查注册表\查注册表\RegeditManager.h 命令字、返回 token、REGMSG、类声明
主插件\查注册表\查注册表\RegeditManager.cpp 注册表枚举、写入、删除、重命名核心实现

2. 本课涉及源码目录和文件(配图)

这个模块比文件管理模块小,但风险不低。原因是注册表不是普通数据文件,它会影响系统行为、软件启动、策略生效和用户环境。

# 3. 插件入口:连接后创建 CRegeditManager

入口模式与文件管理插件一致:选择 TCP/UDP,连接成功后创建管理器对象,进入事件循环。

// 源码位置:主插件\查注册表\查注册表\查注册表.cpp:7-41
// 防御分析注释:
// 1. 这是查注册表插件入口,只负责读取连接配置、建立 socket、创建 CRegeditManager。
// 2. plugInfo 保存远端地址、端口、协议类型和入口运行方式,可理解为“插件上线参数”。
// 3. MainThread 连接成功后创建 CRegeditManager,后续注册表命令都由 OnReceive 分发。
// 4. 注册表访问行为要与网络会话关联,否则只看 RegOpenKeyEx 很难判断上下文。
struct plugInfo
{
    char mark[30];          // 配置定位标记,常用于在二进制中寻找配置块。
    TCHAR szAddress[255];   // 远端地址;要和注册表 API 行为、进程路径一起关联。
    DWORD szPort;           // 远端端口;端口本身不是风险,关键是连接后的注册表能力。
    BOOL IsTcp;             // 通信协议选择;同一注册表能力可复用 TCP 或 UDP。
    BOOL RunDllEntryProc;   // DLL 入口运行模式标志;影响插件退出行为。
} MyInfo =
{
    "plugmark",
    _T("192.168.50.142"),
    5554,
    1,
    0,
};

DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    ISocketBase* socketClient;

    // 触发点 1:根据配置选择通信对象。
    // 检测时不要只依赖固定协议或固定端口。
    if (MyInfo.IsTcp == 1)
        socketClient = new CTcpSocket();
    else
        socketClient = new CUdpSocket();

    // 触发点 2:连接成功后创建 CRegeditManager。
    // 构造函数会发送 TOKEN_REGEDIT,随后等待主控端下发注册表命令。
    if (socketClient->Connect(MyInfo.szAddress, MyInfo.szPort))
    {
        CRegeditManager 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
40
41
42
43
44
45
46

这一段的安全含义和前面几课一致:真正危险的不是“有一个 socket”,而是 socket 后面连接着什么能力。这里连接的是注册表读取和修改能力。

# 4. 命令面总览

4. 命令面总览(配图)

先看 RegeditManager.h 里的命令字和类声明。这个模块的命令面非常集中:

命令 作用 防御关注
COMMAND_REG_ENUM 枚举子键和值 是否批量读取敏感根键和关键路径
COMMAND_REG_CREATEKEY 创建子键 是否创建启动项、服务或策略相关键
COMMAND_REG_CREATKEY 创建或写入值 值类型、值名、数据内容和目标路径
COMMAND_REG_DELKEY 删除子键 可能破坏系统配置或清理痕迹
COMMAND_REG_DELVAL 删除值 可能关闭安全策略或破坏应用配置
COMMAND_REG_RENAME 重命名键 可能影响追踪和配置一致性
// 源码位置:主插件\查注册表\查注册表\RegeditManager.h:5-28
// 防御分析注释:
// 1. TOKEN_* 是插件回包类型,COMMAND_* 是主控端请求类型。
// 2. TOKEN_REG_INFO 和 TOKEN_REG_KEY 分别回传子键列表和值列表。
// 3. TOKEN_REG_ERROR / TOKEN_REG_SUCCEED 表示操作结果,可用于审计成功率和失败探测。
// 4. COMMAND_REG_CREATKEY 名称容易误导,源码里它实际负责按类型写入值。
enum
{
    // ===== 回包 token:说明返回内容是子键、值,还是操作结果 =====
    TOKEN_REG_INFO,
    TOKEN_REG_KEY,
    TOKEN_REG_ERROR,
    TOKEN_REG_SUCCEED,

    // ===== 命令:读取、创建、删除、写值、删值、重命名 =====
    COMMAND_REG_ENUM,
    COMMAND_REG_CREATEKEY,
    COMMAND_REG_DELKEY,
    COMMAND_REG_CREATKEY,
    COMMAND_REG_DELVAL,
    COMMAND_REG_RENAME
};

struct REGMSG
{
    int count;      // 子键数量、值数量或字符串长度;含义随 GetRegInfo/GetRegKey/写入场景变化。
    DWORD size;     // 名称缓冲区大小,主控端解包时依赖它切分字段。
    DWORD valsize;  // 值数据缓冲区大小,读取注册表值时用于定位数据块。
};
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
// 源码位置:主插件\查注册表\查注册表\RegeditManager.h:29-72
// 防御分析注释:
// 1. CRegeditManager 既能读注册表,也能修改注册表。
// 2. m_hKey 保存当前操作根键或已打开子键,函数间会复用这个句柄。
// 3. MKEY 通常保存枚举时选择的根键,KeyPath 保存当前枚举路径。
// 4. 检测和审计时必须同时记录根键、子路径、值名、值类型和进程上下文。
class CRegeditManager : public CManager
{
public:
    BOOL m_buser;
    CRegeditManager(ISocketBase* pClient);
    virtual ~CRegeditManager();
    void OnReceive(LPBYTE lpBuffer, UINT nSize);

private:
    void EnumReg(BYTE bToken, LPBYTE lpBuffer);
    LPBYTE GetRegInfo();
    LPBYTE GetRegKey();
    void CreateKey(LPBYTE lpBuffer);
    void DeleteVal(char* buf);
    void DeleteKey(LPBYTE lpBuffer);
    void Rename(char* buf);
    void CreateKeyEx(char* buf);
    BOOL OpenKey(LPCTSTR lpSubKey);
    BOOL WriteValue(LPCTSTR lpSubKey, LPCTSTR lpVal);
    BOOL WriteValue(LPCTSTR lpSubKey, DWORD dwVal);
    BOOL WriteBuf(LPCTSTR lpValueName, LPCTSTR lpValue);

    HKEY  m_hKey;            // 当前打开的键;写入、删除等函数会依赖它。
    HKEY  m_hKey_n;          // 辅助句柄,具体使用场景随实现变化。
    HKEY  MKEY;              // 当前枚举根键,例如 HKLM/HKCU。
    TCHAR KeyPath[MAX_PATH]; // 当前子路径;这是注册表审计里最核心的字段。
};
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

# 5. 构造函数:发送注册表模块 token

构造函数很短,但它有一个关键动作:发送 TOKEN_REGEDIT。这相当于告诉主控端:“注册表插件已经上线,可以开始发送注册表命令”。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:11-18
// 防御分析注释:
// 1. 构造函数创建后立即发送 TOKEN_REGEDIT。
// 2. 这类模块识别 token 可作为网络协议侧检测线索,早于具体 Reg* API 调用。
// 3. m_buser 被置为 TRUE,表示管理器已进入可用状态。
CRegeditManager::CRegeditManager(ISocketBase* pClient) : CManager(pClient)
{
    m_buser = FALSE;

    // 主动告诉主控端:注册表模块已经准备好。
    BYTE bToken = TOKEN_REGEDIT;
    Send(&bToken, 1);

    m_buser = TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

如果网络检测能识别协议 token,就能在真正写注册表之前发现模块类型。即使不能解码 token,也可以通过“连接建立后紧接着大量注册表 API 调用”做行为检测。

# 6. OnReceive:注册表命令分发器

OnReceive 负责把命令字分发到具体函数。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:24-50
// 防御分析注释:
// 1. lpBuffer[0] 是命令字,决定后续读取还是修改注册表。
// 2. lpBuffer 后续区域通常包含根键标识、子路径、值名、值类型和值数据。
// 3. COMMAND_REG_ENUM 走 EnumReg,读取注册表。
// 4. CREATEKEY、CREATKEY、DELKEY、DELVAL、RENAME 都会改变注册表对象或值。
void CRegeditManager::OnReceive(LPBYTE lpBuffer, UINT nSize)
{
    // 心跳包不触发注册表操作。
    if (lpBuffer[0] == TOKEN_HEARTBEAT) return;

    switch (lpBuffer[0])
    {
    case COMMAND_REG_ENUM:
        // 枚举命令:如果包里带了根键和路径,就枚举指定位置;
        // 否则枚举默认根位置。连续枚举敏感路径是数据发现行为。
        if (nSize >= 3 * sizeof(TCHAR))
            EnumReg(lpBuffer[sizeof(TCHAR)], lpBuffer + 2 * sizeof(TCHAR));
        else
            EnumReg(sizeof(TCHAR), NULL);
        break;
    case COMMAND_REG_CREATEKEY:
        // 创建子键。写入启动项、服务、策略等位置时风险较高。
        CreateKey(lpBuffer + sizeof(TCHAR));
        break;
    case COMMAND_REG_DELKEY:
        // 删除子键。删除动作比读取动作风险更高,应单独告警。
        DeleteKey(lpBuffer + sizeof(TCHAR));
        break;
    case COMMAND_REG_DELVAL:
        // 删除值。常用于关闭配置、清理痕迹或破坏应用设置。
        DeleteVal((char*)lpBuffer + 1);
        break;
    case COMMAND_REG_CREATKEY:
        // 写入值。名称虽然像“创建键”,实际会根据值类型写 REG_SZ/REG_DWORD/REG_EXPAND_SZ。
        CreateKeyEx((char*)lpBuffer + 1);
        break;
    case COMMAND_REG_RENAME:
        // 重命名键。可能影响配置追踪和程序行为。
        Rename((char*)lpBuffer + 1);
        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

这一段要注意两个命名细节:

  1. COMMAND_REG_CREATEKEY 创建的是子键。
  2. COMMAND_REG_CREATKEY 名字少了一个 E,但源码里它根据值类型写入 REG_SZREG_DWORDREG_EXPAND_SZ

# 7. 根键选择:ParseKey

7. 根键选择:ParseKey(配图)

注册表操作的第一步是确定根键。源码通过一个字节映射到五个常见根键。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:312-335
// 防御分析注释:
// 1. bType 来自命令包,用于选择注册表根键。
// 2. m_hKey 是后续 OpenKey、CreateKey、DeleteKey 等操作的起点。
// 3. 默认分支落到 HKEY_LOCAL_MACHINE,安全风险较高,因为 HKLM 影响系统级配置。
// 4. 企业检测要记录根键,不同根键代表完全不同的风险面。
void CRegeditManager::ParseKey(BYTE bType)
{
    switch (bType)
    {
    case MHKEY_CLASSES_ROOT:
        // 文件关联、COM、Shell 扩展等配置,适合关注劫持类风险。
        m_hKey = HKEY_CLASSES_ROOT;
        break;
    case MHKEY_CURRENT_USER:
        // 当前用户配置,常见于用户级启动项和应用设置。
        m_hKey = HKEY_CURRENT_USER;
        break;
    case MHKEY_LOCAL_MACHINE:
        // 系统级配置,服务、驱动、安全策略和软件配置通常在这里。
        m_hKey = HKEY_LOCAL_MACHINE;
        break;
    case MHKEY_USERS:
        // 多用户配置视图,适合排查非当前用户的配置变化。
        m_hKey = HKEY_USERS;
        break;
    case MHKEY_CURRENT_CONFIG:
        // 当前硬件配置,通常关注驱动和硬件相关设置。
        m_hKey = HKEY_CURRENT_CONFIG;
        break;
    default:
        // 默认落到 HKLM。真实工程中更建议拒绝未知类型,而不是默认到高风险根键。
        m_hKey = HKEY_LOCAL_MACHINE;
        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

不同根键的安全意义不同:

根键 常见内容 检测重点
HKEY_CLASSES_ROOT 文件关联、COM、Shell 动作 文件打开方式、脚本处理器、COM 劫持线索
HKEY_CURRENT_USER 当前用户配置 用户级启动项、应用配置、登录脚本
HKEY_LOCAL_MACHINE 系统级配置 服务、驱动、策略、安全产品配置
HKEY_USERS 多用户配置映射 其他用户配置变更
HKEY_CURRENT_CONFIG 当前硬件配置 驱动和硬件配置变化

本课不会讲如何设置自启动或修改策略,只强调这些位置为什么需要被监控。

# 8. 打开键:OpenKey 使用 KEY_ALL_ACCESS

源码里打开子键时使用了 KEY_ALL_ACCESS,这意味着只要权限允许,后续可以读取、写入和删除。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:336-347
// 防御分析注释:
// 1. OpenKey 使用当前 m_hKey 作为根键,lpSubKey 是命令包提供的子路径。
// 2. KEY_ALL_ACCESS 权限过宽,合法工具应尽量按需使用 KEY_READ 或 KEY_SET_VALUE。
// 3. 打开失败也值得记录,连续访问被拒绝可能代表探测高权限位置。
// 4. 成功打开敏感路径后,如果紧接着写入或删除,风险等级更高。
BOOL CRegeditManager::OpenKey(LPCTSTR lpSubKey)
{
    HKEY hKey;

    // 这里不只是“读取打开”,而是申请全部访问权限。
    // 对只枚举的场景,KEY_READ 更符合最小权限原则。
    long lReturn = RegOpenKeyEx(m_hKey, lpSubKey, 0L, KEY_ALL_ACCESS, &hKey);
    if (lReturn == ERROR_SUCCESS)
    {
        // 用已打开句柄替换 m_hKey,后续写入/删除会基于这个键。
        m_hKey = hKey;
        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

对合法软件来说,最小权限原则非常重要:只读枚举就不应申请 KEY_ALL_ACCESS。权限申请过宽会让误操作和滥用的风险都变大。

# 9. 枚举流程:子键和值分开回传

9. 枚举流程:子键和值分开回传(配图)

EnumReg 是枚举入口。它先选择根键和路径,再分别调用:

  1. GetRegInfo:枚举子键。
  2. GetRegKey:枚举当前键下面的值。
// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:510-560
// 防御分析注释:
// 1. EnumReg 是 COMMAND_REG_ENUM 的落点。
// 2. bToken 表示根键,lpBuffer 表示子路径。
// 3. GetRegInfo 回传子键信息,GetRegKey 回传值信息。
// 4. 同一会话里连续枚举 HKCU/HKLM 下敏感路径,应作为数据发现行为。
void CRegeditManager::EnumReg(BYTE bToken, LPBYTE lpBuffer)
{
    ZeroMemory(KeyPath, MAX_PATH);

    // 先把命令包里的根键标识转换为真实 HKEY。
    // 这里使用 MKEY 保存枚举根键,避免和 m_hKey 的写入/删除上下文混淆。
    switch (bToken)
    {
    case MHKEY_CLASSES_ROOT:
        MKEY = HKEY_CLASSES_ROOT;
        break;
    case MHKEY_CURRENT_USER:
        MKEY = HKEY_CURRENT_USER;
        break;
    case MHKEY_LOCAL_MACHINE:
        MKEY = HKEY_LOCAL_MACHINE;
        break;
    case MHKEY_USERS:
        MKEY = HKEY_USERS;
        break;
    case MHKEY_CURRENT_CONFIG:
        MKEY = HKEY_CURRENT_CONFIG;
        break;
    default:
        MKEY = HKEY_LOCAL_MACHINE;
        break;
    }

    // 子路径来自网络命令包。合法工具应做路径允许范围控制。
    if (lpBuffer != NULL)
        lstrcpy(KeyPath, (TCHAR*)lpBuffer);

    // 第一段回包:子键列表。
    LPBYTE lpTemp = GetRegInfo();
    if (lpTemp == NULL)
        SendError();
    else
        Send(lpTemp, (int)(LocalSize(lpTemp)));

    // 第二段回包:当前键下面的值列表。
    lpTemp = GetRegKey();
    if (lpTemp != NULL)
        Send(lpTemp, (int)(LocalSize(lpTemp)));
    else
        SendError();
}
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

这里的设计很适合教学:一个注册表路径下面有两类东西,子键和值。源码把它们分成两个包回传,新手可以把它理解成:

注册表键
  -> 子键列表:像文件夹
  -> 值列表:像文件夹里的配置项
1
2
3

# 10. GetRegInfo:枚举子键

GetRegInfo 使用 RegQueryInfoKey 获取子键数量和最大长度,再用 RegEnumKeyEx 枚举每个子键。它还会打开子键判断该子键下面是否还有子键。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:428-505
// 防御分析注释:
// 1. RegOpenKeyEx 打开当前根键 + KeyPath。
// 2. RegQueryInfoKey 获取子键数量和最大长度,为后续缓冲区分配提供依据。
// 3. RegEnumKeyEx 遍历子键名。
// 4. TOKEN_REG_INFO 回传子键名和是否还有子键,便于主控端展示树形结构。
// 5. 从检测角度看,枚举本身不是问题,关键是“谁在枚举、枚举哪里、之后是否写入/删除”。
LPBYTE CRegeditManager::GetRegInfo()
{
    LPBYTE buf = NULL;
    HKEY hKey;

    // 打开当前路径。这里仍使用 KEY_ALL_ACCESS,权限范围大于只读枚举所需。
    if (RegOpenKeyEx(MKEY, KeyPath, 0, KEY_ALL_ACCESS, &hKey) == ERROR_SUCCESS)
    {
        // 读取当前键下子键和值的统计信息。
        RegQueryInfoKey(hKey, NULL, NULL, NULL,
                        &KeyCnt, &KeyMaxLen, NULL,
                        &NameCnt, &NameMaxLen, &MaxDateLen,
                        NULL, NULL);

        buf = (LPBYTE)LocalAlloc(LPTR, datasize);
        buf[0] = TOKEN_REG_INFO;

        // REGMSG 告诉主控端:有多少个子键、每个名称字段多大。
        REGMSG msg;
        msg.size = KeySize;
        msg.count = KeyCnt;
        memcpy(buf + 1, &msg, sizeof(REGMSG) + 1);

        for (dwIndex = 0; dwIndex < KeyCnt; dwIndex++)
        {
            // 逐个枚举子键名。
            RegEnumKeyEx(hKey, dwIndex, szBuffer, &lpcbName,
                         NULL, NULL, NULL, NULL);

            // 打开子键判断它是否还有下级子键,用于主控端树形展开图标。
            RegOpenKeyEx(hKey, szBuffer, 0, KEY_READ, &hkResult);
            IsHaveSubkeys(dwSubs);

            // 字段 1:是否还有子键;字段 2:子键名称。
            memcpy(buf + dwIndex * KeySize + size, &bRet, sizeof(BOOL));
            memcpy(buf + dwIndex * KeySize + size + sizeof(BOOL),
                   szBuffer, lpcbName * sizeof(TCHAR));
        }
    }
    return buf;
}
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

防御侧可以把 RegEnumKeyEx 作为枚举信号,但更关键的是路径。例如枚举普通软件配置和枚举服务、启动项、安全策略,风险等级完全不同。

# 11. GetRegKey:枚举值名、类型和值数据

GetRegKey 枚举的是当前键下的值。它记录值类型,把 Windows 原生类型映射到模块内部的 MREG_* 类型。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:563-637
// 防御分析注释:
// 1. GetRegKey 枚举的是“当前键下的值”,不是子键。
// 2. RegEnumValue 读取值名、类型和值数据。
// 3. REG_SZ、REG_DWORD、REG_BINARY、REG_EXPAND_SZ 会被映射为 MREG_*。
// 4. 读取敏感路径下的值数据可能暴露启动命令、服务路径、策略状态或应用配置。
LPBYTE CRegeditManager::GetRegKey()
{
    // 打开当前枚举路径。只读枚举理论上使用 KEY_READ 即可。
    if (RegOpenKeyEx(MKEY, KeyPath, 0, KEY_ALL_ACCESS, &hKey) == ERROR_SUCCESS)
    {
        // NameCnt 是值数量,NameMaxLen/MaxDateLen 用于计算回包字段大小。
        RegQueryInfoKey(hKey, NULL, NULL, NULL,
                        &KeyCnt, &KeyMaxLen, NULL,
                        &NameCnt, &NameMaxLen, &MaxDateLen,
                        NULL, NULL);

        buf = (LPBYTE)LocalAlloc(LPTR, size);
        buf[0] = TOKEN_REG_KEY;
        memcpy(buf + 1, (void*)&msg, sizeof(REGMSG));

        for (dwIndex = 0; dwIndex < NameCnt; dwIndex++)
        {
            // 枚举值名、值类型和值数据。
            RegEnumValue(hKey, dwIndex, szValueName, &NameSize,
                         NULL, &Type, szValueDate, &DataSize);

            // 把 Windows 原生注册表类型映射为模块内部类型,便于主控端展示和后续写回。
            if (Type == REG_SZ)
                tmp[0] = MREG_SZ;
            if (Type == REG_DWORD)
                tmp[0] = MREG_DWORD;
            if (Type == REG_BINARY)
                tmp[0] = MREG_BINARY;
            if (Type == REG_EXPAND_SZ)
                tmp[0] = MREG_EXPAND_SZ;

            // 回包字段:值名 + 值数据。
            // 防御侧要注意值数据可能包含路径、命令行、URL、令牌或业务配置。
            memcpy(tmp, szValueName, msg.size);
            memcpy(tmp, szValueDate, msg.valsize);
        }
    }
    return buf;
}
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

值类型很重要。比如 REG_EXPAND_SZ 可能包含环境变量路径,REG_DWORD 常用于开关类配置,REG_SZ 常用于命令、路径和字符串配置。检测时不能只看路径,也要看值名和值类型。

# 12. 写入值:CreateKeyEx 与 WriteValue

源码把不同值类型分发到不同函数:字符串、DWORD、可扩展字符串。这里不讲如何构造命令包,只看源码行为和风险。

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:187-217
// 防御分析注释:
// 1. CreateKeyEx 是 COMMAND_REG_CREATKEY 的落点,名称像创建键,实际按类型写入值。
// 2. buf[0] 是内部值类型,buf + 1 后面才是具体路径、值名和值数据。
// 3. 写入注册表值是高风险动作,尤其目标在启动项、服务、策略和文件关联路径。
// 4. 合法工具应记录写入前后值、操作者、原因和审批编号。
enum KEYVALUE
{
    MREG_SZ,
    MREG_DWORD,
    MREG_BINARY,
    MREG_EXPAND_SZ
};

void CRegeditManager::CreateKeyEx(char* buf)
{
    // 根据值类型分发到不同写入函数。
    // 本课只做防御阅读,不展开命令包构造方式。
    switch (buf[0])
    {
    case MREG_SZ:
        // 写入普通字符串,常用于路径、命令行、配置项。
        CreatSTR(buf + 1);
        break;
    case MREG_DWORD:
        // 写入 DWORD,常用于开关类策略和状态字段。
        CreatDWORD(buf + 1);
        break;
    case MREG_EXPAND_SZ:
        // 写入可扩展字符串,可能包含 %SystemRoot% 等环境变量。
        CreatEXSTR(buf + 1);
        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
// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:165-180
// 防御分析注释:
// 1. RegSetValueEx 是写注册表值的核心 API。
// 2. REG_SZ 和 REG_DWORD 在配置修改、启动项、策略开关中很常见。
// 3. m_hKey 必须已经由 ParseKey/OpenKey/MyCreateKey 等路径设置好。
// 4. 检测侧应保留旧值、新值、值类型和进程上下文。
BOOL CRegeditManager::WriteValue(LPCTSTR lpValueName, LPCTSTR lpValue)
{
    // 写入字符串值。lpValueName 是值名,lpValue 是具体字符串数据。
    // 如果字符串看起来像路径或命令行,应继续做路径归一化和签名校验。
    long lReturn = RegSetValueEx(m_hKey, lpValueName, 0L,
                                 REG_SZ,
                                 (const BYTE*)lpValue,
                                 (lstrlen(lpValue) + 1) * sizeof(TCHAR));
    return lReturn == ERROR_SUCCESS;
}

BOOL CRegeditManager::WriteValue(LPCTSTR lpSubKey, DWORD dwVal)
{
    // 写入 DWORD 值。很多安全策略和应用开关都使用 DWORD,误改影响较大。
    long lReturn = RegSetValueEx(m_hKey, lpSubKey, 0L,
                                 REG_DWORD,
                                 (const BYTE*)&dwVal,
                                 sizeof(DWORD));
    return lReturn == ERROR_SUCCESS;
}
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
// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:253-263
// 防御分析注释:
// 1. REG_EXPAND_SZ 可以保存带环境变量的路径。
// 2. 这类值常见于路径类配置,检测时应展开后再判断真实目标。
// 3. 例如同一值在日志中看起来是变量路径,实际展开后可能指向用户目录或临时目录。
BOOL CRegeditManager::WriteBuf(LPCTSTR lpValueName, LPCTSTR lpValue)
{
    // 写入可扩展字符串。检测时不要只做原始字符串匹配,还要展开环境变量。
    long lReturn = RegSetValueEx(m_hKey, lpValueName, 0L,
                                 REG_EXPAND_SZ,
                                 (const BYTE*)lpValue,
                                 (lstrlen(lpValue) + 1) * sizeof(TCHAR));
    return lReturn == ERROR_SUCCESS;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 13. 创建、删除和重命名键

13. 创建、删除和重命名键(配图)

创建和删除子键是配置变更,重命名键则可能影响配置追踪。源码里使用了 RegCreateKeyExRegDeleteKeyRegDeleteValue 和动态获取的 RegRenameKey

// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:377-404
// 防御分析注释:
// 1. RegCreateKeyEx 使用 KEY_ALL_ACCESS 创建或打开子键。
// 2. 创建服务、启动项、策略、文件关联相关子键时风险较高。
// 3. 合法工具应限制可创建路径,并保留变更记录。
// 4. dw 会告诉调用者是新建还是打开已有键,审计中这两种情况应区分。
BOOL CRegeditManager::MyCreateKey(LPCTSTR lpSubKey)
{
    HKEY hKey;
    DWORD dw;

    // 创建或打开子键。REG_OPTION_NON_VOLATILE 表示重启后仍保留。
    long lReturn = RegCreateKeyEx(m_hKey, lpSubKey, 0L, NULL,
                                  REG_OPTION_NON_VOLATILE,
                                  KEY_ALL_ACCESS, NULL, &hKey, &dw);
    if (lReturn == ERROR_SUCCESS)
    {
        // 更新当前键句柄,后续可继续写值。
        m_hKey = hKey;
        return TRUE;
    }
    return FALSE;
}

void CRegeditManager::CreateKey(LPBYTE lpBuffer)
{
    // 命令包第一个字段是根键类型,后面是子键路径。
    ParseKey(lpBuffer[0]);
    if (MyCreateKey((TCHAR*)(lpBuffer + sizeof(TCHAR))))
        SendSucceed();
    else
        SendError();
}
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
// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:349-375
// 防御分析注释:
// 1. RegDeleteValue 删除值,RegDeleteKey 删除子键。
// 2. 删除安全配置、启动项、服务参数或业务软件配置可能造成严重影响。
// 3. 删除动作应比读取动作更高优先级告警。
// 4. 审计时要记录删除前的值数据,否则事后很难恢复上下文。
BOOL CRegeditManager::DeleteValue(LPCTSTR lpValueName)
{
    // 删除当前 m_hKey 下的指定值名。
    long lReturn = RegDeleteValue(m_hKey, lpValueName);
    return lReturn == ERROR_SUCCESS;
}

BOOL CRegeditManager::MyDeleteKey(LPCTSTR lpSubKey)
{
    // 删除当前根键下的子键。经典 RegDeleteKey 只删除空子键,递归删除需要其他逻辑。
    long lReturn = RegDeleteKey(m_hKey, lpSubKey);
    return lReturn == ERROR_SUCCESS;
}

void CRegeditManager::DeleteKey(LPBYTE lpBuffer)
{
    // 先选择根键,再删除指定子路径。
    ParseKey(lpBuffer[0]);
    if (MyDeleteKey((LPCTSTR)(lpBuffer + sizeof(TCHAR))))
        SendSucceed();
    else
        SendError();
}
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
// 源码位置:主插件\查注册表\查注册表\RegeditManager.cpp:55-93
// 防御分析注释:
// 1. Rename 动态解析 RegRenameKey,因为该 API 并非所有旧系统环境都直接可用。
// 2. buf 中包含根键类型、旧路径长度、新路径长度、旧路径和新路径。
// 3. 重命名注册表键可能影响配置追踪,也可能破坏依赖固定路径的程序。
// 4. 检测侧应记录旧路径、新路径、根键、调用进程和结果。
void CRegeditManager::Rename(char* buf)
{
    // 第一个字节选择根键。
    ParseKey(buf[0]);

    // 后续两个 int 表示旧路径和新路径长度,用于从连续缓冲区中切分字符串。
    memcpy(&oldsize, buf + 1, sizeof(int));
    memcpy(&newsize, buf + 1 + sizeof(int), sizeof(int));

    // 动态加载 ADVAPI32 并解析 RegRenameKey。
    // 这种动态解析 API 的方式也可作为行为特征之一。
    HMODULE hModNtdll = LoadLibraryW(L"ADVAPI32.dll");
    typedef LSTATUS(WINAPI* pfRegRenameKey)(
        HKEY hKey,
        LPCWSTR lpSubKeyName,
        LPCWSTR lpNewKeyName);

    pfRegRenameKey pfuRegRenameKey =
        (pfRegRenameKey)GetProcAddress(hModNtdll, "RegRenameKey");

    // 注意:RegRenameKey 成功时返回 ERROR_SUCCESS,也就是 0。
    // 阅读源码时要关注条件判断是否符合 Windows API 返回约定。
    if (pfuRegRenameKey && pfuRegRenameKey(m_hKey, path_old, path_new))
        SendSucceed();
    else
        SendError();
}
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

# 14. 重点观察区域

本课不提供任何修改这些位置的步骤,只说明为什么它们在企业安全中敏感。

区域 风险原因 检测建议
启动项相关位置 程序登录后自动运行 记录新建、写入、删除、重命名
服务相关位置 控制服务启动、镜像路径和参数 关联服务创建、二进制路径和签名
策略相关位置 影响安全产品、防火墙、UAC、脚本限制等 对非授权进程写入直接告警
Winlogon / Shell / Userinit 影响登录和外壳加载链 任何变更都应重点复核
Classes / Shell / OpenWith / COM 影响文件打开方式和 COM 加载 关注脚本、DLL、可执行路径变化
应用配置和凭据线索 可能包含连接配置、令牌或账户信息 读取和导出都需要记录

注册表检测不能只维护一个“黑名单路径”。更现实的做法是按业务资产分层:终端基线、服务器基线、安全产品配置、办公软件配置、开发工具配置分别建立允许变更范围。

# 15. x86/x64、WoW64 与注册表视图差异

注册表也有 32 位和 64 位视图差异,尤其是 HKLM\Software 下的重定向。这个源码里没有显式使用 KEY_WOW64_64KEYKEY_WOW64_32KEY,所以从防御角度要注意:

场景 风险点 防御建议
32 位进程运行在 64 位系统 访问 HKLM\Software 可能看到 32 位视图 记录进程位数和注册表真实视图
64 位安全工具核查 32 位插件行为 看到的路径可能不完全一致 同时采集 32 位和 64 位视图
值数据包含环境变量 真实路径需要展开后判断 展开 %SystemRoot%%APPDATA% 等变量
只按字符串匹配 容易漏掉大小写、变量和视图差异 做路径归一化和视图归一化

新手常犯的错误是:看到日志里写的是 HKLM\Software\...,就以为所有工具看到的是同一份数据。实际上进程位数、访问权限和注册表视图都会影响结果。

# 16. 企业检测点

16. 企业检测点(配图)

注册表模块的检测同样要做关联。单独一次 RegOpenKeyEx 可能正常,但如果它来自异常插件进程、目标是敏感路径、随后出现写入或删除,并且同一进程还有外联会话,就需要提升优先级。

检测面 关注点 示例规则思路
进程与 API RegOpenKeyExRegEnumValueRegSetValueExRegDeleteValue 非授权进程访问敏感注册表路径
根键与路径 HKCU、HKLM、Services、Policies、Winlogon、Classes 敏感路径被写入、删除或重命名
值类型 REG_SZREG_DWORDREG_EXPAND_SZ 路径类值、命令类值、策略开关类值变化
网络会话 命令 token、回包 token、长连接上下文 注册表读取或写入与远程控制会话相邻
结果反馈 TOKEN_REG_SUCCEEDTOKEN_REG_ERROR 连续成功写入或连续失败探测

建议的关联逻辑:

异常进程建立长连接
  -> 发送注册表模块 token
  -> 枚举 HKCU/HKLM 敏感路径
  -> 读取大量值名和值数据
  -> 写入 / 删除 / 重命名注册表对象
  -> 回传成功 token
  -> 触发主机侧告警和变更复核
1
2
3
4
5
6
7

# 17. 合法练习题

  1. 只阅读源码,整理 COMMAND_REG_* 与函数名的对应关系。
  2. 画出 COMMAND_REG_ENUM -> EnumReg -> GetRegInfo/GetRegKey -> TOKEN_REG_* 的数据流。
  3. 分析 REGMSG 在枚举子键和值枚举中的字段含义差异。
  4. 从防御角度写一条检测规则:异常进程写入 HKLM 策略相关路径。
  5. 设计一个合法远程管理工具的注册表授权模型:哪些根键可读,哪些路径可写,哪些路径禁止变更。
  6. 对比 REG_SZREG_DWORDREG_EXPAND_SZ 的安全检测重点,并说明为什么值类型不能忽略。
  7. 说明 32 位进程和 64 位系统下注册表视图差异会如何影响排查结果。

# 18. 本课小结

查注册表模块的结构很清晰:入口创建 CRegeditManager,构造函数发送模块 token,OnReceive 分发命令,ParseKey 选择根键,GetRegInfo/GetRegKey 完成读取,创建、写入、删除和重命名函数完成修改。

从企业安全角度看,注册表检测要抓住“谁在什么会话里,对哪个根键和路径,做了什么类型的变更”。下一课继续沿着主插件往下看,重点会放在下一个具体功能模块的源码结构、交互流程和防御检测。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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