对象:GHSA-w34q-cm8f-9c5x / CVE-2026-81871(OpenTelemetry-Go OTLP 日志 gRPC 导出器忽略环境变量 TLS 配置)。

文中每一条攻击链结论都出自本地隔离 Docker 实验室的动态复现,代码引用固定到本地源码快照(格式 仓库@tag/commit → 文件:行号),证据边界见文末附录。随文发布的是经过披露过滤的公开附件(attachment/):已发布结论的全部场景可按附录的场景对照逐条复跑;涉及尚未向上游报告的候选问题的材料不随文发布,边界见附录“复现材料”一节。

摘要

OpenTelemetry-Go 的 OTLP 日志 gRPC 导出器(go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc,受影响区间 v0.5.0–v0.20.0,官方口径 < v0.21.0)会把 OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE 这组环境变量完整解析进 cfg.tlsCfg——根 CA、客户端证书、私钥一样不缺。但拨号逻辑只读另一个字段 cfg.gRPCCredentials,默认分支回落到 credentials.NewTLS(nil),也就是 Go 标准库的系统根证书。结果是一次静默降级(silent downgrade):运维通过环境变量声明的自定义根证书信任配置(私有 CA 信任锚约束)和环境变量双向 TLS(mutual TLS, mTLS)同时失效。这种失效的可观测性取决于对端证书落在哪个信任集:有效 TLS 文件的装载过程本身不报错,当攻击端证书同时满足系统信任与名称匹配时,错误的信任选择表现为无告警的成功;而合法 Collector 的 CA 只经环境变量提供、不受系统根信任时,则表现为可见但有误导性的导出失败(unknown authority、EXPORT_FAILED)——故障可见,却指向错误的原因。

同时满足“网络劫持”与“名称匹配且系统受信的服务端证书”两个前提的攻击者,可以冒充 Collector(收集器)窃取、丢弃、篡改日志遥测(telemetry),或在静态 Header 鉴权下直写真实后端;但拿不到客户端证书身份——强制 mTLS 的后端在漏洞能力内不可写入,这是本地动态验证得到的负结果。

根因不是回归。该模块 2024 年 6 月的第一个真实 gRPC 拨号实现(d99c76f)在新的 setting/resolver 配置架构下引入了与既有 gRPCCredentials 语义重叠的第二个 TLS 字段,而消费者只读其一;同仓库的 trace/metric gRPC 与 log HTTP 三个同族导出器都以单一事实来源正确消费同类配置。修复(c65d435,v0.21.0)补上了消费分支,但 loadEnvTLS 的错误处理仍是 fail-open:证书文件损坏时只经全局 ErrorHandler 输出一条弱可见的 stderr 日志,随后不阻断地回退系统根——这条路径修复前后都存在。

1. 现象:一个不会报错的配置失效

按 OTel 的配置契约,运维只要设置环境变量,导出器就该用声明的 CA 去验证 Collector:

OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE=/certs/legit-ca.pem
OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE=/certs/legit-client.pem
OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY=/certs/legit-client-key.pem

漏洞版本确实读取并解析了这三个变量,这一步没有任何问题。问题在后面:解析结果从未进入建连。

这个失效的可观测性取决于对端证书落在哪个信任集——同一段缺陷代码,在两种证书环境下表现出完全相反的可见性:

  • 私有 CA 场景(C1,本地复现):Collector 证书由私有 CA 签发,该 CA 只通过环境变量提供、不在系统根中。客户端验证用的却是系统根,连接失败:x509: certificate signed by unknown authority,EXPORT_FAILED。故障可见,但有误导性——运维看到的现象指向“我的证书配置错了”,而实际上配置是对的,只是被忽略了。
  • 攻击 CA 在系统根中的场景(A1,本地复现):攻击者的证书链接受害主机系统根信任。握手成功,导出返回 EXPORT_OK,全程零告警。攻击发生时,系统反而比故障时更“安静”。

能看见的故障与看不见的攻击,是同一条代码路径的两面。这条路径的数据流分三段,落在模块仅有的两个公开入口上:

图 1 生命周期三段:New 在构造期依次执行装载(env TLS 完整进入 cfg.tlsCfg)与装配(拨号 switch 不读该字段,default 回落系统根);首次 Export 才真正握手,断线重连复用同一套凭据。

New 在构造期依次执行 newConfig 与 newClient,用哪个凭据在这一刻一次定死:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/exporter.go:36-47
// New returns a new [Exporter].
//
// It is recommended to use it with a [BatchProcessor]
// or other processor exporting records asynchronously.
func New(_ context.Context, options ...Option) (*Exporter, error) {
    cfg := newConfig(options)
    c, err := newClient(cfg)
    if err != nil {
        return nil, err
    }
    return newExporter(c), nil
}

但“装配”不等于“建连”:模块调用的是 grpc.NewClient(exporters/otlp/otlplog/otlploggrpc/client.go:70;下文未注明的 client.go/config.go 均指该模块目录内的同名文件),其文档写明建连被推迟到首次 RPC:

// grpc-go@v1.81.1 → clientconn.go:159-162
// NewClient creates a new gRPC "channel" for the target URI provided.  No I/O
// is performed.  Use of the ClientConn for RPCs will automatically cause it to
// connect.  The Connect method may be called to manually create a connection,
// but for most users this should be unnecessary.

真正的 TCP+TLS 握手被推迟到首次 Export,此后断线由 ClientConn 自动重连,而重连复用的是构造时装配的同一套 TransportCredentials,不会再经过配置解析。

这个时序对攻击者是宽松条件:劫持不必赶上进程启动,在位后的第一次导出或任何一次重连握手都是入口。对防御者,它意味着“部署时连对了”证明不了“之后连的一直是对的”。

1.1 公开处置时间线:一个被正确定位却仍走了弯路的报告

公开报告当日已经给出了与最终修复一致的关键定位;公开记录显示的是后续处置如何绕远路。

2025-04-14,独立用户在 Issue #6661 报告 OTEL_EXPORTER_OTLP_CERTIFICATE 不生效并出现 unknown authority。这份报告不是只有现象:报告者当天就完成了正确的代码定位,从 otlploggrpc.New 一路追到 newGRPCDialOptions,指出环境变量路径因 cfg.gRPCCredentials 为空而落入 credentials.NewTLS(nil) 的系统根默认分支,还提出了把已加载 TLS 配置转为 gRPC credentials 的修正方向。

但公开处置最初把问题归为自签证书的使用与文档问题:Issue 先被加上 invalid,随后移除 bug、改标 enhancement/documentation,标题也被改写成“如何用自签证书使用 OTLP 导出器”。转折点是 2025-06 到 07 月的示例 PR #6882——报告者借此对 log HTTP、trace/metric gRPC 导出器做了行为对照,发现同族实现都正常,只有 otlploggrpc 异常。维护者于 2025-07-14 公开承认先前判断可能有误,7 月 21 日恢复 bug 标签并把问题限定到 otlploggrpc。报告者随后提交的公开修复 PR #7087 方向正确,但评审要求补充能验证最终凭据与真实握手的 consumer/E2E 测试,PR 停滞。最终修复以无公开 PR 编号的 Merge commit from fork(c65d435,2026-08-03)合入并发布 v0.21.0,同时补上了 mTLS 语义级的回归测试。

在已检查的全部公开 Issue/PR 讨论里,没有出现攻击、MITM、bypass、pinning 一类的安全定性;首次可公开核验的安全定性,是 2026-09-02 发布的 GHSA(CVSS v4.0 6.3,CWE-295/923)。这里要严格区分:GitHub 不公开安全公告的创建时间,私有安全认知何时形成并不可见。所以既不能把公开日期差当作“安全响应时长”来计算,也不能反过来断言维护者早已知情。“配置失败”与“安全降级”是同一代码路径的两个读法,后者直到 GHSA 才进入公开文本。

2. 信任模型:OTLP/gRPC 导出器本该信任谁

要看懂这个漏洞,先看它的信任模型。导出器的 TLS 配置面有三个入口,最终汇成一次拨号决策:

  1. 编程选项 WithTLSCredentials:写入 cfg.gRPCCredentials,类型是 gRPC 的 credentials.TransportCredentials——已经是“可装配的凭据”。
  2. 环境变量(signal 级优先于全局):OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE > OTEL_EXPORTER_OTLP_CERTIFICATE,客户端证书对同理;经 loadEnvTLS 写入 cfg.tlsCfg,类型是标准库 *tls.Config——只是“配置对象”,还不是凭据。
  3. 隐式默认:无任何显式配置时,拨号端使用 credentials.NewTLS(nil)。

在此之外还有第四个旁路入口:编程选项 WithGRPCConn 自带整条 *grpc.ClientConn。它不汇入这次拨号决策,而是整体替换它——newClient 检测到自带连接非空时直接跳过 newGRPCDialOptions(opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:65-78),连接安全完全由调用方自建连接的方式决定,环境变量与漏洞同时被绕开。第 6 章配置收敛里“未传入自建的安全 WithGRPCConn”这条判定,指的就是这条旁路。

图 2 三个入口汇成一次拨号决策:显式凭据可直接装配、env TLS 只是配置对象(v0.20.0 不读取)、无显式配置时回落系统根;WithGRPCConn 旁路则整体绕过拨号决策。

环境变量入口的完整清单与装载目的地(opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:60-77):

环境变量(signal 级 → 全局回退)

装载目的地

OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE → OTEL_EXPORTER_OTLP_CERTIFICATE

根 CA → tls.Config.RootCAs

OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY → OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE + OTEL_EXPORTER_OTLP_CLIENT_KEY

客户端证书对 → tls.Config.Certificates(成对非空才装载)

修复后的正常优先级是:显式凭据 > 合法 env TLS > 明文 insecure > 系统根默认。v0.20.0 则完全跳过 env TLS,实际顺序是显式凭据 > insecure > 系统根。OTLP 导出器规范(opentelemetry-specification@148f276 → specification/protocol/exporter.md:31-53)另有一层约束:endpoint 的 http/https scheme 优先于 insecure 设置,https 表示安全连接、http 表示明文,两者都覆盖独立的 insecure 变量。

为什么环境变量在这里承担安全语义?因为在这个生态里,env TLS 是运维侧信任声明:容器的 TLS 信任应由部署清单(Kubernetes env、Compose、运行时配置)决定,而不是由代码作者在编译期决定,也不是由“进程碰巧装了哪些系统根”决定。导出器规范为此专门定义了这组变量。忽略它,等于把信任决策从部署面夺回到一个应用作者从未选择、也无法看见的属性上——系统根的内容。第 4 章会看到,这正是攻击者的切入点。

2.1 NewTLS(nil) 的精确语义

漏洞的核心一行是 credentials.NewTLS(nil),值得逐行看清(v0.20.0 依赖 grpc-go v1.81.1):

// grpc-go@v1.81.1 → credentials/tls.go:221-257(节选)
// NewTLS uses c to construct a TransportCredentials based on TLS.
func NewTLS(c *tls.Config) TransportCredentials {
    config := applyDefaults(c)
    // …若调用方设置了 GetConfigForClient 则为其返回值再补一层默认值(c == nil 时不涉及)…
    return &tlsCreds{config: config}
}

func applyDefaults(c *tls.Config) *tls.Config {
    config := credinternal.CloneTLSConfig(c)
    config.NextProtos = credinternal.AppendH2ToNextProtos(config.NextProtos)
    // MinVersion 默认提升到 TLS 1.2、CipherSuites 取安全默认(h2 的 RFC 7540
    // 要求,细节从略)。注意:这些默认值都不触及信任锚——整个函数没有、
    // 也无法为 RootCAs 赋任何非空值。
    return config
}

