proxyport
proxyport
Platform: D3C2026 | Category: Network | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-25 | Status: Solved Techniques: tcp_half_close_fingerprinting, protocol_automation, parallel_pow
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
d3c2026| ID:20260725_d3c2026_proxyport - Tags: tls, gost, tcp_proxy, frp, caddy, proof_of_work
- Indicators: 26-leading-zero-bit SHA-256 proof of work, proxy implementation must be identified through a shared TLS forwarding endpoint, response behavior changes after TLS CloseWrite
- Source:
20260725_d3c2026_proxyport.md
Foothold
Vulnerability / Misconfiguration
- Tcp_half_close_fingerprinting
- Protocol_automation
- Parallel_pow
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- tcp_half_close_fingerprinting
- protocol_automation
- parallel_pow
- Tags: tls, gost, tcp_proxy, frp, caddy, proof_of_work
Original Writeup
<details><summary>Click to expand original content</summary>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:
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:
- Open a TLS connection to the forwarding endpoint.
- Send a complete HTTP/1.0 request.
- Call
tls.Conn.CloseWrite()immediately. - Keep reading from the connection.
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.
Solution
The solver performs the following steps:
- Connect to the interactive TLS service and parse the PoW prefix.
- Search in parallel for a suffix satisfying the 26-zero-bit SHA-256 condition.
- Submit the PoW solution.
- For every
readymessage, make one half-close probe against the forwarding endpoint. - Answer
gostif the returned data containscaddy; otherwise answerfrp.
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
Verification
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.
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
tcpMuxor head-of-line behavior: Plausible but speculative; no concurrency-based classifier was needed once half-close semantics gave an exact split.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR