Contexte

Le SOC de la chaîne de télévision TV Hacks a remarqué des paquets NTP étranges passant leur pare-feu dans le sillage de paquets légitimes. Ces messages sont à destination de l’équipement responsable de la génération des flux télévisuels pour la TNT.

Une analyse de la machine n’a pas permis de trouver quoi que ce soit de suspect à part un module noyau Linux qui semble servir à l’optimisation des flux IP à destination de notre diffuseur.

Ce module et un extrait de capture réseau vous sont fournis pour une première analyse.

L’équipement ne peut être arrêté sous aucun prétexte : cela signifierait un écran noir pour tous les téléspectateurs ! Si vous pouviez comprendre ce que fait l’attaquant, nous pourrons peut-être éviter un drame national.

Nous avons donc un module kernel ainsi qu’une capture réseau contenant 6 trames NTP.

vous pouvez retrouver les deux fichiers ici.

Découverte du module kernel

après avoir récupéré le module kernel, nous pouvons utiliser la commande modinfoafin de lister les informations le concernant.

╭─user@arch-vmware ~/shared/FCSC_2024/TV-HACKS-1
╰─➤  modinfo ipopt.ko                         127 ↵
filename:       /home/user/shared/FCSC_2024/TV-HACKS-1/ipopt.ko
description:    IP optimizer
author:         Shenzhen NetSoft Technology Co., Ltd.
license:        GPL
alias:          acpi*:PNP0700:*
alias:          pnp:dPNP0700*
depends:
vermagic:       3.2.0-4-amd64 SMP mod_unload modversions

le vermagic nous indique que ce module a été compilé pour un kernel 2.2.0-4-amd64.

Il est donc nécessaire d’avoir la bonne version de notre kernel afin de pouvoir le lancer.

J’ai pour ma part d’abord commencé par lire le code statique, mais ma solution ne fonctionnait pas. N’ayant pas confiance en mes capacitées de compréhension de code j’ai donc décidé d’émuler le binaire afin de vérifier mes incertitudes. J’ai néanmoins appris énormément de choses grâce à ça.

Emulation du binaire

Afin de pouvoir débugger le kernel de notre debian nous allons émuler notre VM à l’aide de qemu. Pour ça nous récupèrons un ISO de debian possèdant le kernel 2.2.0-4-amd64 et nous procédons à l’installation.

qemu-img create debian.img 20G
qemu-system-x86_64 -hda debian.img -cdrom debian-7.11.0-amd64-kde-CD-1.iso -boot d -m 512

nous faisons ensuite une installation classique de Debian et enfin nous pouvons lancer notre vm.

qemu-system-x86_64 -hda debian.img -m 512

j’ai également mis en place en network bridge afin de pouvoir envoyer des paquets NTP à ma VM (les paquets envoyés depuis la VM vers elle même à l’aide de scapy ne semblaient pas être intercepté par le module kernel.) Pour ça j’ai suivi ligne pour ligne cet article : https://www.spad.uk/posts/really-simple-network-bridging-with-qemu/

voici ma commande finale permettant de lancer ma VM

qemu-system-x86_64 -hda debian.img -m 1024 -s -net nic,model=virtio,macaddr=52:54:00:00:00:01 -net bridge,br=virbr0

j’utilise l’option -s afin de pouvoir debugger le kernel en remote.

Debuggage

on charge le module kernel

insmod ipopt.ko

on récupère son addresse en mémoire

cat /proc/modules | grep ipopt

puis dans gdb

target remote :1234
# placer un breakpoint dans le module kernel
b*module_base_addr + offset_from_disassembler

afin de pouvoir effectuer mes tests sur le module kernel j’ai également utilisé scapy de la manière suivante :

data = "donnees que je souhaite envoyer"
a = IP(dst="remote ip")/UDP(dport=123, sport=1337)/Raw(load=data)
send(a)

Fonctionnement du binaire

Analysons maintenant le code du module.

La fonction que j’ai renommer do_hmac_512() va effectuer un hmac_512 avec comme message la variable globale que j’ai renommée user_key et comme clé 32 octets contenus dans la variable globale unk_3310.

user_key contient 16 octets nul au début du programme.

Le résultat de celle ci est stocké dans une variable globale hmac_output que nous utiliserons plus tard.

Ensuite la fonction que j’ai appeler deobfuscate_function() va déobfusquer les éléments nécessaires à l’execution de commande bash. Nous ne nous y intéresseront pas.

