University of Minnesota
Development of Secure Software Systems
index.php

CSci 4271 Lab 3

Today's lab follows up on the recent lecture topics related to memory safety attacks, with continued emphasis on understanding low-level program behavior, but now moving on to later stages of an attack.

GDB will again be a useful tool. The slides I used to introduce its key features are here. You can also find the whole GDB manual on the web, or use the help command while it's running.

  1. (Buffer overflow to shellcode.)

    Now that you've seen several examples of buffer overflows, it's time to put on your black hat and explore some techniques that attackers use to exploit them. To focus on the basic principles, today's lab is going to simplify some of the issues, by using a very simple buffer overflow and disabling defenses. You can copy the vulnerable program for this question, source and executable, into your working directory with this command:

    cp /web/classes/Spring-2024/csci4271/labs/03/overflow-from-file{.c,} .
    

    Doing your attacks against the binary we've already compiled for you helps keep things working predictably, but for your background knowledge here's the command we used to compile this binary:

    gcc -no-pie -z execstack -g -Wall -fno-stack-protector overflow-from-file.c -o overflow-from-file
    

    The GCC options -no-pie, -z execstack, and -fno-stack-protector are all disabling defensive mechanisms. -z execstack disables W xor X with respect to the stack, making the stack executable as well as writable. We'll talk more about the other mechanisms in future classes, but disabling them allows simpler versions of attacks to work. If you look at the source code for this program you'll see that the other simple thing about it is the vulnerability. The function read_and_print has a fixed-size buffer ri.buf, and it copies the entire contents of the file specified on the command line into that buffer with the read system call, with only an insufficient check on the length. However, the length check is enough to prevent overwrite the function's return address. So there's a different piece of control data you'll need to overwrite instead.

    There's one other defensive feature you should disable for the purposes of this lab, called ASLR or address-space layout randomization. Linux systems usually have this turned on by default, but you can disable it for certain programs if you want. The most convenient way to do that is to disable it for a shell, and then it will also be disabled for other programs started in that shell. For instance if you like the bash shell (it's the default for CSE Labs accounts), you can say:

    setarch -R bash
    

    Watch out that it can be confusing what level of shell you're in, between the new shell we're creating with the command above and the ones the shellcode might run. One way you can keep track of different levels of shell is by giving them different prompts. This is a little bit trickier to do that it might be because the normal startup script in /etc/bash.bashrc also usually changes the prompt, But for instance you could use one of the following as a substitute for the previous command:

    PS1="no-ASLR \w$ " setarch -R bash --norc
    PROMPT_COMMAND='PS1="no-ASLR \w$ "; PROMPT_COMMAND=' setarch -R bash
    

    You can check whether ASLR has been successfully disabled for the stack of newly-executed programs with the following command:

    for i in `seq 1 10`; do fgrep stack /proc/self/maps; done
    

    If ASLR is enabled, each program executed in the loop will have a different stack location. If ASLR is disabled, the stack locations will all be the same. Here's a sample of the kind of output that indicates ASLR is still enabled:

    7ffc52192000-7ffc521b3000 rw-p 00000000 00:00 0                   [stack]
    7fff00ba5000-7fff00bc6000 rw-p 00000000 00:00 0                   [stack]
    7ffc6aaab000-7ffc6aacc000 rw-p 00000000 00:00 0                   [stack]
    7ffe8498a000-7ffe849ab000 rw-p 00000000 00:00 0                   [stack]
    7ffc4c542000-7ffc4c563000 rw-p 00000000 00:00 0                   [stack]
    7ffd03197000-7ffd031b8000 rw-p 00000000 00:00 0                   [stack]
    7fff4f6f4000-7fff4f715000 rw-p 00000000 00:00 0                   [stack]
    7fffe2f15000-7fffe2f36000 rw-p 00000000 00:00 0                   [stack]
    7ffdeb7b7000-7ffdeb7d8000 rw-p 00000000 00:00 0                   [stack]
    7fffd8dd0000-7fffd8df1000 rw-p 00000000 00:00 0                   [stack]
    

    The differing values in the first two columns show ASLR in action for the stack; of course if you try the experiment yourself the actual values will be different. For comparison if you successfully disable ASLR the locations will always be the same:

    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                   [stack]
    

    Your goal for this problem is to carry out a complete attack including getting the vulnerable program to run shellcode and from that a shell or other program of your choosing. Since the program doesn't have any special privilege as you're testing it, this won't let you run any commands you couldn't otherwise, but you can imagine you're supplying an attack that an unwitting victim user would run. The program reads its input from a file, so the first part of the attack to understand is creating the contents of that file. You'll need some binary data for the attack, so this will also require working with a binary file which is a little different that editing a file that contains only regular text.

    There are interactive programs named hex editors that you can use to edit binary files; they're called hex editors because they usually default to printing each byte in hexadecimal. For instance the CSE Labs machines have a program named ghex installed that you could use for this purpose if you'd like, and a hex editor extension is also available for VS Code. It's possible to do all of the binary file manipulations in this lab with a hex editor.

    However, it can also be convenient to be able to script the operations to create binary files, and since it makes it easier to given unambiguous instructions, we will give you more detailed suggestions for a command-line approach. You can create the contents of binary files using shell commands, and then dump the contents of the files with the program hd to confirm that you made the file you wanted. There are also a variety of different shell commands you can use to write file contents; for instance the shell command printf formats data using most of the same conventions as the C function of the same name. But what these instructions will give commands for Perl and Python. Python needs no introduction (we'll presume a version 3 interpreter named python3; commands that say justpython would work with either Python 2 or Python 3, but you should change it to python3 for the lab machines). Perl is another scripting language that mixes together some shell and C-like features, and works well for short command-line programs.

    Before we start with an actual attack, let's try creating a benign (non-attack) file with a controlled length. The x86-64 stack mostly works in units is 8 bytes, so it's convenient to make input files with that size unit too. The vulnerable buffer in this program is 20 bytes long, so a 16-byte file won't be a security problem. Here's an example Perl command you can use to print 16 bytes of text, and two Python equivalents.

    perl -e 'print "A" x 8, "B" x 8'
    python3 -c 'print("A" * 8 + "B" * 8, end="")'
    python -c 'import os; os.write(1, b"A" * 8 + b"B" * 8)'
    

    The Perl code inside the single quotes is the argument to the -e option that gives a short script on the command line. You can probably guess what Perl's print operation does. The x operator is used to repeat a string, so "A" x 8 is equivalent to "AAAAAAAA". Obviously this will be a more important abbreviation if you want to use a larger repeat count. Perl's print, like C's printf, doesn't automatically put a newline at the end of the output. That may make the output of this command to overlap with your prompt when you print it on the screen, but it's what you usually want for binary data because a newline doesn't have a special meaning in a binary file: it's just a byte with the value 0x0a. (By contrast, Python's print does default to printing a newline, so you need to use either an optional keyword argument or a lower-level interface.) To pass this data to the program, we'll need to put it in a file instead, which you can do with the shell redirection operator like this:

    perl -e 'print "A" x 8, "B" x 8' >16.txt
    python -c 'import os; os.write(1, b"A" * 8 + b"B" * 8)' >16.txt
    

    Try running hd on the newly created 16.txt file to see that its contents look right. Remember that the ASCII codes for uppercase letters start at hex 0x41. You can also try giving it to the ./overflow-from-file program, but because the data fits in the buffer, nothing very exciting should happen. Now, repeat the process with some longer inputs, following the same pattern. For some longer inputs you should see that the program crashes, but crashing isn't proof of a code address getting replaced, because overwriting other parts of the program could also cause it to crash for other reasons. You can figure out how long the string needs to be to overwrite the control data in a similar way as in last two weeks' labs, including by trial and error or by comparing addresses in GDB. It's also more feasible for this program than in the past weeks to determine the offset needed for the attack just from looking at the source code.

    If you have gotten the program to crash, you can double-check that the problem is overwritten control data by running the crashing program under GDB. GDB will stop at the point where the program would have crashed. You should be able to see with x/i $rip that it is at an indirect call instruction, and p/x with the appropriate register should show the overwritten value it's about to try to jump to. For instance, the following excerpt from running GDB shows the case where the return address has been overwritten by the string "qBqBqBqB", which is 0x4271427142714271 in hex.

    $ gdb --args ./overflow-from-file overwrite-fp.txt
    (gdb) run
    Starting program: .../overflow-from-file overwrite-fp.txt
    Program received signal SIGSEGV, Segmentation fault.
    0x00000000004012ef in read_and_print (fd=3, size=48) at overflow-from-file.c:42
    42	    (ri.print_func)(ri.buf);
    (gdb) x/i $rip
    => 0x4012ef :	call   *%rdx
    (gdb) p/x $rdx
    $1 = 0x4271427142714271
    

    The next step for the attacker to take control is to write the shellcode that we want the victim program to execute. You can't just use a normal C compiler process because you only want the bytes for a few instructions, not a complete executable. But for many kinds of shellcode you can test them by wrapping them in a complete executable, and then just extract the bytes you want. For today's lab we've given you a sample of shellcode in assembly that you can copy and compile like this:

    cp /web/classes/Spring-2024/csci4271/labs/03/shellcode.S .
    gcc -nostdlib shellcode.S -o shellcode-test
    

    We've put comments in the assembly code to walk you through what the shellcode is doing in constructing the data needed by the execve system call. But you won't need to go too deep into that for today's lab. One other thing you might notice about the shellcode is that though it uses the number zero for various purposes, none of the instructions has an immediate zero value as an operand, because that would lead to an undesirable zero byte in the instruction encoding. You can run the shellcode-test program to see that it starts a shell as expected. But to get the instruction bytes we want, what you should do is to disassemble the binary:

    objdump -d shellcode-test
    

    The instruction bytes are the middle column of the output, starting with 31 c0. x86 has variable-length instructions, and one of the ways this code was optimized for length was by mostly using short instructions. (If you run hd on the executable, you would also see these hex bytes, starting at offset 0x1000, but with a lot of non-code metadata bytes before and after.)

    One way to put the instruction bytes into a Perl or Python program is to use the \x escape sequence inside a double-quoted string, which works the same way as the same escape in C. But if you have a lot of hex bytes, it's a bit more convenient to use Perl's pack function, which converts data into a binary format using a format string a little bit like printf. The pack format "C*" processes any number of inputs into unsigned 8-bit characters. So the following commands give the same output, as you can check with hd:

    perl -e 'print "\x31\xc0"' | hd
    perl -e 'print pack("C*", map(hex($_), qw(31 c0)))' | hd
    python -c 'import os; os.write(1, b"\x31\xc0")' | hd
    python2 -c 'import os; os.write(1, b"".join([chr(int(x,16)) for x in "31 c0".split()]))' | hd
    python3 -c 'import os; os.write(1, b"".join([bytes([int(x,16)]) for x in "31 c0".split()]))' | hd
    

    This example is on the borderline of whether being easier to do in a more manual way (e.g., cutting and pasting individual sequences of hex bytes from a command output as the input to another command), or doing things with more automation. But it is possible to remove the unneeded lines and columns from the objdump output and reformat the hex bytes into a single long line using common Unix shell text processing commands. You can see how the following pipeline works by adding commands one at a time:

    objdump -d shellcode-test | tail +8 | cut -c 11-32 | fmt -w 999
    

    Another kind of Perl packing that can save you a little bit of work is "Q", which makes a 64-bit integer or pointer value. Though if you already have it in hex, the only work it's really saving is reversing the order of the bytes to be little-endian:

    perl -e 'print "\x90\xef\xcd\xab\x78\x56\x34\x12"' | hd
    perl -e 'print pack("Q", 0x12345678abcdef90)' | hd
    python -c 'import os; os.write(1, b"\x90\xef\xcd\xab\x78\x56\x34\x12")' | hd
    python -c 'import os; import struct; os.write(1, struct.pack("Q", 0x12345678abcdef90))' | hd
    

    Our recommendation for where to put the shellcode in memory is in an environment variable, since that lets you also easily include a NOP sled without worrying about the size of data inside the program. For instance you can easily make your shellcode many thousands of bytes long if you'd like. The bash shell command to set an environment variable to the output of a program looks like:

    export SHELLCODE=$(...)
    

    Where SHELLCODE is the name of the environment variable, and the ... should be replaced with a command, like one of the perl commands we've been demonstrating above. (But without redirecting to a file or piping to hd, of course.)

    An alternative to some of the more complex commands above would be to make just the NOP sled part of your shellcode with a script, and then to cut and paste the code bytes on the end with a hex editor. We've verified that you can do this using ghex, for instance. But having scripted the commands is useful, for instance, if you want to experiment with different NOP sled sizes.

    After you're supplying the shellcode in an environment variable, you'll need to base the address of what you're overwriting the function pointer with on the location of the shellcode. The shellcode and other environment variables will be at slightly higher addresses than the local variables on the stack you may have already looked at in GDB. If you've got a big NOP sled, you may be able to just use a trial and error process, but you can also use the command p getenv("SHELLCODE") when running under GDB to tell you the location of an environment variable on that execution. The layout of the stack differs a little bit inside versus outside of GDB, but it's usually a small variation that can be handled by the NOP sled. Also note that to be able to handle small variations in both directions, you should target your jump at the middle of the NOP sled.

    In general, the larger your NOP sled is, the less precisely you need to target the jump; but the more precisely you target the jump, the smaller NOP sled you'll need. In addition to making up for the fact the environment variable layout will be different outside GDB, a large NOP sled can compensate for the uncertainly in the ordering of environment variables. To make things as easy as possible, we'd recommend you choose a NOP sled size in the range 10,000 and 100,000 bytes. A typical value for the size of all the other environment variables is 6,000 bytes, so being larger than that means the relative effect of other variables is limited. On the other end of the range, Linux limits individual environment variables to 131,072 bytes. If you're having trouble getting the location right or just want to see more detail about what's going on, we've written a program named env-size.c (in the lab directory) which will print information about the size and location of environment variables. You can run it either inside or outside of GDB.

    The paragraphs above should lay out the main pieces you need to put together to make your attack work. However there are a lot of little details that have to work correctly together, so you shouldn't be surprised if your attack doesn't work as expected the first time you try it. This is where GDB comes into the process again: use it to look at what it happening in various stages of execution from the normal execution of the code, to overwriting the function pointer, jumping to the shellcode, and executing through the shellcode.

    Extra complexity 1: try changing the shellcode to execute a different program. /bin/ls is a good first thing to try since it's the same number of characters. A program with a longer path (xcalc would also be traditional) may need a slightly larger change. Because of the way the program path is encoded, you'll also need to do some arithmetic, which can be done with GDB or Perl, among other possibilities.

    Extra complexity 2: change your attack so that instead of putting the shellcode in an environment variable, the shellcode goes in the same file that overwrites the buffer. You'll need to be more exact about the address of that buffer to make this work, since you have much less slack space.

  2. (Side effects of a buffer overflow.)

    The second part of the lab extends the same basic setup as the first part, but we've made the vulnerable program more complex in a way that makes an attack more difficult (and for variety, we've also gone back to a return address overwrite vulnerability). You can get a copy in the same way as before:

    cp /web/classes/Spring-2024/csci4271/labs/03/overflow-from-file-2{.c,} .
    

    If you look at what has been added to the read_and_print function, there is some additional checking code between the read that causes the overflow, and the end of the function. If you're not clear why this is a problem, try extending your attack from the previous part to work for this program. The shellcode can be exactly the same, but the stack layout of the read_and_print function is a bit different. Go ahead and do that now before reading the next paragraph.

    The problem is that the local variable b is stored in between the buffer and the return address in the stack frame. If you overwrite b with an arbitrary value, the checks that the program does on the value will fail, and the program will exit without ever getting to your overwritten return address. Adjust your attack so that works again. The easiest way to do this is to ``overwrite'' b with exactly the same value it was supposed to have in the first place. You may find it useful to use the program nm to find the address of a symbol in a binary. For instance this command:

    nm overflow-from-file-2 | fgrep read_and_print
    

    will find the address of the read_and_print function. Though it might be something else whose address is useful for your attack.

    Extra complexity: in a more complicated scenario, it might be hard for an attacker to predict the old value of b. Since b is a pointer, another choice would be to make it point to another area of your choosing, and then put data there that would pass the program's checks.