쿠키 로그인 취약점
게시판의 단순 쿠키 로그인 공격을 실제 요청으로 확인하고, 자격 증명 검증 뒤 불투명 세션 식별자만 브라우저에 전달하는 인증 경계를 설계합니다.
인증은 요청을 보낸 사용자가 누구인지 확인하는 일이고, 인가는 그 사용자가 특정 게시글을 수정하거나 관리 기능을 사용할 권한이 있는지 판단하는 일입니다.
쿠키는 브라우저가 서버에서 받은 작은 값을 다음 요청마다 다시 보내는 저장 방식이고, HTTP 세션은 추측하기 어려운 쿠키 ID와 서버의 로그인 상태를 연결하는 방식입니다.
쿠키 자체는 인증 증명이 아닙니다.
memberId=7을 쿠키에 저장하고 그 값을 믿으면 공격자는 개발자 도구나 HTTP 클라이언트에서 memberId=1로 바꾸어 다른 회원이 됩니다.
인코딩, Base64, JSON 직렬화는 읽기 모양만 바꿀 뿐 위조를 막지 않습니다.
PAIRED POLICY TRACE · AUTHENTICATION BOUNDARY
같은 쿠키 입력도 인증 규칙을 통과했는지에 따라 전혀 다른 신원이 된다
왼쪽은 공격자가 정한 memberId를 첫 규칙에서 신원으로
받아들인다. 오른쪽은 자격 증명 검증, 세션 ID 교체, 서버 상태 조회를
모두 통과한 AuthenticatedMember만 인가 경계에 전달한다.
취약한 직접 식별
memberId=1을 곧바로 사용자로 해석
- FAIL — client 식별자를 신원으로 사용
- NOT REACHED — credential 검증 없음
- NOT REACHED — 서버 인증 주체 없음
결과: 공격자가 고른 다른 회원으로 해석한다.
검증된 불투명 세션
자격 증명 검증 뒤 서버 주체를 조회
- PASS — client 식별자를 신뢰하지 않음
- PASS — hash 검증 뒤 session ID 교체
- PASS — opaque ID로 서버 상태 조회
결과: 검증된 주체만 인가 경계로 전달한다.
Secure·HttpOnly·SameSite는
탈취와 전송 위험을 줄이는 별도 방어층이다. 어떤 속성도 client가 정한
회원 ID를 인증 증명으로 바꾸지는 못한다.
도식의 왼쪽은 공격자 입력을 곧바로 신원으로 해석해 첫 규칙에서 실패하고, 오른쪽은 자격 증명 검증·세션 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테스트가 통과했다는 것은 기능이 올바르다는 뜻이 아니라 취약한 가정이 재현됐다는 뜻입니다.
보안 회귀 테스트에서는 안전한 구현에 같은 쿠키를 보내 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이어야 합니다.
권한 변경 직후 기존 세션에서도 새 정책이 반영되는지 확인합니다.
다음 문서에서는 불투명 세션의 생성·조회·만료·로그아웃 수명주기와 동시 요청에서 지켜야 할 속성을 구체적으로 다룹니다.