Mitigate and Detect Local Privilege Escalation cause due to Symbolic Links

In this post we will discuss ways to mitigate symbolic links based Local privilege escalation exploits. We are also going to develop our own protection against these attacks.

10 min read
Mitigate and Detect Local Privilege Escalation cause due to Symbolic Links

Abusing Symbolic links(Symlinks) is a very common type for exploitation done by attackers for privilege escalation. The exploitation technique is well known since 2015 when James Forshaw introduce it to the security researcher community. Every year more than hundreds of CVE's are discovered in windows native applications as well as other external windows applications that can be exploited by abusing symlinks.

In the previous post I have explained about the windows symlinks and how to create exploit for symlink attack. In this part I will be explaining how to keep your code safe from symlink vulnerability, and then we will try to develop our own permanent mitigation for symlink exploits.

Windows has introduced OBJ_DONT_REPARSE attribute in OBJECT_ATTRIBUTES structure a while ago to make few operations safe from reparsing but this functionality was undocumented till now. Not only that, the OBJECT_ATTRIBUTES are available through native system calls api's only. Hence, it can be used in limited cases only and developer are mostly unaware about this.

According to microsoft documentation on OBJ_DONT_REPARSE- If this flag is set, no reparse points will be followed when parsing the name of the associated object. If any reparses are encountered the attempt will fail and return an STATUS_REPARSE_POINT_ENCOUNTERED result. This can be used to determine if there are any reparse points in the object's path, in security scenarios.

Another flag IO_STOP_ON_SYMLINK is present in IoCreateFileEx function to block file system symbolic links but it is only available through kernel mode.

According to microsoft documentation on IO_STOP_ON_SYMLINK - If a junction, symbolic link, or global reparse point is encountered while opening or creating the file, I/O manager will return STATUS_STOPPED_ON_SYMLINK.

Prevention for developer

As a product developer you can keep in mind following things to make your code safe from symbolic link attacks

  • If the process is running with high privilege and is accessing the files in directory that are writable by any privilege user then it's better to Impersonate the current user or use restricted tokens.
  • If the process is creating a new directory as high privilege user, then set the proper DACL for the directory.
  • While performing write/delete operations on file that are accessible by low privilege users, its recommend to lock the file after opening and release lock after use. A common api you can use in this case is LockFileEx.

Developing Own Mitigation

Even the symbolic links based vulnerability is present since many years, there is no complete mitigation present from windows. Because of this, I have decided to develop a protection against this issue that anyone can apply to their system and keep themself safe from these exploits.

If you have read my previous blog on symbolic link or in general are familiar with this technique, then you must be familiar that this attack take place in two steps.

  • Step 1: Creating object symbolic link of target file in any accessible object directory.
  • Step 2:Creating junction between the vulnerable directory and object directory.

To clarify, if a process is deleting a filename file.txt present in a Directory which is writable by low privilege user. If attacker wants to redirect the deletion to stuff.any file, than he can do the following:

  • Create object symlink of stuff.any to \RPC Control.
  • Creating directory junction of Dir(where file.txt is present) to \RPC Control.

So while deletion operation, following reparse will be occurred.

Source: https://offsec.almond.consulting/intro-to-file-operation-abuse-on-Windows.html

From above case we can easily think that if we can block any one of the two steps, then the whole exploitation will fail. So, lets try to find out the possibilities of doing this.

Userspace or Kernel space?

For Symbolic link mitigation we need to think about if it's possible to detect it from userspace or we have to do it through a kernel driver.

From userspace, one possible solution that anyone can think about is putting a lock on object creation in object manager and if those objects are of type SymbolicLink to a file in file system, then block it. Unfortunately, there is no object lock like oplock or similar to FileLockEx api present for objects in object manger. Hence, that method cannot be implemented.

Other way to detect new symbolic link creation is by scanning the object manager after small interval and detecting any new malicious symbolic link creation. But this method is for sure going to put a major performance impact on the system and can be very unreliable. Hence, is useless.

Since from userspace we cannot find any good method to block these activities we have to create a kernel driver for this. One method is, from kernel driver we can hook the api used for symbolic link creation and block it in case they are under our suspicious category. But due to patchguard in windows kernel, this is even a difficult job to perform.

But if you dig more on these activities than you will find that these activities are related to IO operations in File system. Hence, a good idea can be to create a minifilter driver that can block these IO activities. Now lets try to find out if a minifilter driver will get IRPs for these two steps or not.

Blocking Step 1: Object Manager Symlink?

To create a symbolic link in object manager windows provide NtCreateSymbolicLinkObject api. This is a undocumented windows NT api with following format.

