HacktTM CTF: Draw With Us

I really enjoyed this challenge! It involved analyzing an expressjs API for vulnerabilities and then exploiting them to get the flag.

The file we were given was stripped.js. We were also quickly given a hint: “Changing your color is the first step towards happiness.”

With this information, I went to work trying to figure out how to access the /updateUser API endpoint. Here’s the code:

app.post("/updateUser", (req, res) => {
  // Update user color and rights
  // Only for admin
  // POST
  // {
  //   color: 0xDEDBEE,
  //   rights: ["height", "width", "usersOnline"]
  // }
  let uid = req.user.id;
  let user = users[uid];
  if (!user || !isAdmin(user)) {
    res.json(err("You're not an admin!"));
    return;
  }
  let color = parseInt(req.body.color);
  users[uid].color = (color || 0x0) & 0xffffff;
  let rights = req.body.rights || [];
  if (rights.length > 0 && checkRights(rights)) {
    users[uid].rights = user.rights.concat(rights).filter(onlyUnique);
  }

  res.json(ok({ user: users[uid] }));
});

OK! Looks like we need to defeat isAdmin(user) so let’s have a look at that!

function isAdmin(u) {
  return u.username.toLowerCase() == config.adminUsername.toLowerCase();
}

Two things jump out here. First is that there is an unsafe comparison happening with ==. Second is the username is being converted to lowercase which could also have some side effects.

Let’s have a look at how they’re verifying users at the /login endpoint… maybe we can just register as the hacktm admin!

app.post("/login", (req, res) => {
  // Login
  // POST
  // {
  //   username: "dumbo",
  // }

  let u = {
    username: req.body.username,
    id: uuidv4(),
    color: Math.random() < 0.5 ? 0xffffff : 0x0,
    rights: [
      "message",
      "height",
      "width",
      "version",
      "usersOnline",
      "adminUsername",
      "backgroundColor"
    ]
  };

  if (isValidUser(u)) {
    users[u.id] = u;
    res.send(ok({ token: sign({ id: u.id }) }));
  } else {
    res.json(err("Invalid creds"));
  }
});

Looks like there’s another function we have to defeat, isValidUser(u)

function isValidUser(u) {
  return (
    u.username.length >= 3 &&
    u.username.toUpperCase() !== config.adminUsername.toUpperCase()
  );
}

Here we have a safe comparison but it’s converting to uppercase instead of lowercase! So we need to find a string that doesn’t equal HACKTM when converted to uppercase but doe equal hacktm when converted to lowercase. A little research led me to this blog post which outlines how some weirdness was discovered with .toLowerCase() and .toLowerCase() converting some UTF characters to ascii. In particular, it looks like the kelvin symbol (\u212A) stays as the UTF kelvin character after .toUpperCase() but is converted to regular ascii ‘k’ after .toLowerCase(). That’s exactly what we need! We can now register an account with the following username: “hac\u212Atm” (hacKtm) and defeat both isAdmin(user) and isValidUser(u)!

Now that we can use the /updateUser endpoint, it looks like we need to add some more “rights” to our user. Since the checkRights(arr) looks like it’s trying to prevent us from adding “p”, “n”, and “port”, those are probably the ones we want to view!

function checkRights(arr) {
  let blacklist = ["p", "n", "port"];
  for (let i = 0; i < arr.length; i++) {
    const element = arr[i];
    if (blacklist.includes(element)) {
      return false;
    }
  }
  return true;
}

The vulnerability here comes from how arrays with a single string value are treated by javascript. The .includes() function does not convert [‘p’] to p, which defeats this check, but then later we can use the /serverInfo endpoint to view config which does end up converting [‘p’] to ‘p’!

app.get("/serverInfo", (req, res) => {
  // Get server info
  // Only for logged in users

  let user = users[req.user.id] || { rights: [] };
  let info = user.rights.map(i => ({ name: i, value: config[i] }));
  res.json(ok({ info: info }));
});

Specifically, config[['p']] is treated exactly the same as config['p']! Thanks javascript!

Using this method to defeat checkRights(arr) we can now get values for ‘p’ and ‘n’ from the config. Then if we have a look at the /init endpoint, we can make a safe bet that these correspond to ‘p’ and ‘n’ in the context of an RSA key.

app.post("/init", (req, res) => {
  // Initialize new round and sign admin token
  // RSA protected!
  // POST
  // {
  //   p:"0",
  //   q:"0"
  // }

  let { p = "0", q = "0", clearPIN } = req.body;

  let target = md5(config.n.toString());

  let pwHash = md5(
    bigInt(String(p))
      .multiply(String(q))
      .toString()
  );

  if (pwHash == target && clearPIN === _clearPIN) {
    // Clear the board
    board = new Array(config.height)
      .fill(0)
      .map(() => new Array(config.width).fill(config.backgroundColor));
    boardString = boardToStrings();

    io.emit("board", { board: boardString });
  }

  //Sign the admin ID
  let adminId = pwHash
    .split("")
    .map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))
    .reduce((a, b) => a + b);

  console.log(adminId);

  res.json(ok({ token: sign({ id: adminId }) }));
});

From the comment it looks like we need to post parameters for p and q to this endpoint. To get q, we can do the following in python:

q = n//p

We need to use the “double” division sign to prevent python from converting the huge integers to scientific notation.

Once we have p and q, we can make the post request to /init and receive a signed admin JWT in the response! The only thing left now is to take that token and use it to make a get request to /flag!

Take a look at my final exploit here!