Inter-Process Communication


Overview

In this project, we’re going to learn about how multiple running programs can interact with each other, called inter-process communication or IPC. Note that “process” here means “running program”, not Scrum or the like. IPC comes in many forms, such as:

  • Sockets: anything over a network usually involves socket interaction. The advantage is that you can use them both locally or over a network, at a slight performance cost.
  • Clipboard: this is how we manually transfer data from one program to another in modern operating systems.

Since those are familiar, we won’t be covering them today. There are, however, lesser-known forms of IPC that we’ll be covering:

  • Signals. These are a feature of all modern OS’s that allow you to alert a process without sending any kind of payload.
  • Unnamed pipes (i.e. | and >) Also provided by all OS’s, this allow us to send data directly from one program to another, or directly to a file. They’re called “unnamed” because the pipes are temporarily created for just that command.
  • Spawning subprocesses. When your system boots, it starts with a single process. If we simply ran that program, that’s called embedded software development. Usually, however, we start up the operating system, which then takes that first process and forks itself into hundreds of other sub-processes. An oversimplified description of the term “operating system” is one giant IPC management ecosystem. Since the OS was originally booted into, it has ultimate control over what happens to our processes.

Part 0: Setup

  1. Log into http://git.gccis.rit.edu using your RIT username and password. We are not using gitlab.com.
  2. Create a repository on the GCCIS GitLab, called ipc-project. It should be private.
  3. Make your instructor and course assistants the Reporter role on the repository.
  4. Clone the reposistory locally using your favorite Git client. Note if you are in the SE labs, we recommend cloning to somewhere on c:\ (e.g. c:\yourusername\ipc-project), not z:\
  5. Open your repository folder in your favorite text editor.
  6. Create the following file and directory structure. All of these files will be empty to start with, and we’ll fill them in one-by-one.
ipc-project/
├── .gitignore
├── .gitlab-ci.yml
├── cat.py
├── listener.c
├── Makefile
├── secret.txt
├── test_cat_exploit.sh
├── test_cat.sh
├── test_full_dd.sh
├── test_listener.sh
├── litterbox/
│   ├── treasure.txt
  1. In our .gitignore, let’s ignore our built binaries, log, shell stuff, and python cache:
listener
output.log
.bash_history
*.pyc
  1. In our .gitlab-ci.yml, use this text. Note that our test cases are all commented out - this is so our initial job passes. As you work, you’ll be uncommenting the test_* files.
image:
  name: python:latest # don't change this
ipc-project:
  stage: test
  script:
    - make
    - chmod +x test_*
    # - ./test_cat.sh
    # - ./test_cat_exploit.sh
    # - ./test_listener.sh
    # - ./test_full_dd.sh
  1. In Makefile, add the following below. Note that Makefiles need a tab character, not spaces.
compile:
	gcc -o listener listener.c
  1. In secret.txt, add Hey! This is a secret!

  2. In litterbox/treasure.txt add Treasure found!

  3. In both listener.c, add the following:

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

int main(){
	printf("Hello, world!\n");
	return 0;
}
  1. Push your code to GitLab and make sure the pipeline runs and passes. Check the console output to make sure the end of it looks something like this:
$ make
gcc -o listener listener.c
$ chmod +x test_*
  1. For this environment, we’re going to be making use of Docker. See the File Permissions Activity for how to work with Docker. In this project, we’re using the Python standard container straight from Dockerhub.

  2. To start up a console inside our container, use this command:

docker run -it --rm --mount type=bind,source="$(pwd)",target=/root -w /root python:latest bash

(this will work on both Windows Powershell, Mac, and Linux)

Note: If you get an error something like this:

docker: Cannot connect to the Docker daemon at unix:///Users/andy/.docker/run/docker.sock. Is the docker daemon running?.

Make sure you’ve got Docker running.

  1. Let’s make sure we can compile, by running make from within your container, and then running ./listener and you should see Hello, World!

Part 1: Signal Listener

Our first task is to learn about operating system signals. These are a feature you interact with and even use every day without realizing it. They’re simple, as lightweight as you can get, and dead simple to implement.

Most languages and operating systems support OS signals of some sort. In this example, let’s get some practice with C.

  1. Starting with listener.c, make sure that your Hello, World! program works. Run your bash shell as in the setup steps. Compile and run.

  2. You may notice that we have some more #include statements above than your standard Hello, World program. This gives us access to the standard libary’s signals and processes API. In your main function, change your printf function to instead print the Process ID.

Hint: the function is called getpid(), and the format string for printing an integer is "%i".

Your output should look like this, though your PID will be different. You can even run this multiple times and see different PIDs.

Listener PID: 82
  1. Next, let’s practice using signals. Add a call to pause() after printf, and rerun.

  2. Notice how we’re not getting our prompt back. The program is set to wait until it receives a signal of any kind. Press Ctrl and c at the same time. (Mac users: this is usually actual control not cmd). The program then exits.

  3. In most shells, Ctrl+c will send a SIGINT or “interrupt” to the program. The GCC compiler by default adds a signal handler for SIGINT. Most compilers do this, so many command line programs can be exited with Ctrl+c. But maybe we want to do some extra cleanup to exit gracefully. In fact, you might even see ^C in your shell - that’s a bash shorthand for saying “someone sent a SIGINT here”

Let’s override this signal handler. Add this line to your main function, between printf and pause():

signal(SIGINT, sigint_handler);
  1. Compile. You’ll get an error. It’ll have a lot of info, but you’ll see this phrase:
undefined reference to `sigint_handler'
  1. Our sigint_handler is intended to be a callback, or a function that is executed when the signal is issued. Define such a function with this signature, placing it above your main() signature line:
void sigint_handler(int);
  1. Also, add the following implementation for this method:
void  sigint_handler(int _sig) {
  printf("SIGINT received!\n");
}

Note: you are welcome to format your curly braces as you see fit. We are ecumenical universalists in our source code formatting for this assignment.

  1. Compile and run. Your output will look something like this:
# ./listener
Listener PID: 142
^CSIGINT received!
  1. But what if we want to send a signal other than SIGINT? Shells don’t have a shortcut for everything. For that we use kill. The name is a little misleading because you’re not necessarily killing the process, but that is the most common use case. Let’s run our program in the background by adding a & to the command. Your output will look something like this:
root@82cc68385377:~# ./listener &
[1] 145
root@82cc68385377:~# Listener PID: 145
  1. Now issue this comand: kill -s INT 145 but replace 145 with whatever your PID is. You should see SIGINT Received. And if you run another command, you’ll get a line showing that your process ended. Like this:
[1]+  Done                    ./listener
  1. EXERCISE. Add a second signal handler that will catch the SIGUSR1 signal. This should print SIGUSR1 received!.

Tip: Make it call a separate function instead of reusing signt_handler.

Here’s what my full output looks like showing both:

root@82cc68385377:~# make
gcc -o listener listener.c
root@82cc68385377:~# ./listener &
[1] 170
root@82cc68385377:~# Listener PID: 170
kill -s USR1 170
SIGUSR1 received!
root@82cc68385377:~# ./listener &
[2] 171
[1]   Done                    ./listener
root@82cc68385377:~# Listener PID: 171
kill -s INT 171
SIGINT received!
  1. Exercise. Let’s make use of our process’ return code. Every program returns a single integer to the operating system to signal if it ended normally. A return code of 0 means “successful”, and a non-zero return code is an error.

Yes, it’s confusing. In C-ish expressions, 0/non-0 mean false/true respectively, but with return codes 0 means “success” and non-0 means “error”. I don’t make the rules, but this ubiquitous convention is here to stay.

For us, GitLab checks the return code of its last statement to determine if the build passed or not. Add a return code here that returns 1 if SIGINT was caught, and 0 if SIGUSR1 was caught. Important: our handlers are void, so we can’t pass our return value back. Instead, the way I did it was to define a return_code integer outside of main(), and set it in each of the handlers. (Just don’t tell anyone I used a global variable…)

  1. CI Test Case. To show your code is working, add this to the test_listener.sh file. Be sure modify .gitlab-ci.yml to make it run when you push to GitLab. We’ll be using a similar format for our bash-flavored “unit tests” in the next parts.
#!/bin/bash
set -x # echo on
set -e # fail script on any failed command

./listener > output.log & # fire off listener in the background
LISTENER_PID=$!
echo -e "Listener PID: $LISTENER_PID"

sleep 1 # give it a moment to start up

kill -s USR1 $LISTENER_PID

actual=`cat output.log`
expected="SIGUSR1 received!*"

if [[ $actual =~ $expected ]]; then
	echo -e "\033[92m PASSED! \033[0m"
	exit 0
else
	echo -e "\031[92m FAILED! \033[0m"
	echo "expected: $expected"
	echo "actual: $actual"
	exit 1
fi

Tip: you can also run this locally. The easiest way is to replace bash in your docker run command with bash test_listener.sh. This saves you from coding on the CI. Here it is in full:

docker run -it --rm --mount type=bind,source="$(pwd)",target=/root -w /root python:latest bash test_listener.sh
  1. Well done! Make sure your code works on the CI, and then tag it with part1. By “works on the CI”, make sure that:
  • You’ve uncommented ./test_listener.sh in .gitlab-ci.yml and pushed
  • The console looks as expected.
  • The job passes (i.e. has a successful return code)

Part 2: Pipes and Distrustful Decomposition

In this section, we’re switching to Python. We could do any programming language for this part, but we’ll try to keep the code concise and simple.

Specifically, let’s simulate a path traversal vulnerability. If you recall from VOTD and the CWE 35: if the input to your program refers to a filepath, you likely want to restrict where the filepath can be. Since there are many, many ways to specify a filepath, getting this logic correct is more complex than you might assume.

  1. We’re going to be making our own version of the cat program that’s in every Unix-like environment. As you see from our script above, cat will print out the contents of a file. (It’s short for concatenate, but it’s also a way to make every command line user a “cat person”. A very cat thing to do!). Try it out!

  2. Now let’s open up our cat.py file and make this its contents

# cat.py - a safe(?) version of cat!
# The linux command `cat` prints out the contents of a file,
# if you give it a file name to open.
#
# Our version will only open files in the directory `litterbox`
# (also, unlike cat, it takes the filename from stdin, not args)

# INPUT: from stdin, we will take in the file name and open it
# OUTPUT: to stdout, the contents of the file if it's in `litterbox/`,
#			         or MEOW if not
import sys
  1. To demonsrate how pipes work, let’s use stdin and stdout. These are called unnamed pipes… which is confusing because didn’t I just say the name?? When you use stdin and stdout, they create temporary pipes.

Make cat.py read from stdin using this:

filename = sys.stdin.read().strip()

This will read the entire input, wait until an EOF (end-of-file), then return the entire string.

Note: this line is not great in terms of resource exhaustion! There’s no limit here and it’ll all end up in memory.

Note: you can see the bad assumption I made by calling it filename not filepath - I assumed the user would only input the NAME of the file, but that string can include directories too.

  1. Next, let’s prepend our filename with our vulnerable code. As much as I cringe with this part, add this code, including the comment:
filename_in_litterbox_hopefully = f"./litterbox/{filename}"
#                  VULNERABLE HERE^^^^^^^^^^^^^^^^^^^^^^^^
  1. Because I can’t just leave vulnerable code without going against that tiny voice in my head, put this code in. Yes, it’s entirely a comment. Feel free to play with my (hopefully) correct implementation. For our purposes, though, let’s say we had this path traversal and didn’t know it.
# the secure way would be like this:
# (add import os to the top)
# abs_filename = os.path.abspath(f"./litterbox/{filename}")
# if abs_filename.startswith(os.path.abspath(f"./litterbox/")):
# 	filename_in_litterbox_hopefully = abs_filename
# else:
# 	raise Exception('Sandbox escaped!')
#
# But this is about Defense in Depth and WHAT IF we made the above mistake
  1. Uing a similar approach to above: read() the entire contents of the file and print it out. I recommending using Python’s with and open syntax.

  2. Wrap the above step in a try/except. In the error handler, add print(f"MEOW").

  3. Get this code working by using echo to send the filename you want to open. My output looks like this:

root@149429f80f41:~# echo no-such-file.txt | python cat.py
MEOW [Errno 2] No such file or directory: './litterbox/no-such-file.txt'
root@149429f80f41:~# echo "treasure.txt" | python cat.py
Treasure found!
root@149429f80f41:~# echo "../secret.txt" | python cat.py
Hey! This is a secret!
  1. Great! Our code is as vulnerable as it should be. Uncomment the next two .gitlab-ci.yml lines that start with test_cat

  2. Let’s add two test cases that do exactly what we did above and push them to the repository:

Put this in test_cat.sh

#!/usr/bin/bash
set -x # echo on
set -e # fail script on any failed command

actual=`echo "treasure.txt" | python cat.py`
expected="Treasure found!"

if [ "$actual" = "$expected" ]; then
	echo -e "\033[92m PASSED! \033[0m"
	exit 0
else
	echo -e "\031[92m FAILED! \033[0m"
	echo "expected: $expected"
	echo "actual: $actual"
	exit 1
fi

Put this in test_cat_exploit.sh:

#!/usr/bin/bash
set -x # echo on
set -e # fail script on any failed command

actual=`echo "../secret.txt" | python cat.py`
expected="Hey! This is a secret!"

if [ "$actual" = "$expected" ]; then
	echo -e "\033[92m PASSED! \033[0m"
	exit 0
else
	echo -e "\031[92m FAILED! \033[0m"
	echo "expected: $expected"
	echo "actual: $actual"
	exit 1
fi
  1. Hopefully they passed on the CI as well! Don’t move on until the following are true:
  • .gitlab-ci.yml has all but test_full_dd.sh uncommented
  • Your test files have been updated with the above shell code
  • GitLab’s console output looks like the output we had above
  • The job itself passed in GitLab
  1. Now let’s safeguard cat.py by using Distrustful Decomposition (DD). In short, DD is about running multiple processes at different permissions levels. In this example, we’ve already done the decomposition by making cat.py listen to a stdin pipe, and we can interact with it from another process (e.g. our shell, GitLab’s CI build system). Open up test_full_dd.sh and paste in the test_cat_exploit.sh.

  2. Before you run it, let’s change what we expect. Alter the test so that instead of Hey that was a secret! being expected, let’s expect that an error was thrown. This isn’t a shell scripting tutorial, so just replace the expected= and if lines with these:

expected=MEOW*

if [[ "$actual" =~ $expected ]]; then

Note: Yes, the [ ] turned into [[ ]] - this is to take advantage of bash’s ability to do “globbing”, and get a fuzzy match to a string. It’s what enables us to use =~ and MEOW* without the quotes. Be glad you didn’t have to write that.

  1. Now, push and run your code on the CI, or run it locally. It should fail now. My output looked like this:
root@149429f80f41:~# ./test_cat_exploit.sh
+ set -e
++ echo ../secret.txt
++ python cat.py
+ actual='Hey! This is a secret!'
+ expected='MEOW*'
+ [[ Hey! This is a secret! =~ MEOW* ]]
+ echo -e '\031[92m FAILED! \033[0m'
[92m FAILED!
+ echo 'expected: MEOW*'
expected: MEOW*
+ echo 'actual: Hey! This is a secret!'
actual: Hey! This is a secret!
+ exit 1
  1. Instead, let’s set up our test case to now use different permissions. Right now, everything runs as root - gross! Update your test case so that it creates a separate user, called garfield. This user will have next to no privileges in the system, other than access to the litterbox folder. Here’s that code:
# Create a new user who has no special permissions except access to the sandbox
# The "users" group is a default group with only access to /root/litterbox
adduser garfield --disabled-password --gecos ""
chgrp users ./litterbox
chown garfield ./litterbox
chmod g+rwx ./litterbox

ls -l --recursive
  1. Run your code again. It should still fail - we’re not actually doing anything with our new user just yet. But, we also added an ls command to check our work. My output looked like this:
root@149429f80f41:~# bash ./test_full_dd.sh
+ set -e
+ adduser garfield --disabled-password --gecos ''
Adding user `garfield' ...
Adding new group `garfield' (1000) ...
Adding new user `garfield' (1000) with group `garfield (1000)' ...
Creating home directory `/home/garfield' ...
Copying files from `/etc/skel' ...
Adding new user `garfield' to supplemental / extra groups `users' ...
Adding user `garfield' to group `users' ...
+ chgrp users ./litterbox
+ chown garfield ./litterbox
+ chmod g+rwx ./litterbox
++ pwd
+ WORKING_DIR=/root
+ ls -l --recursive
.:
total 264
-rw-r--r-- 1 root     root     67 Oct 22 14:34 Makefile
drwxr-xr-x 4 root     root    128 Oct 14 14:03 __pycache__
-rw-r--r-- 1 root     root   1082 Oct 22 15:05 cat.py
-rwxr-xr-x 1 root     root  70776 Oct 25 17:55 listener
-rw-r--r-- 1 root     root    653 Oct 26 13:14 listener.c
drwxrwxr-x 3 garfield users    96 Oct 22 14:05 litterbox
-rw-r--r-- 1 root     root     34 Oct 28 15:11 output.log
-rw-r--r-- 1 root     root     22 Oct 22 14:34 secret.txt
-rwxr-xr-x 1 root     root    335 Oct 22 14:45 test_cat.sh
-rwxr-xr-x 1 root     root    325 Oct 28 16:20 test_cat_exploit.sh
-rw-r--r-- 1 root     root    670 Oct 28 16:24 test_full_dd.sh
-rwxr-xr-x 1 root     root    499 Oct 22 15:15 test_listener.sh

