65.9K
CodeProject 正在变化。 阅读更多。
Home

如何制作自己的沙盒:简单的沙盒解释

starIconstarIconstarIconstarIconstarIcon

5.00/5 (20投票s)

2016 年 9 月 30 日

CPOL

20分钟阅读

viewsIcon

59947

downloadIcon

680

一个微小的沙盒入门

引言

本文是继上一篇文章中介绍的虚拟化技术之后,继续深入探讨。 上一篇文章.

今天,我们将重点关注文件系统虚拟化问题,并实现一个虚拟化文件操作的沙箱。然而,任何商业沙箱解决方案都必须对文件系统操作以及许多其他系统机制进行沙箱化,例如注册表、远程过程调用、命名管道等。

内核模式对象和对象类型对象

当应用程序通过调用 API(例如 `CreateFile()`)打开文件时,会发生许多有趣的事情:首先,会查找给定文件名中的所谓符号名称及其“本机”同级名称,如下所示。

例如,如果一个应用程序打开一个名为“c:\mydocs\file.txt”的文件,它的名称会被替换为“\Device\HarddiskVolume1\mydocs\file.txt”。实际上,符号名称“C:\”已被替换为设备名称“\Device\HarddiskVolume1”。其次,结果的本机名称会再次被 IO Manager 解析——这是一个操作系统的内核模式组件,以确定将打开请求传递给哪个驱动程序。当驱动程序在系统中注册自身时,它由 `DRIVER_OBJECT` 结构表示。此结构与其他内容一起,包含驱动程序负责的设备列表。每一个设备又由 `DEVICE_OBJECT` 结构表示,具体取决于驱动程序创建它将管理的设备对象。

IO Manager 一次遍历一个组件,并尝试确定负责给定组件的“最终”设备。在我们的例子中,它首先遇到“\Device”组件。此时,会确定组件的 `type` 对象。在这种情况下,它是一个对象目录。我建议您从 systinternals.com 下载 `winobj` 工具来观察本机对象目录树。它与文件系统的目录树非常相似——包含对象目录和各种系统对象,例如 ALPC 端口、管道、事件等“文件”。确定对象类型后,并检索所谓的对象类型对象,就会发生进一步的处理。此时,我必须简单谈谈对象类型对象是什么。在启动时,Windows 会在对象管理器中注册许多对象类型——例如对象目录、事件、变异体(也称为互斥体)、设备、驱动程序等等。因此,当驱动程序创建设备对象时,它实际上是创建了一个“device”对象类型的对象。 `Device` 对象类型本身是“对象类型”类型的一个对象。有时,程序员更容易通过编程语言而不是英语来理解事物——所以让我们用 C++ 来表达这个概念。

class object_type
{
    virtual open( .. ) = 0;
    virtual parse( .. ) = 0;
    virtual close (.. ) = 0;
    virtual delete( ... )  = 0;
      ...
};
class eventType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};
class objectDirectoryType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};
class deviceType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};

当用户创建一个事件时,他们实际上是创建了 `eventType` 类型的一个实例。您可能会注意到——这些对象类型包含许多方法——例如 `open()`、`parse()` 等。在解析对象名称期间,对象管理器会调用这些方法,以确定哪个驱动程序负责处理此设备或彼设备。在我们的例子中,它首先遇到“\Device”组件,它基本上是一个对象目录类型的对象。因此,最终会调用对象目录类型 `parse()` 方法,并将路径的其余部分作为参数传递。

objectDirectoryType objectDirectory_t;
objectDirectory_t.parse("HarddiskVolume1\mydocs\file.txt");

`parse()` 方法反过来会确定 `HarddiskVolume1` 是 `device` 类型的一个对象。检索负责此设备的驱动程序(在本例中,是处理此卷的文件系统驱动程序),最终会调用 `deviceType` `parse()` 方法,并将路径的其余部分(即“\mydocs\file1.txt”)作为参数。文件系统筛选器驱动程序,我们将在本文中编写它,更准确地说,负责给定卷的驱动程序实例将在传递给相应回调例程的参数中看到此剩余部分。文件系统驱动程序“负责”处理此剩余部分,因此 `parse()` 方法应告知对象管理器,所有剩余部分都已被“识别”,因此无需进一步处理文件名。实际上,这些对象类型成员并未被记录,但为了理解操作系统如何处理内核对象类型,务必牢记它们的存在。

文件系统筛选器

文件系统筛选器是特殊的驱动程序,它们将自己插入文件系统驱动程序的驱动程序堆栈中,以便它们可以拦截所有上层应用程序和驱动程序发送的请求。当应用程序通过调用 `CreateFile()` API 等向文件系统发送请求时,会构建一个特殊的包,称为输入输出请求包(Input Output Request Packet,IRP),并将其发送给 IO Manager。然后,IO Manager 将请求发送给负责处理该特定请求的驱动程序。如前所述,对象管理器用于解析对象名称以找出哪个驱动程序负责处理给定请求。在我们的例子中,IRP 通常是发送到文件系统驱动程序,但如果存在筛选器,如上图所示,它们将首先收到此请求,由它们决定是拒绝该请求,将其传递给驱动程序(或更低的筛选器,如果存在),还是自己处理该请求,或者修改请求的参数并将其传递给驱动程序堆栈。您可以在下图看到筛选器驱动程序的典型布局。

编写筛选器驱动程序并非易事,需要大量的样板代码。文件系统驱动程序(以及因此的筛选器)会收到各种类型的请求。您需要为每种类型的请求编写一个处理程序(或者更具体地说,分派例程),即使您不想对某个特定请求进行特殊处理。筛选器驱动程序的典型分派例程如下所示。

NTSTATUS
    PassThrough(
    PDEVICE_OBJECT DeviceObject,
    PIRP Irp
    )
    
{
    if (DeviceObject == g_LegacyPipeFilterDevice )
    {
        DEVICE_INFO* pDevInfo = (DEVICE_INFO*)DeviceObject->DeviceExtension;
        if (pDevInfo->pLowerDevice)
        {
            IoSkipCurrentIrpStackLocation(Irp);
            return IoCallDriver(pDevInfo->pLowerDevice,Irp);
        }
    }

    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest( Irp, IO_NO_INCREMENT );
    return STATUS_SUCCESS;
}

在此示例中,管道筛选驱动程序会检查请求是否属于管道文件系统驱动程序(保存在 `g_LegacyPipeFilterDevice` 中),如果是,则将请求传递给较低的设备,即传递给下面的筛选器或设备驱动程序本身。否则,例程将仅成功完成请求。如上所述,IO Manager 以 IO 请求包(IRP)的形式将 IO 请求发送给驱动程序。每个 IRP 除了许多其他内容外,还包含所谓的堆栈位置。为了简化,您可以将它们视为例程的简单堆栈帧,这些堆栈帧分布在已注册的筛选器之间,以便每个筛选器都有自己的堆栈帧。

帧包含可以读取或修改的程序参数。这些参数包括请求的输入数据,例如,如果我们正在处理 `IRP_MJ_CREATE` 请求,则为正在打开的文件名。如果我们想修改较低驱动程序的某些值,我们应该调用 `IoGetNextIrpStackLocation()` 来获取较低驱动程序的堆栈位置。大多数驱动程序将简单地调用 `IoSkipCurrentIrpStackLocation()`:此函数仅更改 IRP 内部的“堆栈帧”指针,以便较低级别的驱动程序接收与我们的相同的“frame”。另一方面,驱动程序可以调用 `IoCopyCurrentIrpStackLocationToNext()` 将堆栈位置数据复制到较低级别的筛选器,但这是一种更耗时的过程,如果驱动程序希望在 IO 请求处理完毕后执行某些操作,则应使用此过程,通过注册回调例程(称为 IO 完成例程)。

上面给出的 `PassThrough()` 函数应由筛选器驱动程序注册,以便在应用程序发送我们要拦截的请求时接收来自 IO Manager 的通知。下面的代码片段展示了通常是如何完成的。

