DiceCTF @ HOPE WriteUps
這次和 XxTSJxX 參加了這場 DiceCTF @ HOPE 的比賽(實質上是 redpwnCTF 2022),拿到了不錯的成績。
web
point
1 | package main |
這邊是要弄一個 json 可以被 unmarshal 使得
whatpoint.Whatpoint == "that_point"
,但是它又不允許 body
中出現 what_point
和 \
,而在 struct definition
的部分 Whatpoint
又是指定要從 what_point
的
key 取值才行。
我是直接進去讀 json.Unmarshal
的 code,主要看到了這一段發現說它
key 在比對的時候也會 case insensitive match,所以把一個字母改大寫就能
bypass 了。
1 | > curl 'https://point.mc.ax/' --data '{"what_poinT":"that_point"}' |
mk
題目有個很直接的 https://mk.mc.ax/render?content=asd
會幫你 render markdown,也沒擋 html 等等的,所以可以很直接的 inject
html,但是它有個這樣的 csp 擋著導致沒辦法直接的 xss:
1 | default-src 'self';base-uri 'self';frame-ancestors 'none';img-src 'none';object-src 'none';script-src 'self' 'unsafe-eval';script-src-attr 'none';style-src 'self' 'unsafe-inline' |
看一下它提供的檔案知道它有 MathJax 2.7.9,所以去翻了官方的 Getting Started,知道 MathJax 是這樣初始化的:
1 | <script type="text/x-mathjax-config"> |
那個 text/x-mathjax-config
看了就相當可疑,把它改了一下變成:
1 | <script type="text/x-mathjax-config"> |
然後塞到 /render?content=...
中就出現 alert 了,代表
MathJax 會 eval 那段 code,所以剩下就是把 admin bot 的 cookie
拿走就搞定。
Flag: hope{make_sure_to_check_your_dependencies}
payment-pal
這題是我到目前為止解過的 web ctf 題目中我認為設計最好的一個題目
這題是個相當複雜的題目,需要找到各個 vulnerability 並全部串在一起才能解開。
首先是從它的 admin bot 看起:
1 | // npm install puppeteer |
總之它的流程就是先 admin 登入,然後再直接登出,最後再 visit 指定的 url 而已。登入後再登出的部分看起來根本是 NOOP,所以讓人更 confused 的地方在於 visit url 的時候已經沒有 admin credentials 了,那還有什麼利用機會嗎?
後端部分是使用 express 和 graphql 寫成的,db 部分只是單純 javascript 的 in memory map 而已:
db.js
:
1 | const users = new Map(); |
auth.js
部分可知它使用了 AES-256-GCM 加密 username 作為
token,塞到 session
cookie 裡面:
1 | const crypto = require("crypto"); |
我們知道 AES-GCM 是 authenticated encryption,所以應該是沒辦法修改
session 的對吧? 然而這份 code 中卻出現了一個大問題,主要是 api
的使用錯誤造成的。對照一下 Google 到的 example
using node.js crypto API with aes-256-gcm 和它這份 code
可以注意到一個點,就是 encrypt
的時候兩者都有使用到
cipher.final()
,但是 decrypt
的時候
auth.js
並沒有使用到
decipher.final()
。測試一下會發現此時把 authTag
亂改也能成功 decrypt,這是因為它需要 decipher.final()
才會檢查 authTag,所以不檢查的 AES-GCM 就相當於 AES-CTR,代表可以隨便
flip 想要的 plaintext。
範例:
1 | const xor = (a, b) => { |
所以這代表它的 token decrypt 出來的 username
是可以隨便亂改的,但是要得到 admin 的帳號還需要知道 admin username
到底是什麼才行 (admin-?
)。
現在的目標從原本的 xss 並用 graphql transfer money
到自己的帳號 簡化成為了 xss 並取得 admin
username,所以 admin bot 登出的這件事就不再是那麼的 confusing
了。假設已經取得了 xss,可以用 history.go(-4)
之類的讓它回到原本的頁面,然後如果是在新分頁得到 xss 的話就只要在
window.opener.location.href
等地方拿 url
就可以了。(登入成功時會 redirect 到
/?message=logged in as ${username}
,所以有 username 在 url
中)
要 xss 就要讀 client side 的
script.js
,這邊只節錄重要部分而已:
1 | const $ = document.querySelector.bind(document); // imagine using jQuery... |
首先是 parseQs
雖然有檢查 __proto__
constructor
prototype
,但是它檢查之後還會
decodeURIComponent
,所以可以很容易的繞過檢查去 prototype
pollution,像是下面的 url 會使
Object.prototype.peko === 'miko'
成立:
1 | https://payment-pal.mc.ax/?__%70roto__%5Bpeko%5D=miko |
但是從這後讀都看不出有什麼能 prototype pollution gadget
能用的地方,最可疑的部分只有 isAdmin
進去之後的
$("#contacts").innerHTML = html;
才有 xss 的機會,但是
await graphql(`query { info { money, isAdmin } }`)
對於普通
account 回傳的都都是
{"data":{"info":{"money":0,"isAdmin":false}}}
,就算 pollute
Object.prototype.isAdmin
也是沒用的。
這邊讓我想到突破點的是不久前解的 SEETF 2022 - Charlotte's Web,裡面讓我學到了 prototype pollution 對某些 browser api 也是有效的,包括 fetch。例如下面這段 code 在 Chromium 和 Firefox 中執行都是能成功 query 的:
1 | Object.prototype.body = JSON.stringify({ query: 'query { info { username } }' }) |
然而這邊的 prototype pollution 只能 pollute 一層,代表沒辦法 pollute
headers
,所以無法透過改 method
和
body
和動些手腳。去查一下 fetch() 的選項能找到有趣的 cache,其中一個玩法是
cache: 'force-cache'
,讓瀏覽器在有 cache 的情況下強制使用
cache 的資料。(是否是 cache 可以在 devtools 中 Network 的 Fulfilled by
看到)
因為 admin bot 是先登入後等了一秒之後才登出,代表
script.js
中的
query { info { money, isAdmin } }
和
query { info { contacts } }
的 response 應該也都存在於
cache 中。所以如果 pollute
Object.prototype.cache = 'force-cache'
的話瀏覽器就會使用先前的 cached response,對 admin bot 來說
isAdmin
就會是 true
!
但這同時 query { info { contacts } }
的回傳值也會是
cached 的 response,如果想要透過控制 contacts 來 xss 的話需要找方法
purge cache。做了一些測試之後發現直接 GET 該 query 的 url
是不行的,但是直接 csrf POST 那個 url 倒是能讓 contacts 的 cache
消失。
剩下就比較簡單了,因為 isAdmin
會讓瀏覽器走到那個 if
branch 中,只要控制 contacts 的 username 就能 inject html 達成
xss。至於控制 contacts 的方法也很常見,就是用 csrf 讓 admin bot
login
到任意的新帳號中,然後因為帳號是自己控制的代表它的
contacts 也是自己可控的 (或是 register
之後再 CSRF
addContact
也行)。這個手段是很常見的 Self-XSS + CSRF =
XSS。
不過要 CSRF graphql 的話會發現
GET /graphql?query=mutation { ... }
會失敗,因為它要求
POST
。雖然 CSRF 沒辦法控制 Content-Type
到
application/json
,但是做點測試就能知道
POST /graphql?query=mutation { ... }
也是可以過的,不一定要用 json 放在 body 中。
完整用 xss 偷 admin username 的 payload:
index.html
:
1 | <script> |
這邊開啟主要做事的 main.html
,然後
history.go(-3)
讓它回到原本的
https://payment-pal.mc.ax/
,方便到時候
main.html
的 tab 得到 xss 的時候可以用
window.opener
回來控制。
另外還有個 location.hash
是和 main.html
有關的。
main.html
:
1 | <script> |
利用 location.hash
選 base url 只是為了方便在 localhost
測試用的 code
而已,可以當作沒看到。而它本體就是按照順序開三個頁面,分別是清除
contacts 的 cache、CSRF register 和 CSRF
addContact,最後再把自己本身導向到 payment-pal 本身並同時 pollute
cache: 'force-cache'
。
nocache.html
:
1 | <script> |
這邊就是那個 POST contacts query 的部分,目標是為了把 cache 清除掉。
csrf_register.html
:
1 | <script> |
這邊是 CSRF 隨機 register 帳號的部分,也就是 Self-XSS 的帳號。
csrf_addcontact.html
:
1 | <script> |
把 xss payload 用 CSRF addContact
進到 db 而已,而 xss
payload 部分做的事就是先把原本的 login + logout +
index.html
的 tab history 再往前弄兩個,之後直接存取它的
href
就能拿到頁面的 url 了。我在 remote instance
上收到的是:
1 | https://payment-pal.mc.ax/?message=logged%20in%20as%20admin-dicegang_pp_user |
所以這樣就知道 admin 的 username 是
admin-dicegang_pp_user
,剩下用 AES-GCM 那個 bug
就能生出一個 username 相同的 token:
1 | // first register an account with username aaaaaaaaaaaaaaaaaaaaaa, and grab its token |
再來 transfer money 給自己的帳號:
1 | curl 'https://payment-pal.mc.ax/graphql' -G --data-urlencode 'query=mutation { transfer(recipient: "pekomiko35", amount: 133742069) { username } }' -H 'Cookie: session=JO+vUwPImTe43EJ1n1TweD6q23X8TQ==.tx6orPNKYjUQ2VpYf9GtKA==.vDV0+uV+9dqXwsAWNYywNA==' -X POST |
最後在自己的帳號下 query query { flag }
就結束了。
Flag: hope{pp=payment-pal=prototype-pollution!!!}
作者 writeup: DiceCTF @ HOPE - web/payment-pal writeup
crypto
reverse-rsa
1 | #!/usr/local/bin/python |
這題有個使用未知 RSA public 加密的 flag 記為 hope{[a-zA-Z0-9_\-]+}
的 regex。
解法就是隨便取個符合 regex 的 hope{a}
(要注意
endianness),然後這個問題就變成了 discrete log 了,因此只要讓
1 | from Crypto.Util.number import * |
small-fortune
1 | from Crypto.Util.number import * |
首先有 256 bits 的
其中的
首先是假定 flag.txt
裡面的 flag 是 flag format
hope{...}
且在最後沒有換行,那麼我們知道
分別移項可以得到兩個多項式
現在有
這邊只有
1 | with open("output.txt") as f: |
註: 是說這題的
misc
arson
這題是 reckless arson 出壞掉的版本,不過我一開始解的時候就成功找到了 intended solution,所以這個 unintended 是後來才額外找的
1 | #!/usr/local/bin/python |
可以知道它是要想辦法提供一個假的 model,讓 torch.load
去
RCE。首先先讀一下 torch.load
的 code (在 serialization.py)
可以知道它有分新的和舊的格式,分別是在 _load
和
_legacy_load
裡面 implement,而這個 unintended 和
_legacy_load
有關。
可以看到他裡面有 UnpicklerWrapper
,它的
find_class
基本上可以很容易達成 RCE,但是 arson.py
本身卻有透過 patch co_consts
修改了
UnpicklerWrapper.find_class
到它這個限制住的
UnpicklerWrapper
,所以應該能擋住直接的 RCE...?
直接往下讀可以看到它有
magic_number = pickle_module.load(f, **pickle_load_args)
,這邊的
pickle_module
就真的是 Python 本身的
pickle
,所以直接弄個單純的 os.system('sh')
的
pickle 當作就能 RCE 了。
reckless arson
這題是前一題的修正版,把原本 _legacy_load
的部分直接簡化成 None
:
1 | 71,75c71,73 |
所以這樣就變的只能使用新的 _load
(source
code 在此),而它裡面這次只有用 UnpicklerWrapper
去載入
pickle,但它的 co_consts
也被改過,導致
find_class
是修正版的
find_class
,因此這邊沒有簡單的 uninteneded
solution,只能想辦法找它允許的那個列表中有哪個可以用。
稍微讀一些 code 可以知道 torch.storage._TypedStorage
相當的有趣,因為它 __new__
裡面有條 code path 會執行到
eval()
:
1 | return _TypedStorage( |
PS: 這個 eval 正好在比賽的前幾天被 patch 了,但是當時 pip 上預設安裝下來的版本還沒修正
讀一下 code 可知進入到那個地方的條件是
cls != _LegacyStorage and cls != _LegacyStorage len(args) == 0
,然後它就會
eval(cls.__module__)
。稍微研究一下 storage 相關的 code 可知
_LegacyStorage
是 _TypedStorage
的 subclass,然後還有
torch.FloatStorage
之類的 class 又繼承自 _LegacyStorage
。因此要過
cls
的檢查就需要選擇 torch.FloatStorage
,
torch.ByteStorage
等等的 class 來用才行。
嘗試直接呼叫 torch.FloatStorage()
可確定它能進到 eval 的
path,所以只要能修改 torch.FloatStorage.__module__
就能有
code execution。
在 pickle 中我們知道有個叫 BUILD
的 opcode (source),它會根據參數
state
的格式看是要透過 __dict__
還是
setattr
來修改一個物件的 attribute。然而 class
__dict__
正常情況下是
mappingproxy
,它不支援直接的修改:
1 | import torch |
但是使用 setattr
就能成功:
1 | import torch |
所以要修改 __module__
的話必須讓它使用
setattr
的版本才行。這件事很重要的原因是因為我用了 splitline/Pickora
來簡化生成 pickle bytecode,而它預設修改 attribute 的方法是使用
BUILD
中修改 __dict__
的方案 (source),所以我還得另外在
macro 的部分另外加上一個 BUILD
macro 來用:
1 | def macro_build(args): |
我這邊其實應該直接改處理 attribute 的 code 比較好,不過後來才知道 Pickora 先前也是用
setattr
的,只是它在這個 commit 被修掉了==
總之,使用 patch 過的 Pickora 去 compile 下面這個 code 就能得到 RCE payload:
1 | from torch import FloatStorage |
順便也附上完整的 code:
1 | import sys |
用 zip 是因為新 format 就只是個 zip,裡面放
archive/version
和 archive/data.pkl
兩個檔案。這部分用 torch.save
存個東西就能看了出來。
rev
better-llvm
這題有個 ELF 要 reverse,執行後可知它是 flag checker 類型的題目。IDA 打開知道它有 embed CPython 在裡面。
首先用 fgets
讀長度 21 的字串然後檢查第一個字元是不是
h
,然後 Py_Initialize()
之後用
PyRun_StringFlags
執行:
1 | def dicegang(): |
之後可以看到它拿 dicegang
的 code object
出來,改了一大堆東西包括 co_consts
還有
co_code
等等。不過到最後又會用
PyRun_StringFlags
執行這段 code 來 check flag:
1 | if dicegang(): |
很明顯的,到這個時候 dicegang
函數已經不是原本的函數了,需要想辦法把 bytecode dump
出來才行。方法也很簡單,因為這段 code 是 string literal,直接寫個 python
script 把那段字串直接 replace 然後寫到新的 elf
就可以了。唯一需要注意的是 replace
之後的新字串長度需要小於等於原本的字串才行,所以最簡單就可以用
exec(input())
。
然後之後可以用 import dis;dis.dis(dicegang)
會發現它出現
IndexError: tuple index out of range
,顯然不正常。所以分別把
co_code
co_consts
co_names
co_varnames
dump 出來可以看到:
1 | 0 LOAD_CONST 4 (4) |
這邊可以看出它問題出在 STORE_FAST
,
LOAD_FAST
最高會存取到 5
,所以要把
co_varnames
隨便塞成有 6 個 names 的 tuple 才行,之後再 dis
就能成功了:
1 | dicegang.__code__ = dicegang.__code__.replace(co_varnames=('a','b','c','d','e','f'));import dis;dis.dis(dicegang) |
完整 patch binary 的 code:
1 | import os |
這邊 dis 出來的結果是這樣:
1 | 2 0 LOAD_CONST 4 (0) |
直接人工一行一行讀,然後寫回 python 長這樣:
1 | def dicegang(): |
inp
那個字串是原本 fgets
讀進來的字串,它會被塞到 co_consts
裡面當輸入。所以這樣可得知這個 check function
是一個一個字元檢查的,雖然不是很懂它的原理,但是它一旦出現錯誤字元就會
early return,所以透過它迴圈的次數去一個一個字元 brute force 就能找出
flag 了:
1 | dt = { |
另外是可以看出它的 c * f - d * e
其實是
dumb
這題是一個使用 snarkjs (ZKP 的 zkSNARK 的一個 implementation) 弄的 flag checker,輸入是一個 32 bits 的字串然後它會告訴你對不對。輸入會作為某個現成 circuit 的輸入進去,然後輸出的結果是一個 public key,裡面就一個值而已,如果那個值是 1 那麼就代表 flag 是正確的。
說實在的,我不懂什麼是 ZKP 也不知道什麼是 zkSNARK,但是讀了一下 Create your first zero-knowledge snark circuit using circom and snarkjs 發現它有個指令可以將 circuit 輸出為數學式:
1 | snarkjs printconstraints -r circuit.r1cs -s circuit.sym |
不過這個指令在我現在安裝的版本已經失效了,所以參考了一下
--help
中的說明找到了一個類似功能的指令
snarkjs rp <r1cs> <sym>
,發現它確實能輸出一堆數學式來:
1 | > snarkjs rp parts/main.r1cs parts/main.sym |
可以看出它確實有把式子輸出出來,但是這樣相當難閱讀,所以寫個 python 把它轉換一下:
1 | # PATH=node_modules/.bin:$PATH snarkjs r1cs print parts/main.r1cs parts/main.sym | sed -e 's/\x1b\[[0-9;]*m//g' > out |
得到這樣的結果,紀錄在 eqs.txt
中:
1 | (21888242871839275222246405745257275088548364400416034343698204186575808495616*flag[0]) * (flag[1]) - (21888242871839275222246405745257275088548364400416034343698204186575808495616*k1[0]) = 0 |
在裡面可以看到
21888242871839275222246405745257275088548364400416034343698204186575808495616
這個數很常出現,而題目給的檔案裏面又有給一個
verification_key.json
,裡面寫了
"curve": "bn128"
。這代表它可能裡面是用 bn128
這條橢圓曲線實作的,而 Google 一下可知它的 order q 為
21888242871839275222246405745257275088548364400416034343698204186575808495617,正好是前面那個數字減一,因此可以推測這些等式都是
整理一下可知第一行的等式其實就只是
flag[0] * flag[1] = k1[0]
,後面也以此類推到
k2,k3,k4
。而等式的最後一行有個變數叫
correct
,可推測那個就是 public key json
輸出的那個數。所以現在的目標就是找出一組輸入
flag[0] ~ flag[31]
使得最後 correct
的值為
1
,這樣就變成數學問題了。
首先是 flag -> k1
, k1 -> k2
,
k2 -> k3
的關係都相當好推,只是兩兩相鄰的樹相乘而已。最後一部份關於
k4
的部分比較多變化,但是一下可以字串處理一下讓它可以在
Sage 中直接
eval,然後透過預先設置好適當的變數之後就能得到一個多項式系統。
因為 k4
的部分沒那麼好直接表示出來,所以我設的變數包括了
correct, flag, k4
。在 sage
中觀察一下這個系統可知它不是線性的,所以沒辦法很直接的解出來,用
groebner basis 之類的也會花很多時間結果什麼都跑不出來。
觀察一下可以發現它有像是這樣的多項式:
1 | -f_0*f_1^4*f_2^6*f_3^4*f_4 + 19762700069597185*f_0*f_1^3*f_2^3*f_3 + 20182628090806273*f_1*f_2^3*f_3^3*f_4 + k4_1 + 21888242871839275222246405745257275088548364001552808768866971747434827354112 |
能看出相鄰位置的 flag 字元會聚集在一起,所以透過已知的 flag format
hope{...}
帶入一些 f_i
的值進去之後就有些
k4_i
可以直接解出來,且他們的值很神奇的都是
1
,所以合理推測所有個 k4
應該都是
1
。
而在假設所有 k4
都等於 1
的情況下再透過其他的已知字元帶入又會出現一些單變數多項式(例如前面的第二式),直接解開也會發現它的範圍也符合,因此就這樣以此類推就能解出整個
flag 了。
1 | q = 21888242871839275222246405745257275088548364400416034343698204186575808495617 |