欺骗Go的X.509证书验证

Hacker News Top 新闻

摘要

这篇博客文章探讨了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 证书验证

Lobsters Hottest

深入解释 Linux 上的 TLS 证书验证,涵盖信任存储、链构建以及常见陷阱(如缺少中间证书)。文章阐明了为什么不同工具可能对证书有效性存在分歧。

Golang 代码审查笔记 II

Lobsters Hottest

来自 elttam 的后续博客文章,介绍了提高安全性的新 Go 语言特性、在代码审计中发现的有问题的编码模式(footguns),以及用于捕获这些模式的 Semgrep 规则。

Go 中过度的空指针检查

Lobsters Hottest

一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。

整数无故发生神秘变化,而本应无代码生成影响

The Old New Thing (Raymond Chen)

一位开发者发现,交换两个等效宏竟导致无关函数中出现意外的整数变化,这篇博客文章深入探究了这一谜团,并对某个大语言模型(LLM)关于控制流保护的解释提出了质疑。