남의 계정 페이지가 캐시에 남는다: 캐시 속임과 캐시 오염 막기

아프로·2026년 11월 1일

캐시 속임과 캐시 오염 표지

2023년 3월, ChatGPT에서 계정 탈취로 이어질 수 있는 문제가 제보됐습니다. 로그인한 사용자의 세션 정보, 곧 이메일과 이름과 액세스 토큰을 돌려주는 엔드포인트가 있었는데, 이 응답이 정적 파일처럼 보이는 경로로 요청되면 캐시 서버에 저장될 수 있었습니다. 피해자가 그런 링크를 한 번 열면, 같은 주소를 요청한 다른 사람이 캐시에서 피해자의 토큰을 받아 갈 수 있는 구조였습니다. OpenAI는 제보 후 수 시간 안에 캐시 서버가 해당 엔드포인트를 저장하지 않도록 정규식 규칙을 넣었고, 이 규칙을 피하는 우회가 다시 발견되자 추가로 수정했습니다(보도 기준이며, 연구자 원문을 직접 확인하지 못해 세부 시각은 [추정]입니다). 앱 코드에는 인증 버그가 없었습니다. 캐시와 앱이 같은 주소를 서로 다르게 이해했을 뿐입니다.

이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 11-4

이 글의 범위. 웹 캐시 속임과 캐시 오염의 원리, 사례, 방어를 다룹니다. 공격용 URL·헤더 조합, 재현 절차, 도구 사용법은 싣지 않습니다. 원리는 그림과 말로 설명하고, 코드와 설정은 모두 방어 쪽이며 도메인과 경로는 가짜 값입니다. 사실관계는 연구 기관·보도의 공개 자료(2026-11-01 기준)를 따르고, 원문을 직접 확인하지 못한 항목은 [추정]으로 표시합니다.

한눈에 보기

기법한 줄 정의대표 사례막는 핵심가장 가까운 OWASP 2025
웹 캐시 속임(Web Cache Deception)피해자의 개인화된 응답을 캐시가 공개 정적 파일로 착각해 저장하게 만든다ChatGPT(2023-03), PayPal(2017 공개), PortSwigger "Gotta Cache 'em all"(2024-08)인증 응답 private, no-store 기본값, 엄격한 라우팅, 캐시 여부는 오리진 헤더로 결정A01 접근 통제 실패
웹 캐시 오염(Web Cache Poisoning)공격자가 만든 오염된 응답을 캐시에 넣어 같은 주소를 여는 모든 사용자에게 퍼뜨린다. 오류 응답을 넣으면 서비스 거부(CPDoS)CPDoS(2019, ACM CCS): 캐시 15종 중 CDN 5곳·프록시 캐시 1종 취약응답을 바꾸는 입력은 캐시 키·Vary에 넣거나 신뢰하지 않음, 오류 응답 캐시 금지A02 보안 설정 오류

두 기법은 방향이 반대입니다. 캐시 속임은 피해자의 응답이 공개되는 문제이고, 캐시 오염은 공격자의 응답이 공개되는 문제입니다. 원인은 같습니다. 캐시와 오리진(원래 응답을 만드는 앱 서버)이 "이 요청은 무엇이고, 이 응답은 누구 것인가"를 다르게 판단합니다. 02편에서 본 파서 차이가 캐시 앞에서 다시 나타난 셈입니다.

먼저 알아 둘 것: 캐시는 무엇을 보고 저장하나

캐시 공격을 이해하려면 캐시가 응답을 어떻게 구분하고, 저장 여부를 누가 정하는지부터 알아야 합니다.

캐시는 여러 곳에 있습니다. 브라우저 캐시는 사용자 한 명만 씁니다. 반면 CDN과 리버스 프록시 캐시(nginx proxy_cache, Varnish 등)는 여러 사용자가 함께 쓰는 공유 캐시입니다. 이 글의 문제는 모두 공유 캐시에서 생깁니다. 한 사람의 응답이 다른 사람에게 갈 수 있는 곳이 공유 캐시이기 때문입니다.

