
CSRF: 클릭 한 번에 계좌가 텅텅
나는 그냥 재미있어 보이는 링크를 눌렀을 뿐인데, 내 이름으로 송금이 되었습니다. 로그인된 상태를 악용하는 교묘한 공격, CSRF를 이해하기까지의 여정.

나는 그냥 재미있어 보이는 링크를 눌렀을 뿐인데, 내 이름으로 송금이 되었습니다. 로그인된 상태를 악용하는 교묘한 공격, CSRF를 이해하기까지의 여정.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

저는 웹 서비스를 만들면서 보안은 나중에 생각하자는 마인드였습니다. 일단 기능이 돌아가게 만드는 게 우선이었거든요. 그런데 어느 날 보안 감사 리포트를 받았는데, "CSRF 취약점" 항목이 빨간색으로 표시되어 있었습니다.
처음엔 "이게 뭐지?" 싶었습니다. XSS는 들어봤는데 CSRF는 생소했거든요. 그래서 제대로 이해해보기로 했습니다. 나중에 해킹당하고 후회하기 전에요.
"사이트 간 요청 위조(Cross-Site Request Forgery)"라는 정의를 봤을 때, 솔직히 감이 안 왔습니다. "위조? 내가 뭘 위조한다는 거지? 내 서버가 뭘 검증을 안 한다는 거야?"
그리고 XSS와 헷갈렸습니다. 둘 다 "악의적인 공격"이고 "웹 취약점"이라는 건 알겠는데,
그러다가 이런 비유를 들었습니다:
"당신이 은행에 전화해서 '계좌 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으로 가는 요청이므로, 쿠키를 첨부합니다.
초보 개발자들이 자주 하는 실수:
"송금 같은 중요한 기능은 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(Cross-Origin Resource Sharing)는 응답을 읽는 것을 제한할 뿐, 요청 자체를 막지는 않습니다. 해커는 응답이 필요 없습니다. 송금 요청이 서버에 도달하기만 하면 됩니다. 서버가 처리한 뒤 응답을 해커에게 보내든 안 보내든, 이미 돈은 빠져나간 후입니다.
저는 제 서비스에 CSRF Token을 도입했습니다.
원리는 간단합니다:
token=xyz789)해커는 쿠키는 도용할 수 있지만, 토큰값은 알 수 없습니다.
왜냐하면 토큰은 HTML 안에 숨겨져 있고, 해커는 당신의 브라우저 화면을 볼 수 없기 때문입니다.
(SOP - Same-Origin Policy 때문에 해커 사이트의 JavaScript는 bank.com의 DOM을 읽을 수 없습니다.)
// 서버에서 토큰 생성
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 감지!');
}
// 정상 처리
});
최근 브라우저들은 SameSite 쿠키 속성을 지원합니다.
res.cookie('session', 'abc123', {
httpOnly: true,
sameSite: 'Lax', // or 'Strict', 'None'
secure: true
});
bank.com이 아닌 외부 사이트에서 오는 모든 요청에 쿠키를 전송하지 않습니다.bank.com 링크를 눌러서 들어와도 로그인이 풀려있습니다. (사용자 경험 저하).Secure 속성(HTTPS)이 필수입니다."서버에 세션(상태) 저장이 부담스럽다면?" 세션 대신 쿠키를 두 번 활용하는 방법입니다.
csrf_token)로 구워줍니다.X-CSRF-Token)에도 똑같이 담아 보냅니다.원리: 해커는 bank.com 도메인에 심어진 쿠키를 전송(Attach)할 수만 있지, 자바스크립트로 그 값을 읽을(Read) 수는 없습니다. (SOP 정책 때문). 해커는 쿠키 값을 몰라서 헤더에 똑같은 값을 담을 수 없습니다.
이 방식은 JWT 기반 인증처럼 서버가 세션을 관리하지 않는 Stateless 아키텍처에서 특히 유용합니다.
서버에서 요청이 어디서 왔는지(Referer 헤더) 확인할 수도 있습니다.
if (!req.headers.referer || !req.headers.referer.startsWith('https://bank.com')) {
return res.status(403).send('잘못된 출처');
}
하지만 Referer는 브라우저 설정이나 프록시에 의해 제거될 수 있어서, 보조 수단으로만 써야 합니다. 일부 기업 프록시나 개인정보 보호 브라우저 확장 프로그램은 Referer 헤더를 아예 제거합니다.
초보자가 제일 많이 헷갈리는 두 공격.
| 특성 | CSRF (Cross-Site Request Forgery) | XSS (Cross-Site Scripting) |
|---|---|---|
| 목적 | "원하지 않는 행동" 실행 (송금, 비번 변경) | "정보 탈취" (쿠키 훔치기) or "악성 코드 실행" |
| 주체 | 피해자가 실행 (해커가 시킨 대로) | 해커의 스크립트가 직접 실행 |
| 스크립트 실행 | 피해자 브라우저에 스크립트를 주입하지 않음 | 피해자 브라우저에서 해커의 스크립트가 실행됨 |
| 방어 | CSRF Token, SameSite Cookie | 입력값 검증(Sanitize), CSP (Content Security Policy) |
| 비유 | 은행원에게 내 목소리 녹음본을 틀어줌 | 은행원에게 최면을 걸어서 내 말을 듣게 함 |
핵심 차이: XSS는 해커가 당신의 브라우저 안에서 코드를 실행하는 것이고, CSRF는 해커가 당신의 브라우저 밖에서 당신이 특정 요청을 보내도록 유도하는 것입니다.
"내 사이트는 안전할까?" 궁금하다면 직접 뚫어봐야 합니다. 보안 팀이 없다면, 창업자인 내가 직접 Red Team이 되어야 합니다.
가장 원초적이지만 확실한 방법입니다.
attack.html 파일을 만듭니다.회원 탈퇴나 비밀번호 변경 같은 치명적인 API를 타겟으로 폼을 만듭니다.<body onload="document.forms[0].submit()">로 자동 제출되게 합니다.개발자라면 이 두 툴은 친구처럼 지내야 합니다.
"설마 대기업도 당해?" 네, 당합니다.
2006년, 넷플릭스에는 CSRF 취약점이 있었습니다. 해커는 사용자의 '대여 목록(Queue)'에 이상한 영화를 마음대로 추가할 수 있었습니다. 로그인한 상태로 악성 사이트에 접속만 하면, 내 취향과 전혀 상관없는 영화들이 대여 목록에 가득 차게 되는 거죠. 추가하는 게(Add) 치명적이지 않아 보일 수 있지만, 배송 주소를 바꾸는 공격이었다면 어땠을까요?
유튜브 초창기, 거의 모든 액션이 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를 이해하면서 배운 핵심:
"나중에 보안 신경 쓰면 되겠지"라고 생각했던 과거의 저에게 하고 싶은 말: 처음부터 CSRF Token을 넣으세요. 나중에 리팩토링하는 게 훨씬 고통스럽습니다.
Lax가 기본값). 옆집(해커 사이트)에서 우리 집(내 서비스)으로 택배(쿠키)를 못 보내게 막는 규칙입니다.Authorization: Bearer ...)에 직접 담아 보낸다면 안전합니다. (브라우저가 자동으로 안 보내주니까). 하지만 쿠키에 저장했다면 Stateless라도 CSRF 공격에 노출됩니다.HttpOnly를 쓰면 클라이언트 JS가 토큰을 못 읽어서 헤더에 못 담으므로, CSRF 토큰용 쿠키는 HttpOnly를 빼야 합니다. 대신 인증 세션 쿠키는 반드시 HttpOnly여야 합니다.{% csrf_token %}, Spring은 CsrfFilter가 기본 제공됩니다.