NTSTATUS RegisterLegacyFilter(PDRIVER_OBJECT DriverObject)
{
    NTSTATUS        ntStatus;
    UNICODE_STRING  ntWin32NameString;    
    PDEVICE_OBJECT  deviceObject = NULL;
    ULONG ulDeviceCharacteristics = 0;
    
    ntStatus = IoCreateDevice(
        DriverObject,                   // Our Driver Object
        sizeof(DEVICE_INFO),                              
        NULL,               
        FILE_DEVICE_DISK_FILE_SYSTEM,   // Device type
        ulDeviceCharacteristics,        // Device characteristics
        FALSE,                          // Not an exclusive device
        &deviceObject );                // Returned ptr to Device Object

    if ( !NT_SUCCESS( ntStatus ) )
    {
        return ntStatus;
    }
    
    UNICODE_STRING uniNamedPipe;
    RtlInitUnicodeString(&uniNamedPipe,L"\\Device\\NamedPipe");
    PFILE_OBJECT fo;
    PDEVICE_OBJECT pLowerDevice;
    ntStatus = IoGetDeviceObjectPointer(&uniNamedPipe,GENERIC_ALL,&fo,&pLowerDevice);
    if ( !NT_SUCCESS( ntStatus ) )
    {
        IoDeleteDevice(deviceObject);
        return ntStatus;
    }
    DEVICE_INFO* devinfo = (DEVICE_INFO*)deviceObject->DeviceExtension;
    devinfo->ul64DeviceType = DEVICETYPE_PIPE_FILTER;
    devinfo->pLowerDevice = NULL;
    g_DriverObject = DriverObject;
    g_LegacyPipeFilterDevice = deviceObject;
    
    if (FlagOn(pLowerDevice->Flags, DO_BUFFERED_IO))
    {
        SetFlag(deviceObject->Flags, DO_BUFFERED_IO);
    }

    if (FlagOn(pLowerDevice->Flags, DO_DIRECT_IO))
    {
        SetFlag(deviceObject->Flags, DO_DIRECT_IO);
    }
    if (FlagOn(pLowerDevice->Characteristics, FILE_DEVICE_SECURE_OPEN))
    {
        DbgPrint("Setting FILE_DEVICE_SECURE_OPEN on legacy filter \n");
        SetFlag(deviceObject->Characteristics, FILE_DEVICE_SECURE_OPEN);
    }

    //
    // Initialize the driver object with this driver's entry points.
    //
    for (size_t i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++) {

        DriverObject->MajorFunction[i] = PassThrough;
    }
    DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateHandler;
    DriverObject->MajorFunction[IRP_MJ_CREATE_NAMED_PIPE] = CreateHandler;
    
    //
    //  Do the attachment.
    //
    //  It is possible for this attachment request to fail because this device
    //  object has not finished initializing.  This can occur if this filter
    //  loaded just as this volume was being mounted.
    //

    for (int i = 0; i < 8; ++i)
    {
        LARGE_INTEGER interval;

        ntStatus = IoAttachDeviceToDeviceStackSafe(
            deviceObject,
            pLowerDevice,
            &(devinfo->pLowerDevice));

        if (NT_SUCCESS(ntStatus))
        {
            break;
        }

        //
        //  Delay, giving the device object a chance to finish its
        //  initialization so we can try again.
        //
        interval.QuadPart = (500 * DELAY_ONE_MILLISECOND);
        KeDelayExecutionThread(KernelMode, FALSE, &interval);
    }
    
    if ( !NT_SUCCESS( ntStatus ) )
    {
        IoDeleteDevice(deviceObject);
        
        return ntStatus;
    }
    return ntStatus;
}

上面的代码为发送到命名管道的请求注册了文件系统筛选设备。首先,它获取虚拟设备的对象,该设备代表管道。

ntStatus = IoGetDeviceObjectPointer(&uniNamedPipe,GENERIC_ALL,&fo,&pLowerDevice)

接下来,它使用默认 `PassThrough()` 处理程序初始化 `MajorFunction` 数组。此数组代表 IO Manager 可能发送到设备的所有请求类型。如果您想自定义某些请求的处理,您将为这些请求注册一个额外的处理程序,如代码所示。最后一步是将我们的筛选器附加到驱动程序堆栈。

 ntStatus = IoAttachDeviceToDeviceStackSafe(
            deviceObject,
            pLowerDevice,
            &(devinfo->pLowerDevice));

回想一下我们的分派例程 `PassThrough()` 通过 `CallDriver()` 例程将请求传递到堆栈——简单地将 IRP 作为参数和一个指向较低设备的指针传递。这个指针实际上是我们附加到的设备。当 API 调用设备时,在某个时候,它会使用其名称,例如 `\\Device\NamedPipe`,它不知道任何筛选器。但我们的筛选器是如何收到请求的呢?这个魔法是由 `IoAttachDeviceToDeviceStackSafe()` 函数完成的——它将我们用 `IoCreateDevice()` 创建的透明筛选器设备(`deviceObject`)附加到较低设备,在我们的例子中,是附加到名为 `\\Device\NamedPipe` 的设备。从那时起,所有定向到命名管道的请求都首先会发送到我们的筛选器。请注意,`CreateIoDevice()` 将 `NULL` 作为设备名称传递。在我们的例子中,不需要名称,因为这是一个筛选设备,因此不会有直接发送到筛选器的请求,而是发送到较低的设备。

从这一点开始,我们几乎完成了我们的最小筛选器驱动程序。我们所要做的就是编写 `DriverEntry()` 例程,它只需调用 `RegisterLegacyFilter`。

NTSTATUS
DriverEntry (
    __in PDRIVER_OBJECT DriverObject,
    __in PUNICODE_STRING RegistryPath
    )
    
{
    return RegisterLegacyFilter(DriverObject);
}

文件系统微型筛选器

如前一节所示,我们编写了大量的代码来编写不执行任何操作的关键驱动程序处理程序。它们仅用于使小型驱动程序正常工作。为了简化,一种新型的筛选驱动程序出现了——微型筛选驱动程序。它们是旧版筛选驱动程序 `FltMgr` 或筛选管理器(Filter Manager)的插件。 `FltMgr` 驱动程序是一个旧版筛选驱动程序,它实现了大部分样板代码,并允许开发人员将有效负载作为插件编写到此驱动程序中。这些插件称为文件系统微型筛选器。下面的图片显示了微型筛选器的简要布局。

如您在上几章中所记得的,每个旧版筛选器都将自己附加到它所筛选的特定设备的驱动程序堆栈。但是,当时没有方便的方法来控制筛选器在堆栈中的确切位置。微型筛选器通过引入 2 个新概念来解决这个问题——高度(altitude)和帧(frame)。高度有助于您控制从 IO Manager 接收通知的顺序。例如,根据上图,微型筛选器 A 第一个接收 IRP,微型筛选器 B 第二个接收,依此类推。一般来说,您的驱动程序占用的高度越高,在堆栈中的位置也越高。高度范围分组到帧中。每个帧代表 `FltMgr` 作为驱动程序堆栈中的旧版筛选器的位置。例如,在上图中,`FltMgr` 有 2 个实例,分别称为 Frame 1 和 Frame 0。可以看到,除了 `FltMgr` 实例外,堆栈中还有其他旧版筛选器。您的驱动程序在 `.INF` 文件中指定其高度,这是一个操作系统用于安装驱动程序的特殊安装文件类型。

沙箱入门:构建和安装驱动程序

现在您已经对内核模式驱动程序有了一些简要的概述,是时候深入研究我们的沙箱了。它的核心是一个微型筛选器驱动程序。您可以在 `src\FSSDK\Kernel\minilt` 中找到其源代码。我假设您正在使用 WDK 7.x 来构建驱动程序。为此,您应该运行适当的环境,例如 Win 7 x86 checked,然后进入源代码目录。只需在 WDK 环境下运行的命令提示符中键入“build /c”,您就会得到构建的驱动程序二进制文件。要安装驱动程序,只需将 `*.inf` 文件复制到包含 `*.sys` 文件的目录中,然后在资源管理器中转到该目录,右键单击 `*.inf` 文件,选择“安装”菜单项,驱动程序就会被安装。我建议您在虚拟机内进行所有实验,VMware 将是一个不错的选择。请注意,64 位 Windows 版本不会加载未签名的驱动程序。要在 VMWare 中运行驱动程序,您应该在来宾操作系统中启用内核模式调试器。这可以通过在管理员权限下运行的 `cmd` 中执行以下命令来完成:

  1. bcdedit /debug on
  2. bcdedit /bootdebug on

这将为来宾操作系统启用调试模式。现在您必须将命名管道指定为 VMWare 的串行端口,并对您主机上的 `WinDBG` 进行一些配置。之后,您将能够使用调试器连接到 VMWare 并调试您的驱动程序。

您可以在 本文 中找到有关如何配置 VMWare 进行驱动程序调试的详细信息。

沙箱入门:架构概述

我们的小型沙箱解决方案包含 3 个模块:提供虚拟化原语的内核模式驱动程序,接收驱动程序通知并能够通过更改接收到的通知参数来修改文件系统行为的用户模式服务,以及帮助服务与驱动程序通信的 `fsproxy` 中间库。让我们从内核模式驱动程序开始观察我们的小型沙箱。

沙箱入门:驱动程序入口

常规应用程序通常在 `WinMain()` 中开始执行,而驱动程序则在 `DriverEntry()` 例程中开始执行。让我们从这个例程开始检查驱动程序。

NTSTATUS
DriverEntry (
    __in PDRIVER_OBJECT DriverObject,
    __in PUNICODE_STRING RegistryPath
    )

{
    OBJECT_ATTRIBUTES oa;
    UNICODE_STRING uniString;
    PSECURITY_DESCRIPTOR sd;
    NTSTATUS status;

    UNREFERENCED_PARAMETER( RegistryPath );
    ProcessNameOffset =  GetProcessNameOffset();
    DbgPrint("Loading driver");
    //
    //  Register with filter manager.
    //

    status = FltRegisterFilter( DriverObject,
                                &FilterRegistration,
                                &MfltData.Filter );

    if (!NT_SUCCESS( status ))
    {

        DbgPrint("RegisterFilter failure 0x%x \n",status);
        return status;
    }

    //
    //  Create a communication port.
    //

    RtlInitUnicodeString( &uniString, ScannerPortName );

    //
    //  We secure the port so only ADMINs & SYSTEM can access it.
    //

    status = FltBuildDefaultSecurityDescriptor( &sd, FLT_PORT_ALL_ACCESS );

    if (NT_SUCCESS( status )) {

        InitializeObjectAttributes( &oa,
                                    &uniString,
                                    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
                                    NULL,
                                    sd );

        status = FltCreateCommunicationPort( MfltData.Filter,
                                             &MfltData.ServerPort,
                                             &oa,
                                             NULL,
                                             FSPortConnect,
                                             FSPortDisconnect,
                                             NULL,
                                             1 );
        //
        //  Free the security descriptor in all cases. It is not needed once
        //  the call to FltCreateCommunicationPort() is made.
        //

        FltFreeSecurityDescriptor( sd );
        regCookie.QuadPart = 0;

        if (NT_SUCCESS( status )) {

            //
            //  Start filtering I/O.
            //
            DbgPrint(" Starting Filtering \n");
            status = FltStartFiltering( MfltData.Filter );

            if (NT_SUCCESS(status))
            {
                  status = PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);
                   if (NT_SUCCESS(status))
                   {
                       DbgPrint(" All done! \n");
                       return STATUS_SUCCESS;

                   }
            }
            DbgPrint(" Something went wrong \n");
            FltCloseCommunicationPort( MfltData.ServerPort );
        }
    }

    FltUnregisterFilter( MfltData.Filter );
    
    return status;
}

DriverEntry 有几个关键点。

首先,它使用 `FltRegisterFilter()` 例程将驱动程序注册为微型筛选器。

status = FltRegisterFilter( DriverObject,
                                &FilterRegistration,
                                &MfltData.Filter );

它在 `FilterRegistration` 中提供一个指向某些操作处理程序的指针数组,这些操作它希望在成功注册的情况下在 `MfltData.Filter` 中进行处理,并接收筛选器实例。 `FilterRegistration` 声明如下:

const FLT_REGISTRATION FilterRegistration = {

    sizeof( FLT_REGISTRATION ),         //  Size
    FLT_REGISTRATION_VERSION,           //  Version
    0,                                  //  Flags
    NULL,                               //  Context Registration.
    Callbacks,                          //  Operation callbacks
    DriverUnload,                       //  FilterUnload
    FSInstanceSetup,                    //  InstanceSetup
    FSQueryTeardown,                    //  InstanceQueryTeardown
    NULL,                               //  InstanceTeardownStart
    NULL,                               //  InstanceTeardownComplete
    FSGenerateFileNameCallback,         // GenerateFileName
    FSNormalizeNameComponentCallback,   // NormalizeNameComponent

    NULL,                               //  NormalizeContextCleanup
#if FLT_MGR_LONGHORN
    NULL,                               // TransactionNotification
    FSNormalizeNameComponentExCallback, // NormalizeNameComponentEx
#endif                                  // FLT_MGR_LONGHORN

};

如您所见,它有一个指向回调的指针——这是旧版筛选器中称为分派例程的类比,一个可以不存在的卸载子例程,以及一些我们稍后将描述的其他辅助函数。现在,让我们关注回调。它们定义如下:

const FLT_OPERATION_REGISTRATION Callbacks[] = {

    { IRP_MJ_CREATE,
      0,
      FSPreCreate,
      NULL
    },

    { IRP_MJ_CLEANUP,
      0,
      FSPreCleanup,
      NULL},

    { IRP_MJ_OPERATION_END}
};

您可以在 MSDN 上看到 `FLT_OPERATION_REGISTRATION` 的详细说明。我们的驱动程序只注册了 2 个回调——`FSPreCreate`,它将在每次收到 `IRP_MJ_CREATE` 请求时调用;以及 `FSPreCleanup`,它将在每次收到 `IRP_MJ_CLEANUP` 时调用。当文件的最后一个句柄关闭时就会收到此请求。我们可以(实际上也将)修改其输入参数并向下传递修改后的请求,以便较低的筛选器以及最终的文件系统驱动程序能够收到修改后的请求。我们可以注册所谓的后通知,该通知在操作完成时收到。这可以通过用后置操作回调例程指针替换 `FSPreCreate` 指针后面的 `NULL` 指针来完成。我们必须用 `IRP_MJ_OPERATION_END` 元素来完成我们的数组。这是一个“伪”操作,标志着回调数组的结束。请注意,我们不必为每个 `IRP_MJ_XXX` 操作提供处理程序,就像我们必须为旧版筛选器那样。

我们的 `DriverEntry()` 所做的第二件重要事情是创建一个微型筛选器端口,用于向用户模式服务发送通知并接收其回复。它通过 `FltCreateCommunicationPort()` 例程来完成。

 status = FltCreateCommunicationPort( MfltData.Filter,
                                             &MfltData.ServerPort,
                                             &oa,
                                             NULL,
                                             FSPortConnect,
                                             FSPortDisconnect,
                                             NULL,
                                             1 );

