Lab 1: Environment Variables and Set-UID Programs

Adapted from the SEED Labs Environment Variable and Set-UID Program Lab (Ubuntu 20.04 edition) by Wenliang Du. See Attribution and License.

EnvironmentSEED Ubuntu 20.04 VM (setup) or your own Ubuntu VM
Fileslab1.tar.gz — the SEED Labsetup sources, repackaged for this course
SubmissionOne PDF, structured as described in Submitting a Lab Report

Overview

Environment variables are a set of dynamic named values that can affect the way running processes behave on a computer. They have been part of Unix since 1979 and are used by essentially every operating system since. Although environment variables affect program behavior, how they achieve that is not well understood by many programmers — and a program that uses environment variables without understanding how they get there is often a vulnerable program.

In this lab you will study how environment variables work, how they propagate from a parent process to its children, and how they affect system and program behavior. The focus is on their effect on Set-UID programs, which are privileged, and therefore sit exactly on the boundary where attacker-controlled input meets elevated privilege.

Topics covered:

  • Environment variables
  • Set-UID programs
  • Securely invoking external programs
  • Capability leaking
  • The dynamic loader/linker

Background reading

  • man 7 environ, man 3 system, man 2 execve, man 2 setuid, man 8 ld.so

Before you begin

Snapshot your VM. Tasks 6 and 7 have you relink /bin/sh and create root-owned Set-UID binaries; a snapshot makes cleanup trivial.

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

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

You should see myprintenv.c, myenv.c, catall.c, and cap_leak.c — the starter files referenced by Tasks 2, 3, 8, and 9. The remaining programs in this lab you write yourself.


Task 1: Manipulating Environment Variables

In this task we look at the commands that set and unset environment variables. We are using Bash in the seed account. The default shell a user gets is set in the last field of that user’s entry in /etc/passwd; you could change it with chsh, but do not do that in this lab.

Do the following:

  • Use printenv or env to print out the environment variables. To look at one variable, use printenv PWD or env | grep PWD.
  • Use export and unset to set and unset environment variables. Note that these two are not separate programs — they are Bash built-ins, and you will not find them as executables anywhere on the filesystem.
$ printenv | head
$ printenv PWD
$ export CS487_LAB=1
$ printenv CS487_LAB
$ unset CS487_LAB
$ printenv CS487_LAB          # what happens now?
$ which export                # and why does this fail?

Deliverable. Show the transcript above. Explain why which export finds nothing even though export clearly works, and what that tells you about where the environment actually lives.


Task 2: Passing Environment Variables from Parent Process to Child Process

In Unix, fork() creates a new process by duplicating the calling process. The new process (the child) is an exact duplicate of the parent — but several things are not inherited by the child (see man fork). In this task we determine whether the parent’s environment variables are among the things that are inherited.

Step 1

Compile and run the following program and describe your observation. It is in the Labsetup folder; compile it with gcc myprintenv.c, which produces a.out. Run it and save the output to a file with a.out > file.

// myprintenv.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

extern char **environ;

void printenv()
{
  int i = 0;
  while (environ[i] != NULL) {
    printf("%s\n", environ[i]);
    i++;
  }
}

void main()
{
  pid_t childPid;
  switch(childPid = fork()) {
    case 0:  /* child process */
      printenv();          /* (1) */
      exit(0);
    default: /* parent process */
      //printenv();        /* (2) */
      exit(0);
  }
}

Step 2

Now comment out the printenv() call in the child case (line ①) and uncomment the one in the parent case (line ②). Compile and run again, and save the output to a different file. Describe your observation.

Step 3

Compare the two files with diff, and draw your conclusion.

$ gcc myprintenv.c -o printenv_child && ./printenv_child > child.txt
# ... edit the file to swap which printenv() is active ...
$ gcc myprintenv.c -o printenv_parent && ./printenv_parent > parent.txt
$ diff child.txt parent.txt

Deliverable. The diff output (or a statement that it is empty) plus your conclusion about whether fork() propagates the environment. State where the child’s environment came from — the kernel, the C library, or the parent’s memory.

Understanding check. fork() gives the child a copy of the parent’s memory. Is the child’s environ the same memory as the parent’s, or a copy? What happens in the parent if the child calls setenv()?


Task 3: Environment Variables and execve()

execve() loads a new program and executes it; it never returns. No new process is created — instead the calling process’s text, data, bss, and stack are overwritten by the program being loaded. Essentially, execve() runs the new program inside the calling process. What happens to the environment variables?

Step 1

Compile and run the following program, which simply executes /usr/bin/env — a program that prints the environment variables of the current process.

// myenv.c
#include <unistd.h>

extern char **environ;

int main()
{
  char *argv[2];

  argv[0] = "/usr/bin/env";
  argv[1] = NULL;
  execve("/usr/bin/env", argv, NULL);    /* (1) */

  return 0;
}

Step 2

Change the execve() invocation on line ① to the following, and describe your observation:

execve("/usr/bin/env", argv, environ);

Step 3

Draw your conclusion about how the new program gets its environment variables.

Deliverable. Both outputs, and a one-paragraph statement of the rule: who decides the environment of an execve()’d program?

Understanding check. In Task 2 the environment survived automatically; here you had to pass it explicitly. Reconcile these two observations. Then explain why execvp() and friends appear to preserve the environment anyway (hint: man 3 exec, and look for environ).


Task 4: Environment Variables and system()

system() is used to execute a command, but unlike execve(), which executes the command directly, system() actually executes /bin/sh -c command — it runs a shell and asks the shell to run the command.

If you look at the implementation of system(), you will see that it uses execl() to execute /bin/sh; execl() calls execve(), passing to it the environment variable array environ. Therefore, when you use system(), the calling process’s environment variables are passed to the new program /bin/sh. Compile and run the following program to verify this:

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

int main()
{
  system("/usr/bin/env");
  return 0;
}

Deliverable. Show that a variable you export in your shell appears in the output. Then explain the chain of custody: your shell → your program → /bin/sh → env. At which step could an attacker influence what env sees?


Task 5: Environment Variable and Set-UID Programs

Set-UID is an important security mechanism in Unix. When a Set-UID program runs, it assumes the owner’s privileges. If the program’s owner is root, then anyone who runs this program gains root’s privileges for the duration of its execution. This allows a program to do many useful things, but because it escalates privilege, it is risky.

The behavior of a Set-UID program is decided by its program logic, not by the user — but users can nonetheless influence that behavior through environment variables. To understand how, we first determine whether environment variables are inherited by a Set-UID program’s process from the user’s process.

Step 1

Write a program that prints out all the environment variables in the current process:

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

extern char **environ;

int main()
{
  int i = 0;
  while (environ[i] != NULL) {
    printf("%s\n", environ[i]);
    i++;
  }
}

Step 2

Compile the program, change its ownership to root, and make it a Set-UID program.

# Assume the program's name is foo
$ gcc foo.c -o foo
$ sudo chown root foo
$ sudo chmod 4755 foo
$ ls -l foo          # confirm the 's' bit

Step 3

In your shell (you need to be in a normal user account, not root), use export to set the following environment variables — they may already exist:

  • PATH
  • LD_LIBRARY_PATH
  • ANY_NAME (a variable you invent; pick whatever name you want)
$ export PATH=/home/seed/cs487:$PATH
$ export LD_LIBRARY_PATH=/home/seed/cs487
$ export CS487_CANARY=hello
$ ./foo | grep -E 'PATH|LD_LIBRARY_PATH|CS487_CANARY'

These environment variables are set in the user’s shell process. Now run the Set-UID program from Step 2 in your shell. After you type the name of the program, the shell forks a child process and uses the child process to run the program. Check whether all the environment variables you set in the shell process (parent) get into the Set-UID child process. Describe your observation. If there are surprises, describe them.

Deliverable. For each of the three variables, state whether it survived into the privileged process. At least one of them will behave differently from the others — identify which, and explain the mechanism responsible (the dynamic linker, not the kernel or the shell).


Task 6: The PATH Environment Variable and Set-UID Programs

Because of the shell program invoked, calling system() inside a Set-UID program is quite dangerous. The behavior of the shell can be affected by environment variables such as PATH, which is provided by the user, who may be malicious. By changing these variables, a malicious user can control the behavior of a Set-UID program.

In Bash you can prepend a directory to PATH like this:

$ export PATH=/home/seed:$PATH

The Set-UID program below is supposed to execute the /bin/ls command; however, the programmer used only the relative path for ls, rather than the absolute path:

int main()
{
  system("ls");
  return 0;
}

Compile the above program, change its owner to root, and make it a Set-UID program. Can you get this Set-UID program to run your own malicious code instead of /bin/ls? If you can, is your malicious code running with root privilege? Describe and explain your observations.

The /bin/sh countermeasure

Note. system(cmd) executes the /bin/sh program first, and then asks that shell to run cmd. In Ubuntu 20.04 (and several versions before it), /bin/sh is a symbolic link pointing to /bin/dash. dash has a countermeasure that prevents itself from being executed in a Set-UID process: if dash detects it is running in a Set-UID process, it immediately changes the effective user ID to the process’s real user ID, essentially dropping the privilege.

Since our victim program is a Set-UID program, the countermeasure in /bin/dash will prevent our attack. To see how the attack works without such a countermeasure, link /bin/sh to another shell that does not have it. The SEED VM ships with zsh:

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

Run the attack again with zsh in place, and compare. When you are done with this task, put /bin/sh back:

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

Deliverable. Show the attack under both shells. Report the output of id (or whoami) from inside your injected code in each case, and explain precisely what dash does differently. State whether the dash countermeasure fixes the vulnerability or merely blocks this exploit.

Understanding check. The dash countermeasure drops privilege. Name a Set-UID program for which that would be an unacceptable fix, and describe what the program should do instead.


Task 7: The LD_PRELOAD Environment Variable and Set-UID Programs

Several environment variables — LD_PRELOAD, LD_LIBRARY_PATH, and other LD_* variables — influence the behavior of the dynamic loader/linker, the part of the OS that loads shared libraries from disk and links them into an executable at run time.

In Linux, ld.so and ld-linux.so are the dynamic loader/linker. LD_LIBRARY_PATH is a colon-separated set of directories to search for libraries before the standard set of directories. LD_PRELOAD specifies a list of additional, user-specified shared libraries to be loaded before all others. In this task we study LD_PRELOAD.

Step 1

First, see how these variables influence the dynamic linker when running a normal program.

  1. Build a dynamic link library. Create the following program and name it mylib.c. It overrides the sleep() function in libc:

    #include <stdio.h>
    void sleep(int s)
    {
      /* If this is invoked by a privileged program,
         you can do damages here!  */
      printf("I am not sleeping!\n");
    }
    
  2. Compile it (in the -lc argument, the second character is a lowercase L):

    $ gcc -fPIC -g -c mylib.c
    $ gcc -shared -o libmylib.so.1.0.1 mylib.o -lc
    
  3. Set the LD_PRELOAD environment variable:

    $ export LD_PRELOAD=./libmylib.so.1.0.1
    
  4. Compile the following program myprog, in the same directory as the library:

    /* myprog.c */
    #include <unistd.h>
    int main()
    {
      sleep(1);
      return 0;
    }
    

Step 2

Run myprog under each of the following conditions, and observe what happens:

  1. Make myprog a regular program, and run it as a normal user.
  2. Make myprog a Set-UID root program, and run it as a normal user.
  3. Make myprog a Set-UID root program, export LD_PRELOAD again in the root account, and run it.
  4. Make myprog a Set-UID user1 program (i.e. the owner is user1, a different user account), export LD_PRELOAD again in a different user’s account (not root), and run it.

Creating the extra account for case 4:

$ sudo adduser user1
$ sudo chown user1 myprog && sudo chmod 4755 myprog

Step 3

You should observe different behaviors in the four scenarios above, even though you are running the same program. Figure out what causes the difference. Environment variables play a role here. Design an experiment to figure out the main causes, and explain why the behaviors in Step 2 are different.

Hint. The child process may not inherit the LD_* environment variables.

Deliverable. A four-row table: scenario, real UID, effective UID, whether the override took effect. Then state the dynamic linker’s rule in one sentence, and explain why case 3 differs from case 2 even though both run a Set-UID root binary.


Task 8: Invoking External Programs Using system() versus execve()

Although system() and execve() can both be used to run new programs, system() is quite dangerous if used in a privileged program such as a Set-UID program. We have already seen how PATH affects the behavior of system(), because the variable affects how the shell works. execve() does not have that problem, because it does not invoke a shell. Invoking a shell has another dangerous consequence, and this time it has nothing to do with environment variables.

The scenario

Bob works for an auditing agency and needs to investigate a company for a suspected fraud. For the investigation, Bob needs to be able to read all the files in the company’s Unix system; on the other hand, to protect the integrity of the system, Bob should not be able to modify any file.

To achieve this, Vince, the superuser, wrote a special set-root-uid program (below) and gave the executable permission to Bob. The program requires Bob to type a file name on the command line, and then it runs /bin/cat to display the specified file. Since the program runs as root, it can display any file Bob specifies. However, since the program has no write operations, Vince is very sure that Bob cannot use this special program to modify any file.

// catall.c
int main(int argc, char *argv[])
{
  char *v[3];
  char *command;

  if (argc < 2) {
    printf("Please type a file name.\n");
    return 1;
  }

  v[0] = "/bin/cat"; v[1] = argv[1]; v[2] = NULL;
  command = malloc(strlen(v[0]) + strlen(v[1]) + 2);
  sprintf(command, "%s %s", v[0], v[1]);

  // Use only one of the followings.
  system(command);
  // execve(v[0], v, NULL);

  return 0;
}

Step 1

Compile the above program, make it a root-owned Set-UID program. The program will use system() to invoke the command. If you were Bob, can you compromise the integrity of the system? For example, can you remove a file that is not writable to you?

$ gcc catall.c -o catall
$ sudo chown root catall && sudo chmod 4755 catall
$ touch /tmp/victim && sudo chown root /tmp/victim && sudo chmod 644 /tmp/victim
$ ./catall /tmp/victim          # the intended use
$ ./catall "???"                # your attack goes here

Step 2

Comment out the system(command) statement and uncomment the execve() statement; the program will now use execve() to invoke the command. Compile the program and make it a root-owned Set-UID program. Do your attacks in Step 1 still work? Describe and explain your observations.

Deliverable. The exact argument you passed to defeat the system() version, proof that the unwritable file was modified or removed, and the result of the same argument against the execve() version. Explain the difference in terms of who parses the string.

Understanding check. The bug here is not system() itself; it is that a string intended as data (a filename) is handed to something that treats it as code (a shell command line). Name two other places in systems programming where the same confusion occurs.


Task 9: Capability Leaking

To follow the Principle of Least Privilege, Set-UID programs often permanently relinquish their root privileges when those privileges are no longer needed. Sometimes the program also needs to hand its control over to the user; in that case root privileges must be revoked. The setuid() system call can be used to revoke privileges. According to the manual: “setuid() sets the effective user ID of the calling process. If the effective UID of the caller is root, the real UID and saved set-user-ID are also set.” Therefore, if a Set-UID program with effective UID 0 calls setuid(n), the process becomes a normal process, with all its UIDs set to n.

When revoking privilege, one of the common mistakes is capability leaking. The process may have gained some privileged capabilities while it was still privileged; when the privilege is downgraded, if the program does not clean up those capabilities, they may still be accessible by the now-unprivileged process. In other words, although the effective user ID of the process is no longer privileged, the process is still privileged because it possesses privileged capabilities.

Compile the following program, change its owner to root, and make it a Set-UID program. Run the program as a normal user. Can you exploit the capability leaking vulnerability in this program? The goal is to write to the /etc/zzz file as a normal user.

// cap_leak.c
void main()
{
  int fd;
  char *v[2];

  /* Assume that /etc/zzz is an important system file,
   * and it is owned by root with permission 0644.
   * Before running this program, you should create
   * the file /etc/zzz first. */
  fd = open("/etc/zzz", O_RDWR | O_APPEND);
  if (fd == -1) {
    printf("Cannot open /etc/zzz\n");
    exit(0);
  }

  // Print out the file descriptor value
  printf("fd is %d\n", fd);

  // Permanently disable the privilege by making the
  // effective uid the same as the real uid
  setuid(getuid());

  // Execute /bin/sh
  v[0] = "/bin/sh"; v[1] = 0;
  execve(v[0], v, 0);
}

Set up the target file first:

$ sudo touch /etc/zzz
$ sudo chmod 644 /etc/zzz
$ ls -l /etc/zzz

Deliverable. The commands you ran inside the spawned shell, the resulting contents of /etc/zzz, and ls -l /etc/zzz showing that you — an unprivileged user — did not own it and could not have written to it directly. Explain why the leaked descriptor still works after setuid(getuid()).

Understanding check. Two fixes are possible: close the descriptor before revoking privilege, or set the close-on-exec flag. Which one defends against more cases, and why? (See man fcntl, FD_CLOEXEC.)


Cleaning up

Undo the global changes this lab made to your VM:

$ sudo ln -sf /bin/dash /bin/sh     # if you relinked it in Task 6
$ sudo rm -f /etc/zzz               # if you created it in Task 9
$ unset LD_PRELOAD LD_LIBRARY_PATH  # Tasks 5 and 7
$ ls -l /bin/sh                     # confirm it points at dash again

Leaving a root-owned Set-UID binary lying around in your home directory is itself a vulnerability. Delete the ones you created, or restore your pre-lab snapshot.

Submission

Submit a single PDF containing, for each of the nine tasks: the commands you ran, a screenshot or transcript of what you observed, and an explanation of why that happened. List the important code snippets followed by explanation — simply attaching code without any explanation will not receive credit.

See Submitting a Lab Report for the expected structure.