日々のあれこれφ(..)

もっぱら壁打ち

SECCON Beginner 2021 参加した

CTFは去年末くらいに常設問題をぽちぽち解いていたりしたのですが、この度コンテストに初参加しました😃

以下感想と解いたweb系のwriteupです。writeupはコンテストが終了した直後にQiitaの方に作問者による公式のものが出ていますが、自分の整理用も兼ねてまとようと思います。

SECCON Beginner 2021について

www.seccon.jp

内容は用意されたセキュリティ知識や情報処理に関する問題をひたすら解いていくものになっています。例年は上位チームによる決勝戦が開催されていたらしいのですが、コロナ禍で完全オンラインで開催されていた今年はそれがなかったのが残念でした。観戦してみたかった。

問題数は28問あり、それぞれジャンル(welcome, crypto, reversing, pwnable, web ,misc)と難易度(easy, medium, hard)に分かれています。

開催期間は5/22(土)14:00~23(日)14:00までの24h、当日の進行は全てDiscord上で行われ、約900のチームが参加していたようでした👀

戦績

私はCTFのCの字も知らない旦那(スマホアプリエンジニア)を誘って二人チームで参加しました。

方針としては、web系の問題を中心に解いていき、余力があればmisc、さらに他の問題を解いていく形にしていました。

触れた問題は以下です。

  • web

    • osoba(easy)
    • Werewolf(easy)
    • check_url(easy)
    • json(medium)
    • cant_use_db(medium)
    • magic(hard)→ 解けなかった
  • misc

    • git-leak(easy)
    • Mail_Address_Validator(easy)
    • depixelization(depixelization)→ 解けなかった
  • crypto

    • simple_RSA(easy)
    • Logical_SEESAW(easy)→ 解けなかった

最終的な結果は693点で227位(943位中)でした。まあまあ。

ちなみにこれだけのチームが参加していながらも、全問解いたチームはたった2チームしかいませんでした。

writeup

問題では対象のwebシステムとアプリケーションのソースコードが配布されるのですが、web系では多くの問題がdocker-composeで提供されていました。ソースコードを読めば解ける問題がほとんどだったのでdocker環境は必須ではないと思うのですが、ローカル環境にdockerが入っているとやりやすいかもです。

[web] osoba (easy)

美味しいお蕎麦を食べたいですね。フラグはサーバの /flag にあります!

という文言とともに静的コンテンツが掲載されているwebサイトのurlと、ソースコードが渡されています。

webサイトでページ遷移すると分かるのですが、urlパラメータが以下のようになっていました。

?page=public/wip.html

アプリケーションのソースコードを見ると、次のようになっています。

── app
│   ├── Dockerfile
│   ├── flag
│   └── src
│       ├── app.py
│       ├── public
│       │   ├── index.html
│       │   ├── kikin.html
│       │   ├── neck.html
│       │   └── wip.html
│       ├── requirements.txt
│       └── uwsgi.ini
├── docker-compose.yml
└── nginx
    └── nginx.conf

なのでこのurlパラメータを ?page=../flag のようにしてあげることで、フラグを入手できます。

典型的なディレクトリトラバーサルの問題でした。

[web] Werewolf (easy)

I wish I could play as a werewolf...

という文言とともにwebサイトurlとPythonファイルが1枚渡されます。

webサイトは入力フォームになっていて、名前とcolorを入力してsubmitすると、 {NAME}, you are FORTUNE_TELLER などと表示されます。

Pythonファイルは以下のようなものでした。

import os
import random
from flask import Flask, render_template, request, session

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

app = Flask(__name__)
app.FLAG = os.getenv("CTF4B_FLAG")

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

class Player:
    def __init__(self):
        self.name = None
        self.color = None
        self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN'])
        # :-)
        # self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN', 'WEREWOLF'])

    @property
    def role(self):
        return self.__role

    # :-)
    # @role.setter
    # def role(self, role):
    #     self.__role = role


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

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == 'GET':
        return render_template('index.html')

    if request.method == 'POST':
        player = Player()

        for k, v in request.form.items():
            player.__dict__[k] = v

        return render_template('result.html',
            name=player.name,
            color=player.color,
            role=player.role,
            flag=app.FLAG if player.role == 'WEREWOLF' else ''
        )

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

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

def index()の最後に書いてある通り、player.role が 'WEREWOLF' だった場合に環境変数に設定されたフラグが入手できるようになっています。 しかしフォームにはnameとcolorしかないためどうにかしてroleを指定できるようにする必要があります。いろいろやり方はあると思いますが、私は単純にChromeデベロッパーツールで <input class="input" type="text" name="name">のnameの値を <input class="input" type="text" name="role"> のように書き換えてWEREWOLFになろうとしました。

ですがこれではまだ通りません。というのも、player.__dict__[k] = vの部分でPlayerクラスの属性をリクエストデータの値に置き換えているのですが、role属性はアンスコが二つ付いたものになっています。

調べて初めて知ったのですが、これは「ネームマングリング」というものでした。 mangleとは難号化という意味で、__が二つついた変数・関数はクラス外からそのままではアクセスできなくなります。 アクセスするにはマングリング後の名前(_Classname__spamのような形)を指定する必要があります。 というわけで、「name="role"」でもなく「name="__role"」でもなく 「name="_Player__role"」 にしてあげることで人狼になることができました😊

参考

[web] check_url (easy)

Have you ever used curl ?

という文言とともにurlとindex.phpが渡されます。

urlにアクセスすると、「c(heck)_url」というタイトルのサイトが開きますが、見ただけでは何のサイトなのかよくわかりません。 「like this」をクリックすると「URL: https://www.example.com」と共にサイトの中央の四角の中に Example Domainという別のサイトが表示されています。urlパラメータに ?url=https://www.example.com のようにurlを入力すると、その入力されたサイトが表示されるものみたいです。

index.phpの中身は以下のようになっていました。

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

if ($url !== "https://www.example.com"){ にある通り、 ?url=https://www.google.comのように入力しても、urlが「URL: https://www👻google👻com」のように置換されてしまいます。

一方どうすればフラグが入手できるのかというと、一番上のif文 if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1"){にある通り、 127.0.0.1(自身)からアクセスした時だけフラグが表示されるみたいです。 ただし三つ目のif文 if(stripos($url,"localhost") !== false で書いてある通りlocalhostは使えません。

このif文を突破する解はIPアドレスを16進数に変換してあげることでした。

IPアドレスはは10進数、16進数に変換した形でもアクセスできるらしいです👀

アドレスが 127.0.0.1であればいいので、それを16進数に直した ?url=https://0x7f000001 のような形でアクセスすると、フラグをゲットできました。

ちなみに10進数だとBad Requestになってしまいます(Apacheの仕様みたいな話を聞いたけどソースを見つけられなかった)

easyだけど早くも苦戦した問題でした。言われてみれば知識としては持っていたような気はするのですが、こういう使い方をしてみようという発想がありませんでした。相方がいなければ解くのにもっと時間がかかっていたと思います😨

参考

[web] json (meduim)

外部公開されている社内システムを見つけました。このシステムからFlagを取り出してください。

という文言とともにurlとアプリケーションコードが入ったzipファイルが渡されます。

urlを開くと「Internal Website / 内部ページ このページはローカルネットワーク(192.168.111.0/24)内の端末からのみ閲覧できます。」などと表示されます。

アプリケーションコードを見ると、bffとapiの二層構成になっています。いずれもgolangで書かれていました。

bff/main.goはipアドレスをチェックした後ページを開くGETリクエストと、POSTリクエストを受け付けるエンドポイントがあります。POSTでは0から2までのidをJSON形式で受け付けており、バリデーションした後apiにリクエストを投げています。

(コードは一部抜粋)

// bff/main.go
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)})
    })

api/main.goではリクエストをJSON parseし、idの値に応じた処理を行っています。

(コードは一部抜粋)

// api/main.go
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")
    })

id=2 だとフラグが出るようになっています。curlでPOSTリクエストしてデータをいい感じに渡してあげれば解けそうです。

ですがbff/main.goの方ではid=2にすると c.JSON(400, gin.H{"error": "It is forbidden to retrieve Flag from this BFF server."}) のエラーが出るようになっています。

そこを考える前に、まずIPアドレスのフィルタリングを突破する必要があります。

今回の場合だとcurl実行時に --header "X-Forwarded-For: 192.168.111.0" を渡してあげればできました。

X-Forwarded-For (XFF) ヘッダーとは、HTTP プロキシ又はロードバランサーを通過してウェブサーバーへ接続したクライアントの、送信元 IP アドレスを特定するためのヘッダーです。

validationの突破方法ですが、ポイントはbffとapi二ヶ所で別々のやり方でJSON parseしている点にありました。

