← Back to Writeups
CUSTOMMediumNetwork

proxyport

XESXOR9/10/20265 min read
#network#custom#medium#tcp_half_close_fingerprinting#protocol_automation#parallel_pow

proxyport

Platform: CUSTOM | Category: Network | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: tcp_half_close_fingerprinting, protocol_automation, parallel_pow

Summary

Walkthrough of the CUSTOM Network proxyport. 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

Description

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.

Analysis

Protocol and constraints

The interactive service first requests a suffix such that:

text
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.

Half-close differential

The decisive difference appears when the client closes only its sending direction:

  1. Open a TLS connection to the forwarding endpoint.
  2. Send a complete HTTP/1.0 request.
  3. Call tls.Conn.CloseWrite() immediately.
  4. Keep reading from the connection.

The outcomes form an exact binary split:

Result after CloseWrite()Classification
136-byte HTTP response containing body caddyGOST
Zero-byte clean EOFFRP

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.

Solution

The solver performs the following steps:

  1. Connect to the interactive TLS service and parse the PoW prefix.
  2. Search in parallel for a suffix satisfying the 26-zero-bit SHA-256 condition.
  3. Submit the PoW solution.
  4. For every ready message, make one half-close probe against the forwarding endpoint.
  5. Answer gost if the returned data contains caddy; otherwise answer frp.
go
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:

bash
go run probe.go

Verification

The accepted run repeatedly showed the deterministic split and ended successfully:

text
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.

Failure Analysis

  • Normal HTTP timing: Eliminated because both proxies produced the same response and their latency distributions overlapped.
  • Several sequential probes: Unnecessary and harmful because the extra probes risked exceeding the round deadline.
  • Source-port reuse: Not observable through the TLS-terminating frontend and fixed backend response.
  • FRP tcpMux or head-of-line behavior: Plausible but speculative; no concurrency-based classifier was needed once half-close semantics gave an exact split.

Key Takeaways / Lessons

  • Mastering tcp_half_close_fingerprinting, protocol_automation, parallel_pow was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-25 • CUSTOM • proxyport • Difficulty: Medium