请注意提供给此例程的 `FSPortConnect()` 和 `FSPortDisconnect()` 子例程的指针。当用户模式服务连接和断开驱动程序时,会调用这些例程。

最后要做的事情就是实际运行筛选。

status = FltStartFiltering( MfltData.Filter );

请注意,此例程将传递 `FltRegisterFilter()` 返回的筛选器实例的指针。从那时起,我们开始接收 `IRP_MJ_CREATE` 和 `IRP_MJ_CLEANUP` 请求的通知。除了文件筛选通知之外,我们还通过此语句要求操作系统告诉我们何时加载和卸载新进程。

 PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);

`CreateProcessNotify` 是我们进程创建/删除通知处理程序的地址。

沙箱入门:FSPreCreate 例程

这就是大部分魔术发生的地方。此例程的关键在于报告正在打开哪个文件以及由哪个进程打开。这些数据被发送到用户模式服务,而服务反过来可能会拒绝访问该文件,将请求重定向到另一个文件(沙箱实际上就是这样工作的),或者仅仅允许操作。此例程需要做的第一件事是检查通信端口(我们在 `DriverEntry()` 中创建的)是否与用户模式服务有连接,如果没有连接,则直接放弃。我们还检查服务本身是否是请求的来源——我们通过检查全局分配结构 `MfltData` 的 `UserProcess` 字段来做到这一点。当用户模式服务连接到端口时,会在 `PortConnect()` 例程中填充此字段。我们也不想处理与分页相关的请求。在所有这些情况下,我们返回 `FLT_PREOP_SUCCESS_NO_CALLBACK` 返回码,这意味着我们已完成请求的处理,并且没有后置操作处理程序。否则,我们将返回 `FLT_PREOP_SUCCESS_WITH_CALLBACK`。如果我们是旧版筛选驱动程序,我们将不得不处理我之前提到的堆栈位置、`IoCallDriver` 过程等等。在微型筛选器的情况下,传递请求相当简单。

如果我们想处理请求,我们首先要做的是填充我们想传递给用户模式的结构——`MINFILTER_NOTIFICATION`。这个结构是完全自定义的。我们传递操作——`CREATE`,对原始请求执行操作的文件名,进程 ID 和原始进程的名称。请注意我们如何找到进程名的。实际上,这是一种获取进程名的非官方方法,不建议在商业软件中使用。更重要的是,它似乎在 64 位版本的 Windows 中不起作用。在商业软件中,您只会将进程 ID 传递给用户模式,如果您想要可执行文件名,您可以使用用户模式 API 来检索它。例如,您可以使用 `OpenProcess` API 通过 PID 获取进程句柄,然后调用 `GetProcessImageFileName()` API 来获取可执行文件名。但是,为了简化我们的沙箱,我们从 `PEPROCESS` 结构的非官方字段中获取进程名。为了找到名称的偏移量,我们考虑到系统中有一个名为“SYSTEM”的进程。我们扫描一个进程,该进程的 `PEPROCESS` 结构中包含此名称,然后我们假设对于任何给定的进程(`PEPROCESS` 结构),映像名称的相对偏移量是相同的。有关详细信息,请参见 `SetProcessName()` 函数。

我们使用两个函数 `FltGetFileNameInformation()` 和 `FltParseFileNameInformation()` 获取“目标”文件的文件名,即正在对其执行请求的文件(例如,正在打开的文件)。

一旦我们的 `MINFILTER_NOTIFICATION` 结构准备就绪,我们就将其发送给用户模式。

Status = FltSendMessage( MfltData.Filter,
            &MfltData.ClientPort,
            notification,
            sizeof(MINFILTER_NOTIFICATION),
            &reply,
            &replyLength,
            NULL );

并在 `reply` 变量中获得回复。如果被要求拒绝操作,则操作很直接。

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

这里的关键点如下:首先,我们通过返回 `FLT_PREOP_COMPLETE` 来更改返回代码。这意味着我们不会将请求传递给堆栈。这就像我们为旧版驱动程序调用 `IoCompleteRequest()` 而不调用 `IoCallDriver()` 一样。其次,我们填充请求的 `IoStatus` 结构。我们设置一个错误代码——`STATUS_ACCESS_DENIED`,并将 `Information` 设置为零。`Information` 是特定于操作的字段。通常,它包含在复制操作期间传输的字节数。

