diff --git a/docs/configuration/outbound/anytls.md b/docs/configuration/outbound/anytls.md index 4b3e03716..af324891c 100644 --- a/docs/configuration/outbound/anytls.md +++ b/docs/configuration/outbound/anytls.md @@ -17,6 +17,7 @@ icon: material/new-box "idle_session_check_interval": "30s", "idle_session_timeout": "30s", "min_idle_session": 5, + "client_metadata": "", "tls": {}, ... // Dial Fields @@ -55,6 +56,12 @@ In the check, close sessions that have been idle for longer than this. Default: In the check, at least the first `n` idle sessions are kept open. Default value: `n`=0 +#### client_metadata + +!!! question "Since sing-box 1.13.16" + +Check [AnyTLS client metadata](/manual/misc/anytls-client-metadata/). + #### tls ==Required== diff --git a/docs/configuration/outbound/anytls.zh.md b/docs/configuration/outbound/anytls.zh.md index c1f8999e7..c3f7abaa6 100644 --- a/docs/configuration/outbound/anytls.zh.md +++ b/docs/configuration/outbound/anytls.zh.md @@ -17,6 +17,7 @@ icon: material/new-box "idle_session_check_interval": "30s", "idle_session_timeout": "30s", "min_idle_session": 5, + "client_metadata": "", "tls": {}, ... // 拨号字段 @@ -55,6 +56,12 @@ AnyTLS 密码。 在检查中,至少前 `n` 个空闲会话保持打开状态。默认值:`n`=0 +#### client_metadata + +!!! question "自 sing-box 1.13.16 起" + +参阅 [AnyTLS 客户端元数据](/zh/manual/misc/anytls-client-metadata/)。 + #### tls ==必填== diff --git a/docs/manual/misc/anytls-client-metadata.md b/docs/manual/misc/anytls-client-metadata.md new file mode 100644 index 000000000..f1a80e001 --- /dev/null +++ b/docs/manual/misc/anytls-client-metadata.md @@ -0,0 +1,67 @@ +--- +icon: material/incognito +--- + +# AnyTLS client metadata + +The AnyTLS protocol has a design flaw: its settings frame requires the client +to send its software name and version to the server, and the protocol +specification requires that clients not disguise this information. + +This field serves no protocol purpose — AnyTLS already has a separate version +field for compatibility negotiation, and the open-source server implementation +does not use client metadata. However, the field allows vendors to collect and track client types, and for +platform-specific clients, potentially infer private information such as the operating system type and version range — +something that should not, and is not expected by users to, appear in an +anti-censorship protocol. We have received reports that +commercial proxy providers use this information to identify and block +connections from the official library provided by AnyTLS for sing-box +integration, reportedly because abusive users connect to their servers with +sing-box or with clients using the same official library. This indicates that +client metadata is being collected and used for discrimination in practice. + +The protocol specification states that "disguising it has no value." We +disagree: the situation is analogous to browsers implementing TLS ECH GREASE — +without it, privacy-protecting clients can be fingerprinted and treated +differently. + +## Status + +### 2025-02-20 + +We merged the +[pull request adding this protocol](https://github.com/SagerNet/sing-box/pull/2615). +Since the metadata was fixed at `sing-anytls/` in the +implementation provided for our use, and we did not carefully review the +protocol specification and other implementations, we wrongly believed that it +was not private information. + +### 2025-04-05 + +The protocol document +[added](https://github.com/anytls/anytls-go/commit/8812aae7ab29dd88bb89067b9ca676e2e7e29171) +the requirement that third-party implementations fill in the real software +name and version, claiming that "disguising it has no value". + +### 2026-07-18 + +A [pull request submitted to sing-box](https://github.com/SagerNet/sing-box/pull/4311) +was found to additionally upload the `sing-box` name and the actual version; +the change was subsequently reverted and was never released. + +### 2026-08-03 + +sing-box 1.13.16 and 1.14.0-beta.5 have been released; the client metadata in +AnyTLS requests is now empty by default. For compatibility, the +[client_metadata](/configuration/outbound/anytls/#client_metadata) outbound +option allows users to set a custom value. + +Since the open-source server implementation does not use this information and +it has no legitimate use, this is not considered a breaking change. + +## Recommendations + +We recommend that the AnyTLS protocol remove the client metadata, or replace +it with an option that is not sent by default and can be customized by the +user; and that other client implementations also take action, to jointly stop +statistics collection and discrimination based on client metadata. diff --git a/docs/manual/misc/anytls-client-metadata.zh.md b/docs/manual/misc/anytls-client-metadata.zh.md new file mode 100644 index 000000000..2251339c2 --- /dev/null +++ b/docs/manual/misc/anytls-client-metadata.zh.md @@ -0,0 +1,35 @@ +--- +icon: material/incognito +--- + +# AnyTLS 客户端元数据 + +AnyTLS 协议具有设计缺陷:其 settings 帧要求客户端向服务器发送软件名称和版本,且协议规范要求客户端不得伪装此信息。 + +此字段不承担协议功能——AnyTLS 已有独立的版本字段用于兼容性协商,且开源服务端实现不使用客户端元数据。然而,此字段使得供应商可以收集并统计客户端类型,对于某些平台特定的客户端,还可能推断出操作系统类型与版本范围等隐私信息,而这不应该,也不是被用户预期的,在一个反审查协议中出现。我们收到报告,有商业代理提供商利用此信息识别和阻止来自 sing-box 使用的、由 AnyTLS 提供的用于 sing-box 集成的官方代码库的连接,据传原因是恶意用户使用 sing-box 或使用相同官方代码库的客户端连接到服务器,这表明客户端元数据在实践中已被用于收集和区别对待。 + +协议规范称「伪装它没有任何意义」。我们不同意:这类似于浏览器实现 TLS ECH GREASE——如果没有这一机制,保护隐私的客户端会被识别并受到差别对待。 + +## 状态 + +### 2025-02-20 + +我们合并了[添加此协议的 PR](https://github.com/SagerNet/sing-box/pull/2615)。由于在供我们使用的实现中,metadata 被固定在 `sing-anytls/`,且我们没有仔细审查协议规范和其他实现,我们错误地认为这不是隐私信息。 + +### 2025-04-05 + +协议文档[加入](https://github.com/anytls/anytls-go/commit/8812aae7ab29dd88bb89067b9ca676e2e7e29171)了要求第三方实现填写真实软件名称与版本号的条款,并声称「伪装它没有任何意义」。 + +### 2026-07-18 + +[向 sing-box 提出的 PR](https://github.com/SagerNet/sing-box/pull/4311) 被发现额外上传了 `sing-box` 和实际版本的字符串,随后此更改被回退,没有发布。 + +### 2026-08-03 + +发布了 sing-box 1.13.16 和 1.14.0-beta.5,现在 AnyTLS 请求中的客户端元数据默认为空。出于兼容性考虑,[client_metadata](/zh/configuration/outbound/anytls/#client_metadata) 出站选项允许用户自定义此值。 + +由于开源服务端实现不使用此信息,且它没有合理用途,这不被视为破坏性更改。 + +## 建议 + +我们建议 AnyTLS 协议移除客户端元数据,或将其替换为非默认提供、且用户可以自定义的选项;并建议其他客户端实现也采取行动,共同阻止基于客户端元数据的统计和区别对待。 diff --git a/mkdocs.yml b/mkdocs.yml index e29592661..e47ec04e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - Hysteria 2: manual/proxy-protocol/hysteria2.md - Misc: - TunnelVision: manual/misc/tunnelvision.md + - AnyTLS client metadata: manual/misc/anytls-client-metadata.md - Configuration: - configuration/index.md - Log: diff --git a/option/anytls.go b/option/anytls.go index 0f7852632..24efbfc6c 100644 --- a/option/anytls.go +++ b/option/anytls.go @@ -22,4 +22,5 @@ type AnyTLSOutboundOptions struct { IdleSessionCheckInterval badoption.Duration `json:"idle_session_check_interval,omitempty"` IdleSessionTimeout badoption.Duration `json:"idle_session_timeout,omitempty"` MinIdleSession int `json:"min_idle_session,omitempty"` + ClientMetadata string `json:"client_metadata,omitempty"` } diff --git a/protocol/anytls/client_metadata.go b/protocol/anytls/client_metadata.go new file mode 100644 index 000000000..09e21195d --- /dev/null +++ b/protocol/anytls/client_metadata.go @@ -0,0 +1,64 @@ +package anytls + +import ( + "encoding/binary" + "net" + "reflect" + "strings" + "sync" + "unsafe" + + "github.com/sagernet/sing/common" + + anytls "github.com/anytls/sing-anytls" + "github.com/anytls/sing-anytls/session" +) + +const ( + commandSettings = 4 + frameHeaderSize = 7 +) + +var ( + clientSessionField, _ = reflect.TypeFor[anytls.Client]().FieldByName("sessionClient") + streamSessionField, _ = reflect.TypeFor[session.Stream]().FieldByName("sess") + sessionConnLockField, _ = reflect.TypeFor[session.Session]().FieldByName("connLock") + sessionBufferField, _ = reflect.TypeFor[session.Session]().FieldByName("buffer") +) + +func sessionClientOf(client *anytls.Client) *session.Client { + return *(**session.Client)(unsafe.Add(unsafe.Pointer(client), clientSessionField.Offset)) +} + +func (h *Outbound) rewriteClientMetadata(conn net.Conn) { + sess := *(**session.Session)(unsafe.Add(unsafe.Pointer(conn.(*session.Stream)), streamSessionField.Offset)) + connLock := (*sync.Mutex)(unsafe.Add(unsafe.Pointer(sess), sessionConnLockField.Offset)) + bufferPointer := (*[]byte)(unsafe.Add(unsafe.Pointer(sess), sessionBufferField.Offset)) + connLock.Lock() + defer connLock.Unlock() + buffer := *bufferPointer + offset := 0 + for offset+frameHeaderSize <= len(buffer) { + dataLength := int(binary.BigEndian.Uint16(buffer[offset+5 : offset+7])) + frameEnd := offset + frameHeaderSize + dataLength + if frameEnd > len(buffer) { + return + } + if buffer[offset] == commandSettings { + data := []byte(strings.Join(common.Map(strings.Split(string(buffer[offset+frameHeaderSize:frameEnd]), "\n"), func(line string) string { + if strings.HasPrefix(line, "client=") { + return "client=" + h.clientMetadata + } + return line + }), "\n")) + newBuffer := make([]byte, 0, offset+frameHeaderSize+len(data)+len(buffer)-frameEnd) + newBuffer = append(newBuffer, buffer[:offset+5]...) + newBuffer = binary.BigEndian.AppendUint16(newBuffer, uint16(len(data))) + newBuffer = append(newBuffer, data...) + newBuffer = append(newBuffer, buffer[frameEnd:]...) + *bufferPointer = newBuffer + return + } + offset = frameEnd + } +} diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef8..8a2d7a9cb 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -19,6 +19,7 @@ import ( "github.com/sagernet/sing/common/uot" anytls "github.com/anytls/sing-anytls" + "github.com/anytls/sing-anytls/session" ) func RegisterOutbound(registry *outbound.Registry) { @@ -27,12 +28,14 @@ func RegisterOutbound(registry *outbound.Registry) { type Outbound struct { outbound.Adapter - dialer tls.Dialer - server M.Socksaddr - tlsConfig tls.Config - client *anytls.Client - uotClient *uot.Client - logger log.ContextLogger + dialer tls.Dialer + server M.Socksaddr + tlsConfig tls.Config + clientMetadata string + client *anytls.Client + sessionClient *session.Client + uotClient *uot.Client + logger log.ContextLogger } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSOutboundOptions) (adapter.Outbound, error) { @@ -81,14 +84,30 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, err } outbound.client = client + outbound.clientMetadata = options.ClientMetadata + outbound.sessionClient = sessionClientOf(client) outbound.uotClient = &uot.Client{ - Dialer: (anytlsDialer)(client.CreateProxy), + Dialer: (anytlsDialer)(outbound.createProxy), Version: uot.Version, } return outbound, nil } +func (h *Outbound) createProxy(ctx context.Context, destination M.Socksaddr) (net.Conn, error) { + conn, err := h.sessionClient.CreateStream(ctx) + if err != nil { + return nil, err + } + h.rewriteClientMetadata(conn) + err = M.SocksaddrSerializer.WriteAddrPort(conn, destination) + if err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + type anytlsDialer func(ctx context.Context, destination M.Socksaddr) (net.Conn, error) func (d anytlsDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { @@ -110,7 +129,7 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination switch N.NetworkName(network) { case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) - return h.client.CreateProxy(ctx, destination) + return h.createProxy(ctx, destination) case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) return h.uotClient.DialContext(ctx, network, destination)