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.
| Environment | SEED Ubuntu 20.04 VM (setup) or your own Ubuntu VM — this lab is x86 (32-bit) and needs the multilib toolchain |
| Files | lab2.tar.gz — the SEED Labsetup sources (retlib.c, exploit.py, Makefile), repackaged for this course |
| Submission | One 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 oneNwill not work for another. This is deliberate: it makes last year’sbadfile— 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
libcat different addresses, so an address taken from the wrong binary will be wrong. - You must
runthe program at least once insidegdbbefore printing the addresses.libcis 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 withsudo 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
prtenvwith-m32.retlibis a 32-bit binary; ifprtenvis 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/shyou pointsystem()at is inside theMYSHELL=/bin/shstring, not at the start of it. If you passed the address of theMinstead of the/, what wouldsystem()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
badfilewas 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
dashcountermeasure 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. Iffoo()took oneintargument, chaining ten calls would be markedly harder. Explain what goes wrong: after a no-argumentfooreturns, where does%esppoint, 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 inret, 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:
- The caller pushes the arguments.
mainpushes1(the argument tofoo) onto the stack. call foopushes the address of the instruction after thecall— the return address — and jumps intofoo.foo’s prologue runspush %ebp(save the caller’s frame pointer) thenmov %esp, %ebp(make%ebppoint atfoo’s new frame). From now onfooaddresses its locals and arguments relative to%ebp.sub $N, %espreserves space forfoo’s locals.
Returning unwinds this in reverse:
leaveis shorthand formov %ebp, %esp(discard the locals) followed bypop %ebp(restore the caller’s frame pointer). After this,%esppoints at the saved return address.retpops 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. retblindly trusts whatever is in that slot. It does not care whether the target is the real caller,system(), orfoo()— which is precisely what lets you redirect execution intolibc.
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.