欺骗Go的X.509证书验证
摘要
这篇博客文章探讨了Go的X.509证书验证中的一个细微错误,其中两个看似相同的CA证书由于PEM编码中空白字符处理的差异可能导致不同的结果。
暂无内容
查看缓存全文
缓存时间: 2026/06/08 21:19
# 愚弄 Go 的 X.509 证书验证
来源:https://danielmangum.com/posts/fooling-go-x509-certificate-verification/
以下是两X\.509 (https://en.wikipedia.org/wiki/X.509)证书。第一个是证书颁发机构(CA)根证书,第二个是由 CA 私钥签名的叶证书。
`ca.crt.pem`
``
-----BEGIN CERTIFICATE-----
MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw
EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5
NDc0NlowEjEQMA4GA1UEAxMHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt
UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP
EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG
GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ==
-----END CERTIFICATE-----
``
`leaf.crt.pem`
``
-----BEGIN CERTIFICATE-----
MIIBHjCBxAIULE3hvnYxU91g9c9H3+uGCSqXi4MwCgYIKoZIzj0EAwIwEjEQMA4G
A1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5NDc0Nlow
DzENMAsGA1UEAwwEbGVhZjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKDZ21Yh
+1AQp1TrxrS8FquIVEHrFRSXncX9xl5vVhZFqvblzTp2Tg7TER5x7rHG1TIqQL1z
xDX4TB+nZOWkyAcwCgYIKoZIzj0EAwIDSQAwRgIhAMeo5t2d1RWL/SB0E+mvvIZP
jFT0wDWX1Bm26MtxRcf9AiEApG96fs70WF1JliFgzkTiNvbG7Gj4SvErZ9nNX/Lr
PnA=
-----END CERTIFICATE-----
``
如果下载这些证书,你可以直观地看到后者将前者作为其颁发者。如果你使用 `openssl` 这样的工具来验证叶证书是否由根证书的私钥签名,你会发现确实如此。
> 当然,除非你在阅读这篇博客时是 2126 年,或者你修改了机器上的系统时间。如果是前者,我对人类还在使用 `openssl` 感到极度失望。
``
openssl verify -CAfile ca.crt.pem leaf.crt.pem
``
现在,如果你想编写一个 Go 程序来验证这个信任链,可能会像下面这样。
`main.go`
``
package main
import (
"encoding/pem"
"crypto/x509"
"fmt"
"os"
"time"
)
func main() {
b, err := os.ReadFile("ca.crt.pem")
if err != nil {
panic(err)
}
block, _ := pem.Decode(b)
ca, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic(err)
}
b, err = os.ReadFile("leaf.crt.pem")
if err != nil {
panic(err)
}
block, _ = pem.Decode(b)
lc, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic(err)
}
roots := x509.NewCertPool()
roots.AddCert(ca)
opts := x509.VerifyOptions{
Roots: roots,
CurrentTime: time.Now(),
}
if _, err := lc.Verify(opts); err != nil {
panic(err)
}
fmt.Println("证书验证成功。")
}
``
但如果你运行该程序,可能会惊讶地看到以下内容。
``
panic: x509: 证书由未知颁发机构签名
``
如果你改用这个 CA 证书,你会看到预期的输出。
`ca.verifies.crt.pem`
``
-----BEGIN CERTIFICATE-----
MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw
EjEQMA4GA1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5
NDc0NlowEjEQMA4GA1UEAwwHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt
UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP
EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG
GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ==
-----END CERTIFICATE-----
``
``
证书验证成功。
``
乍一看这些证书似乎完全相同。你可以使用 `openssl` 查看两个证书的内容,会得到相同的输出。
``
openssl x509 -in ca.crt.pem -noout -text
``
``
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
75:ae:14:be:51:73:c1:01:0e:fd:f0:f4:7f:88:40:9e:3f:b2:74:f6
Signature Algorithm: ecdsa-with-SHA256
Issuer: CN = Root CA
Validity
Not Before: Feb 27 19:47:46 2026 GMT
Not After : Feb 3 19:47:46 2126 GMT
Subject: CN = Root CA
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:a2:f9:04:1f:5a:69:0d:93:b4:d8:14:ca:61:2c:
6b:ff:ac:d9:99:53:5d:59:65:d0:dd:b6:28:a5:b1:
87:43:7f:25:d8:07:72:3a:ca:7f:3a:91:95:5a:ed:
50:dd:86:5d:db:ec:74:b3:aa:d7:94:f1:06:af:7b:
5a:f1:34:ee:52
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Subject Key Identifier:
C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24
X509v3 Authority Key Identifier:
C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24
X509v3 Basic Constraints: critical
CA:TRUE
Signature Algorithm: ecdsa-with-SHA256
Signature Value:
30:44:02:20:7a:d2:4e:4f:ca:24:70:23:9f:73:02:fd:e8:2f:
7f:13:0c:d4:da:46:18:4c:cc:07:b1:07:f9:55:d6:cf:b4:5f:
02:20:32:87:0c:b8:13:d8:5c:f3:fa:ac:4c:8f:b8:88:a7:f6:
a3:fc:cc:46:ee:47:d8:63:49:38:ba:8e:9e:08:25:6d
``
然而,如果你比较证书的字节,你会发现一个非常细微的差别;确切地说,是两个字节。
``
diff <(openssl x509 -in ca.crt.pem -outform der | xxd) <(openssl x509 -in ca.verifies.crt.pem -outform der | xxd)
``
``
4c4
< 00000030: 1231 1030 0e06 0355 0403 1307 526f 6f74 .1.0...U....Root
---
> 00000030: 1231 1030 0e06 0355 0403 0c07 526f 6f74 .1.0...U....Root
8c8
< 00000070: 1307 526f 6f74 2043 4130 5930 1306 072a ..Root CA0Y0...*
---
> 00000070: 0c07 526f 6f74 2043 4130 5930 1306 072a ..Root CA0Y0...*
``
在两处位置中,导致 Go 程序验证失败的证书(`ca.crt.pem`)中都包含一个 `0x13` 字节,而通过验证的证书(`ca.verifies.crt.pem`)则是 `0x0c` 字节。如果你熟悉 X.509 证书,会知道它们使用抽象语法表示法一(ASN.1)(https://en.wikipedia.org/wiki/ASN.1) 定义,并使用区分编码规则(DER)(https://en.wikipedia.org/wiki/X.690#DER_encoding) 进行二进制编码。它们通常以 Base64 (https://en.wikipedia.org/wiki/Base64) 编码后,作为隐私增强邮件(PEM)(https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail) 文本文件存储和传输(你已经在本文中看到过了)。
ASN.1 规范定义了一组数据类型,每种类型都有一个关联的标签(非负整数标识符),在使用 DER 编码时,该标签位于长度和值之前(有关更多信息,请参阅 Let's Encrypt (https://letsencrypt.org/) 的这篇文章 (https://letsencrypt.org/docs/a-warm-welcome-to-asn1-and-der/))。可以使用 `openssl` 再次查看成功验证的证书中不同字段的数据类型。
``
openssl asn1parse -in ca.verifies.crt.pem
``
``
0:d=0 hl=4 l= 378 cons: SEQUENCE
4:d=1 hl=4 l= 289 cons: SEQUENCE
8:d=2 hl=2 l= 3 cons: cont [ 0 ]
10:d=3 hl=2 l= 1 prim: INTEGER :02
13:d=2 hl=2 l= 20 prim: INTEGER :75AE14BE5173C1010EFDF0F47F88409E3FB274F6
35:d=2 hl=2 l= 10 cons: SEQUENCE
37:d=3 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256
47:d=2 hl=2 l= 18 cons: SEQUENCE
49:d=3 hl=2 l= 16 cons: SET
51:d=4 hl=2 l= 14 cons: SEQUENCE
53:d=5 hl=2 l= 3 prim: OBJECT :commonName
58:d=5 hl=2 l= 7 prim: UTF8STRING :Root CA
67:d=2 hl=2 l= 32 cons: SEQUENCE
69:d=3 hl=2 l= 13 prim: UTCTIME :260227194746Z
84:d=3 hl=2 l= 15 prim: GENERALIZEDTIME :21260203194746Z
101:d=2 hl=2 l= 18 cons: SEQUENCE
103:d=3 hl=2 l= 16 cons: SET
105:d=4 hl=2 l= 14 cons: SEQUENCE
107:d=5 hl=2 l= 3 prim: OBJECT :commonName
112:d=5 hl=2 l= 7 prim: UTF8STRING :Root CA
121:d=2 hl=2 l= 89 cons: SEQUENCE
123:d=3 hl=2 l= 19 cons: SEQUENCE
125:d=4 hl=2 l= 7 prim: OBJECT :id-ecPublicKey
134:d=4 hl=2 l= 8 prim: OBJECT :prime256v1
144:d=3 hl=2 l= 66 prim: BIT STRING
212:d=2 hl=2 l= 83 cons: cont [ 3 ]
214:d=3 hl=2 l= 81 cons: SEQUENCE
216:d=4 hl=2 l= 29 cons: SEQUENCE
218:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Subject Key Identifier
223:d=5 hl=2 l= 22 prim: OCTET STRING [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524
247:d=4 hl=2 l= 31 cons: SEQUENCE
249:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Authority Key Identifier
254:d=5 hl=2 l= 24 prim: OCTET STRING [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524
280:d=4 hl=2 l= 15 cons: SEQUENCE
282:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Basic Constraints
287:d=5 hl=2 l= 1 prim: BOOLEAN :255
290:d=5 hl=2 l= 5 prim: OCTET STRING [HEX DUMP]:30030101FF
297:d=1 hl=2 l= 10 cons: SEQUENCE
299:d=2 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256
309:d=1 hl=2 l= 71 prim: BIT STRING
``
在两个 CA 证书的 diff 视图中,不同的字节位于 `Root CA` 字符串之前的两处位置:主题(Subject)和颁发者(Issuer),由于这是自签名证书,因此两者相同。它们后面紧跟一个 `0x07` 字节,正好与 `Root CA` 的字符数(即值的长度)一致。不同的前导字节表明这些字段使用了不同的 ASN.1 数据类型。验证成功的 CA 证书使用 `UTF8String`(`0x0c`),而对验证失败的 CA 证书使用 `openssl` 可以看到它使用的是 `PrintableString`(`0x13`)。
``
openssl asn1parse -in ca.crt.pem
``
``
0:d=0 hl=4 l= 378 cons: SEQUENCE
4:d=1 hl=4 l= 289 cons: SEQUENCE
8:d=2 hl=2 l= 3 cons: cont [ 0 ]
10:d=3 hl=2 l= 1 prim: INTEGER :02
13:d=2 hl=2 l= 20 prim: INTEGER :75AE14BE5173C1010EFDF0F47F88409E3FB274F6
35:d=2 hl=2 l= 10 cons: SEQUENCE
37:d=3 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256
47:d=2 hl=2 l= 18 cons: SEQUENCE
49:d=3 hl=2 l= 16 cons: SET
51:d=4 hl=2 l= 14 cons: SEQUENCE
53:d=5 hl=2 l= 3 prim: OBJECT :commonName
58:d=5 hl=2 l= 7 prim: PRINTABLESTRING :Root CA
67:d=2 hl=2 l= 32 cons: SEQUENCE
69:d=3 hl=2 l= 13 prim: UTCTIME :260227194746Z
84:d=3 hl=2 l= 15 prim: GENERALIZEDTIME :21260203194746Z
101:d=2 hl=2 l= 18 cons: SEQUENCE
103:d=3 hl=2 l= 16 cons: SET
105:d=4 hl=2 l= 14 cons: SEQUENCE
107:d=5 hl=2 l= 3 prim: OBJECT :commonName
112:d=5 hl=2 l= 7 prim: PRINTABLESTRING :Root CA
121:d=2 hl=2 l= 89 cons: SEQUENCE
123:d=3 hl=2 l= 19 cons: SEQUENCE
125:d=4 hl=2 l= 7 prim: OBJECT :id-ecPublicKey
134:d=4 hl=2 l= 8 prim: OBJECT :prime256v1
144:d=3 hl=2 l= 66 prim: BIT STRING
212:d=2 hl=2 l= 83 cons: cont [ 3 ]
214:d=3 hl=2 l= 81 cons: SEQUENCE
216:d=4 hl=2 l= 29 cons: SEQUENCE
218:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Subject Key Identifier
223:d=5 hl=2 l= 22 prim: OCTET STRING [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524
247:d=4 hl=2 l= 31 cons: SEQUENCE
249:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Authority Key Identifier
254:d=5 hl=2 l= 24 prim: OCTET STRING [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524
280:d=4 hl=2 l= 15 cons: SEQUENCE
282:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Basic Constraints
287:d=5 hl=2 l= 1 prim: BOOLEAN :255
290:d=5 hl=2 l= 5 prim: OCTET STRING [HEX DUMP]:30030101FF
297:d=1 hl=2 l= 10 cons: SEQUENCE
299:d=2 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256
309:d=1 hl=2 l= 71 prim: BIT STRING
``
这仍然无法解释为什么 `openssl` 能够使用任一 CA 证书成功验证,而 Go 程序却不能。为了进一步探究,你可以编译程序并使用 `gdb` 单步调试,从 `Verify()` 处设置断点开始。
``
gdb main -ex 'b crypto/x509.(*Certificate).Verify' -ex 'run'
``
单步进入函数 (https://github.com/golang/go/blob/de0d77c6f8c3977d90b5541a34bca9da32494e38/src/crypto/x509/verify.go#L538) 后,你最终会到达构建候选证书链的位置。
> 使用 `b crypto/x509.(*Certificate).buildChains` 为此函数设置断点。
``
var candidateChains [][]*Certificate
if opts.Roots.contains(c) {
candidateChains = [][]*Certificate{{c}}
} else {
candidateChains, err = c.buildChains([]*Certificate{c}, nil, &opts)
if err != nil {
return nil, err
}
}
``
在评估提供的证书池是否包含候选链的过程中,会对 `Roots` 调用 `findPotentialParents()` (https://github.com/golang/go/blob/de0d77c6f8c3977d90b5541a34bca9da32494e38/src/crypto/x509/cert_pool.go#L132)(也会对 `Intermediates` 调用,但本例中没有提供中间证书)。
``
for _, root := range opts.Roots.findPotentialParents(c) {
considerCandidate(rootCertificate, root)
}
``
最后,你找到了失败的原因 (https://github.com/golang/go/blob/de0d77c6f8c3977d90b5541a34bca9da32494e38/src/crypto/x509/cert_pool.go#L144)。叶证书的潜在父证书应具有与叶证书的颁发者(Issuer)相匹配的主题(Subject),即叶证书应指向该证书作为用于签名的证书。
``
for _, c := range s.byName[string(cert.RawIssuer)] {
candidate, constraint, err := s.cert(c)
if err != nil {
continue
}
kidMatch := bytes.Equal(candidate.SubjectKeyId, cert.AuthorityKeyId)
switch {
case kidMatch:
matchingKeyID = append(matchingKeyID, potentialParent{candidate, constraint})
case (len(candidate.SubjectKeyId) == 0 && len(cert.AuthorityKeyId) > 0) ||
(len(candidate.SubjectKeyId) > 0 && len(cert.AuthorityKeyId) == 0):
oneKeyID = append(oneKeyID, potentialParent{candidate, constraint})
default:
mismatchKeyID = append(mismatchKeyID, potentialParent{candidate, constraint})
}
}
``
`CertPool` (https://github.com/golang/go/blob/de0d77c6f8c3977d90b5541a34bca9da32494e38/src/crypto/x509/cert_pool.go#L18) 的 `byName` 映射中的键包含 CA 证书的主题。当使用导致验证失败的 CA 证书时,单步调试上述循环,你会发现迭代次数为零,也就是说,没有 CA 证书的主题与叶证书的颁发者匹配。这怎么可能?关键观察点是,比较时使用的是原始主题和颁发者的字节。
``
// CertPool 是一组证书的集合。
type CertPool struct {
byName map[string][]int // cert.RawSubject => 指向 lazyCerts 的索引
// lazyCerts 包含返回证书的函数,
// 在需要时惰性解析/解压缩。
lazyCerts []lazyCert
// haveSum 保存从 sum224(cert.Raw) 到 true 的映射。
// 仅用于 AddCert 的重复检测,以避免在 AddCert 路径中调用 CertPool.contains
// (因为 contains 方法可能会调用 getCert,从而抵消惰性 getCert 函数带来的节省)。
haveSum map[sum224]bool
// systemPool 指示这是否是派生自系统根证书的特殊池。
// 如果包含额外根证书,则需要
相似文章
Linux 上的 TLS 证书验证
深入解释 Linux 上的 TLS 证书验证,涵盖信任存储、链构建以及常见陷阱(如缺少中间证书)。文章阐明了为什么不同工具可能对证书有效性存在分歧。
Golang 代码审查笔记 II
来自 elttam 的后续博客文章,介绍了提高安全性的新 Go 语言特性、在代码审计中发现的有问题的编码模式(footguns),以及用于捕获这些模式的 Semgrep 规则。
新研究:经过“验证”的GitHub提交并非唯一
Jacob Ginesin的一篇新预印本揭示,经过验证的GitHub提交可以被篡改,产生多个具有不同哈希值的有效签名,这破坏了唯一性的假设,并影响了供应链安全。
Go 中过度的空指针检查
一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。
整数无故发生神秘变化,而本应无代码生成影响
一位开发者发现,交换两个等效宏竟导致无关函数中出现意外的整数变化,这篇博客文章深入探究了这一谜团,并对某个大语言模型(LLM)关于控制流保护的解释提出了质疑。