Beginners CTF 2020 Write-up


SECCON Beginners CTF 2020に参加しました。

Writeupを書くのが初めてどころかCTF参加するのも今回が初めてではありましたがメモ代わりに残しておくことにしました。

解いた問題

Spy

employees.txtの中でWEBにログインできる名前を挙げて送信するとフラグが入手できるようです。

app.pyを開いてみるとコメント付きで怪しい箇所が見つかりました。

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))

アカウントが存在するときにだけ重いパスワードのハッシュ化処理を行っているようです

処理速度もページに表示されるのでemployees.txtの中のユーザー名全てで試してみれば存在するアカウントの一覧ができました。

このアカウントの一覧をChallenge pageから送信することでflagを得ることができました。

Tweetstore

配布されたファイルであるwebserver.goを開きます。

searchというクエリを利用してSQLを作成しているようですが、

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

この処理であれば'が\'に置き換えられるだけなので\'を入力すれば、\\'となりSQLインジェクションが行えそうです(終了後にそもそも'のエスケープ方法が誤りであり'のエスケープ方法は''とすることであることを知りました)

試しに、これを利用して何かSQLインジェクションを行おうとするとInternal Server Errorとなり失敗してしまいました、

そこでSQLの結果を処理する部分を読むと

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})
	}

このようになっていて、text,url,tweeted_atの形になっていない場合にエラーとなってしまうようでした。

この部分はasで名前を付け、time.Timeであるtweeted_atはnow() as tweeted_at や、to_timestamp(0) as tweeted_at のようなものを使い、型を合わせました。

その下の部分を読むとこのように書かれていて、ユーザー名を取得すればflagが得られそうです。

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

最終的に以下のSQLをsearchクエリに渡すためにエンコードし、

\'; select 0 as url, usename as text, to_timestamp(0) as tweeted_at from pg_user;

https://tweetstore.quals.beginners.seccon.jp/?search=\'%3B select 0 as url%2C usename as text%2C to_timestamp(0) as tweeted_at from pg_user%3B --

というURLにアクセスすることでflagが得られました。

An image from Notion

unzip

docker-compose.ymlとindex.phpが配られているので確認します

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

index.phpを確認すると、この部分でfilenameに渡されたファイルの内容を返すコードが見つかり、セッションのfilesに含まれているか確認しているだけで、パストラバーサルを防ぐために行うべき処理が含まれていないように見えます。

そこでfilesに追加している部分も確認すると、このように書かれていました。

for ($i = 0; $i < $zip->numFiles; $i++) {
        $s = $zip->statIndex($i);
        if (!in_array($s["name"], $_SESSION["files"], TRUE)) {
            $_SESSION["files"][] = $s["name"];
        }
    }

アップロードしてzipファイル内のファイル名をそのまま追加しているのでここから攻撃ができそうです。

open_basedirなどの設定でアクセスを制限している可能性もありますが、ここでdocker-compose.ymlを確認すると、/flag.txtにフラグが存在することがわかりました。

volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.txt

/uploads/sessionid/filenameというパスにアクセスする処理なので、../../flag.txtというファイル名のzipファイルをアップロードすればflagを得られそうなので、試しに適当なzipファイルを作成、中のファイル名を../../flag.txtにリネーム後、アップロードすると../../flag.txtにアクセス可能になり、flagが得られました

mask

配布されたバイナリをリバースエンジニアリングツールに通し、中身を確認してみたところ

"atd4`qdedtUpetepqeUdaaeUeaqau"
"c`b bk`kj`KbababcaKbacaKiacki"

この2つの文字列が見つかりました。

そこで./mask ctf4b{とすでに分かっている先頭の部分を入力し、そこで得られる二行の出力がそれぞれ上の2つの文字列の先頭と一致しました。

この2つの文字列と一致するかの確認が含まれていたのでこれに合わせた文字列がflagであると判断しましたが、その前後の処理がこのようなことをした事が無かったためか理解できなかったため、文字数も少ないため

(これは正しい解き方では無いだろうなと考えながら)先頭から総当りで確認していくプログラムを書き、flagを得ました。

siblangs

配布されたファイルがapkであったので、まずはzipファイルとして解凍し、classes.dexをdex2jarを利用してjarに変換し、中のコードを確認しました。

するとreactの文字が見えたため、ReactNativeを利用したアプリについて調べて、assets/index.android.bundleの中身がJavaScriptであることがわかりました。

中身が圧縮されていて読みにくいのでOnline JavaScript Beautifierを使い読みやすく整形し、中身を確認すると

return (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 :)")
                }, t.onPressValidateLastHalf = function() {
                    "android" === h.Platform.OS ? p.default.validate(t.state.flagVal, function(t) {
                        t ? h.Alert.alert("Validation B Succeeded", "Great! Have you checked the other one?") : h.Alert.alert("Validation B Failed", "Learn once, write anywhere ... anywhere?")
                    }) : h.Alert.alert("Sorry!", "Run this app on Android to validate! Or you can try the other one :)")
                }, t
            }

flagにつながりそうなコードがあり、処理を見るとflagの前半と後半に別れているようです。

xoredの中身はflagと"AKeyForios10.3"という文字列とxorを取った結果のようなので、この文字列とxoredの中身でxorを取り、ctf4b{jav4_and_j4va5crが得られました。

後半部分は別の箇所で処理を行っているようなのでそれを探すと、下の方にこのようなものがあり、Java部分で処理を行っていることがわかりました。

m.exports = l.NativeModules.ValidateFlagModule

最初に開いていたJavaで書かれた部分のソースコードを確認すると


try
    {
      Object localObject = Cipher.getInstance("AES/GCM/NoPadding");
      GCMParameterSpec localGCMParameterSpec = new javax/crypto/spec/GCMParameterSpec;
      localGCMParameterSpec.<init>(128, arrayOfByte, 0, 12);
      ((Cipher)localObject).init(2, this.secretKey, localGCMParameterSpec);
      localObject = ((Cipher)localObject).doFinal(arrayOfByte, 12, arrayOfByte.length - 12);
      paramString = paramString.getBytes();
      for (int i = 0; i < localObject.length; i++) {
        if (paramString[(i + 22)] != localObject[i])
        {
          paramCallback.invoke(new Object[] { Boolean.valueOf(false) });
          return;
        }
      }
      paramCallback.invoke(new Object[] { Boolean.valueOf(true) });
    }
    catch (Exception paramString)
    {
      paramCallback.invoke(new Object[] { Boolean.valueOf(false) });
    }

このようなコードがありました。(上の部分は長いので省略)

アプリに組み込むための部分のコードを削除し、比較している部分を文字列をそのまま出力するように変更し、これ単体で動作するように書き換えた後Javaとして実行すると、1pt_3verywhere} となり、flagの後半部分が得られたので前半部分と合わせてflagを得ることができました。

emoemoencode

配布されたファイルを開くとこのように絵文字が並んでいました。

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

試しにこの文字列のコードを確認すると、全てサロゲートペアで、先頭の2byteはすべて同じものでした。

そこで後半の2byteを先頭の文字列はそのまま変換するのであればcであると考えて確認していくと🍣はDF63となっていて、この63の部分がasciiで表したcと一致したので、その他の文字も同様に変換していくとctf4b{stegan0graphy_by_em000000ji}となりflagが得られました。