如果我们想重定向操作,事情就会有所不同。

    if (reply.bSupersedeFile)
        {
            // retrieve volume form name
            // File format possible: \Device\HardDiskVolume1\Windows\File,
            // or \DosDevices\C:\Windows\File OR \??\C:\Windows\File or C:\Windows\File
            RtlZeroMemory(wszTemp,MAX_STRING*sizeof(WCHAR));
            // \Device\HardDiskvol\file or \DosDevice\C:\file

            int endIndex = 0;
            int nSlash = 0; // number of slashes found
            int len = wcslen(reply.wsFileName);
            while (nSlash < 3 )
            {
                if (endIndex == len ) break;
                if (reply.wsFileName[endIndex]==L'\\') nSlash++;
                endIndex++;
            }
            endIndex--;
            if (nSlash != 3) return FLT_PREOP_SUCCESS_NO_CALLBACK; // failure in filename
            WCHAR savedch = reply.wsFileName[endIndex];
            reply.wsFileName[endIndex] = UNICODE_NULL;
            RtlInitUnicodeString(&uniFileName,reply.wsFileName);
            HANDLE h;
            PFILE_OBJECT pFileObject;

            reply.wsFileName[endIndex] =  savedch;
            NTSTATUS Status = RtlStringCchCopyW(wszTemp,MAX_STRING,reply.wsFileName + endIndex );
            RtlInitUnicodeString(&uniFileName,wszTemp);

            Status = IoReplaceFileObjectName(Data->Iopb->TargetFileObject, 
                                    reply.wsFileName, wcslen(reply.wsFileName)*sizeof(wchar_t));
            Data->IoStatus.Status = STATUS_REPARSE;
            Data->IoStatus.Information = IO_REPARSE;
            FltSetCallbackDataDirty(Data);
            return FLT_PREOP_COMPLETE;

        }

这里的关键是调用 `IoReplaceFileObjectName`。

 Status = IoReplaceFileObjectName(Data->Iopb->TargetFileObject, 
          reply.wsFileName, wcslen(reply.wsFileName)*sizeof(wchar_t));

此函数修改输入文件对象的文件名——IO Manager 对象,它代表正在打开的文件。我们可以手动替换名称——通过释放包含名称的字段占用的内存,重新分配它,然后将新名称复制到新分配的缓冲区中。但是,由于此函数是在 Windows 7 中引入的,强烈建议使用它,而不是乱改缓冲区。在我的产品(Cybergenic Shade 沙箱)中,它必须在从 XP 到 Windows 10 的所有操作系统上运行,如果驱动程序在旧版操作系统(Win 7 之前)上运行,我会手动处理缓冲区。在更改了文件名之后,我们用一个特殊的状态——`STATUS_REPARSE`——填充数据,这要求将 `IO_REPARSE` 值设置为 `Information` 字段,并以 `FLT_PREOP_COMPLETE` 返回。重新解析意味着我们希望 IO Manager 重新发布原始请求(带有新参数)。这样,应用程序(请求的发起者)最初就好像要求以新名称打开文件一样。我们还必须调用 `FltSetCallbackDataDirty()`——除非我们也修改了 `IoStatus`,否则每次修改数据结构时都必须调用此 API。实际上,我们在这里修改了 `IoStatus`,所以我们调用这个函数只是为了确保我们通知了 IO Manager 我们的修改。

沙箱入门:名称提供程序

由于我们修改文件名,我们的驱动程序必须实现名称提供程序回调函数,当查询文件的名称或规范化文件名时会调用这些函数。这些回调是 `FSGenerateFileNameCallback` 和 `FSNormalizeNameComponentCallback(Ex)`。但是,由于我们的虚拟化技术基于 `IRP_MJ_CREATE` 请求的重新发布(我们假装虚拟化名称是 `REPARSE_POINTS`),因此这些回调的实现相当直接,并在 此处 进行了详细描述。这个示例基本上使用了该文章中描述的回调实现。所以,有关详细信息,请阅读它 :)。

用户模式服务

用户模式服务位于 filewall 项目中(请参阅附带的示例),并与驱动程序通信。与沙箱相关的关键功能在此函数中实现。

