MIT course "Computer Systems Security". Lecture 3: "Buffer overflow: exploits and protection", part 2
Massachusetts Institute of Technology. Lecture course # 6.858. "Security of computer systems". Nikolai Zeldovich, James Mykens. year 2014
Computer Systems Security is a course on the development and implementation of secure computer systems. Lectures cover threat models, attacks that compromise security, and security methods based on the latest scientific work. Topics include operating system (OS) security, capabilities, information flow control, language security, network protocols, hardware protection and security in web applications.
It is interesting that the attacker can not jump to a specific address, despite the fact that we mainly use hard-coded addresses. What he does is called “heap attack”, and if you are a bad person, then it will be quite fun for you. With such an attack, the hacker begins to dynamically allocate tons of shell code and simply enter it randomly into memory. This is especially effective if you use dynamically high-level languages ​​such as JavaScript. Thus, the tag reader is in a narrow loop and simply generates a large number of lines of shell code and then fills them with a bunch. ')
The attacker cannot determine the exact location of the lines, he simply allocates 10 MB of lines of shell code and makes a random jump. And if he can somehow control one of the ret pointers, there is a possibility that he will "land" in the shell code.
You can use one trick called NOP slide , NOP sled or NOP ramp , where NOP is no-operation instructions , or empty, idle commands. This means that the processor's instruction flow “slips” to its final, desired destination whenever the program goes to the memory address anywhere on the slide.
Imagine that if you have a line of shell code and you move to a random place in this line, it may not work, because it does not allow you to deploy the attack in the right way.
But maybe this stuff you put on the pile is basically just a ton of NOP , and at the very end you have the shell code. This is actually quite clever, because it means that now you can actually get to the right place to jump. Because if you jump into another of these NOPs , it’s just “boom, boom, boom, boom, boom, boom, boom”, and then you get into the shell code.
It seems that this is coming up with the people you probably see in our team. They invent something like that, and that's the problem. So this is another way to get around some random things, just making the randomization of your codes sustainable, if that makes sense.
So, we have discussed some types of accidents that you can use. There are some stupid ideas that people also had. So now you know that when you want to make a system call, for example, you use the syscall libc function, you basically pass on any unique number representing the system call you want to make. So maybe the fork function is 7, sleep is 8, or something like that.
This means that if an attacker can figure out the address of this syscall instruction and go to him in some way, he or she can actually just substitute the number of the system call that they want to use directly. You can imagine that every time the program is running, you actually create a dynamic assignment of syscall numbers to actual syscalls , in order to make it more difficult for an attacker to capture.
There are even some avant-garde offers to change the hardware so that the equipment contains the xor encryption key, which is used for the dynamic functions xor . Imagine that every time you compile a program, all instruction codes get a certain key xor . This key is stored in the hardware register when you initially load the program, and after that, whenever you execute an instruction, the equipment automatically performs an xor operation with it, before continuing with this instruction. In this approach, it is good that now, even if the attacker can generate shell code, he does not recognize this key. So it will be very difficult for him to figure out exactly what needs to be put into memory.
Audience: but if he can get the code, he can also use xor to turn the code back into instruction.
Professor: yes, that is the canonical problem, right. This is somewhat similar to what happens during the BROP attacks, when we seem to have randomized the location of the code, but the attacker can “find” him and find out what is happening. One can imagine that, for example, if an attacker knows some sub-sequence of code that he expects to find in a binary file, then he will try to use the xor operation for this file in order to extract the key.
In essence, we discussed all types of randomization attacks that I wanted to tell you about today. Before we proceed to programming, it is worth discussing the question of which of these methods of protection are used in practice. It turns out that both GCC and Visual Studio by default include the stack canaries approach. This is a very popular and very famous community. If you look at Linux and Windows, they also use such things as memory non-executable and address space randomization. True, the baggy bounds system is not so popular with them, probably because of the cost of memory, processor, false alarms, and so on, which we have already spoken about. So basically, we examined how things are going with preventing the buffer overflow problem.
Now we will talk about ROP , backward-oriented programming. Today I have already told you that it is in terms of randomizing the address space and preventing data from being executed - it is read, write and execute. In fact, these are very powerful things. Because randomization prevents the possibility that the attacker will understand where our hard-coded addresses are located. And the ability to prevent data from being executed ensures that even if you put the shell code on the stack, the attacker will not be able to just jump to it and execute it.
All this looks quite progressive, but hackers are constantly developing methods of attacks against such progressive defense solutions.
So what is the essence of reverse-oriented programming?
What if, instead of just creating a new code during an attack, an attacker could merge the existing pieces of code together and then merge them together in an abnormal order? After all, we know that the program contains tons of such code.
So, fortunately, or unfortunately, it all depends on which side you are on. If you can find enough interesting code snippets and merge them together, then you can get something like the Turing language, where the attacker can essentially do whatever he wants.
Let's look at a very simple example, which at first will seem familiar to you, but then quickly turn into something crazy.
Suppose we have the following program. So, let us have some kind of function and, which is convenient for an attacker, there is this nice feature of the run shell . So this is just a call to the system, it will execute the bin / bash command and this will end. Next we have a canonical buffer overflow process, or, sorry, a function that will announce the creation of a buffer, and then use one of these unsafe functions to fill the buffer with bytes.
So, we know that there is a buffer overflow without any problems. But the interesting thing is that we have this run shell function, but it is difficult to get to it using methods based on buffer overflow. So how can an attacker invoke this run shell command?
First of all, an attacker can disassemble a program, run GDB , and find out the address of this item in the executable file. You are probably familiar with these methods from lab work. Then, during a buffer overflow, an attacker can take this address, place it in the generated buffer overflow, and make sure that the function returns to the run shell .
To be clear, I'll draw it. So, you have a stack that looks like this: below there is an overflowed buffer, above it is a saved break pointer, above it is the return address for prosess_msg . From the bottom left we have a new stack pointer, which initiates the execution of the function, above it a new break pointer, then the stack pointer to be used, and even higher is the break pointer of the previous frame. It all looks pretty familiar.
As I said before, GDB was used during the attack to find out what the address of the run shell was . Thus, in case of a buffer overflow, we can simply put the address of the run shell right here, to the right. In fact, this is a fairly simple extension of what we already know how to do. In essence, this means that if we have a command that starts the shell, and if we can disassemble the binary file to find out where this address is, we can simply place it in this flooded array located at the bottom of the stack. It is quite simple.
So, this was an extremely frivolous example, because the programmer, for some crazy reason, put this function here, thus presenting the attacker with a real gift. Now suppose that instead of calling this thing run_shell , we will call it run_boring , and then it will simply execute the / bin / ls command . However, we did not lose anything, because at the top we will have the string char * bash_path , which will show us the path to this bin / bash .
So the most interesting thing about this is that an attacker who wants to run ls can “parse” the program and find the location of run_boring , and this is not at all fun. But in fact, we have a line in memory that indicates the path of the shell, in addition, we know something else interesting. This is the fact that even if the program does not call the system with the argument / bin / ls , it still makes some kind of call.
So, we know that the system should be somehow connected with this program - system (“/ bin / ls”) . Therefore, we can use these two void operations to actually associate the system with this argument char * bash_path . The first thing we do is go to GDB and find out where this system (“/ bin / ls”) is in the image of the binary process. So, you just go to GDB , just type print_system and get information about its offset. It's pretty simple, and you can do the same for bash_path . So you simply use GDB to find out where this thing lives.
Once you are done, you need to do something else. Because now we really have to somehow figure out how to call the system using the argument we chose. And the way we do this is essentially to falsify the calling frame for the system. If you remember, a frame is something that both the compiler and hardware use to implement the stack call.
We want to organize in the stack something like what I depicted in this drawing. In fact, we're going to fake a system that should have been on the stack, but right before it actually executes its code.
So, here we have the system argument, this is the string we want to execute. At the bottom we have a line where the system should return to when the mentioned line with the argument is executed. The system expects the stack to look just like that just before the execution begins.
We used to assume that there are no arguments when you pass a function, but now it looks a little different. We just have to make sure the argument is in the overflow code that we create. We just have to make sure that this fake calling frame is in this array. Thus, our work will be as follows. Recall that the stack overflow goes from bottom to top.
First, we are going to put the system address here. And on top we will post some reject return address junk return address . This is the place where the system will return after it is finished. This address will be a random set of bytes. Above it, we place the address bash_path . What happens when the buffer overflows now?
After prosess_msg reaches the finish line, he will say: “OK, here is the place where I should return”! The system code continues to execute, it moves higher and sees the fake call frame we created. For the system, nothing amazing will happen, she will say: “Yeah, here it is, the argument I want to perform is bin / bash ”, it executes it, and the attacker has captured the shell!
What have we done now? We have used the knowledge of the calling agreement, the calling convention , as a platform for creating fake stack frames, or fake frame names, I would say. Using this fake calling frame , we can perform any function that is referenced and which is already defined by the application.
The next question we have to ask is: what if the program does not have this line char * bash_path at all ? I note that this line is almost always present in the program. However, suppose that we live in an inverted world, and it is still not there. So what could we do to put this line into a program?
The first thing to do is to specify the correct address for bash_path , placing it higher, in this compartment of our stack, inserting three elements, each of which is 4 bytes in size:
/ 0 / pat / bin
But in any case, our pointer comes here and - boom! - It is done. So you can now invoke the arguments by simply placing them in the shell code. Awful, isn't it? And all this is built up before the full BROP attack. But before you point out a full BROP attack, you need to understand how you simply hook together things that already exist inside the code. When I place this waste return address here, we just want to access the shell. But if you are an attacker, you could send this return address, or return address, to something that could really be used. And if you did this, you could string several functions in a row in a row, several features of the function in a row. This is really a very powerful option.
Because if we just set the return address for the jump, then after that the program usually crashes, which we maybe don’t want. Therefore it is worth linking together some of these things in order to do more interesting things with the program.
Suppose our goal is that we want to call the system an arbitrary number of times. We don't just want to do it once, we will do it any number of times. So how can this be done?
For this, we use two pieces of information that we already know how to get. We know how to get the address of the system - you just need to look at GDB and find it there. We also know how to find the address of this line, bin / bash . Now, in order to initiate this attack using several calls to the system, we need to use gadgets. This brings us closer to what is happening in BROP .
So what we need now is to find the address of these two code operations: pop% eax and ret . The first removes the top of the stack and places it in the eax register, and the second places it in the eip instruction pointer . This is what we call a gadget. It looks like a small set of assembly instructions that an attacker can use to build more ambitious attacks.
Such gadgets are standard tools that hackers use to find things like binary files. It’s also easy to find one of these gadgets, assuming you have a copy of the binary, and we didn’t bother with randomization. These things are very easy to find, as well as very easy to find the address of the system and so on.
So, if we have one of these gadgets, why can we use it? Of course, to cause evil! To do this, you can do the following.
Suppose we change our stack so that it will look like this, the exploit, as before, is directed from the bottom up. The first thing we do is locate the system address here, and above it put the address of the gadget pop / ret . Even higher, we’ll put the bash_path address, and then we’ll repeat: we’ll put the system’s address, the pop / ret gadget’s address, and the bash_path address again .
What will happen here now? It will be a little difficult, so the notes of this lecture are available on the Internet, but for now you can just listen to what is happening here, but when I first understood this, it was like understanding that Santa Claus does not exist!
We will start this place where the entry is located, go back to the system, where the ret instruction is going to remove the item from the stack with the pop command, so now the top of the stack pointer is here. So, we remove the element with pop , then ret returns the ret procedure, which transfers control to the return address selected from the stack, and this return address is placed there with the call command. So, we again make a call to the system, and this process can be repeated again and again.
It is clear that we can link this sequence to perform an arbitrary number of things. In essence, the kernel gets what is called reverse-oriented programming. Notice that we didn’t do anything in this stack. We did what allowed us to prevent the execution of the data without destroying anything. We just made some sort of unexpected jumps to do what we want. In fact, it is very, very, very, smart.
And what is interesting is that at a high level we have defined this new model for calculations.In a traditional, non-malicious program, you have an instruction pointer that points to some linear sequence of instructions. And you increase the instruction pointer to figure out what should be done next. Essentially, reverse-oriented programming uses the stack pointer as an instruction pointer. When we move the stack pointer, we point it to other blocks of code that we are going to execute. But then at the end of the gadget, we return again to the stack pointer, which shows the next block of code to execute. This way you can prevent things that are undesirable for us. This shows how to get around this non-executable bit on the pages. So the next thing we want to do may be a victory over stack canaries.