개요
정의: 공격자가 사용자 모르게 로그인된 웹사이트에 위조된 요청을 보내도록 유도하는 공격
핵심 원인 — 브라우저의 쿠키 자동 전송
- CSRF가 가능한 근본 이유는 브라우저가 요청을 보낼 때 해당 도메인의 쿠키를 자동으로 첨부하기 때문
- 악성 사이트에서
example.com으로 요청을 보내도, 브라우저는example.com의 쿠키를 자동으로 붙여서 보냄
사용자가 example.com에 로그인 → 브라우저에 세션 쿠키 저장
↓
악성 사이트 방문
↓
악성 사이트가 example.com으로 요청 전송
↓
브라우저가 example.com 쿠키를 자동 첨부
↓
서버는 정상 요청으로 판단
작동 방식:
- 사용자가
example.com에 로그인한 상태에서 공격자가 만든 악성 사이트 방문 - 악성 사이트가 사용자 브라우저를 통해
example.com에 위조된 요청 전송 - 브라우저가 쿠키를 자동 첨부하므로 서버는 정상 요청으로 처리
XSS와의 차이
| CSRF | XSS | |
|---|---|---|
| 공격 목적 | 사용자 권한으로 요청 위조 | 악성 스크립트 실행 |
| 공격 경로 | 다른 사이트에서 요청 유도 | 같은 사이트에 스크립트 삽입 |
| 피해 | 의도치 않은 상태 변경 | 정보 탈취, 세션 하이재킹 |
| 인증 필요 | 사용자가 로그인 상태여야 함 | 무관 |
예시
시나리오: 사용자가 example.com에 로그인한 상태에서 CSRF 공격을 받는 경우
1. 공격
<!-- 공격자의 악성 사이트 -->
<form action="https://example.com/change-password" method="POST">
<input type="hidden" name="password" value="hacked1234">
</form>
<script>
document.forms[0].submit(); // 페이지 로드 즉시 자동 제출
</script>
사용자가 이 페이지를 방문하는 순간, 본인도 모르게 비밀번호 변경 요청이 전송됩니다.
2. 영향
- 서버는 쿠키가 첨부된 정상 요청으로 판단
- 사용자 의사와 무관하게 비밀번호 변경, 계정 정보 수정, 송금 등이 발생
3. 대비 방법
CSRF 토큰
- 서버가 예측 불가능한 토큰을 발급하고, 요청마다 이를 검증하는 방식
- 악성 사이트는 이 토큰을 알 수 없기 때문에 위조 요청이 차단됨
서버가 토큰 발급 → 클라이언트가 요청에 포함 → 서버가 검증
악성 사이트는 토큰을 모름 → 검증 실패 → 요청 거부
// 클라이언트
function ChangePasswordForm() {
const [csrfToken, setCsrfToken] = useState('');
const [password, setPassword] = useState('');
useEffect(() => {
// 서버에서 토큰 발급받기
fetch('/csrf-token')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // 헤더에 토큰 포함
},
body: JSON.stringify({ password })
});
};
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="_csrf" value={csrfToken} />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button type="submit">Change Password</button>
</form>
);
}
// Express 서버 — csrf-csrf 사용
const express = require('express');
const { doubleCsrf } = require('csrf-csrf');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.use(express.json());
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-psifi.x-csrf-token',
});
// 토큰 발급
app.get('/csrf-token', (req, res) => {
res.json({ csrfToken: generateToken(req, res) });
});
// 보호가 필요한 라우트에 미들웨어 적용
app.post('/change-password', doubleCsrfProtection, (req, res) => {
// 토큰 검증 통과 후 실행
res.send('Password changed');
});
SameSite 쿠키 설정
- 쿠키가 동일한 사이트에서의 요청에만 전송되도록 제한
- 브라우저 레벨에서 CSRF를 원천 차단하는 가장 간단한 방법
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict' // 타 사이트에서의 요청엔 쿠키 전송 안함
});
| SameSite 값 | 동작 |
|---|---|
Strict | 타 사이트 요청 시 쿠키 전송 완전 차단 |
Lax | 타 사이트에서의 GET 요청은 허용, POST 등은 차단 (기본값) |
None | 모든 요청에 쿠키 전송 (Secure 필수) |
Origin Header 검증
- 서버에서 요청의
Origin헤더를 확인해 신뢰할 수 있는 출처인지 검증 - 서버 레벨에서 검증하는 것이기 때문에 CORS 헤더 추가와는 다른 개념
const allowedOrigins = ['https://example.com'];
app.use((req, res, next) => {
const origin = req.get('origin');
// POST, PUT, DELETE 등 상태 변경 요청에 대해서만 검증
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
if (!origin || !allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'CSRF detected: origin not allowed' });
}
}
next();
});
Origin헤더는 드물게 없는 경우도 있어서 단독 사용보다는 CSRF 토큰 또는 SameSite와 함께 쓰는 게 안전
예방 전략 요약
| 방법 | 방어 원리 | 단독 사용 |
|---|---|---|
| CSRF 토큰 | 서버만 아는 토큰으로 요청 검증 | 가능 |
| SameSite 쿠키 | 타 사이트 요청 시 쿠키 전송 차단 | Strict면 가능 |
| Origin Header 검증 | 요청 출처를 서버에서 직접 확인 | 단독 사용 비권장 |
현대 브라우저는
SameSite=Lax가 기본값이라 과거보다 CSRF 위험이 낮아졌지만, 민감한 작업(비밀번호 변경, 결제 등)에는 CSRF 토큰을 함께 사용하는 것이 권장됨