설명
쿠키 API는 일반적인 쿠키 값을 직렬화하면서 따옴표와 제어 문자를 처리합니다. 따라서 요청에서 가져온 환경설정 값을 theme처럼 고정된 비세션 쿠키에 저장하는 행위 자체는 쿠키 주입이 아닙니다.
다음과 같이 쿠키 이름이나 세션 식별자의 출처를 신뢰하면 문제가 발생할 수 있습니다.
- 신뢰할 수 없는 값이 쿠키 키로 사용되는 경우
- 신뢰할 수 없는 값이 세션 식별자로 사용되는 경우
- 신뢰할 수 없는 문자열이
Set-Cookie헤더 전체로 사용되는 경우
외부 입력을 세션 쿠키에 넣었다고 항상 계정 탈취가 성립하는 것은 아닙니다. 실제 세션 고정이 성립하려면 애플리케이션이 공격자가 알고 있는 식별자를 받아들이고, 피해자가 인증한 뒤에도 같은 식별자나 세션 상태를 재사용해야 합니다. Secure, HttpOnly, SameSite를 설정해도 이 식별자 수명주기 결함은 해결되지 않습니다.
신뢰할 수 없는 쿠키 키는 기존 세션 쿠키나 다른 보안상 중요한 쿠키의 이름을 덮어쓸 수 있습니다. 헤더 전체를 신뢰할 수 없는 값으로 설정하면 공격자가 쿠키 이름, 값, 적용 범위와 속성을 함께 제어할 수 있습니다.
잠재적 영향
- 공격자가 알고 있는 세션 식별자가 로그인 후에도 유지되는 경우 세션 고정과 계정 탈취
- 보안상 중요한 쿠키 이름을 덮어써 인증 또는 애플리케이션 상태를 변조
Domain,Path,SameSite,Secure,HttpOnly같은 속성의 공격자 제어로 쿠키 적용 범위나 전송 정책 약화
보안 속성이 누락된 쿠키, 쿠키에 저장된 민감정보, 쿠키 값을 HTML로 출력할 때의 XSS는 각각 별도로 점검해야 합니다.
해결 방법
- 요청 데이터를 세션 쿠키로 복사하지 말고 프레임워크의 세션 관리자를 사용합니다. 서버 측 세션 식별자는 세션 시스템이 생성하고 서버가 발급한 값만 받아들여야 합니다.
- 로그인 및 모든 권한 변경 후 세션을 갱신하고 이전 식별자를 무효화합니다. Django의
login()은 필요에 따라cycle_key()로 키를 갱신하거나 기존 세션을 교체합니다. Flask나 Starlette의 서명된 쿠키 세션에서는 인증된 사용자 정보를 기록하기 전에 인증 전 상태를 비웁니다. 서버 측 세션 확장 기능은 해당 기능의 회전 API를 사용합니다. - 쿠키 키를 고정합니다. 키 선택이 제품 요구사항이라면 클라이언트 입력을 서버가 소유한 소수의 상수 이름에 매핑합니다. 클라이언트가 함께 보낸 허용 목록은 신뢰하지 않습니다.
- 신뢰할 수 있는 키와 함께
Response.set_cookie()를 사용하고, 요청에서 파생된 문자열을Set-Cookie헤더 전체에 대입하지 않습니다. Werkzeug의dump_cookie()는 일반 쿠키 값을 구조화할 수 있지만, 세션 식별자의 출처를 검증하거나 회전하지는 않습니다. 키와 보안 속성은 서버가 제어해야 합니다. - 세션 쿠키에는 HTTPS와 함께
Secure,HttpOnly를 사용하고 흐름에 맞는SameSite를 선택합니다.Domain과Path를 최소화하고 호환되는 경우__Host-접두사를 우선 사용합니다. 이러한 속성은 방어 계층이며 세션 회전을 대신하지 않습니다. - 고정된 비세션 쿠키의 값은 업무상 허용되는 값인지 검증합니다. 무결성이 중요한 클라이언트 측 상태에는 서명하거나 서버에 저장합니다. 서명은 변조를 막지만 기밀성을 제공하지 않습니다.
단순히 secrets.token_urlsafe()로 값을 만들고 쿠키에 넣는 것만으로는 충분하지 않습니다. 서버가 발급한 식별자만 받아들이고 인증 시점에 회전하며 이전 식별자를 폐기하는 세션 수명주기가 함께 구현되어야 합니다.
예시
세션 관리
변경 전
FastAPI/Starlette에서 요청 값을 세션 쿠키로 사용합니다.
from fastapi import FastAPI, Request
from starlette.responses import Response
app = FastAPI()
@app.get("/set-session")
async def set_session(request: Request):
response = Response("ok")
response.set_cookie(
key="session",
value=request.query_params["sid"], # 취약: 공격자가 아는 세션 값
secure=True,
httponly=True,
samesite="lax",
)
return response
보안 속성을 모두 설정해도 공격자가 정한 세션 값을 애플리케이션이 인증 후 재사용한다면 세션 고정 가능성이 남습니다.
변경 후
Django의 세션 관리자를 사용합니다.
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def sign_in(request):
user = authenticate(
request,
username=request.POST["username"],
password=request.POST["password"],
)
if user is None:
return HttpResponse(status=401)
login(request, user) # Django가 세션 키를 회전하거나 안전하게 교체
return HttpResponse(status=204)
Flask의 서명된 쿠키 세션을 사용합니다.
import os
from flask import Flask, abort, request, session
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
@app.post("/login")
def sign_in():
user = verify_credentials(
request.form["username"],
request.form["password"],
)
if user is None:
abort(401)
session.clear() # 인증 전 상태를 제거
session["user_id"] = user.id
return {"status": "ok"}
verify_credentials는 애플리케이션의 인증 함수를 나타냅니다. 예제의 핵심은 요청 값을 세션 식별자로 복사하지 않고, 인증 성공 후 기존 상태를 비운 다음 프레임워크 세션에 인증 정보를 기록하는 것입니다.
쿠키 이름과 헤더
변경 전
Flask에서 요청 값을 쿠키 키로 사용합니다.
from flask import Flask, make_response, request
app = Flask(__name__)
@app.get("/set-cookie")
def set_cookie():
response = make_response("ok")
response.set_cookie(request.args["name"], "1") # 취약: 동적 쿠키 키
return response
Django에서 Set-Cookie 헤더 전체를 요청 값으로 설정합니다.
from django.http import HttpResponse
def set_raw_cookie(request):
response = HttpResponse("ok")
response.headers["Set-Cookie"] = request.GET["raw"] # 취약: 원시 헤더 전체
return response
Flask에서 요청 값을 매핑의 기본값 또는 직렬화된 세션 값으로 사용합니다.
from flask import make_response, request
from werkzeug.http import dump_cookie
def unsafe_cookie_defaults():
names = {"theme": "theme", "locale": "locale"}
response = make_response("ok")
name = names.get("unknown", request.args["name"])
response.set_cookie(name, "light") # 취약: 기본값은 상수 매핑 밖의 요청 값
response.headers["Set-Cookie"] = dump_cookie(
"__Host-session", request.args["sid"], secure=True, path="/"
) # 취약: 직렬화해도 세션 식별자는 공격자가 선택
return response
변경 후
고정된 비세션 쿠키를 사용합니다.
from fastapi import HTTPException, Request
from starlette.responses import Response
async def save_theme(request: Request):
theme = request.query_params.get("theme", "light")
if theme not in {"light", "dark"}:
raise HTTPException(status_code=400)
response = Response("saved")
response.set_cookie(
key="theme",
value=theme,
secure=True,
samesite="lax",
)
return response
고정된 theme 키의 값은 쿠키 API가 직렬화합니다. 여기서 값 검증은 업무 규칙을 위한 것이며, 원시 Set-Cookie 헤더를 정제하는 수단이 아닙니다.
상수 이름을 선택하고 일반 값을 직렬화합니다.
from flask import make_response, request
from werkzeug.http import dump_cookie
def save_preference():
names = {"theme": "theme", "locale": "locale"}
name = names.get(request.args["kind"], "theme")
response = make_response("ok")
response.set_cookie(name, "light")
response.headers["Set-Cookie"] = dump_cookie("locale", request.args["locale"])
return response
기본값도 상수 쿠키 이름이므로 선택 범위가 고정됩니다. locale은 비세션 키이며 직렬화는 쿠키 구문을 처리합니다. 값의 업무상 의미는 별도로 검증합니다.