信任锚的关键语义不在 grpc-go,而在 Go 标准库的验证路径。按官方文档(go1.25.0,与实验基镜像 golang:1.25-bookworm 一致):

// crypto/tls — Config.RootCAs:
// RootCAs defines the set of root certificate authorities
// that clients use when verifying server certificates.
// If RootCAs is nil, TLS uses the host's root CA set.

// crypto/x509 — VerifyOptions.Roots:
// Roots is the set of trusted root certificates the leaf certificate needs
// to chain up to. If nil, the system roots or the platform verifier are used.

// crypto/x509 — SystemCertPool:
// On Unix systems other than macOS the environment variables SSL_CERT_FILE
// and SSL_CERT_DIR can be used to override the system default locations for
// the SSL certificate file and SSL certificate files directory, respectively.

c == nil 时,CloneTLSConfig(nil) 得到零值配置:强制 TLS 1.2+、补 h2 ALPN、cipher 套件取安全默认——这些都没问题。问题在 RootCAs:零值配置的 RootCAs == nil,而上面的文档约定了此时的验证语义——tls 层把验证交给 x509 时使用宿主系统根证书池(或平台校验器);在 Linux 上,系统根入口还可被 SSL_CERT_FILE/SSL_CERT_DIR 覆盖。这就是本地实验注入攻击 CA 的机制,也是“系统受信证书”这一攻击前提的工程含义。反面同样要强调:主机名校验、链验证、SAN 匹配全程生效——漏洞改变的是“信任哪个根”,不是“是否校验”。CWE-923(未充分限制到预期通信端点)与 CWE-295(证书验证不当)的双归类,恰好对应这两个层面。

3. 根因:加载了,但没人消费

3.1 数据流断点

先看装载侧。newConfig 在应用完所有编程选项后,按字段逐一用 resolver 链解析环境变量与默认值,env TLS 是其中一步:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:106-142(newConfig,节选)
func newConfig(options []Option) config {
    var c config
    for _, opt := range options {
        c = opt.applyOption(c)
    }

    // Apply environment value and default value
    c.endpoint = c.endpoint.Resolve(
        getEnv[string](envEndpoint, convEndpoint),
        fallback[string](defaultEndpoint),
    )
    c.insecure = c.insecure.Resolve(
        loadInsecureFromEnvEndpoint(envEndpoint),
        getEnv[bool](envInsecure, convInsecure),
    )
    c.tlsCfg = c.tlsCfg.Resolve(
        loadEnvTLS[*tls.Config](),
    )
    // …headers / compression / timeout / maxRequestSize / retryCfg 同构…
    return c
}

loadEnvTLS 本身实现完整(config.go:497-541):signal 级变量优先、根 CA 与客户端证书对分别装载、显式选项优先时不覆盖:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:497-541(loadEnvTLS)
// loadEnvTLS returns a resolver that loads a *tls.Config from files defined by
// the OTLP TLS environment variables. This will load both the rootCAs and
// certificates used for mTLS.
//
// If the filepath defined is invalid or does not contain valid TLS files, an
// error is passed to the OTel ErrorHandler and no TLS configuration is
// provided.
func loadEnvTLS[T *tls.Config]() resolver[T] {
    return func(s setting[T]) setting[T] {
        if s.Set {
            // Passed, valid, options have precedence.
            return s
        }

        var rootCAs *x509.CertPool
        var err error
        for _, key := range envTLSCert {
            if v := os.Getenv(key); v != "" {
                rootCAs, err = loadCertPool(v)
                break
            }
        }

        var certs []tls.Certificate
        for _, pair := range envTLSClient {
            cert := os.Getenv(pair.Certificate)
            key := os.Getenv(pair.Key)
            if cert != "" && key != "" {
                var e error
                certs, e = loadCertificates(cert, key)
                err = errors.Join(err, e)
                break
            }
        }

        if err != nil {
            err = fmt.Errorf("failed to load TLS: %w", err)
            otel.Handle(err)
        } else if rootCAs != nil || certs != nil {
            s.Set = true
            s.Value = &tls.Config{RootCAs: rootCAs, Certificates: certs}
        }
        return s
    }
}

if err != nil 分支只经 otel.Handle 告警、不返回错误——这是 5.2 节 fail-open 的伏笔。支撑整个装载侧的 setting/resolver 机制则很简单:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:601-629(setting/resolver/Resolve,节选)
// setting is a configuration setting value.
type setting[T any] struct {
    Value T
    Set   bool
}

// …newSetting 略…

// resolver returns an updated setting after applying an resolution operation.
type resolver[T any] func(setting[T]) setting[T]

// Resolve returns a resolved version of s.
//
// It will apply all the passed fn in the order provided, chaining together the
// return setting to the next input. The setting s is used as the initial
// argument to the first fn.
//
// Each fn needs to validate if it should apply given the Set state of the
// setting. This will not perform any checks on the set state when chaining
// function.
func (s setting[T]) Resolve(fn ...resolver[T]) setting[T] {
    for _, f := range fn {
        s = f(s)
    }
    return s
}

setting[T] 是“值 + 是否已 Set”的二值容器;Resolve 只负责把 resolver 按序链起来,是否尊重已 Set 的状态由每个 resolver 自己检查——loadEnvTLS 开头的 if s.Set { return s } 就是这一检查。装载侧因此天然获得“signal 级优先于全局(envTLSCert 列表先信号级后全局、命中即 break)、显式选项不被 env 覆盖”的优先级语义。这套机制本身工作正常,问题从来不在装载。

到这里,配置对象完整就绪。再看消费侧——New() 构造期调用的 newClient(client.go:53)装配拨号选项;这段装配只在调用方未以 WithGRPCConn 自带连接时执行(client.go:65-78,即第 2 章的旁路入口):

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:52-86(newClient,节选)
// newClient creates a new gRPC log client.
func newClient(cfg config) (*client, error) {
    c := &client{
        exportTimeout:  cfg.timeout.Value,
        maxRequestSize: cfg.maxRequestSize.Value,
        requestFunc:    cfg.retryCfg.Value.RequestFunc(retryable),
        conn:           cfg.gRPCConn.Value,
    }

    if len(cfg.headers.Value) > 0 {
        c.metadata = metadata.New(cfg.headers.Value)
    }

    if c.conn == nil {
        // If the caller did not provide a ClientConn when the client was
        // created, create one using the configuration they did provide.
        dialOpts := newGRPCDialOptions(cfg)

        conn, err := newGRPCClientFn(cfg.endpoint.Value, dialOpts...)
        if err != nil {
            return nil, err
        }
        // Keep track that we own the lifecycle of this conn and need to close
        // it on Shutdown.
        c.ourConn = true
        c.conn = conn
    }

    c.lsc = collogpb.NewLogsServiceClient(c.conn)
    // …observability 插桩初始化后返回…
}

newGRPCClientFn 默认就是 grpc.NewClient(client.go:49-50:// Used for testing. + var newGRPCClientFn = grpc.NewClient),同时也是测试替身的注入点——3.4 节会再见到它。真正出问题的是它拿到的拨号选项:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:96-117(newGRPCDialOptions)
func newGRPCDialOptions(cfg config) []grpc.DialOption {
    userAgent := "OTel Go OTLP over gRPC logs exporter/" + Version()
    dialOpts := []grpc.DialOption{grpc.WithUserAgent(userAgent)}
    dialOpts = append(dialOpts, cfg.dialOptions.Value...)

    // Convert other grpc configs to the dial options.
    // Service config
    if cfg.serviceConfig.Value != "" {
        dialOpts = append(dialOpts, grpc.WithDefaultServiceConfig(cfg.serviceConfig.Value))
    }
    // Prioritize GRPCCredentials over Insecure (passing both is an error).
    switch {
    case cfg.gRPCCredentials.Value != nil:
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(cfg.gRPCCredentials.Value))
    case cfg.insecure.Value:
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
    default:
        // Default to using the host's root CA.
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(
            credentials.NewTLS(nil),
        ))
    }
    // …compression / reconnection 为同函数后续…
}

switch 只判两个字段:cfg.gRPCCredentials(显式选项)与 cfg.insecure(明文)。环境变量用户两个字段都不设,于是落入 default——credentials.NewTLS(nil),系统根。**在 v0.20.0 的整个模块里,cfg.tlsCfg 的消费者数量是零。**数据流在“配置已就绪”与“凭据已装配”之间断裂,而且没有任何断言或测试守卫“env TLS 必须成为凭据”这条不变量。

图 3 写入侧把六个 env TLS 变量完整装入 cfg.tlsCfg;读取侧的 switch 只列显式凭据与 insecure 两类分支,cfg.tlsCfg 零消费者。修复 c65d435 在 ① 与 ② 之间补上 env TLS 分支。

把最终状态压缩成三个布尔量,漏洞格就很清楚:C 表示显式 WithTLSCredentials,T 表示 env TLS 材料已成功装入 cfg.tlsCfg,I 表示最终 insecure 为真,* 表示该值不再影响分支:

C

T

I

v0.20.0 实际传输

v0.21.0+ 实际传输

安全含义

1

*

*

显式 credentials

显式 credentials

不走漏洞分支

0

1

0

系统根 TLS

env TLS

env 声明的私有 CA 信任锚在漏洞版失效

0

1

1

明文

env TLS

漏洞版中 insecure 还会压过已装载的 env TLS

0

0

1

明文

明文

正常 insecure 分支

0

0

0

系统根 TLS

系统根 TLS

正常默认分支

所以修复不是“强化 TLS 算法”,而是把消费顺序从 C > I > 系统根 TLS(T 完全消失)改成 C > T > I > 系统根 TLS。这也解释了为什么同一个缺陷既能表现为 unknown authority,也能表现为无告警的中间人连接:区别只在于对端证书是否恰好落在系统根的信任集中。

把这条数据流按输入、路径、控制点走一遍。输入来自进程环境变量,由运维控制——攻击场景下攻击者并不直接控制它,它的失效才是漏洞本质。链路有两条:loadEnvTLS → cfg.tlsCfg(setting/resolver 结构),以及 newGRPCDialOptions → grpc.WithTransportCredentials(dial option)。攻击者可控的是 TLS 对端与连接路径,且需持有能链到受害系统根的名称匹配证书与私钥;系统根池的内容是部署前提,不应一概写成攻击者可直接控制的输入。出错位置在 switch 的 default 分支——用系统根替代了配置声明的信任锚;没拦住,是因为既无运行时校验也无语义级测试(见 3.4)。

3.2 演化史:不是回归,是新生缺陷

