译者:anhkgg
预估稿费:190RMB
投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿
介绍
在研究windows内核过程中,我们关注了一个很感兴趣的内容,就是PsSetLoadImageNotifyRoutine,像他名字一样就是提供模块加载通知的。
事情是这样的,内核中为加载的PE文件注册了一个回调通知之后,可能会收到一个非法的模块名字。
在对这个问题进行挖掘之后,看起来是一个偶然的问题其实是因为windows内核自己的代码错误引起的。
这个缺陷存在于从Windows 2000到最新的Windows 10发布版本的所有版本中。
优点:模块加载通知
如果你是个开发驱动的安全厂商,你需要知道系统什么时候加载了模块。通过Hook来完成,可以….但是可能会有很多安全和实现的缺陷。
微软是这么介绍windows2000的PsSetLoadImageNotifyRoutine的。这个机制会在一个PE文件被加载到虚拟内存中(不管是内核态还是用户态)通知内核中注册过回调的驱动,。
深入背后:
下面这几种情况会调用到会地哦啊通知例程:
加载驱动
启动新进程(进程可执行文件/系统DLL:ntdll.dll(对于Wow64进程会有两种不同的文件))
动态加载PE镜像-导入表,LoadLibrary,LoadLibraryEx,NtMapViewOfSection
图1:在ntoskrnl.exe中所有对PsSetLoadImageNotifyRoutine的调用
在调用已注册的通知回调时,内核提供一些参数来正确标志加载的PE镜像。参数可以看下面的回调函数原型定义:
VOID (*PLOAD_IMAGE_NOTIFY_ROUTINE)(
_In_opt_ PUNICODE_STRING FullImageName, // The image name
_In_ HANDLE ProcessId, // A handle to the process the PE has been loaded to
_In_ PIMAGE_INFO ImageInfo // Information describing the loaded image (base address, size, kernel/user-mode image, etc)
);
唯一的出路:
实际上,这是WDK文档化的唯一用于监视PE加载到内存的的方法。
另外一种微软推荐的方式,是使用文件系统mini-filter回调(IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION)。NtCreateSection为了能够区分section object是不是一个加载的可执行镜像的一部分,会检查是否存在SEC_IMAGE标志。然而,文件系统mini-filter不会接收这个标志,因此不能区分section object是不是加载PE镜像创建的。
缺陷:错误的模块参数
唯一标志加载的PE文件的参数是FullImageName。
然而,在前面描述的所有场景中,内核使用的另外一种格式的FullImageName。
第一看的时候,我们注意到获取进程可执行文件全路径和系统DLL的环境变量(没有卷名)时,其他动态加载的用户态PE提供的路径也没有卷名。
更让人担忧的是不仅是路径没有了卷名,有时候路径完全是畸形的,可能指向了一个不同的或者不存在的文件。
RTFM
和所有研究人员/开发人员一样,我们做的第一件事就是去看文档,保证对这个东西理解正确了。
根据MSDN的描述,FullImageName表示文件在磁盘的路径,用来标记可执行文件。没有提到可能存在不合法或者不存在的路径。
文档中提高了路径可能是空:在进程创建期间如果操作系统无法获取镜像的完整路径,这个参数可以为空。也就是说,如果这个参数不是空的,那么内核就会认为这是正确的参数而接收。
甚于拼写错误的文档
仔细阅读文档是,我们注意到另一件事,MSDN中显示的函数原型是错误的。参数Create根据描述完全像是跟这个机制没有关系,在WDK中的函数原型完全没有这个参数。很讽刺的就是,使用MSDN提供的原型会导致栈溢出崩溃。
面纱的下面
nt!PsCallImageNotifyRoutines会调用已注册回调函数的。它仅仅是将它调用者传来的UNICODE_STRING指针作为FullImageName参数传给回调函数。在nt!MiMapViewOfImageSection映射一个image的section时,UNICODE_STRING是section表示的FILE_OBJECT的FileName字段。
图2 传给回调的FullImageName实际是FILE_OBJECT的FileName字段
FILE_OBJECT通过SECTION->SEGMENT->CONTROL_AREA来获取。这些都是内部未文档的内核结构体。内存管理器在映射文件到内存中的时候创建了这些结构,只要文件已经映射了,都会在内部使用这些结构。
图3 调用nt!PsCallImageNotifyRoutines之前nt!MiMapViewOfImageSection获取FILE_OBJECT
每一个映射的镜像只有一个SEGMENT。意味着同样一个镜像在同一个进程中或者跨进程间同时存在的多个section会使用同一个SEGMENT和CONTROL_AREA。这就解释了为什么FullImagename在同一个PE文件同时加载到不同进程可以作为PE文件的标志了。
图4 文件映射内部结构(简化版)
继续RTFM
为了弄明白FileName是如何设置和管理的,我们回到文档中,发现MSDN禁止使用它。因为这个值只在初始化进程的IRP_MJ_CREATE请求时是有效的,在文件系统开始处理IRP_MJ_CREATE请求时不考虑是有效的值,但是在文件系统处理完IPR_MJ_CREATE之后FILE_OBJECT确实在使用它。
很明显,NTFS驱动拥有这个UNICODE_STRING(FILE_OBJECT.FileName)的所有权
使用内核调试器调试中,我们发现ntfs!NtfsUpdateCcbsForLcbMove 是负责重命名的一个操作。在看这个函数时我们推断出在IRP_MJ_CREATE请求中文件系统驱动只是创建了一个FILE_OBJECT.FileName的浅拷贝,然后单独维护它。这也就意味着只是拷贝了buffer的地址,而没有拷贝内容。
图5 ntfs!NtfsUpdateCcbsForLcbMove更新文件名字值
刨根问底
如果新路径长度没有超过MaximumLength,共享的buffer内容会被覆盖,FILE_OBJECT.FileName的Legnth字段不会更新,内核可以拿到这个值给回调函数,如果新路径长度超过了MaximunLength,会分配一块新内存,然后回调函数就会拿到过去的值。
尽管我们已经找到了这个bug的原因,但是还是有些事没有弄清楚。比如为什么在image所有句柄(SECTION和FILE_OBJECT中的)关闭之后我们依然可以看到这些畸形的路径。如果文件所有的句柄真的关了,下次这个PE镜像会被打开加载到一个新的FILE_OBJECT中,会创建没有引用的新的路径。
然而,FullImageName依然只想这个老的UNICODE_STRING。这表示句柄计数为0了FILE_OBJECT并没有关闭,意味着引用计数肯定是高于0的。我们也通过调试器确认了这个事情。
最后
内核中引用计数泄露基本是不可能了,我们只有把疑惑指向了:缓存管理器。这可能是一种缓存行为,文件系统驱动维护文件名,但缺引起可能拿到非法的文件名的严重错误。
影响
此时,我们确实已经弄清楚了引起这个问题的原因,但是我们疑惑的是这个bug为什么还存在?有没有什么好的解决方案?
下我们下一篇文章中,我们会努力找到这些问题的答案。
注意
我们大部分分析都在Windows 7 SP1 X86中,系统打了全补丁。这些发现在Windows XP Sp3,Windows 7 SP1 X64, Windows 10 (Redstone)X86/X64(全补丁)系统版本中也验证了。