NtCreateSymbolicLinkObject (

  OUT PHANDLE             pHandle,
  IN ACCESS_MASK          DesiredAccess,
  IN POBJECT_ATTRIBUTES   ObjectAttributes,
  IN PUNICODE_STRING      DestinationName 
  );

If you develop a program to create an object manager symlink using this and try to look at the procmon output for any related file operation, then you will notice that beside the opening of the file which will be symlinked, there is no other activity present. The reason for this is that this api just create a new object in object manager with type symlink, pointing to the target file in filesystem. Hence, we cannot track this activity using a filter driver as no IRP request other than for open file is generated.

Blocking Step 2: Directory junction

Since, detection of object symbolic link is not possible, our only hope is to detect the directory junction creation.

In windows one can use DeviceIoControl api call to create a directory junction. The syntax looks like this:

BOOL DeviceIoControl(
  HANDLE       hDevice,
  DWORD        dwIoControlCode,
  LPVOID       lpInBuffer,
  DWORD        nInBufferSize,
  LPVOID       lpOutBuffer,
  DWORD        nOutBufferSize,
  LPDWORD      lpBytesReturned,
  LPOVERLAPPED lpOverlapped
);
According to MSDN: The DeviceIOControl function provides a device input and output control (IOCTL) interface through which an application can communicate directly with a device driver.

Jumping to filter driver, we need to define what type of traffic we are interested to monitor(IRP_MAJOR_XX) called IRP major function. There is one major function called IRP_MJ_DEVICE_CONTROL similar to DeviceIoControl function. But if you run a sample program that is creating directory junction, in ProcMon you will find that a FileSystemControl operation is shown with Control: FSCTL_SET_REPARSE_POINT.

Luckily, there is a IRP major function IRP_MJ_FILE_SYSTEM_CONTROL present. Hence, we can monitor that only.

Finding the affected Object directory/Object Namespace

One question that can come into your mind is, for which object directories, we need to block the symlink/directory junction attempt? And is there any False positive that can occur, where maybe a legitimate process will try to perform similar actions of creating symlink or directory junction in those object directories?

The answer of first question is, we need to only block attempt of symbolic link for two object directories, \RPC Control and \BaseNamedObjects. The reason for this is because a low privilege user is only allowed to create an object in these two object directory. For other object directory, the symlink creation will fail for low privilege user.

Now coming to next question, is there can be an instance where a legitimate process will try to create a symbolic link or directory junction to these object directory and what if there attempt get blocked? To answer this we need to know what these object directory are used for.

\RPC Control - RPC Control is object directory which has all the ALPC Port present in system that is used for ALPC communication between processes.

\BaseNamedObjects -  BaseNamedObjects is used to redirect named objects transparently to a non-shared location.

Both of these object directories have a specific use and will not be used by default to create a symbolic link to any file in the filesystem. That means we can block the symbolic link or directory junction creation pointing to them without any hesitation.

Developing the mini filter driver

It is clear to us that we have to monitor the IRP_MJ_FILE_SYSTEM_CONTROL major function and any IRP request for directory junction creation which targets \RPC Control or \BaseNameObjects need to get dropped.

In our mini filter driver code first we will define when any IRP request for FILE_SYSTEM_CONTROL will occur then call our driver function.


PFLT_FILTER gFilterHandle = NULL;


CONST FLT_OPERATION_REGISTRATION Callbacks[] = {


    { IRP_MJ_FILE_SYSTEM_CONTROL,
      0,
      testfilterFSPreOperation,
      NULL },


    { IRP_MJ_OPERATION_END }
};


CONST FLT_REGISTRATION FilterRegistration = {

    sizeof( FLT_REGISTRATION ),         //  Size
    FLT_REGISTRATION_VERSION,           //  Version
    0,                                  //  Flags

    NULL,                               //  Context
    Callbacks,                          //  Operation callbacks

    testfilterUnload,                           //  MiniFilterUnload

    NULL,                    //  InstanceSetup - testfilterInstanceSetup
    NULL,            //  InstanceQueryTeardown - testfilterInstanceQueryTeardown
    NULL,            //  InstanceTeardownStart - testfilterInstanceTeardownStart
    NULL,         //  InstanceTeardownComplete - testfilterInstanceTeardownComplete

    NULL,                               //  GenerateFileName
    NULL,                               //  GenerateDestinationFileName
    NULL                                //  NormalizeNameComponent

};

NTSTATUS
testfilterUnload (
    _In_ FLT_FILTER_UNLOAD_FLAGS Flags
    )
{
    UNREFERENCED_PARAMETER( Flags );

    PAGED_CODE();

    PT_DBG_PRINT( PTDBG_TRACE_ROUTINES,
                  ("testfilter!testfilterUnload: Entered\n") );

    FltUnregisterFilter( gFilterHandle );

    return STATUS_SUCCESS;
}


