Step 138. XSS 기초 — Reflected와 Stored, 브라우저 안의 배신자

Step 138. XSS 기초 — Reflected와 Stored, 브라우저 안의 배신자

Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 3시간

전제: Step 134의 쿠키와 HttpOnly, Step 135~137의 인젝션 사고방식(입력이 문법이 된다)을 마쳤다.

  • 준비물: 파이썬 3 + Flask, curl 또는 파이썬 requests, 브라우저(팝업 확인용, 선택).
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 주의: 이 환경에는 브라우저 자동화가 없어서, 팝업이 뜨는 장면은 화면 예시로 표기합니다. 대신 XSS의 본질 — 내 스크립트가 응답 HTML에 살아 있는가 — 는 응답 본문을 직접 검사해 전부 실측합니다. 브라우저에서 확인할 때도 오늘 배우는 "소스 보기로 생사 확인" 절차가 그대로 쓰입니다.

지금까지의 인젝션(SQL, 명령어)은 서버를 공격했습니다. XSS(Cross-Site Scripting) 는 결이 다릅니다 — 서버를 거쳐 다른 사용자의 브라우저를 공격합니다. 내가 심은 <script>가 피해자의 브라우저에서 실행되면, 그 페이지의 권한으로 무엇이든 할 수 있습니다. 대표 목표가 Step 134의 그것, 쿠키 탈취입니다. 오늘은 XSS의 두 종류 — 일회성 Reflected와 잔류하는 Stored — 를 직접 재현하고, 왜 Stored가 훨씬 위험한지를 구조로 이해합니다.


1. 학습 목표

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

  • XSS가 SQL 인젝션과 다른 점(공격 대상이 브라우저)을 설명한다
  • Reflected XSS와 Stored XSS의 차이를 피해 범위 관점에서 구분한다
  • 응답 본문을 검사해 페이로드의 생사를 확인하는 절차를 밟는다
  • <script>가 막힐 때 변형 페이로드(<img onerror>, <svg onload>)의 원리를 설명한다
  • 출력 이스케이프(htmlspecialchars, html.escape)가 왜 근본 방어인지 실험으로 보인다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask(취약 게시판), curl/requests(응답 검사), 브라우저(팝업 확인), DVWA(선택, 출력 예시)
오늘의 페이로드·코드 <script>alert(1)</script>, <img src=x onerror=alert(1)>, html.escape()
필요한 개념 출력 컨텍스트(입력이 HTML 어디에 박히는가), Reflected vs Stored, HTML 이스케이프
오늘의 산출물 lab138.py(취약 게시판) + Reflected/Stored 재현 기록

2-1. XSS의 뿌리 — 익숙한 그 뿌리

Step 135에서 외운 문장을 다시 꺼냅니다: 입력이 데이터가 아니라 문법이 될 때 인젝션이 태어난다. XSS도 같습니다. 다만 문법의 주인이 SQL이 아니라 HTML이고, 실행 장소가 DB가 아니라 브라우저입니다.

서버가 사용자 입력 <script>alert(1)</script>을 "문자열"로 저장·출력하면 화면에는 그 글자가 보일 뿐입니다. 그런데 HTML 문서에 그대로 꿰어 넣으면, 브라우저는 그것을 태그로 해석해 스크립트를 실행합니다. 코드와 데이터를 한 그릇에 섞는 실수 — 인젝션 패밀리의 공통 조상입니다.

2-2. Reflected vs Stored — 1회용과 잔류탄

  • Reflected XSS: 입력이 요청의 응답에 즉시 반사됩니다. 검색창에 넣은 값이 결과 페이지에 다시 나오는 경우. 공격하려면 피해자에게 조작된 링크를 누르게 해야 하고, 실행은 그 사람에게 1회입니다.
  • Stored XSS: 입력이 서버에 저장되고, 그 이후 페이지를 여는 모든 방문자에게 실행됩니다. 게시판 글, 댓글, 프로필 이름이 무대. 링크를 뿌릴 필요도 없이 피해자가 알아서 찾아옵니다.

그래서 Stored가 훨씬 위험합니다 — 피해자 수가 "링크를 누른 사람"이 아니라 "글을 본 사람 전원"이 되니까요. 오늘 둘 다 재현해 이 차이를 몸으로 확인합니다.

2-3. alert(1)은 증명일 뿐이다

입문 실습의 팝업 alert(1)은 목표가 아니라 증명서입니다. "여기서 스크립트가 실행된다"는 사실의 증명. 실전의 페이로드는 alert(document.cookie)로 시작해 공격자 서버로 쿠키를 보내는 코드로 이어집니다. 그리고 Step 134에서 배운 HttpOnly가 정확히 여기서 방패가 됩니다 — JS가 쿠키를 못 읽게 하니까요. 챕터들이 이렇게 맞물립니다.

2-4. 출력 컨텍스트 — 내 입력이 어디에 박혔는가

같은 입력이라도 박히는 위치에 따라 필요한 페이로드가 다릅니다.

  • HTML 본문(텍스트 자리): <script>...</script>가 그대로 먹힘
  • 태그 속성 안(<input value="여기">): 먼저 "로 속성을 닫아야 함
  • 자바스크립트 문자열 안: 따옴표 탈출이 먼저

오늘은 첫 번째(본문 컨텍스트)에 집중하고, "위치를 먼저 본다"는 습관만 심습니다.


3. 따라 하기

3-1. 취약 게시판 띄우기

lab138.py (교육용 취약 코드 — 어디에도 배포 금지):

from flask import Flask, request

app = Flask(__name__)
GUESTBOOK = []  # Stored 실험용 방명록 (메모리 저장)

PAGE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>취약 게시판</title></head>
<body>{body}</body></html>"""

@app.route("/hello")
def hello():
    """Reflected: 입력을 그대로 응답에 반사."""
    name = request.args.get("name", "")
    return PAGE.format(body=f"<h1>안녕하세요, {name}님!</h1>")

@app.route("/guestbook", methods=["GET", "POST"])
def guestbook():
    """Stored: 입력을 저장하고, 이후 모든 방문자에게 보여 준다."""
    if request.method == "POST":
        GUESTBOOK.append(request.form.get("msg", ""))
        return "글이 등록되었습니다."
    items = "".join(f"<li>{m}</li>" for m in GUESTBOOK)
    return PAGE.format(body=f"<h1>방명록</h1><ul>{items}</ul>")

if __name__ == "__main__":
    app.run(port=5138)
python lab138.py

취약점은 한 줄 — 입력을 아무 처리 없이 HTML에 {body}로 꿰어 넣는다는 것입니다.

3-2. Reflected — 정상과 삽입

정상 입력 (2026-09-09 실측, name=alice):

<body><h1>안녕하세요, alice님!</h1></body>

공격 입력 (name=<script>alert(1)</script>) (2026-09-09 실측, 응답 본문):

<body><h1>안녕하세요, <script>alert(1)</script>님!</h1></body>

읽는 법: 응답 HTML 안에 내 <script>태그로 살아 있습니다. 이 응답을 받는 브라우저는 저 부분을 텍스트가 아니라 스크립트로 실행합니다 — 브라우저에서 이 주소를 열면 1 팝업이 뜹니다 (팝업 장면은 화면 예시). curl/requests로는 팝업이 안 뜨지만, 실행 여부의 판정은 본문 검사로 충분합니다 — 태그가 살아 있으면 브라우저는 반드시 실행하니까요.

: 이것이 Reflected의 구조입니다 — 공격 코드가 요청(URL)에 실려 가고, 응답에 반사되어 돌아옵니다. 그래서 피해자에게는 "이 링크를 눌러"가 필요합니다. URL이 ...?name=<script>...인 링크를요.

3-3. Stored — 저장하고, 기다리기

1단계 — 등록 (2026-09-09 실측):

curl -X POST "http://127.0.0.1:5138/guestbook" --data-urlencode "msg=<script>alert(document.cookie)</script>"
→ 글이 등록되었습니다.

2단계 — 다른 방문자의 열람 (2026-09-09 실측, 새 요청으로 GET):

curl "http://127.0.0.1:5138/guestbook"
<body><h1>방명록</h1><ul><li><script>alert(document.cookie)</script></li></ul></body>

읽는 법: 등록 요청의 응답("글이 등록되었습니다")에는 스크립트가 없었습니다. 그런데 나중의 열람 요청에서 살아서 나왔습니다. 내 브라우저에서 등록을 마친 뒤, 시크릿 창이나 다른 브라우저로 방명록만 열어 보세요 — 팝업이 뜹니다 (화면 예시). 공격자는 이미 자리를 떴는데 공격은 계속 실행됩니다.

: 이것이 Stored의 잔류성입니다. 서버가 악성 입력을 DB(여기서는 메모리 리스트)에 저장했고, 이후 모든 열람 응답에 실어 보냅니다. Reflected가 "낚시 링크"라면 Stored는 "독이 든 게시글"입니다. 피해 범위가 열람자 전원으로 번집니다.

3-4. 필터가 있을 때 — 변형 페이로드

DVWA의 Medium 난이도처럼 <script>만 지우는 서툰 필터를 만났다고 합시다 (화면·출력 예시). 우회의 사고방식은 두 갈래입니다.

  1. 대소문자/중첩: 필터가 정확히 <script>만 찾는다면 <Script>가 통과할 수 있습니다 (HTML 태그는 대소문자를 가리지 않음). 지워진 흔적을 이용해 <scr<script>ipt>처럼 중첩하는 방법도 있습니다.
  2. 다른 태그: 스크립트는 <script>만으로 실행되지 않습니다.
<img src=x onerror=alert(1)>     ← 없는 이미지라 로딩 실패 → onerror 실행
<svg onload=alert(1)>            ← SVG 로딩 완료 → onload 실행

공통점은 이벤트 핸들러 속성(onerror, onload)입니다 — HTML에는 스크립트를 실행하는 문법이 여러 개라서, <script> 하나만 막는 필터는 체질상 새어 나갑니다. 단, DVWA 기준 Medium 이상은 이들도 차단합니다 — 난이도별로 무엇이 막히는지 직접 대조해 보는 것이 DVWA의 묘미입니다.

3-5. 컨텍스트 확인 습관 — 소스 보기가 먼저

alert가 안 뜰 때의 점검 순서는 정해져 있습니다. 소스 보기(응답 본문)에서 내 입력이 어떻게 박혔는지부터 봅니다.

  • 그대로 박혔다 → 실행 조건 문제 (태그 미완성, 브라우저 차단)
  • &lt;script&gt;로 바뀌었다 → 서버가 이스케이프함 — 이 입력 경로는 종료
  • <script>만 증발했다 → 필터 — 3-4의 변형을 시도

3-2에서 curl로 본문을 본 것이 바로 이 절차입니다. 브라우저의 "소스 보기(Ctrl+U)"와 정확히 같은 정보를 봅니다.

3-6. 방어 버전 — 이스케이프의 위력

lab138.py에 방어 경로를 추가하고 재시작합니다.

import html

@app.route("/hello_safe")
def hello_safe():
    """방어: 출력 시 HTML 이스케이프 (DVWA Impossible의 htmlspecialchars에 해당)."""
    name = request.args.get("name", "")
    return PAGE.format(body=f"<h1>안녕하세요, {html.escape(name)}님!</h1>")

출력 (2026-09-09 실측, 같은 공격 입력):

<body><h1>안녕하세요, &lt;script&gt;alert(1)&lt;/script&gt;님!</h1></body>

읽는 법: <&lt;로, >&gt;로 바뀌었습니다. 브라우저는 &lt;글자 "<"로 화면에 그릴 뿐 태그로 해석하지 않습니다. 입력은 여전히 화면에 보이지만, 문법으로는 영원히 승격되지 않습니다. DVWA의 Impossible 코드에 있는 htmlspecialchars()가 PHP에서 하는 일이 정확히 이것입니다 (출력 예시).

: SQLi의 파라미터 바인딩처럼, XSS의 출력 이스케이프도 "입력을 데이터로 가두는" 기술입니다. 인젝션 패밀리의 방어는 하나로 통합니다 — 코드와 데이터를 섞지 말 것.


4. 미션과 연습문제

미션 — 두 종류의 XSS 재현 보고서

  1. lab138.py를 완성하고 Reflected(3-2)·Stored(3-3)의 요청과 응답 본문을 캡처합니다 — 스크립트가 태그로 살아 있는 부분을 표시하세요.
  2. Stored 실험에서 "등록 응답에는 없었는데 열람 응답에는 있었다"는 대비가 보이게 캡처를 배치합니다.
  3. 방어 경로(3-6)에 같은 페이로드를 넣어 이스케이프된 본문을 캡처합니다.
  4. 브라우저가 있다면 실제 팝업 장면을, DVWA가 있다면 Reflected/Stored 메뉴의 alert 성공 화면을 추가합니다 (둘 다 선택 사항).
  5. 위키에 XSS기초정리.md — Reflected/Stored 비교 표(실행 시점·피해 범위·전파 경로)와 "이스케이프가 막는 이유" 3줄을 정리합니다.

연습문제

문제 1. XSS가 SQL 인젝션과 같은 뿌리(코드와 데이터의 혼합)에서 자랐다는 말을, 각각의 "문법"과 "실행 장소"를 대조하며 설명해 보세요.

문제 2. Stored XSS가 Reflected보다 위험한 이유를 피해 범위와 전파 경로의 관점에서 설명해 보세요.

문제 3. <script>가 필터로 지워질 때 <img src=x onerror=alert(1)>이 통과할 수 있는 이유를 "이벤트 핸들러" 개념으로 설명해 보세요.

문제 4. html.escape()를 거친 <script>가 왜 실행되지 않는지, 브라우저의 해석 관점에서 설명해 보세요. 그리고 Step 134의 HttpOnly와 출력 이스케이프가 막는 것이 어떻게 다른지 한 줄씩 써 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

검증하는 법: ① Reflected 캡처의 본문에 <script>alert(1)</script>가 태그 형태로 존재하는가 (2026-09-09 실측 기준). ② Stored 캡처에서 POST 응답과 GET 응답이 대비되는가 — 등록 때는 "글이 등록되었습니다"뿐이고, 열람 응답의 <li> 안에 스크립트가 있는가. ③ 방어 경로 본문이 &lt;script&gt;로 시작하는가. ④ 정리 표에 "Reflected = 링크를 누른 1인 / Stored = 열람자 전원"의 대비가 있는가.

연습문제 해답

문제 1 해답. SQLi는 입력이 SQL 문법이 되어 DB 서버에서 실행되고, XSS는 입력이 HTML/자바스크립트 문법이 되어 피해자의 브라우저에서 실행됩니다. 둘 다 "사용자 입력을 코드와 같은 그릇에 섞어 출력/실행한" 것이 뿌리이고, 방어도 같은 철학(입력을 데이터로 가두기 — 바인딩 / 이스케이프)입니다.

문제 2 해답. Reflected는 피해자에게 조작 링크를 누르게 해야 하고 실행도 그 1회입니다 — 전파에 사회공학이 필요합니다. Stored는 서버에 저장되어 이후 열람하는 모든 사람의 브라우저에서 자동 실행됩니다 — 공격자가 손을 뗀 뒤에도 피해가 커집니다. 3-3 실측에서 등록 후의 별개 요청에 스크립트가 살아 있던 것이 그 증거입니다.

문제 3 해답. <img> 태그의 onerror 속성은 이미지 로딩이 실패했을 때 실행되는 자바스크립트를 담는 정식 HTML 문법이기 때문입니다. src=x처럼 존재하지 않는 주소를 주면 로딩이 반드시 실패하므로 alert(1)이 실행됩니다. 필터가 <script>라는 문자열만 찾는다면 이 태그는 무사히 통과합니다 — 스크립트 실행 통로가 여러 개라는 HTML의 구조적 사실이 필터 우회의 근거입니다.

문제 4 해답. 이스케이프를 거치면 <가 문자 참조 &lt;가 되어, 브라우저의 HTML 파서가 그것을 태그의 시작으로 인식하지 않고 화면에 "<" 글자로만 그리기 때문입니다. 태그로 파싱되지 않으면 스크립트 실행도 없습니다. HttpOnly와의 차이: 출력 이스케이프는 스크립트 심기 자체를 막고, HttpOnly는 스크립트가 심어져도 쿠키 탈취를 막습니다 — 둘은 겹이 다른 방어입니다.

완료 기준 체크리스트

  • [ ] XSS의 실행 장소가 피해자 브라우저임을 설명할 수 있다
  • [ ] Reflected와 Stored의 차이를 실행 시점·피해 범위로 말할 수 있다
  • [ ] 응답 본문 검사로 페이로드의 생사를 판정하는 절차를 밟을 수 있다
  • [ ] Reflected 삽입과 Stored 저장-열람을 로컬 서버에서 재현했다
  • [ ] <img onerror>, <svg onload> 변형의 원리(이벤트 핸들러)를 설명할 수 있다
  • [ ] html.escape / htmlspecialchars가 하는 일을 실험으로 확인했다
  • [ ] 미션: 두 종류의 재현 보고서를 작성했다

6. 흔한 실수와 해결

벽 1. alert가 안 떠요 — 어디서부터 봐야 하죠?

증상: 페이로드를 넣었는데 브라우저가 조용합니다.

원인은 셋 중 하나: ① 서버가 이스케이프했거나, ② 필터가 지웠거나, ③ 박힌 위치가 실행 불가능한 곳(속성 안 등)입니다.

해결: 순서가 정해져 있습니다 — 소스 보기(또는 curl 본문)로 내 입력의 최종 형태부터 확인. 3-5의 삼분류에 대입하세요. 팝업 유무만 보고 디버깅하면 길을 잃습니다. 본문이 곧 답입니다.

벽 2. curl로 넣었더니 <%3C로 찍혀요

증상: 서버가 받은 값이 인코딩된 채로입니다.

원인: URL 안의 특수문자는 인코딩이 필요한데, --data-urlencode 없이 손으로 붙이면 서버 해석이 엇갈립니다. 반대로 인코딩된 값을 서버가 디코딩 안 하고 출력하기도 합니다.

해결: curl -G URL --data-urlencode "name=<script>alert(1)</script>" 또는 파이썬 requests.get(url, params={...}). 인코딩은 도구에 맡기세요.

벽 3. Stored인데 등록 직후 새로고침해도 안 보여요

증상: 글을 썼는데 방명록에 없습니다.

원인: 서버를 재시작하면 메모리 저장소(GUESTBOOK 리스트)가 초기화됩니다. 또는 POST가 GET으로 잘못 갔습니다.

해결: 등록(POST) → 열람(GET)을 같은 서버 실행 안에서 연속으로 수행하세요. 메모리 방명록의 한계이자, 실제 서비스가 DB에 저장하는 이유이기도 합니다.

벽 4. <Script>로 바꿨는데도 안 돼요

증상: 대소문자 변형이 안 먹힙니다.

원인: 그 서버는 대소문자 무시 필터거나, 아예 이스케이프 방식입니다. 변형 페이로드는 "서툰 문자열 치환 필터"에만 통합니다.

해결: 소스 보기에서 <Script>가 그대로 살아 있는지부터 보세요. 살아 있는데 실행이 안 됐다면 다른 문제, &lt;로 바뀌었다면 이스케이프 — 이 경로는 종료하고 다른 입력 지점(다른 파라미터, 다른 페이지)을 찾습니다.

벽 5. 브라우저가 팝업을 막았어요 (XSS Auditor 계열)

증상 (화면 예시): 옛날 Chrome에서는 ERR_BLOCKED_BY_XSS_AUDITOR가 떴습니다.

원인: 과거 브라우저에는 Reflected XSS 감지기가 있었습니다 — 요청의 스크립트가 응답에 그대로 반사되면 차단하는 방식. 현대 Chrome은 이 기능을 제거했습니다 (우회가 너무 쉽고 오탐이 많아서).

해결·교훈: 브라우저 방어에 기대지 마세요. 그리고 이 챕터의 본문 검사 절차(3-5)는 브라우저가 차단하든 말든 성립합니다 — 서버 응답에 스크립트가 살아 있다는 사실 자체가 취약점의 증거입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
XSS 입력 스크립트가 피해자 브라우저에서 실행되는 인젝션
Reflected XSS 요청에 실려 응답에 반사 — 링크를 누른 1인에게 1회
Stored XSS 서버에 저장되어 열람자 전원에게 반복 실행 — 훨씬 위험
출력 컨텍스트 입력이 박히는 HTML 위치 — 위치마다 페이로드가 다르다
이벤트 핸들러 onerror/onload 등 — <script> 없이도 스크립트를 실행하는 문법
HTML 이스케이프 <&lt; — 태그 해석을 봉쇄하는 근본 방어

오늘의 명령어와 페이로드

명령·페이로드 하는 일
<script>alert(1)</script> 실행 증명의 기본 페이로드
<script>alert(document.cookie)</script> 쿠키 접근 증명 (Stored 실습)
<img src=x onerror=alert(1)> 이벤트 핸들러 우회
curl -G URL --data-urlencode "name=..." Reflected 시험 (응답 본문 확인)
curl -X POST URL --data-urlencode "msg=..." Stored 심기
html.escape(입력) / htmlspecialchars(입력) 출력 이스케이프 방어

명령어보다 중요한 감각

XSS 공부의 절반은 공격이고 절반은 확인 절차입니다 — "팝업이 떴냐"가 아니라 "응답 본문에 내 태그가 살아 있냐"를 보는 습관. 그리고 Stored의 잔류성을 직접 봤으니 이제 게시판·댓글·프로필 입력란이 다르게 보일 것입니다. SQLi의 바인딩, XSS의 이스케이프 — 인젝션의 방어는 결국 한 문장, 코드와 데이터를 섞지 마라. 이 문장이 이번 주 모든 챕터를 관통합니다.


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