사이트 간 요청 위조(CSRF, CWE-352): 동작 원리와 방어 방법
다른 사이트의 페이지가 로그인한 사용자의 브라우저로 하여금 당신의 앱에 데이터를 바꾸는 요청을 보내게 할 수 있고, 브라우저는 세션 쿠키를 자동으로 붙이므로 쿠키만 확인하는 라우트는 그 동작을 실행합니다. 동기화 토큰, SameSite 쿠키, Sec-Fetch-Site 검사가 이를 어떻게 막는지 Flask와 Flask-WTF 예제로 설명합니다.
알기 쉬운 설명 (ELI5)
당신의 사무실에는 '발송' 트레이에 놓인 서명된 요청서라면 무엇이든 처리해 주는 우편실이 있습니다. 그 트레이에는 당신만 손이 닿기 때문에 우편실은 요청서를 믿습니다. 한 방문객이 나가는 길에 봉인된 봉투를 트레이에 넣어 달라고 부탁합니다. 안에는 '내 모든 소포의 배송지를 바꿔 주세요'라는 요청서가 들어 있습니다. 우편실은 그것이 당신 트레이에 있으니 당신이 쓴 것이라 여기고 처리합니다. CSRF도 똑같습니다. 브라우저는 은행으로 가는 모든 요청에 세션 쿠키를 붙이는데, 다른 웹사이트가 시작한 요청도 예외가 아닙니다. 해결책은 당신 사무실의 페이지만 찍을 수 있는 비밀 도장을 두어, 도장이 없는 요청서는 우편실이 거절하게 하는 것입니다.
핵심 개념 및 용어
- 주변 자격 증명(ambient credentials)
- 브라우저는 어떤 페이지가 시작했든 상관없이 사이트로 가는 요청에 쿠키와 HTTP 인증을 붙입니다.
evil.example에서app.example로 가는 요청도 쿠키의SameSite속성이 막지 않는 한app.example의 세션 쿠키를 가지고 갑니다. - 쿠키의 SameSite 속성
Strict는 모든 교차 사이트 요청에서 쿠키를 보내지 않고,Lax는 최상위 GET 탐색에서만 보내며,None은 항상 보냅니다(Secure필요). Chromium은 속성이 없는 쿠키를Lax로 취급하지만 Firefox와 Safari는 그렇지 않으며, '같은 사이트'에는 형제 서브도메인도 포함됩니다.- 동기화 토큰
- 세션에 묶인 무작위 값으로, 서버가 모든 폼이나 페이지에 넣고 안전하지 않은 모든 요청에서 요구합니다. 다른 사이트는 이 값을 읽을 수 없으므로 유효한 요청을 위조할 수 없습니다. Flask-WTF의
CSRFProtect는csrf_token()으로 토큰을 발급하고csrf_token필드나X-CSRFToken헤더를 확인합니다. - Fetch Metadata(Sec-Fetch-Site)
- 최신 브라우저는 모든 요청에
Sec-Fetch-Site: cross-site,same-site,same-origin,none중 하나를 보내며, 페이지는 이를 위조할 수 없습니다.same-origin이 아닌 안전하지 않은 메서드를 거부하면 서버 측 검사 한 번으로 CSRF를 막을 수 있습니다. - GET으로 상태 변경
- GET으로 데이터를 바꾸는 라우트는
<img>태그나 링크 하나로 실행될 수 있고,SameSite=Lax도 최상위 GET 탐색에서는 쿠키를 보냅니다.request.values는 폼 본문뿐 아니라 쿼리 문자열도 받아들입니다.
단계별 공격 실행 흐름
피해자가 로그인한 상태
app.example의 세션 쿠키를 가지고 있으며, 이 쿠키는 SameSite=None으로, 또는 Lax를 기본값으로 쓰지 않는 브라우저에서 속성 없이 전송됩니다.
공격자가 통제하는 페이지를 엶
페이지에는 https://app.example/account/email로 전송되고 로드되자마자 스스로 제출되는 숨겨진 폼이 있습니다. 무해한 테스트 값 email=test@example.com을 보내는 폼이 이 경우를 보여 줍니다.
브라우저가 쿠키와 함께 요청을 보냄
요청에는 피해자의 세션이 담겨 있고, 서버는 폼이 다른 사이트에 있었다는 사실을 알 수 없습니다.
서버가 계정을 변경함
이메일 주소가 공격자의 것으로 바뀌고, 공격자는 비밀번호를 재설정해 계정을 탈취합니다. 2008년 Zeller와 Felten은 ING Direct, YouTube, The New York Times에서 이런 종류의 CSRF 취약점을 보여 주었습니다.
소스 코드 비교: 취약한 구현 vs 보안 패치
# app.py: 사용자가 의도했다는 증거는 세션 쿠키뿐이다
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET도 동작하고, request.values는 쿼리 문자열까지 읽는다
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
# app.py: 토큰, SameSite 쿠키, Fetch Metadata를 함께 쓴다
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# 모든 POST에 csrf_token 필요; 폼은 {{ csrf_token() }}을 렌더링하고 fetch()는 X-CSRFToken을 보낸다
csrf = CSRFProtect(app)
# 다른 사이트가 시작한 쓰기는 라우트 실행 전에 거부된다
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
abort(403)
# POST만, 데이터는 폼 본문에서
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
엔지니어링 및 시스템 보안 강화 체크리스트
- 상태 변경은 POST, PUT, PATCH, DELETE로만 하고, 폼 데이터는 본문(
request.form)에서 읽으며request.values나 쿼리 문자열에서는 읽지 않습니다. - 앱 전체에 프레임워크의 CSRF 미들웨어(
CSRFProtect, DjangoCsrfViewMiddleware, Spring Securitycsrf())를 켜고, 모든 폼과 AJAX 요청에 토큰을 넣습니다. - 세션 쿠키에는
Secure,HttpOnly,SameSite=Lax또는Strict를 설정하고,SameSite=None은 교차 사이트 iframe 안에서 동작해야 하는 쿠키에만 씁니다. Sec-Fetch-Site헤더가cross-site또는same-site인 안전하지 않은 요청을 거부합니다.- 상태를 바꾸는 각 라우트에 토큰 없는 요청을 보내 400이나 403을 기대하는 CI 테스트를 추가합니다.