사용자가 로그인한 상태에서 나도 모르게 비밀번호가 변경되거나 결제 요청이 승인되는 아찔한 보안 사고를 경험해봤을 것이다. 웹 서비스를 개발하다 보면 인증된 사용자의 권한을 악용하는 CSRF(Cross-Site Request Forgery) 공격을 쉽게 지나치는 경우가 많다. 다만 대부분의 개발자들은 CSRF 공격이 정확히 어떤 원리로 동작하는지 모르거나 단순히 GET 요청만 POST로 바꾸면 해결된다고 오해하곤 한다. 이번에는 CSRF 공격의 정확한 동작 원리부터 세션 기반 토큰 생성, 그리고 실무에서 바로 적용할 수 있는 검증 미들웨어 구현법까지 완벽하게 정리해서 소개하겠다.
CSRF 공격은 사용자가 이미 로그인하여 유효한 세션 쿠키를 가지고 있는 상태를 노린다. 해커는 공격 대상 사용자가 악성 스크립트가 담긴 외부 사이트나 이메일 링크를 방문하도록 유도한다. 해당 외부 사이트에서 사용자의 브라우저는 피해 대상 웹 서버로 비밀번호 변경이나 계좌 이체 같은 중요한 HTTP POST 요청을 전송하도록 조작된다.
이때 브라우저는 대상 도메인의 쿠키를 자동으로 요청 헤더에 포함시켜 전송하는 특성이 있다. 서버 입장에서는 유효한 세션 쿠키가 함께 들어오므로 사용자가 직접 보낸 정상적인 요청인지 해커가 조작한 요청인지 구별하지 못하고 명령을 수행해 버린다.
| 구분 | CSRF (Cross-Site Request Forgery) | XSS (Cross-Site Scripting) |
|---|---|---|
| 공격 목표 | 사용자의 권한을 도용하여 의도치 않은 행위 실행 | 사용자의 브라우저에서 악성 자바스크립트 실행 |
| 탈취 대상 | 서버 측 상태 변경 (비밀번호 변경, 게시글 삭제 등) | 쿠키, 세션 ID, 개인정보 등 데이터 탈취 |
| 핵심 방어책 | CSRF 토큰 검증, SameSite 쿠키 속성 설정 | 입력값 검증, HTML 엔티티 출력 이스케이프 |
CSRF 공격을 차단하는 가장 확실한 방법은 예측 불가능한 난수(CSRF 토큰)를 서버 세션에 저장하고, 모든 상태 변경 요청(POST, PUT, DELETE)에 이 토큰을 동봉하여 검증하는 것이다. 외부 해커 사이트는 사용자의 서버 세션에 저장된 토큰 값을 읽을 수 없기 때문에 위조된 요청을 보내더라도 서버 검증 단계에서 즉시 차단된다.
- 토큰 발급: 사용자의 세션이 생성될 때 난수 생성 함수(random_bytes)를 통해 안전한 고유 토큰을 생성하여 세션에 저장한다.
- 폼 전송: 클라이언트로 렌더링되는 HTML 폼 내부에 hidden input 필드로 토큰을 주입한다.
- 요청 검증: 서버로 들어오는 POST 요청 시 세션에 저장된 토큰과 폼 데이터로 넘어온 토큰을 hash_equals() 함수로 비교한다.
- 재사용 방지: 주요 처리 완료 후 또는 주기적으로 토큰을 갱신하여 재생 공격(Replay Attack)을 방지한다.
실무에서 자주 발생하는 위험한 작성 예시와 토큰 기반 보안이 적용된 올바른 코드를 확인해보자.
세션 로그인 여부만 확인하고 요청의 출처나 토큰 유효성을 검사하지 않아 외부 사이트의 위조 요청을 그대로 처리하는 위험한 코드다.
<?php
session_start();
// 세션 로그인 여부만 확인 (CSRF 공격에 완전히 취약함)
if (!isset($_SESSION['user_id'])) {
die("로그인이 필요합니다.");
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$new_email = $_POST['email'];
// CSRF 토큰 검증 없이 바로 DB 업데이트 수행
update_user_email($_SESSION['user_id'], $new_email);
echo "이메일이 변경되었습니다.";
}
?>
random_bytes()와 hash_equals()를 사용하여 타이밍 공격(Timing Attack)까지 방어하는 안전한 코드 구현이다.
<?php
session_start();
// 1. 세션 내 CSRF 토큰이 없으면 암호학적으로 안전한 난수 토큰 생성
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 2. POST 요청 처리 시 토큰 검증
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user_token = $_POST['csrf_token'] ?? '';
// hash_equals 함수로 시간차 공격 방지 비교
if (empty($user_token) || !hash_equals($_SESSION['csrf_token'], $user_token)) {
http_response_code(403);
die("잘못된 접근입니다. CSRF 토큰 검증 실패.");
}
$new_email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
if (!$new_email) {
die("유효하지 않은 이메일 형식입니다.");
}
// 정상 처리
update_user_email($_SESSION['user_id'], $new_email);
// 성공 후 토큰 재발급 (보안 강화)
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
echo "이메일이 성공적으로 변경되었습니다.";
}
?>
<!-- 폼 출력 시 hidden 필드로 토큰 전달 -->
<form method="POST" action="">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8'); ?>">
<input type="email" name="email" required>
<button type="submit">이메일 변경</button>
</form>
외부 사이트에서 사용자의 세션 쿠키를 악용해 위조된 POST 요청을 보내더라도, 토큰 불일치로 인해 403 Forbidden 응답과 함께 "잘못된 접근입니다. CSRF 토큰 검증 실패." 에러 메시지를 반환하고 데이터베이스 변경 처리가 완벽히 차단된다.
CSRF 방어 로직을 작성할 때 단순한 비교 연산자나 취약한 난수 생성기를 사용하면 보안 허점이 생길 수 있다.
✗ 잘못된 방식: 단순 문자열 비교(`==`) 사용 및 `rand()` 함수 사용
일반 비교 연산자(`==` 또는 `===`)를 사용하면 문자열 비교 시간에 따른 시간차 공격(Timing Attack)에 노출될 수 있다. 또한 `rand()`나 `mt_rand()` 함수는 예측 가능한 난수를 생성하므로 토큰 생성에 절대 사용해서는 안 된다.
✓ 올바른 방식: `hash_equals()` 함수 사용 및 `random_bytes()` 활용
타이밍 공격을 방지하기 위해 상수 시간 비교를 수행하는 `hash_equals()` 함수를 써야 한다. 난수 생성 시에는 암호학적으로 안전한 pseudo-random 바이트를 만드는 `random_bytes()` 함수를 사용해야 한다. 아울러 세션 쿠키 설정 시 `SameSite=Strict` 또는 `SameSite=Lax` 옵션을 적용하여 타 사이트 간 쿠키 전송 제약을 함께 거는 것이 좋다.
CSRF 공격 방어는 웹 서비스 보안에서 사용자 데이터와 시스템 신뢰성을 지키는 핵심 요소다. 요청 시마다 고유한 난수 토큰을 검증하는 작은 보안 습관이 모여서 취약점 없는 강력한 웹 시스템을 만든다는 점을 잊지 말자. 이 글의 3단계 실전 예제 코드를 참고해 서비스 내 폼 전송 로직에 토큰 검증을 적용하면, 외부 요청 위조 공격으로부터 완전히 안전한 웹 서비스를 완성할 수 있을 것이다.