University of Minnesota
Development of Secure Software Systems
index.php

CSci 4271 Lab 1

Today's lab follows up on the recent lecture topics related to memory safety vulnerabilities and understanding low-level program behavior with a debugger.

For the in-person lab, we recommend that you work on this lab in groups of 2-3 students sitting near you in the lab, though this is not required.

A useful tool for the first question and the major focus of the other questions is the debugger GDB. This is a good time to review features of it you might already have been exposed to in 2021. If you'd like a refresher, the slides I used to introduce its key features in CSci 2021 are here. You can also find the whole GDB manual on the web, or use the help command while it's running.

Note that these questions are designed and the binaries are compiled for Linux x86-64, and our primary recommendation is that you complete the lab using the lab workstations running CSE-IT's version of Ubuntu Linux 20.04 (directly or via remote access). If you have your own Linux x86-64 machine, it may also work, but we haven't tested the lab across variations in OS versions. Other Unix-style x86-64 systems could also have a chance of working, though with more potential difficulties. Non x86-64 systems such as ARM-based Macintoshes will definitely not work with the provided binaries or the JIT compiler in part 3.

  1. (Standard buffer overflow.)

    For this question you'll experiment with a program with the same kind of classic stack buffer overflow problem we talked about in class on Thursday. For things to work out right we ask that you use a version that we have compiled, but you can also look at the source code if you'd like. The program forward-overflow.c has the same type of buffer overflow problem in a function named func that we had discussed in class. We suggest you copy the source code and the binary we've already compiled for you into your working directory:

    cp /web/classes/Spring-2023/csci4271/labs/01/forward-overflow{.c,} .
    

    (Notice the space and final dot at the end of that command, which represents that the current directory is the destination of the copy.) One thing we've added to this program is explicit checks to see whether the return addresses are getting overwritten (these are similar to a kind of defense the compiler can also add automatically, but which we've disabled). This means that rather than just segfaulting, you'll see an explanation of exactly what your attack achieved. As with the palindrome attack in class, your goal is to figure out how long of a string you need to provide (as a command-line input to the program) to overwrite a return address with a value of your own choosing. Since it's mostly the length that's important, you can use just normal printable characters on the command line. To be clear that you're seeing which part of your input is overwriting the return address, use the fact that 0x42 and 0x71 in hex are the same as B and q in ASCII to get the program to print a message that ends with:

    return address corrupted to 0x4271427142714271
    

    We have two recommendations about how you might figure this out. If you are doing OK on time we recommend that you try out both, since different techniques are easier in different circumstances.

    • Trial and error. Gradually make the program input longer until interesting things start happening. Try a few different lengths and use characters in your input that have different ASCII codes so you can see which ones (if any) are participating in the overwrite.
    • In the debugger. You can use GDB to print the addresses of things inside the program, and the subtract the addresses to figure out how far apart they are.

    Extra complexity: when using the trial and error approach, you might have noticed sometimes seeing messages about the return address of func getting corrupted, but not with the value from your input. How is that happening? It turns out this is related to something else about how the code manages the stack. You may be able to see what's happening if you step through the code instruction by instruction in the debugger. This kind of effect is also a useful capability for an attacker.

  2. (Debugging program internals.)

    One basic thing that a debugger is good for is examining data internal to a program. That's the way we'll ask you to use GDB for this question, which might remind you of the CSci 2021 "Bomb Lab" if you had that in your 2021.

    The program int-seq reads a sequence of integers from the standard input and stores them in a buffer. (The numbers are written as 8 hex digits each, and separated by spaces.) There is no limit to the length of the sequence, but the buffer that holds it is only declared to hold 4 integers, so there is clearly a buffer overflow problem. However, the program is expecting a particular sequence of numbers, and it stops reading if one of the numbers is not the one it is expecting. Your goal is to trigger the buffer overflow, but to do this you need to supply the right sequence of integers.

    We have given you the source code for the main program and a compiled binary, which you can copy like this:

    cp /web/classes/Spring-2023/csci4271/labs/01/int-seq{.c,} .
    

    However, the key to generating the sequence is a function named weird_func. We haven't given you the source code for this function, and it would be complicated to understand even if you had the source code. So rather than understanding this function, you should just let the program itself run the function, and look at the value that it produces. The program's error message doesn't print the value, but you can get at it by running the program under the debugger.

    For this compiled program we have left in the stack buffer overflow detection code that GCC produces by default, so you can know you have succeeded in your buffer overflow if you get the program to print the following message when the read_int_seq function tries to return:

    *** stack smashing detected ***: terminated
    

    Even though the buffer only holds 4 integers, you might be surprised how many integers you need to read before overwriting the return address (or you might not be surprised if you look at the stack frame layout). Here are a couple of hints that can make the process more efficient. GDB's command display will tell it to automatically print something every time the program stops. By using the debugger to modify the contents of variables, you can make the program continue running instead of stopping, so you don't have to rerun it as many times for experiments. (But of course you still want to find any input that will make the program crash without modifying it.)

  3. (Binary-level debugging.)

    GDB can be used as either a source-code level or a binary-code level debugger. Debugging at the source-code level is usually easier when it's possible, but if you're seeing security problems in someone else's binary that you don't have source code for, or for understanding the details of attacks, looking at execution at the binary-code level is sometimes also needed. For this question, we've written a program that computes Fibonacci numbers, but it does so in an unusual way. Instead of just computing the number directly, it creates and executes a machine-code program to do the computation. This approach is called just-in-time compilation, and a more complex version of it is how Java and JavaScript are usually implemented. For Fibonacci it's a bit overkill, but we've chosen it as an example because source-level debugging won't work for the generated code. You can copy and compile the code with commands like:

    cp /web/classes/Spring-2023/csci4271/labs/01/jit-fib.c .
    gcc -Wall -g jit-fib.c -o jit-fib
    

    This program mostly runs, but it has a bug that causes it to sometimes compute the wrong results. For instance fib(6) should be 8, but this program prints 7 instead. You task for this question is to use GDB to look at the generated code and what it's doing to find where the bug is in the program. You'll want to have GDB disassemble the machine code using the disassemble command (disas for short), but you'll need to supply the right range of addresses and do it at the right time after the code has been written. If you can't see what's wrong with the code, you can also step through how it's working instruction-by-instruction with stepi (si) and print the contents of the registers.

    Extra complexity 1: this program has to change the permissions on the memory region it uses for the generated code, because the memory permissions on Linux are usually set up with what's called a W xor X policy: some memory regions are writeable, and others are executable, but no region is writable and executable at the same time. W xor X is a security measure because regions that are simultaneously writable and executable can make it easier for an attacker to inject their own code. If we were more concerned about this security aspect for this program, how could we manage the permissions differently?

    Extra complexity 2: would it be possible to simplify the algorithm used by the generated code so it uses only two registers instead of three?