bffでは json.Unmarshal(body, &info)type Infoで定義した形でparseされていて、info.IDがバリデーションされています。ところがapiリクエスト時に渡されているデータはinfoではなく、Unmarshal前のbodyです(!)。

apiではシンプルにid, err := jsonparser.GetInt(body, "id") で idに値が渡されています。

このvalidationを突破する方法は、json.Unmarshalとjsonparser.GetIntの処理方法の違いにありました。

json.Unmarshalでは重複するキーを渡した場合、後者の値が返るようになっています。

func main() {
    b := []byte(`{"id":1,"id":2}`)
    type Info struct {
        ID int `json:"id" binding:"required"`
    }

    var info Info
    json.Unmarshal(b, &info)
    fmt.Printf("%+v", info.ID)
}

// 出力: 2

一方jsonparser.GetIntでは最初の方の値が返されるようです。

func main() {
    b := []byte(`{"id":1,"id":2}`)
    id, err := jsonparser.GetInt(b, "id")
    if err != nil {
        fmt.Printf("Failed to parse json")
        return
    }
    fmt.Printf("%+v", id)
}

// 出力: 1

というわけで今回の問題は、 curlのPOSTデータに{"id": 2, "id":1} を指定することでbffのバリデーションを交わしてフラグをゲットできました🚩

プログラムや利用しているライブラリでJSONの重複の扱いがどうなっているのか、存在していた場合どう処理するようにするのかは気をつけないといけないなぁと身に沁みました。

参考

[web] cant_use_db (meduim)

Can't use DB. I have so little money that I can't even buy the ingredients for ramen. 🍜

という文言とともにurlとアプリケーションのファイルが一式渡されます。

urlを開くと購買画面になっていて、左側の方はユーザーのステータス(所持金 $20000、Noodlesの購入個数が0/2、Soupの購入個数が0/1)が表示されています。右側の方は購入画面になっており、 Noodles $10000, Soup $20000それぞれの購入ボタンと「Flag (Noodles>=2, Soup>=1)」と書かれたボタンがあります。少ない所持金でどうやって必要な材料を揃えるかという問題みたいでした。

アプリケーションコードを見ると、リクエストごとに各ボタンを押した時の処理が分かります。

pp = 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()

購入個数や残高の情報がDBではなくファイルに保存されているのが特徴的です。

このファイルの扱い方がこの問題の解につながるのですが、私がこの問題を解いたのはほとんど偶然の気づきでした。

というのも最初はディレクトリトラバーサル系の問題なのかなと、どうにかしてファイルにアクセスして改ざんできないものかと考えていたのですができず、別のやり方を探そうと思った時にふと一番最初にwebアプリの購入ボタンを押した時にほんの少しラグがあってあれ反応がないなと連打したらNoodleが4/2購入できてしまった時のことを思い出しました。

あ、これかと...。

ちなみにボタン押下時にラグがあったのはソースコードにも書いてある通りです。この事象に遭遇した時はまだソースコードさえ開いていなかったので、このやり方でこのままフラグをゲットしてしまうことに抵抗があったのだと思います。にしても早く気づけよ自分...って頭を抱えました。😅

というわけで間髪入れずにNoodleとSoupを必要な数連打する形でフラグを入手しました。

所感

疲れたけど楽しかったに尽きました!

まず何がいいかって土曜日14:00から日曜日14:00までという開催時間がちょうど良かったです。土曜日の午前中はゆっくり寝れるし、終わった後もCTFの疲労をそのまま月曜日に持ち込むことなく、休んだり公式writeup読んだり時間があります。

土日の大半をCTFの問題に向き合うのに使っていましたが、日曜日の夕方は自分でも謎なくらいすごい充実感に満たされていました笑

あとで数えてみたら11hくらいは解いていたみたいでした。他の参加者と比べて多いのか少ないのかはさておき、体感時間はあっという間でした。

  • 土曜日
    • 14:00~16:30 問題解く
    • コンビニまで散歩。おやつ休憩
    • 17:30~19:00 問題解く
    • 夕飯休憩&だらだらする
    • 2100~23:00 問題解く
  • 日曜日
    • 気の済むまで寝る
    • 10:00~14:00 問題解く

問題はやっぱりweb系が一番楽しいです。実装するやレビューするときの参考にもなりそうでした。

自分のwriteupをまとめたので、解けなかった問題のを読みに行こう...