git log 给出的引入提交是 d99c76f(2024-06-24,PR #5523)。在此之前的 client.go 长这样:

// opentelemetry-go@d99c76f~1 → exporters/otlp/otlplog/otlploggrpc/client.go:6-14(当时的全部实现)
type client struct {
    // TODO: implement.
}

func newClient(cfg config) (*client, error) {
    // TODO: implement.
    return &client{}, nil
}

也就是说,漏洞不是某次改动破坏了既有 TLS 处理,而是模块第一个真实的 gRPC 拨号实现天生缺失对 cfg.tlsCfg 的消费——这是 diff 能直接证明的部分。diff 证明不了的是成因。可并置观察的代码事实有两件:cfg.tlsCfg 字段与 loadEnvTLS resolver 的形态与 log HTTP 导出器一致(见 3.3),而 trace 导出器当时已有另一种成熟模式——env 证书在选项应用期经 WithTLSClientConfig 立即折叠为 GRPCCredentials。由此可得一个架构对照层面的推断(仅推断,非维护者结论):otlploggrpc 像是在两种同族形态之间做了一次不完整的搬运——取了 log HTTP 的“字段与装载”,却没补上两种实现都有的“配置最终成为凭据”这一步。但作者是否参照过这两个实现、为何漏掉这一步,公开记录(含 PR #5523 的评审讨论)中没有任何证据能回答。能确定的只有结构性后果:新架构允许“配置”与“消费”分离存放,这一缺失因此不留任何编译期痕迹。

版本区间由此精化。GHSA 一方面说漏洞由 d99c76f 引入,另一方面只给出 < v0.21.0 的范围、未给下界。本地 tag 包含关系给出答案:首个包含 d99c76f 的模块 tag 是 v0.5.0(不存在 v0.4.0),首个包含修复 c65d435 的是 v0.21.0,因此已发布受影响区间是 v0.5.0–v0.20.0,共 18 个发布 tag(16 个 minor 加 v0.12.1/v0.12.2)。对全部 18 个 tag 的静态核查(S-3)确认:loadEnvTLS 函数体逐字节相同,拨号端始终不读 cfg.tlsCfg,区间内不存在临时缓解;v0.6.0 只是新增独立 *_INSECURE 环境变量与 endpoint scheme 推导,v0.14.0 只是把 if-chain 等价改写为 switch,都没有触碰断点。

3.3 同仓三正一反:架构对照

同一仓库里的三个同族导出器,正好构成正确实现的对照组。

trace/metric gRPC 导出器(otlptracegrpc/otlpmetricgrpc)在选项应用期就把 env TLS 折叠进 GRPCCredentials 单一字段。先是 env 证书进入一个中间 tlsConf,随即被包成编程选项:

// opentelemetry-go@v1.38.0 → exporters/otlp/otlptrace/otlptracegrpc/internal/otlpconfig/envconfig.go:78-90(ApplyGRPCEnvConfigs 内)
        envconfig.WithCertPool("CERTIFICATE", func(p *x509.CertPool) { tlsConf.RootCAs = p }),
        envconfig.WithCertPool("TRACES_CERTIFICATE", func(p *x509.CertPool) { tlsConf.RootCAs = p }),
        envconfig.WithClientCert(
            "CLIENT_CERTIFICATE",
            "CLIENT_KEY",
            func(c tls.Certificate) { tlsConf.Certificates = []tls.Certificate{c} },
        ),
        envconfig.WithClientCert(
            "TRACES_CLIENT_CERTIFICATE",
            "TRACES_CLIENT_KEY",
            func(c tls.Certificate) { tlsConf.Certificates = []tls.Certificate{c} },
        ),
        withTLSConfig(tlsConf, func(c *tls.Config) { opts = append(opts, WithTLSClientConfig(c)) }),

WithTLSClientConfig 是关键一步:同一个选项同时写 gRPC 侧与 HTTP 侧两份 Config,gRPC 侧直接落进 GRPCCredentials——装载在选项应用期一次完成:

// opentelemetry-go@v1.38.0 → exporters/otlp/otlptrace/otlptracegrpc/internal/otlpconfig/options.go:310-318
func WithTLSClientConfig(tlsCfg *tls.Config) GenericOption {
    return newSplitOption(func(cfg Config) Config {
        cfg.Traces.TLSCfg = tlsCfg.Clone()
        return cfg
    }, func(cfg Config) Config {
        cfg.Traces.GRPCCredentials = credentials.NewTLS(tlsCfg)
        return cfg
    })
}

拨号处的分支(options.go:130-140)读的正是这个 GRPCCredentials 字段,连默认系统根分支都会把 creds 写回同一字段——装载与消费之间不存在第二条可能被遗漏的路径:

// opentelemetry-go@v1.38.0 → exporters/otlp/otlptrace/otlptracegrpc/internal/otlpconfig/options.go:130-140(NewGRPCConfig 内)
    // Prioritize GRPCCredentials over Insecure (passing both is an error).
    if cfg.Traces.GRPCCredentials != nil {
        cfg.DialOptions = append(cfg.DialOptions, grpc.WithTransportCredentials(cfg.Traces.GRPCCredentials))
    } else if cfg.Traces.Insecure {
        cfg.DialOptions = append(cfg.DialOptions, grpc.WithTransportCredentials(insecure.NewCredentials()))
    } else {
        // Default to using the host's root CA.
        creds := credentials.NewTLS(nil)
        cfg.Traces.GRPCCredentials = creds
        cfg.DialOptions = append(cfg.DialOptions, grpc.WithTransportCredentials(creds))
    }

log HTTP 导出器(otlploghttp)则把 cfg.tlsCfg 直接写入 HTTP 传输层,且对 insecure 与 env TLS 并存直接返回错误,而不是静默择一:

// opentelemetry-go@exporters/otlp/otlplog/otlploghttp/v0.20.0 → exporters/otlp/otlplog/otlploghttp/client.go:63-87(newHTTPClient,节选)
// newHTTPClient creates a new HTTP log client.
func newHTTPClient(ctx context.Context, cfg config) (*client, error) {
    if cfg.insecure.Value && cfg.tlsCfg.Value != nil {
        return nil, errInsecureEndpointWithTLS
    }

    hc := cfg.httpClient
    if hc == nil {
        hc = &http.Client{
            Transport: ourTransport,
            Timeout:   cfg.timeout.Value,
        }

        if cfg.tlsCfg.Value != nil || cfg.proxy.Value != nil {
            clonedTransport := ourTransport.Clone()
            hc.Transport = clonedTransport

            if cfg.tlsCfg.Value != nil {
                clonedTransport.TLSClientConfig = cfg.tlsCfg.Value
            }
            if cfg.proxy.Value != nil {
                clonedTransport.Proxy = cfg.proxy.Value
            }
        }
    }
    // …按 endpoint scheme 构造请求 URL…
}

三者路径不同,共同点是单一事实来源(single source of truth)。只有 otlploggrpc 出现“两个字段、消费者只读一个”。可以泛化的教训是:当一次重构把“配置来源”与“配置消费”拆成两个字段时,最危险的不是新代码遗漏了什么,而是旧消费者根本不知道新字段的存在——没有任何机制强迫消费者去读它,测试也只会在被显式写过断言的地方失败。这里要把对照的成立范围说准:trace/metric 用的是另一种配置组织(选项应用期折叠为单一字段),并非与 otlploggrpc 同一架构;setting/resolver 架构内的正确对照是 log HTTP——它同样以 loadEnvTLS 装入 cfg.tlsCfg,但把该字段直连到传输层消费。因此对照支持的共同点只有“最终 gRPC 凭据由单一事实来源消费”,不能引申为“同一架构下有正有反”。

图 4 trace/metric gRPC 在选项应用期把 env TLS 折叠进 GRPCCredentials 单一字段,log HTTP 把 cfg.tlsCfg 直连到传输层;只有 otlploggrpc 的 env TLS 停在无人读取的字段里。

3.4 为什么测试没拦住

v0.20.0 的 config_test.go 对 env TLS 是有覆盖的——断言 loadEnvTLS 装载成功后 cfg.tlsCfg.Value 非空、字段内容正确。这类单元级断言全部通过,因为装载侧确实没有 bug。修复提交新增的 TestEnvTLSCredentials 才第一次把断言推进到 mTLS 语义级:

// opentelemetry-go@c65d435 → exporters/otlp/otlplog/otlploggrpc/client_test.go:388-431
func TestEnvTLSCredentials(t *testing.T) {
    const certPath, keyPath = "cert_path", "key_path"
    origReadFile := readFile
    readFile = func(name string) ([]byte, error) {
        switch name {
        case certPath:
            return []byte(weakCertificate), nil
        case keyPath:
            return []byte(weakPrivateKey), nil
        default:
            return nil, errors.New("unexpected certificate path")
        }
    }
    t.Cleanup(func() { readFile = origReadFile })

    tlsCfg, err := newTLSConf([]byte(weakCertificate), []byte(weakPrivateKey))
    require.NoError(t, err)
    serverCreds := credentials.NewTLS(&tls.Config{
        Certificates: tlsCfg.Certificates,
        ClientAuth:   tls.RequireAnyClientCert,
    })
    coll, err := newGRPCCollector(t.Context(), "localhost:0", nil, grpc.Creds(serverCreds))
    require.NoError(t, err)
    t.Cleanup(coll.srv.Stop)

    t.Setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "https://"+coll.listener.Addr().String())
    t.Setenv("OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE", certPath)
    t.Setenv("OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE", certPath)
    t.Setenv("OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY", keyPath)

    cfg := newConfig(nil)
    require.NotNil(t, cfg.tlsCfg.Value)
    cfg.tlsCfg.Value.Time = func() time.Time {
        return time.Date(2021, 4, 1, 14, 30, 0, 0, time.UTC)
    }
    client, err := newClient(cfg)
    require.NoError(t, err)
    defer func() { assert.NoError(t, client.Shutdown(t.Context())) }()

    ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
    defer cancel()
    require.NoError(t, client.UploadLogs(ctx, resourceLogs))
    require.Len(t, coll.Collect().Dump(), 1)
}

在 RequireAnyClientCert 的服务端上完成真实 Export,env 的 CA 与客户端证书必须同时真正参与握手才能通过——这正是 cfg.tlsCfg 为零消费者时必然失败的测试。装载测试通过 ≠ 装配消费正确,这是配置类漏洞的典型测试盲区:断言停留在配置对象层,就没有任何用例会在“配置没接上传输”时变红。引入 PR #5523 自己的测试则以 newGRPCClientFn 测试替身替换了真实建连,且只传入显式 gRPCCredentials——从 env TLS 到真实握手的闭环从未被测过。

3.5 根因分层:三道防线连续失效

如果只把修复归纳为“少了一个 case”,会丢掉最有复用价值的部分。这个缺陷同时穿过了三层防线:

层级

失效点

为什么没有自动暴露

应守住的不变量

数据模型

同一 TLS 意图被分存为 tlsCfg 与 gRPCCredentials

两个字段都类型正确,编译器无法知道它们是同一安全决策的两种表示

一个有效 TLS 来源只应对应一个最终凭据事实源

实现

loader 写入 tlsCfg,dialer 只读 gRPCCredentials/insecure

配置对象本身完整,直到首次 RPC 握手才显现语义差异

任一成功装载的 env TLS 必须出现在最终 TransportCredentials 中

验证

测试只断言 loader 结果,拨号测试又使用替身和显式 credentials

没有真实 TLS 握手,就没有一个必须同时消费 CA 与客户端证书的语义断言

安全配置测试必须到达真实消费者,并同时包含成功与拒绝对照

4. 攻击视角:信任边界重绘与六条攻击链路

4.1 攻击者画像:两个缺一不可的前提

官方公告与本地实验共同刻画出的攻击者,需要同时满足:

**① 连接路径控制。**DNS 欺骗、路由劫持、恶意代理、受控网关或本地命名空间能力。注意包默认端点是 localhost:4317——若部署真的停留在回环端点,普通远程攻击者通常无法劫持,需要主机本地或网络命名空间能力。实际进入暴露区的,是配置了远程 Collector、使用 DNS 名称、或经代理/网格出网的部署。

② 名称匹配且系统受信的服务端证书 + 私钥。自签不够、SAN 错误不够、只有公开链没有私钥也不够——Go TLS 的主机名校验全程生效。这个前提的现成满足形态是企业检查代理 CA:预装在信任库里的代理根,可以给任意内部名称签发可过验的证书。本地 D-4 实验用派生镜像建模了这一形态:一次性 proxy CA 合并进系统根(共 122 个证书),三组对照结果是 0/1/0——普通 v0.20.0 镜像拒绝代理;预装 CA 的 v0.20.0 在同时配置了合法私有 CA 的情况下,仍向 TLS 终结代理导出并泄露日志;预装同一 CA 的 v0.21.0 正确使用私有 CA、拒绝代理。D-4 证明的是该前提在建模镜像中可满足,不是真实企业部署率。CVSS 向量里的 AT:P(attack requirements: present)按规范定义泛指成功攻击依赖漏洞组件的特定部署与执行条件,并不专门量化“系统受信证书”这一门槛;AT:P 与本链存在前置条件这一点相符——把该前置条件具体对应到证书门槛,是本文解读。

两个前提叠加的含义:这不是任何互联网攻击者都能直接兑现的漏洞,它的兑现位置在内网中间人、检查代理与流量劫持能力持有的地方。

4.2 实验环境

六条攻击链路与一条 C1 故障对照的动态验证,在 Docker Desktop 的 internal: true 隔离网络中完成。Docker DNS 别名模拟连接劫持——客户端访问 collector-*.lab,该名称解析到恶意端容器;SSL_CERT_FILE 注入攻击 CA 以满足“系统受信”前提(D-4 另用预装 CA 的镜像复核)。客户端用 golang:1.25-bookworm 分别构建 v0.20.0 与 v0.21.0;真实 Collector 使用官方 otel/opentelemetry-collector-contrib:0.136.0 的三个实例(仅服务端 TLS / Bearer 鉴权 / 强制 mTLS)。

实验环境的审计参数如下(完整登记在运行清单,公开附件内为 attachment/results/manifest-public-2026-09-23.md),其中宿主与 Docker 版本是实验(2026-09-21)后两天采集的查询值、并非实验当日记录——这是证据链的已知局限,在此直接标出,读者不必进入内部清单才能发现:

项

值

采集时点

宿主系统

macOS 26.5.1(Build 25F80),Apple arm64

2026-09-23 事后采集

Docker / Compose

Docker Desktop,client 与 server 均 29.8.0;Compose v5.5.1

2026-09-23 事后采集(期间未见 Docker 升级)

客户端构建基础镜像

golang:1.25-bookworm,sha256:3b4a11519ad929d1e1d261a12cff056f0c85b735253d7d861346b9c6f8b36437

2026-09-23 本地镜像核对

真实 Collector 镜像

otel/opentelemetry-collector-contrib:0.136.0,sha256:45392d534c1edcc809c2d112394029246bc679d2ae5ea7081414a1fc74f2c621(全部轮次一致)

各轮 run 头 / 运行清单

实验客户端镜像

otel-ghsa-w34q-lab:vulnerable/fixed/enterprise-*:D-1~D-4 逐轮 digest 已登记;基础轮(A1–A4、B1/B2、C1 与修复对照)digest 未记录、不可追溯

运行清单(证据链局限,见附录“复现材料”)

恶意端不是 HTTP 伪回显,而是按正式 OTLP protobuf 实现 LogsService.Export 的真实 gRPC 服务端,在应用层插桩记录日志正文、gRPC metadata、TLS 对端证书状态、篡改动作与上游转发结果。这个形态同时也是攻击侧门槛的刻画:仅截获 TCP 流量没有任何用处——黑洞丢包以可重试错误收场(连接错误或超时,代码路径推断见 A2),重试预算耗尽后错误返回 SDK,在直接同步 Export 返值路径上故障可见;要兑现读取或伪造,攻击者必须端到端跑通 TLS(含 §2.1 的 h2 ALPN)→ HTTP/2 → gRPC → OTLP LogsService/Export 四层交互,即持有名称匹配证书并运行一个语义正确的 OTLP 服务端。

攻击 CA 与预期私有 CA 完全独立,恶意服务端证书 SAN 覆盖客户端实际访问的名称——验证的是“信任根选择错误”,不是绕过主机名检查。本节及 4.3 表中的全部场景在随文公开附件中各有具名子命令,对照关系与运行入口见文末附录。

图 5 internal: true 的实验室内部网络:两个受害客户端、按正式 OTLP protobuf 实现的恶意端与三个真实 Collector 实例;DNS 别名模拟连接劫持,certgen 一次性签发全部证书,网络不出网、不映射宿主端口。

4.3 攻击—机制映射(含负结果与故障对照)

表中记号:P1 = 控制名称解析/路由/代理等连接路径,P2 = 持有对实际 endpoint 名称匹配、且被受害系统根信任的证书与私钥,P3 = 能完成 h2/gRPC/OTLP 语义交互;R0 = env TLS 已装入 cfg.tlsCfg,但漏洞版拨号 switch 不读它。C1 不是攻击链,而是证明同一代码缺陷另一面的故障对照。

攻击/故障场景

前置条件

输入入口

利用路径

代码机制

根本原因

安全影响

利用边界

A1 窃取

P1 + P2 + P3

env 私有 CA;首次 Export 或断线重连

劫持 endpoint → 恶意端终止 TLS → 解码 resource_logs → 回空 OK

NewTLS(nil) 使 RootCAs == nil 回退系统根

R0;两个 TLS 字段只有一个被消费

日志遥测机密性泄露(VC:L)

主机名/SAN 仍校验;不是任意数据面或主机入侵

A2 丢弃 + 伪造成功

A1 的全部条件

被劫持的 LogsService.Export RPC

收下请求但不转发 → 返回空/全零 ExportLogsServiceResponse

codes.OK 直接成功;仅当 partial_success 的 rejected_log_records 非零或 error_message 非空才返错

R0 使恶意端可成为真实 RPC 对端;响应语义使丢弃无声

遥测丢失与完整性破坏

"无感"仅已验证直接同步 Export;外部对账仍可发现

A3 篡改直写

P1 + P2 + P3;上游仅服务端 TLS

resource_logs/LogRecord.Body

恶意端解码 → 改写 protobuf → 与上游另建 TLS 并转发

TLS 在攻击端终止后,OTLP 请求体对其全量可读写

R0 重绘 TLS 终止点

篡改内容进入真实后端

上游要求 mTLS 或不可重放身份时不闭合

A4 Header 复用

P1 + P2 + P3;客户端配静态 authorization Header

OTEL_EXPORTER_OTLP_LOGS_HEADERS → gRPC metadata

终止 TLS → 读取 metadata → 重放 token 写上游

TLS 在攻击端终止后 metadata 可读,静态 token 可被重放;服务端 Authenticate 只比较 token 值

R0 使静态凭据进入错误 TLS 终止点

凭据泄露 + 冒名写入

仅适用可重放静态凭据;不外推通道绑定、短效凭据、请求签名

B1 env-mTLS 绕过

P1 + P2 + P3;完整 env mTLS

env 客户端证书与私钥

恶意端不请求客户端证书 → 单向 TLS 完成

tls.Config.Certificates 从未进入最终 credentials

R0 同时丢失 CA 与客户端身份

预期 mTLS 的客户端身份层未生效

攻击者不因此获得客户端证书/私钥

B2 写入强制 mTLS(负结果)

B1 的被劫持腿成立;上游强制 mTLS

攻击者向上游提交篡改后请求

与上游握手 → 缺少客户端身份 → TLS 层拒绝

client_ca_file → RequireAndVerifyClientCert

R0 能打开第一腿,但不会泄露第二腿需要的身份

未能写入强制 mTLS 后端

需另行获得合法客户端凭据才可继续,已超出本漏洞

C1 私有 CA 配置失效(故障对照)

无攻击者;合法 Collector CA 仅由 env 提供

env 私有 CA;首次 Export

连合法 Collector → 系统根无该 CA → 握手失败

同一 NewTLS(nil) 默认分支

R0

无攻击影响;遥测导出可见失败

这是功能故障,不是可闭合攻击链

逐链路的动态观测:

链路

终点

关键观测

A1

私有 CA 信任锚约束被绕过 → 窃取

恶意端收到日志正文;client_cert_present=false;客户端 EXPORT_OK

A2

丢弃 + 伪造成功

EXPORT_OK 与 not_forwarded 并存;D-3 持续观测 20/20/0

A3

篡改 → 写入仅服务端 TLS 的真实 Collector

Collector 收到 [tampered-by-lab-proxy] …

A4

静态 Header 截获复用 → 通过上游 Bearer 鉴权写入

恶意端读出 Bearer lab-static-token

B1

env-mTLS 被绕过 → 窃取/伪造

client_cert_present=false;单向 TLS 即完成;客户端 EXPORT_OK

B2

篡改写入强制 mTLS Collector 不闭合

上游报 tls: certificate required;Collector 无该日志

C1

无攻击者时的私有 CA 故障对照

合法 Collector 因 unknown authority 无法建连;客户端 EXPORT_FAILED

**A1(窃取)**是基础链路,也是第 1 章“零告警”一面的完整形态:

图 6 A1 窃取:预期私有 CA 被忽略,SSL_CERT_FILE 注入的攻击 CA 实际生效;恶意端解码完整日志遥测后返回空成功响应,客户端 EXPORT_OK 全程零告警。

数据面就是 OTLP 的 LogsService.Export 请求。服务与消息定义如下(opentelemetry-proto@f904c61 → opentelemetry/proto/collector/logs/v1/logs_service.proto:30-60)——注意响应侧注释的最后一节:完全为空的 partial_success 与不设该字段等价,A2 会用到这个语义:

// opentelemetry-proto@f904c61 → opentelemetry/proto/collector/logs/v1/logs_service.proto:30-60
service LogsService {
  rpc Export(ExportLogsServiceRequest) returns (ExportLogsServiceResponse) {}
}

message ExportLogsServiceRequest {
  // An array of ResourceLogs.
  // For data coming from a single resource this array will typically contain one
  // element. Intermediary nodes (such as OpenTelemetry Collector) that receive
  // data from multiple origins typically batch the data before forwarding further and
  // in that case this array will contain multiple elements.
  repeated opentelemetry.proto.logs.v1.ResourceLogs resource_logs = 1;
}

message ExportLogsServiceResponse {
  // The details of a partially successful export request.
  //
  // If the request is only partially accepted
  // (i.e. when the server accepts only parts of the data and rejects the rest)
  // the server MUST initialize the `partial_success` field and MUST
  // set the `rejected_<signal>` with the number of items it rejected.
  //
  // Servers MAY also make use of the `partial_success` field to convey
  // warnings/suggestions to senders even when the request was fully accepted.
  // In such cases, the `rejected_<signal>` MUST have a value of `0` and
  // the `error_message` MUST be non-empty.
  //
  // A `partial_success` message with an empty value (rejected_<signal> = 0 and
  // `error_message` = "") is equivalent to it not being set/present. Senders
  // SHOULD interpret it the same way as in the full success case.
  ExportLogsPartialSuccess partial_success = 1;
}

请求体 resource_logs[] 是三层嵌套(opentelemetry-proto@f904c61 → opentelemetry/proto/logs/v1/logs.proto:48-213,节选):

// opentelemetry-proto@f904c61 → opentelemetry/proto/logs/v1/logs.proto:48-213(节选)
// A collection of ScopeLogs from a Resource.
message ResourceLogs {
  // The resource for the logs in this message.
  // If this field is not set then resource info is unknown.
  opentelemetry.proto.resource.v1.Resource resource = 1;

  // A list of ScopeLogs that originate from a resource.
  repeated ScopeLogs scope_logs = 2;

  // …schema_url…
}

// A collection of Logs produced by a Scope.
message ScopeLogs {
  // The instrumentation scope information for the logs in this message.
  // Semantically when InstrumentationScope isn't set, it is equivalent with
  // an empty instrumentation scope name (unknown).
  opentelemetry.proto.common.v1.InstrumentationScope scope = 1;

  // A list of log records.
  repeated LogRecord log_records = 2;

  // …schema_url…
}

// A log record according to OpenTelemetry Log Data Model:
// https://github.com/open-telemetry/oteps/blob/main/text/logs/0097-log-data-model.md
message LogRecord {
  // …time_unix_nano / observed_time_unix_nano…

  // Numerical value of the severity, normalized to values described in Log Data Model.
  // [Optional].
  SeverityNumber severity_number = 2;

  // The severity text (also known as log level). The original string representation as
  // it is known at the source. [Optional].
  string severity_text = 3;

  // A value containing the body of the log record. Can be for example a human-readable
  // string message (including multi-line) describing the event in a free form or it can
  // be a structured data composed of arrays and maps of other values. [Optional].
  opentelemetry.proto.common.v1.AnyValue body = 5;

  // …attributes / dropped_attributes_count / flags…
  repeated opentelemetry.proto.common.v1.KeyValue attributes = 6;

  // …
  bytes trace_id = 9;
  bytes span_id = 10;
}

Resource 以属性描述遥测来源本身(service.name 一类语义约定字段所在层);scope_logs[] 标记产生日志的 instrumentation scope;LogRecord 承载每条记录的 body(任意类型的 AnyValue)、severity_text/severity_number、业务 attributes 与 trace 关联字段 trace_id/span_id。业务事件、用户标识、错误详情、结构化属性与 trace 上下文都在这条通道里——A3 篡改的目标正是每条记录的 body 字段。机密性影响取决于具体日志内容;泄露面限于遥测通道、而非任意主机数据面,这与官方 VC:L 评估一致,但不意味着每个部署的日志都是低敏感度。

**A2(丢弃 + 伪造成功)**在 A1 之上只改一个动作:不转发,直接返回空成功响应。客户端的 EXPORT_OK 与恶意端的 not_forwarded 同时出现,遥测完整性破坏且客户端无感。

为什么是“伪造 OK”而不是黑洞丢弃?两种丢弃在客户端代码里走的是完全不同的路径。先看 UploadLogs 的返回值处理:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:171-188(UploadLogs 返回路径)
    return errors.Join(uploadErr, c.requestFunc(ctx, func(ctx context.Context) error {
        resp, err := c.lsc.Export(ctx, pbRequest)
        if resp != nil && resp.PartialSuccess != nil {
            msg := resp.PartialSuccess.GetErrorMessage()
            n := resp.PartialSuccess.GetRejectedLogRecords()
            if n != 0 || msg != "" {
                err := internal.LogPartialSuccessError(n, msg)
                uploadErr = errors.Join(uploadErr, err)
            }
        }
        // nil is converted to OK.
        if status.Code(err) == codes.OK {
            // Success.
            return nil
        }
        return err
    }))
}

黑洞式丢弃不会直接判成功:连接被拒时客户端看到 Unavailable,连接悬死时则等到 DeadlineExceeded,两者都落入应用层重试——本实验室未对黑洞形态单独留动态记录,这一路径由所引代码与下方重试语义推出,而非实测。哪些状态码可重试由 OTLP 规范失败语义限定:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:258-285
// retryable returns if err identifies a request that can be retried and a
// duration to wait for if an explicit throttle time is included in err.
func retryable(err error) (bool, time.Duration) {
    s := status.Convert(err)
    return retryableGRPCStatus(s)
}

func retryableGRPCStatus(s *status.Status) (bool, time.Duration) {
    switch s.Code() {
    // Follows the retryable error codes defined in
    // https://opentelemetry.io/docs/specs/otlp/#failures
    case codes.Canceled,
        codes.DeadlineExceeded,
        codes.Aborted,
        codes.OutOfRange,
        codes.Unavailable,
        codes.DataLoss:
        // Additionally, handle RetryInfo.
        _, d := throttleDelay(s)
        return true, d
    case codes.ResourceExhausted:
        // Retry only if the server signals that the recovery from resource exhaustion is possible.
        return throttleDelay(s)
    }

    // Not a retry-able error.
    return false, 0
}

重试默认开启(config.go:31 的 defaultRetryCfg = retry.DefaultConfig 经 config.go:137-138 成为默认值),5 秒起指数退避至上限 30 秒、总预算 1 分钟,预算耗尽后本批次被丢弃、错误返回 SDK,故障可见:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/internal/retry/retry.go:20-26
// DefaultConfig are the recommended defaults to use.
var DefaultConfig = Config{
    Enabled:         true,
    InitialInterval: 5 * time.Second,
    MaxInterval:     30 * time.Second,
    MaxElapsedTime:  time.Minute,
}

