[🥉 Bronze 4] 우회할 403은 애초에 없었다 — DreamHack 403-forbbiden 풀이

2026-07-21·1분 읽기·

[🥉 Bronze 4] 우회할 403은 애초에 없었다 — DreamHack 403-forbbiden 풀이

가짜 브라우저 창 안에 "403 Forbidden"이 떠 있는 페이지. 제목부터 대놓고 "403 우회"를 암시하니 자연스럽게 X-Forwarded-For, 경로 트릭 같은 고전적인 헤더 우회 기법부터 시도하게 된다. 그런데 그 화면 속 "/secret"이라는 보호된 경로는 애초에 서버에 존재하지도 않는 눈속임이었다. view-source로 스크립트를 열어보면 진짜 취약점은 전혀 다른 곳에 있었다 — 클라이언트가 "정답 조건"을 스스로 판단해서 보내는 POST 요청을, 서버가 그 어떤 맥락도 검증하지 않고 그대로 받아준다는 사실.

문제: 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"이 표시된다.

host3.dreamhack.games 포트로 접속한 첫 화면 — 진짜 브라우저 주소창은 host3.dreamhack.games:PORT를 가리키는데, 페이지 안에 그려진 가짜 주소창에는 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
404

403이 아니라 404다. 이미 여기서 이상한 신호였지만, 일단 흔한 헤더 스푸핑 기법들을 계속 시도했다 — 리버스 프록시가 내부 IP를 신뢰하는 경우를 노리는 X-Forwarded-For, 그리고 일부 WAF/프록시 구성에서 실제 요청 경로 대신 참조되는 X-Original-URL 헤더까지.

터미널 캡쳐 — /secret 단독 요청, X-Forwarded-For: 127.0.0.1 헤더를 추가한 요청, X-Original-URL: /secret 헤더를 추가한 요청, /api/flag 경로 탐색까지 전부 404를 반환하는 결과

전부 404. 어떤 조합을 시도해도 결과가 바뀌지 않았다. 이 시점에서 결론을 뒤집었다 — /secret은 우회해야 할 "보호된 자료"가 아니라, 애초에 백엔드에 존재하지 않는 경로였다. 화면에 그려진 가짜 주소창의 텍스트일 뿐, 실제 라우팅과는 무관한 소품이었던 것이다. 문제 제목이 만든 선입견("403이니까 헤더 우회겠지")이 정확히 여기서 발목을 잡았다 — 진짜 취약점은 /secret이 아니라 전혀 다른 엔드포인트에 있었다.


💣 핵심 — 화면의 논리와 서버의 검증은 별개다

/flag라는 경로가 있다는 건 페이지의 자바스크립트를 보면 바로 알 수 있다. view-source:로 페이지 소스를 열어봤다.

view-source로 연 페이지 소스 상단부 — HTML/CSS로 짜인 가짜 브라우저 크롬 마크업이 그대로 노출됨

스크롤을 내려 <script> 태그를 확인했다.

페이지 하단의 script 태그 — MutationObserver가 #statusCode 요소의 텍스트가 200이 되는 순간을 감지해 POST /flag 요청을 fetch로 보내고, 응답의 flag 값을 성공 화면에 표시하는 로직이 그대로 보임

로직을 정리하면 이렇다.

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) 같은 "요청이 나온 맥락"을 서버가 전혀 검증하지 않는다면, 그 요청은 누구나 그대로 재현할 수 있다.

전체 풀이 흐름 다이어그램 — 가짜 브라우저 목업이 만드는 착시, 고전적 403 우회 시도의 무의미함, view-source로 발견한 스크립트의 진짜 의미, curl로 그 fetch를 그대로 재현하는 4단계

먼저 GET으로 /flag를 직접 열어봤다 — 예상대로 POST 전용 엔드포인트였다.

브라우저로 /flag 를 GET 요청하면 405 Method Not Allowed 가 뜨는 화면

🎯 자체 완결 solve

스크립트가 보내는 것과 정확히 같은 요청을 curl로 그대로 재현했다.

$ curl -X POST http://host3.dreamhack.games:PORT/flag \
    -H 'Content-Type: application/json' \
    -d '{"status":"200"}'
터미널 캡쳐 — GET slash flag 는 405를 반환하지만, POST slash flag 에 Content-Type: application/json 헤더와 status 200 을 담은 JSON 바디를 실어 보내면 곧바로 flag 값이 담긴 JSON이 반환됨

DOM을 실제로 조작할 필요도, 브라우저의 MutationObserver가 발동하길 기다릴 필요도 없었다 — 서버는 이 요청이 진짜 브라우저에서 왔는지, 화면의 "403"이 정말 "200"으로 바뀐 뒤에 왔는지 그 어떤 것도 검증하지 않았다. 그냥 이 JSON 바디 하나만 보고 flag를 내줬다.

같은 요청을 브라우저 콘솔에서 직접 실행해도 물론 동일하게 동작한다 — 개발자 도구 없이도, 그냥 #statusCode의 텍스트를 강제로 바꿔서 원래 의도된 MutationObserver 흐름을 실제로 트리거해봤다.

브라우저 콘솔에서 document.getElementById('statusCode').textContent = '200' 을 실행하면 MutationObserver가 감지해 실제로 POST /flag 요청을 보내고, 응답으로 받은 진짜 flag가 Access Granted! 화면에 그대로 표시됨

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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 3] 클리어 판정이 통째로 브라우저 안에 있다 — DreamHack 달고나 풀이
blog

[🥉 Bronze 3] 클리어 판정이 통째로 브라우저 안에 있다 — DreamHack 달고나 풀이

캔버스로 달고나 뽑기를 하는 웹 문제. 서버 코드는 24줄뿐이고 flag 를 주는 POST /api/stage5/clear 에는 검증이 한 줄도 없다. 레벨 해금은 localStorage, 90% 판정은 game.js 안. 게다가 5단계는 도형 그리는 함수 자체가 없어 브라우저로는 깰 수 없다. 게임을 건드리지 않고 API 만 한 번 때려 flag 를 받았다.
#dreamhack#ctf#web+5
2026-08-14#dreamhack +5
[🥉 Bronze 3] X-User·Referer 헤더 위조로 비밀 문서 읽기 — DreamHack DreamDocs 풀이
blog

[🥉 Bronze 3] X-User·Referer 헤더 위조로 비밀 문서 읽기 — DreamHack DreamDocs 풀이

문서 열람 권한을 Referer와 X-User 헤더로만 판단하는 게시판이다. 둘 다 요청자가 마음대로 보내는 값이라, X-User: admin 과 Referer: /share 를 위조하면 confidential 문서의 본문(FLAG)이 그대로 열린다.
#dreamhack#ctf#web+6
2026-06-04#dreamhack +4
[🥉 Bronze 3] 뚫을 필요가 없었다, 제대로 보기만 하면 됐다 — DreamHack [wargame.kr] already got 풀이
blog

[🥉 Bronze 3] 뚫을 필요가 없었다, 제대로 보기만 하면 됐다 — DreamHack [wargame.kr] already got 풀이

옛 wargame.kr에서 리마스터해 가져온 문제. 페이지를 열어보면 "you've already got key! :p"라는 한 줄짜리 문구뿐이고, HTML 소스를 봐도 똑같다. 문제 설명이 "HTTP 응답 헤더가 보이나요?"라고 대놓고 힌트를 주는데, 이 짧은 문제 하나가 사실 "브라우저 화면에 보이는 것"과 "서버가 실제로 보낸 것"이 다르다는 웹 리버싱의 가장 기초적인 교훈을 정확히 짚고 있었다. 취약점을 찾아 뚫는 문제가 아니라, HTTP 프로토콜을 있는 그대로 관찰하기만 하면 되는 문제였다.
#dreamhack#ctf#web+3
2026-07-21#dreamhack +3