资讯动态

Windows内核对象管理器回调机制:ObRegisterCallbacks逆向分析与结构体内存布局详解

发布时间:2026/10/16 5:20:17 来源:尧图企业网站定制
初次见到 ObRegisterCallbacks是在做内核驱动开发的时候。当时的需求很朴素进程句柄被打开时我想知道是谁在打开、以什么权限打开。翻 WDK 文档能看到这个 API 的基本签名但参数结构里那些字段到底怎么填、回调触发时内核会给我什么数据官方头文件讲得并不透彻。于是我把 ntoskrnl.exe 的导出函数拉出来逆向了一遍从入口反汇编一路跟到内部实现才真正把 OB_CALLBACK_REGISTRATION、OB_OPERATION_REGISTRATION、PRE/POST 回调信息结构这套“回调结构”的完整面貌还原出来。这篇笔记就是用来记录这个逆向过程的。它适合三类读者第一类是刚开始接触内核对象管理器回调机制的驱动学习者想弄明白每个结构体的偏移和用途第二类是需要通过调试器检查其他驱动是否注册了回调、注册了什么回调的安全分析人员第三类是自己写过滤驱动、需要在代码里正确构造注册参数的开发者。无论是哪一类只要你手头有一台能调试内核的 Windows 虚拟机、一套调试工具就能跟着复现。整个分析过程的核心思路并不复杂先在导出表里找到 ObRegisterCallbacks反汇编它的入口注意到它只是个薄封装、真正干活的函数叫 ObpRegisterCallback然后把注册参数指向的几套内存结构逐字节拉出来和已知结构定义比对。下面逐段展开。1. 这个回调机制解决什么问题1.1 对象管理器在哪里执行回调Windows 内核里所有进程、线程、文件、注册表键等“可被句柄引用的对象”都由对象管理器Object Manager统一管理。对象管理器要做的事情很多其中一项是在句柄操作发生时执行统一的检查逻辑。所谓句柄操作最基本的就是创建句柄、复制句柄、关闭句柄这三类。在早期第三方驱动如果想去拦截这些操作要么直接挂钩对象管理器的内部函数要么修改对象类型结构里的函数指针。这些方式很不稳一方面改动内核敏感区域容易触发系统完整性检查另一方面不同 Windows 版本内部实现差异巨大写出来的代码很难长期维护。所以系统后来提供了 ObRegisterCallbacks 这个公开接口让驱动以“注册回调”的方式参与句柄操作的处理流程。回调挂在对象管理器内部的具体位置可以理解为每个对象类型上有一组“对操作感兴趣”的过滤器列表。当一个句柄操作发生时对象管理器会在这个列表里按顺序调用每个过滤器注册的处理函数。你注册的 PRE 回调会在操作真正生效前被调用给你一个机会去修改参数或者直接拒绝POST 回调则在操作完成后被调用用于观察最终结果。1.2 逆向回调结构的实际收益可能有人会问官方头文件已经把结构体定义写得很清楚了为什么还要逆向原因一是文档和真实内存布局之间存在偏差。比如 x64 对齐、UNICODE_STRING 在结构体里的具体位置、联合体字段的偏移这些在头文件里抽象表示成逻辑字段但在调试器里看实际内存你会发现字段之间藏着 padding如果某个写驱动的同学手工构造了结构体就很容易踩偏移不对的坑。原因二是逆向能够揭示内部实现逻辑。顺着 ObRegisterCallbacks 往下追能看到参数校验、海拔高度Altitude合法性判断、对象类型过滤这些细节。这些细节在官方文档里不会写却直接影响调用成败。比如注册时的 Version 字段到底要填几文档里含混说要用 OB_FLT_REGISTRATION_VERSION但逆向后能看到代码里真实判断是“版本不能小于 2”。原因三是在排查问题时逆向出来的结构能帮助你用调试器直接观察其他驱动注册了什么回调。检查某个驱动注册的回调权限掩码、海拔高度、PRE/POST 函数地址比对预期行为这比单纯读源码更可靠。2. 准备一套可复现的内核分析环境2.1 双机调试与符号配置要点逆向内核函数最常见的做法是双机调试一台宿主机跑调试器一台虚拟机作为调试对象。虚拟机需要开启内核调试支持并通过串口、网络或虚拟机的命名管道和宿主机的调试器建立连接。我用的是配置大致如下虚拟机安装 Windows 10 x64 系统宿主机使用调试器连接虚拟机的 COM 调试端口。第一次连接前需要在虚拟机里以管理员身份执行一句命令开启调试模式然后重启系统。这一步做完之后宿主机调试器就能在系统启动阶段介入方便在任意内核函数上下断点。符号配置同样重要。建议将符号路径指向公开的符号服务器这样调试器才能解析 ntoskrnl.exe 的导出函数和结构体类型。没有符号的话只能看到裸地址逆向排查的效率会大打折扣。配置好之后在调试器里先确认符号能正常加载lm m nt这条命令会显示 nt、也就是内核映像的加载信息。如果右侧出现“pdb”字样说明符号加载成功。接下来就可以直接引用函数名了。2.2 从导出表到反汇编的定位思路定位 ObRegisterCallbacks 本身很简单因为它在导出表里。在调试器里执行x nt!ObRegisterCallbacks执行后会有类似输出地址因系统而异关键是你会看到两个名称排在一起nt!ObRegisterCallbacks nt!ObpRegisterCallback这里其实已经暴露出第一个重要的逆向结论ObRegisterCallbacks 是导出包装函数内部逻辑主要在 ObpRegisterCallback。接下来用反汇编命令展开入口函数uf nt!ObRegisterCallbacks“uf”会把函数的所有指令块完整列出。如果你用的是反编译器工具可以直接看到近似的伪代码更方便阅读。不过不管用什么工具入口函数的真实逻辑都相当直白做几个前置检查然后跳转到内部函数。另外可以用结构体查看命令确认调试器从符号里解析出的结构布局dt nt!_OB_CALLBACK_REGISTRATION dt nt!_OB_OPERATION_REGISTRATION这两条命令输出的字段顺序、偏移信息就是你逆向比对时的“官方参考答案”。后续步骤里我会以实际内存和这些字段定义互相印证。3. 跟读入口函数与内部校验逻辑3.1 反汇编 ObRegisterCallbacks 入口为了方便讨论我先给出入口函数的伪代码级别还原结果。不同版本的反汇编行号可能有差异但逻辑结构基本一致ObRegisterCallbacks: movzx eax, word ptr [rcx] ; rcx - CallbackRegistration cmp eax, 2 ; Version 字段不能小于 2 jb fail_invalid_parameter movzx eax, word ptr [rcx2] ; OperationRegistrationCount test eax, eax jz fail_invalid_parameter ; 注册数量为 0 直接失败 mov rax, [rcx10h] ; Altitude.Buffer test rax, rax jz fail_invalid_parameter ; 海拔字符串为空则失败 cmp word ptr [rcx8], 0 ; Altitude.Length jz fail_invalid_parameter ; 中间还有一些针对 Altitude 合法性的检查 ... jmp ObpRegisterCallback这段反汇编揭示了文档里没有明说的三层要求。第一层Version 必须大于等于 2。如果你按照某些旧代码示例填了 1内核会直接返回无效参数。第二层OperationRegistrationCount 必须大于 0也就是说至少有一个操作注册条目。第三层海拔高度字符串不能为空而且这个字符串必须符合系统的数字格式要求。从这里也能看到一道常见的“结构错误”问题如果你手动构造了结构体但内存没有清零RegisterCount 字段里残留垃圾数据或者 Version 字段用了默认零值回调注册就会静默失败。逆向入口的最大价值就是让你在排查问题时第一时间想到检查这几个字段。3.2 ObpRegisterCallback 内部逻辑跳转到 ObpRegisterCallback 之后函数的实际工作就开始了。首先它会再次检查注册结构的合法性但重点变成了操作注册数组里的每一项。简单还原一下内部流程函数会读取 OperationRegistrationCount然后循环遍历你的操作注册数组。对每一项都会检查 ObjectType 字段指向的对象类型指针是否为受支持的类型。从实际逆向来看系统只接受进程对象和线程对象这两种类型。如果你试图给文件对象或者注册表对象注册回调这个函数会拒绝。接下来还会检查 Operations 掩码确认你申请的过滤操作是否属于创建、复制、关闭这三个合法操作范围。超过这个范围的值会被当作非法参数拒绝。全部检查通过后函数会为这份注册分配内核池内存把传入结构体里的字段复制到新内存里然后把它挂到对应对象类型的回调列表中。最后一个不透明句柄会通过第二个参数返回给你。这个句柄本质上是内部注册节点的引用后续注销回调时必须传回这个句柄。单纯看文档容易把注册看得像一个简单“登记”动作其实它涉及分配、复制、入链三个完整步骤。这也是驱动卸载时如果没有正确注销很容易导致内存泄漏或悬空引用的原因。3.3 结构体 Version 与 Altitude 的意义既然逆向结果显示这两个字段格外重要这里就展开说说。Version 字段对应宏常量 OB_FLT_REGISTRATION_VERSION当前驱动程序开发包里定义的值是 2。它存在的意义是为了将来接口演进时内核能区分传入的结构布局是旧版还是新版。一旦调用者填写的版本小于内核期望的版本系统会怀疑你使用的是不兼容的旧结构于是直接失败。这是内核接口常见的兼容性设计。Altitude 是注册时的海拔高度。它是一串数字用来表示过滤驱动在“回调栈”里的位置。你可以把它类比成队列里的优先级海拔越高的驱动其回调越先执行。系统内部用这个值对同一对象类型上的多个回调进行排序。逆向能看到内核会对 Altitude 字符串做长度和数值范围的双重校验太短、太长、以零开头等格式都会被拒。在实际编码中建议使用正式的 ALTITUDE 定义。如果你只是本地学习验证用某个随机数字也能跑通但含义不清晰。一旦有多个驱动注册同一对象类型的回调排序就会很混乱行为不可预期。4. 逐一还原回调结构的内存4.1 OB_CALLBACK_REGISTRATION 内存布局这是整个注册的顶层结构。x64 环境下符号解析出的布局如表所示偏移大小字段说明0x002 字节Version注册结构版本须大于等于 20x022 字节OperationRegistrationCount操作注册数组的元素个数0x082 字节Altitude.Length海拔字符串长度0x0A2 字节Altitude.MaximumLength海拔字符串缓冲上限0x108 字节Altitude.Buffer指向 UNICODE_STRING 字符串缓冲区的指针0x188 字节RegistrationContext回调上下文原样传给回调函数0x208 字节OperationRegistration指向操作注册数组的指针这个结构体常见的误读点在于 Altitude 字段。它是一个 UNICODE_STRING不是简单指针。结构体偏移 0x04 到 0x08 之间存在 4 字节 padding导致 UNICODE_STRING 的 Length 实际落在偏移 0x08而不是直觉上的 0x04。如果手工按字段连续排列构造结构体写进去的数据就会整体错位内核读到 Altitude.Buffer 时只会拿到长度字段的垃圾值。实际调试时可以用下面这个命令直接观察内存里的顶层结构dt nt!_OB_CALLBACK_REGISTRATION rcx当你打断点在入口函数上时rcx 正好指向这个结构体一次就能把版本、数量、海拔、上下文和数组指针全部输出。4.2 OB_OPERATION_REGISTRATION 内存布局注册数组的每个元素是 OB_OPERATION_REGISTRATIONx64 下的布局如下偏移大小字段说明0x008 字节ObjectType指向对象类型指针的指针0x084 字节Operations操作掩码如创建、复制、关闭0x108 字节PreOperationPRE 回调函数指针0x188 字节PostOperationPOST 回调函数指针这个结构体看着简单但有个让很多新手栽跟头的细节ObjectType 的类型是 POBJECT_TYPE 的指针也就是说你要传的是对象类型指针变量的地址而不是对象类型指针本身。一般代码里会写成POBJECT_TYPE ObjectType *PsProcessType;但这个 API 期望的是POBJECT_TYPE *ObjectType PsProcessType;如果你反着填内核在检查时会发现指针指向的地址并不是预期的对象类型结构于是返回 STATUS_INVALID_PARAMETER。这类错误从外部现象看很难定位但如果在逆向时把参数检查逻辑跟读一遍就能立刻理解为什么文档里对类型有这么别扭的要求。4.3 操作掩码与回调函数指针的细节Operations 字段是一个 32 位掩码当前有效取值包括三个#define OB_OPERATION_HANDLE_CREATE 0x00000001 #define OB_OPERATION_HANDLE_DUPLICATE 0x00000002 #define OB_OPERATION_HANDLE_CLOSE 0x00000004想同时监听多种操作直接按位或起来即可。值得留意的是你可以只提供 PreOperation 而不提供 PostOperation也可以反过来甚至可以同时提供。内核不会强制要求两者成对存在。实际操作中我习惯在非调试环境下断言一下这两个函数指针不为空、并且落在内核模块范围内排查回调没触发的问题时会很有用。另一个经验是不要试图在这个结构体里预留多余字段或叠加私有数据。内核只会按固定布局复制前四个字段多余部分既不会被保留也不会被传递到回调里。私有数据请放在 RegistrationContext 中它才是给回调捎带上下文的正路。5. 回调触发路径与 PRE/POST 信息结构5.1 PRE 回调里的检查参数回调触发时PRE 函数会拿到一个信息结构x64 环境下的关键字段大致如下0x00 Operation 创建、复制还是关闭操作 0x08 Information 联合体保存句柄操作细节 0x10 Object 指向对象类型指针的指针 0x18 ObjectHeader 对象头指针 0x20 ObjectInfo 指向对象特定信息 0x28 ProbeMode 请求来自内核态还是用户态这里的核心是 Information 联合体。对于创建操作它包含两个 ULONG 字段一个表示调用者请求的 DesiredAccess一个表示准备授予的 GrantedAccess。对于复制操作结构类似也包含访问权限的请求值和授予值。PRE 回调可以修改 GrantedAccess也就是从访问掩码里剔除你不希望赋予的权限位。比如某进程句柄被请求了写入权限而你希望阻止所有非授权进程获得写权限直接在 GrantedAccess 里清除写权限位即可。不想动权限的话就让 GrantedAccess 保持原样并返回 OB_PREOP_SUCCESS。很多时候分析别人的驱动注册了什么回调光看注册参数不够还需要在回调触发时观察它是否修改了 GrantedAccess。这时候在内核调试器里对回调地址下断点然后单步跟踪就能看到它改了哪些位。5.2 POST 回调的可读信息POST 回调在对象管理器完成句柄操作后被调用。它的信息结构和 PRE 版本高度相似核心差异是 Information 里没有“请求值”和“授予值”的区分了POST 只能拿到最终生效的 GrantedAccess。这种设计是有意义的。POST 回调的存在意义不是修改结果而是观察结果。比如你注册了一个用于审计的回调希望在进程句柄创建完成之后记录创建者的身份、目标进程的 PID、最终被授予的权限POST 回调就是最合适的落点。需要注意的是POST 回调触发时句柄已经生效。它没办法再去拒绝这个操作。如果你的业务逻辑需要在操作执行前挡住它就必须用 PRE 回调而且要在 PRE 回调里返回明确的错误状态。把两者用错或者只依赖 POST 做权限控制都是典型的逻辑错误。5.3 回调返回状态与注册顺序PRE 回调的返回值是一个枚举状态。最常用的是两个OB_PREOP_SUCCESS 表示放行 OB_PREOP_ERROR 表示拒绝如果返回小于 0 的错误码内核还会尝试把 NTSTATUS 错误返回给发起操作的应用层。也就是说你完全可以用 PRE 回调实现“禁止打开指定进程”的权限控制逻辑判断目标是需要保护的进程然后返回一个访问被拒绝的状态。还有一个容易被忽略的点是回调注册顺序。系统按照海拔高度从高到低排列回调那么多个驱动同时监听同一个进程对象类型时顺序就是确定且可预期的。调试时如果发现两个回调互相影响先看看各自的海拔高度别急着怀疑内核调度。6. 调试心得与常见坑6.1 断点应该下在哪个函数如果你想观察任何驱动调用 ObRegisterCallbacks 的行为我推荐直接把断点下在 ObpRegisterCallback。原因有两个第一所有调用最终都会经过它第二它进入时的参数和 ObRegisterCallbacks 完全一致不丢信息。在调试器里设置bp nt!ObpRegisterCallback然后继续运行系统。每一次有驱动注册回调断点都会命中。此时可以用dt nt!_OB_CALLBACK_REGISTRATION rcx查看顶层结构再用du poi(rcx10)查看海拔字符串。这个组合能快速搞清楚当前驱动注册的目的是什么。不过实操中你会发现Windows 系统启动阶段会有不少驱动调用这个接口断点命中次数可能比较多。如果只想看某个特定驱动可以先把目标驱动模块加载起来再在第二个入参处设置条件断点或者干脆等系统启动稳定之后再手动加载驱动触发注册。6.2 结构字段传错的典型现场我自己在初学阶段踩过几个坑整理出来给各位参考。第一个坑是 Version 填错。代码模板里拷贝来的旧代码填OB_FLT_REGISTRATION_VERSION但这在当前头文件里不是数字常量如果你包含的版本旧最终值可能是 1调用直接失败。逆向能帮你确认内核代码里明确比较的是“大于等于 2”。第二个坑是 OperationRegistrationCount 填了 0。有些人以为可以先填 0之后再通过另一个调用添加回调系统不是这么工作的。你必须一次性把操作注册数组填满、数量写对再发起注册。第三个坑是 Altitude 字符串格式不合法。它需要是十进制数字字符串且不带前导零长度也有限制。如果你直接填一个空串或随便填几个字母注册会被拒绝。从调试器布下条件断点后基本一眼就能看出字符串是什么样子。第四个坑是注销时机和句柄保存。ObUnRegisterCallbacks 需要你保存注册时返回的句柄。如果驱动卸载路径没有保存句柄就无法注销导致回调节点悬空。这类问题在内核里尤其隐蔽因为系统可能不会马上崩溃而是在后续访问时随机出现异常。6.3 常见问题速查表现象可能原因处理思路注册返回 STATUS_INVALID_PARAMETERVersion、数量、Altitude 三者至少有一项非法用 dt 查看结构检查字段值和偏移回调始终不触发Operations 掩码没包含对应操作确认掩码是否按位或了所需操作类型回调触发但权限没生效修改了错误的 GrantedAccess 字段单步进入回调看实际读写的是哪个偏移驱动卸载后蓝屏注销失败或回调函数地址已失效确认注册句柄被保存并在卸载时注销多个回调顺序不符合预期海拔高度未规范设置检查各自的海拔字符串数值这些坑在反复操作几次之后会形成肌肉记忆但前提是你要在调试器里真正看到结构体的内存内容而不是只靠文档猜。7. 逆向方法扩展7.1 从回调结构延伸出的内部链表当你把 ObpRegisterCallback 的回调入链逻辑逆向清楚后可以顺着这个思路继续深挖回调注册之后内部链表节点保存在哪里对象类型结构里维护的回调数组是什么形态这种扩展分析的价值在于它能帮你建立“公开 API 只是入口数据结构藏在内部”的全局观。做安全分析的人常需要检查系统里是否有异常的回调注册。与其盲目扫描内存不如先逆向出一个对象类型结构定位到它的回调链表然后遍历每个节点核对节点里的操作掩码和函数指针。这个思路比暴力特征扫描更精准。不过它需要耐心因为对象类型结构在不同版本系统里偏移变动频繁每次分析都得重新核对。7.2 写一个最小驱动做交叉验证单纯逆向总有盲区最好的验证方式是写一个最精简的回调注册驱动注册一个进程对象类型的 PRE 回调然后反复创建进程、观察回调是否触发、参数是否符合预期。这样一来代码层面的结构定义和逆向得到的内存布局就能互相印证。以我个人的经验做完一轮交叉验证后再回看官方头文件很多原本抽象的定义会变得很清楚。比如为什么某个字段是联合体、为什么 Object 字段要设计成“指向指针的指针”都因为你在调试器里亲眼见过它。再往后遇到其他内核回调结构比如注册表操作回调、对象目录通知回调分析起来就会顺手很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