目录

说在前面

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

解题思路

题目界面:

ctf_web.png

题目部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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页面的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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] }));
});

数据包格式为:

1
2
3
4
{
color: 0xDEDBEE,
rights: ["height", "width", "usersOnline"]
}

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

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

1
2
3
4
5
6
7
8
9
10
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的值的,

1
2
3
4
5
6
7
8
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数组如图,注意赋值时的参数值:

node_array.png

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

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

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

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

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

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

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

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

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

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

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

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

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

思路总结

过程复现

ctf_01.png

ctf_02.png

查看n的值:

ctf_03.png

ctf_04.png

ctf_05.png