请教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
来进行用户验证。
比较重要的页面分别是:
/init
获取POST数据中的p和q参数,最终生成一个adminId,返回一个id=adminId的用户token/serverInfo
根据用户的权限返回config内的信息/updateUser
更新用户信息,设置用户权限/login
登录账户/flag
获取Flag 逆推整个过程的话,大概是这样的思路:
访问Flag页面需要对adminId进行判断,adminId需要为0才能获取得到Flag。而adminId是可以通过/init传递p和q参数进行设置的。怎么将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的值。
在源码中可以知道,/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 即可绕过isValidUser
和isAdmin
的验证。
查看n的值: