SpyBug
Overview
- Overall difficulty for me (From 1-10 stars): ★★★★★★★★★☆
Background
As Pandora made her way through the ancient tombs, she received a message from her contact in the Intergalactic Ministry of Spies. They had intercepted a communication from a rival treasure hunter who was working for the alien species. The message contained information about a digital portal that leads to a software used for intercepting audio from the Ministry’s communication channels. Can you hack into the portal and take down the aliens counter-spying operation?
Enumeration
Home page:
In here, we see there’s a login page.
In this challenge, we can download a file:
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug)-[2023.03.21|14:00:33(HKT)]
└> file web_spybug.zip
web_spybug.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug)-[2023.03.21|14:00:34(HKT)]
└> unzip web_spybug.zip
Archive: web_spybug.zip
creating: web_spybug/
inflating: web_spybug/Dockerfile
inflating: web_spybug/build-docker.sh
creating: web_spybug/challenge/
creating: web_spybug/challenge/control-panel/
inflating: web_spybug/challenge/control-panel/example.env
creating: web_spybug/challenge/control-panel/middleware/
inflating: web_spybug/challenge/control-panel/middleware/authagent.js
inflating: web_spybug/challenge/control-panel/middleware/authuser.js
inflating: web_spybug/challenge/control-panel/index.js
creating: web_spybug/challenge/control-panel/utils/
inflating: web_spybug/challenge/control-panel/utils/database.js
inflating: web_spybug/challenge/control-panel/utils/adminbot.js
creating: web_spybug/challenge/control-panel/models/
inflating: web_spybug/challenge/control-panel/models/user.js
inflating: web_spybug/challenge/control-panel/models/recordings.js
inflating: web_spybug/challenge/control-panel/models/index.js
inflating: web_spybug/challenge/control-panel/models/agent.js
inflating: web_spybug/challenge/control-panel/package-lock.json
inflating: web_spybug/challenge/control-panel/package.json
creating: web_spybug/challenge/control-panel/static/
creating: web_spybug/challenge/control-panel/static/css/
inflating: web_spybug/challenge/control-panel/static/css/bootstrap.min.css
inflating: web_spybug/challenge/control-panel/static/css/custom.css
inflating: web_spybug/challenge/control-panel/static/css/bootstrap.min.css.map
inflating: web_spybug/challenge/control-panel/static/css/line-awesome.min.css
creating: web_spybug/challenge/control-panel/static/js/
inflating: web_spybug/challenge/control-panel/static/js/bootstrap.min.js
creating: web_spybug/challenge/control-panel/static/img/
extracting: web_spybug/challenge/control-panel/static/img/icon.png
creating: web_spybug/challenge/control-panel/static/fonts/
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.woff
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.eot
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.woff
inflating: web_spybug/challenge/control-panel/static/fonts/Orbitron-Regular.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.eot
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.woff
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/OFL.txt
extracting: web_spybug/challenge/control-panel/static/fonts/la-regular-400.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.eot
inflating: web_spybug/challenge/control-panel/static/fonts/Orbitron-VariableFont_wght.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.ttf
creating: web_spybug/challenge/control-panel/views/
inflating: web_spybug/challenge/control-panel/views/login.pug
inflating: web_spybug/challenge/control-panel/views/head.pug
inflating: web_spybug/challenge/control-panel/views/panel.pug
creating: web_spybug/challenge/control-panel/routes/
inflating: web_spybug/challenge/control-panel/routes/generic.js
inflating: web_spybug/challenge/control-panel/routes/agents.js
inflating: web_spybug/challenge/control-panel/routes/panel.js
creating: web_spybug/challenge/agent/
inflating: web_spybug/challenge/agent/spybug-agent.go
inflating: web_spybug/challenge/agent/go.mod
inflating: web_spybug/challenge/agent/go.sum
inflating: web_spybug/challenge/agent/rec.wav
inflating: web_spybug/challenge/.prettierignore
inflating: web_spybug/challenge/.gitignore
creating: web_spybug/conf/
inflating: web_spybug/conf/supervisord.conf
Enumeration
Home page:
In here, we see there’s a login page.
In this challenge, we can download a file:
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug)-[2023.03.19|22:25:44(HKT)]
└> file web_spybug.zip
web_spybug.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug)-[2023.03.19|22:25:46(HKT)]
└> unzip web_spybug.zip
Archive: web_spybug.zip
creating: web_spybug/
inflating: web_spybug/Dockerfile
inflating: web_spybug/build-docker.sh
creating: web_spybug/challenge/
creating: web_spybug/challenge/control-panel/
inflating: web_spybug/challenge/control-panel/example.env
creating: web_spybug/challenge/control-panel/middleware/
inflating: web_spybug/challenge/control-panel/middleware/authagent.js
inflating: web_spybug/challenge/control-panel/middleware/authuser.js
inflating: web_spybug/challenge/control-panel/index.js
creating: web_spybug/challenge/control-panel/utils/
inflating: web_spybug/challenge/control-panel/utils/database.js
inflating: web_spybug/challenge/control-panel/utils/adminbot.js
creating: web_spybug/challenge/control-panel/models/
inflating: web_spybug/challenge/control-panel/models/user.js
inflating: web_spybug/challenge/control-panel/models/recordings.js
inflating: web_spybug/challenge/control-panel/models/index.js
inflating: web_spybug/challenge/control-panel/models/agent.js
inflating: web_spybug/challenge/control-panel/package-lock.json
inflating: web_spybug/challenge/control-panel/package.json
creating: web_spybug/challenge/control-panel/static/
creating: web_spybug/challenge/control-panel/static/css/
inflating: web_spybug/challenge/control-panel/static/css/bootstrap.min.css
inflating: web_spybug/challenge/control-panel/static/css/custom.css
inflating: web_spybug/challenge/control-panel/static/css/bootstrap.min.css.map
inflating: web_spybug/challenge/control-panel/static/css/line-awesome.min.css
creating: web_spybug/challenge/control-panel/static/js/
inflating: web_spybug/challenge/control-panel/static/js/bootstrap.min.js
creating: web_spybug/challenge/control-panel/static/img/
extracting: web_spybug/challenge/control-panel/static/img/icon.png
creating: web_spybug/challenge/control-panel/static/fonts/
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.woff
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.eot
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.woff
inflating: web_spybug/challenge/control-panel/static/fonts/Orbitron-Regular.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.eot
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.woff
inflating: web_spybug/challenge/control-panel/static/fonts/la-brands-400.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/OFL.txt
extracting: web_spybug/challenge/control-panel/static/fonts/la-regular-400.woff2
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.eot
inflating: web_spybug/challenge/control-panel/static/fonts/Orbitron-VariableFont_wght.ttf
inflating: web_spybug/challenge/control-panel/static/fonts/la-solid-900.svg
inflating: web_spybug/challenge/control-panel/static/fonts/la-regular-400.ttf
creating: web_spybug/challenge/control-panel/views/
inflating: web_spybug/challenge/control-panel/views/login.pug
inflating: web_spybug/challenge/control-panel/views/head.pug
inflating: web_spybug/challenge/control-panel/views/panel.pug
creating: web_spybug/challenge/control-panel/routes/
inflating: web_spybug/challenge/control-panel/routes/generic.js
inflating: web_spybug/challenge/control-panel/routes/agents.js
inflating: web_spybug/challenge/control-panel/routes/panel.js
creating: web_spybug/challenge/agent/
inflating: web_spybug/challenge/agent/spybug-agent.go
inflating: web_spybug/challenge/agent/go.mod
inflating: web_spybug/challenge/agent/go.sum
inflating: web_spybug/challenge/agent/rec.wav
inflating: web_spybug/challenge/.prettierignore
inflating: web_spybug/challenge/.gitignore
creating: web_spybug/conf/
inflating: web_spybug/conf/supervisord.conf
In /views/*.pug
, we see that this web application is using a JavaScript template engine called “Pug”.
In /views/panel.pug
, the username
variable is directly concatenated:
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}
[...]
Maybe it’s vulnerable to SSTI (Server-Side Template Injection)?
Let’s move on.
In /utils/adminbot.js
, we can see that there’s an admin bot running a Chromium browser via puppeteer
.
Config:
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",
],
};
This adminbot.js
has an async function called visitPanel
:
exports.visitPanel = async () => {
try {
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);
}
};
It’ll launch an incognito browser, then go to http://0.0.0.0:<API_PORT>
.
After that, the bot will type it’s username admin
, password, and click “Login” button.
Finally, the browser will be closed after 5 seconds.
Armed with above information, this challenge is about client-side, and maybe we need to somehow steal admin’s password?
In /routes/panel.js
, we see routes (endpoints) in this web application.
First off, the /panel
route:
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(),
});
});
When we send a GET request to /panel
and we’re logged in, it checks the session’s username is admin
or not.
If we’re admin
, it’ll parse the flag and username to template panel.pug
, and renders it.
With that said, our goal in this challenge is to login as admin
.
Route /panel/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");
});
Function checkUserLogin
in /utils/database.js
:
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;
};
When we send a POST request to /panel/login
with parameter username
and password
, it’ll check those are valid or not.
If it’s valid, create a new session for our user.
Hmm… It seems like we can’t do NoSQL injection to bypass the authentication in here…
In /routes/agents.js
, we see there are 4 routes.
Route /agents/register
:
router.get("/agents/register", async (req, res) => {
res.status(200).json(await registerAgent());
});
Function registerAgent()
in /utils/database.js
:
exports.registerAgent = async () => {
const agentId = uuidv4();
const agentToken = uuidv4();
const options = {
identifier: agentId,
token: agentToken,
};
await db.Agent.create(options);
return options;
};
When we send a GET request to /agents/register
, it’ll call function registerAgent()
from /utils/database.js
to create a new agent, and response us a JSON data.
Route /agents/check/:identifier/:token
:
router.get("/agents/check/:identifier/:token", authAgent, (req, res) => {
res.sendStatus(200);
});
It first checks we’re an agent or not from /middleware/authagent.js
:
const { checkAgentLogin } = require("../utils/database");
module.exports = async (req, res, next) => {
const { identifier, token } = req.params;
if (!(identifier && token)) return res.sendStatus(400);
if (!(await checkAgentLogin(identifier, token))) return res.sendStatus(401);
next();
};
Function checkAgentLogin()
from /utils/database
:
exports.checkAgentLogin = async (agentId, agentToken) => {
const results = await db.Agent.findOne({
where: {
[Op.and]: [{ identifier: agentId }, { token: agentToken }],
},
});
if (!results) return false;
return true;
};
Route /agents/details/:identifier/:token
:
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);
}
);
Function updateAgentDetails()
from /utils/database
:
exports.updateAgentDetails = async (agentId, hostname, platform, arch) => {
await db.Agent.update(
{
hostname: hostname,
platform: platform,
arch: arch,
},
{
where: {
identifier: agentId,
},
}
);
};
When we send a POST request to /agents/details/:identifier/:token
with parameter hostname
, platform
, arch
, it’ll update our agent details.
Route /agents/upload/:identifier/:token
:
router.post(
"/agents/upload/:identifier/:token",
authAgent,
multerUpload.single("recording"),
async (req, res) => {
if (!req.file) return res.sendStatus(400);
const filepath = path.join("./uploads/", req.file.filename);
const buffer = fs.readFileSync(filepath).toString("hex");
if (!buffer.match(/52494646[a-z0-9]{8}57415645/g)) {
fs.unlinkSync(filepath);
return res.sendStatus(400);
}
await createRecording(req.params.identifier, req.file.filename);
res.send(req.file.filename);
}
);
multerUpload
:
const storage = multer.diskStorage({
filename: (req, file, cb) => {
cb(null, uuidv4());
},
destination: (req, file, cb) => {
cb(null, "./uploads");
},
});
const multerUpload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (
file.mimetype === "audio/wave" &&
path.extname(file.originalname) === ".wav"
) {
cb(null, true);
} else {
return cb(null, false);
}
},
});
Function createRecording()
in /utils/database.js
:
exports.createRecording = async (agentId, filepath) => {
await db.Recording.create({
agentId: agentId,
filepath: "/uploads/" + filepath,
});
};
When we send a POST request to /agents/upload/:identifier/:token
, it requires to upload a WAV audio file.
Then, it checks the file contains the WAV file signatures:
If the file contains it, create a new recording, and put it to /uploads/<filepath>
.
Hmm… What can we do with that…
After some local testing, I found that we can inject HTML code in the /panel
via updating agent’s details.
First, register a new agent:
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug/web_spybug/challenge/control-panel)-[2023.03.22|14:19:29(HKT)]
└> curl http://localhost:1337/agents/register
{"identifier":"7983500a-13af-4cab-a99c-7f93496fcf29","token":"23ccc082-9355-406c-a083-2746a9a583f9"}
Then, use those identifier
and token
to update the hostname
, platform
and arch
in /agents/details/:identifier/:token
:
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug/web_spybug/challenge/control-panel)-[2023.03.22|14:40:45(HKT)]
└> curl http://localhost:1337/agents/details/7983500a-13af-4cab-a99c-7f93496fcf29/23ccc082-9355-406c-a083-2746a9a583f9 --data "hostname=host&platform=plat&arch=<h1>Header1</h1>"
OK
After that, I added console.log()
in /utils/adminbot.js
, so that I can see the admin’s password:
[...]
DNUIK9jqISWeLSzh93IKaSFbishkYUYn
2023-03-22 06:43:27,763 INFO reaped unknown pid 964 (terminated by SIGKILL)
2023-03-22 06:43:27,763 INFO reaped unknown pid 947 (terminated by SIGKILL)
2023-03-22 06:43:27,763 INFO reaped unknown pid 948 (terminated by SIGKILL)
2023-03-22 06:43:27,763 INFO reaped unknown pid 926 (terminated by SIGKILL)
2023-03-22 06:43:27,764 INFO reaped unknown pid 927 (terminated by SIGKILL)
[...]
/panel
:
Hmm… I wonder if can we exploit stored XSS…
However, in /index.js
, we see the CSP (Content Security Policy):
res.setHeader("Content-Security-Policy", "script-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none';");
- The
script-src
directive isself
, which means we can execute JavaScript only if thesrc
is pointing to the domain itself. - The
frame-ancestors
directive isnone
, which means we can’t use<iframe>
element. - The
object-src
directive isnone
, which means we can’t use<object>
,<embed>
,<applet>
elements. - The
base-uri
directive isnone
, which means we can’t use<base>
element.
That being said, if we can upload our own evil JavaScript, we can execute any JavaScript code in /panel
, which we’ll then try to steal the flag/admin’s password.
So, the exploitation process is:
- Update our agent’s details, which has a
<script>
element, and it’ssrc
attribute is pointing to our evil JavaScript - Then, that JavaScript will exfilltrate the
/panel
’s content to a site that unders our control. - Finally, we should received user
admin
’s panel’s flag.
In route /agents/upload/:identifier/:token
, we can upload a WAV audio file:
const multerUpload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (
file.mimetype === "audio/wave" &&
path.extname(file.originalname) === ".wav"
) {
cb(null, true);
} else {
return cb(null, false);
}
},
});
[...]
router.post(
"/agents/upload/:identifier/:token",
authAgent,
multerUpload.single("recording"),
async (req, res) => {
if (!req.file) return res.sendStatus(400);
const filepath = path.join("./uploads/", req.file.filename);
console.log(filepath);
const buffer = fs.readFileSync(filepath).toString("hex");
if (!buffer.match(/52494646[a-z0-9]{8}57415645/g)) {
fs.unlinkSync(filepath);
return res.sendStatus(400);
}
await createRecording(req.params.identifier, req.file.filename);
res.send(req.file.filename);
}
);
The multerUpload.single("recording")
means the field is recording
.
Also, it checks MIME type is audio/wave
and the extension is .wav
.
Hmm… I wonder if we can upload a file to arbitrary path via path travsal…
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/SpyBug)-[2023.03.23|20:33:47(HKT)]
└> curl http://localhost:1337/agents/upload/7e7c1171-d6a9-4a51-a80f-eb71bc39870f/447ab5f5-d794-4c58-be62-ab7acd6b5513 -F "recording=@rec.wav" -H "Content-Type: audio/wave"
Bad Request
However, I couldn’t upload it…
Also, the challenge provided a Go lang source code spybug-agent.go
, I tried to compile and run it on my VM, but it outputs an error, and says couldn’t find my sound card…