Click here to Skip to main content
15,867,308 members
Articles / Programming Languages / VC++

How to Make Your Own Sandbox: Simple Sandbox Explained

Rate me:
Please Sign up or sign in to vote.
5.00/5 (21 votes)
2 Oct 2016CPOL20 min read 56.3K   645   35   7
A tiny sandbox primer

Introduction

This article is a continuation on virtualization technologies, introduced in the previous article.

Today, we'll focus on file system virtualization problem and implement a sandbox which virtualizes work with files. Any commercial sandboxing solution, however, has to sandbox not only file system operations but a lot of other system mechanisms, such as registry, remote procedure calls, named pipes, etc.

Kernel Mode Objects and Object Type Objects

When an application opens a file by calling an API, say, CreateFile(), a lot of interesting things happen: first, so called symbolic names in a given file name are being looked up for their "native" siblings, as shown below:

Image 1

For instance, if an app opens a file, named "c:\mydocs\file.txt", its name is to be replaced with something like "\Device\HarddiskVolume1\mydocs\file.txt". In fact, symbolic name "C:\" was replaced with "device" name "\Device\HarddiskVolume1". Second, the resulting native name is parsed again by IO Manager - a kernel mode component of the OS, to determine which driver to pass open request to. When driver registers itself in the system, it is being represented by DRIVER_OBJECT structure. This structure, along with other stuff, contains a list of devices driver is responsible for. Every single device, in its turn, is represented by DEVICE_OBJECT structure and it's up to the driver to create device objects it is going to manage.

IO Manager traverses one component at a time and tries to determine the "resulting" device, responsible for a given component. In our case, it first encounters "\Device" component. At this point, component's type object is determined. In this case, it's an object directory. I recommend you to download winobj utility from systinternals.com to observe native objects directory tree. It is very similar to that of file system's directory tree - with object directories and various system objects, such as ALPC Ports, pipes, events as "files". Once object type is determined, and so called object type object is retrieved, further processing takes place. At this point, I have to say a couple of words on what object type object is. At boot time, Windows registers with Object Manager a lot of object types - such as object directory, event, mutant (also known to user-land developers as mutex), device, driver and so forth. So, when a driver creates, say, device object, it actually creates an object of "device" object type. Device object type, in turn is an object of "object type" type. Sometimes, it's more easy for a programmer to understand things, when they are explained in a programming language rather than in English - so let's express this concept in C++:

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( ... );
};
C++
class objectDirectoryType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};
C++
class deviceType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};

When user creates an event, she basically creates an instance of type eventType. As you may notice - these object type types contain a lot of methods - such as open(), parse(), etc. These are called by Object Manager during parsing object name, so that to determine, which driver is responsible for this or that particular device. In our case, it first encounters "\Device" component which is basically an object of object directory type. Therefore, an object directory type parse() method will eventually be called passing to it path remainder as a parameter:

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

parse() method, in turn will determine that HarddiskVolume1 is an object of type device. A driver, responsible for this device is retrieved (in this case, it is a file system driver, that works with this volume), and a deviceType parse() method is eventually called with path reminder (i.e. "\mydocs\file1.txt"). File System Filter driver, which we are going to write in this article, more precisely, a driver instance, responsible for given volume will see exactly this reminder in parameters, passed to its corresponding callback routines. File system drivers are "responsible" for processing this reminder, so that parse() method should say to Object Manager that all the reminders are 'recognized' and, so, further processing of file name is not required. Actually, these object type members are not documented, but it is essential to keep in mind their existence to understand the way OS deals with kernel object types.

File System Filters

File system filters are special kind of drivers that insert themselves into driver stack of file system drivers so that they can intercept all requests applications and drivers, situated above them send. When an app sends a request to the file system, for instance, by calling CreateFile() API, a special packet, so called Input Output Request Packet, or IRP, is constructed and sent to IO Manager. IO manager then sends the request to driver, which is responsible for processing this particular request. As mentioned earlier, Object Manager is used to parse object name to find out which driver is responsible for processing given request. An IRP, in our case, is, generally speaking, addressed to File System Driver, but if there are filters , as shown in the picture below, they will receive this request first and it's up to them to make a decision whether to decline this request, pass it down to the driver (or lower filter if there is one), to process the request by themselves, or to modify request's parameters and pass it down the driver stack. You can see typical layout of filter drivers in the picture below:

Image 2

Writing filter driver is not a trivial task and requires a lot of boilerplate code. There are tons of requests of different kind file system drivers (and, therefore, filters) receive. You should write a handler (or, more specifically, dispatch routine) for every type of request, even if you don't want to do special processing for this or that particular request. Typical dispatch routine of a filter driver looks like this:

C++
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;
}

In this example, a pipe filtering driver checks if a request belongs to pipe file system driver (saved somewhere in g_LegacyPipeFilterDevice) and, if so, passes the request down to the lower device, i.e., to a filter below or to device driver itself. Otherwise, routine just completes request with success. IO requests to drivers are sent by the IO Manager, mentioned above, in a form of IO Request Packets or IRPs. Each IRP along with lots of other stuff, contains so called stack locations. To simplify things down, you can think of them as if they were simple stack frames of a routine, and these stack frames are distributed among registered filters , so that each filter has its own stack frame.

The frame contains procedure parameters which can be read or modified. These parameters include input data for the request, for example, file name which is being opened if we are processing IRP_MJ_CREATE request. If we want to modify some values for the lower driver, we should call IoGetNextIrpStackLocation() to get stack location of lower driver. Most drivers would simply call IoSkipCurrentIrpStackLocation(): this function simply changes a "stack frame" pointer inside IRP so that lower level driver receives the same "frame" as ours does. On the other hand, a driver may call IoCopyCurrentIrpStackLocationToNext() to copy stack location data to that of lower level filter, but this is a more expensive procedure, and should be used if a driver wants to perform some work after an IO Request is processed, by registering callback routine, called IO Completion Routine.

PassThrough() function given above should be registered by filter driver to receive notifications form IO Manager when applications send requests we want to intercept. The code snippet given below shows how it is typically done.

C++
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;
    }
C++
    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;
}

The code above registers file system filtering device for requests that are sent to named pipes. First, it obtains device object of a virtual device, which represents pipes:

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

Next, it initializes MajorFunction array with default PassThrough() handler. This array represents all types of requests IO Manager may send to the device. If you want to customize processing of some of the requests, you would register additional handler for these as shown in code. The last step is to attach our filter to driver stack:

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

Recall the way our dispatch routine, PassThrough(), passes request down the stack - via CallDriver() routine, simply passing IRP as a parameter and a pointer to lower device. This pointer is actually a device we attached to. When an API calls the device, at some point, it uses its name, such as \\Device\NamedPipe, it is unaware of any filters. But how come our filter receives the request? The magic is done by IoAttachDeviceToDeviceStackSafe() function - it attaches our transparent filter device (deviceObject) which was created somewhere with IoCreateDevice() to lower device, in our case, to that named \\Device\NamedPipe. From that moment, all requests directed to Named Pipes first go to our filter. Note that CreateIoDevice() pass NULL as device name. In our case, name is not required because it is a filtering device and, therefore, there will be no requests directed to the filter, but to the lower device instead.

From this point, we are almost done with our minimal filter driver. All we have to do is to code DriverEntry() routine, which simply calls RegisterLegacyFilter:

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

File System Minifilters

As you saw in the previous section, we've written a lot of code just to write key driver handlers which do nothing. They are required just to make a tiny driver work. To simplify things up, new type of filtering drivers came to the scene - minifilter drivers. These are plugins to a legacy filter driver - FltMgr, or Filter Manager. FltMgr driver is a legacy filtering driver which implements most of the boilerplate code and allows the developer to write payload as a plugin to this driver. These plugins are called file system minifilters. Brief layout of minifilters is shown in the picture below:

Image 3

