AUR compromis : Sécurisez votre Arch avec un script d’audit

Le dépôt d’utilisateurs d’Arch Linux (AUR) a récemment été secoué par une campagne de compromission d’envergure baptisée Atomic Arch. Au total, plus de 1 500 paquets fournis par la communauté ont été infectés par un logiciel malveillant visant à dérober des données sensibles et à établir une persistance furtive sur les systèmes affectés.

Pour contrer cette menace et vous assurer que votre machine est saine, nous allons analyser le fonctionnement de cette attaque et détailler un outil d’audit développé en Python (atomic-lockfile.py) conçu spécifiquement pour identifier les indicateurs de compromission (IoC).


1. Retour sur l’attaque “Atomic Arch”

L’incident, rapporté initialement par Phoronix, a débuté par l’injection de scripts malveillants dans des paquets AUR populaires ou abandonnés. Les attaquants ont exploité la confiance accordée par défaut à l’AUR pour distribuer des variantes de logiciels malveillants sous plusieurs signatures distinctes :

  • atomic-lockfile
  • js-digest
  • lockfile-js
  • nextfile-js

Une fois installé, le malware tente de dissimuler ses activités en utilisant des mécanismes avancés tels que des programmes eBPF (Extended Berkeley Packet Filter) pour intercepter des appels système et masquer des processus ou des fichiers. Il installe également des scripts de persistance via des services systemd exécutés au niveau système ou utilisateur, pointant souvent vers des dossiers temporaires ou lançant un binaire suspect nommé deps.


2. Un script d’audit ciblé : atomic-lockfile.py

Pour auditer votre système face à cette campagne, le script atomic-lockfile.py effectue une série de vérifications en profondeur nécessitant des privilèges root (pour interroger les interfaces eBPF et lire les répertoires système sécurisés).

Voici les principales étapes de détection implémentées dans ce script d’audit.

Étape 1 : Analyse des métadonnées de Pacman

Le script commence par inspecter les métadonnées locales de Pacman dans /var/lib/pacman/local/ pour y déceler la présence de paquets compromis déjà installés.

def check_pacman_logs():
    # ...
    suspect_keywords = ["atomic-lockfile", "js-digest", "lockfile-js", "nextfile-js"]
    for root, dirs, files in os.walk("/var/lib/pacman/local/"):
        for file in files:
            if file in ("desc", "files", "install"):
                # Analyse du contenu des fichiers de métadonnées
                # ...

Étape 2 : Inspection des caches d’assistants AUR

Les assistants AUR comme yay ou paru conservent des copies locales des fichiers de build. Le script recherche des traces des signatures dans les répertoires ~/.cache/yay/ et ~/.cache/paru/clone/ pour repérer si des PKGBUILD ou des scripts d’installation malveillants y ont transité.

Étape 3 : Audit des programmes et cartes eBPF

C’est l’un des aspects les plus critiques. Le malware utilise eBPF pour masquer sa présence. Le script utilise bpftool pour lister les programmes eBPF actifs et repérer des crochets (hooks) suspects sur des fonctions système comme getdents (utilisé pour lister les répertoires) ou sys_enter_write. Il vérifie également les cartes eBPF épinglées dans /sys/fs/bpf/ en dehors de celles légitimement créées par systemd.

def check_ebpf():
    # Utilisation de bpftool pour lister les programmes eBPF actifs
    result = subprocess.run(["bpftool", "prog", "list"], capture_output=True, text=True)
    # Recherche de mots-clés suspects : getdents, sys_enter_write, etc.

Étape 4 : Détection de persistance systemd

Le script parcourt les répertoires des services systemd (/etc/systemd/system et les dossiers utilisateurs sous /home/*/.config/systemd/user/) pour identifier des daemons suspects. Il cible spécifiquement les exécutables lancés depuis /tmp, /var/tmp ou /dev/shm, ainsi que toute exécution d’un binaire nommé deps.


3. Comment exécuter cet audit sur votre machine ?

Pour lancer l’audit sur votre système Arch Linux, vous devez exécuter le script avec les privilèges d’administrateur afin de permettre l’accès aux commandes eBPF et aux fichiers système :

sudo python3 atomic-lockfile.py

Le script affichera un rapport détaillé pour chaque étape de l’analyse, en marquant en rouge ([DANGER]) ou en jaune ([ATTENTION]) les éléments nécessitant une intervention immédiate.


4. Bonnes pratiques de sécurité avec l’AUR

Cet incident rappelle une règle fondamentale : l’AUR est un dépôt communautaire non officiel. Arch Linux ne garantit pas la sécurité des paquets qui y sont hébergés. Voici quelques réflexes essentiels à adopter :

  1. Inspectez systématiquement les PKGBUILDs : Avant d’installer ou de mettre à jour un paquet avec yay ou paru, lisez attentivement le fichier PKGBUILD et les scripts .install associés. Recherchez des URLs suspectes ou des commandes de téléchargement obscures.
  2. Surveillez les paquets orphelins : Les paquets n’ayant plus de mainteneur officiel sont des cibles de choix pour les attaquants qui en reprennent la maintenance pour y injecter du code malveillant.
  3. Limitez l’usage de l’AUR : Privilégiez autant que possible les paquets des dépôts officiels (extra), Flatpak ou la compilation manuelle depuis des sources vérifiées.

Conclusion

La compromission de l’AUR démontre la sophistication croissante des attaques sur la chaîne d’approvisionnement (supply chain) sous Linux. L’utilisation de technologies comme eBPF pour camoufler des malwares nécessite des outils de détection adaptés. N’attendez pas pour cloner le script d’audit et vérifier l’intégrité de votre système !

atomic-lockfile.py
#!/usr/bin/env python3
"""
Audit de sécurité anti-malware AUR (campagne Atomic Arch).
Vérifie la présence d'indicateurs de compromission (IoC) liés à 'atomic-lockfile'
et ses variantes (js-digest, lockfile-js, nextfile-js).
"""

import os
import subprocess
import sys

# Couleurs pour le terminal
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
RESET = "\033[0m"


def print_status(message, status="info"):
    """Affiche un message d'état coloré selon la sévérité."""
    if status == "ok":
        print(f"[{GREEN}OK{RESET}] {message}")
    elif status == "warn":
        print(f"[{YELLOW}ATTENTION{RESET}] {message}")
    elif status == "danger":
        print(f"[{RED}DANGER{RESET}] {message}")
    else:
        print(f"[{BLUE}INFO{RESET}] {message}")


def check_root():
    """S'assure que le script est lancé avec les privilèges root pour bpftool et systemd."""
    if os.geteuid() != 0:
        print(f"{RED}Erreur : Ce script doit être exécuté avec sudo pour analyser le système.{RESET}")
        sys.exit(1)


def check_pacman_logs():
    """Cherche des traces d'infection dans les paquets pacman installés."""
    print_status("Analyse des métadonnées pacman (/var/lib/pacman/local/)...")
    target_path = "/var/lib/pacman/local/"
    found_malicious = False

    if not os.path.exists(target_path):
        print_status("Chemin pacman local introuvable. Es-tu bien sur Arch Linux ?", "warn")
        return

    suspect_keywords = ["atomic-lockfile", "js-digest", "lockfile-js", "nextfile-js"]
    for root, dirs, files in os.walk(target_path):
        for file in files:
            # On cible uniquement les métadonnées textuelles pour optimiser la recherche
            if file not in ("desc", "files", "install"):
                continue
            file_path = os.path.join(root, file)
            try:
                with open(file_path, "r", errors="ignore") as f:
                    content = f.read()
                    for kw in suspect_keywords:
                        if kw in content:
                            print_status(f"Alerte ! Signature '{kw}' trouvée : {file_path}", "danger")
                            found_malicious = True
            except Exception:
                continue

    if not found_malicious:
        print_status("Aucune trace de malware détectée dans les paquets installés.", "ok")


def check_aur_caches():
    """Vérifie si les caches d'assistants AUR contiennent des fichiers ou scripts suspects."""
    print_status("Analyse des caches d'assistants AUR (yay, paru) dans /home...")
    suspect_keywords = ["atomic-lockfile", "js-digest", "lockfile-js", "nextfile-js"]
    found_signature = False

    if not os.path.exists("/home"):
        return

    for user_dir in os.listdir("/home"):
        user_home = os.path.join("/home", user_dir)
        paths_to_check = [
            os.path.join(user_home, ".cache/yay"),
            os.path.join(user_home, ".cache/paru/clone")
        ]
        for base_path in paths_to_check:
            if not os.path.exists(base_path):
                continue
            for root, dirs, files in os.walk(base_path):
                for file in files:
                    if file == "PKGBUILD" or file.endswith(".install") or file.endswith(".js"):
                        file_path = os.path.join(root, file)
                        try:
                            with open(file_path, "r", errors="ignore") as f:
                                content = f.read()
                            for kw in suspect_keywords:
                                if kw in content:
                                    print_status(f"Signature '{kw}' détectée dans : {file_path}", "warn")
                                    found_signature = True
                        except Exception:
                            continue

    if not found_signature:
        print_status("Aucune trace suspecte dans les caches AUR.", "ok")


def check_ebpf():
    """Vérifie si des programmes eBPF suspects (type hook système) sont actifs."""
    print_status("Analyse des programmes eBPF actifs...")
    try:
        result = subprocess.run(
            ["bpftool", "prog", "list"], capture_output=True, text=True, check=True
        )
        output = result.stdout
        suspect_keywords = ["getdents", "sys_enter_write", "sys_enter_read"]
        found_suspect = False

        for line in output.splitlines():
            if any(kw in line.lower() for kw in suspect_keywords):
                print_status(f"Programme eBPF suspect détecté : {line.strip()}", "warn")
                found_suspect = True

        if not found_suspect:
            print_status("Aucun programme eBPF suspect détecté.", "ok")

    except FileNotFoundError:
        print_status("bpftool n'est pas installé. Analyse eBPF ignorée.", "warn")
        print("        -> Installation conseillée : sudo pacman -S bpftool")
    except subprocess.CalledProcessError as e:
        print_status(f"Erreur lors de l'exécution de bpftool : {e}", "warn")


def check_bpf_pinned_maps():
    """Vérifie la présence de cartes eBPF épinglées suspectes dans /sys/fs/bpf/."""
    print_status("Analyse des cartes eBPF épinglées (/sys/fs/bpf/)...")
    bpf_path = "/sys/fs/bpf"
    if not os.path.exists(bpf_path):
        print_status("Répertoire /sys/fs/bpf/ introuvable.", "warn")
        return

    found_pinned = []
    try:
        for root, dirs, files in os.walk(bpf_path):
            for file in files:
                file_path = os.path.join(root, file)
                if "/sys/fs/bpf/systemd" in file_path:
                    continue
                found_pinned.append(file_path)
    except Exception as e:
        print_status(f"Impossible de lire {bpf_path} : {e}", "warn")

    if found_pinned:
        for path in found_pinned:
            print_status(f"Carte eBPF épinglée détectée : {path}", "danger")
        print_status("Cartes eBPF suspectes détectées en dehors de systemd. Risque de rootkit !", "danger")
    else:
        print_status("Aucune carte eBPF suspecte détectée dans /sys/fs/bpf/.", "ok")


def check_tmp_directories():
    """Vérifie la présence de fichiers ou dossiers suspects dans /tmp et /var/tmp."""
    print_status("Analyse des dossiers temporaires (/tmp et /var/tmp)...")
    suspects = ["node_modules", "atomic-lockfile", "js-digest", "lockfile-js", "nextfile-js"]
    found = False

    for tmp_dir in ["/tmp", "/var/tmp"]:
        if not os.path.exists(tmp_dir):
            continue
        try:
            for item in os.listdir(tmp_dir):
                if any(s in item for s in suspects):
                    print_status(f"Fichier/Dossier suspect trouvé dans {tmp_dir} : {item}", "warn")
                    found = True
        except Exception as e:
            print_status(f"Impossible de lire {tmp_dir} : {e}", "warn")

    if not found:
        print_status("Les répertoires temporaires semblent propres.", "ok")


def check_systemd_persistence():
    """Analyse les services systemd système et utilisateur pour détecter une persistance."""
    print_status("Analyse des services systemd (système et utilisateur)...")
    suspect_paths = ["/tmp", "/var/tmp", "/dev/shm"]
    suspect_keywords = ["atomic-lockfile", "js-digest", "lockfile-js", "nextfile-js", "/deps"]
    search_dirs = ["/etc/systemd/system"]

    if os.path.exists("/home"):
        for user_dir in os.listdir("/home"):
            user_systemd = os.path.join("/home", user_dir, ".config/systemd/user")
            if os.path.exists(user_systemd):
                search_dirs.append(user_systemd)
    if os.path.exists("/root/.config/systemd/user"):
        search_dirs.append("/root/.config/systemd/user")

    found_suspicious = False
    for directory in search_dirs:
        try:
            for item in os.listdir(directory):
                if not item.endswith(".service"):
                    continue
                file_path = os.path.join(directory, item)
                try:
                    with open(os.path.realpath(file_path), "r", errors="ignore") as f:
                        content = f.read()

                    is_suspicious = False
                    reason = ""
                    exec_lines = [l for l in content.splitlines() if l.strip().startswith("ExecStart")]
                    for line in exec_lines:
                        if any(sp in line for sp in suspect_paths):
                            is_suspicious, reason = True, f"Exécute depuis un dossier temporaire ({line.strip()})"
                            break
                        if "/deps" in line or line.strip().endswith("deps") or "deps " in line:
                            is_suspicious, reason = True, f"Exécute un binaire nommé 'deps' suspect ({line.strip()})"
                            break

                    for kw in suspect_keywords:
                        if kw in content:
                            is_suspicious, reason = True, f"Contient la signature '{kw}'"
                            break

                    if is_suspicious:
                        print_status(f"Service suspect détecté : {file_path}", "danger")
                        print(f"        -> Motif : {reason}")
                        found_suspicious = True
                except Exception:
                    continue
        except Exception:
            continue

    if not found_suspicious:
        print_status("Aucun service systemd suspect détecté.", "ok")


def main():
    """Point d'entrée principal pour l'exécution séquentielle de l'audit."""
    print(f"{BLUE}=== AUDIT DE SÉCURITÉ ANTI-MALWARE AUR (ATOMIC ARCH) ==={RESET}\n")
    check_root()
    print("-" * 50)
    check_pacman_logs()
    print("-" * 50)
    check_aur_caches()
    print("-" * 50)
    check_ebpf()
    print("-" * 50)
    check_bpf_pinned_maps()
    print("-" * 50)
    check_tmp_directories()
    print("-" * 50)
    check_systemd_persistence()
    print("-" * 50)
    print(f"\n{BLUE}Audit terminé.{RESET}")


if __name__ == "__main__":
    main()

Sources et liens de référence

Catégories : Linux News 
Tags: Arch Linux 

Suggestions de lecture :