캐시 키가 응답을 구분합니다. 공유 캐시는 요청이 오면 몇 가지 값을 묶어 "캐시 키"를 만들고, 같은 키로 저장된 응답이 있으면 오리진에 묻지 않고 그 응답을 돌려줍니다. 키는 보통 호스트 + 경로 + 쿼리 일부입니다. 쿠키나 대부분의 요청 헤더는 키에 들어가지 않습니다. 사용자마다 쿠키가 다르니 키에 넣으면 캐시가 거의 적중하지 않기 때문입니다. 여기서 캐시는 하나의 가정을 깔고 움직입니다. "키가 같으면 응답도 같다." 두 공격 모두 이 가정을 깨뜨립니다.

저장 여부는 원래 오리진이 정합니다. HTTP는 오리진이 응답 헤더로 캐시 정책을 알리게 했습니다.

지시어뜻
Cache-Control: public, max-age=3600공유 캐시를 포함해 누구나 1시간 저장해도 된다
Cache-Control: private브라우저 같은 개인 캐시만 저장할 수 있고, 공유 캐시는 저장하면 안 된다
Cache-Control: no-store어디에도 저장하지 않는다
Cache-Control: no-cache저장은 해도 되지만, 쓰기 전에 오리진에 매번 확인한다
Vary: Accept-Language이 요청 헤더 값에 따라 응답이 달라지니, 캐시 키에 함께 넣어라

그런데 CDN 규칙이 오리진 헤더를 덮어쓸 수 있습니다. 많은 CDN은 콘솔에서 ".css, .js, .png 같은 확장자는 오리진 헤더와 관계없이 하루 저장" 같은 규칙을 걸 수 있습니다. 정적 파일의 캐시 적중률을 높이려는 편리한 기능입니다. 이 순간 캐시 결정권이 오리진의 헤더에서 CDN의 URL 모양 판단으로 넘어갑니다. 캐시 속임은 정확히 이 지점을 이용합니다.

1. 캐시 속임: 내 계정 페이지가 공개 파일로 저장된다

캐시 속임 원리. 로그인한 피해자가 정적 파일처럼 보이는 경로를 열면 CDN은 확장자를 보고 저장하기로 하고, 오리진은 경로 뒷부분을 무시해 계정 페이지를 돌려준다. 그 응답이 저장돼 다른 사람이 같은 주소로 받아 간다

원리

캐시 속임은 두 프로그램의 판단이 동시에 어긋날 때 생깁니다.

  1. 캐시의 판단: "이 URL은 .css로 끝나니 정적 파일이다. 저장한다." 캐시는 응답 내용을 열어 보지 않고 URL 모양만 봅니다.
  2. 오리진의 판단: "이 URL의 앞부분은 계정 페이지 경로다. 뒤에 붙은 부분은 무시하고 계정 페이지를 돌려준다." 많은 웹 프레임워크가 오랫동안 이런 느슨한 매칭을 기본으로 했습니다. 경로 뒤에 붙은 확장자를 "응답 형식 힌트"로 보고 떼어 내거나, 끝의 슬래시나 추가 경로 조각을 관대하게 처리했습니다.

두 판단이 겹치면 이런 일이 생깁니다. 공격자는 피해자에게 "계정 경로 뒤에 정적 확장자가 붙은" 링크를 보내고, 로그인한 피해자가 그 링크를 엽니다. 오리진은 피해자의 쿠키를 보고 피해자의 계정 페이지를 만들어 돌려줍니다. 캐시는 확장자를 보고 이 응답을 공유 캐시에 저장합니다. 캐시 키에는 사용자 구분이 없으므로, 이제 그 주소를 요청하는 사람은 누구든 로그인 없이 피해자의 계정 페이지를 받습니다. ChatGPT 사례처럼 그 페이지에 액세스 토큰이 들어 있다면 계정 탈취로 이어집니다.

확장자만 문제가 되는 것도 아닙니다. 2024년 PortSwigger 연구는 캐시와 오리진이 경로 구분자와 정규화를 다르게 처리하는 경우를 체계적으로 정리했습니다. 어떤 문자를 경로 구분자로 보는지, 인코딩된 문자를 언제 푸는지, 점 두 개(..) 같은 상대 경로를 누가 먼저 정리하는지가 캐시와 오리진 사이에서 다르면, 캐시는 "정적 경로"로, 오리진은 "계정 경로"로 같은 URL을 읽습니다. 02편의 URL 파서 차이와 같은 구조입니다.

왜 OWASP Top 10에 이름이 없나

결과는 다른 사람의 데이터가 노출되는 것이므로 A01 접근 통제 실패에 해당합니다. 그런데 앱의 접근 통제 코드는 올바르게 동작했습니다. 오리진은 피해자의 쿠키를 확인하고 피해자에게 피해자의 데이터를 줬습니다. 캐시도 설정된 규칙대로 확장자를 보고 저장했습니다. 두 구성 요소가 각자 맞게 동작했는데, 둘 사이의 의미가 어긋나서 데이터가 샜습니다. 앱 하나의 결함을 기준으로 분류하는 Top 10에서는 이 원인을 가리킬 이름이 없습니다.

실제 사례

캐시 공격 연구와 사고의 흐름. 2017년 PayPal 사례로 웹 캐시 속임 첫 공개, 2019년 CPDoS, 2023년 3월 ChatGPT 캐시 속임, 2024년 8월 PortSwigger의 경로 구분자·정규화 차이 연구

  • ChatGPT 웹 캐시 속임 (2023-03). 세션 정보(이메일, 이름, 액세스 토큰)를 돌려주는 엔드포인트가 정적 파일처럼 보이는 경로로 캐시될 수 있었습니다. 제보 후 수 시간 안에 1차 수정(캐시 서버에서 정규식으로 해당 엔드포인트 제외)이 들어갔고, 이후 그 정규식을 피하는 우회가 발견돼 추가 수정이 있었습니다. Security Affairs와 SecurityWeek 보도 기준입니다(세부 타임라인은 [추정]). 1차 수정이 우회됐다는 점이 중요합니다. "이 엔드포인트만 캐시에서 빼자"는 개별 예외 규칙은 경로 해석이 하나라도 다르면 다시 빠져나갑니다.
  • PayPal 사례 (2017년 공개). 웹 캐시 속임이라는 이름이 처음 알려진 사례입니다. Black Hat USA 2017 백서로 공개됐습니다.
  • PortSwigger "Gotta Cache 'em all" (2024-08, Black Hat USA·DEF CON 32). 캐시와 오리진의 경로 구분자·정규화 차이를 체계적으로 정리한 연구입니다. 확장자 규칙이 없는 캐시도 경로 해석 차이로 같은 문제를 겪을 수 있음을 보여 줬습니다.

서버에서 막기

① 코드: 인증 응답은 private, no-store를 기본값으로

가장 먼저, 그리고 가장 확실하게 할 일입니다. 로그인 사용자에게 나가는 응답에는 모두 Cache-Control: private, no-store를 붙입니다. 엔드포인트마다 붙이면 반드시 빠지는 곳이 생기므로 필터나 미들웨어에서 일괄로 붙입니다. 공개 캐시해도 되는 응답만 예외로 public을 명시하게 합니다. 기본값을 "저장 금지"로 두고, 저장 허용을 예외로 만드는 순서가 중요합니다.

// 방어 예시: 인증 사용자 응답에 캐시 금지 헤더 일괄 적용 (Spring)
@Component
public class NoStoreForAuthenticatedFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        if (req.getUserPrincipal() != null) {
            res.setHeader("Cache-Control", "private, no-store");
        }
        chain.doFilter(req, res);
    }
}

Spring Security는 기본으로 no-cache, no-store 계열 캐시 헤더를 응답에 넣습니다. 그런데 커스텀 필터 체인을 구성하면서 헤더 설정을 통째로 끈 프로젝트가 꽤 많습니다. 아래처럼 기본 캐시 헤더가 켜져 있는지 확인합니다.

// 방어 예시: Spring Security 기본 캐시 금지 헤더를 끄지 않는다
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.headers(headers -> headers.cacheControl(Customizer.withDefaults()));
    // headers.disable() 같은 설정이 다른 곳에 있다면 이유를 확인한다
    return http.build();
}

FastAPI라면 미들웨어 하나로 같은 일을 합니다. 오류 응답도 함께 저장 금지로 둡니다(2장의 캐시 오염 방어와 이어집니다).

# 방어 예시: 인증 요청과 오류 응답은 저장 금지 (쿠키 이름은 가짜 값)
@app.middleware("http")
async def cache_policy(request, call_next):
    response = await call_next(request)
    authenticated = "session_id" in request.cookies or "authorization" in request.headers
    if authenticated or response.status_code >= 400:
        response.headers["Cache-Control"] = "private, no-store"
    elif "cache-control" not in response.headers:
        response.headers["Cache-Control"] = "no-store"   # 명시하지 않은 응답도 기본은 저장 금지
    return response

② 코드: 라우터를 엄격하게. 없는 경로는 404

계정 경로 뒤에 무엇이 붙든 같은 핸들러가 응답하는 구조가 캐시 속임의 절반입니다. 존재하지 않는 경로 접미사(예: 계정 경로 뒤의 아무거나.css)는 계정 핸들러로 보내지 말고 404로 돌려보냅니다. Spring이 5.3에서 확장자 접미사 패턴 매칭을, 6.0에서 끝 슬래시 매칭을 기본으로 끈 것도 같은 맥락입니다. 오래된 프로젝트에서 이 옵션을 다시 켜 두었다면 이유를 확인합니다.

이 동작은 테스트로 고정해 둡니다. 프레임워크를 업그레이드하거나 라우팅 설정을 바꿀 때 바로 알 수 있습니다.

# 방어 확인 테스트: 계정 경로에 접미사가 붙으면 404, 계정 응답은 no-store (경로는 가짜 값)
def test_account_suffix_is_not_routed(client, logged_in_cookies):
    r = client.get("/account/profile/anything.css", cookies=logged_in_cookies)
    assert r.status_code == 404

def test_account_response_is_not_cacheable(client, logged_in_cookies):
    r = client.get("/account/profile", cookies=logged_in_cookies)
    cc = r.headers["cache-control"]
    assert "no-store" in cc and "public" not in cc

③ 설정: CDN 캐시 규칙은 확장자 대신 오리진 헤더를 기준으로

CDN 콘솔의 "확장자별 캐시" 규칙 하나가 마이페이지 전체를 공개 캐시로 만들 수 있습니다. 캐시 규칙을 "오리진이 Cache-Control: public을 준 응답만 저장"으로 바꿉니다. 대부분의 CDN에는 "오리진 캐시 헤더를 따른다"는 모드가 있습니다. 확장자 규칙이 꼭 필요하다면 정적 자원 전용 경로 프리픽스에만 겁니다.

④ 아키텍처: 정적 자원과 동적 응답을 주소부터 나눈다

  • 정적 자원은 별도 도메인(static.example.com)이나 별도 경로 프리픽스(/assets/)로만 서빙하고, 캐시는 그 범위에만 켭니다.
  • API 도메인(api.example.com)은 CDN 캐시를 끕니다. API 응답은 대부분 사용자별이고, 캐시가 필요한 공개 API는 따로 명시합니다.
  • 정적 자원 쪽에는 쿠키를 보내지 않는 도메인을 쓰면, 그 도메인으로 개인 응답이 만들어질 일 자체가 없어집니다.

리버스 프록시 캐시를 직접 운영한다면 nginx 기준으로 다음처럼 둡니다. nginx는 오리진이 private·no-store를 주거나 Set-Cookie가 있는 응답을 기본으로 저장하지 않으므로, 오리진 헤더가 제대로 붙어 있으면 여기서 한 번 더 걸러집니다.

# 방어 예시(가짜 값): 캐시는 정적 경로에만, 인증 요청은 캐시를 거치지 않게
proxy_cache_path /var/cache/nginx keys_zone=edge:50m max_size=1g inactive=10m;

server {
    server_name www.example.com;

    location /assets/ {
        proxy_pass        http://app_backend;
        proxy_cache       edge;
        proxy_cache_key   "$scheme$host$request_uri";
        proxy_cache_valid 200 10m;                         # 오리진 헤더가 없을 때 200만 10분
        proxy_no_cache     $http_authorization $cookie_session_id;
        proxy_cache_bypass $http_authorization $cookie_session_id;
        add_header X-Cache-Status $upstream_cache_status always;
    }

    location / {
        proxy_pass http://app_backend;                     # 동적 경로는 캐시하지 않는다
    }
}

X-Cache-Status 헤더를 붙여 두면 HIT·MISS·BYPASS를 응답에서 바로 확인할 수 있어 점검과 탐지에 씁니다.

탐지 신호

  • CDN 로그에서 로그인 쿠키가 있는 요청의 캐시 HIT. 로그인한 사용자의 요청이 캐시에서 응답됐다면, 그 응답이 다른 사람의 것일 수 있습니다.
  • 정적 확장자 URL인데 응답 Content-Type이 application/json이나 text/html. 정적 파일이라면 나올 수 없는 조합입니다.
  • 고객 문의 "내 화면에 다른 사람 이름이 보인다".
# 탐지 예시: CDN 로그(JSON 줄)에서 캐시 속임 의심 신호를 찾는다 (필드 이름은 가짜 예시)
import json, sys

STATIC_EXT = (".css", ".js", ".png", ".jpg", ".svg", ".woff2", ".ico")
DYNAMIC_TYPES = ("application/json", "text/html")

for line in sys.stdin:
    e = json.loads(line)
    hit = e.get("cache_status") == "HIT"
    if hit and e.get("has_session_cookie"):
        print("[auth-hit]", e["time"], e["path"])
    if e["path"].lower().endswith(STATIC_EXT) and e.get("content_type", "").startswith(DYNAMIC_TYPES):
        print("[static-path-dynamic-body]", e["time"], e["path"], e.get("cache_status"))

2. 캐시 오염: 나쁜 응답 하나가 모두에게 간다

캐시 오염 원리. 캐시 키는 호스트와 경로와 일부 쿼리인데, 키 밖의 요청 헤더로 오리진 응답이 바뀌면 그 응답이 평범한 키로 저장돼 모든 사용자에게 간다. 오류 응답이 저장되면 CPDoS가 된다

원리

캐시 오염은 "키가 같으면 응답도 같다"는 가정을 반대 방향에서 깨뜨립니다.

오리진이 응답을 만들 때 쓰는 입력 중에는 캐시 키에 들어가지 않는 것(unkeyed input)이 있습니다. 대표적인 것이 일부 요청 헤더입니다. 예를 들어 리버스 프록시 뒤에서 동작하는 앱은 "원래 요청한 호스트"를 알려 주는 전달 헤더를 읽어 절대 URL(스크립트 주소, 리다이렉트 주소, 링크)을 만들곤 합니다. 이 헤더는 캐시 키에 없습니다.

그러면 이런 일이 가능해집니다. 공격자가 키 밖의 헤더 값을 바꿔 요청하면, 오리진은 그 값을 반영한 응답(다른 호스트를 가리키는 스크립트 주소 등)을 만듭니다. 캐시는 이 응답을 평범한 키(호스트 + 경로)로 저장합니다. 그 뒤로 같은 페이지를 여는 모든 사용자가 공격자가 고른 값이 들어간 응답을 받습니다. 캐시 속임은 피해자 한 명의 데이터가 새는 문제였다면, 캐시 오염은 한 번의 요청으로 다수에게 영향을 줍니다.

CPDoS(Cache-Poisoned Denial of Service)는 캐시 오염의 한 갈래입니다. 응답 내용을 바꾸는 대신 오리진이 오류를 내게 만듭니다. 캐시와 오리진이 헤더 크기 제한이나 특정 헤더 처리 방식에서 다르게 동작하면, 캐시는 그 요청을 정상으로 넘기고 오리진은 400 같은 오류를 돌려줍니다. 그 오류 응답이 캐시되면, 그 주소를 여는 모든 사용자가 정상 페이지 대신 오류 페이지를 받습니다. 서버는 멀쩡한데 서비스는 멈춘 상태가 됩니다.

왜 OWASP Top 10에 이름이 없나

캐시 오염은 A02 보안 설정 오류에 가장 가깝습니다. 하지만 원인은 캐시와 오리진 사이의 의미 불일치입니다. 오리진만 보면 "요청 헤더를 읽어 응답을 만든" 평범한 코드이고, 캐시만 보면 "키 규칙대로 저장한" 평범한 동작입니다. CPDoS는 가용성 문제이기도 한데, Top 10은 2004판의 "Denial of Service" 이후 가용성을 독립 항목으로 두지 않습니다.

실제 사례

  • CPDoS (2019, ACM CCS 2019). 쾰른 응용과학대와 함부르크대 연구진이 발표했습니다. 15개 캐시 솔루션을 조사해 CDN 5곳과 프록시 캐시 1종이 취약하다는 것을 확인했고, 그중 CloudFront가 가장 많은 변형에 취약했습니다. AWS는 이후 400 오류 응답을 기본으로 캐시하지 않도록 바꿨습니다. 오류 응답 캐시가 기본값이던 시절의 설정이 그대로 남은 곳이 있다면 지금도 같은 위험이 있습니다.
  • 웹 캐시 오염으로 대규모 바운티가 지급된 사례(대형 코드 호스팅 서비스 등 70건)도 알려져 있지만, 날짜를 확인하지 못해 이 글에서는 다루지 않습니다.

서버에서 막기

① 코드: 응답을 바꾸는 요청 헤더는 신뢰하지 않거나 Vary에 적는다

가장 좋은 방법은 키 밖의 입력으로 응답을 바꾸지 않는 것입니다. 절대 URL을 만들 때 요청 헤더의 호스트를 쓰지 말고, 설정에 고정한 공개 주소를 씁니다.

# 방어 예시: 절대 URL은 설정값으로 만든다. 요청의 전달 헤더로 호스트를 정하지 않는다
PUBLIC_BASE_URL = "https://www.example.com"   # 환경별 설정 파일에서 읽는 값

def absolute_url(path: str) -> str:
    return PUBLIC_BASE_URL + path

def script_tag(name: str) -> str:
    return f'<script src="{absolute_url("/assets/" + name)}"></script>'

Spring Boot라면 전달 헤더를 해석하는 옵션(server.forward-headers-strategy)은 우리가 관리하는 프록시 뒤에서만 켜고, 그 프록시가 외부에서 들어온 같은 이름의 헤더를 지우고 자기 값으로 덮어쓰게 합니다. 이 헤더는 비밀번호 재설정 링크를 만들 때도 문제가 되는데, 07편에서 다시 다룹니다.

정말로 요청 헤더에 따라 응답이 달라져야 한다면(예: 언어별 페이지), 그 헤더를 Vary에 적어 캐시 키에 들어가게 합니다. 응답은 바뀌는데 Vary에 없는 헤더가 하나라도 있다면 오염 후보입니다.

// 방어 예시: 언어에 따라 응답이 달라지면 Vary로 캐시 키에 넣는다 (Spring)
@GetMapping("/notice")
public ResponseEntity<String> notice(@RequestHeader(value = "Accept-Language", defaultValue = "ko") String lang) {
    return ResponseEntity.ok()
            .header(HttpHeaders.VARY, "Accept-Language")
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
            .body(noticeService.render(lang));
}

② 설정: 오류 응답은 저장하지 않거나 아주 짧게

CPDoS의 방어는 오류 응답 캐시를 끄는 것입니다. 오리진은 4xx·5xx 응답에 no-store를 붙이고(1장의 FastAPI 미들웨어가 이미 그렇게 합니다), CDN과 프록시 캐시는 오류 응답 저장을 끄거나 TTL을 몇 초 단위로 짧게 둡니다. 위의 nginx 예시에서 proxy_cache_valid 200 10m처럼 저장할 상태 코드를 200으로 한정한 것도 같은 목적입니다.

③ 설정: 캐시와 오리진의 헤더 처리 기준을 맞춘다

CPDoS는 캐시와 오리진이 헤더 크기 한도나 특정 헤더 처리 방식에서 다르게 동작할 때 생깁니다. 캐시(앞단)의 요청 헤더 크기 한도를 오리진보다 작거나 같게 두면, 오리진이 거부할 요청은 캐시에서 먼저 거부되고 오류가 캐시에 저장될 기회가 줄어듭니다.

④ 아키텍처: 캐시하는 범위를 좁힌다

1장의 원칙이 그대로 적용됩니다. 정적 자원만 별도 경로·도메인에서 캐시하고, 동적 페이지와 API는 캐시하지 않습니다. 캐시하는 응답이 적을수록 오염될 수 있는 응답도 적습니다.

탐지 신호

  • 같은 캐시 키에 대한 오류 응답 HIT 급증. 정상이라면 오류는 캐시에서 나오지 않아야 합니다.
  • 캐시된 응답 안의 절대 URL 호스트가 우리 도메인 목록에 없다. 정적 페이지 HTML을 주기적으로 받아 스크립트·링크 호스트를 허용 목록과 대조하면 오염된 응답을 찾을 수 있습니다.
  • 특정 페이지에서만 오류 신고가 몰리는데 오리진 로그에는 그 시각 요청이 거의 없다. 캐시가 오류를 대신 돌려주고 있다는 뜻입니다.

방어 전후 한 장으로

캐시 결정권 방어 전후 비교. 방어 전에는 CDN 확장자 규칙, 느슨한 라우터, 헤더 없는 오리진, 오류 응답 저장. 방어 후에는 오리진이 public을 줄 때만 저장, 정확한 라우팅과 404, private·no-store 기본값, 오류 응답 저장 금지

두 기법의 방어는 한 문장으로 모입니다. 저장할지는 응답을 만든 오리진이 정하고, 캐시는 그 결정을 따른다. 오리진은 기본값을 "저장 금지"로 두고 공개해도 되는 응답에만 public을 붙입니다. 라우터는 모르는 경로를 아는 핸들러로 보내지 않습니다. 캐시는 URL 모양으로 추측하지 않고, 오류 응답은 저장하지 않습니다.

설계 원칙으로 정리

  1. 캐시 결정권은 오리진에 둔다. 응답 내용을 아는 쪽이 저장 여부를 정해야 합니다. CDN의 확장자 규칙은 응답 내용을 모르는 쪽의 추측입니다.
  2. 기본값은 저장 금지, 허용은 명시. 인증 응답, 오류 응답, 헤더가 없는 응답은 모두 저장하지 않는 쪽으로 기본값을 둡니다. 공개 캐시는 public을 명시한 응답에만 허용합니다.
  3. 모르는 경로는 404. 라우터가 관대할수록 캐시와 오리진이 같은 URL을 다르게 읽을 여지가 커집니다.
  4. 응답을 바꾸는 입력은 모두 캐시 키에 있어야 한다. 키에 없는 입력으로 응답을 바꾸지 말고, 꼭 필요하면 Vary로 키에 넣습니다.
  5. 정적과 동적을 주소부터 나눈다. 캐시하는 도메인·경로와 개인 응답이 나오는 도메인·경로가 겹치지 않게 설계합니다.

점검 체크리스트

  • 로그인 사용자 응답 전체에 Cache-Control: private, no-store가 필터·미들웨어에서 일괄로 붙는다
  • Spring Security 기본 캐시 헤더를 끄는 설정(headers().disable() 등)이 없다
  • 계정·주문 등 개인 경로 뒤에 존재하지 않는 접미사가 붙으면 404이고, 이를 테스트로 고정했다
  • CDN 캐시 규칙이 확장자 대신 "오리진 Cache-Control 준수"를 기준으로 한다
  • 정적 자원은 별도 도메인이나 /assets/ 같은 프리픽스에서만 캐시한다
  • API 도메인은 CDN 캐시가 꺼져 있다
  • 4xx·5xx 응답은 캐시에 저장되지 않거나 TTL이 몇 초 이하다
  • 절대 URL은 설정값으로 만들고, 전달 호스트 헤더는 우리 프록시가 덮어쓴 값만 쓴다
  • 요청 헤더로 응답이 달라지는 엔드포인트는 그 헤더를 Vary에 적는다
  • CDN 로그에서 "로그인 쿠키 + 캐시 HIT", "정적 확장자 + JSON·HTML 응답"을 주기적으로 찾는다

정리

  • 캐시 속임은 피해자의 개인 응답이 공개 캐시에 저장되는 문제이고, 캐시 오염은 공격자가 만든 응답이 다수에게 퍼지는 문제입니다. 둘 다 캐시와 오리진이 같은 요청을 다르게 판단해서 생깁니다.
  • ChatGPT 사례에서 개별 엔드포인트를 캐시에서 빼는 1차 수정은 우회됐습니다. 예외 규칙을 늘리는 방식은 경로 해석 차이 앞에서 오래가지 못합니다.
  • 방어의 기준은 CDN의 URL 모양 판단이 아니라 오리진이 붙인 Cache-Control입니다. 인증 응답과 오류 응답은 저장 금지가 기본값입니다.
  • 엄격한 라우팅, 정적·동적 주소 분리, 응답을 바꾸는 입력의 Vary 명시로 해석 차이가 생길 자리를 줄입니다.

더 깊게

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 11-4

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 1-7, 3-2, 4-2, 6-2

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. ChatGPT 사례의 정확한 수정 시각과 우회 시점은 연구자 원문을 직접 확인하지 못해 보도 기준으로 적고 [추정]으로 표시했습니다. PortSwigger 연구 페이지 주소는 PortSwigger 연구 페이지 명명 규칙에 따른 것으로 [추정]입니다.


이전 글 두 번 해석되는 요청: 파서 차이, 요청 밀반입, SMTP 밀반입을 서버에서 막는 법
다음 글 경계 밖의 이름이 문을 연다: DNS 리바인딩과 서브도메인 탈취
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글