프론트엔드·백엔드 개발자라면 꼭 알아야 할 CSRF(Cross-Site Request Forgery) 공격과 방어법
“로그인한 사용자가 모르는 사이에 자동으로 송금이 되었다면?”
웹 개발자라면 꼭 알아야 할 심각한 보안 취약점, CSRF(Cross-Site Request Forgery)
에 대해 알아보겠습니다.
이번 포스트에서는 직접 공격 서버를 구축하고, 실제 공격을 시연한 후, SameSite 쿠키와 CSRF 토큰으로 방어하는 방법까지 단계별로 실습해보겠습니다.
1. CSRF 공격이란? 피해자 모르게 실행되는 위험한 요청
CSRF 공격의 핵심 원리
CSRF
는 인증된 사용자의 권한을 도용하여 원하지 않는 작업을 수행하게 만드는 공격입니다:
- 피해자가 은행 사이트에 로그인 (세션 쿠키 발급)
- 공격자 사이트 방문 (악성 링크 클릭)
- 자동으로 은행 API 호출 (송금, 비밀번호 변경 등)
- 브라우저가 쿠키를 자동 전송 (인증된 요청으로 처리)
이제 직접 간단하게 공격을 구현해보면서 CSRF의 위험성을 체험해보겠습니다.
2. CSRF 공격 서버 구축하기
공격 시나리오 설정
이전 포스트의 세션 하이재킹 실습 환경을 활용합니다.
matthew.com
이 결제 기능을 제공한다고 가정하고, 공격자가 이를 악용하는 상황을 재현해보겠습니다.
악성 웹 서버 구현
attacker-csrf.js
const http = require('http');
// csrf 라는 핸들러 함수를 만듬
const csrf = (req, res) => {
// 간단한 HTML 을 제공
res.setHeader('Content-Type', 'text/html');
res.write(`
<!DOCTYPE html>
<html>
<head></head>
<body>
CSRF
<!-- img 태그를 사용해 공격 대상인 matthew.com:8080 요청을 보낸다. -->
<img src="http://matthew.com:8080" />
</body>
</html>
`);
res.end();
};
const server = http.createServer(csrf);
server.listen(8082, () => {
console.log('Attacker server is running on port 8082');
});
javascript
공격 코드 분석
간단한 HTML을 제공하고, HTML을 보면 바디에 이미지가 하나 있습니다. 그런데 이 이미지는 바로 공격 대상인 matthew.com
으로 네트워크 요청을 보내는 이미지 태그입니다.
이 악성 HTML의 핵심 트릭:
- 자동 실행: 페이지 로딩만으로 요청 발생
- 보이지 않는 요청:
1x1 픽셀
+display:none
- 다중 공격: 송금, 비밀번호 변경 동시 시도
- 사회공학적 미끼: “무료 쿠폰” 으로 클릭 유도
공격용 웹 페이지를 제공하는 공격 서버에서 이 URL로 요청을 보내면, 다른 사용자의 결제를 대신 해버릴 수도 있습니다. 만약 matthew.com
이 결제할 수 있는 URL을 제공한다고 하면, 문제가 더 심각해질 수 있습니다.
브라우저 보안 설정 해제 (실습용)
실습을 위해 일시적으로 브라우저 보안을 해제합니다. Firefox
의 향상된 추적 방지 부분에서 사용자 지정으로 해서 쿠키 체크박스를 해제할 것입니다.
경고: 실습 후 반드시 원래 설정으로 복구하세요!
Firefox 설정 변경:
- 설정 → 개인정보 및 보안
- 향상된 추적 방지 → 사용자 지정
- 쿠키 체크박스 해제
- 세이프 브라우징을 보호되지 않게 설정
기본적으로 브라우저가 조치해주는 보안 기능을 끈 것입니다.
왜 브라우저 설정을 변경하나요?
최신 브라우저들은 기본적으로
CSRF 공격
을 어느 정도 방어합니다.
실습을 위해 이러한 보호 기능을 일시적으로 해제합니다.
3. CSRF 공격 시연
공격 준비
1단계: 정상 사이트 로그인 (인증 된 상태)
먼저 matthew.com
에 정상적으로 로그인합니다:
http://matthew.com:8080/login
접속- 세션 ID가 쿠키로 저장됨
- 인증된 상태로 홈페이지 리다이렉트
2단계: 공격 사이트 방문 (인증 되지 않은 상태)
이제 다른 탭에서 공격자 사이트를 방문해보겠습니다:
공격 메커니즘 분석
브라우저가 공격 페이지를 렌더링하는 과정:
- HTML 파싱: 브라우저가 화면을 렌더링하다가 이미지 태그를 만남
- img 태그 발견: 이미지에 등록된 주소로 네트워크 요청을 보냄
- 자동 요청: 이것이 바로
matthew.com:8080
으로 요청을 보내는 것 - 쿠키 자동 전송: 이때 중요한 것은 쿠키 값도 똑같이 보낸다는 것입니다! 🚨
공격 성공! 무슨 일이 일어났나?
네트워크 탭을 확인해보면:
GET http://matthew.com:8080/transfer?amount=10000&to=hacker
Cookie: sid=session-id-1756852495508 ← 자동으로 포함됨!
http
핵심 문제점 🚨
방금 다른 탭인 matthew.com
에서 로그인해서 발급받은 쿠키인데, hacker.com
에서도 이 쿠키를 실어서 matthew.com
쪽으로 요청을 보낸 것입니다.
hacker.com
에서 발생한 요청인데matthew.com
의 세션 쿠키가 자동 전송됨matthew.com
측에서는 이 요청이 인증된 요청이라고 판단하고, 비즈니스 로직을 수행할 것입니다- 만약 이 요청이 결제 프로세스라면
matthew.com
에서는 인증된 요청이라고 생각하고 실행할 수도 있다는 것을 보았습니다!
이렇게 서로 다른 사이트 간의 요청을 위조해서 공격하는 기법을 교차 사이트 요청 위조라고 부릅니다.
Cross Site Request Forgery
라고 해서 CSRF
라고도 부릅니다.
이것도 마찬가지로 잘못된 요청에 쿠키가 전달되는 것이 원인입니다. matthew.com
에서 사용할 쿠키인데 hacker.com
에서 발생한 네트워크 요청에도 이 쿠키가 실립니다.
4. CSRF 방어 전략
방법 1: SameSite 쿠키 (가장 간단한 해결책)
간단한 해결방법은 쿠키 디렉티브를 설정하는 것입니다. 이전 쿠키 편에서 본 것처럼 SameSite
디렉티브를 사용하면 쿠키를 사용할 수 있는 사이트를 제한할 수 있습니다.
SameSite
속성은 쿠키가 전송되는 조건을 제한합니다:
// ❌ 취약한 쿠키 설정
res.setHeader('Set-Cookie', `sid=${sid}`);
// ✅ SameSite로 보호된 쿠키
res.setHeader('Set-Cookie', `sid=${sid}; SameSite=Strict`);
javascript
SameSite=Strict
속성으로 인해 브라우저는 이 쿠키를 받으면, 이 서버인 matthew.com
으로 요청할 때만 이 쿠키를 사용할 것입니다.
SameSite 옵션 비교:
옵션 | 동작 | 사용 시나리오 | 보안 수준 |
---|---|---|---|
Strict | 같은 사이트에서만 쿠키 전송 | 은행, 결제 시스템 | ⭐⭐⭐ |
Lax | 일부 크로스 사이트 허용 (GET, 링크 클릭) | 일반 웹사이트 | ⭐⭐ |
None | 모든 요청에 쿠키 전송 (Secure 필수) | 임베드 위젯 | ⭐ |
SameSite 적용 후 테스트
서버 코드 수정:
const login = (req, res) => {
// ... 세션 생성 로직 ...
// SameSite=Strict 추가
res.setHeader('Set-Cookie', `sid=${sid}; SameSite=Strict; HttpOnly`);
res.setHeader('Location', '/');
res.statusCode = 302;
res.end();
};
javascript
공격 재시도
이렇게 SameSite=Strict
속성이 잘 들어감을 확인할 수 있습니다.
다시 hacker.com:8082
로 들어가서 요청을 보내보겠습니다:
이미지 태그를 발견하고 matthew.com
쪽으로 요청을 보내는데, 이때 쿠키 값을 보면 SID 쿠키가 없습니다.
네트워크 요청 분석:
GET http://matthew.com:8080/transfer?amount=10000&to=hacker
Cookie: (비어있음) ← 쿠키가 전송되지 않음! ✅
http
더이상 브라우저는 matthew.com
이 아닌 곳에서는 이 쿠키를 보내지 않습니다.
SameSite=Strict
의 효과
브라우저가 자동으로 쿠키 전송을 차단합니다:
matthew.com
→matthew.com
: 쿠키 전송 ⭕hacker.com
→matthew.com
: 쿠키 차단 ❌
SameSite의 한계점
이 SameSite
쿠키는 MDN 문서에서 한 번 자세하게 읽어보는 것이 좋습니다.
이런 식으로, 아직 실험 중인 기법입니다.
아직은 여전히 실험 중이면서 모든 브라우저에 제공되지 않고 있다고 합니다.
주의사항:
- 2020년 이후 대부분 브라우저가 지원하지만 레거시 브라우저 고려 필요
Chrome
80부터 기본값이Lax
로 변경됨- Safari는 일부 버그가 있을 수 있음
5. CSRF 토큰: 더 강력한 방어
CSRF 토큰의 작동 원리
그래서 다른 예방책이 있습니다. 특별한 토큰을 HTML
문서에 주입하고, 다음 요청 시에 이 토큰이 HTTP 요청에 들어오는지 서버가 확인하는 방식입니다.
예를 들어서, 인증을 하고 나서 HTML 문서를 응답할 때 이런 식으로 할 수 있을 것입니다.
SameSite
만으로 부족할 때 사용하는 전통적이면서도 강력한 방법입니다:
- 서버가 고유 토큰 생성
HTML
폼에 토큰 삽입- 요청 시 토큰 검증
- 토큰 불일치 시 요청 거부
CSRF 토큰 구현
서버 측 구현:
const crypto = require('crypto');
// `CSRF` 토큰 생성 및 세션에 저장
const generateCSRFToken = (sessionId) => {
const token = crypto.randomBytes(32).toString('hex');
database.session[sessionId].csrfToken = token;
return token;
};
// `HTML` 응답에 토큰 포함
const index = (req, res) => {
const sid = parseCookie(req).sid || '';
const userAccount = database.session[sid];
// CSRF 토큰 생성
const csrfToken = userAccount ? generateCSRFToken(sid) : '';
res.write(`
<!DOCTYPE html>
<html>
<body>
<form method="POST" action="/transfer">
<!-- 🔐 CSRF 토큰을 hidden 필드로 포함 -->
<input type="hidden" name="csrf_token" value="${csrfToken}" />
<input name="amount" placeholder="송금액" />
<input name="recipient" placeholder="받는 사람" />
<button type="submit">송금</button>
</form>
<script>
// JavaScript 요청 시에도 토큰 포함
const csrfToken = '${csrfToken}';
// 브라우저에서는 fetch로 요청을 보낼 때
fetch('/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken // 헤더에다가 custom-header를 보내는 것이다
},
body: JSON.stringify({ amount: 1000 })
});
</script>
</body>
</html>
`);
};
// 요청 검증
const validateCSRFToken = (req) => {
const sid = parseCookie(req).sid;
const session = database.session[sid];
// 헤더 또는 바디에서 토큰 추출
const receivedToken = req.headers['x-csrf-token'] || req.body.csrf_token;
// 세션의 토큰과 비교
// matthew.com에서 제공한 HTML에만 요 토큰이 있을 것이다
if (!session || session.csrfToken !== receivedToken) {
throw new Error('❌ CSRF 토큰 검증 실패!');
}
// 서버가 민감한 요청을 처리할 때 이 토큰이 요청 헤더에 실렸는지 확인하고
// 비즈니스 로직을 실행하도록 예방하는 것이다
return true;
};
javascript
이렇게 matthew.com
에서 제공한 HTML에만 있는 토큰을 네트워크 헤더에 실어서 보내면, 서버는 정상적인 요청이라고 판단할 수 있습니다.
CSRF 토큰의 장점과 단점
실제로 CSRF 토큰을 구현하면서 느낀 장단점을 정리해보면:
✅ 장점 - 강력하고 유연한 보안
• 모든 브라우저 지원: 레거시 브라우저에서도 완벽하게 작동
• 이중 방어 가능: SameSite 쿠키와 함께 사용하면 더욱 견고한 보안
• 세밀한 제어: 특정 액션이나 폼별로 다른 토큰 적용 가능
• 검증된 방법: 오랜 기간 사용되어 안정성이 입증됨
❌ 단점 - 구현과 관리의 복잡성
• 구현 복잡도: 토큰 생성, 저장, 검증 로직이 모두 필요
• 개발 오버헤드: 모든 폼과 AJAX 요청에 토큰을 수동으로 추가
• 토큰 관리: 세션별 토큰 저장과 만료 처리가 필요
• 완벽하지 않음: XSS 공격으로 토큰이 유출될 가능성 존재
6. CAPTCHA - “너 로봇 아니지?”
그래서 이게 정말 사람인지 로봇인지 확인해서 사이트를 보호하는 방법이 있는데, 바로 CAPTCHA입니다.
CAPTCHA
의 정식 명칭은… Completely Automated Public Turing test to tell Computers and Humans Apart 🤯
네, 저도 압니다. 너무 깁니다. 그냥 “사람과 컴퓨터를 구별하는 자동화된 테스트”라고 생각하시면 됩니다.
여러분도 한 번쯤은 겪어보셨을 거예요:
- “신호등이 있는 사진을 모두 선택하세요”
- “횡단보도가 보이는 칸을 클릭하세요”
- “이 흐릿한 글자가 뭔지 맞춰보세요” (요즘은 거의 안 보이죠)
사용자 입장에선 짜증나지만, 자동화된 봇 공격을 막는 데는 꽤 효과적입니다.
구글의 reCAPTCHA 같은 서비스를 통해 자동화된 공격을 차단할 수 있습니다:
사용하는 방법은 아래와 같습니다. 매우 간단한 예제로 직접 만들어보겠습니다:
reCAPTCHA v3 구현 예제:
// 클라이언트 측 (HTML)
const index = (req, res) => {
res.write(`
<html>
<head>
<!-- reCAPTCHA v3 스크립트 로드 -->
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
</head>
<body>
<form id="transferForm">
<input name="amount" placeholder="송금액" />
<button type="submit">송금하기</button>
</form>
<script>
document.getElementById('transferForm').onsubmit = (e) => {
e.preventDefault();
// reCAPTCHA 토큰 생성
grecaptcha.ready(() => {
grecaptcha.execute('YOUR_SITE_KEY', {action: 'transfer'})
.then(token => {
// 토큰과 함께 요청 전송
fetch('/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: e.target.amount.value,
recaptchaToken: token
})
});
});
});
};
</script>
</body>
</html>
`);
};
// 서버 측 검증
const verifyRecaptcha = async (token) => {
const response = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=YOUR_SECRET_KEY&response=${token}`,
}
);
const data = await response.json();
// 점수가 0.5 이상이면 사람으로 판단
if (data.success && data.score > 0.5) {
return true;
}
throw new Error('❌ reCAPTCHA 검증 실패: 봇으로 의심됨');
};
javascript
7. 마치며
솔직히 CSRF 공격이라는 게 처음엔 “이게 진짜 가능해?”싶었는데, 직접 실습해보니 정말 간단한 img
태그 하나로도 공격이 가능한 것을 확인할 수 있었습니다.
무서운 건 사용자는 전혀 눈치채지 못한다는 거죠.
저도 처음엔 보안을 너무 빡빡하게 하려다가 사용자들이 “왜 이렇게 불편해요?”라는 피드백을 많이 받았습니다. 캡차 넣고, 재인증 넣고… 결국 보안과 사용성 사이에서 균형을 찾는 게 제일 어려운 것 같아요.
요즘은 AI가 캡차도 뚫는다고 하니, 완벽한 보안은 정말 없는 것 같습니다.
결국 제가 생각하는 좋은 서비스는, 사용자가 보안 기능이 있는지도 모르면서 안전하게 쓸 수 있는 서비스인 것 같습니다. 마치 자동차의 에어백처럼, 평소엔 신경 안 쓰다가 필요할 때 알아서 작동하는 그런 보안이요.
잘못된 내용이나, 조금 더 보완했으면 좋은 부분이 있으시다면 언제든지 저의 성장을 위해 댓글로 남겨주시면 감사하겠습니다!