$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: A TLS guide service asks for a SHA-256 proof of work, then requires distinguishing GOST from FRP across 20 forwarding rounds. Solution: Send one HTTP request, half-close the TLS write side, and classify whether the Caddy response survives.
follow guide in nc port to complete the challenge
The challenge exposes two TLS services. The interactive guide presents a proof of work and then 20 rounds, while the forwarding service changes between GOST and FRP. In each round, the goal is to identify the active forwarding implementation using no more than five probes and submit either gost or frp.
The interactive service first requests a suffix such that:
SHA256(prefix || suffix)
has 26 leading zero bits. After accepting the proof of work, it starts 20 timed rounds. A separate TLS endpoint forwards traffic to a Caddy HTTP server, and each round asks which proxy implementation is active.
An ordinary HTTP request was not enough. Both implementations returned the same 136-byte Caddy response, and their approximately one-second response times overlapped. Therefore, response content and timing under normal connection shutdown did not provide a classifier.
The decisive difference appears when the client closes only its sending direction:
tls.Conn.CloseWrite() immediately.The outcomes form an exact binary split:
Result after CloseWrite() | Classification |
|---|---|
136-byte HTTP response containing body caddy | GOST |
| Zero-byte clean EOF | FRP |
This behavior follows from the proxies' stream-copy semantics. GOST propagates the client EOF as a write half-close toward the destination while preserving the reverse path, so Caddy can still send its response. FRP uses github.com/fatedier/golib/io.Join; when one copy direction reaches EOF, its cleanup closes both streams and tears down the reverse path before the response returns.
Only one probe is needed per round, comfortably below the five-probe limit. It also avoids the timeout encountered when trying several sequential probes.
Alternative theories were unnecessary. Source-port reuse is not observable through the TLS frontend and fixed HTTP response, while FRP tcpMux head-of-line blocking remained speculative after the deterministic half-close classifier succeeded.
The solver performs the following steps:
ready message, make one half-close probe against the forwarding endpoint.gost if the returned data contains caddy; otherwise answer frp.package main import ( "bufio" "crypto/sha256" "crypto/tls" "fmt" "io" "runtime" "strings" "sync" "sync/atomic" "time" ) const ( guideHost = "rqzjeuee3jgpucmo6e6lj3wlxae.cloud.d3c.tf:443" forwardHost = "r7rpppeo2by2izhw6nnuf5sprvy.cloud.d3c.tf:443" ) func dialTLS(address string) (*tls.Conn, error) { host := strings.Split(address, ":")[0] return tls.Dial("tcp", address, &tls.Config{ServerName: host}) } func solvePoW(prefix string) string { var found atomic.Bool answer := make(chan string, 1) workers := runtime.NumCPU() var wg sync.WaitGroup for worker := 0; worker < workers; worker++ { wg.Add(1) go func(start int) { defer wg.Done() for i := uint64(start); !found.Load(); i += uint64(workers) { suffix := fmt.Sprintf("%x", i) h := sha256.Sum256([]byte(prefix + suffix)) // Three zero bytes plus the top two zero bits = 26 bits. if h[0] == 0 && h[1] == 0 && h[2] == 0 && h[3] < 0x40 { if found.CompareAndSwap(false, true) { answer <- suffix } return } } }(worker) } suffix := <-answer wg.Wait() return suffix } func classify() (string, int, error) { c, err := dialTLS(forwardHost) if err != nil { return "", 0, err } defer c.Close() request := "GET / HTTP/1.0\r\nHost: x\r\n\r\n" if _, err := c.Write([]byte(request)); err != nil { return "", 0, err } if err := c.CloseWrite(); err != nil { return "", 0, err } _ = c.SetReadDeadline(time.Now().Add(2500 * time.Millisecond)) response, readErr := io.ReadAll(c) if strings.Contains(string(response), "caddy") { return "gost", len(response), nil } // The observed FRP case is a clean EOF with zero response bytes. A read // error is retained so unexpected network failures do not pass silently. if readErr != nil { return "", len(response), readErr } return "frp", len(response), nil } func main() { c, err := dialTLS(guideHost) if err != nil { panic(err) } defer c.Close() r := bufio.NewReader(c) var prefix string // Read the PoW banner. for { line, err := r.ReadString('\n') if err != nil { panic(err) } fmt.Print(line) if strings.HasPrefix(line, "prefix: ") { prefix = strings.TrimSpace(strings.TrimPrefix(line, "prefix: ")) } if strings.HasPrefix(line, "submit:") { break } } suffix := solvePoW(prefix) fmt.Fprintf(c, "pow %s\n", suffix) var pending string for { line, err := r.ReadString('\n') if len(line) != 0 { fmt.Print(line) } if strings.Contains(line, "ready") { pending, _, err = classify() if err != nil { panic(err) } } if pending != "" && strings.Contains(line, "submit `answer") { fmt.Fprintf(c, "answer %s\n", pending) pending = "" } if err != nil { return } } }
Run it with:
go run probe.go
The accepted run repeatedly showed the deterministic split and ended successfully:
round 03/20 ready [+] halfclose=0 ok=false total=1.03419575s n=0 err=<nil> [+] classifier successes=0 answer=frp recorded round 04/20 ready [+] halfclose=0 ok=true total=930.934625ms n=136 err=<nil> [+] classifier successes=1 answer=gost recorded ... round 20/20 ready [+] halfclose=0 ok=true total=936.130917ms n=136 err=<nil> [+] classifier successes=1 answer=gost recorded All answers are correct. <flag line redacted>
All 20 classifications were accepted.
tcpMux or head-of-line behavior: Plausible but speculative; no concurrency-based classifier was needed once half-close semantics gave an exact split.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar