쿠키 로그인 취약점
게시판의 단순 쿠키 로그인 공격을 실제 요청으로 확인하고, 자격 증명 검증 뒤 불투명 세션 식별자만 브라우저에 전달하는 인증 경계를 설계합니다.
인증은 요청을 보낸 사용자가 누구인지 확인하는 일이고, 인가는 그 사용자가 특정 게시글을 수정하거나 관리 기능을 사용할 권한이 있는지 판단하는 일입니다.
쿠키는 브라우저가 서버에서 받은 작은 값을 다음 요청마다 다시 보내는 저장 방식이고, 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 및 서버 인가는 별도 책임입니다.
도식의 왼쪽은 공격자 입력을 곧바로 신원으로 해석해 첫 규칙에서 실패하고, 오른쪽은 자격 증명 검증·세션 ID 교체·서버 상태 조회를 모두 통과합니다. 같은 모양의 쿠키라도 값을 누가 증명하고 상태를 누가 소유하는지가 결과를 가릅니다.
이 장의 실행 기준선
ch8-1부터 ch8-9까지의 예제를 한 프로젝트에 모아 실행할 수 있도록 루트 빌드 계약은 이 문서에서 한 번만 정의합니다. Gradle 9.5.1과 Java 25를 사용하고 Spring Boot 4.1.1 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.1"))
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.1"))
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 slice 테스트는 상위 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.5.1 | 실행 환경 |
| Java | 25 | toolchain·release |
| Spring Boot | 4.1.1 | enforcedPlatform BOM |
| JUnit | 6.0.3 | strict constraint |
쿠키 위조 가능성
취약한 구현은 로그인 성공 후 공개 식별자를 쿠키에 넣고 이후 요청에서 그대로 조회합니다.
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와 권한은 서버가 조회합니다.
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를 발급해 세션 고정 공격을 줄입니다.
비밀번호는 세션, 로그, 플래시 속성에 저장하지 않습니다.
CredentialVerifier는 비밀번호 해시를 상수 시간으로 비교하고 실패 이유를 “이메일 없음”과 “비밀번호 틀림”으로 외부에 구별해 주지 않습니다.
Spring 보안을 사용하는 실제 서비스에서는 이 과정을 직접 조립하기보다 검증된 인증 필터, 세션 관리, CSRF 보호를 사용합니다.
직접 구현 예제의 목적은 각 경계의 의미를 이해하는 것이며, 자체 암호 프로토콜을 만들라는 뜻이 아닙니다.
쿠키 보안 속성
세션 ID가 위조 불가능해도 탈취·교차 요청·전송 노출을 막아야 합니다.
Secure는 HTTPS에서만 전송하고, HttpOnly는 브라우저 스크립트 접근을 제한하며, SameSite=Lax 또는 Strict는 교차 사이트 전송 범위를 줄입니다.
도메인과 경로는 필요한 최소 범위로 설정합니다.
| 속성 | 줄이는 위험 | 해결하지 못하는 것 |
|---|---|---|
Secure | 평문 HTTP 전송 | HTTPS 엔드포인트의 탈취 |
HttpOnly | JavaScript 쿠키 읽기 | 브라우저가 보내는 CSRF 요청 |
SameSite | 일부 교차 사이트 요청 | 동일 사이트 XSS·모든 CSRF 흐름 |
| 짧은 만료 | 장기 재생 | 만료 전 탈취 재사용 |
인증 쿠키와 CSRF 토큰의 역할을 섞지 않습니다.
SameSite는 방어층 하나이고 상태 변경 폼에는 Spring 보안의 CSRF 토큰처럼 요청 의도를 검증하는 장치를 둡니다.
로그아웃은 쿠키만 지우지 않고 서버 세션도 폐기합니다.
자체 포함 쿠키의 운영 비용
HMAC을 붙이면 페이로드 변조는 탐지할 수 있지만 탈취한 정상 토큰의 재생, 즉시 로그아웃, 권한 변경 반영, 키 교체 문제가 남습니다.
암호화는 내용 노출을 줄여도 재생을 자동 해결하지 않습니다.
“서버 메모리를 아낀다”는 이유만으로 모든 인증 상태를 토큰에 넣지 않습니다.
게시판처럼 브라우저 중심의 단일 웹 애플리케이션은 불투명 세션이 단순합니다.
여러 독립 서비스나 모바일 클라이언트가 있다면 표준 토큰을 검토하되 발급자, 대상, 만료, 폐기, 교체를 포함한 전체 위협 모델을 세웁니다.
JWT라는 형식 이름만 선택해서는 인증 설계가 완성되지 않습니다.
연습 문제
취약한 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이어야 합니다.
권한 변경 직후 기존 세션에서도 새 정책이 반영되는지 확인합니다.
독립 확인
다음 절의 SessionMemberReader를 사용하며 서버 세션에는 회원 41의 AuthenticatedMember가 있습니다. 요청에는 공격자가 추가한 memberId=1, role=ADMIN 쿠키도 함께 있습니다. 권한 저장소는 회원 41의 ADMIN 여부를 false로 반환한다고 가정합니다. 안전한 reader가 읽는 회원 ID와 SessionAuthorization.requireAdmin()의 결과는 무엇인가요? 같은 쿠키를 보내되 유효 세션이 없는 요청도 구분하세요.
클라이언트 주장과 서버 주체를 나누는 풀이
안전한 reader는 회원 ID 쿠키를 신원으로 해석하지 않고 현재 서버 세션의 typed 속성을 읽으므로 회원 41을 얻습니다. ADMIN 쿠키도 현재 권한 조회를 바꾸지 않습니다. 따라서 requireAdmin(41)은 ForbiddenException을 던집니다. 유효 세션이 없는 별도 요청은 reader의 Optional.empty() 결과이며 회원 1이나 관리자로 승격되지 않습니다.
API라면 두 경우를 각각 403과 401로 표현하는 정책을 둘 수 있지만, reader와 권한 클래스만으로 그 HTTP 변환이 구현되지는 않습니다. HTML의 로그인 리다이렉트, API 오류 매핑과 실제 권한 핸들러를 연결한 테스트를 추가해야 합니다. 반면 탈취된 정상 세션 ID는 서버가 유효하게 조회할 수 있으므로 위조한 회원·역할 쿠키를 무시하는 방어만으로 거부되지 않습니다. 만료·폐기·재인증과 리소스 소유권을 별도로 검사해야 하는 이유입니다.
다음 문서에서는 불투명 세션의 생성·조회·만료·로그아웃 수명 주기와 동시 요청에서 지켜야 할 속성을 구체적으로 다룹니다.