“남의 페이지 초안이 왜 보이죠?” — 직접 찾은 CVE 3건 이야기 🕵️♀️
안녕하세요 구독자 님!! 오랜만에 돌아왔어요. 그동안 뉴스레터가 조용했던 이유가 있었는데요, 바로 제가 직접 취약점을 찾아서 제보하고 있었거든요! 😆
그 결과 오픈소스 CMS 두 곳(Wagtail, Craft CMS)에서 CVE 3건을 받게 되었어요. 오늘은 늘 하던 대로 남이 찾은 CVE를 분석하는 대신, 제가 어떤 원리로 취약점을 찾았고 어떤 과정을 거쳐 CVE가 되었는지를 쉽게 풀어볼게요
🚨 핵심 요약 (Key Takeaways)

세 건 모두 패치가 나왔어요. Wagtail은 7.0.7 / 7.3.2 / 7.4, Craft CMS는 4.18 / 5.10 이상으로 업데이트하면 안전합니다.
🔍 Wagtail — “문은 잠갔는데 창문이 열려 있었어요”
Wagtail은 Django 기반의 인기 CMS예요. 페이지를 나무(Tree)처럼 관리하고, “이 편집자는 A 가지의 페이지만 수정할 수 있다”처럼 가지별로 권한을 나눌 수 있어요.
💡 BOLA가 뭐예요?
BOLA(Broken Object Level Authorization)는 OWASP API Top 10에서 1위를 차지하는 취약점이에요. 쉽게 말하면 “로그인한 사람인지는 확인하는데, 그 사람이 이 물건에 접근해도 되는지는 확인하지 않는” 문제예요. URL의 숫자(ID)만 바꿔도 남의 데이터가 보이기 때문에 IDOR이라고도 불러요.
💡 취약점 핵심 원인 2가지
- 리비전 비교 화면 (RevisionsCompare) : “페이지 권한을 하나라도 가진 사람인가?”만 확인하고, 요청한 페이지에 대한 편집·발행 권한은 확인하지 않았어요. 바로 옆의 리비전 보기 화면(RevisionsView)은 제대로 확인하고 있었는데 말이죠!
- 페이지 이력 화면 (PageHistoryView) : 전역 권한만 보는 함수(
user_has_any_permission)를 쓰고, 특정 페이지용 함수(user_has_any_permission_for_instance)는 쓰지 않았어요.
Step by Step : 어떻게 재현했나요?
- 페이지 트리 A와 B를 만들고, ‘Editor_A’에게는 A 트리 권한만 줍니다.
- B 트리에 ‘기밀 페이지’를 만들고 페이지 ID를 메모해요. Editor_A의 대시보드에는 이 페이지가 안 보여요.
- Editor_A로 로그인해서
/admin/pages/revisions/compare/[B페이지 ID]/...를 직접 입력하면, 발행되지 않은 초안 내용이 보여요. 😱 /admin/pages/history/[B페이지 ID]/를 입력하면 누가 언제 무엇을 했는지 담긴 감사 로그까지 보여요.

메인테이너는 두 지점이 각각 독립된 문제라고 보고 CVE를 2개로 나눠 발급했어요.
🌐 Craft CMS — “영향 없음”에서 “Moderate”가 되기까지
이번엔 PHP 기반 CMS인 Craft CMS예요. 문제의 장소는 /actions/app/resource-js 라는 엔드포인트였는데요, 서버가 자기 도메인의 JS 파일을 대신 가져다 주는 ‘프록시’ 역할을 해요.
💡 취약점 핵심 원인 3가지
- 너무 너그러운 기본 설정 :
trustedHosts의 기본값이['any']라서, 공격자가X-Forwarded-Host헤더만 넣으면 사이트의 기준 주소($baseUrl)가 공격자 도메인으로 바뀌어요. - 검증 무력화 : “요청 URL이
$baseUrl로 시작하는지”만 확인하는데, 기준 주소 자체가 이미 오염됐으니 검사를 그냥 통과해요. 서버는 공격자 서버에 요청을 보내요(SSRF). - 강제 JS 응답 : 가져온 내용을
Content-Type: application/javascript로 그대로 돌려주기 때문에, 브라우저는 이걸 정상적인 스크립트로 믿어요.

😮 여기서 한 번 더 파고들었어요
처음 메인테이너의 답변은 “이 엔드포인트는 /cpresources로 시작하는 경로만 처리해서 실제 영향은 없다”였어요. 하지만 생각해보면, Host가 이미 제 서버를 가리키고 있으니 제 서버에 cpresources라는 폴더만 만들면 경로 검사도 통과하잖아요? 🤔
이 부분을 정중하게 다시 설명했더니, 메인테이너가 “SSRF와 달리 JS 삽입은 더 심각하다”며 등급을 Moderate로 올려주었어요. 관리자 페이지는 원래 캐시하지 않기 때문에 캐시 포이지닝까지 이어지기는 어렵다는 설명도 함께요. 결국 해당 엔드포인트는 4.18 / 5.10에서 아예 제거되었습니다.
📬 취약점을 찾으면 어떻게 CVE가 되나요?
많이들 궁금해하실 것 같아서 제보 과정도 정리해봤어요.
- 보고서 작성 : 원인 분석, 재현 절차, 증거 스크린샷, 패치 제안 코드까지 담아서 보냈어요. 패치 코드까지 있으면 메인테이너가 훨씬 빨리 이해해요.
- 비공개 채널로 제보 : Wagtail은 보안팀 이메일로, Craft CMS는 GitHub의 비공개 보안 권고(Security Advisory)로 제보했어요. 절대 공개 이슈로 올리면 안 돼요!
- 기다리기 : 패치가 나오기 전까지는 내용을 비밀로 해야 하고, 보통 90일 안에 해결하는 것이 업계 관례예요. 저도 답장이 늦어 한 번 재촉했다가 “새 정보가 없는 한 기다려 달라”는 답을 받았답니다. 😅
- CVE 발급과 크레딧 : 패치와 함께 권고문이 공개되고, GitHub이 CVE 번호를 발급해요. 권고문에 보고자로 제 이름이 올라갔어요.
참고로 공개 후 NVD(미국 취약점 DB)에서 ‘Not Scheduled’라는 표시를 보고 깜짝 놀랐는데요, 2026년 4월부터 NIST가 모든 CVE를 직접 분석하지 않기로 하면서 붙는 표시예요. CVE 자체는 그대로 유효하니 걱정 마세요!

🛡️ 그럼, 이런 취약점은 어떻게 막을 수 있을까요?
BOLA는 “이 사람이 이 객체에 권한이 있는가?”를 매번 확인하면 돼요. 제가 보고서에 넣었던 패치 제안을 조금 단순하게 보여드릴게요.
# 패치 로직 예시 (단순화)
def get_object(self, queryset=None):
page = get_object_or_404(Page, id=self.pk).specific
perms = page.permissions_for_user(self.request.user)
if not (perms.can_publish() or perms.can_edit()):
raise PermissionDenied # 이 페이지에 권한이 없으면 차단!
return pageHost 헤더 문제는 ‘아무도 믿지 않기’가 기본이에요. trustedHosts처럼 프록시 헤더를 믿는 설정은 실제 프록시 주소만 허용하고, 서버가 외부 URL을 가져올 때는 주소를 허용 목록으로 검사해야 해요.
📚 보안 용어 정리
- BOLA / IDOR : 객체 단위 권한 검사가 빠져서, ID만 바꿔도 남의 데이터에 접근할 수 있는 취약점
- SSRF (Server-Side Request Forgery) : 공격자가 서버를 속여 서버가 원하지 않는 곳에 요청을 보내게 만드는 취약점
- Host Header Poisoning : 서버가 믿는 Host 헤더 값을 조작해 링크·주소 생성을 공격자 도메인으로 바꾸는 기법
- Responsible Disclosure : 취약점을 먼저 개발사에 비공개로 알리고, 패치 후에 공개하는 절차
오늘은 제가 직접 찾은 취약점 이야기를 들려드렸어요. 유명한 오픈소스도 “옆 화면은 확인하는데 이 화면은 안 하는” 작은 틈이 있다는 것, 그리고 “영향 없음”이라는 답에도 한 번 더 질문해보는 것이 중요하다는 걸 배웠네요! 🕵️♀️
이제 다시 매주 월요일에 찾아올게요. 끝까지 읽어주셔서 감사합니다. 다음 호에서 뵙겠습니다 :)
의견을 남겨주세요