[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:53:06 · 조회 21
CSRF는 "공격자가 내 브라우저 대신 요청을 보내는 취약점"이라고 설명하면 절반만 맞다. 진짜 질문은 **"브라우저는 왜 내가 요청하지도 않은 걸 내 자격(쿠키)으로 자동 실행해버리는가"**다. 이 글은 이 블로그의 실제 CSRF 방어 코드(middleware/csrf.js)를 놓고, 브라우저 네트워크 스택의 쿠키 자동 첨부 메커니즘부터 방어를 무력화하는 체이닝까지 따라간다.
middleware/csrf.js는 세션에 저장된 랜덤 토큰과, 폼이 제출한 _csrf 값을 비교하는 **동기화 토큰 패턴(Synchronizer Token Pattern)**이다.
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(24).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
const token = req.body && req.body._csrf;
if (!token || token !== req.session.csrfToken) {
// 거부
}
이 방어가 성립하는 이유는 단 하나 — req.session.csrfToken은 서버가 세션 스토어에 보관하고, 페이지를 렌더링할 때만 폼 안에 hidden input으로 심어주기 때문에, 공격자 사이트는 이 값을 알 방법이 없다. 공격자가 아무리 피해자 브라우저를 통해 요청을 강제로 쏘게 만들어도, _csrf 필드에 맞는 값을 채우지 못하면 서버가 거부한다.
CSRF가 성립하는 근본 원인은 서버 로직이 아니라 브라우저 네트워크 스택의 쿠키 자동 첨부 알고리즘에 있다. 브라우저 내부의 쿠키 저장소(Cookie Jar)는 요청을 보낼 때마다 아래 알고리즘(RFC 6265)을 그대로 수행한다.
SameSite 속성이 있으면 요청이 **동일 사이트에서 시작됐는지(top-level navigation, same-site iframe 등)**를 추가로 판단해 첨부 여부 결정Cookie: 헤더에 실어 요청과 함께 전송3번의 SameSite 검사가 추가되기 전까지(2016년 이전 기본 동작), 브라우저는 도메인/경로만 맞으면 요청의 출처가 어디든 쿠키를 무조건 실어 보냈다. 이게 CSRF의 정확한 근본 원인이다 — evil.com에 있는 <form>이 bank.com으로 요청을 보내도, 브라우저 관점에서는 그냥 "bank.com에 대한 요청이니 bank.com 쿠키를 첨부"할 뿐이다. 브라우저는 **요청의 목적지(도메인)**만 보지 **요청의 출발지(어떤 페이지가 이 요청을 만들었는가)**는 쿠키 첨부 판단에 근본적으로 관여시키지 않는다는 게 이 취약점 클래스의 뿌리다.
SameSite=Lax(현재 대부분 브라우저의 기본값)가 이걸 상당 부분 완화하지만 완전하진 않다 — GET 방식의 top-level navigation은 Lax에서도 쿠키가 첨부된다. 상태를 변경하는 작업을 GET으로 구현해둔 레거시 엔드포인트(GET /account/delete?id=1 같은)가 있다면 SameSite=Lax만으로는 못 막는다는 뜻이다. 이 블로그의 POST /blog/:slug/delete처럼 상태 변경을 반드시 POST로만 받게 설계한 것도 이 완화를 보강하는 조치다.
evil.com에 아래와 같은 자동 제출 폼을 심어둠<form action="https://martina7.com/blog/some-post/delete" method="POST" id="f">
</form>
<script>document.getElementById('f').submit();</script>
evil.com을 방문 → 브라우저가 위 스크립트를 실행하며 martina7.com으로 POST 요청을 자동 전송martina7.com이라는 이유만으로 피해자의 세션 쿠키를 Cookie: 헤더에 자동 첨부req.session을 정상 복원 — 인증(Authentication) 관점에서는 완전히 정상 요청_csrf 필드가 폼에 없으므로 verifyCsrfToken 미들웨어에서 !token 조건에 걸려 즉시 거부 — 인증은 통과했지만 "이 요청이 진짜 사용자 의도로 만들어졌는가"를 검증하는 별도 계층에서 막힌 것이 핵심이다. 이 미들웨어가 없었다면 4번에서 바로 삭제가 실행됐을 것이다.동기화 토큰 패턴의 유일한 전제는 "공격자가 토큰 값을 모른다"는 것이다. 만약 대상 사이트에 XSS가 하나라도 있다면, 공격자 페이지가 아니라 취약한 사이트 자신의 페이지에서 실행되는 스크립트가 되므로 Same-Origin Policy에 걸리지 않고 DOM에서 토큰을 직접 읽어올 수 있다.
fetch('/blog/new').then(r => r.text()).then(html => {
const token = html.match(/name="_csrf" value="([^"]+)"/)[1];
fetch('/blog/victim-post/delete', { method: 'POST', headers: {'Content-Type':'application/x-www-form-urlencoded'}, body: `_csrf=${token}` });
});
CSRF 방어(토큰)와 XSS 방어(이스케이프)는 서로 다른 취약점 클래스를 막는 것 같지만, 실제로는 XSS 하나가 CSRF 방어의 유일한 전제조건을 정확히 무너뜨린다. 이게 XSS 글에서 다룬 체이닝과 같은 지점에서 만난다 — 보안 점검에서 "XSS가 있으면 CSRF는 이미 뚫린 것으로 간주"하는 이유가 여기 있다.
공격자가 자기 계정으로 로그인시키는 CSRF를 피해자 브라우저에서 실행시키면, 피해자가 모르는 사이 공격자 계정으로 로그인된 상태가 된다. 이 상태에서 피해자가 민감한 정보(결제 정보, 개인정보)를 입력하면 그게 공격자 계정에 저장되는 시나리오 — 일반적인 "권한 탈취"가 아니라 거꾸로 피해자를 공격자의 계정에 가둬서 데이터를 흘리게 만드는 방향의 체인이라 자주 간과된다.
CSRF 자체는 상태 변경에 특화되어 있지만, 여기에 /redirect?url=처럼 검증되지 않은 리다이렉트 엔드포인트가 있으면 CSRF로 만든 악성 폼의 action을 신뢰할 수 있는 도메인처럼 보이게 위장해 피해자의 의심을 낮추는 데 활용된다.
서버가 사용자 요청을 받아 대신 외부 URL을 요청하는 기능(SSRF 글 참고)이 있다면, CSRF로 그 요청 자체를 피해자 세션 권한으로 강제 실행시켜서 피해자만 접근 가능한 내부 리소스에 대한 SSRF를 공격자가 원격에서 트리거하는 조합도 가능하다 — CSRF가 "실행 방아쇠", SSRF가 "실제 피해"를 담당하는 체인이다.
SameSite=Lax(또는 Strict) 쿠키 속성을 기본으로 — 단 GET 기반 상태 변경 엔드포인트가 없는지 반드시 함께 점검CSRF를 "브라우저가 대신 요청을 보내는 문제"가 아니라 **"브라우저의 쿠키 자동 첨부 알고리즘이 요청의 출처를 판단 기준에 넣지 않는다는 구조적 특성"**으로 이해하면, 왜 SameSite 하나로는 부족하고 토큰 검증이 여전히 필요한지, 그리고 왜 XSS 한 건이 CSRF 방어 전체를 무너뜨리는 체인의 시작점이 되는지가 같은 논리로 설명된다.