How to write your own “sandbox”: an example of the simplest “sandbox”. Part II
- Tutorial

In the first part of the article, you got a brief idea about drivers in privileged mode. It's time to delve into our sandbox.
Sandbox Example: Build and Install a Driver
The core of our sandbox is a mini-filter driver. You can find its source code in src \ FSSDK \ Kernel \ minilt. I assume that you are using the WDK 7.x kit to build the driver. To do this, you must start the appropriate environment, say, Win 7 x86 checked, and go to the directory with the source code. Just write “build / c” at the command line when starting up in the development environment and you will get the built drivers. To install the driver, it is enough to copy the * .inf file to the folder containing the * .sys file, go to this directory using the explorer and use the context menu on the * .inf file, where select “Install” and the driver will be installed. I recommend that you do all the experiments inside the virtual machine; VMware is a good choice. Note, that 64-bit versions of Windows will not load unsigned drivers. To be able to run the driver in VMware, you must enable the privileged mode debugger in the guest OS. This can be done by executing the following cmd commands, run as administrator:
1. bcdedit / debug on
2. bcdedit / bootdebug on
Now you must designate the named pipe as a serial port for VMware and configure it using WinDBG installed on your machine. After that, you can connect to VMware with a debugger and debug your drivers.
Find detailed information on configuring VMware for driver debugging in this article .
Sandbox Example: Architecture Overview
Our simple sandbox consists of three modules:
• a privileged mode driver that implements virtualization primitives;
• user-mode services, which receives messages from the driver and can change the behavior of the file system by changing the settings for received notifications from the driver;
• The fsproxy middleware library, which helps the service communicate with the driver.
Let's get started on our simplest sandbox with a driver in privileged mode.
Sandbox example: writing a driver
While regular applications tend to start in WinMain (), drivers do this with the DriverEntry () function. Let's start learning the driver with this feature.
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");
//
// Зарегистрироваться через менеджер фильтров
//
status = FltRegisterFilter( DriverObject,
&FilterRegistration,
&MfltData.Filter );
if (!NT_SUCCESS( status ))
{
DbgPrint("RegisterFilter failure 0x%x \n",status);
return status;
}
//
// Создать порт связи.
//
RtlInitUnicodeString( &uniString, ScannerPortName );
//
// Мы защищаем порт, так чтобы только администратор и система имели к нему доступ.
//
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 );
//
// Освобождаем дескриптор безопасности во всех случаях. Он не нужен // после того, как вызвана FltCreateCommunicationPort()
//
FltFreeSecurityDescriptor( sd );
regCookie.QuadPart = 0;
if (NT_SUCCESS( status )) {
//
// Начинаем фильтрацию запросов ввода-вывода.
//
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 features. First, this function registers the driver as a minifler with the FltRegisterFilter () function:
status = FltRegisterFilter( DriverObject,
&FilterRegistration,
&MfltData.Filter );
It is an array of pointers for handlers of certain operations that it wants to intercept in FilterRegistration, and receives a filter instance in MfltData.Filter if registration is successful. FilterRegistration is declared as follows:
const FLT_REGISTRATION FilterRegistration = {
sizeof( FLT_REGISTRATION ), // Размер
FLT_REGISTRATION_VERSION, // Версия
0, // Маркеры
NULL, // Регистрация контекста
Callbacks, // обработчики
DriverUnload, // выгрузка фильтра
FSInstanceSetup, // Установка экземпляра фильтра
FSQueryTeardown, // Запрос на деинсталляцию экземпляра
NULL,
NULL,
FSGenerateFileNameCallback, // Создание имени файла
FSNormalizeNameComponentCallback, // Нормализация компонента имени
NULL, // Нормализация очистки контекста
#if FLT_MGR_LONGHORN
NULL, // Оповещение о транзакциях
FSNormalizeNameComponentExCallback, // Нормализация только компонента имени
#endif // FLT_MGR_LONGHORN
};
As you can see, the structure contains a pointer to an array of event handlers (Callbacks). This is an analogue of dispatching procedures in "outdated" drivers. In addition, this structure contains pointers to some other auxiliary functions - we will describe them later. Now we will dwell on the handlers described in the Callbacks array. They are defined as follows:
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 description of the FLT_OPERATION_REGISTRATION structure on MSDN. Our driver registers only two handlers: FSPreCreate, which will be called every time an IRP_MJ_CREATE request is received, and FSPreCleanup, which, in turn, will be called every time an IRP_MJ_CLEANUP is received. This request will arrive when the last file descriptor closes. We can (and will) change the input parameters and send the modified request down the stack, so that the lower filters and the file system driver will receive the modified request. We could register the so-called post-notifications arriving at the end of the operation. To do this, the null pointer that follows the pointer to FSPreCreate can be replaced with a pointer to the corresponding post-handler. We must complete our array with the element IRP_MJ_OPERATION_END. This is a “fake” operation that marks the end of an array of event handlers. Please note that we should not provide a handler for each IRP_MJ_XXX operation, as we would have to do for "traditional" filter drivers.
The second important thing our DriverEntry () does is create a mini-filter port. It is used to send notifications from a user level service and receives responses from it. This is done using the FltCreateCommunicationPort () operation:
status = FltCreateCommunicationPort( MfltData.Filter,
&MfltData.ServerPort,
&oa,
NULL,
FSPortConnect,
FSPortDisconnect,
NULL,
1 );
Pointers to the FSPortConnect () and FSPortDisconnect () functions occur when connecting and disconnecting the user mode service from the driver.
And the last thing to do is to start filtering:
status = FltStartFiltering( MfltData.Filter );
Note that the pointer to the filter instance returned by FltRegisterFilter () is passed to this procedure. From now on, we will begin to receive notification of IRP_MJ_CREATE & IRP_MJ_CLEANUP requests. Along with file filtering notifications, we also ask the OS to tell us when a new process is loading and unloading using this function:
PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);
CreateProcessNotify is our handler for notifications of the creation and termination of a process.
Sandbox Example: FSPreCreate Handler
Real magic is born here. The essence of this function is to inform which file was opened and by which process the opening was initiated. This data is sent to the user mode service. A service (service) provides a response in the form of a command either to deny access to a file, or to redirect a request to another file (this is how the sandbox actually works), or about permission to perform the operation. The first thing that happens in this case is checking the connection with the user mode service through the communication port (communication port) that we created in DriverEntry (), and if there is no connection, no further action will occur. We also check whether the service is the source (initiator) of the request - we do this by checking the UserProcess field of the globally allocated MfltData structure. This field is populated in the PortConnect () routine, which is called when the user-mode service connects to the port. Also, we do not want to deal with requests related to paging. In all these cases, we return the return code FLT_PREOP_SUCCESS_NO_CALLBACK, which means that we have completed the processing of the request and we do not have a post-operation handler. Otherwise, we will return FLT_PREOP_SUCCESS_WITH_CALLBACK. If it were a “traditional” driver filter, then we would have to deal with the stack frames that I mentioned earlier, the IoCallDriver procedure, etc. In the case of mini-filters, sending a request is quite simple. related to paging. In all these cases, we return the return code FLT_PREOP_SUCCESS_NO_CALLBACK, which means that we have completed the processing of the request and we do not have a post-operation handler. Otherwise, we will return FLT_PREOP_SUCCESS_WITH_CALLBACK. If it were a “traditional” driver filter, then we would have to deal with the stack frames that I mentioned earlier, the IoCallDriver procedure, etc. In the case of mini-filters, sending a request is quite simple. related to paging. In all these cases, we return the return code FLT_PREOP_SUCCESS_NO_CALLBACK, which means that we have completed the processing of the request and we do not have a post-operation handler. Otherwise, we will return FLT_PREOP_SUCCESS_WITH_CALLBACK. If it were a “traditional” driver filter, then we would have to deal with the stack frames that I mentioned earlier, the IoCallDriver procedure, etc. In the case of mini-filters, sending a request is quite simple. IoCallDriver procedure, etc. In the case of mini-filters, sending a request is quite simple. IoCallDriver procedure, etc. In the case of mini-filters, sending a request is quite simple.
If we want to process the request, the first thing we need to do is fill in the structure that we want to transfer to user mode - MINFILTER_NOTIFICATION. She is fully customizable. We pass in the type of operation (CREATE), the name of the file on which the request was executed, the process identification number (PID), and the name of the source process. It is worth paying attention to how we figure out the name of the process. In fact, this is an undocumented way to get the name of a process that is not recommended for use in commercial software. Moreover, this does not work with x64 versions of Windows. In commercial software, you will only pass the process ID (user ID) to user mode, and if you need an executable name, you can get it using the user mode API. You, for example, you can use the OpenProcess () API to open the process by its ID and then call the GetProcessImageFileName () API to get the name of the executable file. But to simplify our sandbox, we get the process name from the undocumented field of the PEPROCESS structure. To find out the name offset (relative address), we take into account that the system has a process called “SYSTEM”. We scan the process containing the given name in the PEPROCESS structure, then use the detected name offset when analyzing any other process. For more information, see the SetProcessName () function. To find out the name offset (relative address), we take into account that the system has a process called “SYSTEM”. We scan the process containing the given name in the PEPROCESS structure, then use the detected name offset when analyzing any other process. For more information, see the SetProcessName () function. To find out the name offset (relative address), we take into account that the system has a process called “SYSTEM”. We scan the process containing the given name in the PEPROCESS structure, then use the detected name offset when analyzing any other process. For more information, see the SetProcessName () function.
We get the file name from the “target” file for which the request was received (for example, a request to open the file) using two functions - FltGetFileNameInformation () and FltParseFileNameInformation ().
After filling the MINFILTER_NOTIFICATION structure, we send it to user mode:
Status = FltSendMessage( MfltData.Filter,
&MfltData.ClientPort,
notification,
sizeof(MINFILTER_NOTIFICATION),
&reply,
&replyLength,
NULL );
And we get the answer in the variable reply. If we are asked to cancel the operation, then the action is simple:
if (!reply.bAllow)
{
Data->IoStatus.Status = STATUS_ACCESS_DENIED;
Data->IoStatus.Information = 0;
return FLT_PREOP_COMPLETE;
}
The key points here are: first, we change the return code by returning FLT_PREOP_COMPLETE. This means that we will not pass the request down the driver stack, as, for example, we would do when calling IoCompleteRequest () from the "traditional" driver without calling IoCallDriver (). Secondly, we fill in the IoStatus field in the request structure. Set the error code STATUS_ACCESS_DENIED and the Information field to “zero”. As a rule, the Information field records the number of bytes transferred during the operation, for example, the number of bytes copied is recorded during the copy operation.
If we want to redirect the operation, then it looks different:
if (reply.bSupersedeFile)
{
// извлечь имя тома
// возможный формат файла: \Device\HardDiskVolume1\Windows\File,
// или \DosDevices\C:\Windows\File OR \??\C:\Windows\File или C:\Windows\File
RtlZeroMemory(wszTemp,MAX_STRING*sizeof(WCHAR));
// \Device\HardDiskvol\file или \DosDevice\C:\file
int endIndex = 0;
int nSlash = 0; // количество найденных слешей
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; // ошибка в имени файла
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;
}
The key here is calling IoReplaceFileObjectName
Status = IoReplaceFileObjectName(Data->Iopb->TargetFileObject, reply.wsFileName, wcslen(reply.wsFileName)*sizeof(wchar_t));
This function changes the file name of the transferred file object (FILE_OBJECT) - the object of the I / O manager, which is an open file. The manual name is replaced as follows: freeing the memory with the field containing the name, we allocate a buffer and copy the new name there. But since the advent of the IoReplaceFileObjectName function in Windows 7, it is strongly recommended to use it instead of a buffer. In the author’s personal project (Cybergenic Shade Sandbox product), which is compatible with all operating systems - from XP to Windows 10, I just manually use buffers if the driver runs on older OS (before Win 7). After the file name is changed, we fill the data with the special status STATUS_REPARSE, and fill the Information field with the value IO_REPARSE. Further we return the status FLT_PREOP_COMPLETE. REPARSE means we want so that the I / O manager restarts the original request (with new parameters), as in the case when the application (request initiator) would initially request to open the file with a new name. We should also call FltSetCallbackDataDirty () - this API function is needed every time we change the data structure, except when we also change IoStatus. In fact, we are really changing IoStatus here, so we call this function to make sure that we have notified the I / O manager of these changes. when we also change IoStatus. In fact, we are really changing IoStatus here, so we call this function to make sure that we have notified the I / O manager of these changes. when we also change IoStatus. In fact, we are really changing IoStatus here, so we call this function to make sure that we have notified the I / O manager of these changes.
Sandbox Example: Name Providers
Since we are changing the file names, our driver must contain an implementation of the name provider handlers that are called when the file name is requested or when the file name is “normalized”. These handlers are FSGenerateFileNameCallback and FSNormalizeNameComponentCallback (Ex).
Our virtualization method is based on the “restart” of the IRP_MJ_CREATE request (we pretend that the virtualized names are REPARSE_POINTS), and the implementation of these handlers is a fairly simple matter, which is described in detail here .
User mode service
User mode is in the filewall project (see source code for the article) and communicates with the driver. Key functionality is represented by the following function:
bool CService::FS_Emulate( MINFILTER_NOTIFICATION* pNotification,
MINFILTER_REPLY* pReply,
const CRule& rule)
{
using namespace std;
// сформировать новый путь
// проверить, существует ли путь, если нет - создать/копировать
if(IsSandboxedFile(ToDos(pNotificationwsFileName).c_str(),rule.SandBoxRoot))
{
pReply->bSupersedeFile = FALSE;
pReply->bAllow = TRUE;
return true;
}
wchar_t* originalPath = pNotification->wsFileName; // неуправляемый код
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()) )
{
// пустая директория – создаем ее в песочнице
CreateComplexDirectory(ToDos(substitutedPath).c_str() );
}
else
{
// полное имя файла – создать копию файла в песочнице (sandbox), если ее еще нет
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
{
// нет такого файла, но родительский каталог создать надо, если его нет
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 the driver decides to redirect the file name. The algorithm here is very simple: if the file placed in the sandbox already exists, then the request is simply redirected, filling the pReply variable with the new file name - the name in the sandbox folder. If such a file does not exist, then the original file is copied and only after that the original request is changed to indicate the new copied file. How does the service know that the request needs to be redirected to a specific process? This is done using rules - see the implementation of the CRule class. Rules (usually the only rule in our demo service) are loaded with the LoadRules () function.
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 a process (or processes) called “cmd.exe” and redirects all operations with * .txt files to the sandbox. If you run cmd.exe on the PC where our service is running, it isolates your operations in the sandbox. For example, you can create a txt file from cmd.exe, say by running the command “dir> files.txt”, files.txt will be created in C: /sandbox//files.txt, where is the current directory for cmd.exe. If you add an existing file from cmd.exe, you will get two copies of it - an unchanged version in the original file system and a modified one in C: / Sandbox.
Conclusion
In this article, we looked at the main aspects of creating a sandbox. However, some details and problems remained unaffected.
For example, you cannot manage rules from user mode, as this significantly slows down the PC. This approach is quite simple in terms of implementation and possible for educational use, but in no case should it be used in commercial software.
Another limitation is the notification / response structure with predefined buffers for file names. These buffers have two drawbacks: firstly, their size is limited and some files located deep in the file system will not be processed correctly. Secondly, a substantial part of kernel mode memory allocated under the file name is not used in most cases. Therefore, a more reasonable memory allocation strategy should be used in commercial software.
And another drawback is the widespread use of the FltSendMessage () function, which is rather slow. It should be used only when the user mode application should show the user a request, and the user should allow or decline the operation. In this case, this function can be used, since interaction with a person is much slower than the execution of any code. But if the program responds automatically, you should avoid over-interacting with the user mode code.
References to sources
» Original article
» Source code of the considered sandbox