웹 폼을 만들다 보면 자연스럽게 이런 생각을 하게 됩니다.
- “POST 요청인데 굳이 보안까지 신경 써야 하나?”
- “로그인도 돼 있는데 위험할 게 있나?”
하지만 이 생각이 바로 CSRF(Cross-Site Request Forgery) 취약점이 생기는 지점입니다.
아래는 실무에서 자주 쓰는 가장 단순하면서도 효과적인 CSRF 방어 코드입니다.
function csrf_token(){
if(empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_check(){
$t = $_POST['_csrf'] ?? '';
if(!$t || empty($_SESSION['_csrf']) || !hash_equals($_SESSION['_csrf'], $t)) {
exit('CSRF');
}
}
왜 이런 코드가 필요한지, 그리고 각 줄이 왜 이렇게 생겼는지를 설명합니다.
CSRF는 무엇을 막기 위한 것인가
CSRF는 한 문장으로 요약하면 이렇습니다.
“사용자가 이미 로그인되어 있다는 사실을 악용해,사용자의 의도와 무관한 요청을 서버에 보내는 공격”
중요한 점은 브라우저가 쿠키를 자동으로 전송한다는 사실입니다.
예를 들어 사용자가 로그인된 상태에서 공격자가 만든 사이트에 접속하면, 그 사이트는 이런 요청을 만들 수 있습니다.
<form action="https://example.com/delete" method="post">
<input type="hidden" name="id" value="123">
</form>
<script>document.forms[0].submit()</script>
브라우저는 쿠키를 자동으로 포함하고 서버는 “정상 로그인 사용자의 POST 요청”으로 인식합니다
결과적으로 사용자가 원하지 않은 동작이 실행됩니다.
POST라고 안전하지 않은 이유가 여기에 있습니다.
CSRF 방어의 핵심 아이디어
CSRF 방어는 생각보다 단순합니다.
“이 요청이 정말 우리 서버가 만든 폼에서 온 게 맞는가?”
이를 확인하기 위해 서버는 예측 불가능한 토큰을 하나 생성, 폼에 숨겨서 함께 전송, 요청이 돌아왔을 때 서버에 저장된 값과 비교를 하게됩니다.
공격자는 이 토큰을 알 수 없기 때문에 요청을 위조해도 검증 단계에서 걸러집니다.
csrf_token()은 왜 이렇게 생겼는가
function csrf_token(){
if(empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
} ① 왜 세션에 저장하나
CSRF 토큰은 사용자별로 달라야 하고, 서버가 신뢰할 수 있는 저장소에 있어야 합니다.
- 쿠키 ❌(자동 전송됨)
- hidden input만 ❌ (검증 기준 없음)
- 세션 ⭕
그래서 세션에 저장된 토큰과 POST로 온 값을 비교합니다.
② 왜 random_bytes()인가
random_bytes(16)
- 암호학적으로 안전한 난수
- PHP 7+ 표준
- 예측 불가능
16바이트 = 128비트
무작위로 맞추는 건 사실상 불가능한 수준입니다.
③ 왜 bin2hex()를 쓰나
random_bytes()는 바이너리 데이터를 반환합니다.
이 값은 그대로 HTML input에 쓰기 어렵기 때문에
bin2hex(random_bytes(16))
처럼 문자열(hex) 로 변환합니다.
④ 왜 “없을 때만” 생성하나
if(empty($_SESSION['_csrf']))
- 페이지 새로고침 시 토큰 유지
- 여러 폼에서 재사용 가능
- 불필요한 재생성 방지
이 방식은 세션 단위 CSRF 토큰 전략입니다.
csrf_check()는 무엇을 검증하는가
function csrf_check(){
$t = $_POST['_csrf'] ?? '';
if(!$t || empty($_SESSION['_csrf']) || !hash_equals($_SESSION['_csrf'], $t)) {
exit('CSRF');
}
}
이 함수는 3가지를 동시에 검사합니다.
① 토큰이 아예 없는 요청 차단
$t = $_POST['_csrf'] ?? '';
if(!$t) exit('CSRF');
정상적인 폼이라면 _csrf 필드가 반드시 존재해야 합니다.
없다는 건 외부에서 만든 요청일 가능성이 높습니다.
② 서버 쪽 기준 토큰이 없는 경우 차단
empty($_SESSION['_csrf'])
- 세션이 초기화되었거나
- 비정상적인 요청 흐름
이 경우도 안전하게 차단합니다.
③ 왜 hash_equals()를 쓰나
hash_equals($_SESSION['_csrf'], $t)
일반 비교(===)는 문자열이 다를 경우 중간에서 비교를 멈춥니다.
hash_equals()는:
- 항상 같은 시간에 비교
- 타이밍 공격 방지용 함수
CSRF 토큰 비교에는 정석적인 선택입니다.
실패 시 바로 exit하는 이유
exit('CSRF');
CSRF 실패는
사용자 입력 오류가 아니고
설명이 필요한 에러도 아닙니다
즉시 종료해서
- 이후 로직 실행 차단
- 공격자에게 정보 노출 최소화
또는 아래와 같이 사용하기도 합니다.
http_response_code(400);
exit('Bad Request');
이 구현의 장점 요약
- 구현 방식 : 세션 기반 CSRF 토큰
- 난수 : random_bytes()
- 비교 : hash_equals()
- 복잡도 : 매우 낮음
- 효과 : 매우 큼
가장 단순하지만 실무에서 충분히 강력한 방식
마무리
CSRF는 “로그인된 사용자”를 공격하는 취약점입니다.
그래서 오히려 관리자 페이지, 설정 변경, 삭제 요청 같은 곳에서 더 치명적입니다.
이 짧은 두 함수는 “이 요청이 정말 우리 서버가 만든 폼인가?”를 검증하는 최소한의 안전장치입니다.
귀찮아 보여도, CSRF 방어는 선택이 아니라 기본값이어야 합니다.