쿠키 로그인 취약점
게시판의 단순 쿠키 로그인 공격을 실제 요청으로 확인하고, 자격 증명 검증 뒤 불투명 세션 식별자만 브라우저에 전달하는 인증 경계를 설계합니다.
인증(authentication)은 요청을 보낸 사용자가 누구인지 확인하는 일이고, 인가(authorization)는 그 사용자에게 특정 게시글을 수정하거나 관리 기능을 쓸 권한이 있는지 판단하는 일입니다. 쿠키는 브라우저가 서버에서 받은 작은 값을 다음 요청마다 다시 보내는 저장 방식이고, HTTP 세션은 추측하기 어려운 쿠키 ID와 서버의 로그인 상태를 연결하는 방식입니다.
쿠키 자체는 인증의 증명이 아닙니다. memberId=7을 쿠키에 저장하고 그 값을 믿으면, 공격자는 개발자 도구나 HTTP 클라이언트에서 값을 memberId=1로 바꿔 다른 회원이 됩니다. 인코딩이나 Base64, JSON 직렬화는 읽는 모양만 바꿀 뿐 위조를 막지 못합니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
직접 회원 ID 쿠키와 검증 뒤 서버 세션 저장을 비교한다
실제 공격 테스트는 memberId=1을 보내면 그대로 회원 1로 해석됨을 보입니다. 안전한 로그인 예제는 verifier의 성공 결과만 새 서버 세션에 저장합니다.
EXECUTED ATTACK FIXTURE
위조 ID가 그대로 회원 1이 된다
MockHttpServletRequest에 memberId=1을 넣은 unit test가 reader의 결과 1을 확인합니다. HttpOnly와 Secure를 붙여도 HTTP 클라이언트가 직접 선택한 ID를 인증 증명으로 만들지 못합니다.
LOGIN IMPLEMENTATION / VERIFIER CONTRACT
실패는 같은 폼 · 성공은 세션 교체
verifier가 empty면 login.failed를 기록하고 같은 폼입니다. 성공이면 기존 세션 invalidate→새 세션 생성→authenticatedMember 저장→posts redirect입니다. 비밀번호 해시 비교는 CredentialVerifier interface가 요구하는 구현이며 이 문서에 해시 알고리즘 또는 구현 테스트는 없습니다.
SERVER IDENTITY / AUTHORIZATION
서버 주체 조회와 현재 권한 검사는 다음 경계
다음 문서의 SessionMemberReader가 생성 없는 세션 조회로 typed 주체를 얻습니다. 여기의 SessionAuthorization은 현재 ADMIN 역할을 repository interface에 묻습니다. 안전한 구현의 위조 쿠키 401/일반 회원 403/관리자 200 검증은 연습의 추가 조건입니다.
SEPARATE COOKIE / CSRF PROTECTION
보안 속성은 노출과 전송 범위를 제한한다
Secure는 HTTPS 전송, HttpOnly는 스크립트 읽기, SameSite는 일부 교차 사이트 전송을 제한합니다. 탈취된 정상 세션 재생과 리소스 인가는 별도 문제입니다. 상태 변경의 CSRF 검증과 로그아웃의 서버 세션 폐기도 별도 정책입니다.
취약 reader는 공격자 쿠키 memberId=1을 Long.parseLong으로 읽어 회원 1로 해석하며 자격 증명을 검사하지 않습니다. 안전한 LoginController는 CredentialVerifier 성공 뒤 기존 세션을 무효화하고 새 세션에 AuthenticatedMember를 저장합니다. 해시 검증 구현은 verifier interface의 요구 계약이며 코드에 구현되어 있지 않습니다. 쿠키 보안 속성과 CSRF 및 서버 인가는 별도 책임입니다.
그림의 위쪽은 취약한 reader가 공격자가 고른 memberId=1을 Long.parseLong으로 읽어 로그인 검증 없이 회원 1로 해석하는 경로입니다. 아래쪽은 CredentialVerifier가 성공했을 때만 기존 세션을 무효화하고 새 세션을 만든 뒤, 서버 세션에 AuthenticatedMember를 저장하는 경로입니다. 두 영역은 이어서 실행되는 것이 아니라 서로 다른 구현을 비교한 것입니다. 같은 모양의 쿠키라도 그 값을 누가 증명하고 상태를 누가 갖고 있는지가 결과를 가릅니다. 해시 검증의 구현은 verifier 인터페이스가 요구하는 일이며 이 글에 구현되어 있지 않고, 쿠키 보안 속성과 CSRF, 서버의 인가는 따로 다룹니다.
이 장의 실행 기준
8장 1편부터 9편까지의 예제를 한 프로젝트에 모아 실행할 수 있도록, 루트 빌드는 이 글에서 한 번만 정의합니다. Gradle 9.8.2와 Java 25를 쓰고, Spring Boot 4.1.2 BOM과 JUnit 6.0.3을 고정합니다.
rootProject.name = "servlet-request-contracts"plugins {
java
}
group = "board"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation(enforcedPlatform(
"org.springframework.boot:spring-boot-dependencies:4.1.2"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework:spring-tx")
testImplementation(enforcedPlatform(
"org.springframework.boot:spring-boot-dependencies:4.1.2"))
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
constraints {
testImplementation("org.junit.jupiter:junit-jupiter") {
version { strictly("6.0.3") }
}
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.release = 25
options.compilerArgs.add("-parameters")
}
tasks.test {
useJUnitPlatform()
}모든 MVC 슬라이스 테스트는 상위 board 패키지의 같은 Boot 구성을 찾습니다. 글마다 임시 테스트 구성을 만들지 않고, 애플리케이션의 진입점도 여기서 한 번만 정의합니다.
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}| 구성 요소 | 고정 버전 | 고정하는 곳 |
|---|---|---|
| Gradle | 9.8.2 | 실행 환경 |
| Java | 25 | 툴체인과 release |
| Spring Boot | 4.1.2 | enforcedPlatform BOM |
| JUnit | 6.0.3 | strict 제약 |
이 장의 MVC 슬라이스 테스트는 컨트롤러가 생성자로 받는 포트를 @MockitoBean으로 대신합니다. BoardApplication으로 전체 애플리케이션을 시작하려면 포트마다 실제 빈이 있어야 하므로, 저장소가 아직 없는 포트의 구현과 전체 구성의 시작 테스트는 게시판 웹 통합에서 한곳에 모읍니다.
쿠키는 위조할 수 있습니다
취약한 구현은 로그인에 성공한 뒤 공개 식별자를 쿠키에 넣고, 이후 요청에서 그 값을 그대로 조회합니다. HttpOnly는 JavaScript가 읽는 것을 막지만 HTTP 클라이언트가 값을 바꾸는 것은 막지 못합니다. Secure도 전송 구간을 보호할 뿐 값이 진짜인지 보장하지 않습니다.
package board.web.auth;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
public final class InsecureMemberCookie {
public long currentMemberId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
throw new UnauthenticatedException();
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals("memberId")) {
return Long.parseLong(cookie.getValue());
}
}
throw new UnauthenticatedException();
}
public static final class UnauthenticatedException
extends RuntimeException {
}
}이 코드는 쿠키가 실제 로그인을 거쳐 발급되었는지, 만료되었는지, 탈취된 뒤 폐기되었는지 알 수 없습니다. 회원 ID가 UUID여도 추측하기 어려워질 뿐, 다른 경로로 유출된 값을 다시 쓰는 것은 막지 못합니다. 이메일이나 역할을 쿠키에 넣으면 변조의 영향은 더 커집니다.
위조 로그인 테스트
package board.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
class InsecureCookieAttackTest {
@Test
void 공격자가_memberId_cookie를_바꾸면_다른_회원으로_해석된다() {
var request = new MockHttpServletRequest();
request.setCookies(new Cookie("memberId", "1"));
var reader = new InsecureMemberCookie();
long interpretedMember = reader.currentMemberId(request);
assertThat(interpretedMember).isEqualTo(1L);
}
}password verification = not executed
attacker supplied Cookie: memberId=1
server interpreted member = 1
authorization boundary = bypassed이 테스트가 통과했다는 것은 기능이 올바르다는 뜻이 아니라 취약한 가정이 재현되었다는 뜻입니다. 테스트가 직접 검사하는 값은 currentMemberId()가 돌려준 1입니다. 결과 설명에 적힌 인가 우회는 이 값을 신원으로 믿는 핸들러가 뒤에 있다고 가정한 위험이며, 이 단위 테스트가 실제 HTTP 관리자 화면의 200이나 안전한 구현의 401까지 실행한 것은 아닙니다. 테스트 대상을 reader와 인증 정책, HTTP 오류 표현으로 나누어 확인하고, 보안 회귀 테스트에서는 안전한 구현에 같은 쿠키를 보내 401이나 로그인 리다이렉트가 되는지를 검증합니다.
불투명한 세션 식별자
로그인 요청은 인증 정보 검증기를 거쳐야 하고, 성공하면 서버 쪽 세션에 인증 주체를 저장합니다. 쿠키에는 엔트로피가 충분한 불투명 ID만 있고, 실제 회원 ID와 권한은 서버가 조회합니다. 아래 LoginController가 쓰는 SessionMemberReader와 그 안의 AuthenticatedMember는 다음 글 HTTP 세션 수명 주기에서 만드는 src/main/java/board/web/auth/SessionMemberReader.java에 있으므로, 이 컨트롤러를 넣은 뒤 프로젝트를 컴파일하고 앞의 공격 테스트를 실행하려면 그 파일도 함께 추가합니다.
package board.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.util.Optional;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import board.web.auth.SessionMemberReader.AuthenticatedMember;
@Controller
public final class LoginController {
private final CredentialVerifier credentials;
public LoginController(CredentialVerifier credentials) {
this.credentials = credentials;
}
@PostMapping("/login")
String login(
@ModelAttribute("form") LoginForm form,
BindingResult result,
HttpServletRequest request
) {
if (result.hasErrors()) {
return "login/form";
}
var member = credentials.verify(
form.email(), form.password());
if (member.isEmpty()) {
result.reject("login.failed", "로그인 정보를 확인하세요.");
return "login/form";
}
HttpSession previous = request.getSession(false);
if (previous != null) {
previous.invalidate();
}
HttpSession session = request.getSession(true);
session.setAttribute(
SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
member.get());
return "redirect:/posts";
}
public record LoginForm(String email, String password) {
}
public interface CredentialVerifier {
Optional<AuthenticatedMember> verify(
String email, String password);
}
}로그인 직전에 기존 세션을 폐기하고 새 ID를 발급해 세션 고정(session fixation) 공격을 줄입니다. 비밀번호는 세션이나 로그, 플래시 속성에 저장하지 않습니다. CredentialVerifier는 비밀번호 해시를 상수 시간으로 비교하고, 실패 이유를 이메일 없음과 비밀번호 틀림으로 나누어 바깥에 알려 주지 않습니다.
Spring Security를 쓰는 실제 서비스에서는 이 과정을 직접 조립하기보다 검증된 인증 필터와 세션 관리, CSRF 보호를 씁니다. 직접 구현한 예제는 각 단계의 의미를 이해하기 위한 것이며, 암호 프로토콜을 직접 만들라는 뜻이 아닙니다.
더 깊이: 내장 Tomcat의 세션 ID는 어떻게 만들어질까
Spring Boot 4.1.2가 쓰는 내장 Tomcat 11은 따로 설정하지 않으면 표준 세션 ID 생성기(StandardSessionIdGenerator)로 ID를 만듭니다. Tomcat 문서에 따르면 이 생성기는 sessionIdLength의 기본값인 16개의 무작위 바이트를 만들고, 바이트마다 16진수 두 글자로 적습니다. 그래서 JSESSIONID의 값은 32글자이고, 무작위로 정해지는 부분은 128비트입니다. 무작위 바이트는 java.security.SecureRandom에서 얻으며, 알고리즘을 지정하지 않으면 SHA1PRNG를 쓰고 그 알고리즘이 없는 플랫폼에서는 플랫폼의 기본 알고리즘을 씁니다.
이 크기라면 공격자가 살아 있는 세션의 ID를 하나하나 대입해 맞히는 방법은 현실적이지 않습니다. 그래서 세션 ID를 지키는 일은 추측을 막는 것보다, 이미 발급된 값이 로그나 URL, 평문 전송으로 새 나가지 않게 하는 쪽에 달려 있습니다. 한 가지 예외는 여러 인스턴스를 둔 Tomcat에서 엔진에 jvmRoute를 정한 경우입니다. 이때 생성기는 그 값을 .으로 이어 ID 끝에 붙이는데, 로드 밸런서가 같은 인스턴스로 요청을 보내게 하려는 표시라서 무작위가 아니라 서버 이름이 그대로 드러나는 부분입니다.
쿠키의 보안 속성
세션 ID를 위조할 수 없어도 탈취와 교차 요청, 전송 중 노출은 따로 막아야 합니다. Secure는 HTTPS에서만 전송하게 하고, HttpOnly는 브라우저 스크립트의 접근을 막으며, SameSite=Lax나 Strict는 교차 사이트 전송의 범위를 줄입니다. 도메인과 경로는 필요한 최소 범위로 설정합니다.
| 속성 | 줄이는 위험 | 해결하지 못하는 것 |
|---|---|---|
Secure | 평문 HTTP 전송 | HTTPS 엔드포인트에서의 탈취 |
HttpOnly | JavaScript의 쿠키 읽기 | 브라우저가 보내는 CSRF 요청 |
SameSite | 일부 교차 사이트 요청 | 같은 사이트의 XSS와 모든 CSRF 흐름 |
| 짧은 만료 | 오랜 기간의 재사용 | 만료 전에 탈취한 값의 재사용 |
인증 쿠키와 CSRF 토큰의 역할을 섞지 않습니다. SameSite는 방어층 가운데 하나일 뿐이므로, 상태를 바꾸는 폼에는 Spring Security의 CSRF 토큰처럼 요청의 의도를 검증하는 장치를 둡니다. 로그아웃할 때는 쿠키만 지우지 않고 서버 세션도 폐기합니다.
자체 포함 쿠키의 운영 비용
HMAC을 붙이면 페이로드의 변조는 알아챌 수 있지만, 탈취한 정상 토큰의 재사용과 즉시 로그아웃, 권한 변경의 반영, 키 교체 문제가 남습니다. 암호화는 내용의 노출을 줄여도 재사용 문제를 저절로 풀지 않습니다.
서버 메모리를 아낀다는 이유만으로 모든 인증 상태를 토큰에 넣지 않습니다. 게시판처럼 브라우저 중심의 단일 웹 애플리케이션에는 불투명 세션이 더 단순합니다. 독립된 서비스가 여럿이거나 모바일 클라이언트가 있다면 표준 토큰을 검토하되, 발급자와 대상, 만료, 폐기, 교체를 포함한 전체 위협 모델을 세웁니다. JWT라는 형식의 이름을 고르는 것만으로 인증 설계가 끝나지는 않으며, JWT의 구조와 서버 세션과의 차이는 쿠키와 세션에서 다룹니다.
정리
- 쿠키는 클라이언트가 바꿀 수 있는 값이므로, 회원 ID나 역할을 쿠키에 넣고 신원으로 믿으면 다른 회원이 될 수 있습니다.
- 로그인은 검증기를 통과했을 때만 기존 세션을 폐기하고 새 세션에 인증 주체를 저장하며, 쿠키에는 불투명한 세션 ID만 둡니다.
Secure,HttpOnly,SameSite는 각각 전송과 스크립트 읽기, 교차 사이트 전송을 줄일 뿐, 위조나 CSRF, 탈취한 세션의 재사용을 모두 막지는 않습니다.- 상태를 바꾸는 요청에는 CSRF 토큰을 두고, 로그아웃할 때는 서버 세션도 폐기합니다.
- 서명하거나 암호화한 자체 포함 토큰도 재사용과 즉시 폐기, 키 교체 문제가 남으므로 위협 모델 전체를 보고 고릅니다.
연습 문제
1. 역할 쿠키를 믿는 핸들러 제거하기
취약한 role=ADMIN 쿠키를 읽어 관리자 화면을 허용하는 핸들러를 테스트로 재현한 뒤 없애려고 합니다. 로그인에 성공하면 서버 세션에는 회원 ID만 저장하고, 관리자 여부는 요청마다 현재 회원 정보를 조회하거나 잠시 캐시한 권한 스냅샷으로 확인합니다. 위조한 쿠키와 탈취한 세션을 구별해 기대 결과를 어떻게 작성하나요?
해설 보기
role이나 memberId 쿠키가 있어도 인증 세션이 없으면 로그인하지 않은 요청입니다. 세션의 회원 ID로 서버 쪽 권한을 조회하고, 리소스별 소유권도 따로 확인합니다.
package board.web.auth;
public final class SessionAuthorization {
private final MemberAuthorityRepository authorities;
public SessionAuthorization(MemberAuthorityRepository authorities) {
this.authorities = authorities;
}
public void requireAdmin(long authenticatedMemberId) {
if (!authorities.hasRole(authenticatedMemberId, "ADMIN")) {
throw new ForbiddenException();
}
}
public static final class ForbiddenException extends RuntimeException {
}
public interface MemberAuthorityRepository {
boolean hasRole(long memberId, String role);
}
}테스트는 세션 없이 관리자 쿠키만 있는 요청, 일반 회원 세션에 관리자 쿠키를 더한 요청, 실제 관리자 세션의 요청으로 나눕니다. 앞의 두 요청은 각각 401과 403, 마지막 요청만 200이어야 합니다. 권한을 바꾼 직후 기존 세션에도 새 정책이 반영되는지 확인합니다.
2. 클라이언트의 주장과 서버의 주체
다음 글의 SessionMemberReader를 쓰며, 서버 세션에는 회원 41의 AuthenticatedMember가 있습니다. 요청에는 공격자가 더한 memberId=1과 role=ADMIN 쿠키도 함께 있고, 권한 저장소는 회원 41의 ADMIN 여부를 거짓으로 돌려줍니다. 안전한 reader가 읽는 회원 ID와 SessionAuthorization.requireAdmin()의 결과는 무엇인가요? 같은 쿠키를 보내되 올바른 세션이 없는 요청과도 구분하세요.
해설 보기
안전한 reader는 회원 ID 쿠키를 신원으로 해석하지 않고 현재 서버 세션의 타입이 있는 속성을 읽으므로 회원 41을 얻습니다. ADMIN 쿠키도 현재 권한 조회를 바꾸지 않으므로, requireAdmin(41)은 ForbiddenException을 던집니다. 올바른 세션이 없는 요청에서는 reader가 Optional.empty()를 돌려주며, 회원 1이나 관리자로 올라가지 않습니다.
API라면 두 경우를 각각 403과 401로 나타내는 정책을 둘 수 있지만, reader와 권한 클래스만으로 그 HTTP 변환이 구현되지는 않습니다. HTML의 로그인 리다이렉트나 API의 오류 매핑을 실제 권한 핸들러와 연결한 테스트를 더해야 합니다. 반면 탈취된 정상 세션 ID는 서버가 올바르게 조회할 수 있으므로, 위조한 회원과 역할 쿠키를 무시하는 것만으로는 거부되지 않습니다. 만료와 폐기, 재인증, 리소스 소유권을 따로 검사해야 하는 이유입니다.
다음 글 HTTP 세션 수명 주기에서는 불투명 세션의 생성과 조회, 만료, 로그아웃의 수명 주기와, 동시 요청에서 지켜야 할 속성을 구체적으로 다룹니다.