It’s a well known fact that interface layers are a good source of bugs, and potentially security vulnerabilities. A feature which makes sense at the time of development might come back as a misfeature in subsequent years due to layers built above the feature. This blog post will describe one such weird edge case in file path handling on Windows. This edge case is very much in the category of "interesting" but not necessarily "useful" from a security perspective. If anyone thinks of a good use for it, let us all know :-)
Let's start with a simple bit of C++ code:
BOOL OpenFile(LPCWSTR filename) {
HANDLE file = CreateFileW(filename, GENERIC_READ,
FILE_SHARE_READ, nullptr, CREATE_ALWAYS, 0, nullptr);
if (file == INVALID_HANDLE_VALUE)
return FALSE;
CloseHandle(file);
return TRUE;
}
HANDLE file = CreateFileW(filename, GENERIC_READ,
FILE_SHARE_READ, nullptr, CREATE_ALWAYS, 0, nullptr);
if (file == INVALID_HANDLE_VALUE)
return FALSE;
CloseHandle(file);
return TRUE;
}
Nothing too strange here, OpenFile is just a wrapper around CreateFile. The purpose is to create a new file with a specified name and report TRUE if the creation was successful or FALSE if it was not. Now we need to something to call OpenFile.
void Test(LPCWSTR filename) {
if (!OpenFile(filename))
wcout << L"Error (base) - " << filename << endl;
else
wcout << L"Success (base) - " << filename << endl;
WCHAR full_path[MAX_PATH];
if (!GetFullPathNameW(filename, MAX_PATH, full_path, nullptr)) {
wcout << L"Error getting full path" << endl;
return;
}
if (!OpenFile(full_path))
wcout << L"Error (full) - " << filename << endl;
else
wcout << L"Success (full) - " << filename << endl;
}
The Test function calls opens a file twice. First it just uses the base filename passed to the function. The base filename is then converted to a full path and the file is opened again. If there’s no funny stuff then the two open calls should be equivalent.
void RunTests() {
WCHAR temp_path[MAX_PATH];
GetTempPathW(MAX_PATH, temp_path);
SetCurrentDirectoryW(temp_path);
Test(L"abc");
Test(L":xyz");
}
Finally we’ve have RunTests which will contains a couple of calls to Test. The function first changes the current directory to the user’s temp directory so we know we’re in a writable location and then runs Test twice with different filenames abc and :xyz. What would we expect the results to be? The first test tries to create the file abc. Nothing too strange, according to the general Win32 path conversion rules you’d expect the abc file to be created inside the temp directory. The second test :xyz is a bit more tricky, it looks like a Alternate Data Stream (ADS) name, however to be a valid stream name you need the name of the file before the colon otherwise what file would it add the stream to? Let’s find out the results by running the code:
Success (base) - abc
Success (full) - abc
Success (base) - :xyz
Error (full) - :xyz
We’ll that result is unexpected. While we guessed correctly that abc would succeed, it seems :xyz succeeded when we passed it the base filename but failed when we used the full filename. There must be some a good reason for that behavior. Let’s use a debugger to try and work out why this occurs. First I run the application in WinDBG adding the following breakpoint which will break on NtCreateFile and dump the OBJECT_ATTRIBUTES which contains the filename, the wait for the call to complete and print the NTSTATUS result:
bp ntdll!NtCreateFile "!obja @r8; gu; !error @rax; gh"
With the breakpoint set the tests can be executed, the following is the output:
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is abc
OBJ_CASE_INSENSITIVE
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is \??\C:\Users\user\AppData\Local\Temp\abc
OBJ_CASE_INSENSITIVE
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is :xyz
OBJ_CASE_INSENSITIVE
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is \??\C:\Users\user\AppData\Local\Temp\:xyz
OBJ_CASE_INSENSITIVE
Error code: (NTSTATUS) 0xc0000033 (3221225523) - Object Name invalid.
This at least explains why the second call to OpenFile with :xyz fails. Our call to GetFullPathName has resulted in a full path which is invalid. As I mentioned earlier you need a filename before the stream separator for the NTFS filename to be valid. But that doesn’t them explain why the first call did succeed.
The solution is CreateFile doesn’t resolve the relative path to a full path, but instead passes the same name we passed in, i.e. :xyz. This behavior's possible because the OBJECT_ATTRIBUTES structure has a RootDirectory field which contains a handle from where the kernel can start a parsing operation. Sadly !obja doesn’t print the handle value for us, so we’ll need to do it manually, replace the !obja part in the previous breakpoint to the following:
.printf \"Name: %msu Handle: %x\\n\", poi(@r8+10), poi(@r8+8)
If you change the breakpoint and re-run the application you'll get the following output:
Name: abc Handle: 94
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: \??\C:\Users\user\AppData\Local\Temp\abc Handle: 0
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: :xyz Handle: 94
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: \??\C:\Users\user\AppData\Local\Temp\:xyz Handle: 0
Error code: (NTSTATUS) 0xc0000033 (3221225523) - Object Name invalid.
When the full path is being passed the handle is NULL, but when the relative path is used it’s the value 0x94. And what is handle 0x94? It’s a handle to the current directory, which in this case is the temp directory. So in theory we should find a named stream xyz on the temp directory if our theory is correct.
Let’s just check everything works as we expect, and let’s try it with a file as well:
So it works both for directories and files. The reason it works with CreateFile is we know the temp folder is writable, so we can create named streams. Parsing from an existing File object with a filename which starts with colon results in the NTFS filesystem accessing a named stream rather than a new file or subdirectory. It makes some kind of twisted sense, but the fact that a relative path can have a totally different behavior to a fully qualified path it clearly nor a designed in feature but an interaction between the way NTFS handles relative paths and how Win32 optimizes file access in the current directory.
I did find documentation for this behavior on MSDN, but I can no longer seem to find the page. It’s not on the obvious pages, and during searching you find archaic gems such as this. However, as I said I can’t find of a good use case for this behavior. If a privileged service is not canonicalizing and verifying paths then that’s already a potential security issue. And these paths have limited use, for example passing it to LoadLibrary fails as the path is canonicalized first and then opened.
Still, don't discount this misfeature as pointless. Never underestimate the value of unusual or undefined behavior in a system when looking for security vulnerabilities. I tend to collect, and document stupid things like this because you really never know when they might come in handy. An OS like Windows is so complex I'm always learning new things and behaviors, even before new features are added. Improving your knowledge of a system is one of the best ways to becoming an effective security researcher so don't be afraid to just mess around and test things. Even if you don't find a vulnerability you might at least get a new, interesting insight into how your platform of choice works.
0 Comments