Script de déchiffrement Python

decrypt/decrypt.py

Prérequis

Après avoir compris le fonctionnement du ransomware grâce à la rétro-ingénierie, la phase de déchiffrement a pu commencer avec quelques prérequis:

# Clé de chiffrement
KEY = b"iment super !!!!"
# Vecteur d'initialisation, utilisé pour déchiffrer le premier block de 16 bytes de la chaine.
# Il est essentiel puisque le premier block ne peut pas bénéficier de l'opération xor (base du chainage AES CBC)
# avec le précédent block. Cette première opération est donc effectuée avec l'IV.
IV = b"la srs c'est vra"
# Les fichier qu'on va déchiffrer sont ceux dont l'extension est .srs
TARGET_EXTENSION = ".srs"

Dans un soucis de praticité, et d’adaptabilité, le programme interprète différents flags lors de son lancement:

Gestion des paramètres du programme

####################################################################################
#
#   Récupération des paramètres du programme
#
####################################################################################

parser = argparse.ArgumentParser()
parser.add_argument('-p', '--path', type=str, help='Path from which the decryption will start')
parser.add_argument('-r', '--recursive', action='store_true', help='Recurse over directories')
parser.add_argument('-v', '--verbose', action='store_true', help='Display execution flow')
args = parser.parse_args()

Que l’on peut consulter via la commande:

python decrypt.py -h

here

Ces différents flags sont accédés à différents endroit du code pour moduler le comportement du programme.

Gestionnaire de fichiers

Utilitaire de chemin

Afin de concentrer toutes les opérations sur les chemins telles que les ouvertures de fichiers, les fermetures, la normalisation du chemin sous forme de string en fonction de l’os sur lequel le programme est exécuté, bien qu’en pratique le ransomware n’opère que sur les os Windows.

# Classe pour la manipilation des path
class StandardPath:

    # En AES128, les blocks de bytes à déchiffrer doivent être d'une taille multiple
    # de 16, on ajoute donc du padding lorsque ce n'est pas le cas
    def padding_to_mod_16bytes(string):
        nb_byte_padding = 16 - (len(string) % 16)
        return string + b" " * (nb_byte_padding % 16) 

    # Créé un path qui est syntaxiquement correcte vis à vis de l'os sur lequel le script s'exécute
    def standard_path_compose(*argv) -> str:            
        return str(os.path.join(*argv))

    # Modifie un path afin qu'il correspondent aux exigences de l'os sur lequel le script s'exécute
    def standardize_path(string_path : str) -> str:
        return str(os.path.normpath(string_path))
        
    # Retourne l'extension d'un chemin de fichier
    def get_extension(string_path : str) -> str:
        return str(Path(string_path).suffix)
        
    # Retourne True si le chemin de fichier contient l'extension souhaitée
    def has_extension(string_path : str, extension : str) -> bool:
        return (Path(string_path).suffix == extension)
        
    # Enlève l'extension d'un fichier
    def remove_extension(string_path : str) -> str:
        return str(Path(string_path).with_suffix(""))
        
    # Vérifie que le chemin mène à un fichier
    def is_file(string_path : str) -> bool:
        return os.path.isfile(string_path)
        
    # Vérifie que le chemin mène à un dossier
    def is_dir(string_path : str) -> bool:
        return os.path.isdir(string_path)
    
    # Verifie l'existance d'un chemin
    def exists(string_path : str) -> bool:
        return os.path.exists(string_path)
        
    # Vérifie que le chemin mène au fichier existe et peut être accédé en lecture
    def is_file_readable(string_path : str) -> bool:
        return StandardPath.exists(string_path) and os.access(string_path, os.R_OK)
        
    # Vérifie que le chemin mène au fichier existe et peut être accédé en écriture
    def is_file_writable(string_path : str) -> bool:
        return StandardPath.exists(string_path) and os.access(string_path, os.W_OK)
        
    # Ouvre un fichier
    def open(string_path : str, options : FILE_ACCESS_OPTION):
        
        try:
            return open(string_path, options.value)
        except IOError:
            return None
        
    # Lis l'intégralité du contenu d'un fichier
    def read(f) -> str:
        try:
            return f.read()
        except IOError:
            return None
            
    # Lis l'intégralité du contenu d'un fichier et ajoute le padding nécessaire
    def read_padding(f, padding=16):
        try:
            content = f.read()
            return StandardPath.padding_to_mod_16bytes(content)
        except IOError:
            return None
        
    # Ecrit dans un fichier
    def write(f, content : str):
        try:
            return f.write(content)
        except IOError:
            return
        
    # Ferme un fichier
    def close(f):
        try:
            f.close()
        except IOError:
            return

A été ajouté à la classe StandardPath une Enum permettant de restreindre l’accès aux fichiers en lecture d’octets et en écritures d’octets afin de permettre le déchiffrement sur des chaines de bytes.

# Enum pour définir et limiter les possibles options d'accès aux fichiers
class FILE_ACCESS_OPTION(Enum):
    READ_B = "rb"   # read_bytes
    WRITE_B = "wb"  # write_bytes

# Classe pour la manipilation des path
class StandardPath:

    # [...]

    # Ouvre un fichier
    def open(string_path : str, options : FILE_ACCESS_OPTION):
        
        try:
            return open(string_path, options.value)
        except IOError:
            return None

La manipulation de fichier se fait donc à-présent de manière transparente vis à vis de l’os sur lequel le programme est lancé.

Parcours de l’arborescence de fichiers

Le parcours de fichiers se déroule de manière récursive à partir d’un chemin renseigné par l’utilisateur via les flags. Le path pouvant être une expression régulière, la bibliothèque glob peut être utilisée.
La fonction consiste simplement à récupérer tous les fichiers / dossiers du niveau de la branche sur laquelle le chemin se trouve. Si l’object trouvé est un fichier, on lance la phase de déchiffrement, si c’est un dossier, la fonction s’appelle récursivement (si le flags -r est actif).
Ainsi, toute l’arboressance dont la racine est string_path est parcourue:

####################################################################################
#
#   Parcours récursif de l'arborescence de fichiers
#
####################################################################################

# Fonction dont le but est de parcourir l'arborescence de fichiers récursivement
# à partir du premier chemin, renseigné dans PATH   
def file_handler(string_path : str):

    # On parcours tous les fichiers / dossiers à partir d'une regex
    for string_path in glob.iglob(string_path):
        if StandardPath.is_file(string_path):
            # Déchiffrement si l'objet manipulé est un fichier
            restore_file_encrypted(string_path)
        elif StandardPath.is_dir(string_path):
            # Récursion si l'objet manipulé est un dossier
            if vars(args).get("recursive"):
                file_handler(StandardPath.standard_path_compose(string_path, "*"))
    

Déchiffrement

Le coeur de fonctionnement du script est la fonction decrypt_block(). Celle-ci permet de déchiffrer une chaine d’octets à-partir de l’IV (Vecteur d’Initialisation) et de la clé. Cette fonction se contente donc juste de déchiffrer et ne se soucie en aucun cas du type de conteneur à déchiffrer:

# Déchiffre une chaine de bytes
def decrypt_block(key : bytes, iv : bytes, encrypted_data : bytes) -> bytes:

    # Créé un objet de chiffrement / Déchiffrement AES
    aes = AES.new(key, AES.MODE_CBC, iv)
    # Déchiffre une string
    decrypted_data = aes.decrypt(encrypted_data)
    
    return decrypted_data

Une autre fonction qui va limiter les accès à decrypt_block() est nécessaire. restore_file_encrypted() va servir de liant entre la fonction de parcours des fichiers file_handler() et decrypt_block(). Celle-ci va s’assurer que les fichiers convoités pour le déchiffrement portent bien l’extension .srs, et qu’il sont accessible en lecture.
Si tel est le cas, un fichier de sortie est créé, portant le même nom que le fichier chiffré, mais dépourvu de son extension .srs:

####################################################################################
#
#   Récupère les fichier chiffrés
#
####################################################################################

def restore_file_encrypted(string_path):
    # Vérifie que le fichier que l'on cherche à restaurer ait bien un extension en .srs, et qu'il soit accessible en lecture
    if not StandardPath.has_extension(string_path, TARGET_EXTENSION) or not StandardPath.is_file_readable(string_path):
        return

    # Le chemin du fichier dépourvu de l'extension .srs représente le chemin du fichier restauré
    string_output_path = StandardPath.remove_extension(string_path)

    # Ouverture des fichiers d'entrée et de sortie
    in_file = StandardPath.open(string_path, FILE_ACCESS_OPTION.READ_B)
    out_file = StandardPath.open(string_output_path, FILE_ACCESS_OPTION.WRITE_B)
    
    if in_file == None or out_file == None:
        return
    
    # Récupération du contenu d'entrée, auquel on ajoute le padding nécessaire pour que le nombre
    # de caractère soit un multiple de 16
    crypted_content = StandardPath.read_padding(in_file)
    
    # Récupération du contenu déchiffré et écriture de celui-ci dans le fichier de destination
    decrypted_content = decrypt_block(KEY, IV, crypted_content)
    StandardPath.write(out_file, decrypted_content)
    
    # Fermeture des fichiers
    StandardPath.close(out_file)
    StandardPath.close(in_file)

    if vars(args).get('verbose'):
        print(Fore.RED + string_path + Style.RESET_ALL + " restored as " + Fore.GREEN + string_output_path + Fore.RESET)

Environnement

Afin de pouvoir lancer le script sur un os Windows, quelques prérequis sur l’environnement sont à prévoir:

Installation de Python

Entrer dans la barre de recherche Windows “microsoft store” (1)
Sélectionner l’application “Microsoft Store” (2):

here

Entrer dans la barre de recherche Microsoft Store “python” (1)
Sélectionner Python en version 3.9 (2):

here

Cliquer sur “Obtenir” (1):

here

Cliquer sur “Non merci” (1):

here

Vérifier que Python est bien installé en tapant dans un invite de commandes la commande:

python --version

here

Python est alors installé en version 3.9.

Utilisation du script

Ouvrir maintenant son terminal favori. Ici ce sera Git bash**.
Installer le paquet d’environnement virtuel Python virtualenv:

pip install virtualenv

Si tout à bien fonctionné, un dossier venv a été créé:

here

Créer l’environnement virtuel avec la commande:

python -m venv venv
ls

here

Rejoindre alors l’environnement virtuel:

source venv/Scripts/activate

Une annotation “(venv)” devrait alors apparaitre sur votre prompt:

here

Installer les dépendances du script:

decrypt/requirements.txt

pip install -r requirements.txt

here

La commande:

python decrypt.py -h

permet de voir quels flags peuvent être interprétés, et leur fonction:

here

Voici l’architecture dans laquelle le programme va s’exécuter:

tree . /F

Commande exécutée depuis l’Invite de commandes Windows:

here

decrypt/*.srs

Lancer le programme:

python decrypt.py --recursive --verbose --path "./*"

La sortie de l’exécution nous montre quels fichiers ont été considérés comme chiffrés (rouge), et où est-ce qu’ils ont été déchiffrés (vert).

here

Voici l’architecture après l’exécution du programme:

tree . /F

Commande exécutée depuis l’Invite de commandes Windows:

here

Pour quitter l’environnement virtuel:

deactivate

here