As you remember from the previous chapters, each legacy filter attaches itself to a driver stack of a particular device it filters. There was, however, no convenient way of controlling the exact place in the stack your filter occupies. Minifilters fix this issue by introducing 2 new concepts - an altitude and a frame. An altitude helps you control the order in which you receive notifications from IO Manager. For example, according to the picture above, Minifilter A is the first to receive IRP, minifilter B is the second and so on. Generally speaking, the higher altitude your driver occupies, the higher place in the stack you get. Ranges of altitudes are grouped into frames. Each frame represents FltMgr position as a legacy filter in the driver stack. For example, in the picture above, there are 2 instances of FltMgr, called Frame 1 and Frame 0. As you can see, there are other legacy filters present in the stack, along with FltMgr instances. Your driver specifies its altitude in .INF file, a special type of installation file an OS uses to install drivers.

Sandbox Primer: Building and Installing the Driver

Now that you have been given some brief overview of kernel mode drivers, it's time to dig into our sandbox. The core of it is a miniflter driver. You can locate its source code in src\FSSDK\Kernel\minilt. I assume that you are using WDK 7.x to build the driver. To do so, you should run the appropriate environment , say Win 7 x86 checked, and get to the source directory. Just type "build /c" in command prompt , being run under WDK environment and you'll get driver binaries built. To install the driver, simply copy *.inf file into directory that contains *.sys file, get to that directory with Explorer, and use context menu on *.inf file - select "Install" menu item and the driver will be installed. I recommend that you do all the experiments inside virtual machine, a VMWare would be a good choice for this. Please also note, that 64 bit variants of Windows would not load unsigned driver. To be able to run the driver in VMWare, you should enable kernel mode debugger in the guest OS. This is done by performing the following commands in cmd, being run under administrator:

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

This will enable debugging mode for guest OS. Now you must assign a named pipe as a serial port for VMWare and do some configuration to WinDBG, installed on your host machine. After that, you'll be able to connect to VMWare with debugger and debug your driver.

You can find detailed information on how to configure your VMWare for drivers debugging from this article.

Sandbox Primer: An Architecture Overview

Our tiny sandboxing solution consists of 3 modules: kernel mode driver, which provides virtualization primitives, a user mode service which receives notifications from driver and is able to modify file system behaviour by altering received notifications parameters, and fsproxy intermediate library which helps service communicate with the driver. Let's start observation of our tiny sandbox with kernel mode driver.

Sandbox Primer: Driver Entry

While regular applications usually start their execution in WinMain(), drivers do this in DriverEntry() routine. Let's start examining the driver with this routine.

C++
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 has several key points:

First, it registers the driver as a minifler with FltRegisterFilter() routine:

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

It provides an array of pointers to handlers of certain operations it wants to process in FilterRegistration and receives filter instance in MfltData.Filter in case of successful registration. FilterRegistration is declared as follows:

C++
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

};

As you can see, it has a pointer to callbacks - an analogue of what is called dispatch routine in legacy filters, an unload subroutine, which can be absent and some other auxiliary functions we'll describe later. For now, let's focus on callbacks. They are defined as follows:

C++
const FLT_OPERATION_REGISTRATION Callbacks[] = {

    { IRP_MJ_CREATE,
      0,
      FSPreCreate,
      NULL
    },

    { IRP_MJ_CLEANUP,
      0,
      FSPreCleanup,
      NULL},

    { IRP_MJ_OPERATION_END}
};

You can see a detailed explanation of FLT_OPERATION_REGISTRATION on MSDN. Our driver registers only 2 callbacks - FSPreCreate, which will be called each time an IRP_MJ_CREATE request is received and FSPreCleanup, which, in turn, will be called each time IRP_MJ_CLEANUP is received. This request is received when last handle to a file is closed. We can (and actually will) modify its input parameters and send modified request down the stack so that lower filters and, eventually, file system driver will receive modified request. We could have registered so called post-notification which is received when an operation is complete. This could be done by replacing NULL pointer, which follows FSPreCreate pointer with post-op callback routine pointer. We must finalize our array with IRP_MJ_OPERATION_END element. This is a "fake" operation which marks end of callbacks array. Note that we don't have to provide handler for each IRP_MJ_XXX operation as we had to for legacy filters.

Second important thing our DriverEntry() does - it creates a minifilter port, which is used to send notifications to user mode service and receive replies back from it. It does this with FltCreateCommunicationPort() routine:

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

Note pointers to FSPortConnect() and FSPortDisconnect() subroutines provided to this routine. These are called when user mode service connects and disconnects driver respectively.

