본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

쿠키 로그인 취약점

게시판의 단순 쿠키 로그인 공격을 실제 요청으로 확인하고, 자격 증명 검증 뒤 불투명 세션 식별자만 브라우저에 전달하는 인증 경계를 설계합니다.

인증은 요청을 보낸 사용자가 누구인지 확인하는 일이고, 인가는 그 사용자가 특정 게시글을 수정하거나 관리 기능을 사용할 권한이 있는지 판단하는 일입니다.

쿠키는 브라우저가 서버에서 받은 작은 값을 다음 요청마다 다시 보내는 저장 방식이고, HTTP 세션은 추측하기 어려운 쿠키 ID와 서버의 로그인 상태를 연결하는 방식입니다.

쿠키 자체는 인증 증명이 아닙니다.

memberId=7을 쿠키에 저장하고 그 값을 믿으면 공격자는 개발자 도구나 HTTP 클라이언트에서 memberId=1로 바꾸어 다른 회원이 됩니다.

인코딩, Base64, JSON 직렬화는 읽기 모양만 바꿀 뿐 위조를 막지 않습니다.


쿠키 위조 가능성

취약한 구현은 로그인 성공 후 공개 식별자를 쿠키에 넣고 이후 요청에서 그대로 조회합니다.

HttpOnly는 JavaScript 읽기를 제한하지만 HTTP 클라이언트가 값을 바꾸는 것을 막지 않습니다.

Secure도 전송 구간을 보호할 뿐 값의 진위를 보장하지 않습니다.

취약한 예: 신뢰하면 안 되는 식별자 cookie
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여도 추측 난이도만 달라지고 다른 경로에서 유출된 값을 재사용할 수 있습니다.

이메일이나 역할을 쿠키에 넣으면 변조 영향은 더 커집니다.


위조 로그인 테스트

src/test/java/board/web/auth/InsecureCookieAttackTest.java
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와 권한은 서버가 조회합니다.

src/main/java/board/web/auth/LoginController.java
package board.web.auth;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public final class LoginController {
    public static final String AUTHENTICATED_MEMBER =
            "authenticatedMember";
    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(
                AUTHENTICATED_MEMBER,
                new AuthenticatedMember(member.get().id()));
        return "redirect:/posts";
    }

    public record LoginForm(String email, String password) {
    }

    public record AuthenticatedMember(long id) {
    }
}

로그인 직전에 기존 세션을 폐기하고 새 ID를 발급해 세션 고정 공격을 줄입니다.

비밀번호는 세션, 로그, 플래시 속성에 저장하지 않습니다.

CredentialVerifier는 비밀번호 해시를 일정 시간 비교하고 실패 이유를 “이메일 없음”과 “비밀번호 틀림”으로 외부에 구별해 주지 않습니다.

Spring 보안을 사용하는 실제 서비스에서는 이 과정을 직접 조립하기보다 검증된 인증 필터, 세션 관리, CSRF 보호를 사용합니다.

직접 구현 예제의 목적은 각 경계의 의미를 이해하는 것이며, 자체 암호 프로토콜을 만들라는 뜻이 아닙니다.


쿠키 보안 속성

세션 ID가 위조 불가능해도 탈취·교차 요청·전송 노출을 막아야 합니다.

Secure는 HTTPS에서만 전송하고, HttpOnly는 브라우저 스크립트 접근을 제한하며, SameSite=Lax 또는 Strict는 교차 사이트 전송 범위를 줄입니다.

도메인과 경로는 필요한 최소 범위로 설정합니다.

속성줄이는 위험해결하지 못하는 것
Secure평문 HTTP 전송HTTPS 엔드포인트의 탈취
HttpOnlyJavaScript 쿠키 읽기브라우저가 보내는 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 {
    }
}

테스트는 세션 없음+관리자 쿠키, 일반 회원 세션+관리자 쿠키, 실제 관리자 세션을 나눕니다.

앞의 두 요청은 각각 401과 403, 마지막 요청만 200이어야 합니다.

권한 변경 직후 기존 세션에서도 새 정책이 반영되는지 확인합니다.

다음 문서에서는 불투명 세션의 생성·조회·만료·로그아웃 수명주기와 동시 요청에서 지켜야 할 속성을 구체적으로 다룹니다.