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.
Related work
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"
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:
References
- REPARSE_DATA_BUFFER structure (ntifs.h)
- REPARSE_DATA_BUFFER
- MountPointReparseBuffer
- Windows 10^H^H Symbolic Link Mitigations
- Follow the Link: Exploiting Symbolic Links with Ease
- An introduction to privileged file operation abuse on Windows
- Mitigate and Detect Local Privilege Escalation cause due to Symbolic Links