You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
如果我可以知道最後留下來的 note 是什麼,就能反過來推測出 flag 的順序。如果留下來的是我建立的 note,代表 flag 一定排在前面,字典序也在前面。
因此這題的重點就是,該怎麼知道留下來的 note 是哪一個?
根據去年的解法,我一開始的想法一樣是讓 server side busy。Node.js 是 single thread,所以在處理完一個請求之前,是沒辦法接收其他請求的(非同步則是另外一回事)。
所以我的想法是建立一個 note,裡面有一堆 <img src=/?{random_number}>,在字數限制內大概可以發送 700~1000 個請求左右,藉由發一堆請求給 server,讓 server 變得忙碌。
這題還有另一點不同,那就是 bot:
constvisit=async(url)=>{// clear all dataawaitfsp.rm(tmpDir,{recursive: true,force: true});letbrowser;try{browser=awaitlaunchBrowser();letpage=awaitbrowser.newPage();// set flagawaitpage.goto("http://localhost:3000",{timeout: 7500,waitUntil: "networkidle2"});awaitsleep(2000);awaitpage.evaluate((flag)=>{document.querySelector("input[type=text]").value=flag;document.querySelector("form[action='/create']").submit();},FLAG);awaitpage.waitForNavigation({waitUntil: "networkidle2"});// restart browser, which should close all windowsawaitbrowser.close();browser=awaitlaunchBrowser();page=awaitbrowser.newPage();// go to the submitted siteawaitpage.goto(url,{timeout: 7500,waitUntil: "networkidle2"})// restart browser, which should close all windowsawaitbrowser.close();browser=awaitlaunchBrowser();page=awaitbrowser.newPage();// check on notes now that all other windows are closedawaitpage.goto("http://localhost:3000",{timeout: 7500,waitUntil: "networkidle2"});awaitsleep(8000);awaitpage.evaluate(()=>{document.querySelector("form[action='/view']").submit();});awaitpage.waitForNavigation({waitUntil: "networkidle2"});awaitbrowser.close();browser=null;}catch(err){console.log(err);}finally{if(browser)awaitbrowser.close();}};
在訪問完我們提供的 URL 以後,bot 才去訪問 /view 頁面,因此這次我們沒辦法從瀏覽器上面去衡量時間,而是要從自己 local 去測量。如果前面講的想法沒錯,照理來說在我們 local 也可以測量出時間,server response time 會變慢。
但嘗試了大概三四個小時以後,發現行不通。
理由大概有兩點,第一點是 server 的處理速度太快,我測了一下發送 500 個請求給 localhost,大概 400ms 就處理完了,第二點是時間區間很難抓,很難掌握到「bot 訪問 /view」的那段時間。
總之呢,試了很久都沒辦法得到一個穩定的辦法,只好先放棄了。
而此時我把注意力轉移到了新增 note 時的這一段:
constnewLen=totalLen+text.length;if(newLen>16384){// case 1returnreply.redirect(`/?message=Cannot add, please delete some notes first (${newLen} > 16384 chars)`);}userNotes.push(text);userNotes.sort();notes.set(req.cookies.id,userNotes);// case2reply.redirect("/?message=Note added successfully");
constpuppeteer=require("puppeteer");constcrypto=require("crypto");constsleep=(ms)=>newPromise(r=>setTimeout(r,ms));constvisit=async(url)=>{constuser=crypto.randomBytes(16).toString("hex");constpass=crypto.randomBytes(32).toString("hex");letbrowser;try{browser=awaitpuppeteer.launch({headless: "new",pipe: true,args: ["--no-sandbox","--disable-setuid-sandbox","--js-flags=--noexpose_wasm,--jitless",],dumpio: true});constcontext=awaitbrowser.createIncognitoBrowserContext();constpage=awaitcontext.newPage();awaitpage.goto("http://localhost:3000/register",{timeout: 5000,waitUntil: 'domcontentloaded'});// create new accountawaitpage.waitForSelector("button[type=submit]");awaitpage.type("input[placeholder='Username']",user);awaitpage.type("input[placeholder='Password']",pass);awaitpage.click("button[type=submit]");awaitsleep(3000);// create paste with flagawaitpage.type("input[placeholder='Title']","Flag");awaitpage.type("textarea[placeholder='Paste contents']","Flag");constimgUpload=awaitpage.$("input[type=file]");awaitimgUpload.uploadFile("./flag.png");awaitpage.click("button[type=submit]");awaitsleep(3000);// go to exploit pageawaitpage.goto(url,{timeout: 5000,waitUntil: 'domcontentloaded'});awaitsleep(30_000);awaitbrowser.close();browser=null;}catch(err){console.log(err);}finally{if(browser)awaitbrowser.close();}returnuser;};module.exports={ visit };
會先隨機產生一組帳號密碼,註冊後上傳 flag 作為圖片,接著訪問我們的網頁。因此目標就是要偷走這張圖片,就可以拿到 flag。
這題前端在顯示 note 時,用的都是安全的顯示方式,所以沒辦法注入 HTML 等等,因此一定是要找別的方式,其中就屬上傳檔案最為可疑了:
fastify.route({method: 'POST',path: '/api/create',onRequest: requiresLogin,handler: async(req,res)=>{constbody=Object.fromEntries(Object.keys(req.body).map((key)=>[key,req.body[key].value]));const{ title, text }=body;if(typeoftitle!=="string"||typeoftext!=="string"){thrownewError("Title or text must be string");}if(title.length>32||text.length>512){thrownewError("Title or text too long");}constid=crypto.randomBytes(8).toString("hex");constpaste={ id, title, text };if(req.body.file){constfilename=sanitizeFilename(req.body.file.filename.slice(0,64),"-");constext=filename.slice(filename.lastIndexOf("."));if(![".png",".jpeg",".jpg"].includes(ext)){thrownewError("Invalid file format for image");}constbuffer=awaitreq.body.file.toBuffer();try{awaitfsp.mkdir(path.join(__dirname,'public','uploads',req.user.user));}catch{}try{awaitfsp.writeFile(path.join(__dirname,'public','uploads',req.user.user,filename),buffer);}catch{}paste.image=`${req.user.user}/${filename}`;}req.user.pastes.push(paste);return{success: true};}});
refused to execute inline script
because it violates the following Content Security Policy directive:
"script-src 'sha256-BCut0I6hAnpHxUpwpaDB1crwgr249r2udW3tkBGQLv4=' 'unsafe-inline'".
Note that 'unsafe-inline' is ignored
if either a hash or nonce value is present in the source list.
因此這題可以用的 JavaScript 只有題目原先給的而已,其他都要靠 CSS 搞定。
利用以前作者出過的另外一題的技巧,可以藉由 dom clobbering defaultView 來決定 client router 要 render 哪一頁,就等於是可以在任意頁面注入 HTML 跟 CSS,細節可以參考我寫過的:corCTF 2022 writeup - modernblog。
我們需要先得到 /home 裡面會出現的 post id,再得到 /view/:id 裡面會出現的圖片路徑,就能取得 flag。這個 post id 的長度有 16 位,每一位都是 0-f,更麻煩的是這個 post id 每一次請求都會更新:
fastify.route({method: 'GET',path: '/api/pastes',onRequest: requiresLogin,handler: (req,res)=>{req.user.pastes.forEach(p=>p.id=crypto.randomBytes(8).toString("hex"));returnreq.user.pastes.map(({ id, title })=>({ id, title }));}});
importReactfrom"react";import{Link,useParams,useNavigate}from"react-router-dom";importaxiosfrom'axios';exportdefaultfunctionView(){const{ id }=useParams();constnavigate=useNavigate();const[paste,setPaste]=React.useState(null);React.useEffect(()=>{(async()=>{try{constr=awaitaxios.get(`/api/paste/${id}`);if(r.data){setPaste(r.data);if(!r.data.image){awaitdeletePaste(r.data.id);}}}catch(e){alert(e?.response?.data?.message||e.message);navigate("/home");}})();},[]);constdeletePaste=async(id)=>{try{awaitaxios.get(`/api/destroy/${id}`);}catch(e){alert(e?.response?.data?.message||e.message);navigate("/home");}};if(!paste){return<></>}return(<><h3>{paste.title}</h3>{paste.image&&(<imgsrc={`/uploads/${paste.image}`}onLoad={()=>deletePaste(paste.id)}onError={()=>deletePaste(paste.id)}className="mw-100"/>)}<divstyle={{whiteSpace: "pre-line"}}className="mb-2">{paste.text}</div><Linkto="/home">← Back</Link></>);}
可以用 meta tag 的 CSP connect-src 阻止刪除圖片的請求,並且用 iframe 的 sandbox 阻止跳出 modal。
uploading files as .png or .jpg have no mimetype (old version of fastify static) so they are mime sniffed (no xcto) and you can upload arb html / css
use technique from modernblog (clobber defaultView) and upload arb html that react router thinks is a target path. this lets us add custom html onto any page of the react app we want
now, we need to leak both the flag post id and username. we do this with css injection and iframes
we can use css to change the width/height of an iframe, and since there is no frame-src, we can point it to our own domain and read these values
i use window.open to get a window ref, then reading w.frames[0].innerWidth repeatedly
the only issue is, how do we leak the entire id if on every refresh the post ids change?
lets use the classic css recursive import (with a twist)
the issue with recursive import is that you need to import from a server you control. you need this bc you need the next css file request to stop responding until you leak the previous data so you know what css to send. but style-src is self, so we cant stall the next css file - or can we?
my solution: lets abuse the connection pool! if we block every socket on another tab, we can stop the css from importing until we are ready, and we unblock and reblock the socket pool at will
this allows us to control the time at which the next css file is uploaded, essentially letting us recreate the recursive css technique even when we dont control the target server!
this is a little complicated, we need to remove type module from script tag so it doesnt block, as well as move it to body. in addition we have to start the initial css req in a style tag (which is why unsafe-inline is there), otherwise it blocks
we also need to create a “buffer” of empty css files that just request another one so we can account for the initial api requests (as they happen in tandem with the css requests)
with this you can leak the post id
now to leak the username, you do the same technique but need to stop the image from deleting
use a csp meta tag with connect src to stop it from requesting the destroy endpoint
but this causes an alert which blocks everything, so you put this in an iframe srcdoc that doesnt allow modals
do all of this in 30 seconds and you can get the flag! (my solve finishes in 25s with no optimization)
相比於去年跟前年,今年的 web 題難度有顯著降低了不少,變得更平易近人了,靠著隊友的努力拿下了第一名,而 web 題也只剩一題沒解出來。
這次我基本上只解了簡單的 funnylogin 跟難的 safestlist,其他都是隊友解開的,還有另一題 another-csp 有看了一下,因此這篇只會記我有看過的以及比較難的題目。
如果想看其他題,可以參考其他人的 writeup:
官方提供的所有題目原始碼:https://github.com/dicegang/dicectf-quals-2024-challenges
關鍵字列表:
web/another-csp (16 solves)
這題的程式碼滿簡單的,簡化過後如下:
你可以插入任意程式碼到 iframe 裡面,目標是偷到相同網頁下的 token。
而重點是 iframe 的 sandbox 全開,CSP 也封鎖得很死。從這兩個線索中,可以得出限制是:
defeault-src 'none'
,所以禁止引入任何外部資源sandbox
,因此不能執行任何 JavaScript,也無法透過 meta 重新導向少了 JavaScript 以後,就少很多攻擊面了,因此只能從 HTML 與 CSS 下手。這一題的 CSS 有開 unsafe-inline,所以是可以加上 CSS 的。
不過無論如何,看起來都沒辦法對外發送 request,因此要嘛是找到 bypass(例如說 dns prefetch,但這題應該也不適用),要嘛就是要搭配題目的其他部分。
這一題的 bot 的運作方式不太一樣:
如果 browserOpen 的話,可以從 response 中得知。因此看到題目後我就有個想法,如果讓 Chromium crash 會發生什麼事?是不是可以透過這個方式來 leak 出 token?
舉例來說,假如我們寫一條 CSS 是
h1[data-token^="0"] { /*crash*/ }
,來讓 Chromium crash,那或許就可以加快或是拖慢 bot 執行的時間,進而得知這個 selector 是否符合。後來是隊友從 Chromium issues 中找到了讓 Chromium crash 的方式:
在賽後討論中也看到 Discord 內有人貼了 payload,讓網頁載入變得超級慢,也可以達到類似的效果,這是 @Trixter 貼的:
有點像是 Billion laughs attack 那樣,透過不斷重複構造出一個超大 payload,就可以拖慢速度。
拖慢速度以後就可以用剛剛講過的方式去測量網頁載入所需要的時間,因為超過 10 秒的話會直接 timeout,藉由這點來 leak 出 flag。
web/safestlist (2 solves)
這題是修改自之前我有解過的一個題目:SekaiCTF 2022 筆記與 concurrent limit,我簡單描述一下修改後的版本。
這個題目是一個經典的 note app,你可以建立新的 note,但問題是 note 內容會先經過
DOMPurify.sanitize
,所以沒辦法 XSS。而 CSP 的部分是default-src 'self'
,只能往題目的 origin 發送請求。也就是說,你沒辦法把請求往外傳。
除了建立 note 以外,還可以刪除 note,是用 note 的 index 來刪的。
而這題的核心是這一段建立 note 的程式碼:
注意那個
userNotes.sort();
,會根據 note 的內容進行排序。flag 的格式是dice{[a-z]+}
,利用這個排序功能,可以得出一個簡單的策略。假設 flag 是
dice{c}
,而我們先建立了一個dice{a
的 note,建立完以後去刪除第一個 note,這時候dice{a
會被刪掉,留下 flagdice{c}
。若是我們先建立了
dice{d
的 note,再去刪除第一個,就換成dice{c}
被刪掉,留下剛剛建立的dice{d
。換句話說,建立 note 以後再刪除第一個 note,根據排序的不同,留下來的 note 也不同。
如果我可以知道最後留下來的 note 是什麼,就能反過來推測出 flag 的順序。如果留下來的是我建立的 note,代表 flag 一定排在前面,字典序也在前面。
因此這題的重點就是,該怎麼知道留下來的 note 是哪一個?
根據去年的解法,我一開始的想法一樣是讓 server side busy。Node.js 是 single thread,所以在處理完一個請求之前,是沒辦法接收其他請求的(非同步則是另外一回事)。
所以我的想法是建立一個 note,裡面有一堆
<img src=/?{random_number}>
,在字數限制內大概可以發送 700~1000 個請求左右,藉由發一堆請求給 server,讓 server 變得忙碌。這題還有另一點不同,那就是 bot:
在訪問完我們提供的 URL 以後,bot 才去訪問
/view
頁面,因此這次我們沒辦法從瀏覽器上面去衡量時間,而是要從自己 local 去測量。如果前面講的想法沒錯,照理來說在我們 local 也可以測量出時間,server response time 會變慢。但嘗試了大概三四個小時以後,發現行不通。
理由大概有兩點,第一點是 server 的處理速度太快,我測了一下發送 500 個請求給 localhost,大概 400ms 就處理完了,第二點是時間區間很難抓,很難掌握到「bot 訪問 /view」的那段時間。
總之呢,試了很久都沒辦法得到一個穩定的辦法,只好先放棄了。
而此時我把注意力轉移到了新增 note 時的這一段:
如果筆記長度超出 16384,會重新導向到
/?message=Cannot add, please delete some notes first
,反之則導向至/?message=Note added successfully
,換言之,如果可以偵測出導向到的是哪一個,一樣可以利用類似的手法 leak 出 flag。我有個想法是猜測瀏覽器對於網址長度應該會有限制,可以試著構造出一個超長的網址,導向到
/?message=Cannot add, please delete some notes first
時會超過限制,而導向到/?message=Note added successfully
時則不會。但問題是這邊我們沒辦法控制 path 的長度,那該怎麼讓網址變長?
我試了一下 username,例如說:
http://${'a'.repeat(1000000)}}:pwd@localhost:3000
,發現居然成功了!細節可以看底下這個 PoC:
當我建立長度只有 2 的 note 時,網址在限制之內,因此正常開啟新的頁面,去拿
win.location.href
會觸發 cross-origin 的錯誤。但如果是建立長度 20000 的 note 時,重新導向的頁面網址太長,所以觸發錯誤,導致新開的頁面變成了
about:blank
,不會觸發錯誤。因此,確實可以靠著網址長度這一點,得知 note 到底有沒有建立成功。
最後的 exploit 如下:
每 submit 一次,就能知道 flag 的順序在某個字元前面還後面,運用 binary search 的話,大約 submit 6 次可以知道結果,一次要等 30 秒,總共需要 3 分鐘,因為懶得自動化所以我就手動慢慢 leak 了。
大概花了 40 分鐘左右拿到 flag,不過這其實是 unintended 就是了。
預期解
筆記一下 strellic 在 Discord 裡面貼的預期解法,用到了 background fetch API:
web/burnbin (1 solve)
先講一下,這題我沒解開也沒時間看,底下是參考作者的解答寫的。
這題的類型也是類似於經典的 note app,可以註冊一個新的帳號並且建立 note,建立的時候可以上傳一張圖片。
先來看一下 bot 的部分:
會先隨機產生一組帳號密碼,註冊後上傳 flag 作為圖片,接著訪問我們的網頁。因此目標就是要偷走這張圖片,就可以拿到 flag。
這題前端在顯示 note 時,用的都是安全的顯示方式,所以沒辦法注入 HTML 等等,因此一定是要找別的方式,其中就屬上傳檔案最為可疑了:
在上傳檔案時會檢查是否為
.png
、.jpeg
或.jpg
結尾,不是的話就拋出錯誤。雖然乍看之下只能上傳圖片,但如果上傳檔名是.png
的檔案,在舊版的 fastify static 中就不會有 mimetype,這題也沒有禁止 mime sniffing,就能上傳 HTML 或是 CSS 檔案。順帶一提,這一題的 CSP 如下:
雖然說乍看之下 script-src 有 unsafe-inline,但其實是沒作用的,嘗試了之後會發現底下錯誤:
因此這題可以用的 JavaScript 只有題目原先給的而已,其他都要靠 CSS 搞定。
利用以前作者出過的另外一題的技巧,可以藉由 dom clobbering defaultView 來決定 client router 要 render 哪一頁,就等於是可以在任意頁面注入 HTML 跟 CSS,細節可以參考我寫過的:corCTF 2022 writeup - modernblog。
我們需要先得到
/home
裡面會出現的 post id,再得到/view/:id
裡面會出現的圖片路徑,就能取得 flag。這個 post id 的長度有 16 位,每一位都是 0-f,更麻煩的是這個 post id 每一次請求都會更新:作者給的解法是運用 CSS + iframe 來 leak 出頁面上的資訊,如果只是洩露出一位很簡單,可以利用長寬來做,像是:
因為這邊 CSP 並沒有 frame-src,所以這個 iframe 會是我們的 origin,可以用
window.innerWidth
來得到寬度,藉此知道第一個字元是什麼。但問題是每次請求都會不一樣,所以我們必須在一次之內得到所有字元,否則 id 就不同了。
如果要一次 leak 出這麼多字元,一種方式是使用之前在 0CTF 2023 中才提過的方式,另一種是 recursive import,但這種通常都需要有自己的 server 配合。
而作者則是利用了 connection pool 的上限解掉了後者的問題,connection pool 在 CTF 中出現的頻率不低,簡單來說就是把 Chromium 的 255 個 connection 都填滿,就能控制下一個資源什麼時候載入。
因此做法是:
.jpg
),裡面會 leak 出第一個字元並且 import.png
概念是應該是這樣,但實作上似乎有許多狀況需要考慮,會複雜許多,可以參考最後會附上的作者解法,裡面有更多細節。
leak 出 id 以後,接著就可以如法炮製,把圖片路徑也 leak 出來。
但重點是 view note 的頁面,會自動發送請求把圖片刪除,出現錯誤的話也會跳出
alert
:可以用 meta tag 的 CSP connect-src 阻止刪除圖片的請求,並且用 iframe 的 sandbox 阻止跳出 modal。
不過我覺得這題最難的事情是要在 30 秒內把所有事情做完,等於說每一個環節都必須自動化,這個真的難。
底下附上作者 strellic 的解法,上面是參考他的解法寫的:
後記
最近有其他事情在忙,有段時間沒打 CTF 了,總覺得有點生疏,不過把 safestlist 解掉真的滿開心的,代表身手沒有退步太多XD
除此之外,這篇也是相隔了兩個月之後的更新,是 2024 年的第一篇,雖然有點晚了,不過還是祝各位讀者新年快樂。
The text was updated successfully, but these errors were encountered: