Platform Security: Exercise 2

In this exercise, we will develop a basic run-time permission system. You will need to use an updated Vagrantfile again, so make sure to reprovision your virtual machine with vagrant up --provision.

You will build on your container system from the previous exercise, so make sure that you have this working in your virtual machine before you begin (remember that the mount point cannot be in a Virtualbox shared folder, so if you are using a fresh virtual machine then you may need to recreate the container somewhere outside of /home/vagrant/exercises).

Part 1: Unix Domain Sockets

Unix domain sockets provide a networking-like functionality, but on the local machine only. Rather than using IP addresses and ports, domain sockets can be addressed by name or by accessing a path in the filesystem. Moreover, you can send more than just data over a domain socket: you can open a file in one process, then send the file descriptor to another process, allowing access to be delegated.

In this part, you will create a Unix domain socket and use it to connect two processes to one another. Unix domain sockets are documented in the unix(7) manual page.

Unix sockets are used similarly to network sockets. A process can:

  1. Create a socket with socket(2)
  2. Bind it to an address with bind(2)
  3. Listen for packets with recvfrom(2), or connections with listen(2) and accept(2) (depending on what type parameter was passed to socket(2)).

We will start by creating a new program domain-listener based on the template domain-listener-template.c that will create a new Unix domain socket at a location, then repeatedly accept connections and print any received packets. Fill in the template with your own code where there are comments marked with [Add your code here].

Similarly, create a program domain-sender by filling in the template domain-sender-template.c that will connect to the Unix domain socket created by domain-listener, read lines of text from standard input, and send them through the socket.

In one terminal run:

$ musl-gcc -static -o domain-listener domain-listener.c
$ ./domain-listener domain.sock

And in another, run:

$ musl-gcc -static -o domain-sender domain-sender.c
$ ./domain-sender domain.sock

Type some lines of text into the domain-sender console: they should appear in the domain-listener console.

In the next part, we will use the same mechanism to communicate between applications in a container, and the container run-time that you built in the last exercise.

Part 2: Adding some middleware

In this part, you will add Unix domain socket support to the container system that you built last week, and use them to allow containerised applications to access files outside the container.

First, take a copy of the code used to build last week’s container-fs, and copy in the create_socket and accept_loop functions from domain-listener. You may also need to #include some additional header files. Then, after mounting container’s root filesystem, use create_socket to create a Unix domain socket inside the mount point at /run/container.sock (if you are comfortable with C string manipulation, then use malloc(3), strlen(3), and strncat(3) to assemble the path that you will pass to create_socket; otherwise, you can take a similar approach as you used last week to construct the mount option string piece by piece).

At this point, you could create the containerised process and immediately call accept_loop to wait for connections from the container. However, this poses a problem, as while waiting for connections, you cannot simultaneously wait for the containerised process to terminate using waitpid(2) as you did before. Instead, you can create a new process using fork(2) to process requests from the container.

After creating the containerised process using clone(2), call fork(2), and inspect the return value to determine whether you are in the old process or the new process. If you are in the old process, then continue as before. But if the return value of fork(2) indicates that you are in the new process, you can now safely call accept_loop.

To test this, add the domain-sender binary to your container from the last exercise (by copying it to the app directory), and then use it to send some messages to the container runtime (for presentation purposes, here < denotes output and > denotes input):

/ # ./domain-sender /run/container.sock 
> Hello!
< Hello!

You should now modify accept_loop to distinguish between different kinds of message. For this exercise, we will need two kinds of message:

Modify accept_loop to identify Print messages, by checking to see whether a received message begins with the character 'P'. If so, then print the rest of the message to the terminal as before. Check that this gives the expected results: sending a message using domain-sender will only result in text being printed by the runtime if it is prefixed with the character 'P'

You now have a way for containerised programs to make requests of the container runtime. Next, you will use the ability of Unix domain sockets to send file descriptors, in order to allow the containerised program to access files outside of its container. Your virtual machine includes a library libancillary that will help you do this. To use libancillary, add to the beginning of your programs the line

#include <ancillary.h>

and add the option -lancillary to the end of your command line when compiling your programs.

Next, modify accept_loop to identify Open messages, by checking to see whether a received message begins with the character ‘O’. If so, then interpret the rest of the message as the file to be opened, and try to open it in read/write mode using open(2), creating the file if it does not already exist. If this fails, then respond with a one-character string “-”, and if it succeeds, respond with a one-character string “-”, and then use the ancil_send_fd function to send the file descriptor to the application, before closing it again:

ancil_send_fd(connection_socket, opened_file);
close(opened_file);

Finally, you will modify your application fib from the first exercise to write its output to a file outside the container. Copy into fib.c the connect_socket function that you implemented for the domain-sender application. You will also need to copy some #include directives, as well as to add #include <ancillary.h>.

Check that fib.c still compiles:

$ musl-gcc -static -o fib fib.c -lancillary
$

Now, add a new function fopen_outside to fib.c based on the following template, and fill in the spaces marked [Add your code here]:

FILE* fopen_outside(const char* path, const char* mode) {
  int runtime_socket = connect_socket("/run/container.sock");
  if (runtime_socket < 0) {
    return NULL;
  }
  
  size_t command_len = 1+strlen(path);
  char* command = malloc(command_len + 1;
  // Fill command with the string "O<path to open>".
  // [Add your code here]
  
  // Use send(2) to send the command through runtime_socket.
  // [Add your code here]
  
  free(command);
  
  // Use recv(2) to get the response, and return NULL if the operation failed.
  // [Add your code here]
  
  int fd;
  // If the operation was successful, use ancil_recv_fd(runtime_socket, &fd)) to get the open file.
  // [Add your code here]
  
  // Convert the raw file descriptor to a FILE* object using fdopen(3), and return the result.
  // [Add your code here]
  
}

Then, replace your previous call to fopen with a call to fopen_outside, compile your new fib, and insert it into the container filesystem as e.g. /usr/bin/fib-outside. Run a shell in your container as in Exercise 2, and try using fib-outside to write the Fibonacci numbers to a file outside of the container.

Part 3: Run-time permissions

Now, applications inside a container can access files outside. But this defeats the isolation that the container provides; we shouldn’t let applications access just anything outside. One way to make this safe, is to allow files outside the container to be opened _only with user approval**, like in Android run-time permissions.

Important: Before attempting this part, run the following command so that the root user can run graphical applications:

$ xhost si:localuser:root

See https://wiki.archlinux.org/title/Running_GUI_applications_as_root for more details.

An easy way to ask for user approval is with the zenity tool, as follows:

$ zenity --question --text "Do you want to give access to /foo/bar/baz?"

This will pop up a dialog box with the text “Do you want to give access to /foo/bar/baz”, and “Yes” and “No” buttons. The command will return zero if the user clicked “Yes”, and one if the user clicked “No”.

Start by adding a new function based on the template below:

int ask_permission(const char* path) {

  int return_value = -1;

  // First, create the message that we will ask the user about
  size_t message_length = strlen(path) + strlen("Allow access to ''") + 1;
  char* message = malloc(message_length);
  snprintf(message, message_length, "Allow access to \"%s\"", path);
  
  
  // Now, we want to run the command "zenity --question --text <the message above>".
  // To do this, you need to create a new process using fork(2), and then use execlp(3)
  // to run zenity, and use waitpid(2) to get the result.
  
  pid_t pid = fork();
  if (pid < 0) {
    goto cleanup;
  }
  
  if (pid == 0) {
    // We are in the new process, so run zenity using execlp(3)
    // [add your code here]
  } else {
    // We are in the parent process, so wait for zenity to complete and get the return value.
    
    
    // Use waitpid(2) to wait for the child process (whose pid is stored in pid)
    // [add your code here]
    
    // Use the "wstatus" output from waitpid to make sure that
    //   - zenity terminated normally (i.e. it executed, rather than being killed)
    //   - the exit status of zenity was zero (i.e. the user clicked "yes")
    // If so, then return zero, otherwise return one
    // [add your code here]
  }
  
cleanup:
  free(message);
  return return_value;
}

Then, modify your previous code so that before processing an “open” command, the user is prompted using the new ask_permission function; only if they click “yes” should the file be opened, otherwise you should respond to the requesting process with a “-” failure message.