绕过内核函数指针完整性检查

2021-04-13 15:18:54 浏览数 (2)

确保(系统)完整性是软件安全产品(例如反作弊或反病毒)中的重要细节。这些都是为了确保操作系统的主要功能没有被篡改。一种常见的完整性检查是对单个驱动程序对象的验证。出于多种原因,可以直接在内存中操作这些驱动程序对象(直接修改内核对象),但是本文所涉及的特定利用是对主要功能IRP_MJ_DEVICE_CONTROL的修改,即I / O处理程序。

…DeviceIoControl挂钩?

挂钩DeviceIoControl是一种常见的方法,用于拦截从驱动程序(例如,磁盘驱动器(disk.sys)获取磁盘信息)或网络驱动程序(ndis.sys)获取的信息。此类信息非常容易获取,反作弊和其他软件产品使用该信息来标识要许可或列入黑名单的唯一用户。[多年来,作弊者一直在通过拦截DeviceIoControl调用来表现出好像使用了完全不同的硬件,从而与这种类型的硬件禁止(标记某些硬件序列号并禁止与之相关的任何人)作斗争。

一个很好的例子是namazso的hdd serial spoofer项目,该项目挂接磁盘驱动程序并欺骗对硬盘序列号的任何查询,从而防止诸如BattlEyeEasy Anti Cheat之类的反作弊行为仅通过磁盘信息就将播放器列入黑名单。

此方法非常简单,它包括直接修改给定驱动程序对象中的函数指针表MajorFunction。该表包含驱动程序的所有基本功能,包括DeviceIoControl函数(IRP_MJ_DEVICE_CONTROL),并且系统操作使用该表来查找特定事件的相应处理程序。要挂钩I / O处理程序,我们只需将函数指针覆盖到给定的中间件即可:

代码语言:txt复制
auto& io_handler = driver_object->MajorFunction[IRP_MJ_DEVICE_CONTROL];
ioctl_hook::original_handler = io_handler;  // SAVE ORIGINAL FUNCTION
io_handler = ioctl_hook::handler;           // SET POINTER TO HOOK

与往常一样,找出驱动程序对象是否已被修改也很简单。

盲目完整性检查

BattlEyeEasy Anti Cheat反作弊使用相同的方法来确保主要功能表的完整性:

代码语言:txt复制
const auto& section = pe->sections[".text"];

for (const auto& major_function : driver->major_functions)
{
    if (major_function.address < section.address || 
        major_function.address >= section.address   section_size)
    {
        // ANOMALY FOUND
        // ..
    }
}

这就是我所说的盲目完整性检查,因为您实际上并不知道条目应该指向何处,但是您仍然需要确保它们未被篡改。如上所示,这是通过将每个条目与它们驻留的存储区(在驱动程序的可执行部分内部)进行比较来完成的。如果表条目指向驱动程序对象外部的内存位置,则很可能已被恶意行为者劫持。此完整性检查假定目标函数只要位于可执行的只读段之内,就不会被篡改。

这是什么故障检测,是如果我们已经更改了表项的东西我们可以控制,不留一节。

劫持控制流程

当我们无法更改节数据或不离开节时,我们怎么甚至劫持控制流?由于实际上可以控制执行流程,因此可以确定在调用I / O处理程序时执行哪个字节序列,并且您可能已经知道,CPU实际上并不关心对齐。如果我们找到一个字节序列,该字节序列产生了到模块外部目标的分支操作,则可以成功挂接该函数,而无需触发完整性检查。

我们可以使用磁盘驱动器作为案例研究。首先,让我们通过附加windbg / kd并运行以下命令来转储已加载的drisk驱动程序的.text部分:.writemem E:disksys.dmp disk.sys 0x1000 disk.sys 0x6000

这会将.text节(虚拟大小为0x5000)输出到磁盘上的指定目标。完成此操作后,我们可以使用任意x86_64反汇编程序创建一个简单的分支分析器,并打印出所有无条件分支操作,包括目标地址,稍后我们可以检查其有效性,

这是我的静态分析器发现的198个无条件分支操作的摘要:

代码语言:txt复制
.branch_analyzer.exe E:disksys.dmp
[ ] Analyzing code branching
[=]   Loaded size.... 20481
[ ] Branch
[=]   Target......... FFFFF80CCB379E7B
[=]   Instruction.... E9 45 8B 59 08
[ ] Branch
[=]   Target......... FFFFF80CC2DE1095
[=]   Instruction.... E9 5F FF FF FF
[ ] Branch
[=]   Target......... FFFFF80CC2DE10BE
[=]   Instruction.... E9 77 FF FF FF
[ ] Branch
[=]   Target......... FFFFF80CC2DE10F5
[=]   Instruction.... EB 95
[ ] Branch
[=]   Target......... FFFFF80CC2DE10F2
[=]   Instruction.... E9 77 FF FF FF
[ ] Branch
[=]   Target......... FFFFF80CC2DE2B5D
[=]   Instruction.... E9 B7 19 00 00
[ ] Branch
[=]   Target......... FFFFF80CC2DE2BC3
[=]   Instruction.... E9 E4 19 00 00

kd具有扩展名,使您可以查找任何给定地址(!pte)的相应页表项。让我们在代码段中的第一个目标上尝试一下:

代码语言:txt复制
0: kd> !pte FFFFF80CCB379E7B
                                           VA fffff80ccb379e7b
PXE at FFFF9BCDE6F37F80    PPE at FFFF9BCDE6FF0198    PDE at FFFF9BCDFE0332C8    PTE at FFFF9BFC06659BC8
contains 00000000BFF08063  contains 0A000001DAF83863  contains 0000000000000000
pfn bff08     ---DA--KWEV  pfn 1daf83    ---DA--KWEV  not valid

伟大的!这证实了我们的怀疑,即当代码段足够大时,我们可以找到未对齐的x86_64指令,这些指令会产生无条件分支,而无需修改实际代码。如果我们将主要功能表条目指向给定的E9 45 8B 59 08代码序列,则每次调用I / O处理程序时,代码流都会到达内存地址FFFFF80CCB379E7B,该地址是磁盘驱动程序外部的内存区域。这不是这种操作的唯一实例。该工具发现了33个可能的分支目标,它们驻留在未分配的内存中。幸运的是,我们可以通过调用MmIsAddressValid使此过程自动化并打印任何无效的目标。现在只剩下一个问题:我们如何强制控制内存中的特定地址?

处理页表条目

由于虚拟地址转换,现代操作系统使用了某种页表系统,这对我们来说是微不足道的。我们要做的就是找到相应的页表条目并将其手动标记为有效。Windows上的页表实现(如果您的处理器使用的品牌与英特尔不同,地址转换可能会更改)已在英特尔手册中进行了详细记录:

使用4级分页时,线性地址通过使用CR3内容定位的内存中分页结构的层次结构进行转换。4级分页将48位线性地址转换为52位物理地址。1尽管52位对应于4 PByte,但线性地址限于48位。在任何给定时间最多可以访问256 TB的线性地址空间。4级分页使用分页结构的层次结构来生成线性地址的转换。CR3用于查找第一个分页结构PML4表。

以下是intel手册中记录的一些相关结构:

代码语言:txt复制
union PML4_BASE {
    std::uint64_t value;
#pragma warning(disable : 4201)         // UB
    struct {
        std::uint64_t ignored_1 : 3;
        std::uint64_t write_through : 1;
        std::uint64_t cache_disable : 1;
        std::uint64_t ignored_2 : 7;
        std::uint64_t pml4_p : 40;
        std::uint64_t reserved : 12;
    };
};
struct HARDWARE_PTE                
{  
    std::uint64_t Valid : 1;              
    std::uint64_t Write : 1;              
    std::uint64_t Owner : 1;              
    std::uint64_t WriteThrough : 1;       
    std::uint64_t CacheDisable : 1;       
    std::uint64_t Accessed : 1;           
    std::uint64_t Dirty : 1;              
    std::uint64_t LargePage : 1;          
    std::uint64_t Global : 1;             
    std::uint64_t CopyOnWrite : 1;        
    std::uint64_t Prototype : 1;          
    std::uint64_t reserved0 : 1;          
    std::uint64_t PageFrameNumber : 36;   
    std::uint64_t reserved1 : 4;          
    std::uint64_t SoftwareWsIndex : 11;   
    std::uint64_t NoExecute : 1;          
};
struct MMPTE
{
    union
    {
        std::uint64_t   Long;
        std::uint64_t   VolatileLong;
        HARDWARE_PTE    Hard;
        MMPTE_SOFTWARE  Soft;
    }u;
};

为简单起见,我将不再赘述地址翻译,因为它已经读了很长时间了。如果您想了解更多信息,欢迎阅读《英特尔手册第一卷》。3A第4.5章,涵盖了4级寻呼的细节;对于本文的其余部分,为了清楚起见,我将对其进行抽象。

完成页表条目转换后,我们需要手动拼凑有效的页表条目,以便在将控制流强制到该内存区域时,处理器不会发生页错误。通过利用页框架数据库,确保Windows操作系统实际上知道该内存页已被使用,因此不能妥善处理,或者通过简单地操纵页表项,从而导致巨大的竞争状况,可以正确地做到这一点。我们将展示;-)。

因为页表项必须由页目录项(必须由页目录指针表项引用,依此类推)引用,所以如果需要的话,我们需要初始化这些结构。显示了完整的分页层次结构,如下所示:

图1-具有4级分页的CR3和分页结构条目的格式,版权所有:英特尔

这是页表条目的基本重建:

代码语言:txt复制
page_info.pte->u.Hard.Dirty = 1;
page_info.pte->u.Hard.Accessed = 1;
page_info.pte->u.Hard.Owner = 0;
page_info.pte->u.Hard.Write = write ? 1 : 0;
page_info.pte->u.Hard.NoExecute = execute ? 0 : 1;
page_info.pte->u.Hard.Valid = 1;

和页面目录条目:

代码语言:txt复制
page_info.pde->u.Hard.Dirty = 1;
page_info.pde->u.Hard.Accessed = 1;
page_info.pde->u.Hard.Owner = 0;
page_info.pde->u.Hard.Write = 1;
page_info.pde->u.Hard.NoExecute = 0;
page_info.pde->u.Hard.Valid = 1;

看?非常简单。这就是您要做的全部;您已经处于内核模式,因此可以通过控制寄存器CR3获取PML4的基地址

0 人点赞