Lab 2: Return-to-libc Attack

Adapted from the SEED Labs Return-to-libc Attack Lab (Ubuntu 20.04 edition) by Wenliang Du. See Attribution and License.

EnvironmentSEED Ubuntu 20.04 VM (setup) or your own Ubuntu VM — this lab is x86 (32-bit) and needs the multilib toolchain
Fileslab2.tar.gz — the SEED Labsetup sources (retlib.c, exploit.py, Makefile), repackaged for this course
SubmissionOne PDF, structured as described in Submitting a Lab Report

Overview

The classic way to exploit a buffer overflow is to inject a shellcode into the program’s stack and then overwrite a return address so that control jumps into that shellcode. To stop this, modern systems mark the stack non-executable: the CPU refuses to run instructions that live on the stack, so the injected shellcode never executes.

This defense is not fool-proof. In a return-to-libc attack you never execute your own code at all — instead you redirect the vulnerable program into code that is already in its address space and already executable: the C library (libc), which every program links against. If you can make the return address point at system() and arrange for its argument to be the string "/bin/sh", the program politely spawns a shell for you, entirely with legitimate, executable library code. The non-executable stack never enters the picture.

In this lab you are given a root-owned Set-UID program with a buffer-overflow vulnerability. Your task is to build a return-to-libc attack that bypasses the non-executable stack and gives you a root shell. You will then defeat the /bin/dash privilege-dropping countermeasure, and finally get a taste of Return-Oriented Programming (ROP) by chaining several returns together.

Topics covered:

  • Buffer-overflow vulnerabilities
  • Stack layout during a function call, and the non-executable stack
  • The return-to-libc technique
  • Chaining calls; an introduction to Return-Oriented Programming (ROP)

Background reading

  • Chapter 5 of Computer & Internet Security: A Hands-on Approach, 2nd edition, by Wenliang Du
  • man 3 system, man 3 exec, man 3 getenv, man 1 gdb
  • The Appendix: understanding the function-call mechanism at the end of this lab — read it before Task 3 if you are shaky on stack frames.

A note on architecture (read this first)

Return-to-libc on a 64-bit (x64) program is considerably harder than on a 32-bit (x86) program, so — like the SEED lab — we stay in 32-bit for the whole lab. Every gcc command uses the -m32 flag, which produces a 32-bit binary. This has two consequences:

  • You need the 32-bit toolchain and libraries by executing sudo apt install gcc gcc-multilib. The SEED Ubuntu 20.04 VM already has them.
  • The attack depends on running actual x86 code. On an Apple Silicon Mac an ARM VM cannot execute the 32-bit x86 binaries this lab produces, contact the instructor on Piazza with your SSH public key and NetID for a department VM. Do not attempt this lab on an ARM VM.

Before you begin

Snapshot your VM. This lab relinks /bin/sh, disables kernel address randomization, and creates a root-owned Set-UID binary. A snapshot makes cleanup a two-minute rollback.

Download the lab source files and unpack them inside the VM:

mkdir -p ~/cs487/lab2 && cd ~/cs487/lab2
wget https://xjtuwxg.github.io/computer-security-labs/labs/files/lab2.tar.gz
tar xzf lab2.tar.gz
cd Labsetup && ls

You should see retlib.c (the vulnerable program), exploit.py (a skeleton you fill in for Task 3), and Makefile (which compiles and installs the Set-UID binary with the right flags).


Setting up the environment

Ubuntu ships several defenses that make buffer-overflow attacks hard. To study the attack we first switch them off, one at a time. Understand what each one does — several of the deliverables ask you to reason about them.

1. Address Space Layout Randomization (ASLR)

ASLR randomizes the starting address of the stack, the heap, and shared libraries on every run, so you cannot guess where system() or your shell string lives. Turn it off for the whole VM:

$ sudo sysctl -w kernel.randomize_va_space=0

With this set to 0, the same binary loads libc at the same address every run, which is what makes Task 1’s gdb lookup meaningful.

2. StackGuard

gcc can insert a stack canary — a guard value placed between the local buffer and the saved return address. If a strcpy() overflow clobbers the return address, it clobbers the canary too, and the program aborts before returning. Disable it at compile time with -fno-stack-protector (already in the Makefile).

3. Non-executable stack