Le binaire va ensuite mettre un place des hook netfilter. Ces hooks vont permettre d’intercepter les trames entrantes et sortantes sur la machine afin de pouvoir les examiner, les modifiers, etc.

la définition de cette fonction est la suivante :

int nf_register_hooks(struct nf_hook_ops *reg, unsigned int n);

Cette fonction prend en paramètre une liste de structure nf_hooks_op ainsi que le nombre d’éléments présents cette liste.

struct nf_hook_ops {
	struct list_head list;

	/* User fills in from here down. */
	nf_hookfn	*hook;
	struct module	*owner;
	void		*priv;
	u_int8_t	pf;
	unsigned int	hooknum;
	/* Hooks are ordered in ascending priority. */
	int		priority;
};

Nous allons nous intéresser au premier élément dde notre strutcure hookqui est en réalité un pointeur vers la une fonction à executer.

On récupère donc 4 hooks netfilter que nous allons renommer tout simple hook1, hook2, hook3, hook4.

Les deux premiers servent à filtrer le trafic entrant, les deux derniers servent à filtrer le trafic sortant sur la machine.

Analyse des paquets entrant

Les deux premiers hooks semblent faire la même chose pour des types de paquets différents, ils vont d’abord parser notre paquet, vérifier que celui ci est un paquet NTP (verifie que le protocole soit UDP ainsi que le port source soit 123), vérifier que le début des données NTP commencent par fCsC avant d’envoyer ces données à une autre fonction.

voici le code décompilé et pas très propre car je n’avais pas la force de recréer les structures dans IDA. Néanmoins j’ai laissé des commentaires sur les parties importantes de celui ci.

Nous arrivons maintenant sur la partie intéressante.

Si le paquet est de la forme souhaitée, le code va appeler deux fonctions et vérifier si celle-ci retournent 0.

La première fonction prend en paramètre :

  • un pointeur vers les données NTP du paquet
  • la taille de ces données - 16
  • une liste de 8 bytes
  • la taille de celle ci
  • un pointeur vers les 16 derniers bytes de nos données NTP
  • la taille de ces 16 derniers bytes

la seconde fonction va prendre en paramètre:

  • un pointeur vers nos données NTP à l’offset 20
  • la taille de notre paquet - 36
  • un pointeur vers une variable que nous verrons plus tard
  • un pointeur vers une liste de 8 bytes
  • la taille de celle ci
  • un pointeur vers les données contenu dans notre paquet NTP juste après le fCsC
  • la taille des données.

à partir de ces infos nous pouvons avoir une idée de la structure des paquets NTP envoyés.

magic number : 4 bytes (fCsC)
data1 : 16 bytes
data2 : n bytes
data3 : 16 bytes

Nous pouvons maintenant nous aventurer dans notre première fonction.

J’ai détaillé celle-ci en 5 points principaux :

  1. Le binaire va déobfusquer une chaine de caractère en mémoire correspondant à hmac(256) puis il va faire appel à la fonction crypto_alloc_shashavec comme argument notre chaine de caractère déchiffrée. Il va donc créer un “crypto handler” nous permettant par la suite d’hasher nos données.

Le binaire utilise une obfuscation très simple sur les chaines de caractère, celle ci consiste à prendre la liste d’octets obfusqué 2 par 2 et appliquer un xor entre ces deux octets. Cette opération est réalisée par la fonction sub_23C0.

v1 = [ 0xc1, 0xa9, 0xa6, 0xcb, 0xd5, 0xb4, 0x97, 0xf4, 0x6f, 0x47, 0xb8, 0xcb, 0x10, 0x78, 0xfd, 0x9c, 0x58, 0x6a, 0x98, 0xad, 0x13, 0x25, 0xe1, 0xc8, 0x77, 0x77 ]

for i in range(0,len(v1),2):
	print(chr(v1[i] ^ v1[i+1]), end="")
print()
# output : hmac(sha256)
  1. Il va ensuite faire appel à la fonction __________10() avec comme arguments les 8 bytes passé en paramètres, ainsi qu’un pointeur vers un buffer de 32 bytes. Nous reviendrons sur cette fonction (que j’aurai du renommer plus proprement) juste après. Garder en tête qu’elle génère la clé pour notre hmac 256.

  2. Nous spécifions la clé à utiliser à notre crypto handler.

  3. Nous effectuons un hmac256 de nos données NTP sans les 16 derniers bytes.

  4. Et enfin nous comparons le hash obtenu avec les 16 derniers bytes de nos données NTP.

Cette fonction fais donc une vérification d’intégritée sur les paquets NTP que le serveur reçoit.

