Preface

I played the HTB Cyber Apocalypse 2024 CTF part of Friendly Maltese Citizens, where we succeeded in fully clearing all of the 67 challenges in just under 15 hours. The Percetron web challenge ended up being the least solved challenge during the CTF, and so I decided to do a write-up for it as our approach also was a lot different compared to the intended one.

Overview

Your faction's mission is to infiltrate and breach the heavily fortified servers of KORP's web application, known as "Percetron." This application stores sensitive information, including IP addresses and digital certificates, crucial for the city's infrastructure.

As the challenge description mentions, the app is meant for storing digital certificates that then can be queried:

Pasted image 20240313193129.png

overview of the application

Behind the scenes, the app uses Neo4j as a graph database for the certificates and MongoDB for storing the accounts that can log in. We can register as a normal user and search various certificates imported into the system, but otherwise, we don't have any other functionality we can use as a normal user.

We can notice there are a couple of additional endpoints (/healthcheck and /healthcheck-dev) defined: challenge/routes/panel.js

router.get("/healthcheck", authMiddleware, (req, res) => {
  const targetUrl = req.query.url;

  if (!targetUrl) {
    return res.status(400).json({ message: "Mandatory URL not specified" });
  }

  if (!check(targetUrl)) {
    return res.status(403).json({ message: "Access to URL is denied" });
  }

  axios.get(targetUrl, { maxRedirects: 0, validateStatus: () => true, timeout: 40000 })
    .then(resp => {
      res.status(resp.status).send();
    })
    .catch(() => {
      res.status(500).send();
    });
});

router.get("/healthcheck-dev", authMiddleware, async (req, res) => {
  let targetUrl = req.query.url;

  if (!targetUrl) {
    return res.status(400).json({ message: "Mandatory URL not specified" });
  }

  getUrlStatusCode(targetUrl)
    .then(statusCode => {
      res.status(statusCode).send();
    })
    .catch(() => {
      res.status(500).send();
    });
});

The endpoints simply request our provided URL and return the status code the server received, so we have a blind Server-Side Request Forgery (SSRF).

However, there is one additional limitation to the /healthcheck-dev endpoint, specifically that HAProxy is running in front of the application with the following restriction: conf/haproxy.conf

backend forward_default
    http-request deny if { path -i -m beg /healthcheck-dev }
    server s1 127.0.0.1:3000

We can make arbitrary requests, with the exception that if the request path beg(ins) with /healthcheck-dev, then the request is blocked. So we're only able to use the /healthcheck endpoint.

Some additional code analysis will reveal to us that there's a function vulnerable to Cypher (language Neo4j uses for queries, "similar" to SQL) injection: challenge/util/neo4j.js

  async addCertificate(cert) {
    const certPath = path.join(this.certDir, randomHex(10) + ".cert");
    const certInfo = parseCert(cert.cert);

    if (!certInfo) {
      return false;
    }

    const insertCertQuery = `
      CREATE (:Certificate {
          common_name: '${certInfo.issuer.commonName}',
          file_name: '${certPath}',
          org_name: '${certInfo.issuer.organizationName}',
          locality_name: '${certInfo.issuer.localityName}',
          state_name: '${certInfo.issuer.stateOrProvinceName}',
          country_name: '${certInfo.issuer.countryName}'
      });
    `;

    try {
      await this.runQuery(insertCertQuery);
      fs.writeFileSync(certPath, cert.cert);
      return true;
    } catch (error) {
      return false;
    }
  }

Though we're unable to directly access the function due to it requiring an admin account: challenge/routes/panel.js

router.post("/panel/management/addcert", adminMiddleware, async (req, res) => {
    const pem = req.body.pem;
    const pubKey = req.body.pubKey;
    const privKey = req.body.privKey;

    if (!(pem && pubKey && privKey)) return res.render("error", {message: "Missing parameters"});

    const db = new Neo4jConnection();
    const certCreated = await db.addCertificate({"cert": pem, "pubKey": pubKey, "privKey": privKey});

    if (!certCreated) {
        return res.render("error", {message: "Could not add certificate"});
    }

    res.redirect("/panel/management");
});

The flag is stored with a randomized filename, so we know that the eventual goal should be to gain remote code execution: entrypoint.sh