./__pycache__:
total 8
-rw-r--r-- 1 root root 1302 Oct 14 13:21 test_cat.cpython-312.pyc
-rw-r--r-- 1 root root 1374 Oct 14 14:03 test_listener.cpython-312.pyc

./litterbox:
total 4
-rw-r--r-- 1 root root 15 Oct 22 14:06 treasure.txt

Note: if you are getting an error like this:

adduser: The user `garfield' already exists.

You’ve run your script multiple times within a single bash session. Instead, run the script as a docker run so that the environment starts clean every time.

  1. Some of your numbers might be different, but make sure the permissions match.

  2. And finally, instead of running python cat.py as root, let’s change our run to run as garfield.

WORKING_DIR=`pwd`
actual=`echo "../secret.txt" | su - garfield -c "python $WORKING_DIR/cat.py"`

Note: when we use su, the system changes directories to garfield’s home directory. This command works around that by making the directories absolute.

  1. Run test_full_dd.sh again (on the GitLab or locally). My entire output looks like this.
+ set -e
+ adduser garfield --disabled-password --gecos ''
Adding user `garfield' ...
Adding new group `garfield' (1000) ...
Adding new user `garfield' (1000) with group `garfield (1000)' ...
Creating home directory `/home/garfield' ...
Copying files from `/etc/skel' ...
Adding new user `garfield' to supplemental / extra groups `users' ...
Adding user `garfield' to group `users' ...
+ chgrp users ./litterbox
+ chown garfield ./litterbox
+ chmod g+rwx ./litterbox
+ ls -l --recursive
.:
total 188
-rw-r--r-- 1 root     root     67 Oct 22 14:34 Makefile
drwxr-xr-x 4 root     root    128 Oct 14 14:03 __pycache__
prw-r--r-- 1 root     root      0 Oct 22 16:28 can_w_string.pipe
-rw-r--r-- 1 root     root   1082 Oct 22 15:05 cat.py
-rwxr-xr-x 1 root     root  70432 Oct 25 13:11 hello
-rw-r--r-- 1 root     root    133 Oct 22 15:50 hello.c
-rwxr-xr-x 1 root     root  70776 Oct 25 17:55 listener
-rw-r--r-- 1 root     root    653 Oct 26 13:14 listener.c
drwxrwxr-x 3 garfield users    96 Oct 22 14:05 litterbox
-rw-r--r-- 1 root     root     34 Oct 28 15:11 output.log
-rw-r--r-- 1 root     root     53 Oct 28 16:27 repeater.py
-rw-r--r-- 1 root     root     22 Oct 22 14:34 secret.txt
-rw-r--r-- 1 root     root      0 Oct 22 16:26 talker.py
-rwxr-xr-x 1 root     root    335 Oct 22 14:45 test_cat.sh
-rwxr-xr-x 1 root     root    325 Oct 28 16:20 test_cat_exploit.sh
-rw-r--r-- 1 root     root    669 Oct 28 16:29 test_full_dd.sh
-rwxr-xr-x 1 root     root    499 Oct 22 15:15 test_listener.sh

