How to mitigate symbolic link attacks on Windows?

TL;DR

SymlinkProtect is a custom minifilter driver for Windows written in C++. It is loaded into the file system driver stack as a filter driver. This allows it to monitor user-mode applications and block malicious attempts to set a reparse point on a directory creating a mount point to some suspicious targets like \RPC Control.

Motivation

Microsoft have recently added hard link mitigation to Windows and they are also actively working on mitigations for other attacks involving file path redirection through junctions or mountpoints. However, in the mean time, symbolic links still present quite a large attack surface. If you are not familiar with the subject, James Forshaw’s (@tiraniddo) blog, presentation and tools are the go-to resources.

In this post, I propose a possible (theoretical) solution for this problem inspired by Pavel Yosifovich’s (@zodiacon) great Windows Kernel Programming book. Consider this project to be a short exercise for developing file system minifilters for Windows, without actually discussing kernel programming in detail. Code snippets have been simplified for readability. The actual source code of the driver may slightly differ in the code repository.

Shubham Dubey (@nixhacker) has developed the SymBlock minifilter driver for the same purpose. However, there are some key differences in SymBlock that could even allow to bypass it. The below is the check implemented in SymBlock to determine whether the given operation should be blocked.

1wcsstr(inBuffer->MountPointReparseBuffer.PathBuffer, L"\\RPC Control")

First, the wcsstr function does case-sensitive string comparison, hence it can be bypassed by typing the name of the target object directory a little differently like the following:

CreateSymlink.exe C:\Test\win.ini C:\Windows\win.ini "\rPc cOnTrOl"

Bypassing SymBlock

Bypassing SymBlock

Second, the PathBuffer member of the REPARSE_DATA_BUFFER structure can actually contain the SubstituteName anywhere in the buffer, hence the check could be bypassed by storing a dummy PrintName before the SubstituteName in the PathBuffer array.

PathBuffer = PrintName + NULL + SubstituteName + NULL

Third, the wcsstr function terminates on NULL characters, hence it could be bypassed by simply inserting a NULL character at the beginning of the PathBuffer array. This way SymBlock would check the dummy PrintName and allow the operation.

PathBuffer = NULL + SubstituteName + NULL + PrintName + NULL

Some modifications would be needed to the BuildMountPoint function in the ReparsePoint.cpp file of the Symbolic Link Testing Tools to test the latter cases, but should not be a problem.

1buffer->MountPointReparseBuffer.PrintNameOffset = 0;
2buffer->MountPointReparseBuffer.PrintNameLength = static_cast<USHORT>(printname_byte_size);
3memcpy(buffer->MountPointReparseBuffer.PathBuffer, printname.c_str(), printname_byte_size + 2);
4
5buffer->MountPointReparseBuffer.SubstituteNameOffset = static_cast<USHORT>(printname_byte_size + 2);
6buffer->MountPointReparseBuffer.SubstituteNameLength = static_cast<USHORT>(target_byte_size);
7memcpy(buffer->MountPointReparseBuffer.PathBuffer + printname.size() + 1, target.c_str(), target_byte_size + 2);

Design and implementation

SymlinkProtect registers a single SymlinkProtectPreFSControl preoperation callback for IRP_MJ_FILE_SYSTEM_CONTROL operations. It performs several checks to be as sufficient as possible and continues uninterrupted, if any of the following conditions are true:

 1// The operation is originating from kernel mode.
 2if (Data->RequestorMode == KernelMode
 3    return FLT_PREOP_SUCCESS_NO_CALLBACK;
 4
 5// The operation is not a reparse point operation.
 6auto& params = Data->Iopb->Parameters.DeviceIoControl;
 7if (params.Buffered.IoControlCode != FSCTL_SET_REPARSE_POINT)
 8    return FLT_PREOP_SUCCESS_NO_CALLBACK;
 9
10// The reparse point is not a mount point.
11auto* reparseBuffer = (REPARSE_DATA_BUFFER*)params.Buffered.SystemBuffer;
12if (reparseBuffer->ReparseTag != IO_REPARSE_TAG_MOUNT_POINT)
13    return FLT_PREOP_SUCCESS_NO_CALLBACK;

The REPARSE_DATA_BUFFER structure has a MountPointReparseBuffer subtype, which contains information about mount point reparse points. A mount point has a substitute name and a print name associated with it, these are stored in the PathBuffer character array. The first key point here is that the substitute name is a pathname identifying the actual target of the mount point. The second is that the substitute name and print name strings can appear in any order in the PathBuffer. The SubstituteNameOffset and SubstituteNameLength members contain the offset and the length of the substitute name string in the PathBuffer array.

The SymlinkProtectPreFSControl callback allocates a string buffer and copies the substitute name string from the PathBuffer and finally frees the allocated memory. Note that both the offset and the length values are in bytes, so they must be divided by sizeof(WCHAR) to get the array index and the number of characters to copy. Furthermore, note that wcsncpy_s will also write a terminating NULL character into the destination array.

1ULONG maxSize = 32767 * sizeof(WCHAR);
2auto targetName = (WCHAR*)ExAllocatePool(PagedPool, maxSize + sizeof(WCHAR));
3
4auto offset = reparseBuffer->MountPointReparseBuffer.SubstituteNameOffset / sizeof(WCHAR);
5auto count = reparseBuffer->MountPointReparseBuffer.SubstituteNameLength / sizeof(WCHAR);
6wcsncpy_s(targetName, 1 + maxSize / sizeof(WCHAR), &reparseBuffer->MountPointReparseBuffer.PathBuffer[offset], count);
7
8ExFreePool(targetName);

Some object manager directories are writable and can be used to create pseudo-symlinks. However, a junction pointing to somewhere within the %SystemRoot% can be also dangerous. Hence, the driver should block set reparse point operations with the following targets:

  • \RPC Control
  • \BaseNamedObjects
  • \Session\X\AppContainerNamedObjects
  • C:\ProgramData
  • C:\Program Files
  • C:\Program Files (x86)
  • C:\Windows

We need to look for the above substrings using wcsstr, which is case sensitive and expects a NULL-terminated string. Hence the code copies the target name with wcsncpy_s to a new string buffer and converts it to lowercase using _wcslwr before scanning for the given patterns. The full path string could be quite long, but we do not(?) need the full string, so we truncate it, if necessary.

 1bool IsSymlinkAllowed(_In_ WCHAR* targetName)
 2{
 3    auto allowSymlink = true;
 4
 5    WCHAR dest[512] = { 0 };
 6    wcsncpy_s(dest, targetName, _TRUNCATE);
 7    _wcslwr(dest);
 8
 9    if (wcsstr(dest, L"\\rpc control") != nullptr ||
10        wcsstr(dest, L"\\basenamedobjects") != nullptr ||
11        wcsstr(dest, L"\\appcontainernamedobjects") != nullptr ||
12        wcsstr(dest, L":\\program") != nullptr ||
13        wcsstr(dest, L":\\windows") != nullptr)
14    {
15        allowSymlink = false;
16    }
17
18    return allowSymlink;
19}

The complete project is available on GitHub. Use the INF file to install the file system filter driver, then load the driver with the fltmc load symlinkprotect command. Filtering for SymlinkProtect* will show alerts in DebugView as the below screenshot illustrates:

SymlinkProtect in action

SymlinkProtect in action

References