
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 모양 판단으로 넘어갑니다. 캐시 속임은 정확히 이 지점을 이용합니다.

캐시 속임은 두 프로그램의 판단이 동시에 어긋날 때 생깁니다.
.css로 끝나니 정적 파일이다. 저장한다." 캐시는 응답 내용을 열어 보지 않고 URL 모양만 봅니다.두 판단이 겹치면 이런 일이 생깁니다. 공격자는 피해자에게 "계정 경로 뒤에 정적 확장자가 붙은" 링크를 보내고, 로그인한 피해자가 그 링크를 엽니다. 오리진은 피해자의 쿠키를 보고 피해자의 계정 페이지를 만들어 돌려줍니다. 캐시는 확장자를 보고 이 응답을 공유 캐시에 저장합니다. 캐시 키에는 사용자 구분이 없으므로, 이제 그 주소를 요청하는 사람은 누구든 로그인 없이 피해자의 계정 페이지를 받습니다. ChatGPT 사례처럼 그 페이지에 액세스 토큰이 들어 있다면 계정 탈취로 이어집니다.
확장자만 문제가 되는 것도 아닙니다. 2024년 PortSwigger 연구는 캐시와 오리진이 경로 구분자와 정규화를 다르게 처리하는 경우를 체계적으로 정리했습니다. 어떤 문자를 경로 구분자로 보는지, 인코딩된 문자를 언제 푸는지, 점 두 개(..) 같은 상대 경로를 누가 먼저 정리하는지가 캐시와 오리진 사이에서 다르면, 캐시는 "정적 경로"로, 오리진은 "계정 경로"로 같은 URL을 읽습니다. 02편의 URL 파서 차이와 같은 구조입니다.
결과는 다른 사람의 데이터가 노출되는 것이므로 A01 접근 통제 실패에 해당합니다. 그런데 앱의 접근 통제 코드는 올바르게 동작했습니다. 오리진은 피해자의 쿠키를 확인하고 피해자에게 피해자의 데이터를 줬습니다. 캐시도 설정된 규칙대로 확장자를 보고 저장했습니다. 두 구성 요소가 각자 맞게 동작했는데, 둘 사이의 의미가 어긋나서 데이터가 샜습니다. 앱 하나의 결함을 기준으로 분류하는 Top 10에서는 이 원인을 가리킬 이름이 없습니다.

① 코드: 인증 응답은 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.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를 응답에서 바로 확인할 수 있어 점검과 탐지에 씁니다.
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"))

캐시 오염은 "키가 같으면 응답도 같다"는 가정을 반대 방향에서 깨뜨립니다.
오리진이 응답을 만들 때 쓰는 입력 중에는 캐시 키에 들어가지 않는 것(unkeyed input)이 있습니다. 대표적인 것이 일부 요청 헤더입니다. 예를 들어 리버스 프록시 뒤에서 동작하는 앱은 "원래 요청한 호스트"를 알려 주는 전달 헤더를 읽어 절대 URL(스크립트 주소, 리다이렉트 주소, 링크)을 만들곤 합니다. 이 헤더는 캐시 키에 없습니다.
그러면 이런 일이 가능해집니다. 공격자가 키 밖의 헤더 값을 바꿔 요청하면, 오리진은 그 값을 반영한 응답(다른 호스트를 가리키는 스크립트 주소 등)을 만듭니다. 캐시는 이 응답을 평범한 키(호스트 + 경로)로 저장합니다. 그 뒤로 같은 페이지를 여는 모든 사용자가 공격자가 고른 값이 들어간 응답을 받습니다. 캐시 속임은 피해자 한 명의 데이터가 새는 문제였다면, 캐시 오염은 한 번의 요청으로 다수에게 영향을 줍니다.
CPDoS(Cache-Poisoned Denial of Service)는 캐시 오염의 한 갈래입니다. 응답 내용을 바꾸는 대신 오리진이 오류를 내게 만듭니다. 캐시와 오리진이 헤더 크기 제한이나 특정 헤더 처리 방식에서 다르게 동작하면, 캐시는 그 요청을 정상으로 넘기고 오리진은 400 같은 오류를 돌려줍니다. 그 오류 응답이 캐시되면, 그 주소를 여는 모든 사용자가 정상 페이지 대신 오류 페이지를 받습니다. 서버는 멀쩡한데 서비스는 멈춘 상태가 됩니다.
캐시 오염은 A02 보안 설정 오류에 가장 가깝습니다. 하지만 원인은 캐시와 오리진 사이의 의미 불일치입니다. 오리진만 보면 "요청 헤더를 읽어 응답을 만든" 평범한 코드이고, 캐시만 보면 "키 규칙대로 저장한" 평범한 동작입니다. CPDoS는 가용성 문제이기도 한데, Top 10은 2004판의 "Denial of Service" 이후 가용성을 독립 항목으로 두지 않습니다.
① 코드: 응답을 바꾸는 요청 헤더는 신뢰하지 않거나 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는 캐시하지 않습니다. 캐시하는 응답이 적을수록 오염될 수 있는 응답도 적습니다.

두 기법의 방어는 한 문장으로 모입니다. 저장할지는 응답을 만든 오리진이 정하고, 캐시는 그 결정을 따른다. 오리진은 기본값을 "저장 금지"로 두고 공개해도 되는 응답에만 public을 붙입니다. 라우터는 모르는 경로를 아는 핸들러로 보내지 않습니다. 캐시는 URL 모양으로 추측하지 않고, 오류 응답은 저장하지 않습니다.
public을 명시한 응답에만 허용합니다.Vary로 키에 넣습니다.Cache-Control: private, no-store가 필터·미들웨어에서 일괄로 붙는다headers().disable() 등)이 없다Cache-Control 준수"를 기준으로 한다/assets/ 같은 프리픽스에서만 캐시한다Vary에 적는다Cache-Control입니다. 인증 응답과 오류 응답은 저장 금지가 기본값입니다.Vary 명시로 해석 차이가 생길 자리를 줄입니다.오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. ChatGPT 사례의 정확한 수정 시각과 우회 시점은 연구자 원문을 직접 확인하지 못해 보도 기준으로 적고 [추정]으로 표시했습니다. PortSwigger 연구 페이지 주소는 PortSwigger 연구 페이지 명명 규칙에 따른 것으로 [추정]입니다.
이전 글 두 번 해석되는 요청: 파서 차이, 요청 밀반입, SMTP 밀반입을 서버에서 막는 법
다음 글 경계 밖의 이름이 문을 연다: DNS 리바인딩과 서브도메인 탈취
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록