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-lockfilejs-digestlockfile-jsnextfile-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 :
- Inspectez systématiquement les PKGBUILDs : Avant d’installer ou de mettre à jour un paquet avec
yayouparu, lisez attentivement le fichierPKGBUILDet les scripts.installassociés. Recherchez des URLs suspectes ou des commandes de téléchargement obscures. - 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.
- 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
- Article Phoronix - Arch Linux AUR Compromise
- Fil de discussion officiel sur la liste Arch Linux
- Forum de discussion CachyOS