./__pycache__:
total 8
-rw-r--r-- 1 root root 1302 Oct 14 13:21 test_cat.cpython-312.pyc
-rw-r--r-- 1 root root 1374 Oct 14 14:03 test_listener.cpython-312.pyc

./litterbox:
total 4
-rw-r--r-- 1 root root 15 Oct 22 14:06 treasure.txt
++ pwd
+ WORKING_DIR=/root
++ echo ../secret.txt
++ su - garfield -c 'python /root/cat.py'
+ actual='MEOW [Errno 2] No such file or directory: '\''./litterbox/../secret.txt'\'''
+ expected='MEOW*'
+ [[ MEOW [Errno 2] No such file or directory: './litterbox/../secret.txt' =~ MEOW* ]]
+ echo -e '\033[92m PASSED! \033[0m'
 PASSED!
+ exit 0
  1. You are done if:
  • All tests are uncommented in .gitlab-ci.py
  • The console output resembles the above output.
  • The GitLab job passes
  • Tag your submission on GitLab submission

Submission and Grading:

  • Make sure your repository is shared with both instructor and course assistant with “Reporter” permissions.

Point breakdown:

  • (5) Submission directions followed
  • (10) Part 1: passes on CI with the proper test case set up
  • (10) Part 2: passes on CI with the proper test case set up