📜 ⬆️ ⬇️

How to write your sandbox? Analysis of the simplest "sandbox"

image
If you happen to write large applications, you probably used such virtual machines as VMWare, Virtual PC or something else. But have you asked the question: how do they work? These amazing, one might say, magical technologies fascinated me for quite a long time. To debunk the “magic” and understand the details, I wrote my own virtualization system - “sandbox” “from scratch”. The solution to this problem was quite difficult. The implementation of such a product poses many questions, the answers to which you will not find in Google, so I want to share my experience with the community.

Estimated Reader Training Level


Developing a virtual machine is not a task for novice programmers, so I assume you have experience in programming for Windows, and in particular:

• you write well in C / C ++;
• have experience in Win32 API programming;
• read books about the internal structure of Windows (for example, the books of Mark Russinovich);
• have a basic understanding of the assembly of software.

You will also benefit from the experience of developing Windows kernel drivers. Despite the fact that the sandbox requires a certain amount of code that works in kernel mode, when writing this material, I took into account that you may not have the necessary knowledge. I will cover the topic of driver development in detail in this article series.
')

Virtual machines and sandboxes - what's the difference?


Virtual machines should be divided into two large classes - “heavy” virtual machines , i.e. fully emulated hardware, and lightweight virtual machines that emulate critical parts of the operating system, such as the file system, the registry, and some other OS primitives (for example, mutexes). Examples of lightweight virtual machines: Featherweight Virtual Machine, Sandboxie, Cybergenic Shade .

Featherweight Virtual Machine is an open source project, however there are some drawbacks, for example, the way the OS kernel functions are virtualized. Featherweight Virtual Machine uses the technology of "hooks", and this implies a change in the kernel code of the OS - something that has become forbidden for 64-bit operating systems, starting with Vista. This kind of kernel code patches activate Patch Guard — a special OS component that calls a BSOD (blue “screen of death”) in such cases, because Microsoft sees patches as malicious acts. Therefore, FVM could be a good starting point for a first look at virtualization technology as such, but it is not compatible with modern operating systems. The main difficulties arise when trying to maintain compatibility with Patch Guard, we will look at these issues in detail later.

Most programmers invent Patch Guard bypass technologies, but such bypasses weaken the OS protection, making it more vulnerable to “traditional” kernel-level malware that could not be run on an OS protected by Patch Guard. Our goal is not to bypass or disable Patch Guard, but to maintain compatibility with it. The sandbox should add additional OS protection to make it more resistant to malicious attacks, and not weaken the protection by incorrectly working with Patch Guard.

In this series of articles, we are going to focus on developing a lightweight virtual machine. The main idea is to intercept the OS requests for critical system operations and redirect them to some kind of virtual storage, to a dedicated folder, for file operations, in particular. Let's say there is some application for which we want to emulate file modifications with the name C: \ Myfile . We have to make a copy of this file in our virtual folder, say, C: \ Sandbox \ C \ Myfile , and redirect all operations that the application performs on this file to its virtual counterpart. The same goes for registry operations and some other system mechanisms.

What should be virtualized and how?


Let's sum up what exactly virtualization of an operation means. Let's start with file system virtualization. As you know, to access the file, the application first opens it.

From a Windows point of view, an API function call is called, such as CreateFile (). If the call is successful, the application can read and write to the file. When the work is finished, the file is closed by calling the CloseHandle () API. Thus, we should be able to intercept the call to CreateFile (), change its parameters, such as the name of the file that the application wants to open. By this we would force the application to open another file.

But it is a bad idea to intercept the call to CreateFile () for several reasons: first, the application can open the file in another way, not only using CreateFile (). For example, this can be done through NtCreateFile (), a native API call, which in fact is called in CreateFile (). Thus, by intercepting NtCreateFile (), we will intercept CreateFile (), because ultimately it calls NtCreateFile (). But NtCreateFile () is not the most underlying function.

So where is the most "lower" function CreateFile (), which will be called when the application wants to open / create a file? Such a function is in the kernel mode code. All file operations are controlled by file system drivers. Windows supports so-called file system filters (in this article, we will focus on a subclass of such filters - mini- filters.), Which are used to filter file operations. Therefore, having written the file system minifilter, we could intercept all the file system operations that we need. That is, our first goal is to intercept system calls for file operations in yard mode by writing a mini-filter driver. By doing this, we could force the OS to open a completely different file. In our case, it will be a virtual file-copy of the original. However, an attentive reader may notice: since copying is a very expensive operation, it would be nice to streamline the process a bit. First, you can check whether the file has been opened read-only or not. If yes, then there is no need to make a copy. Since there will be no file changes here, we can simply provide access to the source file.

We will not intercept reading or writing as such - it is enough to intercept kernel-mode functions equivalent to (CreateFile () (OpenFile ()) in order to redirect all the rest of the work with files to our virtual folder. However, file system virtualization is not enough. We also we should have virtualized the registry and some other operating system primitives, but for now let's focus on file system virtualization, and we will start by discussing general issues related to file system devices and some 's kernel-mode components.

Privileged Objects and Objects of Type


When an application opens a file by calling the application programming interface, say, using the CreateFile () function, a lot of interesting things happen: first, the so-called symbolic names in the specified file name look for their native twin, as shown below:



For example, if an application opens a file called "c: \ mydocs \ file.txt", its name is replaced with something like "\ Device \ HarddiskVolume1 \ mydocs \ file.txt". In fact, the symbolic name "C: \" was replaced by a "device" named "\ Device \ HarddiskVolume1". Secondly, as a result, the native name is analyzed again by the object manager, a component in the privileged mode of the operating system, in order to determine which driver received the opening request. When a driver is registered in the system, it is represented by the structure DRIVER_OBJECT. This structure, along with other things, contains a list of devices for which the driver is responsible. Each device, in turn, is represented by the structure of the device object (DEVICE_OBJECT) and the driver is responsible for creating the device objects (DEVICE_OBJECTs) that it is going to manage.

The object manager goes through one component at a time and tries to determine the “final” device responsible for the component. In our case, the first thing the driver encounters is the component "\ Device". At this point, an object of type is defined for the component. In this case, this is the object directory. I recommend that you download the winobj utility from systinternals.com to see the object tree's native tree. It is very similar to the file system directory tree - it contains object directories and various system objects, such as ALPC ports, named pipes, events, in the form of “files”. As soon as the type of the object is determined and the so-called “type of object” is retrieved, further actions are performed.

And now I have to say a few words about what an “object of type” is. During startup, Windows registers with the Object Manager many types of objects, such as a directory object, an event, a mutant (also known to user space developers as a “mutex”), devices, drivers, and so on. So when a driver creates, say, a device object — in fact, it creates an object of type “device”. An object of type "device", in turn, is an object of type "object of type". Sometimes it is easier for a programmer to understand things when they are viewed in a programming language, rather than in a spoken language, so let's express this concept in 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( ... ); }; 

When a user creates an event, he simply creates an instance of type eventType. As you can see, these objects of type “object of type” contain many methods: open (), parse (), etc. They are called by the object manager during the parsing of an object name to determine which driver is responsible for a particular device. In our case, first it encounters the component "\ Device", which is simply a directory object (directory object). So, the parse () method of the “directory” object (objectDirectoryType) will be called, to which the rest of the path will be passed as a parameter:

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

The parse () method, in turn, determines that HarddiskVolume1 is an object of type "device". The driver responsible for this device is extracted (in this case, it is the file system driver that works with this volume), and the parse () method of the device type (deviceType) is ultimately called the rest of the path ( for example, "\ mydocs \ file1.txt"). The file system filter driver, about which we will write in this article (to be exact, the driver instance) responsible for the specified volume, will see exactly this remaining path in the parameters passed to the corresponding callback procedure. File system drivers are responsible for handling this “remnant” path, so the parse () method should tell the object manager that the entire path is recognized, which means that further processing of the file name is not required. In fact, these members of the “object type” type are not documented, but it is important to keep them in mind in order to understand how the OS works with the types of kernel objects.

File System Filters


File system filters are a special kind of drivers that insert themselves into the file system driver stack so that they can intercept all requests for applications and drivers located above them. When an application sends a request to the file system, for example, by calling the CreateFile () function, a special I / O request packet (Input Output Request Packet, or IRP) is constructed and sent to the I / O manager. The I / O manager sends the request to the driver, who is responsible for processing this particular request. As mentioned earlier, the object manager is used to analyze the object name to find out which driver is responsible for handling this request. In our case, the IRP is addressed to the file system driver, but if there are filters (as shown in the picture below), they receive this request first and they decide whether to execute the request or reject it, send it down to the driver (or the bottom filter if it is ), process the request yourself or modify the request parameters and pass it to the driver stack. You can see typical driver filter schemes in the image below.

Writing a filter driver is far from simple and requires a lot of sample code. There is a mass of requests received from various kinds of file system drivers (and therefore filters). You must write a handler (or, more specifically, a dispatch routine) for each type of request, even if you do not want to do special processing on a particular request. A typical driver-filter dispatch procedure is as follows:

 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, the channel filter driver checks whether the request is for the channel's file system driver (the pointer to the device object is stored in g_LegacyPipeFilterDevice), and if so, it sends the request to the lower device, for example, the underlying filter, or the device driver itself. Otherwise, the procedure simply completes the request successfully. I / O requests are sent by the I / O manager mentioned above, in the form of requesting I / O packets or simply IRP packets. Each IRP, along with a large number of other materials, contains so-called stack frames. To simplify things, you can present them simply by the stack frames of the procedure, which are distributed among the registered filters so that each filter has its own stack frame.

The frame contains the parameters of the procedure that can be read or changed. These parameters include the input to the request, for example, the name of the file that will be opened when processing the IRP_MJ_CREATE request. If we want to change some values ​​for the bottom driver, we need to call IoGetNextIrpStackLocation () to get the stack frame of the bottom driver. Most drivers simply call IoSkipCurrentIrpStackLocation (): this function changes the stack frame pointer inside the IRP so that the lower level driver receives the same frame as our driver. On the other hand, the driver can call IoCopyCurrentIrpStackLocationToNext () to copy the contents of the stack frame to the underlying filter frame, but this is a more expensive procedure and is usually used if the driver wants to do some work after processing the I / O request by registering a callback procedure which is called the I / O completion procedure.

The PassThrough () function above should be registered by the filter driver to receive a notification from the I / O manager whenever applications send requests that we want to intercept. The code snippet below shows how this is usually done:

 NTSTATUS RegisterLegacyFilter(PDRIVER_OBJECT DriverObject) { NTSTATUS ntStatus; UNICODE_STRING ntWin32NameString; PDEVICE_OBJECT deviceObject = NULL; ULONG ulDeviceCharacteristics = 0; ntStatus = IoCreateDevice( DriverObject, //    sizeof(DEVICE_INFO), NULL, FILE_DEVICE_DISK_FILE_SYSTEM, //   ulDeviceCharacteristics, //   FALSE, //    &deviceObject ); //     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); } // //   . 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; // // «»   . // // ,    ,     //    .   ,   //   ,      . // for (int i = 0; i < 8; ++i) { LARGE_INTEGER interval; ntStatus = IoAttachDeviceToDeviceStackSafe( deviceObject, pLowerDevice, &(devinfo->pLowerDevice)); if (NT_SUCCESS(ntStatus)) { break; } // // ,       , // ,     . // interval.QuadPart = (500 * DELAY_ONE_MILLISECOND); KeDelayExecutionThread(KernelMode, FALSE, &interval); } if ( !NT_SUCCESS( ntStatus ) ) { IoDeleteDevice(deviceObject); return ntStatus; } return ntStatus; } 

The above code registers a device filter for requests sent to named pipes. First, it receives an instance of the DEVICE_OBJECT class of the virtual device that represents the channels:

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

Next, it fills the MajorFunction array with the default PassThrough () handler. This array represents all types of requests that the I / O manager can send to devices controlled by the driver. If you want to process some requests in a special way, you register additional handlers for them, as shown in the code using the example of the CreateHandler handler. The last step is to attach our filter to the driver stack:

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

Recall how our PassThrough () dispatcher procedure passes the request down the stack through the CallDriver () procedure, simply passing the IRP as a parameter, and a pointer to the bottom device. This pointer is the device to which we are attached. When an API function calls a device, at some point it uses its name, for example, \\ Device \ NamedPipe, and it does not know about any filters. But how is it that our filter receives a request? Magic is performed by the IoAttachDeviceToDeviceStackSafe () function - it attaches our transparent filter device (deviceObject), which was created using IoCreateDevice (), to the bottom device, in our case, to the one called \\ Device \ NamedPipe. From this point on, all requests directed to the named pipes first pass through our filter. Note that CreateIoDevice () passes NULL as the device name. In this case, the name is not required, since this is a device filter and, therefore, there will be no requests sent directly to the filter, instead they will go to the device, but the upstream filter will intercept them first.

From this point on, we have almost finished our minimum driver filter. All we need to do is encode the DriverEntry () procedure, which simply calls RegisterLegacyFilter:

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

File system minifilters


As you saw in the previous section, we wrote a lot of code just to write key driver handlers that do nothing. They are needed, just to write a minimal working driver. To simplify things, a new type of filter driver - mini-filter drivers - comes onto the scene. These are plugins to the “outdated” FltMgr filter driver, or filter manager. The FltMgr driver is a “traditional” filter driver that implements most of the template code and allows the developer to create a “payload” to this driver as a plugin for it. These plugins are called file system minifilters. A brief layout of the minifilters is shown in the figure below.


As you remember from the previous chapters, each “traditional” filter is attached to a specific device driver stack. It would, however, be inconvenient to control the exact place occupied by the filter in the stack. Minifilters correct this problem by introducing two new concepts - height and frame. , -. , IRP, – . , , , . . FltMgr «» . , FltMgr « 1» « 0». , «» , FltMgr. .inf-, , .

Conclusion


, - , - – , «» «» -. , , -.

«», . «», , , , , , ..

Source: https://habr.com/ru/post/317396/


All Articles