bool CService::FS_Emulate( MINFILTER_NOTIFICATION* pNotification, 
                           MINFILTER_REPLY* pReply, const CRule& rule)
{
    using namespace std;
    // form new path
    // chek if path exists, if not - create/copy
    if (IsSandboxedFile(ToDos(pNotification->wsFileName).c_str(),rule.SandBoxRoot))
    {
        pReply->bSupersedeFile  = FALSE;
        pReply->bAllow = TRUE;
        return true;
    }
    wchar_t* originalPath = pNotification->wsFileName; // in native
    int iLen = GetNativeDeviceNameLen(originalPath);
    wstring relativePath;
    for (int i = iLen ; i < wcslen(originalPath); i++) relativePath += originalPath[i];
    wstring substitutedPath = ToNative(rule.SandBoxRoot) + relativePath;
    if (PathFileExists(ToDos(originalPath).c_str()))
    {
        if (PathIsDirectory(ToDos(originalPath).c_str()) )
        {
            // just an empty directory - simply create it in sandbox

            CreateComplexDirectory(ToDos(substitutedPath).c_str() );
        }
        else
        {
            // full file name provided - create a copy of the file in sandbox, if not already present

            wstring path = ToDos(substitutedPath);
            wchar_t* pFileName = PathFindFileName(path.c_str());
            int iFilePos = pFileName - path.c_str();
            wstring Dir;
            for (int i = 0; i< iFilePos-1; i++) Dir = Dir + path[i];

            CreateComplexDirectory(ToDos(Dir).c_str());
            CopyFile(ToDos(originalPath).c_str(),path.c_str(),TRUE);
        }
     }
    else
    {
        // no such file, but we have to create parent directory if not exists
        wstring path = ToDos(substitutedPath);
        wchar_t* pFileName = PathFindFileName(path.c_str());
        int iFilePos = pFileName - path.c_str();
        wstring Dir;
        for (int i = 0; i< iFilePos-1; i++) Dir = Dir + path[i];

        CreateComplexDirectory(ToDos(Dir).c_str());
    }
    wcscpy(pReply->wsFileName,substitutedPath.c_str());
    pReply->bSupersedeFile  = TRUE;
    pReply->bAllow = TRUE;
    return true;
}

当驱动程序决定重定向文件名时会调用它。这里使用的算法很简单:如果沙箱化的文件已存在,它将通过用新文件名(`sandbox` 文件夹内的名称)填充 `pReply` 变量来重定向请求。如果不存在,则会将原始文件复制,之后才会修改原始请求以指向新复制的文件。服务如何知道是否应该为特定进程重定向请求?这是通过规则实现的——请参见 `CRule` 类的实现。规则(实际上是我们演示服务中的单个规则)在 `LoadRules()` 函数中加载。

bool CService::LoadRules()
{
    CRule rule;
    ZeroMemory(&rule, sizeof(rule));
    rule.dwAction = emulate;
    wcscpy(rule.ImageName,L"cmd.exe");
    rule.GenericNotification.iComponent = COM_FILE;
    rule.GenericNotification.Operation = CREATE;
    wcscpy(rule.GenericNotification.wsFileName,L"\\Device\\Harddisk*\\*.txt");
    wcscpy(rule.SandBoxRoot,L"C:\\Sandbox");
    GetRuleManager()->AddRule(rule);
    return true;
}

此函数为名为“cmd.exe”的进程创建了一条规则,并对所有 `*.txt` 文件操作进行“sandboxes”。如果您在运行我们服务的 PC 上运行 `cmd.exe`,它将对这些操作进行沙箱化。例如,您可以通过运行“dir > files.txt”命令从 `cmd.exe` 创建一个 txt 文件,“files.txt”文件将被创建在 `C:/sandbox/

/files.txt` 中,其中 `` 是 `cmd.exe` 的当前目录。如果您从 `cmd.exe` 编辑已有的文件,您将得到它的两个副本——一个未修改的版本在原始 FS 上,一个修改过的版本在 `C:/Sandbox` 中。

结论

好吧,我认为沙箱的基本内容已经涵盖了。这里还有很多细节和瓶颈没有涉及。例如,不应从用户模式驱动规则,因为这种方法会显著降低 PC 性能。这种方法实现起来非常简单,足够用于学习目的或作为 PoC 示例,但绝不应在商业软件中使用。另一个限制是通知/回复结构,带有用于文件名的预分配缓冲区。这些缓冲区有两个缺点:首先,它们的尺寸有限,位于 FS 深处的一些文件将被不正确地处理。第二个缺点是,在大多数情况下,它们占用的内核模式内存大部分未被使用。因此,商业软件也应使用智能内存分配策略。另一个缺点是广泛使用 `FltSendMessage()` 函数,该函数相当慢。它应该只用于用户模式应用程序需要向用户显示请求,并且用户必须允许或拒绝操作的情况。在这种情况下,使用此函数是可以的,因为与人类的交互比执行任何代码要慢得多。但是,如果您的程序自动响应,您应该避免大量与用户模式代码通信。

一位有心的读者一定会注意到,示例的组件名称与 Cybergenic Shade / BEST Platform 的名称相匹配。实际上,这段代码源自一个非常早期的 PoC 示例,后来发展成这个产品。目前,代码已完全重写、优化,当然也变得非常复杂。但是,这个非常早期的 PoC 实现易于理解,并且(我希望)适合学习和证明概念。

© . All rights reserved.