왜 CSRF를 공부하게 되었나
저는 웹 서비스를 만들면서 보안은 나중에 생각하자는 마인드였습니다. 일단 기능이 돌아가게 만드는 게 우선이었거든요. 그런데 어느 날 보안 감사 리포트를 받았는데, "CSRF 취약점" 항목이 빨간색으로 표시되어 있었습니다.
처음엔 "이게 뭐지?" 싶었습니다. XSS는 들어봤는데 CSRF는 생소했거든요. 그래서 제대로 이해해보기로 했습니다. 나중에 해킹당하고 후회하기 전에요.
처음엔 뭐가 이해가 안 갔나
"사이트 간 요청 위조(Cross-Site Request Forgery)"라는 정의를 봤을 때, 솔직히 감이 안 왔습니다. "위조? 내가 뭘 위조한다는 거지? 내 서버가 뭘 검증을 안 한다는 거야?"
그리고 XSS와 헷갈렸습니다. 둘 다 "악의적인 공격"이고 "웹 취약점"이라는 건 알겠는데,
- XSS는 악성 스크립트를 실행시킨다
- CSRF는... 뭐? 요청을 위조한다?
도대체 차이가 뭔지 명확하지 않았습니다.
깨달음의 순간 - "은행 대리인 사칭"
그러다가 이런 비유를 들었습니다:
"당신이 은행에 전화해서 '계좌 100만원 이체해주세요'라고 하면, 은행은 당신 목소리를 듣고 본인 확인을 합니다. 하지만 CSRF는 당신의 목소리를 녹음해서, 나중에 그 녹음파일을 틀어서 은행을 속이는 겁니다. 은행은 '어? 이 목소리 맞는데?'하고 이체를 해버립니다."
이 비유를 듣자마자 무릎을 탁 쳤습니다.
CSRF는 내가 직접 요청을 보낸 게 아닌데, 마치 내가 보낸 것처럼 보이게 만드는 공격이었습니다. 핵심은 쿠키(Cookie)였습니다. 브라우저는 자동으로 쿠키를 첨부해서 요청을 보내는데, 해커가 이걸 악용하는 거였죠.
공격 시나리오 (실제 흐름)
- 당신은 은행 사이트(
bank.com)에 로그인했습니다.- 브라우저에
session=abc123쿠키가 저장됩니다.
- 브라우저에
- 해커가 메일을 보냅니다: "무료 아이폰 당첨! 클릭↗"
- 링크를 클릭하면, 해커 사이트(
evil.com)로 이동합니다. - 그 페이지에 숨겨진 코드가 있습니다:
<img src="https://bank.com/transfer?to=hacker&amount=1000000" /> - 브라우저는 이미지를 로딩하려고
bank.com에 요청을 보냅니다.- 이때 자동으로
session=abc123쿠키를 첨부합니다. (브라우저의 기본 동작)
- 이때 자동으로
- 은행 서버는 "어? 세션이 유효한 철수님이 요청했네?" 하고 송금을 승인합니다.
당신은 아무것도 안 했는데 돈이 빠져나갔습니다.
왜 이런 일이 가능한가? 자세히 살펴보기
브라우저의 "친절한" 쿠키 자동 전송
브라우저는 보안상의 이유가 아니라 편의성을 위해 쿠키를 자동으로 보냅니다.
당신이 bank.com에 요청을 보낼 때마다 일일이 "세션 번호가 뭐였더라?"하고 입력하지 않아도 되도록요.
문제는, 브라우저는 "이 요청을 내가 직접 보낸 건지, 해커 사이트의 코드가 보낸 건지 구분하지 못합니다."
당신이:
- 은행 사이트에서 직접 "송금" 버튼을 누르든
- 해커 사이트의
<img src="...">태그가 요청을 보내든
브라우저 입장에선 둘 다 똑같이 bank.com으로 가는 요청이므로, 쿠키를 첨부합니다.
GET vs POST: 둘 다 위험하다
초보 개발자들이 자주 하는 실수:
"송금 같은 중요한 기능은 POST로 하면 안전하지 않나요?"
아닙니다. CSRF는 POST 요청도 위조할 수 있습니다.
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="hacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit(); // 자동 제출
</script>
페이지를 열자마자 이 폼이 자동으로 제출되고, 브라우저는 쿠키를 첨부해서 보냅니다. 서버는 정상적인 POST 요청으로 인식합니다.
CORS는 CSRF를 막지 못한다
또 다른 흔한 오해: "CORS 설정하면 CSRF 막을 수 있지 않나요?"
CORS(Cross-Origin Resource Sharing)는 응답을 읽는 것을 제한할 뿐, 요청 자체를 막지는 않습니다. 해커는 응답이 필요 없습니다. 송금 요청이 서버에 도달하기만 하면 됩니다. 서버가 처리한 뒤 응답을 해커에게 보내든 안 보내든, 이미 돈은 빠져나간 후입니다.
어떻게 막을까? (Application)
1. CSRF Token (Anti-CSRF Token)
저는 제 서비스에 CSRF Token을 도입했습니다.
원리는 간단합니다:
- 사용자가 폼 페이지를 요청하면, 서버는 랜덤 토큰을 생성합니다. (예:
token=xyz789) - 이 토큰을 폼의 숨겨진 필드와 세션에 동시에 저장합니다.
- 사용자가 폼을 제출하면, 토큰도 함께 전송됩니다.
- 서버는 "폼에서 온 토큰"과 "세션에 저장된 토큰"이 일치하는지 확인합니다.
해커는 쿠키는 도용할 수 있지만, 토큰값은 알 수 없습니다.
왜냐하면 토큰은 HTML 안에 숨겨져 있고, 해커는 당신의 브라우저 화면을 볼 수 없기 때문입니다.
(SOP - Same-Origin Policy 때문에 해커 사이트의 JavaScript는 bank.com의 DOM을 읽을 수 없습니다.)
실제 코드 예시 (Express.js)
// 서버에서 토큰 생성
app.get('/transfer', (req, res) => {
const csrfToken = generateRandomToken();
req.session.csrfToken = csrfToken; // 세션에 저장
res.render('transfer', { csrfToken }); // HTML에 전달
});
// HTML 폼
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input name="to" />
<input name="amount" />
<button type="submit">송금</button>
</form>
// 서버에서 검증
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send('CSRF 감지!');
}
// 정상 처리
});
2. SameSite Cookie 속성
최근 브라우저들은 SameSite 쿠키 속성을 지원합니다.
res.cookie('session', 'abc123', {
httpOnly: true,
sameSite: 'Lax', // or 'Strict', 'None'
secure: true
});
SameSite 3대장 완벽 정리
- Strict: 가장 보수적.
bank.com이 아닌 외부 사이트에서 오는 모든 요청에 쿠키를 전송하지 않습니다.- 단점: 사용자가 메일에서
bank.com링크를 눌러서 들어와도 로그인이 풀려있습니다. (사용자 경험 저하).
- Lax (Default): 적절한 타협.
- Safe method (GET) 요청에는 쿠키를 보냅니다. (링크 클릭 등).
- Unsafe method (POST, PUT, DELETE) 요청에는 쿠키를 차단합니다. (CSRF 방어).
- 대부분의 서비스가 이걸로 충분합니다.
- None: 쿠키 대개방.
- 3rd-party 쿠키 공유가 필요한 경우.
Secure속성(HTTPS)이 필수입니다.
3. Double Submit Cookie 패턴 (Stateless 방어)
"서버에 세션(상태) 저장이 부담스럽다면?" 세션 대신 쿠키를 두 번 활용하는 방법입니다.
- 사용자가 로그인하면, 서버는 랜덤 토큰을 쿠키(
csrf_token)로 구워줍니다. - 프론트엔드는 요청을 보낼 때, 쿠키에 있는 이 값을 읽어서 헤더(
X-CSRF-Token)에도 똑같이 담아 보냅니다. - 서버는 "쿠키값 == 헤더값"인지 비교합니다.
원리: 해커는 bank.com 도메인에 심어진 쿠키를 전송(Attach)할 수만 있지, 자바스크립트로 그 값을 읽을(Read) 수는 없습니다. (SOP 정책 때문). 해커는 쿠키 값을 몰라서 헤더에 똑같은 값을 담을 수 없습니다.
이 방식은 JWT 기반 인증처럼 서버가 세션을 관리하지 않는 Stateless 아키텍처에서 특히 유용합니다.
4. Referer 검증
서버에서 요청이 어디서 왔는지(Referer 헤더) 확인할 수도 있습니다.
if (!req.headers.referer || !req.headers.referer.startsWith('https://bank.com')) {
return res.status(403).send('잘못된 출처');
}
하지만 Referer는 브라우저 설정이나 프록시에 의해 제거될 수 있어서, 보조 수단으로만 써야 합니다. 일부 기업 프록시나 개인정보 보호 브라우저 확장 프로그램은 Referer 헤더를 아예 제거합니다.
5. CSRF vs XSS: 헷갈리는 두 형제
초보자가 제일 많이 헷갈리는 두 공격.
| 특성 | CSRF (Cross-Site Request Forgery) | XSS (Cross-Site Scripting) |
|---|---|---|
| 목적 | "원하지 않는 행동" 실행 (송금, 비번 변경) | "정보 탈취" (쿠키 훔치기) or "악성 코드 실행" |
| 주체 | 피해자가 실행 (해커가 시킨 대로) | 해커의 스크립트가 직접 실행 |
| 스크립트 실행 | 피해자 브라우저에 스크립트를 주입하지 않음 | 피해자 브라우저에서 해커의 스크립트가 실행됨 |
| 방어 | CSRF Token, SameSite Cookie | 입력값 검증(Sanitize), CSP (Content Security Policy) |
| 비유 | 은행원에게 내 목소리 녹음본을 틀어줌 | 은행원에게 최면을 걸어서 내 말을 듣게 함 |
핵심 차이: XSS는 해커가 당신의 브라우저 안에서 코드를 실행하는 것이고, CSRF는 해커가 당신의 브라우저 밖에서 당신이 특정 요청을 보내도록 유도하는 것입니다.
6. CSRF 테스트하기 (직접 해킹해보기)
"내 사이트는 안전할까?" 궁금하다면 직접 뚫어봐야 합니다. 보안 팀이 없다면, 창업자인 내가 직접 Red Team이 되어야 합니다.
1. 간단한 HTML POC (Proof of Concept) 만들기
가장 원초적이지만 확실한 방법입니다.
- 로컬에
attack.html파일을 만듭니다. - 내 서비스의
회원 탈퇴나비밀번호 변경같은 치명적인 API를 타겟으로 폼을 만듭니다. <body onload="document.forms[0].submit()">로 자동 제출되게 합니다.- 로그인한 상태에서 이 HTML 파일을 브라우저로 엽니다.
- 계정이 삭제되거나 정보가 바뀌면 뚫린 겁니다.
2. 전문 도구 사용: OWASP ZAP / Burp Suite
개발자라면 이 두 툴은 친구처럼 지내야 합니다.
- OWASP ZAP (무료): 'Anti-CSRF Tokens Scanner' 기능이 있습니다. 폼이 있는데 토큰이 없으면 바로 경고를 때립니다.
- Burp Suite (유료/무료): "Generate CSRF PoC" 기능이 예술입니다. 내가 잡은 요청(Intercept)을 우클릭 한 번으로 공격용 HTML 코드로 만들어줍니다. 이걸로 팀원들한테 시연하면 바로 설득 가능합니다.
7. 역사 속의 CSRF (우리가 배울 점)
"설마 대기업도 당해?" 네, 당합니다.
Netflix (2006)
2006년, 넷플릭스에는 CSRF 취약점이 있었습니다. 해커는 사용자의 '대여 목록(Queue)'에 이상한 영화를 마음대로 추가할 수 있었습니다. 로그인한 상태로 악성 사이트에 접속만 하면, 내 취향과 전혀 상관없는 영화들이 대여 목록에 가득 차게 되는 거죠. 추가하는 게(Add) 치명적이지 않아 보일 수 있지만, 배송 주소를 바꾸는 공격이었다면 어땠을까요?
YouTube (2008)
유튜브 초창기, 거의 모든 액션이 CSRF에 취약했습니다. 가장 유명한 건 "친구 추가" 공격이었습니다. 해커는 링크 하나로 수천 명의 사용자를 강제로 자신의 친구나 구독자로 만들 수 있었습니다. 심지어 관리자 권한으로 동영상을 삭제하는 기능까지도 CSRF로 트리거 될 수 있었다는 루머가 있습니다.
이 사건들 이후로 프레임워크 레벨에서 CSRF 방어가 기본으로 탑재되기 시작했습니다. (Rails 2.0, Django 등)
우리가 지금 편하게 쓰는 ctrl+c, v 보안 기능들은 선배들의 피와 땀(그리고 해킹 사고)으로 만들어진 겁니다.
실제로 겪은 실수
저는 처음에 CSRF Token을 도입했는데, AJAX 요청에는 토큰을 안 붙였습니다. "폼 제출만 막으면 되겠지" 싶었거든요.
그런데 해커는 AJAX로도 요청을 위조할 수 있습니다:
// 해커 사이트의 스크립트
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // 쿠키 포함
body: JSON.stringify({ to: 'hacker', amount: 1000000 })
});
(물론 CORS preflight가 있어서 커스텀 헤더가 있는 경우엔 막히지만, Content-Type: application/x-www-form-urlencoded 같은 "simple request"는 preflight 없이 바로 전송됩니다.)
결국 모든 상태 변경 요청(POST, PUT, DELETE)에 CSRF Token을 붙여야 했습니다.
정리하면
CSRF를 이해하면서 배운 핵심:
- 브라우저는 쿠키를 자동으로 보냅니다. 선의든 악의든 구분 못 합니다.
- 해커는 내 브라우저를 조종하지 못하지만, 내 브라우저가 요청을 보내게 만들 수는 있습니다.
- 방어는 간단합니다: Token 하나만 제대로 구현하면 됩니다.
- SameSite=Lax가 기본값이 된 요즘, CSRF 위험이 많이 줄었지만 완전히 사라진 건 아닙니다.
"나중에 보안 신경 쓰면 되겠지"라고 생각했던 과거의 저에게 하고 싶은 말: 처음부터 CSRF Token을 넣으세요. 나중에 리팩토링하는 게 훨씬 고통스럽습니다.
8. 용어 사전 (Glossary)
- CSRF Token: 요청의 위조 여부를 판단하기 위해 서버가 발급하는 일회성(또는 세션별) 난수 코드. 마치 "여권"과 같습니다. 입국 심사(요청 처리)할 때 여권(토큰)이 없으면 거부당합니다.
- SameSite Cookie: 쿠키가 "같은 사이트"에서만 전송되도록 제한하는 속성. (Chrome 80부터
Lax가 기본값). 옆집(해커 사이트)에서 우리 집(내 서비스)으로 택배(쿠키)를 못 보내게 막는 규칙입니다. - SOP (Same-Origin Policy): 브라우저 보안의 근간. 다른 출처의 리소스를 마음대로 읽지 못하게 막는 정책.
- CORS (Cross-Origin Resource Sharing): SOP를 완화해서 다른 출처와 통신하게 해주는 정책. (CSRF 방어책이 아님!).
- Double Submit Cookie: 세션 없이 CSRF를 방어하는 기법. 쿠키와 헤더에 같은 토큰을 담아 비교.
9. FAQ & Common Questions
- Q: REST API는 Stateless한데 CSRF 공격이 통하나요?
- A: 만약 인증 토큰을 Local Storage에 저장하고 헤더(
Authorization: Bearer ...)에 직접 담아 보낸다면 안전합니다. (브라우저가 자동으로 안 보내주니까). 하지만 쿠키에 저장했다면 Stateless라도 CSRF 공격에 노출됩니다.
- A: 만약 인증 토큰을 Local Storage에 저장하고 헤더(
- Q: CSRF Token을 쿠키에 저장하면 안 되나요? (Double Submit Cookie)
- A: 됩니다. 단,
HttpOnly를 쓰면 클라이언트 JS가 토큰을 못 읽어서 헤더에 못 담으므로, CSRF 토큰용 쿠키는HttpOnly를 빼야 합니다. 대신 인증 세션 쿠키는 반드시HttpOnly여야 합니다.
- A: 됩니다. 단,
- Q: 요즘 프레임워크들은 어떤가요?
- A: React/Angular/Vue 같은 SPA는 보통 JSON 통신을 하고 헤더에 토큰을 담는 방식을 써서 CSRF에서 비교적 안전합니다. 반면 Django/Spring MVC/Rails 같은 전통적인 Server-Side Rendering 프레임워크는 Form 전송이 많아 CSRF 방어가 필수입니다. Django는
{% csrf_token %}, Spring은CsrfFilter가 기본 제공됩니다.
- A: React/Angular/Vue 같은 SPA는 보통 JSON 통신을 하고 헤더에 토큰을 담는 방식을 써서 CSRF에서 비교적 안전합니다. 반면 Django/Spring MVC/Rails 같은 전통적인 Server-Side Rendering 프레임워크는 Form 전송이 많아 CSRF 방어가 필수입니다. Django는