返回空 ExportLogsServiceResponse 则让 status.Code(err) == codes.OK 直接判成功(client.go:182),应用层重试与 gRPC 连接层自动重连两层恢复机制同时被绕开。

这里连“空”都是精确选择:响应消息只有 partial_success 一个字段,攻击者若返回 rejected_log_records > 0 或非空 error_message 的半成功响应,客户端会把该错误并入本次导出的返回值(client.go:173-180),伪造即穿帮——无声的成功响应只需同时满足 codes.OK 且 partial_success 缺失或语义为空(全零视为未设置),这不是某一种唯一的编码形态,而是一类等价条件。D-3 把它从单次补强为持续性证据:同一 exporter 实例以 5 秒间隔同步 Export 共 20 次,约 95 秒窗口内客户端 20/20 成功、恶意端 20/20 收到且全部不转发、真实 Collector 0/20 收到。这把 A2 的可检测面压缩到 6.3 节特征 2 的两侧流量对账——客户端侧没有任何信号。边界:该结论限于直接同步 Export 的返值路径,未逐一验证 SDK 处理器、外部流量对账或自定义监控配置,因此不外推为“所有部署都无外部告警”。

**A3(篡改直写)**证明读取能力可以升级为写入能力:恶意代理在 protobuf 层把每条 LogRecord.Body 改写为 [tampered-by-lab-proxy] <original>,再用合法 Collector CA 与真实 Collector 建立上游 TLS——上游只做服务端 TLS 时不要求客户端身份,因此接受攻击者另建的这条 TLS 连接(来源地址等旁路信息或可用于区分连接,但那不是身份校验),篡改后的日志直接入库。

A4(Header 复用)针对一种部署形态:OTLP 的静态 Header 鉴权。客户端配置 OTEL_EXPORTER_OTLP_LOGS_HEADERS=authorization=Bearer%20lab-static-token,该 Header 随 gRPC metadata 发送,TLS 在攻击端终止后对攻击端全量可读;恶意端读出后在篡改后的上游请求中重放,通过官方 bearertokenauth 扩展的鉴权写入真实 Collector。实验真正依赖的是该扩展服务端的 Authenticate——它从请求 metadata 取出 token,与配置的静态预期值做常数时间比较,源码把语义写得直白:

// opentelemetry-collector-contrib@v0.136.0 → extension/bearertokenauthextension/bearertokenauth.go:222-239(Authenticate,服务端接收路径)
// Authenticate checks whether the given context contains valid auth data. Validates tokens from clients trying to access the service (incoming requests)
func (b *bearerTokenAuth) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) {
    auth, ok := headers[strings.ToLower(b.header)]
    if !ok {
        auth, ok = headers[b.header]
    }
    if !ok || len(auth) == 0 {
        return ctx, fmt.Errorf("missing or empty authorization header: %s", b.header)
    }
    token := auth[0] // Extract token from authorization header
    expectedTokens := b.authorizationValues()
    for _, expectedToken := range expectedTokens {
        if subtle.ConstantTimeCompare([]byte(expectedToken), []byte(token)) == 1 {
            return ctx, nil // Authentication successful, token is valid
        }
    }
    return ctx, fmt.Errorf("scheme or token does not match: %s", token) // Token is invalid
}

接收端的鉴权判定只有一条:token 值与静态预期值相等。它不校验 token 来自哪条传输、哪个对端——TLS 在攻击端终止后 metadata 可读,静态 token 因此可被原样重放。需要限定一个容易误引的细节:同一文件里的 RequireTransportSecurity()(bearertokenauth.go:36-39)恒为 true,但它属于该扩展作为 gRPC 客户端 PerRPCCredentials 的发送路径接口,不参与本实验中 Collector 接收端的鉴权;它表达的是“凭据应在 TLS 上发送”,而被重放的 token 确实走了一段 TLS——攻击者的 TLS。传输安全标志验证的是“有 TLS”,不是“TLS 终止在谁手里”。边界:此链仅适用于可重放的静态凭据;通道绑定、短效凭据、请求签名类方案不可外推。

**B1(env-mTLS 绕过)**是漏洞的第二个安全面:客户端通过环境变量提供完整的合法 CA + 客户端证书 + 私钥,期望 mTLS;但证书从未进入被劫持的握手。恶意服务端不请求客户端证书,单向 TLS 即完成,服务端观测 client_cert_present=false,客户端照常 EXPORT_OK。运维以为部署了 mTLS,身份层从未生效。

4.4 不可闭合链路即能力上界

**B2(篡改写入强制 mTLS 的 Collector)不闭合,这是本地动态得到的负结果,也是这个漏洞能力上界的精确刻画。**推理链的每一环都有观测:漏洞连接不携带客户端证书(B1 的 client_cert_present=false);被劫持连接的活动 TransportCredentials 未包含客户端证书配置,因此没有出示客户端证书、也没有完成任何基于私钥的签名认证,攻击者同样未获得可用于上游认证的私钥能力;因此当恶意代理向强制 mTLS 的 Collector 发起上游连接时,服务端在应用层之前就以 remote error: tls: certificate required 终止握手,Collector 没有该条日志。这个拒绝来自 Collector 的服务端 TLS 装配——YAML 的 client_ca_file 映射为 RequireAndVerifyClientCert:

// opentelemetry-collector@v0.136.0 → config/configtls/configtls.go:445-467(ServerConfig.LoadTLSConfig)
// LoadTLSConfig loads the TLS configuration.
func (c ServerConfig) LoadTLSConfig(_ context.Context) (*tls.Config, error) {
    tlsCfg, err := c.loadTLSConfig()
    if err != nil {
        return nil, fmt.Errorf("failed to load TLS config: %w", err)
    }
    if c.ClientCAFile != "" {
        reloader, err := newClientCAsReloader(c.ClientCAFile, &c)
        if err != nil {
            return nil, err
        }
        if c.ReloadClientCAFile {
            err = reloader.startWatching()
            if err != nil {
                return nil, err
            }
            tlsCfg.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { return reloader.getClientConfig(tlsCfg) }
        }
        tlsCfg.ClientCAs = reloader.certPool
        tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
    }
    return tlsCfg, nil
}

这正是身份边界的位置。

换句话说,漏洞给出的是“读取 / 丢弃 / 向弱认证后端写入”,不赋予攻击者被上游接受的客户端认证能力。攻击者若想闭合 B2,必须另行获得被 Collector 接受的客户端凭据——那是另一个漏洞或另一次入侵的工作,不属于本缺陷。实验中刻意没有用人工挂载合法客户端私钥的方式“补全”上游握手:那等于预先假定攻击者已经拿到了缺失的身份,是绕开而非闭合此链。

4.5 信任边界(trust boundary)重绘

把正常链路与漏洞链路并置,可以看清边界移动的方向:

图 7 正常链路的信任边界钉在 Collector 入口;漏洞发生后 TLS 终止点移到攻击者手中,边界回撤到受害者进程的名称解析与路由选择。

设计上,env TLS 声明的信任锚应该把边界钉在 Collector 入口——路径上的 DNS、代理、网格都无权解密。漏洞发生后,TLS 终止点变成攻击者可控,实际信任边界回撤到“受害者进程对 Collector 名称和路径做出选择的那一刻”:**凡影响名字解析或路由的组件——DNS 服务、CNI 与 service mesh sidecar、egress gateway、宿主路由、出站代理——都成了这条边界上的实际守门人。**环境变量契约的本意正是绕过这些中间人声明信任,漏洞把这项决策权还给了他们。

除主数据面外,这条链路上还有两个旁路观测面:gRPC metadata(authorization 等 Header,A4 的入口)与 TLS 握手状态(PeerCertificates,B1/B2 的判据)。第 6 章的检测特征全部落在这两个面与主数据面的流量对账上。

