-
-
[分享]注册表回调内核通信
-
发表于: 1天前 175
-
注册表回调内核通信
R3 与 R0 通信最常规的方式是 DeviceIoControl,但驱动必须 IoCreateDevice 创建设备对象、暴露符号链接(\\.\xxx),导致驱动可被遍历发现、通信特征固定。而注册表回调通信正是打破传统通信的方法之一,其特点是:不创建设备,用注册表回调做通道。
核心原理
Windows 提供 CmRegisterCallbackEx 注册回调,此后系统每一次注册表操作都会先经过该回调。回调可以查看操作内容、返回状态码决定放行或阻止。
利用这一点,我们约定:
- R3 程序以
REG_BINARY类型调用RegSetValueExW写入一个结构体,触发回调 - 回调从待写入数据中解析出结构体,执行对应的内核操作
- 回调返回
STATUS_UNSUCCESSFUL,阻止该数据真正落盘 - R3 收到写入失败,但命令已执行、结果已到内存
数据全程在内存传递,不落盘、不建设备。
协议:CrComm.h
typedef struct _DriverParameter {
ULONG64 VCode; // 校验码:必须为 0 才触发处理
ULONG64 ControlCode; // 命令号
PVOID InputBuffer; // 内核读这里(用户态内存)
PVOID OutputBuffer; // 内核写这里(用户态内存)
} DriverParameter, * PDriverParameter;
typedef struct _RWMEMDATA {
ULONG64 ProcessId; // 目标进程 PID
ULONG64 Address; // 目标虚拟地址
ULONG64 Size; // 读写字节数
ULONG64 Buffer; // 当前进程缓冲区地址
ULONG64 Type; // 0=读 1=写
} RWMEMDATA, * PRWMEMDATA;
VCode 相当于暗号,非 0 时回调直接放行,不影响系统正常写注册表。InputBuffer/OutputBuffer 可以直接传用户态指针,因为回调运行在调用者进程上下文,内核视角下这些地址可直接读写。
命令码定义:
#define COMM_ApiReadandwrite 0x0 // 内存读写
#define COMM_Judgmentloading 0x11 // 驱动加载检测
内核侧:回调与命令分发
注册与卸载
// DriverEntry 中注册回调
CmRegisterCallbackEx(
(PEX_CALLBACK_FUNCTION)RegistryCallback,
&altitude, &altitude, &RegistryCallback, &g_RegCookie, NULL);
// DriverUnload 中注销回调
CmUnRegisterCallback(g_RegCookie);
altitude("429628")是回调排序高度,系统用来决定多个回调的调用顺序。卸载时必须注销,否则回调指向已释放代码 → 蓝屏。
回调分发
NTSTATUS RegistryCallback(PVOID a1, PVOID a2, PVOID a3)
{
REG_NOTIFY_CLASS Notify = (REG_NOTIFY_CLASS)(ULONG64)a2;
switch (Notify)
{
case RegNtSetValueKey:
{
PREG_SET_VALUE_KEY_INFORMATION RegInfo = (PREG_SET_VALUE_KEY_INFORMATION)a3;
// 第一道过滤:必须是 REG_BINARY 且大小等于 DriverParameter
if (RegInfo->Type != REG_BINARY || RegInfo->DataSize != sizeof(DriverParameter))
break; // 不是我们的数据,放行
PDriverParameter Parameter = (PDriverParameter)RegInfo->Data;
// 第二道过滤:VCode 必须为 0
if (Parameter->VCode != 0)
return STATUS_SUCCESS;
NTSTATUS Status = STATUS_UNSUCCESSFUL;
switch (Parameter->ControlCode)
{
case COMM_Judgmentloading: // 0x11 加载检测
*(ULONG64*)Parameter->InputBuffer = 6;
Status = STATUS_SUCCESS;
break;
case COMM_ApiReadandwrite: // 0x0 内存读写
{
PRWMEMDATA Data = (PRWMEMDATA)Parameter->InputBuffer;
HANDLE ProcessId = (HANDLE)Data->ProcessId;
PVOID Address = (PVOID)Data->Address;
PVOID Buffer = (PVOID)Data->Buffer;
ULONG Size = (ULONG)Data->Size;
UCHAR Type = (UCHAR)Data->Type;
USHORT Ret = RaWMem(ProcessId, Address, Size, Buffer, Type);
RtlCopyMemory(Parameter->OutputBuffer, &Ret, sizeof(USHORT));
Status = STATUS_SUCCESS;
break;
}
}
// 返回值反转:成功 → 阻止落盘,失败 → 放行
return NT_SUCCESS(Status) ? STATUS_UNSUCCESSFUL : STATUS_SUCCESS;
}
}
return STATUS_SUCCESS;
}
返回值反转是核心:
- 命令处理成功 → 返回
STATUS_UNSUCCESSFUL→ 写入被阻止,数据不落盘 - 命令处理失败 → 返回
STATUS_SUCCESS→ 放行正常写入
所以 RegSetValueExW 返回 0x1f 不代表失败,恰恰说明命令已被内核成功处理。
跨进程内存读写
static USHORT RaWMem(HANDLE ProcessId, PVOID Address, ULONG Size, PVOID Buffer, UCHAR Type)
{
PEPROCESS TargetProcess = NULL;
NTSTATUS Status = PsLookupProcessByProcessId(ProcessId, &TargetProcess);
if (!NT_SUCCESS(Status))
return FAILURE;
PEPROCESS CurrentProcess = IoGetCurrentProcess();
SIZE_T CopiedBytes = 0;
if (Type == 0) // 读:目标进程 → 当前进程
Status = MmCopyVirtualMemory(TargetProcess, Address, CurrentProcess, Buffer, Size, KernelMode, &CopiedBytes);
else // 写:当前进程 → 目标进程
Status = MmCopyVirtualMemory(CurrentProcess, Buffer, TargetProcess, Address, Size, KernelMode, &CopiedBytes);
ObDereferenceObject(TargetProcess); // 释放引用计数
return NT_SUCCESS(Status) ? SUCCESS : FAILURE;
}
PsLookupProcessByProcessId 将 PID 转为 PEPROCESS,IoGetCurrentProcess 取当前进程,MmCopyVirtualMemory 完成跨进程拷贝,最后 ObDereferenceObject 释放引用(否则内核对象泄漏)。
用户侧:发送命令
static BOOL SendCommand(PDriverParameter Param)
{
HKEY hKey;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_TEST_PATH, 0, KEY_SET_VALUE, &hKey) != ERROR_SUCCESS)
return FALSE;
RegSetValueExW(hKey, L"TestValue", 0, REG_BINARY, (const BYTE*)Param, sizeof(DriverParameter));
RegCloseKey(hKey);
return TRUE; // 不检查 RegSetValueExW 返回值
}
路径选 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 只是为了让操作看起来更常见,实际上回调拦截所有注册表写,路径不限。SendCommand 只负责投递,结果通过 InputBuffer/OutputBuffer 的内容判断。
两个测试用例
// 测试1:驱动加载检测
TestJudgmentloading();
// 构造:VCode=0, ControlCode=0x11, InputBuffer=&Result
// 内核写 6 到 Result,用户检查 Result==6 确认驱动在位
// 测试2:内存读写
TestApiReadAndWrite();
// 构造:RWMEMDATA 描述读自身进程 TestData 变量
// 内核 MmCopyVirtualMemory 拷贝到 ReadBuffer
// 用户检查 Ret==SUCCESS && ReadBuffer==0x12345678
完整时序
R3 R0
1. 填充 DriverParameter
2. RegOpenKeyExW
3. RegSetValueExW(REG_BINARY) ────▶ 4. 触发回调
5. 校验 Type/Size/VCode
6. switch 分发执行
7. 成功 → 返回 STATUS_UNSUCCESSFUL
4'. 返回 0x1f ◀─────────────────────── (写入被阻止)
5'. 检查 InputBuffer/OutputBuffer 结果
这样设计隐蔽在哪里
| 对比项 | IOCTL 通信 | 注册表回调通信 |
|---|---|---|
| 设备对象 | 有,可被遍历 | 无 |
| 符号链接 | 有,可被打开 | 无 |
| 通信特征 | DeviceIoControl + IRP | 普通注册表写 |
| 数据残留 | 可能留日志/缓存 | 不落盘,无痕迹 |
| 干扰系统 | 专属通道 | 非本方命令全放行 |
注意事项
- 卸载驱动必须
CmUnRegisterCallback(g_RegCookie),否则回调残留 → 蓝屏 PsLookupProcessByProcessId后必须ObDereferenceObject,否则内核对象泄漏- 该技术为双刃剑,仅用于学习研究,切勿用于恶意用途
缺点总结
注册表回调通信虽然隐蔽性不错,但缺点同样明显,注册表拦截本身确实会成为检测特征。
其一是频率异常,正常程序写注册表通常是一次性或低频,而通过注册表回调方式通信会出现注册表写操作频率异常成为其的特征。
其二是数据特征,RegSetValueExW 写入的数据是 sizeof(DriverParameter) 的 REG_BINARY,而普通程序写 REG_BINARY 很少见(大多是 REG_SZ/REG_DWORD),即使写 REG_BINARY,大小也几乎不可能是固定的 32 字节(sizeof(DriverParameter) 的精确值)内容看起来像指针值(InputBuffer/OutputBuffer 是地址),正常注册表值不会有进程地址
其三是返回值异常(0x1f),RegSetValueExW 成功写入应该返回 ERROR_SUCCESS(0),但采用注册表回调通信的程序每次通信都会返回 STATUS_UNSUCCESSFUL → 用户态拿到 0x1f (ERROR_ACCESS_DENIED)。一个正常程序反复写注册表,每次都被拒绝但还在继续写——行为上就很可疑。
五、改进思路
如果要在现有项目基础上改进,可以考虑:
数据加密+随机填充:DriverParameter 内容加密,VCode 不在内存中以明文 0 出现
随机注册表路径:不在固定路径写,每次通信随机选一个合法路径
低频通信 + 时间抖动:避免连续高频写注册表,加入随机延迟
混合通信:低频命令用注册表(如心跳检测),大量数据通过共享内存
换 altitude:找一个合法的、属于某个已卸载产品的 altitude 来冒充
⚠️ 本文仅用于学习与学术研究目的。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
赞赏
- [分享]注册表回调内核通信 162
- [分享]简单编译器实现 1851
- [分享]Lexer简单词法解析器入门 1197
- [分享]vm解释器入门2 1260
- [分享]vm解释器入门 1558