Step 192. 고급 XSS: CSP 우회, DOM 심화 — 방어막이 있는 환경의 공격

Step 192. 고급 XSS: CSP 우회, DOM 심화 — 방어막이 있는 환경의 공격

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 138~139의 XSS 기초(Reflected/Stored, 쿠키 탈취, 필터 우회)를 마쳤다.

  • 준비물: 파이썬 3 + Flask(로컬 CSP 실험용), 브라우저 개발자 도구, (선택) PortSwigger Academy 계정 + Burp Suite.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. PortSwigger Web Security Academy는 공격 연습을 위해 만들어진 합법 학습 플랫폼입니다.
  • 주의: CSP 헤더의 유무와 응답 구조는 로컬 서버에서 실측합니다. 헤더를 받은 브라우저가 스크립트를 막는 장면과 PortSwigger 랩 화면은 이 환경에서 재현할 수 없어 화면 예시로 표기합니다.

Step 138~139의 XSS는 "아무 방어가 없는" 세계였습니다. 현실의 잘 만든 사이트에는 CSP(Content-Security-Policy) 라는 방어막이 있습니다 — "이 페이지에서 실행될 수 있는 스크립트의 출처"를 서버가 헤더로 선언하고, 브라우저가 그 규칙을 집행합니다. 그런데 이 방어막은 정책이 헐거우면 뚫립니다. 고급 XSS는 페이로드 경쟁이 아니라 정책 읽기 경쟁입니다. 오늘은 CSP를 읽고 허점을 찾는 법, 그리고 서버를 거치지 않고 브라우저 안에서 완결되는 DOM 기반 XSS 심화를 다룹니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • CSP 헤더의 지시자(script-src, 'self' 등)를 읽고 의미를 해석한다
  • CSP가 있을 때와 없을 때의 응답 차이를 로컬 서버로 확인한다
  • 느슨한 CSP를 우회하는 대표 경로(넓은 화이트리스트, JSONP, 취약 CDN)를 열거한다
  • DOM XSS의 소스(source)에서 싱크(sink)까지의 데이터 흐름을 추적한다
  • PortSwigger의 CSP·DOM XSS 랩에 접근하는 순서를 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask(CSP 유무 두 서버), requests(헤더 검사), 브라우저 개발자 도구(Console·Sources), Burp 내장 브라우저의 DOM Invader(예시)
오늘의 페이로드·헤더 Content-Security-Policy: default-src 'self'; script-src 'self', <img src=x onerror=alert(1)>, location.search, innerHTML
필요한 개념 CSP 지시자, 소스→싱크 흐름, JSONP, DOM Invader의 자동 탐지
오늘의 산출물 lab192_nocsp.py / lab192_csp.py + CSP 정책 분석 기록 + "CSP가 있어도 뚫리는 조건" 문서

2-1. CSP — 서버가 선언하고 브라우저가 집행하는 규칙

CSP는 응답 헤더입니다.

Content-Security-Policy: default-src 'self'; script-src 'self'

읽는 법: "기본적으로(default-src) 모든 리소스는 같은 출처('self')에서만, 스크립트는(script-src) 역시 같은 출처에서만 로드하라." 이 헤더가 있으면 브라우저는 인라인 <script>onerror 같은 핸들러를 실행하지 않습니다 — 공격자가 심은 스크립트는 출처가 "인라인"이라 규칙 위반이기 때문입니다.

중요한 사실 하나: CSP는 서버의 응답 본문을 바꾸지 않습니다. 취약한 페이지는 여전히 페이로드를 그대로 담아 보냅니다. 차단은 헤더를 읽은 브라우저가 합니다. 이 사실이 오늘 로컬 실측의 핵심 관찰입니다.

2-2. CSP의 허점 — 정책이 헐거운 네 가지 형태

CSP가 있어도 뚫리는 대표 조건입니다.

  1. script-src에 넓은 화이트리스트: script-src https://cdn.example.com처럼 외부 도메인을 허용했는데, 그 CDN에 공격자가 올릴 수 있는 콘텐츠(옛날 버전 라이브러리, 사용자 업로드)가 있으면 그 도메인을 경유해 스크립트를 로드합니다.
  2. JSONP 엔드포인트 허용: 허용 도메인에 JSONP(callback= 파라미터로 응답이 바뀌는 API)가 있으면, 콜백 이름에 JS를 넣어 실행시키는 고전적 우회가 됩니다.
  3. 'unsafe-inline' 허용: 인라인 스크립트를 허용하는 순간 CSP의 XSS 방어 가치는 거의 사라집니다.
  4. 지시자 누락: default-src만 있고 script-src가 없는 등 구멍이 있는 정책.

공격자의 절차는 정해져 있습니다: 헤더 전체를 복사 → 허용 목록을 적고 → 그중 악용 가능한 것을 찾는다. 정책마다 답이 다르기에 "정답 페이로드"가 아니라 "읽는 절차"를 배우는 것입니다.

2-3. DOM 기반 XSS — 서버 로그에도 안 남는 공격

지금까지의 XSS는 서버의 응답 HTML에 페이로드가 박혔습니다. DOM XSS는 다릅니다 — 서버는 무관하고, 페이지 안의 자바스크립트가 사용자 입력을 위험한 방식으로 처리해 실행됩니다.

핵심 용어 두 개:

  • 소스(source): 공격자가 조종할 수 있는 입력 지점. 대표적으로 location.search(URL의 ? 뒤), location.hash(# 뒤), document.referrer.
  • 싱크(sink): 입력이 "코드가 되는" 지점. innerHTML, document.write, eval, location.href에의 대입 등.

소스에서 나온 값이 정제 없이 싱크에 흘러 들어가면 DOM XSS가 됩니다. URL의 # 뒤는 서버로 전송되지 않으므로, 해시를 쓰는 DOM XSS는 서버 로그에 흔적이 남지 않는 것도 특징입니다.

2-4. DOM Invader — 소스·싱크를 자동으로 추적하는 도구

Burp Suite의 내장 브라우저에 들어 있는 확장으로, 페이지의 소스→싱크 흐름을 자동 탐지해 줍니다. "어떤 입력이 어느 싱크로 흐르는가"를 일일이 코드에서 찾지 않게 해 주는 도구입니다. 오늘은 존재와 역할만 익히고, 원리 수업은 수동 추적(개발자 도구)에 둡니다 — 도구가 보여 주는 것을 이해하려면 수동 추적을 먼저 겪어야 합니다.


3. 따라 하기

3-1. CSP 없는 서버와 있는 서버 — 같은 본문, 다른 헤더

두 개의 서버를 만듭니다. 둘 다 같은 취약 검색 페이지인데, 하나만 CSP 헤더를 붙입니다.

lab192_nocsp.py:

from flask import Flask, request

app = Flask(__name__)

@app.route("/search")
def search():
    q = request.args.get("q", "")
    return f"<html><body>검색어: {q}<script>window.xss_ran=true;</script></body></html>"

if __name__ == "__main__":
    app.run(port=5192)

lab192_csp.py (달라진 부분은 헤더뿐):

from flask import Flask, request

app = Flask(__name__)

@app.route("/search")
def search():
    q = request.args.get("q", "")
    body = f"<html><body>검색어: {q}<script>window.xss_ran=true;</script></body></html>"
    return body, 200, {
        "Content-Security-Policy": "default-src 'self'; script-src 'self'"
    }

if __name__ == "__main__":
    app.run(port=5195)

3-2. 헤더와 본문을 검사하다

check192.py:

import requests

for port, label in [(5192, "CSP 없음"), (5195, "CSP 있음")]:
    r = requests.get(f"http://127.0.0.1:{port}/search",
                     params={"q": "<img src=x onerror=alert(1)>"})
    print(f"--- {label} (:{port}) ---")
    print("상태:", r.status_code)
    csp = r.headers.get("Content-Security-Policy")
    print("CSP 헤더:", csp if csp else "(없음)")
    print("본문에 페이로드 생존:", "<img src=x onerror=alert(1)>" in r.text)
    print("본문에 인라인 스크립트 생존:", "<script>window.xss_ran" in r.text)

출력 (2026-09-09 실측):

--- CSP 없음 (:5192) ---
상태: 200
CSP 헤더: (없음)
본문에 페이로드 생존: True
본문에 인라인 스크립트 생존: True
--- CSP 있음 (:5195) ---
상태: 200
CSP 헤더: default-src 'self'; script-src 'self'
본문에 페이로드 생존: True
본문에 인라인 스크립트 생존: True

읽는 법: 결정적인 관찰입니다 — CSP가 있어도 본문은 똑같이 오염돼 있습니다. 서버는 페이로드를 그대로보내고, 차이는 헤더 한 줄뿐입니다. CSP는 "오염을 막는 필터"가 아니라 "오염된 문서에 붙는 집행 규칙"입니다. 그래서 CSP만 믿고 출력 이스케이프를 게을리하면, 정책의 허점 하나로 전체가 무너집니다.

3-3. 브라우저가 집행하는 장면 (화면 예시)

두 페이지를 브라우저에서 열면 차이가 보입니다 (화면 예시):

  • CSP 없음: alert(1) 팝업이 뜹니다.
  • CSP 있음: 팝업이 뜨지 않고, 개발자 도구 Console에 이런 메시지가 남습니다:
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".

여러분 환경에서 브라우저로 두 포트를 열어 이 대비를 직접 확인해 보세요. "서버는 오염된 채 보내고, 브라우저가 막는다"는 3-2의 결론이 눈으로 확인됩니다.

3-4. DOM XSS — 소스에서 싱크까지 손으로 추적하기

아래는 DOM XSS가 내장된 페이지의 전형입니다 (PortSwigger DOM 랩들의 구조, 코드 예시):

<script>
  // 소스: location.search — URL의 ? 뒤는 공격자가 링크로 조종 가능
  var query = new URLSearchParams(location.search).get("q");
  // 싱크: innerHTML — 문자열이 HTML 문법으로 해석된다
  document.getElementById("result").innerHTML = "검색어: " + query;
</script>

추적 절차:

  1. 페이지 소스(Ctrl+U)와 개발자 도구 Sources에서 location.innerHTML을 검색합니다.
  2. 소스 후보(location.search 등)에서 나온 변수가 어떤 함수를 거쳐 어디에 대입되는지 따라갑니다.
  3. 싱크가 innerHTML이면 <img src=x onerror=alert(1)>를, location.href 대입이면 javascript:alert(1)을 흘려보냅니다.

읽는 법: 이 흐름에서는 서버가 페이로드를 본 적이 없습니다. ?q= 뒤 값이 브라우저 안에서만 움직입니다. 그래서 서버 측 WAF(웹 방화벽)가 아무리 잘 돼 있어도 DOM XSS는 못 막는 경우가 있습니다 — 방어는 반드시 클라이언트 코드, 즉 싱크 사용 자제(textContent 사용)로 해야 합니다.

3-5. PortSwigger 랩 공략 순서 (화면 예시)

Academy의 "Cross-site scripting" 경로에서 CSP와 DOM 랩을 푸는 권장 순서입니다.

  1. DOM XSS 랩부터: 3-4의 추적 절차를 랩 페이지에 적용합니다. 개발자 도구에서 소스와 싱크를 찾고, 페이로드 링크를 만들어 스스로 열어 봅니다.
  2. CSP 랩: 응답 헤더를 먼저 복사해 허용 목록을 종이에 적습니다. script-src에 있는 각 도메인·키워드('unsafe-inline'? 넓은 CDN?)를 2-2의 목록과 대조해 악용 가능한 것을 찾습니다.
  3. 막히면 랩의 힌트를 보기 전에 정책을 다시 읽으세요 — CSP 랩의 답은 거의 항상 헤더 안에 적혀 있습니다.

4. 미션과 연습문제

미션 — CSP 분석과 DOM 추적 기록

  1. lab192_nocsp.pylab192_csp.py를 띄워 check192.py의 결과를 재현하고, 브라우저에서 두 페이지의 팝업 차이를 눈으로 확인합니다.
  2. script-src 'self' https://cdn.example.com 같은 느슨한 정책으로 헤더를 바꿔 보고, 이 정책의 허점을 세 줄로 분석합니다.
  3. 3-4의 DOM XSS 페이지를 직접 만들고, 소스→싱크 흐름을 화살표로 그린 뒤 페이로드로 팝업을 띄웁니다. 이어서 싱크를 textContent로 바꿔 방어를 확인합니다.
  4. 위키에 CSP우회조건.md — "CSP가 있어도 뚫리는 조건 5가지"를 2-2를 확장해 정리합니다.

연습문제

문제 1. 실측에서 CSP 서버도 본문에 페이로드가 그대로 살아 있었습니다. 이 사실이 말해 주는 CSP의 본질은 무엇인가요?

문제 2. Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com을 읽고, 공격자가 노릴 만한 지점을 설명해 보세요.

문제 3. DOM XSS에서 location.hash를 소스로 쓰는 공격이 서버 로그에 남지 않는 이유를 HTTP 요청의 구조로 설명해 보세요.

문제 4. DOM XSS의 방어가 왜 서버 측 필터보다 클라이언트 코드 수정(싱크 교체)에 달려 있나요?


5. 모범 답안과 완료 기준

미션 모범 답안

검증하는 법: ① 두 서버의 응답에서 "CSP 헤더 유무"와 "본문 페이로드 생존 True"가 모두 확인되는가 (2026-09-09 실측 기준). ② 브라우저에서 CSP 페이지는 팝업이 없고 Console에 차단 메시지가 있는가. ③ 느슨한 정책 분석에 "허용 도메인 경유"라는 표현이 있는가. ④ DOM 페이지에서 innerHTML → 팝업, textContent → 텍스트로만 출력의 대비가 확인되는가.

연습문제 해답

문제 1 해답. CSP는 입력을 정제하는 필터가 아니라, 오염된 문서의 실행 규칙을 브라우저에 지시하는 헤더라는 것입니다. 서버의 취약점(이스케이프 누락)은 그대로 있고, 막는 주체가 브라우저입니다. 그래서 규칙에 허점이 있거나 브라우저가 규칙을 지원하지 않으면 무방비가 됩니다 — 근본 방어는 여전히 출력 이스케이프입니다.

문제 2 해답. script-src에 외부 도메인 cdn.example.com이 허용돼 있습니다. 공격자는 ① 그 CDN에 올라간 취약한 구버전 라이브러리를 로드하거나, ② 그 도메인에서 콘텐츠를 올릴 수 있는 기능(업로드, JSONP)을 찾아, 허용된 도메인 경유로 자기 스크립트를 로드합니다. 인라인은 막혀 있어도 "허용된 출처" 자체가 악용 통로가 되는 구조입니다.

문제 3 해답. URL의 # 뒤(fragment)는 브라우저가 서버에 요청을 보낼 때 제외합니다. https://site/page#<script>...를 열어도 서버에는 /page만 도착합니다. 페이로드가 서버를 한 번도 지나지 않으므로 서버 로그에는 어떤 흔적도 없고, 서버 측 탐지·차단도 불가능합니다.

문제 4 해답. DOM XSS는 데이터가 서버의 응답 생성 과정을 거치지 않고 브라우저 안에서 소스→싱크로 직행하기 때문입니다. 서버가 입력을 필터링할 기회 자체가 없습니다(특히 hash 소스). 따라서 방어는 싱크를 안전한 API로 교체하는 것 — HTML 해석을 일으키는 innerHTML 대신 텍스트만 넣는 textContent를 쓰는 것 — 이 정답입니다.

완료 기준 체크리스트

  • [ ] CSP 헤더의 default-src, script-src, 'self'의 의미를 읽을 수 있다
  • [ ] CSP가 본문을 바꾸지 않고 브라우저가 집행함을 실측으로 확인했다
  • [ ] 느슨한 CSP의 우회 경로(화이트리스트 경유, JSONP, unsafe-inline)를 세 가지 이상 말할 수 있다
  • [ ] 소스와 싱크의 뜻을 들고 대표 예를 각각 둘 이상 말할 수 있다
  • [ ] DOM XSS 페이지를 만들어 소스→싱크 추적과 textContent 방어를 확인했다
  • [ ] 미션: CSP 분석 기록 + CSP우회조건.md 정리를 마쳤다

6. 흔한 실수와 해결

벽 1. CSP를 걸었는데도 팝업이 떠요

증상: 헤더를 붙였는데 브라우저에서 alert이 실행됩니다.

원인 1순위: 헤더 이름·철자 오류(Content-Security-Policy의 하이픈). 2순위: 정책에 'unsafe-inline'이 들어 있습니다. 3순위: 브라우저가 캐시한 옛 응답을 보고 있습니다.

해결: 개발자 도구 Network 탭에서 응답 헤더가 실제로 내려왔는지부터 확인하세요. 3-2의 check192.py처럼 코드로 헤더를 찍어 보는 습관이 가장 확실합니다.

벽 2. 헤더는 있는데 브라우저가 CSP를 적용하지 않아요

원인: CSP는 응답마다 붙어야 합니다. 어떤 경로에는 붙이고 어떤 경로에는 안 붙이면, 안 붙은 경로가 공격로가 됩니다.

해결: Flask라면 미들웨어(@app.after_request)로 모든 응답에 붙이세요. 실무에서는 웹 서버(Nginx 등) 단에서 일괄 부여하는 것이 누락 방지에 좋습니다.

벽 3. DOM XSS 랩에서 페이로드를 넣었는데 아무 일도 없어요

원인 1순위: 소스가 location.hash인데 ?q=에 넣었거나(그 반대도). 소스마다 입력 위치가 다릅니다. 2순위: 싱크가 innerHTML이 아니라 document.write라 컨텍스트가 다릅니다.

해결: 추적 순서를 거꾸로 하지 마세요 — 소스를 먼저 확정하고(코드에서 location. 계열을 찾아), 그 소스가 읽는 위치에 페이로드를 놓으세요.

벽 4. CSP 랩을 어디서부터 손대야 할지 모르겠어요

원인: 페이로드를 먼저 찾고 있습니다. CSP 랩은 페이로드 문제가 아니라 독해 문제입니다.

해결: 절차를 강제하세요 — ① 응답 헤더 전체 복사, ② script-src의 허용 목록을 하나씩 줄 바꿈으로 적기, ③ 각 항목에 "이 출처에 내 코드를 올릴 수 있나?" 질문 던지기. 셋을 하고도 답이 안 보이면 그때 힌트를 보세요.

벽 5. textContent로 바꿨는데도 실행돼요

원인: 바꾼 곳이 싱크가 아니거나, 싱크가 여러 개인데 하나만 바꿨습니다. 또는 값이 다른 흐름(eval, setTimeout(문자열))으로도 새고 있습니다.

해결: 그 값이 도달하는 모든 싱크를 찾으세요. 검색창에 변수명을 넣어 참조를 전부 열거하는 것이 빠릅니다. eval과 문자열 인자 setTimeout도 싱크입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
CSP 실행 가능한 리소스 출처를 서버가 헤더로 선언, 브라우저가 집행하는 방어막
script-src 'self' 스크립트는 같은 출처에서만 로드 — 인라인 스크립트 차단 효과
화이트리스트 우회 허용된 도메인(CDN·JSONP)을 경유해 악성 스크립트 로드
소스(source) 공격자 조종 입력 지점 — location.search, location.hash
싱크(sink) 입력이 코드가 되는 지점 — innerHTML, eval
DOM Invader 소스→싱크 흐름을 자동 탐지하는 Burp 내장 브라우저 확장

오늘의 명령어와 페이로드

명령·코드 하는 일
Content-Security-Policy: default-src 'self'; script-src 'self' 최소 CSP 정책 부여
r.headers.get("Content-Security-Policy") 응답의 CSP 유무 코드로 확인
new URLSearchParams(location.search).get("q") DOM XSS의 전형적 소스
element.innerHTML = 입력 위험한 싱크 (교체: textContent)
개발자 도구에서 location. / innerHTML 검색 소스·싱크 수동 추적의 시작

명령어보다 중요한 감각

고급 XSS에서 페이로드 암기력은 부차적입니다. 진짜 실력은 두 가지 독해입니다 — 헤더를 읽는 독해(CSP의 허용 목록에서 약한 고리 찾기)와 코드를 읽는 독해(데이터가 소스에서 싱크로 흐르는 경로 추적). 그리고 방어의 교훈도 대칭입니다: CSP는 두 번째 방어선이지 첫 번째가 아닙니다. 오염을 근원에서 막는 출력 이스케이프와 안전한 싱크 선택이 먼저이고, CSP는 그 위의 보험입니다.


전부 체크되면 Step 192 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.