MIT course "Computer Systems Security". Lecture 2: "Control of hacker attacks", part 2

Original author: Nikolai Zeldovich, James Mykens
  • Transfer
  • Tutorial

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.

Lecture 1: "Introduction: threat models" Part 1 / Part 2 / Part 3
Lecture 2: "Control of hacker attacks" Part 1 / Part 2 / Part 3

So, we have a buffer over which we place a canary. Above is the saved value of the break point pointer saved EBP and the return address is placed above it. If you remember, the overflow goes from the bottom up, so before you get to the return address, it will first destroy the canary.

Audience: why will it affect the canary?

Professor:because it is assumed that the attacker does not know how to arbitrarily “jump” in the memory. Traditional memory overflow attacks begin with a hacker examining the buffer size limit, after which the overflow begins at the bottom line. But you are right - if the attacker can get directly to the return address line, no canary will help us. However, in the case of a traditional buffer overflow attack, everything should be done this way - from the bottom up.

Thus, the basic idea of ​​using canaries is that we allow a malicious exploit to overflow a memory buffer. We have a runtime code that, when it returns from a function, checks the canary to make sure that it has the correct value.

Lecture hall:can an attacker rewrite the return address and change the "canary"? How can he verify that it has been modified, but continues to perform its function?

Professor: yes, it can. Thus, you must have some kind of code fragment that will actually check this before the function returns. That is, in this case, it is necessary to have the support of the compiler, which will actually expand the calling convention . So that part of the return sequence occurs before we consider the validity of this value, to ensure that the canary has not been destroyed. Only after that can we think of something else.

Lecture hall:unless the malefactor cannot learn or guess, what "canary" has value?

Professor: that's exactly what I'm going to talk about! What is the problem with this circuit? What if, for example, we put A in each program? Or a whole branch of 4 values ​​of A? Of course, any hacker can find out the size of the buffer, its capacity, and thus determine the position of the canary in any system. Therefore, we can use different types of quantities that we put in our canary to prevent this.

There is one thing you can do with our canary. It will be a very funny “canary” type, which uses the C program functions and handles special characters, the so-called deterministic “canary” type.

Imagine that you used the symbol 0 for the canary. The binary value of zero is the zero byte, the zero character in ASCII. A value of -1 means return to the previous position, and so on. Many functions stop or change when they encounter characters or values ​​such as 0, CR, LF, -1. Imagine that you, as a hacker, use a certain line management function to climb up the buffer, hit the 0 character in the canary and the process stops! If you use the carriage return-oriented function -1, which is often used as a line terminator, the process also ends. So -1 is another magic sign.

There is one more thing that can be used in the “canary” - these are random values ​​that are difficult to guess for an attacker. The strength of a random value is based on how difficult it is for an attacker to guess. For example, if an attacker understands that there are only 3 bits of entropy on your system, then he can use a brute-force attack. Therefore, the possibility of using random numbers to protect against attacks is rather limited.

Lecture hall:it usually happens that I read from another buffer and write what I read into this buffer of a given stack. In this situation, it seems that the random meaning of the canary is useless, because I read the data from another buffer and know where the canary is. I have another buffer that I control and which I never check. And in this buffer I can put quite a lot of what I want to put. I do not need a random canary because I can safely rewrite it. So I don’t see how it really works - in the scenario you suggested, when the function stops while reading data from the buffer.

Professor:I understand your question - you mean that we use a deterministic canary, but do not use one of the functions of the standard library, which can be "deceived" by our symbols 0, CR, LF, -1. Then yes, in the situation described by you, the canary is not needed.

The idea is that you can fill this buffer with bytes from anywhere, but anything that allows you to guess these values ​​or get them randomly will result in a defeat.

Audience: Is it possible to use something like the number of seconds or milliseconds as random numbers and use them in a canary?

Professor:data calls do not contain such a large number of accidents as you think. Because the program has logs of records or a function that you can call to find out the time when the program was loaded, and other such things. But in general, you are right - in practice, if you can use a hardware device, usually low-level, with the best system timings, this kind of approach can work.

Audience: even if we have time to view the logs about the beginning of the buffer overflow, it is still important how much we will refuse the request. And if we cannot get control over how long a computer request to the server takes, then it is doubtful that one can guess the exact time deterministically.

Professor:quite right, I have already said that evil lies in the details, this is exactly the case. In other words, if you have some way to, for example, determine the type of timing channel, you may find that the amount of entropy, or the number of accidents, does not fill the whole timestamp, but much less. Therefore, an attacker can determine the hour and minute when you did this, but not for a second.

Audience: To record an attempt to minimize your own randomness is a bad idea?

Professor: quite right!

Audience: usually we just have to use everything that our systems support, right?

Professor:Yes this is true. It's like inventing your own cryptosystem, which is another popular thing that our graduates sometimes want to do. But we are not the NSA, we are not mathematicians, so it usually does not work out. So you're absolutely right about that.

But even if you use system randomness, you can still get less bits of entropy than you expect. I will give you an example of phase address randomization. It is on this principle that the stack canaries approach works . Since we are engaged in computer security, you are probably wondering when the canaries fail to cope with their task and whether there are ways to fail the canary.

One such method is attacking by rewriting function pointers. Because if the blow is applied to the function pointer, the canary cannot do anything.

Suppose you have a code like int * ptr ... .. that triggers a pointer, no matter how, then you have a char buf [128] buffer , the gets (buf) function , and at the very bottom is a pointer that is assigned a value : * ptr = 5 .

I note that we did not attempt to attack the return address of the function containing this code. As you can see, if the buffer is full, the pointer address located above it will be damaged. If an attacker can damage this pointer, then he can then assign a 5 to one of the addresses he controls. Everyone can see that the "canary" will not help here? Because we do not attack the way in which the function returns.

Audience: Can the pointer be below the buffer?

Professor:maybe, but the order of specific variables depends on many different things, on the way the compiler arranges the contents, on the size of the hardware column, and so on. But you are right, if the buffer overflow goes up, and the pointer is located below in front of the buffer, then the overflow will not be able to damage it.

Audience: why can't you connect the canary to the canary function, as you did with the return address?

Professor:This is an interesting moment! You can do such things. In fact, you can try to imagine a compiler that, whenever it has a pointer, always tries to add an addition to some things. However, checking all these things is too expensive. Because every time you want to use any pointer or call any function, you need to have a code that will check if this canary is correct. Basically, you could do something like that, but does that make sense? We see that the "canaries" do not help in this situation.

And one more thing that we discussed earlier is that if an attacker can guess a coincidence, then, in principle, random "canaries" will not work. Creating safety resources based on chance is a separate, very complex topic, so we will not go into it.

Audience: so the canary contains fewer bits than the return address? Because otherwise you could not just remember this address and check if it has changed?

Professor: let's see. You talk about this scheme when the canary is located above the buffer, and you mean that the system cannot be secure if it is impossible to look at the return address and check if it has not been changed.

Yes and no. Note that when a buffer overflow attacks, everything above it will be rewritten, so this can still cause problems. But basically, if these things were unchangeable in some sense, then you could do something like this. But the problem is that in many cases, the manipulation of the return address is quite a complicated thing. Because you can imagine that a special function can be called from different places, and so on. In this case, we run a little ahead, and if there is time at the end of the lecture, then we will return to this.

These are the situations in which the canary can fail. There are other places where failure is possible, for example, when attacking malloc and free functions. The malloc function allocates a block of memory of a certain size in bytes and returns a pointer to the beginning of the block. The contents of the allocated block of memory is not initialized, it remains with undefined values. And the free function frees up memory allocated dynamically earlier.

This is a unique attack in the style of S. See what happens here. Imagine that you have two pointers here, p and q, for which we allocate 1,024 bytes of memory to each of these pointers using malloc . Suppose we are doing a strcpy function for p from some kind of buffer error that is controlled by an attacker. This is where the overflow occurs. And then we run the free q and free p commands.. This is pretty simple code, right?

We have 2 pointers for which we have allocated memory, we use one of them for a particular function, a buffer overflow occurs, and we release the memory of both pointers.

Suppose that the memory lines of both objects p and q are located in the memory space next to each other. This can happen bad things, is not it? Because the strcpy function is used to copy the contents of str2 to str1 . The str2 argument must be a pointer to a string ending in zero, and strcpy returns a pointer to str1 . If str1 and str2 stringsoverlap, the behavior of the strcpy function is undefined.

Therefore, the strycpy function , which processes memory p , can also affect the memory allocated for q . And it can cause problems.

It is possible that you did something of the sort in your own code inadvertently when you used some kind of weird type of pointers. And everything seems to be working, but when you need to call the free function , this is what happens. And the attacker can take advantage of it, I will explain why this is happening.

Imagine that inside the implementation of the free and malloc functions, the selected block looks like this.

Let's assume that there are visible application data at the top of the block, and below we have the size of the variable. This size is not what the application directly sees, but a kind of “accounting” that is conducted by free or malloc , so that you know the size of the allocated memory buffer. Next to the selected block is a free block. Suppose that a free block has some metadata that looks like this: on top we have a block size, below there is free space, the pointer “back” is located even lower, and below it is a “forward” pointer. And at the very bottom of the block size is shown again.

Why do we have 2 pointers here? Because the memory allocation system in this case uses a doubly linked list to track how the free blocks are related to each other. Therefore, when you select a free block, you exclude it from this doubly linked list. And then, when you free it, you will do some arithmetic for the pointer and put these things in order. After that, you add it to this linked list, right?

Whenever you hear about pointer arithmetic, you should think that this is your canary. Because there will be a lot of problems. Let me remind you that we have a buffer overflow p . If we assume that p and qlocated next to each other, or very close to the memory space, then it may eventually happen that this buffer overflow can overwrite some size data for the selected pointer q - this is the bottom of our dedicated block. If you continue to follow my thought from the very beginning, your imagination will tell you where everything starts to go wrong. Indeed, in essence, what ultimately happens with these operations is free q and free p - they look at this metadata in a dedicated block in order to do all the necessary manipulations with the pointer.

That is, at some point in the execution, the free functions are going to get some pointer based on the size value: p = (size)and size is what the attacker controls because he overflowed the buffer, right?

He did a bunch of arithmetic calculations, looked at the back function and pointers of this block, and now he is going to do something like update the “back” pointer and the “forward” pointer — these are the two bottom lines.

But in reality it should not bother you. This is just an example of the code that takes place in this case. But the fact is, because of the size rewritten by the hacker, he now controls this pointer, which passes through the free function . And because of this, the two states present here in the bottom lines are actually updates of pointers. And since the attacker was able to control this pit actually controls these two pointers. This is where an attack can occur.

Therefore, when free works and trying to do something like a union of these two blocks, you have a double-linked list. Because if you have two blocks that collide with each other and both are free, you want to combine them into one big block.

But if we control the size, it means that we control the whole process of the four lines shown above. This means that if we understand how overflow works, then we can write data to memory in the way we choose. As I said, such things often happen with your own code, if you are not smart with a pointer. When you make some double-free errorfree q and free p or something else, your function fails. Because you messed up with the metadata that live in each of these highlighted blocks, and at some point this calculation will indicate a kind of “garbage” value, after which you will be “dead”. But if you are an attacker, you can choose this value and use it to your advantage.

Let's now move on to a different approach to prevent buffer overflow attacks. This approach is to check the boundaries. The purpose of checking boundaries is to make sure that when you use a particular pointer, it will only refer to what is a memory object. And this pointer is within the acceptable limits of this memory object. This is the main idea of ​​verification. It is actually quite simple - at a high level. Again, in the case of C, it is very difficult to understand such things. They say that this actually means: that the pointer is within or outside the boundaries, is it valid or invalid?

For example, suppose you have two parts of the code — here I’ll write them down. You declare an array of 1024-byte characters and declare a pointer, and then get the address of one of the elements in X: char X [1024] , char * y = & x [108].

Does it make sense? Is this a good idea? Hard to say. If we consider this X as a string here, maybe it makes sense to use such a pointer. You will be able to increase and decrease it, because you may be looking for some special meaning of your characteristics.
But if this is a network message or something like that, it is possible that in reality there is some kind of structure that is built in here. Because otherwise it makes no sense to use a pointer of this type. So the problem is that it allows you to do whatever you want. It's hard to determine what you really want him to do. As a result, defining things like the security of pointers in C is a smart task.

You can also imagine that life becomes even more complicated if you use the value type of struct and union union . Imagine you have a union. It will look like this: you have some integer , then some structinside of which there are several int values .

Keep in mind that the union works in such a way that they basically allocate the maximum size for the largest item. And at any moment you usually expect that either this integer will be valid or this struct , but not both at the same time.

Imagine that you have a code that does something like this: int x p: & (u, s, k) , that is, it creates a pointer to this address: the union u, the value type s, the value k.

Strictly speaking, this is an indication of boundaries, because there is a memory that has been allocated for this, but this is not wrong. Because in fact, when executed, this unionconsiders only integer , or only struct . Thus, as a result of the ambiguous semantics of pointers in programs written in C, such approaches to checking boundaries offer a rather weak notion of pointer correctness. And this concept is as follows.

If you have a pointer p ' , derived from the base pointer p , then this p' should be used only to store memory belonging to the original base pointer.

Know that this is a weaker target than forcing the pointer to fully correct semantics. Because you may still have strange problems, for example, with this union. It may be that at some particular point in the program something wrong happened with an indication of the correct value in the union , and, at a minimum, this pointer reference is not consistent. So this can be seen as an example of how the creation of this pointer violated the semantics of the network message embedded in X. But at least you only suppress the memory that belongs to you, not arbitrary memory. In the world of programs written in C, this is considered a success. So this is the main idea.

The problem with using these types of semantics is that in many cases you need the help of a compiler. Because you usually need to recompile programs to apply these semantics with respect to p and p 'This may be backward compatible. But this is the basic concept of checking boundaries.

How can I implement border checking? One of the easiest ways is called Electric fencing - electric fencing. It lies in the fact that for each object that you select in the heap, you select the enclosing page that is right next to it.

And you install protection on this page, so if someone tries to touch it, you will get a serious error. Hardcore rules will tell you that this is beyond the selected boundaries, and the program will immediately stop working. This is a very simple thing to do. The best thing about this solution is that whenever you get an invalid reference to memory, it immediately causes an error.

If you have ever debugged a basic C or C ++ program, then you ran into problems when you repeatedly corrupted memory without receiving any messages about it. And only when something collapsed in the program did you realize that you did something wrong. However, you could not determine where exactly you made a mistake. You simply did what is called "heisenbag" - a software error that disappears or changes its properties when you try to find it, that is, a thing that contains the concept of uncertainty. Examples include errors that appear in the final version of the program, but which are not visible when debugging the program.

So in this scheme it is good that as soon as the pointer gets on this page guard page - boom! Everything explodes and the program stops.

59:00 min


Course MIT "Security of computer systems." Lecture 2: "Control of hacker attacks", part 2

Full version of the course is available here .

Thank you for staying with us. Do you like our articles? Want to see more interesting materials? Support us by placing an order or recommending to friends, 30% discount for Habr's users on a unique analogue of the entry-level servers that we invented for you: The whole truth about VPS (KVM) E5-2650 v4 (6 Cores) 10GB DDR4 240GB SSD 1Gbps from $ 20 or how to share the server? (Options are available with RAID1 and RAID10, up to 24 cores and up to 40GB DDR4).

Dell R730xd 2 times cheaper? Only we have 2 x Intel Dodeca-Core Xeon E5-2650v4 128GB DDR4 6x480GB SSD 1Gbps 100 TV from $ 249 in the Netherlands and the USA! Read aboutHow to build the infrastructure of the building. class c using servers Dell R730xd E5-2650 v4 worth 9000 euros for a penny?

Also popular now: