문제: DreamHack — 403-forbbiden 분류: Web 난이도: 🥉 Bronze 4 FLAG:
DH{afjqofuida34@1!21%!1204m1n13aax091!kJ@$}
문제 이름부터 "403 forbidden error"다. 이 정도로 노골적인 제목이면 보통 IP 스푸핑 헤더나 경로 정규화 트릭으로 접근 제어를 우회하는 문제를 기대하게 된다. 그런데 이번엔 그 기대 자체가 함정이었다 — 화면에 보이는 "보호된 페이지"는 애초에 서버에 존재하지도 않는 소품이었고, 진짜 취약점은 전혀 다른 자리에 조용히 놓여 있었다.
문제 개요
VM을 띄우면 자바스크립트로 그린 가짜 브라우저 창 하나가 뜬다. 주소창처럼 생긴 요소에 securevault.dreamhack.games/secret이라는 그럴듯한 URL이 적혀 있고, 그 안에 큼지막하게 "403 Forbidden"이 표시된다.
여기서 처음 눈여겨봐야 할 게 있다 — 진짜 브라우저 주소창(위쪽, Chrome 자체의 것)과 페이지 안에 HTML/CSS로 그려진 가짜 주소창(흰 배경의 "브라우저 크롬" 목업)은 서로 다른 것이다. securevault.dreamhack.games/secret은 실제로 요청이 가는 주소가 아니라, 그냥 화면에 적힌 텍스트일 뿐이다.
▶🕳️ 삽질 — 제목이 시킨 대로 403 우회 기법을 순서대로 시도해봤다
제목이 "403 forbidden error"이니 자연스럽게 흔한 403 우회 기법부터 하나씩 시도했다. /secret이 화면에 적힌 "보호된 경로"처럼 보였으니, 그 경로를 직접 두드리는 것부터 시작했다.
$ curl -s -o /dev/null -w '%{http_code}' http://host3.dreamhack.games:PORT/secret
404403이 아니라 404다. 이미 여기서 이상한 신호였지만, 일단 흔한 헤더 스푸핑 기법들을 계속 시도했다 — 리버스 프록시가 내부 IP를 신뢰하는 경우를 노리는 X-Forwarded-For, 그리고 일부 WAF/프록시 구성에서 실제 요청 경로 대신 참조되는 X-Original-URL 헤더까지.

전부 404. 어떤 조합을 시도해도 결과가 바뀌지 않았다. 이 시점에서 결론을 뒤집었다 — /secret은 우회해야 할 "보호된 자료"가 아니라, 애초에 백엔드에 존재하지 않는 경로였다. 화면에 그려진 가짜 주소창의 텍스트일 뿐, 실제 라우팅과는 무관한 소품이었던 것이다. 문제 제목이 만든 선입견("403이니까 헤더 우회겠지")이 정확히 여기서 발목을 잡았다 — 진짜 취약점은 /secret이 아니라 전혀 다른 엔드포인트에 있었다.
💣 핵심 — 화면의 논리와 서버의 검증은 별개다
/flag라는 경로가 있다는 건 페이지의 자바스크립트를 보면 바로 알 수 있다. view-source:로 페이지 소스를 열어봤다.
스크롤을 내려 <script> 태그를 확인했다.
로직을 정리하면 이렇다.
const statusEl = document.getElementById("statusCode");
new MutationObserver(() => {
if (statusEl.textContent.trim() === "200") {
fetch("/flag", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "200" })
})
.then(r => r.json())
.then(data => {
if (data.flag) { /* 성공 화면 표시 */ }
});
}
}).observe(statusEl, { childList: true, subtree: true, characterData: true });#statusCode(화면에 큼지막하게 보이는 "403" 텍스트)의 내용이 "200"으로 바뀌는 순간을 MutationObserver가 감지해서, 그때 /flag에 POST 요청을 보내고 응답으로 받은 flag 값을 화면에 표시한다. 그럴듯한 "클라이언트가 200을 만들어내면 서버가 인정해준다"는 흐름처럼 보이지만, 정작 저 텍스트를 실제로 "200"으로 바꾸는 코드는 페이지 어디에도 없다. 즉 이 MutationObserver는 한 번도 자동으로 발동할 일이 없는, 순수하게 연출용으로 심어둔 장식이다.
여기서 핵심을 짚어야 한다. 이 스크립트가 하는 일의 본질은 결국 **"어떤 조건이 갖춰지면 /flag에 {"status": "200"}이라는 JSON을 담아 POST를 보낸다"**는 것뿐이다. 브라우저가 그 조건을 스스로 판단해서 요청을 보내든, 우리가 그냥 curl로 똑같은 요청을 직접 보내든, 서버 입장에서는 완전히 동일한 HTTP 요청이다. DOM 상태나 세션, 어떤 페이지에서 왔는지(Referer) 같은 "요청이 나온 맥락"을 서버가 전혀 검증하지 않는다면, 그 요청은 누구나 그대로 재현할 수 있다.
먼저 GET으로 /flag를 직접 열어봤다 — 예상대로 POST 전용 엔드포인트였다.
🎯 자체 완결 solve
스크립트가 보내는 것과 정확히 같은 요청을 curl로 그대로 재현했다.
$ curl -X POST http://host3.dreamhack.games:PORT/flag \
-H 'Content-Type: application/json' \
-d '{"status":"200"}'
DOM을 실제로 조작할 필요도, 브라우저의 MutationObserver가 발동하길 기다릴 필요도 없었다 — 서버는 이 요청이 진짜 브라우저에서 왔는지, 화면의 "403"이 정말 "200"으로 바뀐 뒤에 왔는지 그 어떤 것도 검증하지 않았다. 그냥 이 JSON 바디 하나만 보고 flag를 내줬다.
같은 요청을 브라우저 콘솔에서 직접 실행해도 물론 동일하게 동작한다 — 개발자 도구 없이도, 그냥 #statusCode의 텍스트를 강제로 바꿔서 원래 의도된 MutationObserver 흐름을 실제로 트리거해봤다.
MutationObserver가 정상적으로 발동하면서 진짜 /flag 응답을 받아와 "Access Granted!" 화면과 함께 flag를 표시한다 — curl로 직접 보낸 것과 서버 응답은 완전히 동일했다.
DH{afjqofuida34@1!21%!1204m1n13aax091!kJ@$}flag에 특수문자(!, @, %, $)가 많아 전달 과정에서 깨질 수 있으니, 서버에서 flag를 다시 조회해 정확히 같은 문자열인지 확인한 뒤 제출했다. "정답! 해결 처리됨".
📝 결론
이 문제가 짚어준 지점은 두 가지였다.
- 문제(또는 화면) 제목이 만드는 선입견을 의심 없이 받아들이면 안 된다. "403 forbidden"이라는 제목과 그럴듯한 목업 화면은 자연스럽게 "헤더 우회"라는 익숙한 카테고리로 사고를 몰아간다. 하지만 실제로 존재하지 않는 경로(
/secret)를 대상으로 아무리 정교한 우회 기법을 시도해도 결과는 바뀌지 않는다 — 몇 번의 요청으로 "우회할 대상이 진짜로 존재하는가" 자체를 먼저 확인하는 습관이, 엉뚱한 방향으로 시간을 쓰는 걸 막아준다. - 클라이언트가 "이 조건이 충족되면 이 요청을 보낸다"는 로직을 갖고 있다면, 그 조건 자체가 아니라 최종적으로 나가는 요청이 진짜 공격 표면이다. 이 문제의
MutationObserver는 "브라우저 DOM이 어떤 상태가 되어야만" 요청이 나가는 것처럼 보이게 설계돼 있었지만, 서버는 그 요청이 어떤 경로로 만들어졌는지 전혀 모른다 — HTTP 요청은 그 자체로 완전한 것이고, 브라우저가 어떤 조건 하에 그걸 보냈는지는 서버에게 아무 의미가 없다. 클라이언트 로직이 아무리 복잡해 보여도, 서버가 최종 요청의 내용만 검증한다면 그 요청은 그대로 재현 가능하다. - "정답 판정"이 클라이언트 주도로 보이는 UX일수록, 서버 쪽 검증이 실제로 어디까지인지 확인해야 한다. 이번 엔드포인트는 body의
status필드 존재 여부조차 의미 있게 검사하지 않는 것처럼 보였다 — 진짜 필요한 건 세션이나 이전 요청 이력에 기반한 서버 사이드 상태 검증이었을 텐데, 그게 전혀 없었다.
방어자 관점에서 보면 이 문제는 단순하지만 실무에서 실제로 반복되는 패턴을 정확히 짚는다 — "접근 제어"처럼 보이는 화면 연출과 실제 접근 제어는 다르다. 프론트엔드에서 아무리 그럴듯한 조건부 UI를 만들어도, 그 UI가 트리거하는 API 엔드포인트 자체가 별도의 인증/인가 검증 없이 열려 있다면 그 UI는 장식일 뿐이다. /flag 같은 민감한 엔드포인트는 요청의 "형태"가 아니라 요청을 보낸 주체(세션, 토큰, 이전 단계의 서버 사이드 상태)를 검증해야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…