本系列文章为看雪星星人为看雪安全爱好者创作的原创免费作品。
欢迎评论、交流、转载,转载请保留看雪星星人署名并勿用于商业盈利。
本人水平有限,错漏在所难免,欢迎批评指正。
本节将介绍可疑库的设计,以及我们在Windows中,是如何拦截模块的加载、执行,并在其中插入路径判断的。
可疑库实际上是可疑路径库,是一组路径的集合。每当Windows中有模块要被加载执行的时候,我们应该将被加载执行的路径和可疑库中的路径进行比较。如果前者在可疑库中,说明该文件可疑,不应直接执行,而应进一步处理。反之,则该模块可以直接执行。
这样做的本质理由是为了避免每次都进行相当消耗性能的处理,比如计算整个文件内容的散列值。所以这个比较应该非常快,不应损耗太多性能。作为反面典型的设计方法是将所有的路径保存在一个数组中,每次需要对比的时候将目标路径和库中所有路径挨个对比。这样当库中路径非常多的时候,其性能损耗可能会不亚于计算文件的散列值。
有很多方法可以提高字符串对比的性能。本书的代码使用哈希表。即将所有可以库中的路径计算一个散列值(在这里又常称为哈希值),然后插入哈希表中。哈希表由一定数量的哈希链(每个都是单链表)组成。哈希链的头保存在一个由散列值索引的数组中,这样查找很快。当两个路径的散列值一样的时候,它们会被插入同一条哈希链上。
在对比任何一条路径的时候,可先计算该路径的散列值,然后瞬间定位(通过数组索引而无需对比)到该散列值对应的哈希链上。遍历哈希链逐个进行字符串的对比,即可确认该路径是否在可疑库中。只要确保哈希链的条数不会远小于路径库中路径的总数,也就是每条哈希链上的路径数不会太多,性能就是可靠的。
当然读者也可以用任何可以进行高性能对比查找的数据结构来实现可疑库,比如各种树。
在实际项目中,哈希表中的散列算法(这里又可称哈希算法)的选择非常重要。算法必须计算快捷、散布均匀,才能充分地利用处理器资源和存储空间。但这对本书来说不是重点。所以我只很简单地对路径上每个字符求和来获得一个散列值。
这里有一个特别要注意的地方:Windows的文件路径并不区分大小写。所以在对比和计算散列值之前,都必须将文件路径全部转成大写或者小写,本书中一律转成大写。
在Windows内核中,字符串一般用UNICODE_STRING结构来表示。考虑到本书中的路径字符串将会插入到链表中,因此需要重新包装,在其后增加一个指针以便插入链表。因此,本书代码中的可疑库中的可疑路径数据结构定义如代码4-1所示。要注意其中的字符串成员path必须是已经全部转换为大写的,否则计算散列值和对比都会出麻烦。
代码4-1 可疑库中的可疑路径数据结构定义
因为成员path是UNICODE_STRING类型,而UNICODE_STRING是Windows内核定义的,其结构如代码4-2所示。其中Length表示的是实际字符串的字节(尤其是要注意是字节,而不是字符数)长度。这个长度不含字符串末尾的NULL结束符,实际上这种字符串末尾也不一定有结束符。而MaximumLength表示的是缓冲区指针Buffer指向的空间可用的实际长度。
代码4-2 Windows内核定义的UNICODE_STRING结构
指向真正的字符串内容的指针Buffer显然必须指向一块有效的内存。为了简便起见,本书中的代码都会将结构DUBIOUS_PATH的内存空间分配得更大,让path.Buffer的内容刚好指向DUBIOUS_PATH后部的“多余”区域。所以DUBIOUS_PATH的真正结构如图4-1所示。
图4-1 DUBIOUS_PATH的真正结构
此外,本书中实际的路径散列值计算代码如图代码4-3所示。这些代码唯一目的是简单并用于理解。但实际商用中可能需要选择性能更好、散布更均匀的哈希算法。
代码4-3 路径散列值计算代码
该代码的算法并未考虑简单求和获得的散列是否均匀。但可以看出,散列值最大为0xffff,实际一共可以拥有65536个散列值。也就是说,如果可疑路径库中的可疑路径不远大于这个数字,性能损耗就不会太严重。如果担心性能不足,则还可增大这个值,只是会增加少量内存损耗。
可疑路径的数量实际上会达到多少,这是很难预计的。最好的办法是将实际用户的可疑库中的路径数量作为日志上报上来,后续按实际的值来调整哈希表的大小以便性能达到最优。
最后,作为可疑库(实际是一个哈希表)的实体,我们将定义一个静态全局数组。它的每个数组元素指向一个哈希链。所以毫无疑问,这个数组的元素个数应该为代码4-3中的HASH_MAX加1。注意这个加1!代表可疑库的全局变量g_dubious_path定义如代码4-4所示。
代码4-4 代表可疑库的全局变量g_dubious_path定义
假定上面代码的(1)处的定义缺失了“+1”,那么这个数组将只有0xffff个元素。由于元素下标从0开始,所以最大下标为0xfffe。那么攻击者只要尝试生成一个路径计算散列值为0xffff的可执行文件,我们的系统就会往内核中越过数组边界写入一个指针。
这种写入有可能是无大碍的(若数组之后是一段没什么用的多余空间),但也有可能是致命的。若假定后续是一个重要结构指针,被覆盖后可能导致系统蓝屏。此外还可能引入安全漏洞,如果该处涉及一些重要的安全策略。
从这里我们可以看到,由安全系统引入新的安全问题,这看起来似乎很讽刺,其实是完全有可能的。
除了全局变量g_dubious_path之外,(2)处的自旋锁用来保持可疑库的操作的同步性,防止多线程竞争的情况下搞坏链表。
可疑库在实际应用中,首要作用是判断某个路径是否可疑。该操作即是输入一个文件的全路径,判断该路径是否在可疑库中,这是一个典型的查找工作。本例中实现的判断路径是否可疑的代码如代码4-5所示。
代码4-5 本例中实现的判断路径是否可疑的代码
输入参数mod_path就是某个模块的全路径,它必须是全大写的。接下来的代码和我们前面看到过的各种处理代码相似,都是先检查中断级。如果中断级不正确则直接返回,不做任何处理。如(1)处代码所示。
接下来检查输入参数。mod_path显然不应为NULL。如果为NULL则不做处理。但为了确保安全期间,这里在(2)处也检查了一下mod_path的长度。
微软的NTFS文件系统本身并不限制文件路径的长度。但是无限长的文件路径很容易带来问题。如内存分配失败、字符串比较导致越界等等。考虑到长度超过一定限度的路径本身就已经很可疑(可能是攻击者的一种试探),这里直接简单地将所有超长路径设定为可疑。所以处设置了(2)处ret = TRUE,表示返回路径可疑。
然后是(3)处调用DubiousPathHash计算散列值。其实现见代码4-3。得到散列值之后,在(5)处用自旋锁进行加锁。
之所以这里要加锁,是因为Windows中可执行模块的修改和加载显然可能在任何进程中随时随地地发生。如果两个线程在两个核上同时进行可疑库的查询和増删,就会带来一堆冲突问题。所以这里用Windows内核中最常用的自旋锁进行加锁。
使用自旋锁需要注意两点:
(1)自旋锁是一种轮询机制实现等待的锁,处理器性能损耗很大。所以不要在获得锁之后做太长或者太复杂的处理。否则其他线程等待锁的时候会耗费太多处理器时间。
(2)自旋锁会导致中断级升高。因此如果在获得锁之后调用任何系统提供内核函数,都要重新审视这些函数的中断级要求。我个人的经验是,最好不要试图在获得锁之后调用任何内核函数。
在获得了散列值((3)处得到的hash)之后,哈希表中的哈希链的指针就是g_dubious_path[hash]。这个索引查找过程极快,几乎没有性能损失。性能损失会出现在6处的DubiousSearchInHashLine中。
该函数对单链表进行查找,并逐一和输入参数字符串进行完全的对比,因此会比较损耗性能。但在每条哈希链上的节点数量很少的情况下,这个损失也是基本可以忽略不计的。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2024-5-10 09:28
被星星人编辑
,原因: