세션을 사용하는 웹 애플리케이션을 운영하다 보면, 공격자가 사용자의 세션 ID를 미리 알아낸 후 그것으로 접근하는 상황을 마주칠 수 있다. 이를 세션 고정 공격(Session Fixation)이라고 하는데, 대부분의 개발자들은 세션 관리의 기본만 알고 있어서 이런 공격에 취약한 구조로 코딩하곤 한다. 심지어 로그인 성공 후에도 세션 ID를 그대로 유지하는 위험한 패턴을 사용하는 경우가 많다. 이번에는 세션 고정 공격이 정확히 뭔지, 왜 위험한지, 그리고 어떻게 완벽하게 방어하는지를 실무 기반으로 정리해서 소개하겠다.
세션 고정 공격은 다음과 같은 방식으로 진행된다. 공격자가 먼저 정상적으로 웹사이트에 접속해 세션을 만든 후, 그 세션 ID를 URL 파라미터나 쿠키로 피해자에게 전달한다. 예를 들어 http://example.com/?PHPSESSID=attacker_session_id 같은 링크를 SNS나 이메일로 보내는 것이다. 피해자가 그 링크를 클릭한 후 로그인하면, 공격자가 이미 알고 있는 세션 ID로 로그인된 상태가 되는 것이다. 즉, 공격자도 동일한 세션 ID를 가지고 있기 때문에 피해자의 계정으로 접근할 수 있게 된다.
왜 이런 일이 생길까? 바로 많은 PHP 애플리케이션이 로그인 전후로 세션 ID를 변경하지 않기 때문이다. $_SESSION 데이터만 수정하고 세션 ID 자체는 그대로 두면, 공격자가 이미 알고 있던 세션 ID가 계속 유효하게 된다.
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'];
$password = $_POST['password'];
// DB에서 사용자 인증
if (authenticate_user($username, $password)) {
// 위험: 세션 ID를 그대로 유지
$_SESSION['user_id'] = $user_id;
$_SESSION['username'] = $username;
$_SESSION['logged_in'] = true;
header('Location: /dashboard');
}
}
?>
이 코드는 사용자가 로그인한 후에도 session_id() 값이 변하지 않는다. 공격자가 피해자에게 보낸 세션 ID가 그대로 유효하게 남아있다는 뜻이다.
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'];
$password = $_POST['password'];
// DB에서 사용자 인증
if (authenticate_user($username, $password)) {
// 1. 기존 세션 데이터 백업
$old_session = $_SESSION;
// 2. 세션 ID 재생성 (중요!)
session_regenerate_id(true);
// 3. 새로운 세션에 사용자 정보 저장
$_SESSION['user_id'] = $user_id;
$_SESSION['username'] = $username;
$_SESSION['logged_in'] = true;
$_SESSION['login_time'] = time();
header('Location: /dashboard');
}
}
?>
session_regenerate_id(true)는 기존 세션 파일을 삭제하면서 완전히 새로운 세션 ID를 생성한다. 공격자가 미리 알고 있던 세션 ID는 더 이상 유효하지 않게 된다.
<?php
// config/session.php
// 세션 보안 설정
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // HTTPS만 사용
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.gc_maxlifetime', 3600); // 1시간
ini_set('session.sid_length', 48); // 세션 ID 길이 증가
ini_set('session.sid_bits_per_character', 6);
session_start();
?>
session.use_strict_mode는 서버에 존재하지 않는 세션 ID가 들어오면 자동으로 거부한다. 이것만으로도 대부분의 세션 고정 공격을 방어할 수 있다.
<?php
// 사용자 인증 함수
function login_user($username, $password) {
// 1. DB에서 사용자 확인
$user = get_user_by_username($username);
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
// 2. 기존 세션 완전 제거
$_SESSION = [];
// 3. 세션 ID 재생성
session_regenerate_id(true);
// 4. 새로운 세션 데이터 설정
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['email'] = $user['email'];
$_SESSION['logged_in'] = true;
$_SESSION['login_time'] = time();
$_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
$_SESSION['ip_address'] = $_SERVER['REMOTE_ADDR'];
// 5. DB에 로그인 기록 저장
save_login_log($user['id'], $_SERVER['REMOTE_ADDR']);
return true;
}
?>
<?php
// 모든 보호된 페이지에서 호출
function validate_session() {
// 1. 로그인 여부 확인
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: /login');
exit;
}
// 2. User-Agent 검증 (세션 탈취 방지)
if ($_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT']) {
session_destroy();
die('세션 위조 감지');
}
// 3. IP 주소 검증 (선택, IP 변경 가능성 고려)
// if ($_SESSION['ip_address'] !== $_SERVER['REMOTE_ADDR']) {
// session_destroy();
// die('IP 주소 변경 감지');
// }
// 4. 세션 타임아웃 검증
if (time() - $_SESSION['login_time'] > 3600) {
session_destroy();
die('세션 만료');
}
}
// 보호된 페이지
validate_session();
echo '안전한 페이지';
?>
<?php
// 잘못된 로그아웃
$_SESSION = [];
session_destroy();
// 문제: 세션 ID는 그대로 남아있음
?>
<?php
// 올바른 로그아웃
session_start();
session_regenerate_id(true); // 세션 ID 변경
$_SESSION = [];
session_destroy();
header('Location: /login');
?>
<?php
// 위험한 코드
echo '<a href="/dashboard?PHPSESSID=' . session_id() . '">대시보드</a>';
// 문제: URL이 로그에 기록되면서 세션 ID 노출
?>
PHP는 기본적으로 쿠키에만 세션 ID를 저장하도록 설정하되, 위 코드처럼 URL에 노출시키지 않아야 한다.
<?php
// 잘못된 코드
session_regenerate_id(false); // false는 기존 파일 유지
// 공격자가 이전 세션 ID로 접근 가능
?>
<?php
// 올바른 코드
session_regenerate_id(true); // true는 기존 파일 삭제
// 공격자가 이전 세션 ID로 접근 불가능
?>
세션 ID 재생성만으로는 부족하다. 쿠키 자체가 탈취되면 의미가 없기 때문이다. 반드시 다음과 같이 설정해야 한다:
| 설정 | 설명 | 코드 |
|---|---|---|
| HttpOnly | JavaScript로 접근 불가 (XSS 방지) | session.cookie_httponly = 1 |
| Secure | HTTPS 연결에서만 전송 | session.cookie_secure = 1 |
| SameSite | CSRF 공격 방지 | session.cookie_samesite = Strict |
| Strict Mode | 서버에 없는 세션 ID 자동 거부 | session.use_strict_mode = 1 |
세션 고정 공격은 생각보다 위험하지만, 간단한 한 줄의 코드(session_regenerate_id(true))로 완벽하게 방어할 수 있다. 문제는 많은 개발자들이 이 중요성을 모르거나 무시한다는 것이다. 세션 보안은 전체 웹 애플리케이션 보안의 기초이기 때문에, 작은 최적화와 습관이 모여서 진정한 보안 체계를 만든다는 점을 잊지 말자. 이 글의 로그인/로그아웃 함수와 세션 검증 로직을 참고해 지금 바로 기존 프로젝트에 적용하면, 피싱 공격을 통한 세션 탈취로부터 사용자를 완벽하게 보호할 수 있을 것이다.