4.6 与公开利用链的差异

官方验证材料(GHSA 附带的 validation artifact,本实验未运行该附件)证明的是“私有 CA 未被采用 + 显式凭据路径成功”——配置被忽略这一层。公开技术文章基本复述公告。本地实验补全的是公告之后的部分:真实 MITM 拓扑下的窃取、持续伪造成功、protobuf 层篡改入库、Header 复用,以及强制 mTLS 的能力边界(B2 负结果)。同时如实记录实验与真实环境的距离:DNS 别名是劫持的建模而非真实 DNS 投毒;SSL_CERT_FILE 与预装 CA 是“系统受信”前提的建模而非企业代理根普及率测量。截至 2026-09-23 发文前复核,CISA KEV catalogVersion: 2026.09.22 未收录该 CVE,CISA ADP 记录仍为 Exploitation: none(时间戳 2026-09-17)——这只说明截至所述查询日期,所查的 CISA 来源未提供已确认在野利用的证据(CISA ADP 采用的 SSVC 判定对 none 的语义即“当时缺乏活动利用证据”),不是对在野利用状态的全局否定,也不支持推断“从未被利用”。

5. 边界之外:修复残留与“修复完整性”

5.1 修复本身

c65d435 对 client.go 的改动集中在一个 hunk——一行注释措辞更新,加两行新分支:

// opentelemetry-go@c65d435 → exporters/otlp/otlplog/otlploggrpc/client.go:103-114(newGRPCDialOptions 内)
    if cfg.serviceConfig.Value != "" {
        dialOpts = append(dialOpts, grpc.WithDefaultServiceConfig(cfg.serviceConfig.Value))
    }
-   // Prioritize GRPCCredentials over Insecure (passing both is an error).
+   // Prioritize configured credentials over Insecure (passing both is an error).
    switch {
    case cfg.gRPCCredentials.Value != nil:
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(cfg.gRPCCredentials.Value))
+   case cfg.tlsCfg.Value != nil:
+       dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(cfg.tlsCfg.Value)))
    case cfg.insecure.Value:
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))

插入位置在显式凭据与明文之间,测试到位(3.4 节的 mTLS 语义级回归)。v0.21.0 到 2026-09-21 检核的固定快照 94c37188(上游提交 2026-09-18)该分支无回归。修复版的正负对照都通过:直连攻击端点被拒(unknown authority,env CA 真正参与验证);env 证书直连强制 mTLS Collector 成功入库。就“合法 env TLS 未被消费”这一原始缺陷而言,修复是完整的。但由此形成的新优先级(显式凭据 > env TLS > insecure > 系统根)不能整体背书为“正确”:这一评价只覆盖来源一致的合法配置;当 scheme、insecure 与 env TLS 冲突在场时,v0.21.0 的实际取舍仍存在与 OTLP 规范不一致的形态——5.4 节 D-2 已动态确认的三类缺口正落在这一配置面——冲突配置下的优先级需按该节的证据级别单独评价。

5.2 残留 fail-open:加载失败仍然不失败

修复没有触碰 loadEnvTLS 的错误路径——该函数在 v0.21.0 与 v0.20.0 逐字节相同。回看 3.1 节装载侧的收尾分支:加载失败被包装为 failed to load TLS 后交给 otel.Handle(err),不返回错误、不阻断启动,tlsCfg 保持未设置,拨号照常落到系统根:

// opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:532-539(loadEnvTLS 错误路径,v0.21.0 同构)
        if err != nil {
            err = fmt.Errorf("failed to load TLS: %w", err)
            otel.Handle(err)
        } else if rootCAs != nil || certs != nil {
            s.Set = true
            s.Value = &tls.Config{RootCAs: rootCAs, Certificates: certs}
        }
        return s

D-1 实验在 v0.21.0 上对三个场景做了动态确认:

场景

注入错误

实测结果

坏路径

CA 路径不存在

stderr failed to load TLS: open …: no such file or directory + EXPORT_OK,攻击端收到日志

坏私钥

私有 CA 合法,客户端私钥非 PEM

stderr failed to load TLS: tls: failed to find any PEM data in key input + EXPORT_OK,攻击端收到日志

不匹配

私有 CA 合法,证书/私钥不匹配

stderr failed to load TLS: tls: private key does not match public key + EXPORT_OK,攻击端收到日志

第二个细节值得放大:后两个场景里根 CA 已经成功加载,随后客户端凭据加载失败,errors.Join 合并出错误后整个 setting 不 Set——已装进内存的私有 CA 被一并丢弃,最终连接只受攻击 CA 信任的端点。一个字节的坏私钥,废掉了整个信任声明。

图 8 加载失败只经 otel.Handle 输出一条弱可见的 stderr 日志,不返回错误、不阻断启动;tlsCfg 保持未设置,连接照常回落系统根并 EXPORT_OK。

这条信号的可见性需要精确表述(S-5 静态审计 + D-1 动态互证):默认处理器是 Go 标准库 log.Print,实际形态是 stderr 上一行带日期时间的文本(2026/09/21 06:00:12 failed to load TLS: …),无 severity、无组件名、无结构化字段;不阻断 exporter,也不会自动进入 OTel LoggerProvider。otel.Handle 的全局 ErrorHandler 通道、内部 logr 与日志导出器是三条独立管线——配置了 OTel 日志导出,不等于这条错误会到达任何后端。应用可以用 SetErrorHandler 把它桥接到别的系统,也可以显式装一个 noop 处理器让它完全无声。准确的定性是:默认弱可见、部署相关、可被完全静默化——采集进程 stderr 的运营者可见,其余情况容易遗漏。

攻击含义仍然受限:触发它需要配置损坏叠加第 4 章的网络与证书前提,攻击者并不比原始漏洞获得更多能力。但运维含义直接:升级到 v0.21.0 并不能把“配错证书”变成“显式失败”。部署验收若假定 fail-closed,会在这一条上落空。

**披露状态。**这条错误路径同样未作为问题向上游报告,但它与 §5.4 的候选缺口有一处关键差别:其语义是上游源码文档注释明示的既定行为——loadEnvTLS 的注释写明“路径无效或文件不合法时,错误仅经 OTel ErrorHandler 传递、不提供 TLS 配置”(该注释与函数体在 v0.20.0 与 v0.21.0 逐字节相同,2026-09-21 检核的固定快照仍在,仅措辞微调)。本文公布的是对这一已文档化契约的动态确认与后果评估,不涉及未公开的触发条件,因此不在 R-4 协调披露的冻结范围内;公开附件相应保留 n4-* 三组复现场景与 observed-n4 观测记录。

5.3 字段分裂是架构问题,不是笔误

cfg.tlsCfg 没有对应的公开编程选项——截至 2026-09-21 检核的固定快照(94c37188),全部导出选项里没有 WithTLSClientConfig 这类入口(trace/metric 导出器有)。修复分支 case cfg.tlsCfg.Value != nil 实际是纯环境变量分支:代码级调用方想让 TLS 生效,只能自己构造 tls.Config 再走 WithTLSCredentials。“两个字段、消费者只读一个”的分裂在修复后依然存在于类型系统里,只是拨号端现在两个都读了。这坐实了 3.3 节的判断:这是架构层的字段设计问题,不是一个被顺手修掉的笔误。

5.4 修复没有重构的:endpoint 来源拼接与不完整 mTLS

**披露状态。**本节三类缺口与第 7 章的跨 SDK 候选路径,截至成文(2026-09-23)均未向上游报告:没有对应的 issue、advisory,也没有维护者确认。按协调披露惯例(执行边界登记于研究计划 R-4),本文对它们的公开粒度划在问题类别、影响方向与证据级别:允许给出组件/模块名与问题形态(第 7 章对 Java 可选 sender 的描述即按此层发布——用于说明影响边界,单独凭它不足以直接复现);不给出精确源码定位、可直接落地的配置组合、逐步触发细节、场景命令与逐条观测数据——凡能与正文已公开的代码、变量名拼合出可复现配置组合的条件表述,同样按组合条件对待、不随文发布——这些保留在研究仓库的私有实验记录中,待完成上游报告(R-4)后再行补充。随文公开附件已按同一白名单剔除这三类缺口的场景入口、客户端选项模式与结果记录——R-4 完成前,附件与正文都不含这些材料;届时如需补充公开,将按新轮次另行登记观测,不以事后重跑回填原始轮次。

S-2 静态矩阵与 D-2 动态实验确认了三类与原 CVE 不同路径的配置缺口(均在 v0.21.0 上动态验证存在):

**① 修复后仍存在的信任降级方向(配置来源类缺口)。**D-2 已在 v0.21.0 上动态确认:存在调用方未显式请求明文、最终连接却被降级为明文的配置形态,缺口落在“多个配置来源之间缺少绑定”这一类别。其涉及的来源组合与代码位置按上文披露状态保留在本地研究记录中。

**② Option 顺序决定传输。**同一配置意图经不同顺序的编程选项表达时,最终传输语义由最后写入的选项决定(D-2 动态确认)。这一格包含调用方显式请求明文的选项,按最后写入者优先是 setting 机制的既定语义,与规范“scheme 优先于 insecure”的要求不一致,记录为优先级/规范一致性问题,不单独定性为漏洞。

**③ 错误配置被无日志忽略(错误处理类缺口)。**D-2 已在 v0.21.0 上动态确认:存在装载层完全不产生信号的错误配置形态——连 5.2 节那条弱可见的 stderr 都没有——连接静默退化,错误配置应有的告警完全缺席。这是 fail-open 家族的诊断缺口;精确的配置形态按上文披露状态保留在本地研究记录中。

三项都不改变“合法完整的 env TLS 材料在 v0.21.0 已被正确消费”的结论,但它们共同说明:修复解决的是“装载后不消费”这一格,配置面的来源绑定与错误语义没有被重构。

6. 检测、缓解与修复

6.1 资产面:先找对版本号

资产盘点最常见的误区在这里:日志导出器是独立版本的 Go 模块,主模块 go.opentelemetry.io/otel 的版本号对它没有任何指示作用——v1.x 的主模块完全可以搭配 v0.5.0–v0.20.0 区间内任意一个 otlploggrpc。正确的检索对象是交付二进制的 Go build info、SBOM,以及源码侧 go.mod/go.sum 中的 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc,命中区间 v0.5.0–v0.20.0(资产处置可沿用官方 < v0.21.0 口径)。优先查实际交付物,再回溯构建源:

go version -m /path/to/service-binary | grep otlploggrpc  # 二进制保留 Go build info 时,给出真正打包版本
go list -m all | grep otlploggrpc                       # 可进入应用模块时,查最终解析版本
grep otlploggrpc go.sum                                 # 只是线索:go.sum 出现不等于一定打进交付物

6.2 配置面:从命中版本收敛到实际暴露

命中版本只是资产事实,不等于攻击条件成立。可以用下列问题逐层收敛:

判定层

需核对的问题

对结论的影响

触发配置

是否使用六个 env TLS 变量之一(signal/global 的 CA、client cert、client key)

否:不命中这条 env-only 漏洞路径

安全旁路

是否显式使用 WithTLSCredentials,或传入已正确加固的 WithGRPCConn

是:内部错误 switch 被凭据分支或整体连接旁路覆盖

路径暴露

endpoint 是未改动的 localhost:4317,还是远程 DNS 名、跨集群地址、网格/代理出站

回环默认使远程劫持通常不可达;后三者才进入 P1 需重点核对的区域

证书前提

攻击路径上的实体能否获得 endpoint 名称匹配、且被受害系统根信任的证书与私钥