This is the very defense the attack is designed to bypass, so we keep it on. A program’s header declares whether it needs an executable stack; compiling with -z noexecstack marks the stack non-executable. The Makefile uses this flag on purpose — your exploit must work despite it.

4. The /bin/dash countermeasure

system() does not run your command directly; it runs /bin/sh -c <command>. In Ubuntu 20.04, /bin/sh is a symlink to /bin/dash, and dash drops privilege when it detects it is running in a Set-UID process — which would defeat our attack before it even starts. For Tasks 1–3 we sidestep this by pointing /bin/sh at zsh, which has no such countermeasure:

$ sudo ln -sf /bin/zsh /bin/sh

We will put /bin/sh back to dash and defeat the countermeasure properly in Task 4.

The vulnerable program

Here is retlib.c. It reads up to 1000 bytes from a file called badfile and hands them to bof(), which copies them into a much smaller buffer with strcpy() — the overflow. The program prints the addresses of buffer[] and the frame pointer to make your life easier. It is a root-owned Set-UID program, so a successful overflow yields a root shell.

// retlib.c
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#ifndef BUF_SIZE
#define BUF_SIZE 12
#endif

int bof(char *str)
{
    char buffer[BUF_SIZE];
    unsigned int *framep;

    // Copy ebp into framep
    asm("movl %%ebp, %0" : "=r" (framep));

    /* print out information for experiment purpose */
    printf("Address of buffer[] inside bof():  0x%.8x\n", (unsigned)buffer);
    printf("Frame Pointer value inside bof():  0x%.8x\n", (unsigned)framep);

    strcpy(buffer, str);   //  <-- buffer overflow!

    return 1;
}

// This function is used only in the optional Task 5.
void foo(){
    static int i = 1;
    printf("Function foo() is invoked %d times\n", i++);
    return;
}

int main(int argc, char **argv)
{
   char input[1000];
   FILE *badfile;

   badfile = fopen("badfile", "r");
   int length = fread(input, sizeof(char), 1000, badfile);
   printf("Address of input[] inside main():  0x%x\n", (unsigned int) input);
   printf("Input size: %d\n", length);

   bof(input);

   printf("(^_^)(^_^) Returned Properly (^_^)(^_^)\n");
   return 1;
}

Compile and install

Build the Set-UID binary with the provided Makefile. It compiles for 32-bit with the countermeasures configured as above, then changes the owner to root and sets the Set-UID bit. Ownership must be changed before the Set-UID bit is set — changing ownership clears the bit — and the Makefile already orders these correctly:

$ make

The Makefile runs, in effect:

$ gcc -m32 -DBUF_SIZE=N -fno-stack-protector -z noexecstack -o retlib retlib.c
$ sudo chown root retlib
$ sudo chmod 4755 retlib
$ ls -l retlib          # confirm the 's' bit and root ownership

N is the buffer size, defined by the N = line in the Makefile. Your instructor may give you a specific value of N to use (it can be anything from 10 to 800); changing it changes the stack layout, so use the value you are told and set it in the Makefile before building. Without -DBUF_SIZE, the default from the source is 12.

Why the instructor picks N. A different buffer size shifts every offset in your exploit, so a solution built for one N will not work for another. This is deliberate: it makes last year’s badfile — and the ones posted online — useless, and forces you to derive the offsets yourself.


Task 1: Finding the Addresses of libc Functions

Because ASLR is off, libc loads at the same address every time you run retlib, so you can read the addresses of system() and exit() straight out of the program under gdb. You need exit() as well as system() — Task 3 explains why.

Two things matter here:

  • Debug the Set-UID binary itself, not a private copy you recompiled. A Set-UID and a non-Set-UID build of the same program may load libc at different addresses, so an address taken from the wrong binary will be wrong.
  • You must run the program at least once inside gdb before printing the addresses. libc is loaded lazily by the dynamic linker; until the program actually starts, the symbols are not yet resolved to real addresses.
$ touch badfile          # retlib opens badfile on startup; create an empty one
$ gdb -q retlib
pwndbg> break main
Breakpoint 1 at 0x...
pwndbg> run
...
Breakpoint 1, 0x... in main ()
pwndbg> p system
$1 = {<text variable, no debug info>} 0xf7e12420 <system>
pwndbg> p exit
$2 = {<text variable, no debug info>} 0xf7e04f80 <exit>
pwndbg> quit

(The addresses above are examples; yours will differ.) If you prefer, script it in batch mode:

$ cat > gdb_command.txt <<'EOF'
break main
run
p system
p exit
quit
EOF
$ gdb -q -batch -x gdb_command.txt ./retlib

Deliverable. Report the addresses of system() and exit() you obtained. Explain why you must run the program once inside gdb before the addresses are valid, and why debugging the Set-UID binary (rather than a recompiled copy) matters.

Understanding check. With ASLR off you get the same system() address on every run. What specifically would change if you re-enabled it with sudo sysctl -w kernel.randomize_va_space=2, and why does that break the attack you are about to build?


Task 2: Putting the Shell String in Memory

To call system("/bin/sh") you need the string "/bin/sh" sitting somewhere in the process’s memory, and you need to know its address so you can pass it as the argument. There are several ways to arrange this; we use an environment variable, because the shell copies exported variables into the memory of every program it launches.

Define a variable holding the string and confirm it reaches the child process:

$ export MYSHELL=/bin/sh
$ env | grep MYSHELL
MYSHELL=/bin/sh

Now find where in memory that string lands. Compile this helper and run it in the same terminal:

// prtenv.c
#include <stdio.h>
#include <stdlib.h>

void main(){
   char* shell = getenv("MYSHELL");
   if (shell)
      printf("%x\n", (unsigned int)shell);
}
$ gcc -m32 -o prtenv prtenv.c
$ ./prtenv
ffffd...

With ASLR off, prtenv prints the same address every time. Because retlib sees the same environment, MYSHELL sits at (very nearly) the same place when retlib runs — but the address depends on the length of the program’s name. The environment block sits at the very top of the stack, just above argv, which includes the program’s path; a longer or shorter name pushes everything below it up or down. That is why the helper is named prtenv — 6 characters, exactly matching retlib — so the address it reports matches the one retlib will see.

Note. Compile prtenv with -m32. retlib is a 32-bit binary; if prtenv is 64-bit the stack is laid out differently and the address will not match.

Deliverable. The address of MYSHELL reported by prtenv, and evidence that it is stable across runs. Explain, in terms of where the environment block lives on the stack, why the length of the program name shifts this address — and why prtenv therefore had to have exactly the same name length as retlib.

Understanding check. The /bin/sh you point system() at is inside the MYSHELL=/bin/sh string, not at the start of it. If you passed the address of the M instead of the /, what would system() try to run, and what would happen?


Task 3: Launching the Attack

You now have the three ingredients: the address of system(), the address of exit() (Task 1), and the address of the "/bin/sh" string (Task 2). The remaining job is to lay them out in badfile so that when bof() returns, the CPU “returns” into system() with "/bin/sh" as its argument.

How the stack must look

When bof() executes its ret, the CPU pops whatever is at the top of the stack and jumps there — that slot is the saved return address. A return-to-libc payload replaces that slot with the address of system(), and then lays out, just above it, exactly what system() expects to find after a normal call:

      higher addresses
   +------------------------+
   |  address of "/bin/sh"  |  <- argument to system()
   +------------------------+
   |  address of exit()     |  <- "return address" system() sees
   +------------------------+
   |  address of system()   |  <- overwrites bof()'s saved return address
   +------------------------+   <- where %esp points when bof() does `ret`
   |  saved %ebp (clobbered)|
   +------------------------+
   |      buffer[...]        |  <- strcpy() copies your badfile to here
      lower addresses

When bof returns, execution jumps to system(). system() sees the slot above it as its return address (that is the calling convention), and the slot above that as its first argument. So you place exit() where system() will return, and the address of "/bin/sh" where system() reads its argument. When the shell you spawn exits, system() returns — into exit() — and the program terminates cleanly instead of crashing.

Fill in the exploit

exploit.py (provided) writes the three addresses into badfile at offsets X, Y, and Z. Your job is to supply the three addresses and the three offsets:

#!/usr/bin/env python3
import sys

# Fill content with non-zero values
content = bytearray(0xaa for i in range(300))

X = 0
sh_addr = 0x00000000       # The address of "/bin/sh"
content[X:X+4] = (sh_addr).to_bytes(4,byteorder='little')

Y = 0
system_addr = 0x00000000   # The address of system()
content[Y:Y+4] = (system_addr).to_bytes(4,byteorder='little')

Z = 0
exit_addr = 0x00000000     # The address of exit()
content[Z:Z+4] = (exit_addr).to_bytes(4,byteorder='little')

# Save content to a file
with open("badfile", "wb") as f:
  f.write(content)

The offset Y is the distance, in bytes, from the start of buffer[] to the saved return-address slot. Read it off from the two addresses retlib prints: the frame pointer points at the saved %ebp, and the return address is 4 bytes above that. Once you know Y, the picture above fixes the other two: Z = Y + 4 and X = Y + 8.

$ ./exploit.py         # build badfile
$ ./retlib             # trigger the overflow
...
# id                   # <-- root shell!
uid=0(root) ...

A note on gdb and %ebp. If you use gdb to pin down Y, be aware that in Ubuntu 20.04, when you break at bof, gdb stops before the prologue sets %ebp to bof’s frame — so printing $ebp there gives you the caller’s %ebp. Step forward with next a few instructions until the prologue has run, then read $ebp. (The SEED book’s 16.04 walkthrough omits this step.)

Deliverable. Your values of X, Y, and Z with the reasoning for each (show how you derived Y from the printed frame pointer, or, if you brute-forced it, show the trials). Include the working exploit.py and a screenshot of the resulting root shell with the output of id showing uid=0(root).

Attack variation 1: Is exit() really necessary?

Set exit_addr to a junk value (or omit it) and run the attack again. Report what happens when you exit the spawned shell, and explain why. Does the missing exit() stop you from getting the shell, or only affect what happens afterward?

Attack variation 2: Does the program’s name matter?

Rename retlib to something of a different length — for example newretlib — and rerun the attack *without changing badfile:

$ cp retlib newretlib      # keep the Set-UID copy; or re-make under the new name
$ ./newretlib

Report whether it still works. If it fails, explain why in terms of what you learned in Task 2.

Deliverable. For each variation: the change you made, what you observed, and the explanation. Variation 1 should discuss what system() returns into; variation 2 should connect back to the address of the environment string.

Understanding check. In this attack you never placed a single executable instruction on the stack, yet the stack is where your badfile was copied. Explain why the non-executable stack defense does nothing to stop you here.


Task 4 (Optional, 1 bonus point): Defeating the Shell’s Countermeasure

For Tasks 1-3 you pointed /bin/sh at zsh to sidestep the dash privilege drop. Now put it back to the real default and defeat the countermeasure properly:

$ sudo ln -sf /bin/dash /bin/sh

If you re-run your Task 3 attack now, system() invokes /bin/sh → dash, dash notices it is running Set-UID and resets its effective UID to your real UID — so you get a shell, but not a root shell. Confirm this first; it is the behavior you are about to defeat.

The idea

dash (and bash) drop privilege unless they are started with the -p flag. system() never passes -p, so going through system() always drops privilege. The way around it is to skip system() entirely and call a libc function that runs /bin/bash -p directly. The exec family does exactly that — consider execv:

int execv(const char *pathname, char *const argv[]);

To run /bin/bash -p you must set up, in memory:

pathname  = address of the string "/bin/bash"
argv[0]   = address of "/bin/bash"
argv[1]   = address of "-p"
argv[2]   = NULL      (four bytes of zero)

and then return into execv with pathname as its first argument and the address of your argv[] array as its second.

You can get the addresses of the "/bin/bash" and "-p" strings the same way you got "/bin/sh" in Task 2 (export them as environment variables and locate them). The argv[] array — three pointers plus a terminating NULL — you build yourself inside your input.

The zero-bytes catch. argv[2] must be four zero bytes, but strcpy() stops at the first zero: anything you place after a 0x00000000 in badfile is not copied into bof’s buffer. The trick is that your entire input is also sitting in main()’s input[] buffer, copied there by fread() (which does not stop at zeros). main() prints the address of input[] for you, so build your argv[] array out in that buffer, where the terminating NULL is harmless, and point execv’s second argument at it.

Deliverable. First, evidence that the Task 3 attack yields a non-root shell once /bin/sh points back at dash (show id). Then your execv-based attack: how you laid out pathname, the argv[] array, and the three strings in memory; how you worked around the zero-byte problem; and a screenshot of the resulting root shell (id showing uid=0(root)). Explain in one paragraph why execv("/bin/bash", …) with -p keeps the privilege that system() loses.

Understanding check. The dash countermeasure drops privilege whenever it is launched Set-UID. You just showed it can be bypassed. Was the countermeasure useless, then — or did it raise the bar in some concrete way? State precisely what extra work it forced on the attacker.


Task 5: Return-Oriented Programming

Task 4 chained two ideas: run something other than system(). Generalize that to chaining many returns, and you have Return-Oriented Programming (ROP) — stitching together existing code fragments so that each one “returns” into the next.

You will do a small, self-contained case of this. retlib.c contains a function foo() that the program never calls. Craft your input so that when bof() returns, the program invokes foo() 10 times in a row, and only then hands you a root shell:

$ ./retlib
...
Function foo() is invoked 1 times
Function foo() is invoked 2 times
...
Function foo() is invoked 10 times
bash-5.0#            <-- root shell

How to think about it

In Task 3 you set up the stack so that bof returned into system(), and system() returned into exit(). Do the same thing, but with foo as the link in the chain: arrange the stack so bof returns into foo, foo returns into another foo, and so on ten times; the tenth foo returns into your Task-4 execv payload to give you the root shell.

Because foo() takes no arguments, each link in the chain is just a return address stacked on the previous one — you do not have to leave room for arguments between them. That is exactly what makes this case tractable by hand.

Deliverable. Your input construction (annotated: which four-byte slot holds what), and a screenshot showing foo() invoked ten times followed by a root shell. Explain how each foo “returns” into the next.

Understanding check. This was easy only because foo() takes no arguments. If foo() took one int argument, chaining ten calls would be markedly harder. Explain what goes wrong: after a no-argument foo returns, where does %esp point, and why does an argument sitting on the stack break the next link of the chain? (This is the problem that general ROP, with its “gadgets” ending in ret, exists to solve.)


Appendix: Understanding the Function-Call Mechanism

If the offsets in Task 3 feel like guesswork, work through this first. Compile a trivial program to assembly and watch what a call actually does to the stack.

/* foobar.c */
#include <stdio.h>

void foo(int x)
{
  printf("Hello world: %d\n", x);
}

int main()
{
  foo(1);
  return 0;
}
$ gcc -m32 -S foobar.c && cat foobar.s

The interesting part is what surrounds the call foo in main and the prologue of foo. Reading it top to bottom, a call proceeds like this:

  1. The caller pushes the arguments. main pushes 1 (the argument to foo) onto the stack.
  2. call foo pushes the address of the instruction after the call — the return address — and jumps into foo.
  3. foo’s prologue runs push %ebp (save the caller’s frame pointer) then mov %esp, %ebp (make %ebp point at foo’s new frame). From now on foo addresses its locals and arguments relative to %ebp.
  4. sub $N, %esp reserves space for foo’s locals.

Returning unwinds this in reverse:

  1. leave is shorthand for mov %ebp, %esp (discard the locals) followed by pop %ebp (restore the caller’s frame pointer). After this, %esp points at the saved return address.
  2. ret pops that return address and jumps to it.

Two facts from this are the whole basis of the attack:

  • The saved return address sits immediately above the saved %ebp, at %ebp + 4. Overwrite that slot and you control where the function returns.
  • ret blindly trusts whatever is in that slot. It does not care whether the target is the real caller, system(), or foo() — which is precisely what lets you redirect execution into libc.

Map this back onto retlib: the frame pointer the program prints is bof’s saved %ebp; the return-address slot you overwrite is 4 bytes above it; and the distance from buffer[] to that slot is the offset Y you needed in Task 3.


Cleaning up

Undo the global changes this lab made to your VM:

$ sudo ln -sf /bin/dash /bin/sh              # if it is still pointing at zsh
$ sudo sysctl -w kernel.randomize_va_space=2 # re-enable ASLR
$ unset MYSHELL                              # Task 2 (and any bash/-p vars)
$ ls -l /bin/sh                              # confirm it points at dash again

A root-owned Set-UID binary sitting in your home directory is itself a vulnerability. Delete the retlib binaries you built (including any renamed copies from Task 3, variation 2), or restore your pre-lab snapshot.

Submission

Submit a single PDF containing, for each task: the commands you ran, a screenshot or transcript of what you observed, and an explanation of why it happened. For the attack tasks, include your exploit.py / input construction and state clearly how you derived every address and offset. List the important code snippets followed by explanation — attaching code with no explanation will not receive credit.

See Submitting a Lab Report for the expected structure.