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
- Log into http://git.gccis.rit.edu using your RIT username and password. We are not using gitlab.com.
- Create a repository on the GCCIS GitLab, called
ipc-project
. It should be private. - Make your instructor and course assistants the
Reporter
role on the repository. - 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
), notz:\
- Open your repository folder in your favorite text editor.
- 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
- In our .gitignore, let’s ignore our built binaries, log, shell stuff, and python cache:
listener
output.log
.bash_history
*.pyc
- 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 thetest_*
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
- In
Makefile
, add the following below. Note that Makefiles need a tab character, not spaces.
compile:
gcc -o listener listener.c
-
In
secret.txt
, addHey! This is a secret!
-
In
litterbox/treasure.txt
addTreasure found!
-
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;
}
- 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_*
-
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.
-
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.
- Let’s make sure we can compile, by running
make
from within your container, and then running./listener
and you should seeHello, 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.
-
Starting with
listener.c
, make sure that yourHello, World!
program works. Run yourbash
shell as in the setup steps. Compile and run. -
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 yourmain
function, change yourprintf
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
-
Next, let’s practice using signals. Add a call to
pause()
afterprintf
, and rerun. -
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
andc
at the same time. (Mac users: this is usually actualcontrol
notcmd
). The program then exits. -
In most shells,
Ctrl+c
will send aSIGINT
or “interrupt” to the program. The GCC compiler by default adds a signal handler forSIGINT
. Most compilers do this, so many command line programs can be exited withCtrl+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);
- Compile. You’ll get an error. It’ll have a lot of info, but you’ll see this phrase:
undefined reference to `sigint_handler'
- 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 yourmain()
signature line:
void sigint_handler(int);
- 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.
- Compile and run. Your output will look something like this:
# ./listener
Listener PID: 142
^CSIGINT received!
- But what if we want to send a signal other than
SIGINT
? Shells don’t have a shortcut for everything. For that we usekill
. 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
- Now issue this comand:
kill -s INT 145
but replace145
with whatever your PID is. You should seeSIGINT Received
. And if you run another command, you’ll get a line showing that your process ended. Like this:
[1]+ Done ./listener
- EXERCISE. Add a second signal handler that will catch the
SIGUSR1
signal. This should printSIGUSR1 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!
- 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…)
- 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
- 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.
-
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 forconcatenate
, but it’s also a way to make every command line user a “cat person”. A very cat thing to do!). Try it out! -
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
- To demonsrate how pipes work, let’s use
stdin
andstdout
. 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.
- 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^^^^^^^^^^^^^^^^^^^^^^^^
- 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
-
Uing a similar approach to above:
read()
the entire contents of the file and print it out. I recommending using Python’swith
andopen
syntax. -
Wrap the above step in a
try
/except
. In the error handler, addprint(f"MEOW")
. -
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!
-
Great! Our code is as vulnerable as it should be. Uncomment the next two
.gitlab-ci.yml
lines that start withtest_cat
-
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
- Hopefully they passed on the CI as well! Don’t move on until the following are true:
.gitlab-ci.yml
has all buttest_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
-
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 makingcat.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 uptest_full_dd.sh
and paste in thetest_cat_exploit.sh
. -
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 theexpected=
andif
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.
- 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
- 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 thelitterbox
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
- 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.
-
Some of your numbers might be different, but make sure the permissions match.
-
And finally, instead of running
python cat.py
as root, let’s change our run to run asgarfield
.
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.
- 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
- 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