Nous allons maintenant rapidemment détailler la fonction __________10()

Celle-ci effectue … roulement de tambours … un hmac 256 !

Elle prend comme clé notre variable globale hmac_output que nous avons définie au début de notre analyse et en message la valeur passé en paramètre de la fonction + 1 byte à 1. (j’ai simplifié le fonctionnement de la fonction car le reste n’est pas utilisé dans notre cas, néanmoins le fonctionnement de celle ci est légérement différent si l’output demandé en paramètre est différent supérieur à 32.)

ce qui nous donne en python

out = hmac.new(
    hmac_output,
    msg=p1 + (1).to_bytes(1, "little"),
    digestmod=hashlib.sha256
).digest()

Nous en avons maintenant fini avec cette première fonction. Nous pouvons maintenant nous attaquer à la deuxième fonction.

Pour rappel, les valeurs passées en paramètre à cette fonction sont nos données NTP à l’offset 20 (sans le magic byte et sans les 16 premiers bytes), la taille de ces données, un pointeur vers une chaine de 8 bytes, la taille de celle-ci, ainsi qu’un pointeur vers les 16 premiers octets de nos données et encore une fois leur taille.

J’ai encore une fois simplifié la fonction en 6 points principaux.

  1. Il vérifie que la taille des données est un multiple de 16.

  2. il va déobfusquer la chaine de caractère cbc(aes) et créer un crypto handler pour pouvoir déchiffrer de l’aes.

  3. il fait à nouveau appel à la fonction __________10() afin de lui générer une clé de 16 bytes avec les 8 bytes passé en argument.

  4. Il spécifie ensuite la clé à notre crypto handler.

  5. Il déchiffre enfin nos données passé en paramètre avec comme IV les 16 premiers octets de notre paquet NTP

  6. Il vérifie que le padding des données déchifrée est correct (il utilise le padding PKCS7)

Afin d’identifier les fonctions permettant de spécifier la clé AES et chiffrer les données j’ai rechercher sur internet comment était effectué les chiffrements AES cbc dans le kernel. Les paramètres passé en paramètres de mes fonctions concordaient à celles présentes sur internet et grâce au débugger que j’ai mis en place j’ai pu confirmer ma théorie en verifiant les valeurs en sortie de fonction.

Si nous récapitulons, nous avons des paquets de la forme suivante.

A ce moment là je pensais que le challenge etait terminé, plus qu’a déchiffrer les données présentes dans le pcap … que nenni ! C’est que le début !

Interprétation des données déchiffrée

Une fois nos données déchiffrées, l’algorithme va vérifier si le premier byte de celui est à 0.

s’il n’est pas nul, alors on va executer le code présent dans les données déchiffrée … seulement si la user_key n’est pas nul ! je n’avais pas fait gaffe à cette condition au début, c’était donc logique que je ne puisse pas déchiffrer les données.

si le premier byte est nul, alors les 16 prochains octets vont servir à définir une nouvelle clé stockée dans la variable user_key.

L’utilisateur a donc forcément défini une clé avant de pouvoir envoyer ses payloads.

Génération d’une nouvelle clé

la génération de la clé est très simple, il va d’abord appliquer 3 opérations ET binaire sur chaque octet de la clé envoyé dans la trame NTP avant d’utiliser celle-ci dans la fonction do_hmac_sha512() que nous avons déjà vu au début. Pour rappel, cette fonction stocke le hash créé dans la variable globale hmac_output.

Voici donc un schéma simplifié de notre chiffrement

Notre clé est utilisée deux fois, pour chiffrer mais également pour générer le checksum.

Casser le chiffrement

La vulnérabilité de ce chiffrement réside dans les 3 ET binaire appliqué sur la clé donnée par l’utilisateur.

En effet, l’opération & fonctionne de la manière suivante :

1 & 1 = 1
0 & 1 = 0
1 & 0 = 0
0 & 0 = 0

prenons un example :

Imaginons nous avons une clé sur 1 octet : 'A' -> 01000001

Nous avons donc 8 bits à bruteforce pour trouver la bonne clé.

Mais si l’on applique un & avec la valeur 'F' -> 01000110

Alors les octets 1, 4, 5, 6 et 8 seront forcément à 0 et nous aurons seulement les valeurs 2, 3 et 7 à bruteforce.

si l’on applique un & une fois notre clé est affaiblie, alors imaginez 3 fois !

Nous allons donc bruteforce notre clé de 16 octets, pour chaque clé nous allons calculer la valeur du checksum avec celle-ci et comparer avec le checksum présent dans la trame NTP. Si le checksum calculé avec notre clé est égal à celui présent dans la trame alors nous aurons trouvé la bonne clé de chiffrement.

Voici le script qui va nous permettre de casser la clé de chiffrement.

import binascii
import hmac
import hashlib
from Crypto.Cipher import AES


and_values = [0x2F, 0x4E, 0x2F, 0x66, 0xE4, 0x7F, 0x7A, 0x5E, 0xEB, 0xE5, 0xE7, 0x8C, 0xB2, 0x19, 0x1C, 0x36, 0xB7, 0xFB, 0x76, 0x0E, 0xAC, 0x28, 0x0C, 0xDE, 0xB7, 0xBF, 0x98, 0x69, 0x39, 0x7B, 0xFB, 0xFD, 0xE2, 0x76, 0xCB, 0xFE, 0x5D, 0xDF, 0x70, 0xC9, 0x8F, 0x54, 0x16, 0xAD, 0xFF, 0xF6, 0xB7, 0xCE]

hmac_key = [0x15, 0xba, 0x94, 0x08, 0x48, 0x6e, 0x0d, 0xa2, 0x6d, 0x67, 0xe9, 0x7b, 0xc5, 0x56, 0x2c, 0xd2, 0x2e, 0x58, 0xa7, 0x4d, 0x05, 0x32, 0xa2, 0x26, 0x21, 0x8a, 0x35, 0xfe, 0x35, 0x59, 0x99, 0x54]

# la trame NTP
paquet = binascii.unhexlify("6643734364bda3c196d86f172737afdc409cdb3d675995e6524e17b50f16b0de154404f0df3c0b5f62193b759965e7f6d76cb38ac475d9c91e16217376fd97b33e09228ee738ecdf0b7ee2c1001f42f8700c1125683750d77825417142069c0a849d5c7fd44e54e43b49365cd1569304667a4dc702a6155f84ad155ee5fa21623bce226fa3b3f1e18dbd2da64253d9e0e1054ee8")

# les 8 octets utilisé pour générer la clé du hmac ainsi que la clé AES
p1 = binascii.unhexlify("A612E61640B3218E")
p2 = binascii.unhexlify("192C98524983EAB4")

IV = paquet[4:4+16]
ciphertext = paquet[4+16:-16]
checksum = paquet[-16:]

partial_key = ""

# on récupère les & finaux afin de connaitre les bits qui seront à 0
for i in range(16):
	x = f"{and_values[i] & and_values[i+16] & and_values[i+32]:08b}"
	partial_key += x

# on compte le nombre de bits à bruteforce
n = partial_key.count("1")

values = [i for i in partial_key]

# on bruteforce jusqu'a ce que le checksum soit le même.
for i in range(0, 2**n):
	v = f"{i:021b}"
	partial_test = values.copy()
	
	k = 0
	for j in range(len(partial_test)):
		if partial_test[j] == "1":
			partial_test[j] = v[k]
			k += 1
	
	partial_key_test = int("".join(partial_test), 2).to_bytes(16, "big")

	key2 = hmac.new(
	    bytes(hmac_key),
	    msg=bytes(partial_key_test), 
	    digestmod=hashlib.sha256
	).digest()

	out = hmac.new(
	    key2,
	    msg=p1 + (1).to_bytes(1, "little"),
	    digestmod=hashlib.sha256
	).digest()


	final_key = hmac.new(
	    out,
	    msg=paquet[:-16],
	    digestmod=hashlib.sha256
	).digest()


	if final_key[:16] == checksum:
		break

# on calcule la clé de chiffrement AES
out = hmac.new(
    bytes(key2),
    msg=p2 + (1).to_bytes(1, "little"),
    digestmod=hashlib.sha256
).digest()

# on déchiffre les données.
decipher = AES.new(out[:16],AES.MODE_CBC, IV)
plaintext = decipher.decrypt(ciphertext)
print(plaintext)

Après quelques minutes on récupère une joli commande bash avec le flag présent à l’intérieur :)

╭─user@arch-vmware ~/shared/FCSC_2024/TV-HACKS-1
╰─➤  python3 bruteforce_bits.py                                                                       1 ↵
b'/bin/sh\x00-c\x00echo "FCSC{5d58e776e659866d110ac50dc2bce631e634222953a234893cb4978594ec0ae1}" > /root/flag1\x00\x00\x08\x08\x08\x08\x08\x08\x08\x08'