As we discussed in the previous parts, the Linux Kernel is a collection of code written entirely in C. This is important to keep in mind while we are dealing with the attack floats because the vulnerabilities found in the Linux Kernel can be found in any executable file written using the C programming language. In short, the concept is the same. Kernel Exploits have certain options and aspects for attacking. We examine them here in detail.
Network devices, as mentioned in the Drivers section, only undertake the task of transmitting packets. Kernel undertakes the main task of how the packages will be distributed, with which transactions they will be associated, and their contents. As we can understand from here, the received or sent packet is controlled and examined by the Kernel. In order to understand how it is sent or to whom it is sent, the packet passes through the Kernel before it reaches the producer or the process that will receive it. Any vulnerability that may arise in the code written to undertake these tasks in the Linux Kernel can have very serious and dangerous consequences. Because malicious packages that are specially prepared and target the code structure used by the Kernel while examining packages can be counted as a 0-click vulnerability. In this case, almost no protection measures are possible for the user. If the network protocol used by the exploit is not a widely used and open protocol by default, great harm can be avoided. But if it’s a very common and widely used protocol or interface, then the exploit can literally cause a disaster. Such exploits can be triggered remotely and this is their most powerful feature. Also, such exploits are almost non-existent in today’s operating systems (as is known).
Among the attack surfaces, it is the most used today. Although Userspace is a restricted area, it may want to increase its authority to communicate with the Kernel and perform certain operations. There are multiple ways to increase authority in the system for exploits running in Userland. They can use the vulnerabilities of other processes running with high authority in the system, they can perform the operations they want with high-level privileges with the vulnerabilities discovered on the kernel in the syscall and ioctl handler features. As you know, Userspace is a memory area where multiple applications come together and there are applications with different authorization types. Although it normally seems safe, things don’t always go as planned when multiple and different applications are gathered together. Because each transaction has a different task and target, transactions that want to fulfill these tasks or targets create a very complex structure. Of course, within this complex structure, more than one attack surface for malicious users also emerges. As we said at the beginning, they are all codes and it is very likely that there are vulnerabilities in each code.
Devices and drivers work as a whole and are in constant communication with the Kernel. They share the information they have obtained with the Kernel and perform the tasks requested from them. So, another attack vulnerability is obtained by exploiting these devices, drivers or related code snippets in the Kernel. For example, a programmable USB device behaves much differently than a regular USB device when plugged into a computer. Here, the attacker can gain the ability to perform unauthorized operations on the computer by using a vulnerability in the codes of the device driver or in the codes found in the Kernel to enable communication between the device and the Usermode. An exploitation can be performed not only by plugging a device, but also by directly targeting a device driver. Linux has a Monolithic Kernel. Codes owned by device drivers work in Kernel mode and have high privileges. An application running in Userland can make certain requests to use the device, however, a communication bridge is established between the device driver and the requesting process. With the help of this established connection, the malicious user can perform unauthorized operations by exploiting a vulnerability in the device driver.
First of all, we should know that such attacks may result in executing commands with high authority on the system. Once attackers gain such authority, there is almost no limit to what they can do. They can not only increase their authority but also implement rootkits or drivers that can run continuously in the system and take full control of the computer. They can create a backdoor for themselves and repeatedly run high-authority code whenever they want. They can manipulate the devices on the computer, even gain the ability to interfere with the devices in the Trustzone.
Another important point to be mentioned here is that the Linux Kernel has thousands of different features. It is usually only a select few of these thousands of features that end users need. In fact, all the features offered in userland are an attack surface. There are maybe thousands of different features that your operating system has but that you have not used until today. It is very important to clean the Kernel from such unnecessary plugins. Attack surfaces can be significantly limited by completely removing such unused features from the Kernel, because all of these features in the operating system have the potential to be an attack surface. Operating systems that run large-scale operations and run multiple systems are among the most popular targets for attackers. Because as the attack surface increases, the methods to be used in the attack also increase, and thus the success rate of the attack increases considerably.
As we mentioned before, the Linux Kernel is a stack of code written entirely in C. It is possible to see all the vulnerabilities that the C language can cause in the Kernel. Although the Root Cause varies, the purpose of using the vulnerabilities in the Kernel is generally for local privilege escalation. This means a low-authority user can run code with high privileges with the help of these vulnerabilities. The vulnerabilities that we will discuss under this heading are all found in Linux Core. In other words, they are the types that can be exploited without the need for any third-party application or component. We will divide the vulnerabilities into three groups and examine why the vulnerabilities occur, and how they are exploited and patched. Let us start.
DirtyCow is a Race Condition vulnerability that directly affects Linux distributions, including Android. There are certain techniques that operating systems offer to programs. With these techniques, programs can use their memory more effectively and increase performance. One of these techniques is Copy-On-Write. This memory management technique, which we will briefly call COW, is used for the application to access certain data on the physical disk. Here, if the process wants to read or use the data in the physical memory, it does not copy them directly to its own memory. The process contains a structure in its own memory that points to the data in the physical memory, thus saving time and resources. If it wants to access this data, it can access the data in the physical memory with this pointer in its memory. So far, everything is normal and as it should be, but now let’s examine why this vulnerability occurs.
In the Linux operating system, as in all other operating systems, there are privilege classes. So certain files and apps are restricted for certain users. Thanks to these authorization differences, not every user can read and write every file they want. Some files are only under the control of the root user. That is, a normal user can read and run this file or application, but cannot make any changes to the file or application. In this way, users cannot leave the limits drawn for them and cannot perform unauthorized transactions.
The COW technique that we mentioned at the beginning is related to what we have explained. For example, let’s say we have root user data in physical memory. Any user can read this data, but cannot change it. If a process wants to access this data, it can read it, the mmap function with MAP_PRİVATE flag can be used for this and a COW memory is created for the data to be accessed. With this created memory, the data to be accessed can be read in the process. However, as soon as any change is wanted to be made on this data, the copy of this data is transferred from the physical memory to the memory of the process itself, and the desired changes are made on this copied data. If this write operation takes place in a normal process, the page tables will already be updated. In this way, our writing will affect the data we copied, not the original data.
Here, the path followed after the necessary memory space is created is actually the basic working principle of the vulnerability. The memory space used for this operation is said to be cleaned in case of “dirty” with the help of another function, and the writing and deletion operations are overlapped with each other. Well, even if we say how this conflict will be, the following detail, which we should not forget about computers, comes into play: computers have been devices that have evolved and accelerated over the years. And this path we are discussing takes advantage of the speeds of these devices with excellent processing capabilities. With simultaneous writing and deleting operations, the operating system activates the writing process without updating the page tables for a very short time, and thus the original file is written instead of the copy.
With what we have mentioned so far, we have made a small introduction to the exploitation phase of vulnerability. When we examine the exploit code, we first open our target file in the main function using the open function and the O_RDONLY flag. In this way, we obtain a file descriptor. In line 101, we prepare a memory area for mapping using the file descriptor we obtained. There is another flag that we should pay attention to when using the mmap function; MAP_PRIVATE. According to information from mmap‘s man page, the MAP_PRIVATE flag is used for copy-on-write mapping. This means that the data to be accessed with the required file descriptor is not transferred directly to the area mapped in the memory of the process. This saves time and resources. In lines 106 and 107, two threads to run simultaneously are created. One of them is madviseThread and the other is procselfmemThread. As their names suggest, it is possible to make certain inferences about their content. Let’s examine these two threads in detail.
ProcselfmemThread starts by assigning the argument we gave to the exploit code to a variable in it. Then it opens the “/proc/self/mem” file with the O_RDWR flag with the open function. Here “/proc/self” is a special directory in Linux operating system. This directory contains important information about the process that is currently accessing this directory. In this way, applications can access their own memory or private information through this directory. The O_RDWR flag used with the open function enables Read and Write operations.
In other words, by using this file descriptor, that returns from open, reading and writing, operations can be done on the file. In the next step, we enter a long loop that we will see in the other thread. It is here that the meaning of Race Condition becomes clearer. Trying to make the computer do things in an infinite loop with more than one thread running simultaneously can sometimes lead to very unique results that are impossible to encounter. In the thread we are discussing, in this long loop, it first tries to point to the target data mapped in memory with the process fseek function. Afterwards, the data given as an argument is constantly being written to this file. I think we can guess what will happen in this case. Since the operating system knows that the file we aim to change belongs to the root user, it will not allow this operation and will allow the original file to be copied to the virtual memory of our process first and overwritten by this write function. And this loop will continue.
Now, let us inspect madviseThread. As the name suggests, this thread provides an advice to the memory. Using the madvise function with the MADV_DONTNEED flag affects the first 100 bytes of the mapped memory space. The purpose of our function here makes sense with its flag. We can even say that this MADV_DONTNEED flag is the key to an entire exploit.
With the MADV_DONTNEED flag, the madvise function indicates to the operating system that this field is not needed if it is dirty. In other words, it says that if there is any change on this area, it can be deleted and used again as a clean area. But there is one more detail here, thanks to this flag, if the data in this field that will be deleted in case of being dirty is needed again, this data can be reloaded into the required field.
This is the basic working principle of our exploit. So, the general flow goes in this direction. A thread constantly wants to write this field, on the other hand, another thread cleans this field as soon as it is written and deletes the data here. Then, when the need to rewrite is required and the data here is wanted to be accessed, it is restored. With two threads running simultaneously, these operations are repeated very quickly, although these operations are repeated quickly, restoring the data that needs to be loaded when needed is not that fast. Because the transition of data from physical storage to virtual storage cannot keep up with the operating speed of the processor. And in a very unique moment, without updating the page tables, the write operation is applied to the original data in the physical storage device instead of the copied data in the virtual memory. And so, the original data owned by the root user is written. Write, one of the syscalls offered by the Kernel, is used uncontrollably at this point. In other words, the operating system cannot keep up with this fast cycle and writes the original data in a time of confusion. Thus, the exploitation phase is completed and a data or file with the write authority of a normal user is successfully manipulated.
So far, we have understood why the vulnerability occurs and how it is exploited. So, how was this vulnerability fixed? Although there is more than one key factor in the exploitation phase, the biggest and main reason is that page tables cannot keep up with the processing speed and lag behind. Let us explain this in more detail.
We are trying to perform operations in an infinite loop in two different threads, but while these loops continue, the operating system tries to transfer data from the physical storage device to the virtual storage area and update the tables. Here is our key point, the exploitation is successfully completed when we perform the write operation without allowing the tables to be updated. So, we can determine page tables as a Root Cause. As long as we are sure that the area trying to be written is really the COPY version before writing, the vulnerability is completely gone.
The solution to the vulnerability was exactly like this. The operating system has been optimized to continue this type of write operation after making sure that the correct location is written. In other words, the possibility of confusion that may arise with this endless loop has completely disappeared. Because the operating system has made sure that when a special and important file is accessed and written, it writes to the data that is COPY. That way the DirtyCow vulnerability was gone, at least until it spawned again in a different genre. In fact, the DirtyCow vulnerability was previously known and tried to be controlled. But it reappeared with the updates coming in the following periods and, also, it is said to be a vulnerability in distributions that use the Linux Kernel for many years. After this latest version, necessary control code blocks related to virtual tables were added to the operating system, thus preventing DirtyCow and similar Race Condition vulnerabilities from manipulating private files or data as a result of the confusion.
Every Linux or MacOS users have used the command called sudo before. This command was developed with the aim of providing operations that require high privileges to a low-authority user. It is an application that comes within the operating system itself. If the low-authority user meets the requirements to use this command, or if it has a password, it can use the sudo command to escalate privilege or run an application as root. This is a brief summary of the application called sudo.
Now, let us address the Heap Based Buffer Overflow vulnerability discovered in sudo in 2021. Although this vulnerability emerged in 2021, the piece of code from which it originated was added to the application in 2011. Later in the document, we will also touch on why it took 10 years for this vulnerability to be discovered. Although sudo appears as a stand-alone application, it contains multiple components. In the vulnerability that we will examine, sudoedit is our main character. Sudoedit is a sudo dependent binary. In short, sudoedit is a part of sudo and it is a symlink to sudo. You can see this if you type file /usr/bin/sudoedit in your terminal. What does it mean?
When you run a symlink application, it will run the application it is linked to. But here, when the main application is running, the value of argv is replaced with the name of the symlink application. So when we run the sudoedit application from the terminal, the sudo application actually runs as sudoedit with the value of argv. This process ensures that different features within an application are made available to other applications that are symlinks. So there is a centralization. In this way, the argv value is checked every time the sudo command is run. If this argument ends with “edit”, the “mode” and “progname” values are changed at the beginning of the application. Now our sudo application realizes that it is running as sudoedit and follows a path accordingly. However, in the current sudo application, the program name is determined by a function called getprogname(). If there is a problem in this process, the argv value is checked as the 2nd plan.
Now, let us examine why this vulnerability occurs.
Since sudo is an open-source application, the source codes can be easily accessed via Github. The origin of the vulnerability is the sudo/plugins/sudoers/sudoers.c file. With the link VulnerableCode, it is possible to see the vulnerable section. We will refer to this section for our analysis of the vulnerability. This file contains certain loops in the set_cmnd() function. This NewArgv function, which is a variable of the for loop on line 958, is actually user args that goes inside. We can simply call it string arrays. The general purpose of the for loop in line 958 is to find the lengths of the incoming data one by one and store it in a variable called size. In line 960, with the user_args = malloc(size) code, an area equal to the size of the strings taken as an argument is allocated in heap. Our buffer created right here will be vulnerable to overflow.
On line 970, we encounter a new for loop. The purpose of our loop here is to combine the arguments from the user in the buffer we allocate. That is, to make string arrays that come as user arguments into a long and single string in the created buffer, so just string concatenation. So far, everything seems correct. In the for loop, a space is left between each argument, it is collected in a single buffer, and the string to be created is completed by adding the last null byte.
We come to the starting point of our whole story: the if block on line 972. The payload used for the PoC is briefly as follows; sudoedit -s AAAAAAAAAAAAAA\ Some things are starting to make sense now. In the if block on line 972, verifying that the next character is “\” and the next character is not space. If the conditions are met, we shift the pointer pointing to the user argument by one character and copy the next character to the buffer we allocate. In short, we ignore the backslash character, so we just copy the next character into the buffer and continue. What if the character immediately after the backslash is a null byte? The PoC payload we mentioned above does exactly that. The last character of our argument is backslash, which means that the character copied to the allocated buffer will be null byte. Right here we go back to line 971, the while loop runs until it encounters a null byte. This makes sense, because every argument we receive from the user is a string and must end with null byte. Thus, when the while loop reaches the end of the argument, it returns to the for loop by adding the space character to the buffer to copy another argument.
However, as in our PoC payload, strings ending with backslash break this structure. After copying the null byte that comes after the backslash to the buffer, what we process as an argument is completely over. But since we are copying the null byte and pointing to the next character, the while loop does not stop here and continues to copy data into the allocated buffer until it hits an additional null byte. Continuing the copy operation of the while loop causes a Heap Based Buffer Overflow since the copy operation is made to a buffer allocated according to the size of the user argument.
Now, we can deduce why the vulnerability originates and how it will be triggered. But the arguments sent by the user do not come directly to the set_cmnd() function. In other words, we cannot directly use the arguments we receive from the user here. The arguments presented by the user are first passed through the parse_args() function, where they are processed at certain points. Then they are ready to be used. With the if block on line 623 in the parse_args() function, it is checked whether the argument sent by the user contains special characters other than allowed. In the next line, if there is a special character other than the allowed ones, it is tried to be escaped by adding a backslash(“\”) in front of it. In this case, the payload we use for the PoC should not work and the backslash at the end should be escaped, right? The set_cmnd() and parse_args() functions work in conjunction with each other. In other words, the arguments to the set_cmnd() function are first cleaned by passing the parse_args() function, but let’s take a closer look at the conditions for these two functions to work. Looking at line 604 in the parse_args() function, we encounter an if block.
As seen in the if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) statement, if MODE_RUN and MODE_SHELL values are present, the arguments sent by the user will be parsed and free of special characters. On the other hand, when we look at the set_cmnd() function, we encounter a similar if block on line 964. if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) statement checks for MODE_SHELL or MODE_LOGIN_SHELL values. And if this condition is met, our vulnerable code works. Here we notice that the two different if blocks do not exactly overlap. So, there must be a way to get to the set_cmnd() function without triggering the parse_args() function, right?. Right here, with -s flag, we can run sudo without changing the MODE_RUN value while triggering the MODE_SHELL value. Thus, using the -s flag, we can run the set_cmnd() function without triggering the parse_args() function. In this way, the backslash(“\”) we send as an argument does not change. Therefore, we can trigger the vulnerability.
While the cause of the vulnerability and how it can be triggered is clear, exploitation is a separate art and skill. First of all, we know that we have a buffer that we allocate in the heap and we can overflow this buffer. At the same time, we can write any heap field that can come after the buffer we allocate. To be honest, knowledge plays a big role here. Especially mastering the exploitation techniques and knowing the Linux in detail is the most important thing here.
There are different exploitation methods mentioned in the document submitted with the release of the vulnerability. We will continue with struct service_user overwrite, which is the second exploitation method, it is basically about placing the vulnerable buffer just behind the service_user object and, consequently, overwriting the service_user object. So, what will this do?
The service_user object is used to load the external library by the Name Server Switch service in Linux. The sudo application uses the NSS service to load an external library. With the __libc_dlopen() function in the nss_load_library() function, it loads the required library from the outside in line with the information obtained from the service_user object. The name of the library to be loaded is determined by the char name variable on the service_user object. Overflowing this part of the service_user object here will give us the ability to load and run any library of any name we want. As a result of loading the library we have named, we have access to code execution in root privilege, and the Local Privilege Escalation is completed. So, how is it possible to put the vulnerable buffer just before the service_user object? This is where the issue we mentioned above comes to the fore.
For this type of exploitation, the attacker must know the NSS service and have a good knowledge of Linux Internals. Moreover, even putting the vulnerable buffer behind the target object requires a lot of knowledge and effort. In heap, there is not only the information of the application. It is also possible to see the values affected by the operating system and environmental variables. Here, when we look at the heap in the debugger, it is possible to see the LC_CYTPE values. These are the environmental variables we mentioned before. We can determine the size of the buffer we will allocate according to the argument we give, and thus we can allocate a vulnerable buffer of different sizes to the heap. The heap is in a fragmented state. That is, certain parts are constantly used and freed with free(). Allocation algorithm selects a suitable place according to the size of the buffer to be distributed and allocates it onto the heap.
Changing the size of the buffer here causes buffers of different sizes to be allocated in different areas on the heap. In this way, we can have some influence on what may come before or after our vulnerable buffer. It may not be possible to achieve this with only the buffer size. The more data we can change, the greater impact on the heap. The environmental variables we mentioned above are also a factor that we can use for this purpose. Changing the size of the buffer and environmental variables at the same time results in the vulnerable buffer being allocated at multiple points on the heap. With these allocations, different objects on the heap can be overwritten each time. As a result, the application gives errors in different functions. What can be done by looking at these functions can be considered once again.
The path followed here is exactly as follows. The environmental variables LC_CYTPE values and the size of argument (vulnerable buffer) are constantly being changed randomly. It is quite possible to write a brute force script for this. Then, with these values, the sudo application is run with gdb. After each error, the back trace is checked to reveal which functions might be affected. In other words, we understand where the different size environmental variables and the buffer actually cause us to overwrite on the heap. Thus, we try to find a suitable function and object for exploit by looking at the back trace values. At this point, the aforementioned suitable function was nss_lookout_function(). When looking at the back trace values, errors were seen in certain NSS-related functions and it was known by the researchers that NSS was an attack surface to be used in Linux. The nss_load_library() function called by nss_lookup_funciton() was used to load external libraries with __libc_dlopen(). This seems to be the easiest and most suitable target for exploitation. However, at another point that should be mentioned here, the Qualys team made inferences in their other research in 2017 that the nss_load_library() function can be used for attacks and exploitation. In other words, the detail that needs to be noticed is; this is a method known to them before. It is of course possible to generate different exploitation scenarios, but for this scenario, nss_load_library() seems to be the most logical target.
We can say that the most basic reason for the vulnerability is the set_cmnd() function. If we look at the code where the vulnerability was fixed, by adding ISSET(sudo_mode, MODE_RUN) to the if block on line 964, the parse_args() function is prevented from being bypassed, and in this way, by adding backslash(“\”) in front of the special arguments we send, it makes it dysfunctional. Furthermore, if we look at the if block on line 973, an additional end operator makes sure that the next character from the backslash is not a null byte. In addition, with the if block added to the for loop, additional precautions have been taken against any overflow.
Let us clear up another issue that we mentioned at the beginning. When we look at the payload that we can use for the PoC, we see that it is actually very easy to find for a fuzzer. “sudoedit -s “123456\” doesn’t seem like a complicated and difficult payload for a normal user either. So, it’s very unlikely that a fuzzer won’t be able to find it, isn’t it? Although we think so, many fuzzers nowadays do not have the skills like argument fuzzing. Although AFL++ has such a fuzzing mechanic lately, the aforementioned sudo binary does not take the arguments directly from stdin. So, it is not possible to send the argument directly into it with a fuzzer. It is necessary to make changes on both fuzzer and target binary. Also, as you know, the source of the vulnerability is sudoedit. So, you need to fuzz the argv value as well to run sudo with sudoedit mode. Although AFL++ can also fuzz arvg, sudo does not directly use argv value as a progname. It uses the function called getprogname() for this.
Therefore, you need to fix this part of sudo source code as well. After all these problems are solved, there are still things that need to be fixed. In other words, although it may seem very easy to find with a fuzzer, there is more than one reason behind why no fuzzer has ever been found in this vulnerability. So, at this point, we can say that the secret is completely manual code analysis, which is also how the Qualys team found this vulnerability. Another important point we should mention here is that the life span of vulnerabilities found with manual code analysis can be years longer than those found with fuzzers. Today, such unique vulnerabilities are found by manual code analysis rather than fuzzers. And even years after they are found, they can still remain as 0day.