And the last thing to do is to actually run the filtering:

C++
status = FltStartFiltering( MfltData.Filter );

Note that a pointer to filter instance, returned by FltRegisterFilter() is passed to this routine. From that point, we begin to receive notifications for IRP_MJ_CREATE & IRP_MJ_CLEANUP requests. Along with file filtering notifications, we also ask OS to tell us when a new process is loaded and unloaded with this statement:

C++
PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);

CreateProcessNotify is an address to our process create/delete notification handler.

Sandbox Primer: FSPreCreate Routine

This is where most of the magic happens. The key point of this routine is to report what file is being opened and by what process. This data is sent to user mode service, and, the service, in turn, may reply whether to deny access to the file, redirect the request to another file (that is how sandboxing actually works) or to simply allow the operation. First thing this routine has to do is to check out if there is a connection to a user mode service via communication port we created in DriverEntry(), and if there is no connection, just give up. We also check whether the service itself is the originator of request - we do it by checking UserProcess field of globally allocated structure MfltData. This field is filled in PortConnect() routine which is called when a user mode service connects to the port. We also don't want to deal with requests, related to paging. In all these cases, we return FLT_PREOP_SUCCESS_NO_CALLBACK return code which means that we have completed processing of the request and we have no post-op processing handler. Otherwise, we would return FLT_PREOP_SUCCESS_WITH_CALLBACK. If we were legacy filtering driver, we would have to deal with Stack Locations, I mentioned earlier, IoCallDriver procedure and so on. In case of minifilters, passing request down is quite straightforward.

In case we want to process the request, first thing we must do is to fill structure we want to pass to user mode - MINFILTER_NOTIFICATION. This structure is completely custom. We pass operation - CREATE, a file name on which originating request is performed, process id and name of originating process. Note the way we find out process name. Actually, it is an undocumented way to get process name and is not recommended to use in commercial software. More than that, it seems not to work in x64 versions of Windows. In commercial software, you would pass only process id to user mode, and, in case you want executable name, you could retrieve it with user mode API. You may, for example, use OpenProcess API to get a handle to process by its PID and then call GetProcessImageFileName() API to get executable file name. But, to simplify our sandbox, we get process Name from undocumented field of PEPROCESS structure.To find out offset of the name, we take into account that there is a process named "SYSTEM" in the system. We scan for a process, that contains this name somewhere in PEPROCESS structure and then we assume that for any given process (PEPROCESS structure), a relative offset of image name is the same. See SetProcessName() function for details.

We get file name of the "target" file, i.e., file the request is being done on (for instance, a file, being opened) with 2 functions, FltGetFileNameInformation() and FltParseFileNameInformation().

Once we have our MINFILTER_NOTIFICATION structure ready, we send it to user mode:

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

And get a reply in reply variable. In case we are requested to deny operation, the action is straightforward:

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

Key things here are as follows: first, we alter return code, by returning FLT_PREOP_COMPLETE. It means that we won't pass the request down the stack. As if we would just call IoCompleteRequest() for legacy driver without calling IoCallDriver(). Second, we fill IoStatus structure of the request. We set an error code - STATUS_ACCESS_DENIED and set Information to zero. Information is operation-specific field. Usually, it contains number of bytes, transferred during, for example, copy operation.

Things go differently if we want to redirect the operation:

C++
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;

    }

Key thing here is a call to IoReplaceFileObjectName:

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

This function modifies file name of input File Object - an IO Manager object which represents file being opened. We could replace the name manually - by freeing memory, occupied by field which contains the name, reallocating it, and, copying new name into that newly allocated buffer. But, since this function was introduced, in Windows 7, it is highly recommended to use it, instead of messing around with buffers. In my product (Cybergenic Shade sandbox) which must run on all OSes, from XP up to Windows 10, I mess with the buffers manually, in case the driver is run on legacy OSes (prior to Win 7). After we have file name changed, we fill data with a special status - STATUS_REPARSE, which requires IO_REPARSE value to be set for Information field and return with FLT_PREOP_COMPLETE. Reparse means that we want IO Manager to reissue original request (with new parameters). So that it would be as if an application (the originator of request) had initially asked to open file with new name. We also must call FltSetCallbackDataDirty() - this API is to be called each time we modify Data structure, unless we also modified IoStatus. In fact, we did modify IoStatus here, so we call this function just to ensure we notified IO Manager of our modifications.