分三种结局:满足 → 无声 MITM 的证书侧前提成立;不满足、且合法 Collector 证书本就链到系统根(如公共 CA 签发或系统预装其根)→ 导出正常,env CA 同样被忽略但无任何可见症状;不满足、且 Collector 的 CA 仅经 env 提供 → C1 式可见导出故障。后两种都排除了无声 MITM,但不改变缺陷存在

数据与凭据

日志是否含身份、业务、错误详情;是否使用静态 Header

决定 A1 的信息价值与 A4 能否闭合

上游认证

真实 Collector 是仅服务端 TLS、静态 Header,还是强制 mTLS

决定攻击是止于窃取/丢弃,还能继续篡改入库

实务上最值得优先处置的组合是:受影响二进制 + env 私有 CA/mTLS + 远程名称端点 + 系统根中存在可终止该名称的代理/企业 CA。再叠加可重放静态 Header 时,能力从读取/丢弃扩展到冒名写入;真实后端强制 mTLS 时,写入腿被截断,但第一腿的窃取与丢弃仍然成立。

6.3 检测特征(每条对应第 4 章的动态观测)

  1. **强制 mTLS Collector 上的握手失败与身份流量消失。**B1 的 client_cert_present=false 是恶意实验端点的观测,不应直接翻译成“真实 Collector 会建立一条无证书会话”。强制 mTLS 的真实 Collector 会像 B2 一样在 TLS 层拒绝它。生产检测应关注 certificate required/握手失败,以及原本稳定的客户端身份或入库流量突然中断;不要把攻击端插桩字段当成现成的 Collector 告警字段。
  2. **导出量与入库量的无声差值。**A2/D-3 的 20/20/0 形态:应用侧持续成功、Collector 侧零入库、无重试告警。只看 exporter 返值无法发现,需要把应用提交计数/业务事件数与 Collector 接收、存储入库计数做时间窗对账。
  3. **凭据使用上下文漂移。**真实 Collector 看不到之前的恶意 TLS 终止点,但可以审计“同一静态 token 突然来自新网络身份/新客户端,并伴随非常规日志修改”。这是 A4 的侧面线索,需后端能把 token、TLS/网络对端与数据异常做关联;单独命中不足以证明利用。
  4. **客户端 stderr 的 failed to load TLS。**D-1 特征:单行文本,默认形态无结构。前提是进程 stderr 被采集、且自定义 ErrorHandler 没有把它吞掉——按 5.2 节的审计,“配置了 OTel”不构成这个前提。

以上特征在真实 SOC 环境的信噪比未做测量,应作为检测方向而非即用规则。

6.4 修复与缓解

  1. 升级 v0.21.0+(原 CVE 的根本修复),并重新构建、部署实际打包该模块的二进制——库漏洞的修复以二进制重建为完成点。
  2. 短期显式 WithTLSCredentials(官方缓解口径):直接走 C 分支,绕开整个 env 断点。效力取决于传入凭据的内容:必须用预期的私有 CA(需要 mTLS 时含客户端证书与私钥)构造 tls.Config 再生成 credentials——若显式传入的仍是系统根凭据(NewTLS(nil) 一类),只是绕过了出错分支,并未恢复私有 CA/mTLS 的信任约束。落地时应按第 3 条在真实握手上做正负对照:预期 CA 必须成功,仅被系统根信任的同名对照证书必须被拒。局限是需要改代码重建,且不解决 5.4 节的配置面问题。
  3. 把 env TLS 的语义验收放到真实握手层:仅在隔离的 staging/实验环境做三个断言——合法私有 CA 必须成功;同名称但只被系统根信任的对照证书必须被拒绝;证书路径/PEM 损坏必须让部署门禁失败。当前 v0.21.0 对第三项的库内行为仍是 fail-open(5.2 节),因此门禁需额外把 stderr 的 failed to load TLS 视为失败;自定义 ErrorHandler 的应用还要证明该错误不会被 noop 吞掉。
  4. Collector 侧强制 mTLS(client_ca_file → RequireAndVerifyClientCert):B2 的防御读法——它不能阻止窃取与丢弃(那些发生在客户端到攻击者的第一腿),但使本漏洞的上游写入/篡改入库能力不闭合。因此它截断的是伪造后端记录这一支,不会把总体损害简化为“只剩机密性”——A2 的丢弃仍是遥测完整性/可见性损害。

7. 结语:同源模式的可推广性

“装载后未消费”不是 OpenTelemetry-Go 独有的偶然。对 Java、Python、Node.js、Rust 与 Collector 的固定源码做同一套“读取 → 存放 → 建连消费”追踪(S-4),得到三层结构的结果。

同源静态候选路径。Java SDK 默认的 OkHttp sender 把 env TLS 连续传入最终 transport(TlsConfigHelper → ImmutableGrpcSenderConfig → sslSocketFactory),没有字段断裂;但可选的 opentelemetry-exporter-sender-grpc-managed-channel sender 在自动配置、且调用方未显式注入 ManagedChannel 时,会构建一个“最小 fallback channel”:只消费 endpoint、端口与明文判定,已构造进发送器配置的 SSL context/trust manager 一律不被使用——该模块的源码注释明确说明其余设置被有意忽略("intentionally ignored")。这与 Go 漏洞是同一模式,但只覆盖非默认 sender 的 fallback 路径,因此称为候选而非已确认实例。当前证据是固定源码的静态可达性,未做动态复现,也尚未向上游报告或获得安全定性(披露状态见 5.4 节)。按该披露边界,正文对候选路径仅保留可选 sender 组件名与 fallback 激活面的一般定性条件;精确源码定位、可直接落地的配置组合与逐步触发细节不随文发布,完整“读取 → 存放 → 消费”链审计保留在本地 S-4 报告中。不能写成“Java 部署普遍受影响”或新的已确认漏洞。

**正确架构的反例。**Python 与 Node.js 的默认 gRPC 路径都把 env TLS 折叠成一个最终 credentials 对象/closure 并直接交给 channel;Collector 的 YAML TLS 从同一个 ClientConfig.TLS 字段直达 LoadTLSConfig() → credentials.NewTLS;Rust 固定快照根本不支持证书类 OTLP env。字段分裂不是多 SDK 的必然设计结果——它是可以避免的具体选择。

**相邻的 loader 缺口(静态)。**即便汇入单一字段,安全配置仍可能在装载条件与错误语义处失效。Python 存在条件性未装载的候选缺口:mTLS 相关材料在特定配置形态下根本不会被读取,链路退回默认系统凭据——与 Go 的“装载后未消费”方向相反。Node.js 的证书材料读取失败只产生一条诊断告警并返回空值,按仓库测试预期退化到默认 TLS 凭据(fail-open 家族)。

两者均无本地动态 MITM 证据,列为待验证;精确源码定位与触发/装载条件同样按 5.4 节披露状态保留在本地审计中,不随文发布。

落到方法论上,对任何“env/配置文件 → 安全设置”的路径,应该同时追问三个问题:**这个字段有几个写入者、几个读者?最终 transport 消费的是哪一个?输入缺失或损坏时是 fail-closed 还是回退默认信任?**再加上 3.4 节的测试教训:断言要落在语义级(真实握手、真实消费方),配置对象层的绿灯证明不了配置被用过。

最后用四个问题收口:

  • 为什么成立:装载与消费被拆进两个字段后,消费者在漏洞版本里缺席(“翻译漏步”的成因解释是架构对照推断,见 3.2),而测试盲区恰好停在配置对象层。
  • 何时可用:攻击者同时握有连接路径控制与名称匹配的系统受信证书;目标部署不在回环端点上通常即排除普通远程劫持,但具备主机本地或网络命名空间能力的攻击者仍可能控制回环路径(4.1 节),需另行评估——这组前提把漏洞定位在内网中间人场景,而非公网通用武器。
  • 影响多大:日志遥测的读取、丢弃与向弱认证后端的写入;不含客户端身份窃取(强制 mTLS 上游不可写入,动态负结果);主机执行、提权与应用可用性影响未获证实——与 CVSS VA:N 的评估口径一致,但 VA:N 是评分结论而非“绝无主机侧影响”的证明。
  • 如何阻断:升级补上消费分支,Collector 侧 mTLS 封死写入腿,fail-closed 验收堵住残留的回退路径。

一个不报错的降级,最终要靠让它在每个该报错的地方报错来终结。

附录:文章边界与适用条件

  • 全部攻击链结论以“攻击者满足公告前提(网络位置 + 系统受信名称匹配证书)”为条件。早期实验用 SSL_CERT_FILE 注入攻击 CA,D-4 用派生镜像预装代理 CA;两者都是对证书前提的实验建模,不构成“公网可利用”或真实企业普及率证据。
  • Header 链(A4)仅适用于可重放的静态凭据;通道绑定、短效凭据、请求签名不可外推。
  • 未主张:主机 RCE/提权、Collector 管理权限、应用可用性影响(与 CVSS VA:N 一致)、在野利用。截至成文(2026-09-23),CISA KEV catalogVersion: 2026.09.22 未收录该 CVE,CISA ADP 记录的 Exploitation: none 时间戳为 2026-09-17;两者只支持“截至所述查询日期,所查 CISA 来源未提供已确认在野利用的证据”(SSVC 对 none 的判定语义即当时缺乏活动利用证据),不构成对利用状态的全局否定。
  • 版本结论限定 Go 模块 otlploggrpc(v0.5.0–v0.20.0,官方口径 < v0.21.0);trace/metric gRPC 与 log HTTP 导出器经源码对照确认不在此缺陷形态内(对照版本:v1.38.0 时代 trace/metric、v0.20.0 log HTTP)。
  • 跨 SDK 结论(第七章)为固定 commit 的静态审计:Java 509182e4、Python 5321c606、Node.js bbaec146、Rust 1d663f8、Collector v0.136.0;其运行时影响未经动态验证。
  • D-3 的持续性结论限于直接同步 Export 路径;各部署能否从外部流量差、stderr 或自定义监控发现缺口,取决于处理器与监控配置,未逐配置验证。

复现材料:随文公开附件(披露过滤)与私有研究实验室

文中全部动态观测出自同一个隔离 Docker Compose 实验室。随文发布的是公开安全附件 attachment/——研究实验室按白名单过滤后的副本:编排、实验客户端、恶意端与证书流程完整,已发布结论的全部场景可逐条复跑;涉及尚未向上游报告的候选问题(§5.4 三类缺口与 §7 候选路径,R-4 跟踪)的场景入口、客户端选项模式与结果记录已被剔除。完整研究实验室不随文发布,待 R-4 协调披露完成并取得上游受理记录后再行评估;届时补充公开的观测将作为新轮次另行登记,不以事后重跑冒充原始轮次。

附件内:编排定义在 attachment/docker-compose.yml(lab 网络保持 internal: true,不发布宿主端口);场景入口统一是 attachment/run-scenario.sh;实验客户端与恶意端实现在 attachment/main.go 与 emit_v020.inc/emit_v021.inc;证书由 certgen 服务一次性生成在实验卷内。标准入口(在附件根目录执行):

./run-scenario.sh prepare     # 重建网络,构建 v0.20.0/v0.21.0 及 D-4 派生镜像并启动全部服务
./run-scenario.sh <场景>      # 按下表选择子命令
./run-scenario.sh evidence    # 汇出恶意端全部 JSONL 证据
./run-scenario.sh clean       # 清理环境

注意 prepare 与 clean 都会执行 docker compose down -v 删除实验卷,需要保留的观测应先另行保存。