NTSTATUS
DriverEntry (
    _In_ PDRIVER_OBJECT DriverObject,
    _In_ PUNICODE_STRING RegistryPath
    )
{
    NTSTATUS status;

    UNREFERENCED_PARAMETER( RegistryPath );

    PT_DBG_PRINT( PTDBG_TRACE_ROUTINES,
                  ("testfilter!DriverEntry: Entered\n") );

    //
    //  Register with FltMgr to tell it our callback routines
    //

    status = FltRegisterFilter( DriverObject,
                                &FilterRegistration,
                                &gFilterHandle );

    FLT_ASSERT( NT_SUCCESS( status ) );

    if (NT_SUCCESS( status )) {

        //
        //  Start filtering i/o
        //

        status = FltStartFiltering( gFilterHandle );

        if (!NT_SUCCESS( status )) {

            FltUnregisterFilter( gFilterHandle );
        }
    }

    return status;
}

Our logic will go in testfilterFSPreOperation function. Now lets see what we need to put in that function.

We will retrieve the IRP packet target filename using.FltGetFileNameInformation. If that name is not present means the request is not related to any specific file, so we will forward that packet without any modification.

FLT_PREOP_CALLBACK_STATUS
testfilterFSPreOperation(
    _Inout_ PFLT_CALLBACK_DATA Data,
    _In_ PCFLT_RELATED_OBJECTS FltObjects,
    _Flt_CompletionContext_Outptr_ PVOID* CompletionContext
)
{
    NTSTATUS status;
    //ULONG inBuffer;
    UNREFERENCED_PARAMETER(FltObjects);
    UNREFERENCED_PARAMETER(CompletionContext);
    PFLT_FILE_NAME_INFORMATION FileNameInfo;
    
    PT_DBG_PRINT(PTDBG_TRACE_ROUTINES,
        ("testfilter!testfilterPreOperation: Entered\n"));

    status = FltGetFileNameInformation(Data, FLT_FILE_NAME_NORMALIZED | FLT_FILE_NAME_QUERY_DEFAULT, &FileNameInfo);
     if (NT_SUCCESS(status))
    {
     // parse the further details
    ...
    }
    
    return FLT_PREOP_SUCCESS_NO_CALLBACK;
 }
    

To get details about DeviceIOControl arguments we need to look in Data->Iopb->Parameters.DeviceIoControl structure. This structure has three types of buffer:(more details here)

  • METHOD_BUFFERED
  • METHOD_IN_DIRECT
  • METHOD_OUT_DIRECT

We need to identify which buffer will contain the information that we requires. If you check the definition of FSCTL_SET_REPARSE_POINT used for reparse point creation by DeviceIOControl (present in winioctl.h), then you will find the following code:

#define FSCTL_SET_REPARSE_POINT         CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 41, METHOD_BUFFERED, FILE_SPECIAL_ACCESS) // REPARSE_DATA_BUFFER,

From third argument, we can guess that the further buffer data will be in METHOD_BUFFERED.

Moving to next step. For directory junction creation we already mentioned the second parameter dwIoControlCode need to be FSCTL_SET_REPARSE_POINT. So let's add a check if the IOControlCode is matched.

if (NT_SUCCESS(status))
        {
            // If IO Control code is 589988 that means FSCTL_SET_REPARSE_POINT is passed in DeviceIOControl
            // For directory junction creation FSCTL_SET_REPARSE_POINT control code is used.
            if (Data->Iopb->Parameters.DeviceIoControl.Buffered.IoControlCode != 589988) {
                FltReleaseFileNameInformation(FileNameInfo);
                return FLT_PREOP_SUCCESS_NO_CALLBACK;
            }
          ...
        }

IRP_MJ_FILE_SYSTEM_CONTROL has few minor functions as well. We need to add a check about this that if a minor function is set to something that is used for directory creation then only proceed  . If you check the MSDN here, then you will guess that we need to use only IRP_MN_USER_FS_REQUEST since it will be used if the request is from usermode.

MSDN for IRP_MN_USER_FS_REQUEST: Indicates an FSCTL request, possibly on behalf of a user-mode application that has called the Microsoft Win32 DeviceIoControl function.
if (NT_SUCCESS(status))
        {
            // If IO Control code is 589988 that means FSCTL_SET_REPARSE_POINT is passed in DeviceIOControl
            // For directory junction creation FSCTL_SET_REPARSE_POINT control code is used.
            if (Data->Iopb->Parameters.DeviceIoControl.Buffered.IoControlCode != 589988) {
                FltReleaseFileNameInformation(FileNameInfo);
                return FLT_PREOP_SUCCESS_NO_CALLBACK;
            }
            // Only proceed if Minor funtion is IRP_MN_USER_FS_REQUEST
            // this minor funtion show that the FSCTL request are on behalf of user mode application
            if (Data->Iopb->MinorFunction == IRP_MN_USER_FS_REQUEST) {
            ...
 			}
       }

