HackTM中一道Node.js题分析(Draw with us)

2020-02-10 约 1049 字 预计阅读 5 分钟

声明:本文 【HackTM中一道Node.js题分析(Draw with us)】 由作者 Threezh1 于 2020-02-10 08:46:33 首发 先知社区 曾经 浏览数 218 次

感谢 Threezh1 的辛苦付出!

说在前面

请教Hpdoger师傅Node.js的问题时,他给了我一道HackTM CTF 2020的Node.js题。花了几个小时看也没有很好的解决。最后还是他给了思路才把想整个过程理清楚。由于才刚刚学习node.js,文章中如果出现问题还希望师傅们指出来,十分感谢。

解题思路

题目界面:

题目部分源码:

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

整个题目就是一个在线画图的程序,当输入用户名登录之后就可以对网页上的颜色格子进行操作。(这不是重点)服务端使用了express框架,并使用express-jwt来进行用户验证。

比较重要的页面分别是:

  1. /init 获取POST数据中的p和q参数,最终生成一个adminId,返回一个id=adminId的用户token
  2. /serverInfo 根据用户的权限返回config内的信息
  3. /updateUser 更新用户信息,设置用户权限
  4. /login 登录账户
  5. /flag 获取Flag

逆推整个过程的话,大概是这样的思路:

怎么获取Flag?

访问Flag页面需要对adminId进行判断,adminId需要为0才能获取得到Flag。而adminId是可以通过/init传递p和q参数进行设置的。怎么将adminId设置为0呢?

怎么将adminId设置为0?

来具体看一下/init页面,它会获取POST数据中的p和q参数,并最终生成一个adminId:

app.post("/init", (req, res) => {
  let { p = "0", q = "0", clearPIN } = req.body; // 从POST数据当中获取得到p和q
  let target = md5(config.n.toString()); // target是config中n的md5加密
  let pwHash = md5(
    bigInt(String(p))
      .multiply(String(q))
      .toString()
  ); // 将p与p相乘
  if (pwHash == target && clearPIN === _clearPIN) {
    // 清理面板
    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 }) }));
});

一些语句已经作了注释,最重要的在后面的map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i)),这个语句的作用就是将pwHash中的每一位与target中的相同位置的字符进行异或。最后reduce将异或后的值进行取和。

pwnHash的来源是md5(p*q),target的来源是md5(n),如果想要它们异或后再取和的值为0的话,我们该怎么做呢?

我们首先得知道n的值是多少。在知道n的情况下,将p设置为n的值,q设置为1。(qp互换也可以)这样pwnHash和target的值就会相同。相同的值进行异或就会为0,取和之后也为0。这样就可以使adminId为0了。页面最终还会返回id为0的token,利用token就可以获取flag了。

那现在的问题就是怎么得到n的值。

怎么获取n的值?

在源码中可以知道,/serverInfo会根据用户的权限(right)返回config内的信息。默认获取得到的值中是没有n的,所以我们需要通过/updateUser页面来设置当前用户查看config信息的权限。

先来看下/updateUser页面的源码:

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)) { //检查rights
    users[uid].rights = user.rights.concat(rights).filter(onlyUnique); //去重操作
  }

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

数据包格式为:

{
    color: 0xDEDBEE,
    rights: ["height", "width", "usersOnline"]
}

rights部分就是要添加查看的权限。

先不看前面是否为管理员的判断,直接看后面添加权限时的判断。这里调用了checkRights()来进行权限检查。

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

函数中设置了一个黑名单,不允许p、n、port被添加进用户的查看权限中。验证方式是用for循环获取每一个权限,查看其是否在blacklist中。如果存在就返回false,不存在就返回true。所以传递rights的不能直接传递"n"。

先来看/serverInfo页面是怎么获取config的值的,

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

这里获取config的值就是通过config[i]获取的(i是键名)。那如何不直接传递"n"而得到n的值呢?

这里要了解javascript中数组取值的方式。定义一个array1数组如图,注意赋值时的参数值:

可以看到,我这里传递给array1的键值是一个多维数组,但是同样可以获取得到数组中键名为"port"的值。(这种取值的方式在python、php中不行)

由于这样的取值方式是可行的,所以我们只需要给right赋值一个["n"]就可以绕过前面的黑名单了。

好,现在可以取到n了,再来看看怎么登陆管理员用户名。

怎么使用管理员用户名登陆?

登陆页面/login中有一个函数用于判断用户名是否为合理的用户名:

function isValidUser(u) {
  return (
    u.username.length >= 3 &&
    u.username.toUpperCase() !== config.adminUsername.toUpperCase()
    // 长度大于3并且不能为adminUsername
  );
}

/updateUser页面中有一个函数用于判断用户是否为管理员:

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

在登录时,isValidUser函数会对用户输入的用户名进行toUpperCase处理,再与管理员用户名进行对比。如果输入的用户名与管理员用户名相同,就不允许登录。

但是我们可以看到,在之后的一个判断用户是否为管理员的函数中,对用户名进行处理的是toLowerCase。所以这两个差异,就可以使用大小写特性来进行绕过。

大小写差异可以参考p神的这篇文章:Fuzz中的javascript大小写特性

题目中默认的管理员用户名为:hacktm

所以,我们指定登录时的用户名为:hacKtm 即可绕过isValidUserisAdmin的验证。

思路总结

  • 利用javascript大小写特性使用管理员的用户名登录
  • 给用户添加查看n的权限并查看n的值
  • 通过赋值p、q将adminId设置为0
  • 获取Flag

过程复现

  • 利用javascript大小写特性使用管理员的用户名登录

  • 给用户添加查看n的权限并查看n的值

查看n的值:

  • 通过赋值p、q将adminId设置为0

  • 获取Flag

关键词:[‘安全技术’, ‘CTF’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now