镜像 digest 与证据链局限。D-1~D-4 轮次的镜像 digest 逐轮固化于运行清单(JSONL run 头与清单交叉核对一致,D-4 另与本地现存镜像核对);基础轮(A1–A4、B1/B2、C1 与修复对照)的 digest 未记录、不可追溯——该轮早于 run 头约定生成,镜像随后续 prepare 重建而覆盖,其观测的版本支撑是分层的:七条 JSONL 记录中,A1(兼 A2 单次形态)、C1 与两条修复对照共四条自带 exporter_module 字段(v0.20.0/v0.21.0);A3、A4、B1 三条没有该字段,其客户端版本依赖场景脚本中子命令到客户端镜像的固定对应(forward/header/mtls-gap 均运行 victim-vulnerable)与运行清单的场景登记,另由文件落盘时间、同构 Compose 文件与同一证书生成流程关联。这是已登记的证据链局限,不以补跑回填原始轮次。同样需要准确定性的是基础轮证据的形态:observed-2026-09-21.jsonl 是七条结构化实验记录(场景、exporter_module、client_result、日志正文、证书/上游结果等摘要字段),不是原始 stdout/stderr 或逐条带时间戳的事件流;基础轮的原始输出——一次性客户端容器的完整输出、恶意端 /evidence 卷内的逐事件 JSONL、当轮 Collector 日志——已随后续 prepare 的 down -v 与容器清理删除,均已不可再取得。对照之下,D-1/D-3/D-4 的结果文件含 run 头(逐轮 digest)与逐条时间戳,D-1 记录还保存了当时的客户端 stdout/stderr 原文;今日重跑各场景只能产生新轮次的观测,不回填也不替代基础轮记录。宿主与工具版本、逐轮 digest 及场景—时间对应的公开登记见 attachment/results/manifest-public-2026-09-23.md。

文章编号与场景子命令的对照。表中“人工核验判据”是读者复核用的预期观测,不是脚本内建断言:只有 d3-ack-discard 与 d4-enterprise-proxy 在脚本内做计数断言(不符即非零退出);capture、forward、header、mtls-gap、fixed-mtls、三项 n4-* 与 d2-tls-over-insecure 只运行客户端并打印相关容器日志,脚本退出码仅反映客户端命令本身(emit 在 EXPORT_FAILED 时以退出码 2 结束);config-failure 与 fixed-pinning 仅以“命令失败”为门槛,不校验失败原因。逐项核验的通用入口:客户端结论看子命令输出中的 EXPORT_OK/EXPORT_FAILED 行与退出码;恶意端与上游结论看子命令紧随打印的容器日志中的 LAB_EVENT JSON 行,或 ./run-scenario.sh evidence 导出的 /evidence/*.jsonl(事件字段:bodies、authorization、client_cert_present、mutation_applied、upstream_result)。

文章位置

编号

子命令

结果文件

人工核验判据(含核验方法)

§4.3

A1(兼 A2 单次形态)

capture

attachment/results/observed-2026-09-21.jsonl

客户端输出 EXPORT_OK;attacker-capture 的 LAB_EVENT/capture.jsonl 事件含日志正文、client_cert_present=false、upstream_result=not_forwarded

§4.3

A2 持续性(D-3)

d3-ack-discard

attachment/results/observed-d3-2026-09-21.jsonl

脚本内建断言:D3_COUNTS 须为客户端 20 / 恶意端 20 / 真实 Collector 0(约 95 秒窗口),否则非零退出;D3_VERDICT=continuous_ack_discard_confirmed 供复核

§4.3

A3

forward

attachment/results/observed-2026-09-21.jsonl

客户端 EXPORT_OK;attacker-forward 事件 mutation_applied=true、upstream_result=success;real-ca 容器日志出现 [tampered-by-lab-proxy] 前缀正文

§4.3

A4

header

attachment/results/observed-2026-09-21.jsonl

客户端 EXPORT_OK;attacker-token 事件 authorization 含 Bearer lab-static-token、upstream_result=success;real-token 日志出现改写后正文

§4.3/§4.4

B1 + B2

mtls-gap

attachment/results/observed-2026-09-21.jsonl

客户端 EXPORT_OK;attacker-mtls 事件 client_cert_present=false 且 upstream_result 为 remote error: tls: certificate required;real-mtls 日志无该 marker

§4.3

C1

config-failure

attachment/results/observed-2026-09-21.jsonl

脚本门槛仅为命令失败,需另核对失败原因:客户端 EXPORT_FAILED 行包含 x509: certificate signed by unknown authority,排除其他原因的失败

§5.1

修复负对照

fixed-pinning

attachment/results/observed-2026-09-21.jsonl

脚本门槛仅为命令失败,需另核对失败原因为 unknown authority——证明拒绝来自 env 私有 CA 的验证,而非其他连接问题

§5.1

修复正对照

fixed-mtls

attachment/results/observed-2026-09-21.jsonl

客户端 EXPORT_OK;real-mtls 容器日志出现 LAB_FIXED_MTLS_OK_9c5x

§5.2

D-1

n4-bad-path / n4-bad-key / n4-mismatch

attachment/results/observed-n4-2026-09-21.jsonl

客户端 stderr 含 failed to load TLS(三场景错误类型分别为路径不存在、私钥非 PEM、私钥与证书不匹配)且输出 EXPORT_OK;attacker-capture 收到对应 marker

§3.1

优先级表 T=1, I=1 行

d2-tls-over-insecure

attachment/results/observed-d2-public-2026-09-21.jsonl(过滤提取件:run 头 + 两条已发布记录)

两次运行均 EXPORT_OK:v0.20.0 的 marker 由 attacker-plain(明文端点)收到、attacker-tls 无;v0.21.0 反转为 attacker-tls(TLS 端点)收到、attacker-plain 无

§4.1

D-4

d4-enterprise-proxy

attachment/results/observed-d4-2026-09-21.jsonl

脚本内建断言:普通 v0.20.0 与企业 v0.21.0 必须失败、企业 v0.20.0 必须成功,且 D4_COUNTS 须为 proxy_received=1、unexpected_control_events=0(代理收到计数 0/1/0)

复现边界与正文一致:DNS 别名与 SSL_CERT_FILE(或 D-4 预装 CA)是对攻击前提的建模,不是真实劫持或企业信任库普及率的证据;实验室不得连接附件网络之外的任何目标。两点补充说明:其一,子命令 fixed-pinning 是实验场景的历史命名,指“v0.21.0 以 env 声明的私有 CA 信任锚拒绝攻击 CA”这一负对照,不代表严格的证书固定(certificate/SPKI pinning);其二,§5.4 的 D-2 三类缺口与 §7 候选路径因尚未完成上游协调披露(见 5.4 节披露边界),其可直接复现的触发组合、场景子命令与结果记录不在公开附件中,正文仅保留问题类别、影响方向与证据级别(§7 Java 候选路径另按披露边界保留组件名与激活面的一般定性条件),完整材料保留在私有研究实验室。

主要引用

对象

固定位置

漏洞装载侧

opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/config.go:60-77,106-142,497-541,601-629(env 变量表、newConfig、loadEnvTLS、setting/Resolve)

漏洞消费侧

opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/client.go:52-86,96-117(newClient 与 WithGRPCConn 旁路、newGRPCDialOptions switch :107-117)

建连生命周期与重试语义

opentelemetry-go@exporters/otlp/otlplog/otlploggrpc/v0.20.0 → exporters/otlp/otlplog/otlploggrpc/exporter.go:36-47、exporters/otlp/otlplog/otlploggrpc/client.go:53-86,142-190,258-285、exporters/otlp/otlplog/otlploggrpc/config.go:31,137-138、exporters/otlp/otlplog/otlploggrpc/internal/retry/retry.go:20-26;grpc-go@v1.81.1 → clientconn.go:159-162(NewClient 不做 I/O,RPC 触发建连)

OTLP 日志数据面与响应语义

opentelemetry-proto@f904c61 → opentelemetry/proto/collector/logs/v1/logs_service.proto:30-60、opentelemetry/proto/logs/v1/logs.proto:48-213

引入提交(前身为空壳)

opentelemetry-go@d99c76f~1 → exporters/otlp/otlplog/otlploggrpc/client.go:6-14 对照 @d99c76f;线上锚点:commit d99c76f、PR #5523

修复提交与回归测试

opentelemetry-go@c65d435 → exporters/otlp/otlplog/otlploggrpc/client.go:103-114(diff hunk)、exporters/otlp/otlplog/otlploggrpc/client_test.go:388-431;发布于模块 tag exporters/otlp/otlplog/otlploggrpc/v0.21.0;线上锚点:commit c65d435

修复后残留 fail-open

opentelemetry-go@94c37188(2026-09-21 固定快照) → exporters/otlp/otlplog/otlploggrpc/config.go:505-545(otel.Handle 在 :535)、exporters/otlp/otlplog/otlploggrpc/client.go:109-117;v0.20.0/v0.21.0 同构(错误路径 :532-539)

trace gRPC 正确对照

opentelemetry-go@v1.38.0 → exporters/otlp/otlptrace/otlptracegrpc/internal/otlpconfig/options.go:130-140,310-318、envconfig.go:78-90

log HTTP 正确对照

opentelemetry-go@exporters/otlp/otlplog/otlploghttp/v0.20.0 → exporters/otlp/otlplog/otlploghttp/client.go:63-87

NewTLS(nil) 语义

grpc-go@v1.81.1 → credentials/tls.go:221-257(线上源码);Go 官方文档(go1.25.0,与实验基镜像一致):crypto/tls Config.RootCAs、crypto/x509 VerifyOptions.Roots、crypto/x509 SystemCertPool(SSL_CERT_FILE/SSL_CERT_DIR)——RootCAs/Roots 为 nil 时使用宿主系统根(或平台校验器)

OTLP 导出器规范

opentelemetry-specification@148f276 → specification/protocol/exporter.md:31-53(固定 commit 线上版);现行在线版 opentelemetry.io/docs/specs/otlp

Collector mTLS 语义

opentelemetry-collector@v0.136.0 → config/configtls/configtls.go:445-467

A4 上游 Bearer 鉴权

opentelemetry-collector-contrib@v0.136.0 → extension/bearertokenauthextension/bearertokenauth.go:222-239(服务端 Authenticate 从 metadata 取 token 并与静态预期值做常数时间比较,只比较值);同文件 :31-39 为扩展作为 gRPC 客户端 PerRPCCredentials 的发送路径(GetRequestMetadata/RequireTransportSecurity),不参与接收端鉴权

跨 SDK 静态对照(§7)

固定源码快照:opentelemetry-java@509182e4、opentelemetry-python@5321c606、opentelemetry-js@bbaec146(commit 解析与登记见 repos/README.md)。候选路径与相邻缺口的精确源码定位、可直接落地的触发组合按 §5.4 披露边界保留在本地 S-4 审计(docs/research/s4-cross-sdk-env-tls-audit.md),不随文发布

公告与编号

GHSA-w34q-cm8f-9c5x(2026-09-02 发布、2026-09-17 更新:< v0.21.0、CVSS v4.0 6.3、CWE-295/923,2026-09-23 复核一致);CVE-2026-81871(另见 NVD);公开讨论 Issue #6661、PR #6882、PR #7087;CISA KEV 官方 JSON(主站拒绝时用官方镜像),2026-09-23 查询 catalogVersion: 2026.09.22 未收录、CISA ADP Exploitation: none(2026-09-17)——结论与边界见 §4.6

动态观测

隔离 Docker 实验室 6 条攻击链路(含 B2 负结果)+ C1 故障对照,以及 D-1/D-3/D-4 与 D-2 已发布部分(2026-09-21)。结构化实验记录随公开附件发布于 attachment/results/*.jsonl(D-2 为过滤提取件):基础轮文件为七条结果摘要、原始输出已不可再取得,D-1/D-3/D-4 含 run 头与逐轮 digest(D-1 另存客户端 stdout/stderr 原文),证据链边界见附录“镜像 digest 与证据链局限”;宿主与工具版本、逐轮镜像 digest 及场景—时间对应见 attachment/results/manifest-public-2026-09-23.md