Now the only thing  left is to parse the target filename and if it matches with the object directory name then drop the packet.

DeviceIOControl requires lpInBuffer and lpOutBuffer with their size as argument. the lpOutBuffer need to be 0 for directory junction and lpInBuffer need to have the following structure:

_Field_size_bytes_(ReparseDataLength)
    union {
        struct {
            USHORT SubstituteNameOffset;
            USHORT SubstituteNameLength;
            USHORT PrintNameOffset;
            USHORT PrintNameLength;
            ULONG Flags;
            WCHAR PathBuffer[1];
        } SymbolicLinkReparseBuffer;
        struct {
            USHORT SubstituteNameOffset;
            USHORT SubstituteNameLength;
            USHORT PrintNameOffset;
            USHORT PrintNameLength;
            WCHAR PathBuffer[1];
        } MountPointReparseBuffer;
        struct {
            UCHAR  DataBuffer[1];
        } GenericReparseBuffer;
    } DUMMYUNIONNAME;
} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;

parameter PathBuffer will have our target directory name for the junction. Keeping that in mind, lets complete our code here.

if (Data->Iopb->MinorFunction == IRP_MN_USER_FS_REQUEST) {

                // If Target filename is null
                if (Data->Iopb->TargetFileObject->FileName.Length == 0) {
                    FltReleaseFileNameInformation(FileNameInfo);
                    return FLT_PREOP_SUCCESS_NO_CALLBACK;
                }
                
                
                if (Data->Iopb->TargetFileObject->FileName.Length < 300){
                    RtlCopyMemory(NameOfTarget, Data->Iopb->TargetFileObject->FileName.Buffer, Data->Iopb->TargetFileObject->FileName.Length);
                }
                else {
                    KdPrint(("Filename not saved since to large"));
                }
                // For Directory junction - OutputBuffer length will be 0 and InputBuffer will be greater the 0.
                if (Data->Iopb->Parameters.DeviceIoControl.Buffered.InputBufferLength > 0 && Data->Iopb->Parameters.DeviceIoControl.Buffered.OutputBufferLength == 0) {
                    REPARSE_DATA_BUFFER* inBuffer;
                    inBuffer = (REPARSE_DATA_BUFFER*)Data->Iopb->Parameters.DeviceIoControl.Buffered.SystemBuffer;

                    if (wcsstr(inBuffer->MountPointReparseBuffer.PathBuffer, L"\\RPC Control") != NULL) {
                        KdPrint(("Symlink Attack detected \r\n"));
                        KdPrint(("Directory Juntion from %ws to \\RPC Control \r\n",NameOfTarget));
                        Data->IoStatus.Status = STATUS_INVALID_PARAMETER;
                        Data->IoStatus.Information = 0;
                        FltReleaseFileNameInformation(FileNameInfo);
                        return FLT_PREOP_COMPLETE;
                    }
                    if (wcsstr(inBuffer->MountPointReparseBuffer.PathBuffer, L"\\BaseNamedObjects") != NULL) {
                        KdPrint(("Symlink Attack detected \r\n"));
                        KdPrint(("Directory Juntion from %ws to \\BaseNamedObjects \r\n", NameOfTarget));
                        Data->IoStatus.Status = STATUS_INVALID_PARAMETER;
                        Data->IoStatus.Information = 0;
                        FltReleaseFileNameInformation(FileNameInfo);
                        return FLT_PREOP_COMPLETE;
                    }
                    
                }
            }
            
        }

In the above code, we are checking the PathBuffer value and if it matches with our target object directory name then we will set IRP packet request to FLT_PREOP_COMPLETE. FLT_PREOP_COMPLETE will tell the IRP manager that the request has been fulfilled and no further processing in IRP stack is require. In lame words, its equivalent of dropping the packet without reaching to the destination IRP driver.

You can find the complete code here . Compile and install the .inf. Then load the driver using net start drivername . So, next time when someone try to create the junction point to RPC Control or BaseNamedObjects, that attempt will get blocked. You can see the logs for this in DebugView tool.

So, our driver is functioning correctly. In a small-time, we have developed a kernel driver that can make your windows system safe from 100s of cve's related to local privilege escalation.