I always keep an eye out for new Windows features which might have security implications and per-directory case sensitivity certainly caught my attention. With 1903 not too far off I thought it was time I actual did a short blog post about per-directory case-sensitivity and mull over some of the security implications. While I'm at it why not go on a whistle-stop tour of case sensitivity in Windows NT over the years.
Disclaimer. I don't currently and have never previously worked for Microsoft so much of what I'm going to discuss is informed speculation.
The Early Years
The Windows NT operating system has had the ability to have case-sensitive files since the very first version. This is because of the OS's well known, but little used, POSIX subsystem. If you look at the documentation for CreateFile you'll notice a flag, FILE_FLAG_POSIX_SEMANTICS which is used for the following purposes:
"Access will occur according to POSIX rules. This includes allowing multiple files with names, differing only in case, for file systems that support that naming."
It's make sense therefore that all you'd need to do to get a case-sensitive file system is use this flag exclusively. Of course being an optional flag it's unlikely that the majority of Windows software will use it correctly. You might wonder what the flag is actually doing, as CreateFile is not a system call. If we dig into the code inside KERNEL32 we'll find the following:
BOOL CreateFileInternal(LPCWSTR lpFileName, ..., DWORD dwFlagsAndAttributes) {
// ...
OBJECT_ATTRIBUTES ObjectAttributes;
if (dwFlagsAndAttributes & FILE_FLAG_POSIX_SEMANTICS){
ObjectAttributes.Attributes = 0;
} else {
ObjectAttributes.Attributes = OBJ_CASE_INSENSITIVE;
}
NtCreateFile(..., &ObjectAttributes, ...);
}
This code shows that if the FILE_FLAG_POSIX_SEMANTICS flag is set, the the Attributes member of the OBJECT_ATTRIBUTES structure passed to NtCreateFile is initialized to 0. Otherwise it's initialized with the flag OBJ_CASE_INSENSITIVE. The OBJ_CASE_INSENSITIVE instructs the Object Manager to do a case-insensitive lookup for a named kernel object. However files do not directly get parsed by the Object Manager, so the IO manager converts this flag to the IO_STACK_LOCATION flag SL_CASE_SENSITIVE before handing it off to the file system driver in an IRP_MJ_CREATE IRP. The file system driver can then honour that flag or not, in the case of NTFS it honours it and performs a case-sensitive file search instead of the default case-insensitive search.
Aside. Specifying FILE_FLAG_POSIX_SEMANTICS supports one other additional feature of CreateFile that I can see. By specifying FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_POSIX_SEMANTICS and FILE_ATTRIBUTE_DIRECTORY in the dwFlagsAndAttributes parameter and CREATE_NEW as the dwCreationDisposition parameter the API will create a new directory and return a handle to it. This would normally require calling CreateDirectory, then a second call to open or using the native NtCreateFile system call.
NTFS always supported case-preserving operations, so creating the file AbC.txt will leave the case intact. However when it does an initial check to make sure the file doesn't already exist if you request abc.TXT then NTFS would find it during a case-insensitive search. If the create is done case-sensitive then NTFS won't find the file and you can now create the second file. This allows NTFS to support full case-sensitivity.
It seems too simple to create files in a case-sensitive manner, just use the FILE_FLAG_POSIX_SEMANTICS flag or don't pass OBJ_CASE_INSENSITIVE to NtCreateFile. Let's try that using PowerShell on a default installation on Windows 10 1809 to see if that's really the case.
First we create a file with the name AbC.txt, as NTFS is case preserving this will be the name assigned to it in the file system. We then open the file first with the OBJ_CASE_INSENSITIVE attribute flag set and specifying the name all in lowercase. As expected we open the file and displaying the name shows the case-preserved form. Next we do the same operation without the OBJ_CASE_INSENSITIVE flag, however unexpectedly it still works. It seems the kernel is just ignoring the missing flag and doing the open case-insensitive.
It turns out this is by design, as case-insensitive operation is defined as opt-in no one would ever correctly set the flag and the whole edifice of the Windows subsystem would probably quickly fall apart. Therefore honouring enabling support for case-sensitive operation is behind a Session Manager Kernel Registry value, ObCaseInsensitive. This registry value is reflected in the global kernel variable, ObpCaseInsensitive which is set to TRUE by default. There's only one place this variable is used, ObpLookupObjectName, which looks like the following:
NTSTATUS ObpLookupObjectName(POBJECT_ATTRIBUTES ObjectAttributes, ...) {
// ...
DWORD Attributes = ObjectAttributes->Attributes;
if (ObpCaseInsensitive) {
Attributes |= OBJ_CASE_INSENSITIVE;
}
// Continue lookup.
}
From this code we can see if ObpCaseInsensitive set to TRUE then regardless of the Attribute flags passed to the lookup operation OBJ_CASE_INSENSITIVE is always set. What this means is no matter what you do you can't perform a case-sensitive lookup operation on a default install of Windows. Of course if you installed the POSIX subsystem you'll typically find the kernel variable set to FALSE which would enable case-sensitive operation for everyone, at least if they forget to set the flags.
Let's try the same test again with PowerShell but make sure ObpCaseInsensitive is FALSE to see if we now get the expected operation.
With the OBJ_CASE_INSENSITIVE flag set we can still open the file AbC.txt with the lower case name. However without specifying the flag we we get STATUS_OBJECT_NAME_NOT_FOUND which indicates the lookup operation failed.
Windows Subsystem for Linux
Let's fast forward to the introduction of WSL in Windows 10 1607. WSL needed some way of representing a typical case-sensitive Linux file system. In theory the developers could have implemented it on top of a case-insensitive file system but that'd likely introduce too many compatibility issues. However just disabling ObCaseInsensitive globally would likely introduce their own set of compatibility issues on the Windows side. A compromise was needed to support case-sensitive files on an existing volume.
Aside. It could be argued that Unix-like operating systems (including Linux) don't have a case-sensitive file system at all, but a case-blind file system. Most Unix-like file systems just treat file names on disk as strings of opaque bytes, either the file name matches a sequence of bytes or it doesn't. The file system doesn't really care whether any particular byte is a lower or upper case character. This of course leads to interesting problems such as where two file names which look identical to a user can have different byte representations resulting in unexpected failures to open files. Some file systems such macOS's HFS+ use Unicode Normalization Forms to make file names have a canonical byte representation to make this easier but leads to massive additional complexity, and was infamously removed in the successor APFS. UPDATE: It's been pointed out that Apple actually reversed the APFS change in iOS 11/macOS 10.13.
This compromise can be found back in ObpLookupObjectName as shown below:
NTSTATUS ObpLookupObjectName(POBJECT_ATTRIBUTES ObjectAttributes, ...) {
// ...
DWORD Attributes = ObjectAttributes->Attributes;
if (ObpCaseInsensitive &&
KeGetCurrentThread()->CrossThreadFlags.ExplicitCaseSensitivity == FALSE) {
Attributes |= OBJ_CASE_INSENSITIVE;
}
// Continue lookup.
}
In the code we now find that the existing check for ObpCaseInsensitive is augmented with an additional check on the current thread's CrossThreadFlags for the ExplicitCaseSensitivity bit flag. Only if the flag is not set will case-insensitive lookup be forced. This looks like a quick hack to get case-sensitive files without having to change the global behavior. We can find the code which sets this flag in NtSetInformationThread.
NTSTATUS NtSetInformationThread(HANDLE ThreadHandle,
THREADINFOCLASS ThreadInformationClass,
PVOID ThreadInformation,
ULONG ThreadInformationLength) {
switch(ThreadInformationClass) {
case ThreadExplicitCaseSensitivity:
if (ThreadInformationLength != sizeof(DWORD))
return STATUS_INFO_LENGTH_MISMATCH;
DWORD value = *((DWORD*)ThreadInformation);
if (value) {
if (!SeSinglePrivilegeCheck(SeDebugPrivilege, PreviousMode))
return STATUS_PRIVILEGE_NOT_HELD;
if (!RtlTestProtectedAccess(Process, 0x51) )
return STATUS_ACCESS_DENIED;
}
if (value)
Thread->CrossThreadFlags.ExplicitCaseSensitivity = TRUE;
else
Thread->CrossThreadFlags.ExplicitCaseSensitivity = FALSE;
break;
}
// ...
}
Notice in the code to set the the ExplicitCaseSensitivity flag we need to have both SeDebugPrivilege and be a protected process at level 0x51 which is PPL at Windows signing level. This code is from Windows 10 1809, I'm not sure it was this restrictive previously. However for the purposes of WSL it doesn't matter as all processes are gated by a system service and kernel driver so these checks can be easily bypassed. As any new thread for a WSL process must go via the Pico process driver this flag could be automatically set and everything would just work.
Per-Directory Case-Sensitivity
A per-thread opt-out from case-insensitivity solved the immediate problem, allowing WSL to create case-sensitive files on an existing volume, but it didn't help Windows applications inter-operating with files created by WSL. I'm guessing NTFS makes no guarantees on what file will get opened if performing a case-insensitive lookup when there's multiple files with the same name but with different case. A Windows application could easily get into difficultly trying to open a file and always getting the wrong one. Further work was clearly needed, so introduced in 1803 was the topic at the start of this blog, Per-Directory Case Sensitivity.
The NTFS driver already handled the case-sensitive lookup operation, therefore why not move the responsibility to enable case sensitive operation to NTFS? There's plenty of spare capacity for a simple bit flag. The blog post I reference at the start suggests using the fsutil command to set case-sensitivity, however of course I want to know how it's done under the hood so I put fsutil from a Windows Insider build into IDA to find out what it was doing. Fortunately changing case-sensitivity is now documented. You pass the FILE_CASE_SENSITIVE_INFORMATION structure with the FILE_CS_FLAG_CASE_SENSITIVE_DIR set via NtSetInformationFile to a directory. with the FileCaseSensitiveInformation information class. We can see the implementation for this in the NTFS driver.
NTSTATUS NtfsSetCaseSensitiveInfo(PIRP Irp, PNTFS_FILE_OBJECT FileObject) {
if (FileObject->Type != FILE_DIRECTORY) {
return STATUS_INVALID_PARAMETER;
}
NSTATUS status = NtfsCaseSensitiveInfoAccessCheck(Irp, FileObject);
if (NT_ERROR(status))
return status;
PFILE_CASE_SENSITIVE_INFORMATION info =
(PFILE_CASE_SENSITIVE_INFORMATION)Irp->AssociatedIrp.SystemBuffer;
if (info->Flags & FILE_CS_FLAG_CASE_SENSITIVE_DIR) {
if ((g_NtfsEnableDirCaseSensitivity & 1) == 0)
return STATUS_NOT_SUPPORTED;
if ((g_NtfsEnableDirCaseSensitivity & 2)
&& !NtfsIsFileDeleteable(FileObject))
{
return STATUS_DIRECTORY_NOT_EMPTY;
}
FileObject->Flags |= 0x400;
} else {
if (NtfsDoesDirHaveCaseDifferingNames(FileObject)) {
return STATUS_CASE_DIFFERING_NAMES_IN_DIR;
}
FileObject->Flags &= ~0x400;
}
return STATUS_SUCCESS;
}
There's a bit to unpack here. Firstly you can only apply this to a directory, which makes some sense based on the description of the feature. You also need to pass an access check with the call NtfsCaseSensitiveInfoAccessCheck. We'll skip over that for a second.
Next we go into the actual setting or unsetting of the flag. Support for Per-Directory Case-Sensitivity is not enabled unless bit 0 is set in the global g_NtfsEnableDirCaseSensitivity variable. This value is loaded from the value NtfsEnableDirCaseSensitivity in HKLM\SYSTEM\CurrentControlSet\Control\FileSystem, the value is set to 0 by default. This means that this feature is not available on a fresh install of Windows 10, almost certainly this value is set when WSL is installed, but I've also found it on the Microsoft app-development VM which I don't believe has WSL installed, so you might find it enabled in unexpected places. The g_NtfsEnableDirCaseSensitivity variable can also have bit 1 set, which indicates that the directory must be empty before changing the case-sensitivity flag (checked with NtfsIsFileDeleteable) however I've not seen that enabled. If those checks pass then the flag 0x400 is set in the NTFS file object.
If the flag is being unset the only check made is whether the directory contains any existing colliding file names. This seems to have been added recently as when I originally tested this feature in an Insider Preview you could disable the flag with conflicting filenames which isn't necessarily sensible behavior.
Going back to the access check, the code for NtfsCaseSensitiveInfoAccessCheck looks like the following:
NTSTATUS NtfsCaseSensitiveInfoAccessCheck(PIRP Irp, PNTFS_FILE_OBJECT FileObject) {
if (NtfsEffectiveMode(Irp) || FileObject->Access & FILE_WRITE_ATTRIBUTES) {
PSECURITY_DESCRIPTOR SecurityDescriptor;
SECURITY_SUBJECT_CONTEXT SubjectContext;
SeCaptureSubjectContext(&SubjectContext);
NtfsLoadSecurityDescriptor(FileObject, &SecurityDescriptor);
if (SeAccessCheck(SecurityDescriptor,
&SubjectContext
FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD)) {
return STATUS_SUCCESS;
}
}
return STATUS_ACCESS_DENIED;
}
The first check ensures the file handle is opened with FILE_WRITE_ATTRIBUTES access, however that isn't sufficient to enable the flag. The check also ensures that if an access check is performed on the directory's security descriptor that the caller would be granted FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY and FILE_DELETE_CHILD access rights. Presumably this secondary check is to prevent situations where a file handle was shared to another process with less privileges but with FILE_WRITE_ATTRIBUTES rights.
If the security check is passed and the feature is enabled you can now change the case-sensitivity behavior, and it's even honored by arbitrary Windows applications such as PowerShell or notepad without any changes. Also note that the case-sensitivity flag is inherited by any new directory created under the original.
Security Implications of Per-Directory Case-Sensitivity
Let's get on to the thing which interests me most, what's the security implications on this feature? You might not immediately see a problem with this behavior. What it does do is subvert the expectations of normal Windows applications when it comes to the behavior of file name lookup with no way of of detecting its use or mitigating against it. At least with the FILE_FLAG_POSIX_SEMANTICS flag you were only introducing unexpected case-sensitivity if you opted in, but this feature means the NTFS driver doesn't pay any attention to the state of OBJ_CASE_INSENSITIVE when making its lookup decisions. That's great from an interop perspective, but less great from a correctness perspective.
Some of the use cases I could see this being are problem are as follows:
- TOCTOU where the file name used to open a file has its case modified between a security check and the final operation resulting in the check opening a different file to the final one.
- Overriding file lookup in a shared location if the create request's case doesn't match the actual case of the file on disk. This would be mitigated if the flag to disable setting case-sensitivity on empty directories was enabled by default.
- Directory tee'ing, where you replace lookup of an earlier directory in a path based on the state of the case-sensitive flag. This at least is partially mitigated by the check for conflicting file names in a directory, however I've no idea how robust that is.
I found it interesting that this feature also doesn't use RtlIsSandboxToken to check the caller's not in a sandbox. As long as you meet the access check requirements it looks like you can do this from an AppContainer, but its possible I missed something. On the plus side this feature isn't enabled by default, but I could imagine it getting set accidentally through enterprise imaging or some future application decides it must be on, such as Visual Studio. It's a lot better from a security perspective to not turn on case-sensitivity globally. Also despite my initial interest I've yet to actual find a good use for this behavior, but IMO it's only a matter of time :-)