Sandbox Primer: Name Provider

As far as we modify file names, our driver must implement name provider callback functions which are called when a file is queried for name or when file name is normalized. These callbacks are FSGenerateFileNameCallback and FSNormalizeNameComponentCallback(Ex). But as far as our virtualization technique is based on IRP_MJ_CREATE request reissue (we pretend that virtualized names are REPARSE_POINTS), implementation of these callbacks are quite straightforward and described in detail here. This sample basically uses these callbacks implementation, described in that article. So, for details, go and read it :).

User Mode Service

User mode service is located in filewall project (see attached sample) and communicates with driver. The key functionality, related to sandboxing is implemented in this function:

C++
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;
}

It is called when a driver decides to redirect file name. Algorithm used here is straightforward: if a sandboxed file already exists, it just redirects the request, by filling up pReply variable with a new file name - a name inside sandbox folder. If not, an original file is copied and only after that, the original request is modified to point to that newly copied file. How does the service know if a request should be redirected for a particular process? It's done via rules - see CRule class implementation. Rules (actually single rule in our demo service) are loaded in LoadRules() function.

C++
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;
}

This function creates a rule for process(es), named "cmd.exe" and "sandboxes" all the operations with .txt file. If you run cmd.exe on PC, that runs our service, it will sandbox these operations. For instance, you may create a txt file from cmd.exe, say, by running "dir > files.txt" command, "files.txt" file will be created in C:/sandbox/<dir>/files.txt, where <dir> - is current directory for cmd.exe. If you edit an already existing file from within cmd.exe, you'll get 2 copies of it - unmodified version on original FS and a modified one - inside C:/Sandbox.

Conclusion

Well, I think the very basics of sandboxing is covered. There are a lot of details and bottlenecks, not covered here. For instance, rules should never be driven from user mode as far as this approach significantly slows down PC performance. This approach is very simple to implement and good enough to use for learning purposes or as a PoC sample, however should never be used in commercial software. Another limitation is notification/reply structures with a preallocated buffers for file names. These buffers have 2 drawbacks: first, they are limited in size and some files, located deeply in FS will be processed incorrectly. The second drawback is that in most cases, large amount of kernel mode memory, occupied by them is unused. So a smart memory allocation strategy should be used in commercial software, as well. And another drawback is extensive usage of FltSendMessage() function which is rather slow. It should be used only for cases, when a user mode app needs to show a request to user and they must allow or deny an operation. In this case, it's ok to use this function , as far as interaction with human is much more slower that execution of any code. But if your program reacts automatically, you should avoid communicating with user mode code extensively.

A good reader will definitely notice that component names of the sample match those of Cybergenic Shade / BEST Platform. Actually, this code is derived from a very early PoC sample, which subsequently evolved into this product. For now, the code is completely rewritten, optimized and became very complicated of course. But this, very early PoC implementation is easy to understand and suitable (I hope) for learning purposes and proving the concept.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
SHADE Sandbox LLC
Germany Germany
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Generalexcellent Pin
Southmountain20-May-17 9:38
Southmountain20-May-17 9:38 
QuestionMissing Code? Pin
asdf5006-Dec-16 18:43
asdf5006-Dec-16 18:43 
AnswerRe: Missing Code? Pin
Eugene Balabanov9-Dec-16 1:22
Eugene Balabanov9-Dec-16 1:22 
GeneralRe: Missing Code? Pin
asdf50026-Dec-16 5:32
asdf50026-Dec-16 5:32 
QuestionA downvote Pin
Eugene Balabanov15-Oct-16 1:40
Eugene Balabanov15-Oct-16 1:40 
AnswerRe: A downvote Pin
Dmitriy Gakh18-Oct-16 20:27
professionalDmitriy Gakh18-Oct-16 20:27 
GeneralMy vote of 5 Pin
Jose A Pascoa6-Oct-16 22:45
Jose A Pascoa6-Oct-16 22:45 
Interesting primer.

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.