原文:https://windows-internals.com/dkom-now-with-symbolic-links/
翻译:看雪翻译小组----OsWalker
校对:看雪翻译小组----fyb波
你可能会想“关于内核回调还有什么值得说的嘛?我们已经知道了所有可能的回调----进程创建回调,对象类型回调,镜像加载通知,回调对象,对象类型回调,host扩展等等,已经没有其它类型的回调了。不是嘛?不是嘛?”
不。
对于内核hook,微软从没停止过关闭一扇门并打开两扇,Windows 10 更新(RS2)增加了一个新的回调类型——符号链接。
以前内容是符号链接目标的UNICODE_STRING现在成了一个联合体,其中包含我们在内核中最喜欢看到的关键字之一——回调。
RS2版本加入这些回调是为了支持内存分区,这是一个新对象类型,用于将物理地址区域分段为内存管理器支持的实例。不关注太多细节,关键就是\KernelObjects里的一些事件对象,如LowMemoryCondition,不再是全局Condition了——而引用为当前调用者的内存分区的特定Condition。然而,为了不破坏兼容性,它们的名字和位置并不改变(如\KernelObjects\Partition2\ LowMemoryCondition)。这导致符号链接加了一个动态回调成员,在EPROCESS的当前内存分区域中可以看到,该回调会返回调用者进程的内存分区的一个合适的KEVENT对象。
现在,一旦符号链接flag的第五位(Flags & OBJECT_SYMBOLIC_LINK_USE_CALLBACK)设为一,LinkTarget将不再被当做字符串,将被当做符合下面函数原型的函数:
符号链接被解析时函数都将被调用,在函数中需要将对象管理器解析的链接目标路径设置为SymlinkPath参数,或者将用作该符号链接目标的正确对象设置为Object参数。
根据一个传入的包含flags和目标字符串或回调函数的结构,ObCreateSymbolicLink函数设置(或不设置)该回调函数。
根据这个参数,该函数创建一个符号链接并设置链接目标:
不幸的是,ObCreateSymbolicLink没有导出,所以我们不能直接调用这个函数通过传入回调函数来创建一个符号链接。然而并没那么简单。这个函数有两个调用者——NtCreateSymbolicLinkObject和MiCreateMemoryEvent。后一个函数处理我们之前提到的内存分区功能。它以无目标字符串的符号链接形式创建各种内存事件,并设置回调函数为MiResolveMemoryEvent:
可以在WinObjEx中看到这些符号链接。通过没有目标字符串这个特征可以识别它们:
但MiCreateMemoryEvent是一个内部函数,在本例中对我们用处不大。所有我们转去看看NtCreateSymbolicLinkObject,这个对我们有一点用处:
它始终设置OB_SYMLINK_TARGET结构的flags为0,意味着目标始终是个字符串,不是函数指针。这很不幸,这说明我们无法从用户模式创建包含回调的符号链接对象。实际上我们也不这样期望,所以并不会使我们失望。相反,我们想尝试修改一个现有的符号链接,我们可以据此hook一个经常使用的符号链接,使任何时候符号链接被使用时调用我们注册的自己的函数。
我们选择C:盘的符号链接作为目标。要实现这个目的,首先需要打开符号链接并获得它的对象,之后才能修改它:
获得符号链接对象后,需要保存目标字符串或者它指向的设备,因为回调函数需要用它们做返回值。获取设备比较麻烦并且有些问题,而字符串就保存在对象本身中。我们将字符串存在一个全局变量中,这样我们就有了修改符号链接需要的一切。接下来就需要创建我们的回调函数了。
symlinkCallback函数接收符号链接对象、两个输出参数(必须赋值其中一个)和SymlinkContext作为参数,SymlinkContext由注册回调函数的一方控制。我们使用这个上下文参数存储原来的LinkTarget字符串,从而可以将它设置到输出参数SymlinkPath以将符号链接指向正确的目标位置。
定义回调函数后,就可以在main函数中修改符号链接对象了:
理论上,工作已经完成了。加载驱动后,每当访问c盘都将调用我们的回调。但你可能注意到了,这里有个条件竞争。回调函数和上下文都是一个联合的一部分,这个联合也可以是一个unicode string,这里的数据会以错误的格式被解析,引起格式混淆并崩溃。
这个数据的类型由OBJECT_SYMBOLIC_LINK.Flags决定,但是Callback域距OBJECT_SYMBOLIC_LINK结构中的Flags域太远。我们无法使用一条CPU指令同时修改这两个值,除非我们到达Intel的TSX(Transactional Synchronization eXtensions)领域,其准许我们无竞争地在一个内存事务中执行所有这些访问。然而,除了旁路bug,现实中没有使用TSX的实例,我们也不愿意做第一个,以免该功能被intel取消。
如果我们先修改flags,在修改LinkTarget字符串之前可能会有人访问该符号链接,,内核会尝试将unicode string当做可执行代码调用,导致崩溃。如果先修改字符串,然后有次尝试使用符号链接,内核将把回调函数地址的低两字节解析为字符串长度,并以字符串形式读该长度的字节。这会是一个很大的长度,将导致无法预期的结果,最可能的是再次发生读无效内存错误,并崩溃。
我们找到了一个解决这个问题的办法,我们认为这个办法非常巧妙。它需要大约45分钟来处理链接设置,但有时你必须付出些代价。我们还想到一个更简单的可能方案:创建我们自己的内容正确的OBJECT_SYMBOLIC_LINK对象,然后修改OBJECT_DIRECTORY_ENTRY来替换指向原对象的指针。因为这是一个普通指针,所以可以使用InterlockedCompareExchangePointer。而且,OBJECT_DIRECTORY有一个锁(EX_PUSH_LOCK),我们可以利用它使操作安全。到我们更喜欢我们的巧妙方案(想在下面介绍该方法)。
之前提到,如果将字符串指针修改为函数指针,在修改flags前符号链接会被使用,使用者会将回调函数地址的前两字节当做字符串长度,并根据长度尝试解析字符串。因此,我们决定想办法让回调函数地址的低两字节为0000,这使得字符串长度为0,从而不会进行字符串解析。所以我们需要将回调函数对齐到64KB地址。实现这个需要许多尝试和一些链接魔法,实际流程如下:
进行这些修改后,我们的驱动代码结构如下:
编译驱动并在IDA中打开看看回调函数地址:
加载驱动看看如果把符号链接当做字符串解析会发生什么。。。
对修改后的符号链接进行转储,可以看到,将回调函数地址当做unicode string时,将得到长度为0的字符串,这解决了条件竞争问题。
当然,如果想卸载驱动,还需要实现一个卸载过程将符号链接修改回去。我们将对象保存在全局变量symlinkObj中,保存原来的LinkTarget在全局变量origStr中,在卸载时可以将全部东西恢复。
必须先修改flags然后立刻修改LinkTarget来避免条件竞争。你可能注意到了另一个有趣的代码(常在POC代码中出现)——调用_MemoryBarrier()。你可能已经知道,编译器保留了重新排列针对非易失性变量(成员)的内存操作的权利,这意味着,无法保证我们写的这两行C代码与实际汇编代码匹配。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)