[转] 写给工程师:关于证书(certificate)和公钥基础设施(PKI)的一切 转载序 本文转载 http://arthurchiao.art/blog/everything-about-pki-zh/ ,感谢译者ArthurChiao、作者的贡献。在之前,我写过一篇关于HTTPS证书吊销机制的文章,那时也一直对PKI体系混乱的知识点有很多盲点,也一直想搞清楚这些,文章末尾还立下flag,至今两年,惨遭打脸。 这几天在学习eBPF领域知识,在ArthurChiao的博客上学习了很多,也就遇到了这篇文章,以借花献佛,转给各位看官,也算填平了两年前的flag吧。 译者序 本文翻译自 2018 年的一篇英文博客:Everything you should know about certificates and PKI but are too afraid to ask,作者 MIKE MALONE。 这篇长文并不是枯燥、零碎地介绍 PKI、X.509、OID 等概念,而是从前因后果、历史沿革的角度把这些东西串联起来,逻辑非常清晰,让读者知其然,更知其所以然。 证书和 PKI 的目标其实很简单:将名字关联到公钥(bind names to public keys)。 加密方式的演进: MAC 最早的验证消息是否被篡改的方式,发送消息时附带一段验证码 | 双方共享同一密码,做哈希;最常用的哈希算法:HMAC | \/ Signature 解决 MAC 存在的一些问题;双方不再共享同一密码,而是使用密钥对 | | \/ PKC 公钥加密,或称非对称加密,最常用的一种 Signature 方式 | 公钥给别人,私钥自己留着; | 发送给我的消息:别人用 *我的公钥* 加密;我用我的私钥解密 \/ Certificate 公钥加密的基础,概念:CA/issuer/subject/relying-party/... | 按功能来说,分为两种 | |---用于 *签名*(签发其他证书) 的证书 |---用于 *加解密* 的证书 证书(certificate)相关格式及其关系(沉重的历史负担): 最常用的格式 | 信息比 X.509 更丰富的格式 | 其他格式 mTLS 等常用 Java 常用 微软常用 .p7b .p7c .pfx .p12 X.509 v3 PKCS#7 PKCS#12 SSH 证书 PGP 证书 =====> 证书格式 \ | / (封装格式,证书结构体) \ | / \ | / \ | / \-------------+----------------/ | ASN.1 (类似于 JSON、ProtoBuf 等) =====> 描述格式 | /-------------+----------------\ / | \ / | \ / | \ / | \ DER PEM =====> 编码格式 二进制格式 文本格式 (序列化) .der .pem .crt .cer 一些解释: X.509 从结构上定义证书中应该包含的信息,例如签发者、秘钥等等;但使用哪个格式(例如 JSON 还是 YAML 还是 ASN.1)来描述,并不属于 X.509 的内容; ASN.1 是 X.509 的描述格式(或者说用 ASN.1 格式来定义 X.509),类似于现在的 protobuf; ASN 中有很多数据类型,除了常见的整形、字符串等类型,还有一个称为 OID 的特殊类型,用点分整数表示,例如 2.5.4.3,有点像 URI 或 IP 地址,在设计上是全球唯一标识符。 ASN.1 只是一种描述格式,并未定义如何序列化为比特流,因此又引出了 ASN.1 的编码格式。 ASN.1 与其编码格式的关系,与 unicode 与 utf8 的关系类似。 DER:一种二进制编码格式 PEM:一种文本编码格式,通常以 .pem、.crt 或 .cer 为后缀。 某些场景下,X.509 信息不够丰富,因此又设计了一些信息更丰富(例如可以包含证书链、秘钥)的证书封装格式,包括 PKCS #7 和 #12。 仍然用 ASN.1 格式描述 基本都是用 DER 编码 以上提到的东西,再加上 CA、信任仓库、信任链、certificate path validation、CSR、证书生命周期管理、SPIFFE 等还没有提到但也与加密相关的东西,统称为公钥基础设施(PKI)。 翻译时调整了一些配图,也加了几张新图,以方便展示和理解。 由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。 以下是译文。 前言 证书(certificates)与 PKI(public key infrastructure,公钥基础设施)很难。我认识的很多非常聪明的人也会绕过这一主题。 我个人也很长时间没去碰这些内容,但说起来很讽刺,我没去碰的原因是不懂,所以不好意思问 —— 然后更不懂,自然更不好意思问 ——如此形成恶性循环。 为什么要学习 PKI 最终我还是硬着头皮学习了这些东西,因为我觉得PKI 能使一个人在加解密层面(乃至更大的安全层面)去思考如何定义一个系统。具体来说,PKI 技术, 都是通用的、厂商无关的(universal and vendor neutral); 适用于任何地方,因此即使系统可分布在世界各地,彼此之间也能安全地通信; 在概念上很简单,并且非常灵活;如果使用我们的 TLS everywhere 模型,那甚至连 VPN 都不需要了。 总之一句话:非常强大! 本文目的 在深入理解了 PKI 之后,我很后悔没有早点学这些东西。 PKI 非常强大且有趣,虽然它背后的数学原理很复杂,一些相关标准也设计地非常愚蠢(巴洛克式的复杂),但其 核心概念其实非常简单; 证书是识别(identify)代码和设备的最佳方式, 而 identity(身份)对安全、监控、指标等很多东西都非常有用; 使用证书并不是太难,不会难于学习一门新语言或一种新数据库。 那为什么大家对这些内容望而却步呢?我认为主要是缺少很好的文档,所以经常看地云里雾里,半途而弃。 本文试图弥补这一缺失。我认为大部分工程师花一个小时读完本文后,头脑中都将有具备了那些关于加解密的最重要概念和最常见使用场景 —— 这正是本文的目的 —— 一小时只是很小的一个投资,而且这些内容是无法通过其他途径学到的。 本文将用到以下两个开源工具: step CLI step certificates 极简 TL; DR(太长不读) 证书和 PKI 的目的是:将名字关联到公钥(bind names to public keys)。 这是关于证书和 PKI 的最高抽象,其他都属于实现细节。 术语 本文将用到以下术语。 Entity(实体) Entity 是任何存在的东西(anything that exists) —— 即使只在逻辑或概念上存在(even if only exists logically or conceptually)。 例如, 你的计算机是一个 entity, 你写的代码也是一个 entity, 你自己也是一个 entity, 你吃的杂粮饼也是一个 entity, 你六岁时见过的幽灵也是一个 entity —— 即使你妈告诉你幽灵并不存在,这只是你的臆想。 Identity(身份) 每个 entity(实体)都有一个 identity(身份)。要精确定义这个概念比较困难,这么来说吧:identity 是使你之所以为你(what makes you you)的东西,懂吗? 具体到计算机领域,identity 通常用一系列属性来表示,描述某个具体的 entity,这里的属性包括 group、age、location、favorite color、shoe size 等等。 Identifier(身份标识符) Identifier 跟 identity 还不是一个东西:每个 identifier 都是一个唯一标识符,也唯一地关联到某个有 identity 的 entity。 例如,我是 Mike,但 Mike 并不是我的 identity,而只是个 name —— 虽然二者在我们小范围的讨论中是同义的。 Claim(声明) & Authentication(认证) 一个 entity 能 claim(声明)说,它拥有某个或某些 name。 其他 entity 能够对这个 claim 进行认证(authenticate),以确认这份声明的真假。 一般来说,认证的目的是确认某些 claim 的合法性。 Claim 不是只能关联到 name,还可以关联到别的东西。例如,我能 claim 任何东西:my age, your age, access rights, the meaning of life 等等。 Subscriber & CA & relying party (RP) 能作为一个证书的 subject 的 entity,称为 subscriber(证书 owner)或 end entity。 对应地,subscriber 的证书有时也称为 end entity certificates 或 leaf certificates,原因在后面讨论 certificate chains 时会介绍。 CA(certificate authority,证书权威)是给 subscriber 颁发证书的 entity,是一种 certificate issuer(证书颁发者)。 CA 的证书,通常称为 root certificate 或 intermediate certificate,具体取决于 CA 类型。 Relying party 是 使用证书的用户(certificate user),它验证由 CA 颁发(给 subscriber)的证书是否合法。 一个 entity 可以同时是一个 subscriber 和一个 relying party。也就是说,单个 entity 既有自己的证书,又使用其他证书来认证 remote peers,例如双向 TLS(mutual TLS,mTLS)场景。 小结 对于我们接下来的讨论,这些术语就够了。下面将进入正题,看如何在实际中实现证书的声明和认证。 想了解更多相关术语,可参考 RFC 4949。 MAC(消息认证码)和 signature(签名) MAC(message authentication code)和 HMAC(hash-based MAC) MAC(消息认证码)是一小段数据,用于验证某个 entity 发送的消息未被篡改。其基本原理如下图所示: MAC/HMAC 原理。图片来自:okta.com 对消息(message)和双方都知道的一个密码(shared secret,a password)做哈希,得到的哈希值就是 MAC; 发送方将消息连带 MAC 一起发给接收方; 接收方收到消息之后,用同一个密码来计算 MAC,然后跟消息中提供的 MAC 对比。如果相同,就证明未被篡改。 关于哈希: 哈希是单向的,因此无法从输出反推输入。这一点至关重要:否则截获消息的人就可以根据 MAC 和哈希函数反推出你的 secrets。 此外,生成 MAC 的哈希算法选择也至关重要,本文不会展开,但提醒一点:不要试图用自己设计的 MAC 算法。 最常用的 MAC 算法是 HMAC(hash-based message authentication code)。 Signature(签名) 讨论 MAC 其实是为了引出 signature(签名)这一主题。 签名在概念上与 MAC 类似,但不是用共享 secret 的方式,而是使用一对秘钥(key pair): MAC 方式中,至少有两个 entity 需要知道共享的 secret,也就是消息的发送方和接收方。双方都可以生成 MAC,因此给定一个合法的 MAC,我们是无法知道是谁生成的。 签名就不同了。签名能用公钥(public key)验证,但只能用相应的私钥(private key)生成。因此对于接收方来说,它只能验证签名是否合法,而无法生成同样的签名。 如果只有一个 entity 知道秘钥,那这种特性称为 non-repudiation(不可否认性):持有私钥的人无法否认(repudiate)数据是由他签名的这一事实。 小结 MAC 与 signature 都叫做签名,是因为它们和现实世界中的签名是很像的。例如,如果想让某人同意某事,并且事后还能证明他们当时的确同意了,就把问题写下来,然后让他们手写签字(签名)。 Public key cryptography(公钥加密,或称非对称加密) 证书和 PKI 的基础是公钥加密(public key cryptography),也叫非对称加密(asymmetric cryptography)。 秘钥对 公钥加密系统使用秘钥对(key pair)加解密。一个秘钥对包含: 一个私钥(private key):owner 持有,解密用,不要分享给任何人; 这一点非常重要,值得重复一遍:公钥加密系统的安全性取决于私钥(private key)的机密性。 一个公钥(public key):加密用,可分发和共享给别人; 秘钥可以做的事情: 加解密:公钥(public key)加密,私钥(private key)解密。 签名:私钥(private key)对数据进行签名(sign some data);任何有公钥的人都可以对签名进行验证,证明这个签名确实是私钥生成的。 公钥加密系统使计算机能“看到”对方 公钥加密是数学给计算机科学的神秘礼物,其数学基础显然很复杂,但如果只是使用,那并不理解它的每一步数学原理。公钥加密使计算机能做一些之前无法做的事情:它们现在能看到对方是谁了。 这句话的意思是说,公钥加密使一台计算(或代码)能向其他计算机或程序证明,不用直接分享某些信息,它也能知道该信息。更具体来说, 以前要证明你有密码,就必须向别人展示这个密码。但展示之后,任何有这个密码的人就都能使用它了。 私钥却与此不同。你能通过私钥对我的身份进行认证(authenticate my identity),但却无法假冒我。 例如,你发给我一个大随机数,我对这个随机数进行签名,然后将再发送给你。你能用公钥对这个签名进行认证,确认这个签名(消息)确实来自我。这就是一种证明你在和我(而不是别的其他的人)通信的很好证据。这使得网络上的计算机能有效地知道它们在和谁通信。 这听起来是一件如此理所当然的事情,但仔细地想一下,网络上只有流动的 0 和 1,你怎么知道消息来自谁,在和谁通信?因此公钥加密系统是一个非常伟大的发明。 证书(certificate):计算机和代码的驾驶证 前面说道,公钥加密系统使我们能知道和谁在通信,但这个的前提是:要知道(有)对方的公钥。 那么,如果对方不知道我的公钥怎么办?这就轮到证书出场了。 想一下,我们需求其实非常简单: 首先要将公钥和它的 owner 信息发给对方; 但光有这个信息还不行,还要让对方相信这些信息; 证书就是用来解决这个问题的,解决方式是请一个双方都信任的权威机构对以上信息作出证明(签名)。 证书的内容:(subscriber 的)公钥+名字 证书是一个数据结构,其中包含一个 public key 和一个 name; 权威机构对证书进行签名,签名的大概意思是:public key xxx 关联到了 name xx; 对证书进行签名的 entity 称为 issuer(或 certificate authority, CA),证书中的 entity 称为 subject。 举个例子,如果某个 Issuer 为 Bob 签发了一张证书,其中的内容就可以解读如下: Some Issuer says Bob's public key is 01:23:42... 证书是权威机构颁发的身份证明,并没有什么神奇之处 其中 Some Issuer 是证书的签发者(证书权威),证书是为了证明这是 Bob 的公钥,Some Issuer 也是这个声明的签字方。 证书的本质:基于对 issuer 公钥的信任来学习其他公钥 由上可知,如果知道 Some Issuer 的公钥,就可以通过验证签名的方式来对它(用私钥)签发的证书进行认证(authenticate)。如果如果你信任 Some Issuer,那你就可以信任这个声明。 因此,证书使大家能基于对 issuer 公钥的信任和知识,来学习到其他 entity 的公钥(上面的例子中就是 Bob)。这就是证书的本质。 与驾照的类比 证书就像是计算机/代码的驾照或护照。如果你之前从未见过我,但信任车管局,那你可以用我的驾照做认证: 首先验证驾照是真的(检查 hologram 等), 然后人脸和照片上对的上, 然后看名字是我,等等。 计算机用证书做类似的事情:如果之前从未和其他电脑通信,但信任一些证书权威,那可以用证书来认证: 首先验证证书是合法的(用证书签发者的公钥检查签名等), 然后提取证书中的(subscriber 的)公钥和名字, 然后用 subscriber 的公钥,通过网络验证该 subscriber 的签名; 查看名字是否正确等等。 证书内容解析举例 下面是个真实的证书: 还是与驾照类比: 驾照:描述了你是否有资格开车; 证书:描述你是否是一个 CA,你的公钥能否用来签名或加密。 二者都有有效期。 上图中有大量的细节,很多东西将在下面讨论到。但归根结底还是本文最开始总结的那句话:证书不过是一个将名字关联到公钥(bind names to public keys)的东西。其他都是实现细节。 证书编码格式及历史演进 接下来看一看证书在底层的表示(represented as bits and bytes)。 这部分内容复杂且相当令人沮丧。事实上,我怀疑证书和秘钥诡异的编码方式是导致 PKI 如此混乱和令人沮丧的根源。 X.509 证书 一般来说,人们提到“证书”而没有加额外限定词时,指的都是 X.509 v3证书。 更准确地说,他们指的是RFC 5280 中描述、CA/Browser Forum Baseline Requirements中进一步完善的PKIX 变种。 换句话说,指的是浏览器理解并用来做 HTTPS(HTTP over TLS)的那些证书。 也有其他的证书格式,例如著名的 SSH 和 PGP 都有它们各自的格式。 本文主要关注 X.509,理解了 X.509,其他格式都是类似的。由于这些证书使用广泛,因此有很好的函数库,而且也用在浏览器之外的场景。毫无疑问,它们是internal PKI 颁发的最常见证书格式。重要的是,这些证书在很多 TLS/HTTPS 客户端/服务端程序中都是开箱即用的。 X.509 起源:电信领域 了解一点 X.509 的历史对理解它会有很大帮助。 X.509 在 1988 年作为国际电信联盟(ITU)X.500 项目的一部分首次标准化。这是通信(telecom)领域的标准,想通过它构建一个全球电话簿(global telephone book)。虽然这个项目没有成功,但却留下了一些遗产,X.509 就是其中之一。 如果查看 X.509 的证书,会看到其中包含了 locality、state、country 等信息,之前可能会有疑问为什么为 web 设计的证书会有这些东西,现在应该明白了,因为X.509 并不是为 web 设计的。 ASN.1:数据抽象格式 X.509 构建在 ASN.1 (Abstract Syntax Notation,抽象语法标注)之上,后者是另一个ITU-T 标准 (X.208 and X.680)。 ASN.1 定义数据类型, 可以将 ASN.1 理解成 X.509 的 JSON, 但实际上更像 protobuf、thrift 或 SQL DDL。 RFC 5280 用 ASN.1 来定义 X.509 证书,其中包括名字、秘钥、签名等信息。 OID (object identitfier) ASN.1 除了有常见的数据类型,如整形、字符串、集合、列表等,还有一个不常见但很重要的类型:OID(object identifier,对象标识符)。 OID 与 URI 有些像,但比 URI 要怪。 OID (在设计上)是全球唯一标识符。 在结构上,OID 是在一个 hierarchical namespace 中的一个整数序列(例如 2.5.4.3)。 可以用 OID 来 tag 一段数据的类型。例如,一个 string 本来只是一个 string,但可以 tag 一个 OID 2.5.4.3,然后就变成了一个特殊 string:这是X.509 的通用名字(common name) 字段。 ASN.1 编码格式 ASN.1 只是抽象(abstract),因为这个标准并未定义在数据层应该如何表示(represented as bits and bytes)。ASN.1 与其编码格式的关系,就像 unicode 与 utf8 的区别。因此,有很多种编码规则(encoding rules),描述具体如何表示 ASN.1 数据。原以为增加这层额外的抽象会有所帮助,但实际证明大部分情况下反而徒增烦恼。 DER (distinguished encoding rules):二进制格式 ASN.1 有很多种编码规则,但用于 X.509 和其他加密相关的,只有一种常见格式:DER —— 虽然有时也会用到 non-canonical的 basic encoding rules (BER,基础编码规则) 。 DER 是非常简单的 TLV(type-length-value)编码,但实际上用户无需关心这些,因为函数库封装好了。但不要高兴得太早 —— 虽然我们不必关心 DER 的编解码,但要能判断给定的某个 X.509 证书是 DER 还是其他类型编码的。这里的其他类型包括: 一些比 DER 更友好的格式, 封装了证书及其他额外信息的格式(something more than just a certificate)。 DER 编码的证书通常以 .der 为后缀。 PEM (privacy enhanced email):文本格式 DER 是二进制格式,不便复制粘贴。因此大部分证书都是以PEM 格式打包的,这是另一个历史怪胎。 如果你熟悉 MIME 的话,二者是比较类似的:由 header、base64 编码的 payload、footer 三部分组成。header 中有标签(label)来描述 payload。例如下面是一个 PEM 编码的 X.509 证书: -----BEGIN CERTIFICATE----- MIIBwzCCAWqgAwIBAgIRAIi5QRl9kz1wb+SUP20gB1kwCgYIKoZIzj0EAwIwGzEZ MBcGA1UEAxMQTDVkIFRlc3QgUm9vdCBDQTAeFw0xODExMDYyMjA0MDNaFw0yODEx BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRc+LHppFk8sflIpm/XKpbNMwx3 SDAfBgNVHSMEGDAWgBTirEpzC7/gexnnz7ozjWKd71lz5DAKBggqhkjOPQQDAgNH ADBEAiAejDEfua7dud78lxWe9eYxYcM93mlUMFIzbWlOJzg+rgIgcdtU9wIKmn5q FU3iOiRP5VyLNmrsQD3/ItjUN1f1ouY= -----END CERTIFICATE----- 但令人震惊的时,即便如此简单的功能,在实现上也已经出现混乱:PEM labels 在不同工具之间是不一致的。RFC 7468 试图标准化 PEM 的使用规范,但也并不完整,不是所有工具都遵循这个规范。 PEM 编码的证书通常以 .pem、.crt 或 .cer 为后缀。再次提醒,这只是“通常”情况,实际上某些工具可能并不遵循这些惯例。 下面介绍几个前面提到的“其他类型的打包格式”。 比 X.509 信息更丰富的证书打包(封装)格式 X.509 只是一种常用的证书格式,但有人觉得这种格式能装的信息不够多,因此又定义了一些比 X.509 更大的数据结构(但仍然用 ASN.1),能将证书、秘钥以及其他东西封装(打包)到一起。因此,有时说我需要“一个证书”时,其实真正说的是包(package)中包含的那个“证书”(a certificate in one of theseenvelopes),而不是这个包本身。 PKCS #7:Java 中常用 你可能会遇到的是一个称为 PKCS(Public Key Cryptography Standards,公钥加密标准)的标准的一部分,它由 RSA labs 发布(真实历史要 更加复杂一些,本文不展开)。 其中的第一个标准是 PKCS#7,后面被IETF 重新冠名为 Cryptographic Message Syntax (CMS),其中可以包含多个证书(以 full certificate chain 方式编码,后面会看到)。 PKCS#7 在 Java 中使用广泛。常见扩展名是 .p7b and .p7c。 PKCS #12:微软常用 另一个常见的打包格式 PKCS#12,它能将一个证书链(这一点与 PKCS#7 类似)连同一个(加密之后的)私钥打包到一起。 微软的产品多用这种格式,常见后缀.pfx and .p12。 再次说明,PKCS#7 和 PKCS#12 envelopes 仍然使用 ASN.1,这意味着它们都能以原始 DER、BER 或 PEM 的格式编码。从我个人的经验来看,二者几乎都是 DER 编码的。 秘钥编解码 秘钥编码(Key encoding)的过程与以上描述的类似(复杂): 用某种 ASN.1 数据结构描述秘钥(key); 用 DER 做二进制编码,或用 PEM (hopefully with a useful header) 做一些稍微友好一些的表示。 秘钥的解密过程(deciphering),一半是是科学,一半是艺术。 如果足够幸运,根据 RFC 7468 就能找到其中的 PEM payload; 椭圆曲线秘钥通常符合 RFC 7468 规范,虽然这里看起来似乎也并没有什么标准。 下面是一个 PEM 编码的椭圆曲线秘钥(PEM-encoded elliptic curve key): $ step crypto keypair --kty EC --no-password --insecure ec.pub ec.prv $ cat ec.pub ec.prv -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc73/+JOESKlqWlhf0UzcRjEe7inF uu2z1DWxr+2YRLfTaJOm9huerJCh71z5lugg+QVLZBedKGEff5jgTssXHg== -----END PUBLIC KEY----- -----BEGIN EC PRIVATE KEY----- MHcCAQEEICjpa3i7ICHSIqZPZfkJpcRim/EAmUtMFGJg6QjkMqDMoAoGCCqGSM49 AwEHoUQDQgAEc73/+JOESKlqWlhf0UzcRjEe7inFuu2z1DWxr+2YRLfTaJOm9hue rJCh71z5lugg+QVLZBedKGEff5jgTssXHg== -----END EC PRIVATE KEY----- 其他秘钥,通常用 PEM label "PRIVATE KEY" 描述 PEM 编码的 PKCS#8 格式私钥 PEM label "PRIVATE KEY" 描述的秘钥,通常暗示这是一个PKCS#8 payload,这是一种私钥(private key)封装格式,其中包含秘钥类型和其他 metadata。 密码加密的私钥 用密码来加密私钥也很常见(private keys encrypted using a password),这里的密码可以是 a shared secret or symmetric key。看起来大致如下(Proc-Type and DEK-Info 是 PEM 的一部分,表示这个 PEM 的 payload 是用 AES-256-CBC 加密的): -----BEGIN EC PRIVATE KEY----- Proc-Type: 4,ENCRYPTED DEK-Info: AES-256-CBC,b3fd6578bf18d12a76c98bda947c4ac9 qdV5u+wrywkbO0Ai8VUuwZO1cqhwsNaDQwTiYUwohvot7Vw851rW/43poPhH07So sdLFVCKPd9v6F9n2dkdWCeeFlI4hfx+EwzXLuaRWg6aoYOj7ucJdkofyRyd4pEt+ Mj60xqLkaRtphh9HWKgaHsdBki68LQbObLOz4c6SyxI= -----END EC PRIVATE KEY----- PKCS#8 对象也能被加密,这种情况下 header label 应该是 "ENCRYPTED PRIVATE KEY" per RFC 7468。这种情况下不会看到 Proc-Type 和 Dek-Info headers,因为这些信息此时编码到了 payload 中。 公钥、私钥常见扩展名 公钥:.pub or .pem, 私钥:.prv, .key, or .pem。 但再次说明,有些工具或组织可能并不遵循业界惯例。 小结 ASN.1 用于定义数据类型,例如证书(certificate)和秘钥(key)——就像用 JSON 定义一个 request body —— X.509 用 ASN.1 定义。 DER 是一组将 ASN.1 编码成二进制(比特和字节)的编码规则(encoding rules)。 PKCS#7 and PKCS#12 是比 X.509 更大的数据结构(封装格式),也用 ASN.1 定义,其中能包含除了证书之外的其他东西。二者分别在 Java 和 Microsoft 产品中使用较多。 DER 编码之后是二进制数据,不方便复制粘贴,因此大部分证书都是用 PEM 编码的,它用 base64 对 DER 进行编码,然后再加上自己的 label。 私钥通常用是 PEM 编码的 PKCS#8 对象,但有时也会用密码来加密。 如果觉得以上内容理解起来很杂乱,那并不是你的问题,而是加密领域的现状就是如此。我已经尽力了。 PKI (Public Key Infrastructure) 至此我们已经知道了证书的来历和样子,但这仅仅是本文的一半。下面看证书是如何创建和使用的。 Public key infrastructure (PKI) 是一个统称,包括了我们在如下与证书和秘钥管理及交互操作时需要用到的所有东西:签发、分发、存放、使用、验证、撤回等等。就像“数据库基础设施” 一样,这个名词是有意取的这样模糊的。 证书是大部分 PKI 的构建模块,而证书权威是其基础。 PKI 包括了 libraries, cron jobs, protocols, conventions, clients, servers, people, processes, names, discovery mechanisms, and all the other stuff you'll need to use public key cryptography effectively。 自己从头开始构建一个 PKI 是一件极其庞大的工作,但实际上 一些简单的 PKI 甚至并不使用证书。例如, 编辑 ~/.ssh/authorized_keys 文件时,就是在配置一个简单的无证书形式的(certificate-less)PKI,SSH 通过这种方式在扁平文件内实现 public key 和 name 的绑定; PGP 用证书,但不用 CA,而是用一个 web-of-trust model; 甚至可以 用区块链 来 assign name 并将它们 bind 到 public key。 如果从头开始构建一个 PKI,唯一确定的事情是:你需要用到公钥(public keys),其他东西都随设计而异。 下文将主要关注 web 领域使用的 PKI,以及基于 Web PKI 技术、遵循现有标准的 internal PKI。 证书和 PKI 的目标其实很简单:将名字关联到公钥(bind names to public keys)。在下面的内容中,不要忘了这一点。 Web PKI vs Internal PKI 浏览器访问 HTTPS 链接时会用到 Web PKI。虽然也有一些问题,但它大大提升了 web 的安全性,而且基本上对用户透明。在访问互联网 web 服务时,应该在所有可能的情况下都启用它。 Web PKI 由 RFC 5280 定义,CA/Browser Forum (a.k.a., CA/B or CAB Forum) 对其进行了进一步完善。 有时也称为 "Internet PKI" 或 PKIX (after the working group that created it). PKIX 和 CAB Forum 文档涵盖了很大内容。它们定义了前面讨论的各种证书、还定义什么是 “name” 以及位于证书中什么位置、能使用什么签名算法、RP 如何判断 issuer 的证书、如何指定证书的 validity period (issue and expiry dates)、撤回、certificate path validation、CA 判断某人是否拥有一个域名等等。 Web PKI 很重要,是因为浏览器默认使用 Web PKI 证书。 Internal PKI 是用户为自己的产品基础设施使用的 PKI,这些产品包括 服务、容器、虚拟机等; 企业 IT 应用; 公司终端设备,例如笔记本电脑、手机等; 其他需要识别的代码或设备。 Internal PKI 使你能认证和建立加密通道,这样你的服务就可以安全地在公网上的任意位置互相通信了。 有了 Web PKI,为什么还要使用自己的 internal PKI? 首先,简单来说:Web PKI 设计中并没有考虑内部使用场景。即使有了 Let's Encrypt 这样的提供免费证书和自动化交付的 CA,用户还是需要自己处理 rate limits 和availability 之类的事情。如果有很多 service,部署很频繁,就非常不方便。 另外,Web PKI 中,用户对证书生命周期、撤回机制、续约过程、秘钥类型、算法等等很多重要的细节都没有控制权,或只有很少控制权。而下文将会看到,这些都是非常重要的东西。 最后,CA/Browser Forum Baseline Requirements实际上禁止将 Web PKI CA 关联到 internal IPs (e.g., 10.0.0.0/8)及 internal DNS names that aren't fully-qualified and resolvable in public global DNS (e.g., you can't bind a kubernetes cluster DNS name like foo.ns.svc.cluster.local)。如果需要在证书中绑定到这些 name,或者签发大量证书,或者控制证书细节,就需要自己的 internal PKI. 下面一节将看到,信任(或缺乏信任)是避免将 Web PKI 用于内部场景的另一个原因。 总结起来,建议: 面向公网的服务或 API,使用 Web PKI; 其他所有场景,都使用 internal PKI。 Trust & Trustworthiness Trust Stores(信任仓库) 前面介绍到,证书可解读为一个 statement 或 claim,例如: Issuer(签发者)说,该 subject 的公钥是 xxx。 Issuer 会对这份声明进行签名,relying party 能(通过 issuer 的公钥)验证(authenticate)签名是否合法。但这里其实跳过了一个重要问题:relying party 是如何知道 issuer 的公钥的? 预配置信任的根证书 答案其实很简单:relying parties 在自己的 trust store(信任库)预先配置了一个它信任的根证书(trusted root certificates,也称为 trust anchors)列表, 预配置的具体方式(the manner in which this pre-configuration occurs),是 PKI 非常重要的一面: 一种方式是从另一个 PKI 来 bootstrap:可以用一些自动化工具,通过 SSH 将 root 证书拷贝到 relying party。这里用到里前面提到的 SSH PKI。 如果是在 cloud 上,那 PKI 依赖层次(信任链)又深了一步:SSH PKI 是由 Web PKI 加上认证方式来 bootstrap 的,这里的认证是你创建 cloud 账户时选择的认证方式。 信任链 如果沿着这个信任链(chain of trust)回溯足够远,最后总能找到人(people):每个信任链都终结在现实世界(meatspace)。 下面这个图画地更清楚一些, Image credit: Cilium TLS inspection 根证书自签名 信任仓库中的根证书是自签名的(self-signed):issuer 和 subject相同。逻辑上,这种 statement 表示的是: Mike 说:Mike 的公钥是 xxx。 自签名的证书保证了该证书的 subject/issuer 知道对应的私钥,但任何人都可以生成一个自签名的证书,这个证书中可以写任何他们想写的名字(name)。 因此证书的起源(provenance)就非常关键:一个自签名的证书,只有当它进入信任仓库的过程是可信任时,才应该信任这个根证书。 在 macOS 上,信任仓库是由 Keychain 管理的。 在一些 Linux 发行版上,可能只是 /etc 或其他路径下面的一些文件。 如果你的用户能修改这些文件,那最好先确认是你信任这些用户的。 信任仓库的来源 所以,信任仓库又从哪里来?对于 Web PKI 来说,最重要的relying parties 就是浏览器。主流浏览器默认使用的信任仓库 ——及其他任何使用 TLS 的东西 —— 都是由四个组织维护的: Apple's root certificate:iOS/macOS 程序 Microsoft's root certificate program:Windows 使用 Mozilla's root certificate program: Mozilla 产品使用,由于其开放和透明,也作为其他一些信任仓库从基础 (e.g., for many Linux distributions) Google 未维护 root certificate program(Chrome 通常使用所在计算的操作系统的信任仓库),但维护了自己的黑名单,列出了自己不信任的根证书或特定证书。 (ChromeOS builds off of Mozilla's certificate program) 操作系统的信任仓库 操作系统中的信任仓库通常都是系统自带的。 Firefox 自带了自己的信任仓库(通过 TLS 从 mozilla.org 分发 ——bootstrapping off of Web PKI using some other trust store)。 编程语言和其他非浏览器的东西例如 curl,通过默认用操作系统的信任仓库。 因此,这个信任仓库通常情况下,会被该系统上预装的很多东西默认使用;通过软件更新(通常使用另一个 PKI 来签名)而更新。 信任仓库中通常包含了超过 100 个由这些程序维护的常见证书权威(certificate authorities)。其中一些著名的: Let's Encrypt Symantec DigiCert Entrust 如果想编程控制: Cloudflare's cfssl project maintains a github repository that includes the trusted certificates from various trust stores to assist with certificate bundling (which we'll discuss momentarily). For a more human-friendly experience you can query Censys to see which certificates are trusted by Mozilla,Apple, and Microsoft. Trustworthiness(可靠性) 这 100 多个证书权威在理论上是可信的(trusted) —— 浏览器和其他一些软件默认情况下信任由这些权威颁发的证书。 但是,这并不意味着它们是可靠的(trustworthy)。已经出现过 Web PKI 证书权威向政府机构提供假证书的事故,以便窥探流量(snoop on traffic)或仿冒某些网站。这类“受信任的” CA 中,其中在司法管辖权之外的地方运营 —— 包括民主国家和专制国家。 NSA 利用每个可能的机会来削弱 Web PKI。2011 年,两个“受信任的”证书权威DigiNotar and Comodo 都被攻陷了。DigiNotar 证书泄露可能与 NSA 相关。 此外,还有大量 CA 签发格式不对或不兼容的证书。因此,虽然按业界规范来说 这些 CA 是受信的,但按照经验来说它们是不可靠(不靠谱)的。 我们很快就会看到,Web PKI 的安全性取决于安全性最弱的权威(the least secure CA)的安全性。这显然不是我们希望的。 浏览器社区已经在采取行动来解决这些问题。CA/Browser Forum Baseline Requirements 规定了这些受信的证书权威在签发证书时应该遵守的规则。作为 WebTrust audit 项目的一部分,在将 CA 加入到某些信任仓库(例如 Mozilla 的)之前,会对 CA 合规性进行审计。 如果内部场景(internal stuff)已经在使用 TLS,你可能大部分情况下并不需要信任这些 public CA。如果信任了,就为 NSA 和其他组织打开了一扇地狱之门:你的系统安全性将取决于 100 多个组织中安全性最弱的那一个。 Federation 证书欺骗的风险 令事情更糟糕的是,Web PKI relying parties (RPs) 信任它们的信任仓库中任何 CA签发给任何 subscriber 的证书。结果是 Web PKI 整体的安全性取决于所有 Web PKI CA 中最弱的那个。2011 DigiNotar 攻击就说明了这个问题: 作为攻击的一部分,给 google.com 签发了一个假证书,这个证书被大部分浏览器和操作系统信任,而它们不管 google 和 DigiNotar 没有任何关系这一事实。 还有类似的欺骗证书颁发给了 Yahoo!, Mozilla, The Tor Project。 最终的解决方式是将 DigiNotar 的根证书从主流信任仓库中移除,但显然在此期间已经造成了大量破坏。 最近,森海塞尔(Sennheiser)因为在它们的 HeadSetup APP 信任仓库中安装了一个自签名的根证书引起了一次重大安全事故, 他们将相应的私钥(private key)嵌入在了 app 的配置中, 任何人都能从中提取这个私钥,然后颁发证书给任何 domain, 因此,任何在自己的信任仓库中添加了 Sennheiser 证书的,都将会信任这些欺骗证书。 这完全摧毁了 TLS 带来的好处,太糟糕了! 改进措施 已经有一些机制来减少此类风险: Certificate Authority Authorization (CAA) allows you to restrict which CAs can issue certificates for your domain using a special DNS record. Certificate Transparency (CT) (RFC 6962) mandates that CAs submit every certificate they issue to an impartial observer that maintains a public certificate log to detect fraudulently issued certificates. Cryptographic proof of CT submission is included in issued certificate HTTP Public Key Pinning (HPKP or just "pinning") lets a subscriber (a website) tell an RP (a browser) to only accept certain public keys in certificates for a particular domain. 这里存在的问题是:缺少 RP 端的支持。CAB Forum now mandates CAA checks in browsers. Some browsers also have some support for CT and HPKP. 但对于其他 RPs (e.g., most TLS standard library implementations) 这些东西几乎都是没有贯彻执行的。This issue will come up repeatedly: a lot of certificate policy must be enforced by RPs, and RPs can rarely be bothered. If RPs don't check CAA records and don't require proof of CT submission this stuff doesn't do much good. Internal PKI 使用单独的信任仓库 在任何情况下,如果使用自己的 internal PKI,都应该为 internal 服务维护一个单独的信任仓库。即, 不要将你的根证书直接加到系统已有的信任仓库, 而应该配置 internal TLS 只使用你自己的根证书。 Internal PKI 细粒度控制:CAA & SPIFFE 如果想在内部实现更好的联邦(federation) —— 例如限制 internal CA 能签发哪些证书, 可以试试 CAA records 然后对 RPs 进行恰当配置。 还可以看看 SPIFFE,这是一个还在不断发展的项目,目标是对一些 internal PKI 相关的问题进行标准化。 什么是证书权威(Certificate Authority)? 前面已经讨论了很多 CA 相关的东西,但我们还没定义什么是 CA。 一个证书权威(CA)就是一个受信任的证书颁发者。 CA 通过对一个证书进行签名,对一个公钥和名字之间的绑定关系(binding)做担保。 本质上来说,一个 CA 只不过是另一个证书加上用来签其他证书的相应私钥。 显然需要一些逻辑和过程来将这些东西串联起来。CA 需要将它的证书分发到信任仓库,接受和处理证书请求,颁发证书给 subscriber。 一个暴露此类 API 给外部调用、自动化这些过程的 CA 称为在线证书权威(online CA)。 在信任仓库中那些自签名的根证书 称为根证书权威(root CA)。 Web PKI 不能自动化签发证书 CAB Forum Baseline Requirements 4.3.1 明确规定:一个 Web PKI CA 的 root private key只能通过 issue a direct command 来签发证书。 换句话说,Web PKI root CA 不能自动化证书签名(certificate signing)过程。 对于任何大的 CA operation 来说,无法在线完成都是一个问题。不可能每次签发一个证书时,都人工敲一个命令。 这样规定是出于安全考虑。 Web PKI root certificates 广泛存在于信任仓库中,很难被撤回。截获一个 root CA private key 理论上将影响几十亿的人和设备。 因此,最佳实践就是,确保 root private keys 是离线的(offline),理想情况下在一些专用硬件上,连接到某些物理空间隔离的设备上,有很好的物理安全性,有严格的使用流程。 一些 internal PKI 也遵循类似的实践,但实际上并没有这个必要。 如果能自动化 root certificate rotation (例如,通过配置管理或编排工具,更新信任仓库),你就能轻松地 rotate 一个 compromised root key。 由于人们如此沉迷于 internal PKI 的根秘钥管理,导致 internal PKI 的部署效率大大降低。你的 AWS root account credentials 至少也是机密信息,你又是如何管理它的呢? Intermediates, Chains, and Bundling 在 root CA offline 的前提下,为使证书 issuance 可扩展(例如,使自动化成为可能),root private key 只在很少情况下使用, 用来签发几个intermediate certificates。 然后 intermediate CA(也称为 subordinate CAs)用相应的 intermediate private keys 来签发 leaf certificates to subscribers。 如下图所示: Image credit: Cilium TLS inspection 下面这张图把签发关系展示地更清楚, Image credit: Cilium TLS inspection Intermediates 通常并不包含在信任仓库中,所以撤回或 roate 比较容易,因此通过 intermediate CA,就实现了 certificate issuance 的在线和自动化(online and automated)。 这种 leaf、intermediate、root 组成的证书捆绑(bundle)机制,形成了一个证书链(certificate chain)。 leaf 由 intermediate 签发, intermediate 又由 root 签发, root 自签名(signs itself)。 技术上来说,上面都是简化的例子,你可以创建更长的 chain 和更复杂的图(例如,cross-certification)。但不推荐这么做,因为复杂性很快会失控。在任何情况下,end entity certificates 都是叶子节点,这也是称为叶子证书(leaf certificate)的原因。 当配置一个 subscriber 时(例如,Apache、Nginx、Linkderd、Envoy),通常不仅需要叶子证书,还需要一个包含了 intermediates 的 certificate bundle。 有时会用 PKCS#7 和 PKCS#12,因为它们能包含一个完整的证书链(certificate chain)。 更多情况下,证书链编码成一个简单的空行隔开的 PEM 对象(sequence of line-separated PEM objects)。 Some stuff expects the certs to be ordered from leaf to root, other stuff expects root to leaf, and some stuff doesn't care.More annoying inconsistency. Google and Stack Overflow help here. Or trial and error. 下面是一个例子: $ cat server.crt -----BEGIN CERTIFICATE----- MIICFDCCAbmgAwIBAgIRANE187UXf5fn5TgXSq65CMQwCgYIKoZIzj0EAwIwHzEd ... MBsGA1UEAxMUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwHhcNMTgxMjA1MTc0OTQ0WhcN HO3iTsozZsCuqA34HMaqXveiEie4AiEAhUjjb7vCGuPpTmn8HenA5hJplr+Ql8s1 d+SmYsT0jDU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIBuzCCAWKgAwIBAgIRAKBv/7Xs6GPAK4Y8z4udSbswCgYIKoZIzj0EAwIwFzEV ... BRvPAJZb+soYP0tnObqWdplmO+krWmHqCWtK8hcCIHS/es7GBEj3bmGMus+8n4Q1 x8YmK7ASLmSCffCTct9Y -----END CERTIFICATE----- Again, annoying and baroque, but not rocket science. RP:Certificate path validation 由于 intermediate certificates 并未包含在信任仓库中,因此需要与leaf certificates 一样分发和验证。 前面已经介绍,配置 subscriber 时需要提供这些 intermediates,subscribers 随后再将它们传给 RP。 如果使用 TLS,那这个过程发生在 TLS 握手时。 当一个 subscriber 将它的证书发给 relying party 时,其中会包含所有能证明来自信任的根证书的 intermediates。 relying party 通过一个称为 certificate path validation 的过程来验证 leaf 和 intermediate certificates 。 完整的 certificate path validation算法比较复杂。包括了 checking certificate expirations revocation status various certificate policies key use restrictions a bunch of other stuff 显然,PKI RP 准确实现这个算法是非常关键的。 如果关闭 certificate path validation(例如,curl -k),用户将面临重大风险,所以不要关闭。 完成正确的 TLS 并没有那么难,certificate path validation 是TLS 中完成认证(authentication)的部分。 可能有人会说,channel 已经是加密的了,因此关闭没关系 —— 错,有关系。没有认证(authentication)的加密是毫无价值的 —— 这就像在教堂忏悔:你说的话都是私密的,但却并不知道帘幕后面的人是谁 —— 只不过这里不是教堂,而是互联网。 秘钥和证书的生命周期 在能通过 TLS 等协议使用证书之前,要先配置如何从 CA 获取一个证书。逻辑上来说这是一个相当简单的过程: 需要证书的 subscriber 自己先生成一个 key pair,然后通过请求发送给 CA, CA 检查其中关联的 name 是否正确,如果正确就签名并返回一个证书。 证书会过期,过期的证书就不会被 RP 信任了。如果证书快过期了而还想继续用它,就需要续期(renew )并轮转(rotate)它。如果想在一个证书过期之前就让 RP 停止信任它,就需要执行撤销(revoke)。 与 PKI 相关的大部分东西一样,这些看似简单的过程实际上都充满坑。其中也隐藏了计算机科学中最难的两个问题:缓存一致性和命名(naming)。但另一方面,一旦理解了背后的原理,再反过来看实际在用的一些东西就简单多了。 Naming things(命名相关) DN (distinguished names) 历史上,X.509 使用 X.500 distinguished names (DN) 来命名证书的使用者(name the subject of a certificate),即 subscriber。一个 DN 包含了一个 common name (对作者我来说,就是 “Mike Malone”),此外还可以包含locality、country、organization、organizational unit 及其他一些东西(数字电话簿相关)。 没人理解 DN,它在互联网上也没什么意义。 应该避免使用 DN。如果真的要用,也要尽量保持简单。 无需使用全部字段,实际上,也不应该使用全部字段。 common name 可能就是需要用到的全部字段了,如果你是一个 thrill seeker ,可以在用上一个 organization name。 PKIX 规定一个网站的 DNS hostname 应该关联到 DN common name。最近,CAB Forum 已经废弃了这个规定,使整个 DN 字段变成可选的(Baseline Requirements, sections 7.1.4.2)。 SAN (subject alternative name) 现代最佳实践使用subject alternative name (SAN) X.509 extension来 bind 证书中的 name。 常用的 SAN 有四种类型,绑定的都是广泛使用的名字: domain names (DNS) email addresse IP addresse URI 在我们讨论的上下文中,这些都是唯一的,而且它们能很好地映射到我们想识别的东西: email addresses for people domain names and IP addresses for machines and code, URIs if you want to get fancy 应该使用 SAN。 注意,Web PKI 允许在一个证书内 bind 多个 name,name 也也允许通配符。也就是说, 一个证书可以有多个 SNA,也可以有类似 *.smallstep.com 这样的 SAN。 这对有多个域名的的网站来说很有用。 生成 key pairs 有了 name 之后,需要先生成一个密钥对,然后才能创建证书。前面提到:PKI 的安全性在根本上取决于一个简单的事实:只有与证书中的 subscriber name 对应的 entity,才应该拥有与该证书对应的私钥。为确保这个条件成立, 最佳实践是让 subscriber 生成它自己的密钥对,这样就只有它自己知道私钥。 绝对应该避免通过网络发送私钥。 生成证书时使用什么类型的秘钥?这一主题值得单独写一篇文章,这里只提供一点快速指导(截止 2018.12)。 如今有一个缓慢但清晰的从 RSA 转向椭圆曲线秘钥的趋势(ECDSA或 EdDSA)。 如果决定使用 RSA 秘钥,确保它们至少是 2048 比特长,但也不要超过 4096 比特。 如果使用 ECDSA,那 P-256 曲线可能是最好选择(secp256k1 or prime256v1 in openssl),除非你担心 NSA,这种情况下你可以选择更 fancier 一些的东西,例如 EdDSA with Curve25519(但对这些秘钥的支持还不是太好)。 下面是用 openssl 生成一个椭圆曲线 P-256 key pair 的例子: $ openssl ecparam -name prime256v1 -genkey -out k.prv $ openssl ec -in k.prv -pubout -out k.pub #也可以用 step 生成 $ step crypto keypair --kty EC --curve P-256 k.pub k.prv 还可以通过编程来生成这些证书,这样能做到证书不落磁盘。 Issuance(确保证书中的信息都是对的) subscriber 有了一个 name 和一对 key 之后,下一步就是从 CA 获取一个 leaf certificate。 对 CA 来说,它需要认证(证明)两件事情: subscriber 证书中的公钥,确实是该 subscriber 的公钥(例如,验证该 subscriber 知道对应的私钥);这一步通常通过一个简单的技术机制实现:证书签名请求(certificate signing request, CSR)。 证书中将要绑定的 name,确实是该 subscriber 的 name。这一步要难很多。抽象来说,这个过程称为 identity proofing(身份证明)或 registration(注册). Certificate signing requests(证书签名请求,PKCS#10) Subscriber 请求一个证书时,会向 CA 会提交一个 certificate signing request (CSR)。 CSR 也是一个 ASN.1 结构,定义在 PKCS#10。 与证书类似,CSR 数据结构包括一个公钥、一个名字和一个签名。 CSR 是自签名的,用与 CRS 中公钥对应的私钥自签名。 这个签名用于证明该 subscriber 有对应的私钥,能对任何用其公钥加密的东西进行解密。 还使即使 CSR 被复制或转发,都没有能篡改其中的内容(篡改无效)。 CSR 中包括了很多证书细节配置项。但在实际中,大部分配置项都会被 CA 忽略。大部分 CA 都使用自己的固定模板,或提供一个 administrative 接口来收集这些信息。 用 step 命令创建一个密钥对和 CSR 的例子: $ step certificate create -csr test.smallstep.com test.csr test.key OpenSSL 功能也非常强大,但 用起来不够方便。 Identity proofing(身份证明过程) CA 收到一个 CSR 并验证签名之后,接下来需要确认证书中绑定的 name 是否真的是这个 subscriber 的 name。这项工作很棘手。证书的核心功能是能让 RP 对 subscriber 进行认证。因此,如果一个证书都还没有颁发,CA 如何对这个 subscriber 进行认证呢? 答案是:分情况。 Web PKI 证明身份过程 Web PKI 有三种类型的证书,它们最大的区别就是如何识别 subscriber,以及它们所用到的 identity proofing 机制。 这三种证书是: domain validation (DV,域验证) DV 证书绑定的是 DNS name,CA 在颁发时需要验证的这个 domain name 确实是由该 subscriber 控制的。 证明过程通常是通过一个简单的流程,例如 给 WHOIS 记录中该 domain name 的管理员发送一封确认邮件。 ACME protocol(最初由 Let's Encrypt 开发和使用)改进了这种方式,更加自动化:不再用邮件验证,而是由 ACME CA 提出一个 challenge,该 subscriber 通过完成这个问题来证明它拥有这个域名。challenge 部分属于 ACME 规范的扩展部门,常见的包括: 在指定的 URL 上提供一个随机数(HTTP challenge) 在 DNS TXT 记录中放置一个随机数(DNS challenge) organization validation (OV,组织验证) OV 和下面将介绍的 EV 证书构建在 DV 证书之上,它们包括了 name 和域名所属组织的位置信息(location)。 OV 和 EV 证书不仅仅将证书关联到域名,还关联到控制这个域名的法律实体(legal entity)。 OV 证书的验证过程,不同的 CA 并不统一。为解决这个问题,CAB Forum 引入了 EV 证书。 extended validation (EV,扩展验证) EV 证书包含的基本信息与 OV 是一样的,但强制要求严格验证(identity proofing)。 EV 过程需要几天或几个星期,其中可能包括公网记录搜索(public records searches)和公司人员(用笔)签署的(纸质)证词。 这些完成之后,当相应网站时,某些浏览器会在 URL 栏中显示该组织的名称。例如: 但除了这个场景之外,EV certificates 并未得到广泛使用,Web PKI RP 也未强依赖它。 本质上来说,每个 Web PKI RP 只需要 DV 级别的 assurance 就行了,也就是确保域名是被该 subscriber 控制的。重要的是能理解一个 DV 证书在设计上的意思和在实际上做了什么: 在设计上,希望通过它证明:请求这个证书的 entity 拥有对应的域名; 在实际上,真正完成的操作是:在某个时间,请求这个证书的 entity 能读一封邮件,或配置一条 DNS 记录,或能通过 HTTP serve 一个指定随机数等等。 但话说回来,DNS、电子邮件和 BGP 这些底层基础设施本身的安全性也并没有做到足够好,针对这些基础设施的攻击还是时有发生,目的之一就是获取证书。 Internal PKI 证明身份过程 上面是 Web PKI 的身份证明过程,再来看 internal PKI 的身份证明过程。 实际上,用户可以使用任何方式来做 internal PKI 的 identity proofing,并且效果可能比 Web PKI 依赖 DNS 或邮件方式的效果更好。 乍听起来好像很难,但其实不难,因为可以利用已有的受信基础设施:用来搭建基础设施的工具,也能用来为这些基础设施之上的服务创建和证明安全身份。 如果用户已经信任 Chef/Puppet/Ansible/Kubernetes,允许它们将代码放到服务器上,那也应该信任它们能完成 identity attestations 如果在 AWS 上,可以用 instance identity documents 如果在 GCP:GCP Azure provisioning infrastructure 必须理解 identity 的概念,这样才能将正确的代码放到正确的位置。此外,用户必须信任这套机制。基于这些知识和信任,才能配置 RP 信任仓库、将 subscribers 纳入你的 internal PKI 管理范围。而完成这些功能全部所需做的就是:设计和实现某种方式,能让provisioning infrastructure 在每个服务启动时,能将它们的 identity 告诉你的 CA。顺便说一句,这正是step certificates 解决的事情。 Expiration(过期) 证书通常都会过期。虽然这不是强制规定,但一般都这么做。设置一个过期时间非常重要, 证书都是分散在各处的:通常 RP 在验证一个证书时,并没有某个中心式权威能感知到(这个操作)。 如果没有过期时间,证书将永久有效。 安全领域的一条经验就是:时间过的越久,凭证被泄露的概率就越接近 100%。 因此,设置过期时间非常重要。具体来说,X.509 证书中包含一个有效时间范围: issued at not before not after:过了这个时间,证书就过期了。 这个机制看起来设计良好,但实际上也是有一些不足的: 首先,没有什么能阻止 RP 错误地(或因为糟糕的设计)接受一个过期证书; 其次,证书是分散的。验证证书是否过期是每个 RP 的责任,而有时它们会出乱子。例如,RP 依赖的系统时钟不对时。最坏的情况就是系统时钟被重置为了 unix epoch(1970.1.1),此时它无法信任任何证书。 在 subscriber 侧,证书过期后,私钥要处理得当: 如果一个密钥对之前是用来签名/认证的(例如,基于 TLS), 应该在不需要这个密钥对之后,立即删除私钥。 保留已经失效的签名秘钥(signing key)会导致不必要的风险:对谁都已经没有用处,反而会被拿去仿冒签名。 如果密钥对是用来加密的,情况就不同了。 只要还有数据是用这个加密过的,就需要留着这个私钥。 这就是为什么很多人会说,不要用同一组秘钥来同时做签名和加密(signing and encryption)。因为当一个用于签名的私钥过期时,无法实现秘钥生命周期的最佳管理:最终不得不保留着这个私钥,因为解密还要用它。 Renewal(续期) 证书快过期时,如果还想继续使用,就需要续期。 Web PKI 证书续期 Web PKI 实际上并没有标准的续期过期: 没有一个标准方式来延长证书的合法时间, 一般是直接用一个新证书替换过期的。 因此续期过程和 issuance 过程是一样的:生成并提交一个 CSR,然后完成 identity proofing。 Internal PKI 证书续期 对于 internal PKI 我们能做的更好。 最简单的方式是: 用 mTLS 之类的协议对老证书续期。 CA 能对 subscriber 提供的客户端证书进行认证(authenticate),重签一个更长的时间,然后返回这个证书。 这使得续期过程很容易自动化,而且强制 subscriber 定期与中心权威保持沟通。 基于这种机制能轻松构建一个证书的监控和撤销基础设施。 小结 证书的续期过程其实并不是太难,最难的是记得续期这件事。 几乎每个管理过公网证书的人,都经历过证书过期导致的生产事故,例如这个。我的建议是: 发现问题之后,一定要全面排查,解决能发现的所有此类问题。 另外,使用生命周期比较短的证书。这会反过来逼迫你们优化和自动化整个流程。 Let's Encrypt 使自动化非常容易,它签发 90 天有效期的证书,因此对 Web PKI 来说非常合适。对于 internal PKI,建议有效期签的更短:24 小时或更短。有一些实现上的挑战 ——hitless certificate rotation可能比较棘手 —— 但这些工作是值得的。 用 step 检查证书过期时间: step certificate inspect cert.pem --format json | jq .validity.end step certificate inspect https://smallstep.com --format json | jq .validity.end 将这种命令行封装到监控采集脚本,就可以实现某种程度的监控和自动化。 Revocation(撤销) 如果一个私钥泄露了,或者一个证书已经不再用了,就需要撤销它。即希望: 明确地将其标记为非法的, 所有 RP 都不再信任这个证书了,即使它还未过期。 但实际上,撤销证书过程也是一团糟。 主动撤销的困难 与过期类似,执行撤回的职责在 RP。 与过期不同的是,撤销状态无法编码在证书中。RP 只能依靠某些带外过程(out-of-band process)来判断证书的撤销状态。 除非显式配置,否则大部分 Web PKI TLS RP 并不关注撤销状态。换句话说,默认情况下,大部分 TLS 实现都乐于接受已经撤销的证书。 Internal PKI:被动撤销机制 Internal PKI 的趋势是接受这个现实,然后试图通过被动撤销(passive revocation)机制来弥补,具体来说就是签发生命周期很短的证书,这样就使撤销过程变得不再那么重要了。想撤销一个证书时,直接不给它续期就行了,过一段时间就会自动过期。 可以看到,这个机制有效的前提就是使用生命周期很短的证书。具体有多短? 取决于你的威胁模型(安全专家说了算)。 24 小时是很常见的,但也有短到 5 分钟的。 如果生命周期太短,显然也会给可扩展性和可用性带来挑战:每次续期都需要与 online CA 交互,因此 CA 有性能压力。 如果缩短了证书的生命周期,记得确保你的时钟是同步的,否则就有罪受了。 对于 web 和其他的被动撤销不适合的场景,如果认真思考之后发现真的需要撤销功能,那有两个选择: CRL(,证书撤销列表,RFC 5280) OCSP(Online Certificate Signing Protocol,在线证书签名协议,RFC 2560) 主动检查机制:CRL(Certificate Revocation Lists) CRL 定义在 RFC 5280 中,这是一个相当庞杂的 RFC,还定义了很多其他东西。简单来是,CRL 是一个有符号整数序列,用来识别已撤销的证书。 这个维护在一个 CRL distribution point 服务中,每个证书中都包含指向这个服务的 URL。工作流程:每个 RP 下载这个列表并缓存到本地,在对证书进行验证时,从本地缓存查询撤销状态。但这里也有一些明显的问题: CRL 可能很大, distribution point 也可能失效。 RP 的 CRL 缓存同步经常是天级的,因此如果一个证书撤销了,可能要几天之后才能同步到这个状态。 此外,RP fail open 也很常见 —— CRL distribution point 挂了之后,就接受这个证书。这显然是一个安全问题:只要对 CRL distribution point 发起 DDoS 攻击,就能让 RP 接受一个已经撤销的证书。 因此,即使已经在用 CRL,也应该考虑使用短时证书来保持 CRL size 比较小。CRL 只需要包含已撤销但还未过期的证书的 serial numbers,因此证书生命周期越短,CRL 越短。 主动检查机制:OCSP(Online Certificate Signing Protocol) 主动检查机制除了 CRL 之外,另一个选择是 OCSP,它允许 RP 实时查询一个 OCSP responder:指定证书的 serial number 来获取这个证书的撤销状态。 与 CRL distribution point 类似,OCSP responder URL 也包含在证书中。这样看,OCSP 似乎更加友好,但实际上它也有自己的问题。对于 Web PKI,它引入了验证的隐私问题: 每次查询 OCSP responder,使得它能看到我正在访问哪个网站。 此外,它还增加了每个 TLS 连接的开销:需要一个额外请求来检查证实的撤销状态。 与 CRL 一样,很多 RPs (including browsers) 会在 OCSP responder 失效时直接认为证书有效(未撤销)。 主动检查机制:OCSP stapling(合订,绑定) OCSP stapling 是 OCSP 的一个变种,目的是解决以上提到的那些问题。 相比于让 RP 每次都去查询 OCSP responder,OCSP stapling 中让证书的 subscriber 来做这件事情。OCSP response 是一个经过签名的、时间较短的证词(signed attestation),证明这个证书未被撤销。 attestation 包含在 subscriber 和 RP 的 TLS handshake ("stapled to" the certificate) 中。这给 RP 提供了相对比较及时的撤销状态,而不用每次都去查询 OCSP responder。subscriber 可以在 signed OCSP response 过期之前多次使用它。这减少了 OCSP 的负担,也解决了 OCSP 的隐私问题。 但是,所有这些东西其实最终都像是一个 鲁布·戈德堡装置(Rube Goldberg Device) , 鲁布·戈德堡机械(Rube Goldberg machine)是一种被设计得过度复杂的机械组合,以迂回曲折的方法去完成一些其实是非常简单的工作,例如倒一杯茶,或打一只蛋等等。设计者必须计算精确,令机械的每个部件都能够准确发挥功用,因为任何一个环节出错,都极有可能令原定的任务不能达成。解释来自 知乎。 如果让 subscribers 去 CA 获取一些生命周期很短的证词(signed attestation)来证明对应的证书并没有过期,为什么不直接干掉中间环节,直接使用生命周期很短的证书呢? 使用证书 虽然理解 PKI 需要以上长篇大论,但在实际中用证书其实是非常简单的。 下面以 TLS 为例,其他方式也是类似的: 配置 PKI relying party 使用哪个根证书;对于 Web PKI,通常已经默认配置了正确的根证书,这一步可以跳过。 配置 PKI subscriber 使用哪个证书和私钥(或如何生成自己的密钥对、如何提交 CSR);某个 entity (code, device, server, etc) 既是 RP 又是 subscriber 是很常见的。这样的 entities 需要同时配置根证书、证书和私钥。 下面是个完整例子,展示 certificate issuance, root certificate distribution, and TLS client (RP) and server (subscriber) configuration: 希望这展示了使用 internal PKI 和 TLS 是如何简单直接。 有了这样的基础,就无需使用自签名的证书或做一些危险的事情,例如禁用 certificate path validation(curl -k)。 几乎每个 TLS client/server 都支持这些参数;但是,它们又几乎都不关注秘钥和证书的声明周期:都假设证书会出现在磁盘上的恰当位置,有人或服务会帮它们完成 rotate等工作。这项生命周期相关的工作才是难点。 结束语 公钥加密系统使计算机能在网络上看到对方("see" across networks)。 如果我有公钥,就能“看到”你有对应的私钥,但我自己是无法使用这个私钥的。 如果还没有对方的公钥,就需要证书来帮忙。证书将公钥和私钥拥有者的名字(name)相关联,它们就像是计算机和代码的驾照。 证书权威(CA)用它们的私钥对证书进行签名,对这些绑定关系作出担保,它们就像是车管局(DMV), 如果你出示一张车管局颁发的驾照,脸长得也和驾照上的照片一样,那别人就可以认为你就是驾照上这个人(名字)。同理,如果你是唯一知道某个秘钥的 entity,你给我的证书也是从我信任的某个 CA 来的,那我就认为证书中的 name 就是你。 现实中, 大部分证书都是 X.509 v3 证书,用 ASN.1 格式定义,通常序列化为 PEM-encoded DER。 相应的私钥通常表示为 PKCS#8 objects,也序列化为 PEM-encoded DER。 如果你用 Java 或微软的产品,可能会遇到 PKCS#7 and PKCS#12 封装格式。 加密领域有沉重的历史包袱,使当前的这些东西学起来、用起来非常让人沮丧,这比一项技术因为太难而不想学更加令人沮丧。 PKI 是使用公钥基础设施时涉及到的所有东西的统称:names, key types,certificates, CAs, cron jobs, libraries 等。 Web PKI 是浏览器默认使用的 PKI。Web PKI CA 是受信但不可靠的(trusted but not trustworthy)。 Internal PKI 是用户自己构建和维护的 PKI。需要它是因为 Web PKI 并不是针对 internal 使用场景设计的,Internal PKI 更易于自动化和扩展,并且能让用户控制很多细节,例如 naming and certificate lifetime。 建议公网上使用 Web PKI,内网使用自己的 internal PKI(例如,use TLS 来替代 VPN)。 Smallstep Certificate Manager 使构建 internal PKI 非常简单。 要获得一个证书,需要命令和生成证书。建议 name 用 SAN: DNS SANs for code and machines EMAIL SANs for people 如果这些都不能用,就用 URI SAN 秘钥类型(key type)是很大一个主题,但几乎不重要:你可以随便修改秘钥类型,而且实际上加密本身(crypto)并不是 PKI 中最弱的一环。 要从 CA 获取一个证书,需要提交一个 CSR 并证明申请者的身份(identity)。使用生命周期较短的证书和 passive revocation。自动化证书续期过程。不要禁用 certificate path validation。 最后还是那句话:证书和 PKI 将名字关联到公钥(bind names to public keys)。其他都是细节。 延伸阅读(译注) 更多相关内容或实践,推荐: Illustrated X.509 Certificate,2020 超详细图解 X.509 证书。 Cilium TLS inspection,2021 图解 X.509 证书、信任链,及 Cilium/hubble L7 实战。 What Is an X.509 Certificate? What to Know About PKI Certificates 本文转载自 http://arthurchiao.art/blog/everything-about-pki-zh/ 。 2021-10-12 2 0
How I investigated memory leaks in Go using pprof on a large codebase 此文为转载,原地址见文章下方。 I have been working with Go for the better part of the year, implementing a scalable blockchain infrastructure at Orbs, and it’s been an exciting year. Over the course of 2018, we researched on which language to choose for our blockchain implementation. This led us to choose Go because of our understanding that it has a good community and an amazing tool-set. In recent weeks we are entering the final stages of integration of our system. As in any large system, the later stage problems which include performance issues, in specific memory leaks, may occur. As we were integrating the system, we realized we found one. In this article I will touch the specifics of how to investigate a memory leak in Go, detailing the steps taken to find, understand and resolve it. The tool-set offered by Golang is exceptional but has its limitations. Touching these first, the biggest one is the limited ability to investigate full core dumps. A full core dump would be the image of the memory (or user-memory) taken by the process running the program. We can imagine the memory mapping as a tree, and traversing that tree would take us through the different allocations of objects and the relations. This means that whatever is at the root is the reason for ‘holding’ the memory and not GCing it (Garbage Collecting). Since in Go there is no simple way to analyze the full core dump, getting to the roots of an object that does not get GC-ed is difficult. At the time of writing, we were unable to find any tool online that can assist us with that. Since there exists a core dump format and a simple enough way to export it from the debug package, it could be that there is one used at Google. Searching online it looks like it is in the Golang pipeline, creating a core dump viewer of such, but doesn’t look like anyone is working on it. Having said that, even without access to such a solution, with the existing tools we can usually get to the root cause. Memory Leaks Memory leaks, or memory pressure, can come in many forms throughout the system. Usually we address them as bugs, but sometimes their root cause may be in design decisions. As we build our system under emerging design principles, such considerations are not believed to be of importance and that is okay. It is more important to build the system in a way that avoids premature optimizations and enables you to perform them later on as the code matures, rather than over engineer it from the get-go. Still, some common examples of seeing memory pressure issues materialize are: Too many allocations, incorrect data representation Heavy usage of reflection or strings Using globals Orphaned, never-ending goroutines In Go, the simplest way to create a memory leak is defining a global variable, array, and appending data to that array. This great blog post describes that case in a good way. So why am I writing this post? When I was researching into this case I found many resources about memory leaks. Yet, in reality systems have more than 50 lines of code and a single struct. In such cases, finding the source of a memory issue is much more complex than what that example describes. Golang gives us an amazing tool called pprof. This tool, when mastered, can assist in investigating and most likely finding any memory issue. Another purpose it has is for investigating CPU issues, but I will not go into anything related to CPU in this post. go tool pprof Covering everything that this tool does will require more than one blog post. One thing that took a while is finding out how to use this tool to get something actionable. I will concentrate this post on the memory related feature of it. The pprof package creates a heap sampled dump file, which you can later analyze / visualize to give you a map of both: Current memory allocations Total (cumulative) memory allocations The tool has the ability to compare snapshots. This can enable you to compare a time diff display of what happened right now and 30 seconds ago, for example. For stress scenarios this can be useful to assist in locating problematic areas of your code. pprof profiles The way pprof works is using profiles. A Profile is a collection of stack traces showing the call sequences that led to instances of a particular event, such as allocation. The file runtime/pprof/pprof.go contains the detailed information and implementation of the profiles. Go has several built in profiles for us to use in common cases: goroutine — stack traces of all current goroutines heap — a sampling of memory allocations of live objects allocs — a sampling of all past memory allocations threadcreate — stack traces that led to the creation of new OS threads block — stack traces that led to blocking on synchronization primitives mutex — stack traces of holders of contended mutexes When looking at memory issues, we will concentrate on the heap profile. The allocs profile is identical in regards of the data collection it does. The difference between the two is the way the pprof tool reads there at start time. Allocs profile will start pprof in a mode which displays the total number of bytes allocated since the program began (including garbage-collected bytes). We will usually use that mode when trying to make our code more efficient. The heap In abstract, this is where the OS (Operating System) stores the memory of objects our code uses. This is the memory which later gets ‘garbage collected’, or freed manually in non-garbage collected languages. The heap is not the only place where memory allocations happen, some memory is also allocated in the Stack. The Stack purpose is short term. In Go the stack is usually used for assignments which happen inside the closure of a function. Another place where Go uses the stack is when the compiler ‘knows’ how much memory needs to be reserved before run-time (e.g. fixed size arrays). There is a way to run the Go compiler so it will output an analysis of where allocations ‘escape’ the stack to the heap, but I will not touch that in this post. While heap data needs to be ‘freed’ and gc-ed, stack data does not. This means it is much more efficient to use the stack where possible. This is an abstract of the different locations where memory allocation happens. There is a lot more to it but this will be outside the scope for this post. Obtaining heap data with pprof There are two main ways of obtaining the data for this tool. The first will usually be part of a test or a branch and includes importing runtime/pprof and then calling pprof.WriteHeapProfile(some_file) to write the heap information. Note that WriteHeapProfile is syntactic sugar for running: // lookup takes a profile namepprof.Lookup("heap").WriteTo(some_file, 0) According to the docs, WriteHeapProfile exists for backwards compatibility. The rest of the profiles do not have such shortcuts and you must use the Lookup() function to get their profile data. The second, which is the more interesting one, is to enable it over HTTP (web based endpoints). This allows you to extract the data adhoc, from a running container in your e2e / test environment or even from ‘production’. This is one more place where the Go runtime and tool-set excels. The entire package documentation is found here, but the TL;DR is you will need to add it to your code as such: import ( "net/http" _ "net/http/pprof") ... func main() { ... http.ListenAndServe("localhost:8080", nil)} The ‘side effect’ of importing net/http/pprof is the registration the pprof endpoints under the web server root at /debug/pprof. Now using curl we can get the heap information files to investigate: curl -sK -v http://localhost:8080/debug/pprof/heap > heap.out Adding the http.ListenAndServe() above is only required if your program did not have a http listener before. If you do have one it will hook on it and there is no need to listen again. There are also ways to set it up using a ServeMux.HandleFunc() which would make more sense to a more complex http-enabled program. Using pprof So we have collected the data, what now? As mentioned above, there are two main memory analysis strategies with pprof. One is around looking at the current allocations (bytes or object count), called inuse. The other is looking at all the allocated bytes or object count throughout the run-time of the program, called alloc. This means regardless if it was gc-ed, a summation of everything sampled. This is a good place to reiterate that the heap profile is a sampling of memory allocations. pprof behind the scenes is using the runtime.MemProfile function, which by default collects allocation information on each 512KB of allocated bytes. It is possible to change MemProfile to collect information on all objects. Note that most likely, this will slow down your application. This means that by default, there is some chance that a problem may happen with smaller objects that will slip under pprof’s radar. For a large codebase / long-running program, this is not an issue. Once we collected the profile file, it is time to load it into the interactive console pprof offers. Do so by running: > go tool pprof heap.out Let's observe the information displayed Type: inuse_spaceTime: Jan 22, 2019 at 1:08pm (IST)Entering interactive mode (type "help" for commands, "o" for options)(pprof) The important thing to note here is the Type: inuse_space . This means we are looking at allocation data of a specific moment (when we captured the profile). The type is the configuration value of sample_index, and the possible values are: inuse_space — amount of memory allocated and not released yet inuse_object s— amount of objects allocated and not released yet alloc_space — total amount of memory allocated (regardless of released) alloc_objects — total amount of objects allocated (regardless of released) Now type top in the interactive, the output will be the top memory consumers We can see a line telling us about Dropped Nodes, this means they are filtered out. A node is an object entry, or a ‘node’ in the tree. Dropping nodes is a good idea to reduce some noise, but sometimes it may hide the root cause of a memory issue. We will see an example of that as we continue our investigation. If you want to include all data of the profile, add the -nodefraction=0 option when running pprof or type nodefraction=0 in the interactive. In the outputted list we can see two values, flat and cum. flat means that the memory allocated by this function and is held by that function cum means that the memory was allocated by this function or function that it called down the stack This information alone can sometimes help us understand if there is a problem. Take for example a case where a function is responsible of allocating a lot of memory but is not holding it. This would mean that some other object is pointing to that memory and keeping it allocated, meaning we may have a system design issue or a bug. Another neat trick about top in the interactive window is that it is actually running top10. The top command supports topN format where N is the number of entries you want to see. In the case pasted above, typing top70 for example, would output all nodes. Visualizations While the topN gives a textual list, there are several very useful visualization options that come with pprof. It is possible to type png or gif and many more (see go tool pprof -help for a full list). On our system, the default visual output looks something like: This may be intimidating at first, but it is the visualization of memory allocation flows (according to stack traces) in a program. Reading the graph is not as complicated as it looks. A white square with a number shows allocated space (and the cumulative of how much memory it’s taking right now on the edge of the graph), and each wider rectangle shows the allocating function. Note that in the above image, I took a png off a inuse_space execution mode. Many times you should also take a look at inuse_objects as well, as it can assist with finding allocation issues. Digging deeper, finding a root cause So far, we were able to understand what is allocating memory in our application during runtime. This helps us get a feeling of how our program behaves (or misbehaves). In our case, we could see that memory is retained by membuffers, which is our data serialization library. This does not mean that we have a memory leak at that code segment, it means that the memory is being retained by that function. It is important to understand how to read the graph, and the pprof output in general . In this case, understanding that when we serialize data, meaning that we allocate memory to structs and primitive objects (int, string), it is never released. Jumping to conclusions or misinterpreting the graph, we could have assumed that one of the nodes on the path to serialization is responsible to retaining the memory, for example: subset of the graph Somewhere in the chain we can see our logging library, responsible for >50MB of allocated memory. This is memory which is allocated by functions called by our logger. Thinking it through, this is actually expected. The logger causes memory allocations as it needs to serialize data for outputting it to the log and thus it is causing memory allocations in the process. We can also see that down the allocation path, the memory is only retained by serialization and nothing else. Additionally, the amount of memory retained by the logger is about 30% of the total. The above tells us that, most likely, the problem is not with the logger. If it was 100%, or something close to it, then we should have been looking there — but it’s not. What it could mean is that something is being logged that shouldn’t be, but it is not a memory leak by the logger. This is a good time to introduce another pprof command called list. It accepts a regular expression which will be a filter of what to list. The ‘list’ is in actual the annotated source code related to the allocation. In the context of the logger which we are looking into, we will execute list RequestNew as we would like to see the calls made to the logger. These calls are coming from two functions which happen to begin with the same prefix. We can see that the allocations made are sitting in the cum column, meaning the memory allocated is retained down the call stack. This correlates to what the graph also shows. At that point it is easy to see that the reason the logger was allocating the memory is because we sent it the entire ‘block’ object. It needed to serialize some parts of it at the very least (our objects are membuffer objects, which always implement some String() function). Is it a useful log message, or good practice? Probably not, but it is not a memory leak, not at the logger end or the code which called the logger. list can find the source code when searching for it under your GOPATH environment. In cases where the root it is searching for does not match, which depends on your build machine, you can use the -trim_path option. This will assist with fixing it and letting you see the annotated source code. Remember to set your git to the right commit which was running when the heap profile was captured. So why is memory retained? The background to this investigation was the suspicion that we have a problem — a memory leak. We came to that notion as we saw memory consumption was higher than what we would expect the system to need. On top of that, we saw it ever increasing, which was another strong indicator for ‘there is a problem here’. At this point, in the case of Java or .Net, we would open some ‘gc roots’ analysis or profiler and get to the actual object which is referencing to that data, and is creating the leak. As explained, this is not exactly possible with Go, both because of a tooling issue but also because of Go’s low level memory representation. Without going into details, we do not think Go retains which object is stored at which address (except for pointers maybe). This means that in actual, understanding which memory address represents which member of your object (struct) will require some sort of mapping to the output of a heap profile. Talking theory, this could mean that before taking a full core dump, one should also take a heap profile so the addresses can be mapped to the allocating line and file and thus the object represented in the memory. At this point, because we are familiar with our system, it was easy to understand this is not a bug anymore. It was (almost) by design. But let’s continue to explore how to get the information from the tools (pprof) to find the root cause. When setting nodefraction=0 we will get to see the entire map of the allocated objects, including the smaller ones. Let’s look at the output: memory visualization with nodefraction=0 We have two new subtrees. Reminding again, pprof heap profile is sampling memory allocations. For our system that works — we are not missing any important information. The longer new tree, in green, which is completely disconnected from the rest of the system is the test runner, it is not interesting. system was configured to “leak” ? The shorter one, in blue, which has an edge connecting it to the entire system is inMemoryBlockPersistance . That name also explains the ‘leak’ we imagined we have. This is the data backend, which is storing all data in memory and not persisting to disk. Whats nice to note is that we could see immediately that it is holding two large objects. Why two? Because we can see the object is of size 1.28MB and the function is retaining 2.57MB, meaning two of them. The problem is well understood at this point. We could have used delve (the debugger) to see that this is the array holding all blocks for the in-memory persistence driver we have. So what could we fix? Well, that sucked, it was a human error. While the process was educating (and sharing is caring), we did not get any better, or did we? There was one thing that still ‘smelled’ about this heap information. The deserialized data was taking up too much memory, why 142MB for something that should be taking substantially less? . . pprof can answer that — actually, it exists to answer such questions exactly. To look into the annotated source code of the function, we will run list lazy. We use lazy, as the function name we are looking for is lazyCalcOffsets() and we know no other functions in our code begin with lazy. Typing list lazyCalcOffsets would work as well of course. We can see two interesting pieces of information. Again, remember that pprof heap profile samples information about allocations. We can see that both the flat and the cum numbers are the same. This indicates that the memory allocated is also retained by these allocation points. Next, we can see that the make() is taking some memory. That makes sense, it is the pointer to the data structure. Yet we also see the assignment at line 43 is taking up memory, meaning it creates an allocation. This educated us about maps, where an assignment to a map is not a straightforward variable assignment. This article goes into great detail on how map works. In short a map has an overhead, and the more elements the bigger this overhead is going to ‘cost’ when comparing to a slice. The following should be taken with a grain of salt: It would be okay to say that using a map[int]T, when the data is not sparse or can be converted to sequential indices, should usually be attempted with a slice implementation if memory consumption is a relevant consideration. Yet, a large slice, when expanded, might slow down an operation, where in a map this slowdown will be negligible. There is no magic formula for optimizations. In the code above, after checking how we used that map, we realized that while we imagined it to be a sparse array, it came out as not so sparse. This matches the above argument and we could immediately see that a small refactor of changing the map to a slice is actually possible, and might make that code more memory efficient. So we changed it to: As simple as that, instead of using a map we are now using a slice. Because of the way we receive the data which is lazy loaded into it, and how we later access that data, other than these two lines and the struct holding that data, no other code change was required. What did it do to the memory consumption? Let’s look at the benchcmp for just a couple of tests The read tests initialize the data structure, which creates the allocations. We can see that runtime improved by ~30%, allocations are down by 50% and memory consumption by >90% (!) Since the map, now-slice, was never filled with a lot of items, the numbers pretty much show what we will see in production. It depends on the data entropy, but there may be cases where both allocations and memory consumption improvements would have been even greater. Looking at pprof again, and taking a heap profile from the same test we will see that now the memory consumption is in fact down by ~90%. The takeaway will be that for smaller data sets, you shouldn’t use maps where slices would suffice, as maps have a large overhead. Full core dump As mentioned, this is where we see the biggest limitation with tooling right now. When we were investigating this issue we got obsessed with being able to get to the root object, without much success. Go evolves over time at a great pace, but that evolution comes with a price in the case of the full dump or memory representation. The full heap dump format, as it changes, is not backwards compatible. The latest version described here and to write a full heap dump, you can use debug.WriteHeapDump(). Albeit right now we do not find ourselves ‘stuck’ because there is no good solution for exploring full dumps. pprof answered all our questions until now. Do note, the internet remembers a lot of information which is no longer relevant. Here are some things you should ignore if you are going to try and open a full dump yourself, as of go1.11: There is no way to open and debug a full core dump on MacOS, only Linux. The tools at https://github.com/randall77/hprof are for Go1.3, there exists a fork for 1.7+ but it does not work properly either (incomplete). viewcore at https://github.com/golang/debug/tree/master/cmd/viewcore does not really compile. It is easy enough to fix (packages in the internal are pointing to golang.org and not github.com), but, it does not work either, not on MacOS, maybe on Linux. Also https://github.com/randall77/corelib fails on MacOS pprof UI One last detail to be aware of when it comes to pprof, is its UI feature. It can save a lot of time when beginning an investigation into any issue relating to a profile taken with pprof. go tool pprof -http=:8080 heap.out At that point it should open the web browser. If it does not then browse to the port you set it to. It enables you to change the options and get the visual feedback much faster than you can from the command line. A very useful way to consume the information. The UI actually got me familiar with the flame graphs, which expose culprit areas of the code very quickly. Conclusion Go is an exciting language with a very rich toolset, there is a lot more you can do with pprof. This post does not touch CPU profiling at all, for example. Some other good reads: https://rakyll.org/archive/ — I believe this to be one of the go contributors around performance monitoring, a lot of good posts at her blog https://github.com/google/gops — written by JBD (who runs rakyll.org), this tool warrants its own blog post. https://medium.com/@cep21/using-go-1-10-new-trace-features-to-debug-an-integration-test-1dc39e4e812d — go tool trace which is around CPU profiling, this is a great post about that profiling feature. 原文地址:How I investigated memory leaks in Go using pprof on a large codebase 2020-08-01 0 0
TLS handshake Fatal Alert Close Notify的错误排查 前置知识 浏览器同源安全策略 WebSocket\TCP通讯协议 HTTPS TLS 握手机制 CA证书信任链 strace、dtruss系统调用动态跟踪 keychain安全机制 macOS错误日志体系 背景 前段时间接了一个项目----MOA,一款安装在macOS、Win办公电脑软件,是公司内部安全管理客户端,用来保护员工PC安全的软件。 我之前没有接触过macOS的开发,对objective-C(以下简称OC)语言也不熟悉,我这边的几个研发兄弟也一样,都是做Linux C、C++出身,对于这个陌生的领域,他们倒是不拒绝,很乐意去尝试。 在MOA新的版本中,需要具备与SSO单点登陆系统联动,控制内网SSO的准入功能,研发开发完成后,进行了灰度部署。 在灰度时,遇到一个BUG,浏览器的websocket无法与本地wss服务通讯,遇到tls handshake的错误,导致很多人无法登陆SSO,无法正常办公。 研发兄弟们也都是忙于熟悉OC语言,熟悉macOS的运行机制,忙于做业务功能开发,没时间排查这问题,以至于这BUG导致项目延期两周,影响上线进度。产品经理也在周会上表达了不满,开始对研发的技术能力产生怀疑。。。又到了拯救地球的时刻了,我决定来查一下这BUG。 运行机制介绍 关于这个SSO的通讯机制,工作过程是这样的: 假设:SSO的地址是https://sso.mt.com,MOA监听的地址是localhost.moa.mt.com MOA进程创建HTTP SERVER,监听loopback地址的某端口; 在用户打开浏览器登陆SSO时,SSO的域名会返回一段JS给浏览器; 浏览器执行后,会通过Websocket的方式连接loopback的这个端口,进行通讯; 那么问题来了,这种场景下,除了Websocket还有其他方式吗? 浏览器的同源策略对WS、WSS协议有影响吗? 对浏览器安全比较了解的同学很容易就给出了答案,走Websocket是最合适的方案,同源策略并不对Websocket做限制。 但是,浏览器的当前页面的协议是HTTPS时,Websocket也必须是TLS协议才行,也就是说对应WSS协议。 在macOS上开启Websocket,并且还要支持TLS通讯加密,需要在macOS上做证书私钥的部署,我们RD同学考虑到私钥泄露的风险,决定走自签CA证书的方式,在MOA部署时,将CA证书写入到macOS的Keychain根证书链中。 只不过这一步需要弹出KeyChain Access钥匙串访问的授权密码窗口,让用户授权。 现象 在SSO阻断功能的版本灰度后,收到其他部门的反馈,有好多同事的电脑显示SSO无法与MOA通讯,重启电脑后就好了。一共灰度一百多个人,出问题的就有四五个,而且不知道如何重现这个BUG。这就很让人头疼,我为了重现,尝试可很多办法,安装、卸载、再安装、再卸载,循环往复各种尝试重现,这个BUG终于重现了,但我也不知道具体的重现办法。 从技术上描述这个BUG是这样的,macOS上浏览器的https://sso.mt.com/xxx的Websocket无法连接到本地监听的localhost.moa.mt.com地址,报告网络错误。 对于这种错误,稍微对网络通讯debug的同学,都知道用tcpdump或者wireshark来抓包分析,抓到的数据包截图如下: 从这张通讯图上,你能发现问题出在哪了吗? TLS握手到哪个阶段了?谁先关闭的socket?关闭之前都做了什么通讯? Frame 7: 63 bytes on wire (504 bits), 63 bytes captured (504 bits) on interface 0 Null/Loopback Internet Protocol Version 4, Src: localhost (127.0.0.1), Dst: localhost (127.0.0.1) Transmission Control Protocol, Src Port: sun-sea-port (16161), Dst Port: 63655 (63655), Seq: 1, Ack: 518, Len: 7 Transport Layer Security TLSv1.2 Record Layer: Alert (Level: Fatal, Description: Close Notify) Content Type: Alert (21) Version: TLS 1.2 (0x0303) Length: 2 Alert Message Level: Fatal (2) Description: Close Notify (0) 机智如你,是Server给Client发了Level: Fatal, Description: Close Notify错误后,直接发了FIN包,进行关闭TCP SOCKET。 TLS Fatal Alert Close Notify 什么是TLS handshakeFatal Close Notify错误?第一次见到这个错吧?别担心,我也是。。。为什么在TLS 握手时,Client发了Client Hello后,Server没回Server Hello?而是回了这个错误? 在Openssl的文档SSL_shutdown - shut down a TLS/SSL connection 说明中,找到了这个错误的描述 SSL_shutdown() shuts down an active TLS/SSL connection. It sends the close_notify shutdown alert to the peer. SSL_shutdown() tries to send the close_notify shutdown alert to the peer. Whether the operation succeeds or not, the SSL_SENT_SHUTDOWN flag is set and a currently open session is considered closed and good and will be kept in the session cache for further reuse. Note that SSL_shutdown() must not be called if a previous fatal error has occurred on a connection i.e. if SSL_get_error() has returned SSL_ERROR_SYSCALL or SSL_ERROR_SSL. 从openssl官方文档中的SSL_shutdown函数描述里可以找到close_notify的介绍,当调用SSL_shutdown()函数后,将发送close_notify到对应的peer中。不管是client还是server,都可以发送这个错误。 在SSL_alert_type_string函数的介绍如下: A warning alert is sent, when a non-fatal error condition occurs. The "close notify" alert is sent as a warning alert. Other examples for non-fatal errors are certificate errors ("certificate expired", "unsupported certificate"), for which a warning alert may be sent. (The sending party may however decide to send a fatal error.) The receiving side may cancel the connection on reception of a warning alert on it discretion. Several alert messages must be sent as fatal alert messages as specified by the TLS RFC. A fatal alert always leads to a connection abort. 发送方可以决定是否发送Fatal级别的错误,在我们抓包的数据来看,是Moa Client(也就是说Server端)发送了Fatal级别的错误,原因是可能因为certificate expired、unsupported certificate"等等。但具体的错误,还是需要继续排查确认。 排查 那么,既然知道了TLS handshake的Fatal Close Nofity错误是服务端发起的,那么为什么呢?服务端的进程发生了什么问题?这如何排查?进程不能重启,重启后BUG现象就丢失了。 引用IBM developerworks上的一句话: 有时候,需要了解在应用程序内部正在发生的情况。例如,应用程序可能会运行失败,而又没有显示有帮助的错误消息,或者系统服务没有按照预期的方式运行。在这些情况下,您可能不掌握应用程序源代码,因此无法通过传统的调试过程寻找问题的原因。跟踪提供了一种替代方法。 在进行跟踪时,监视应用程序和操作系统之间的交互,常常会检查应用程序在执行期间调用的操作系统函数。 通过跟踪应用程序,可以查明以下方面的情况: 内存使用量和映射内存的调用 在执行期间打开和关闭的文件 对不同文件的读和写操作 为给定的应用程序装载的库 对于遇到的这个问题,我们要想了解到TLS Close Notify之前,都做了些什么操作,正因为我们程序不能重启,不知道如何重现,所以动态跟踪系统调用的做法最符合我们需求。 Linux strace 在Linux上,我们常用strace来对进程进行系统调用的跟踪探测。比如下面的例子 root@cnxct:~#strace -ff -tt -s 1000 -o 111.log -p 800 00:08:40.417260 futex(0x1ddbf08, FUTEX_WAIT_PRIVATE, 0, NULL) = 0 00:08:43.380903 futex(0x1ddbf20, FUTEX_WAIT_PRIVATE, 2, NULL) = 0 00:08:43.381001 setsockopt(232, SOL_TCP, TCP_NODELAY, [1], 4) = -1 EOPNOTSUPP (Operation not supported) 00:08:43.381053 gettid() = 9757 00:08:43.381077 setsockopt(232, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 00:08:43.381102 openat(AT_FDCWD, "/dev/urandom", O_RDONLY) = 252 00:08:43.381134 read(252, "K8\232Nl\26\rU\354\312\221\260\20563+v=\367\372\371\317W\f>.\304\221\342B$\375", 32) = 32 00:08:43.381162 close(252) = 0 00:08:43.381603 recvfrom(232, "\22\0\0\0", 4, MSG_DONTWAIT, NULL, NULL) = 4 00:08:43.381629 recvfrom(232, "\3SET NAMES utf8mb4", 18, MSG_DONTWAIT, NULL, NULL) = 18 能看到,strace的结果中,会有当前系统的CPU时间、syscall函数、参数、返回值等,有了系统时间,则会很方便的跟其他调试信息关联。 macOS dtruss 在macOS上,也有想对应的工具,dtruss,之前调试时,用过几次,命令参数、输出结果跟strace差别太大,总是不习惯。 现在遇到问题了,逃不开,只好开始学习这个工具的使用。 参数上 cfc4n@cnxct-mt:~$ dtruss USAGE: dtruss [-acdefholLs] [-t syscall] { -p PID | -n name | command | -W name } -p PID # examine this PID -n name # examine this process name -t syscall # examine this syscall only -W name # wait for a process matching this name -a # print all details -c # print syscall counts -d # print relative times (us) -e # print elapsed times (us) -f # follow children -l # force printing pid/lwpid -o # print on cpu times -s # print stack backtraces -L # don't print pid/lwpid -b bufsize # dynamic variable buf size eg, dtruss df -h # run and examine "df -h" dtruss -p 1871 # examine PID 1871 dtruss -n tar # examine all processes called "tar" dtruss -f test.sh # run test.sh and follow children 从参数上来看, -p PID、-n name都是选择跟踪的目标的 -c、-d、-o等参数是统计对应系统调用执行时占用的CPU时间,在性能分析的场景下很有用。 -s参数是打印系统调用对应的函数调用栈,这个参数对本次bug的排查会很有帮助。 工具参数熟悉后,整理思路开始跟踪吧。 BUG对应的系统调用范围确定 在我们去触发BUG时,程序本身也还在运行,在执行其他业务逻辑。那我们如何确定bug产生的过程对应的系统调用记录范围? 系统调用执行时的时间 前面提到,在Linux平台上,strace的日志会带有系统当前时间,其他的检测软件中也会有记录系统时间,这样关联就比较方便。但macOS平台上,dtruss是没有这个系统当前时间输出的,这样就没法便捷的关联BUG时间段,没法确定syscall日志的范围,很头疼。 比如tcpdump或wireshark捕获的网络通讯包中,也会有系统时间,哪怕没法跟dtruss日志关联,但也还是要抓包捕获,来确认BUG确实产生了。开tcpdump,选择loopback网卡(因为是127.0.0.1这个回环网址,所以是loopback网卡),设定参数port 16161,开启嗅探,同时开始重现BUG。 系统syscall的API入手关联 在wireshark的分析中,确认BUG重现后,着手分析dtruss日志。 从我们研发的经验上考虑,浏览器向MOA发起wss TCP连接时,wss server会去调用accept系统调用来处理,那么我们只要确定在大约的时间段里,找到accept的syscall,就能定位到这个bug产生最开始的入口点。 cfc4n@cnxct-mt:~$ dtruss -p 78158 dtrace: system integrity protection is on, some features will not be available dtrace: failed to initialize dtrace: DTrace requires additional privileges cfc4n@cnxct-mt:~$ please dtruss -p 78158 Password: dtrace: system integrity protection is on, some features will not be available SYSCALL(args) = return workq_kernreturn(0x40, 0x700005CCEB80, 0x1) = 0 Err#-2 kevent_qos(0xFFFFFFFFFFFFFFFF, 0x7FFEE0E63920, 0x1) = 0 0 workq_kernreturn(0x40, 0x700005CCEB80, 0x1) = 0 Err#-2 workq_kernreturn(0x40, 0x700005CCEB80, 0x1) = 0 Err#-2 accept(0x4, 0x700005CCE7A0, 0x700005CCE79C) = 25 0 fcntl(0x19, 0x4, 0x4) = 0 0 setsockopt(0x19, 0xFFFF, 0x1022) = 0 0 kevent_id(0x6000036A8500, 0x700005CCEB80, 0x0) = 1 0 thread_selfid(0x0, 0x0, 0x0) = 21736566 0 lstat64("/Applications/Firefox.app/Contents/Resources\0", 0x700005D50F70, 0x0) = -1 Err#2 open("/Applications/MOA.app/Contents/Resources/MOA.app/Contents/Resources/server.p12\0", 0x0, 0x1B6) = 26 0 fstat64(0x1A, 0x700005D512B0, 0x0) = 0 0 dtrace: error on enabled probe ID 2174 (ID 168: syscall::read:return): invalid kernel access in action #12 at DIF offset 68 close(0x1A) = 0 0 open("/Users/cfc4n/Library/Keychains/login.keychain-db\0", 0x0, 0x0) = 27 0 __mac_syscall(0x7FFF7833ACD0, 0x52, 0x700005D4F9E0) = 0 0 lstat64("/Users/cfc4n/Library/Keychains/login.keychain-db.sb-ad1d361c-gMs51v\0", 0x700005D4FC48, 0x0) = 0 0 fstatfs64(0x1B, 0x700005D4FC48, 0x0) = 0 0 open_dprotected_np(0x600003BFA080, 0xB01, 0xFFFFFFFFFFFFFFFF) = -1 Err#17 open_dprotected_np(0x600003BFA080, 0x901, 0xFFFFFFFFFFFFFFFF) = 28 0 fcntl(0x1B, 0x30, 0x1) = 0 0 fcntl(0x1C, 0x30, 0x1) = 0 0 fcntl(0x1C, 0x4C, 0x1) = 0 0 __mac_syscall(0x7FFF7833ACD0, 0x53, 0x700005D4EFE8) = 0 0 fstat64_extended(0x1C, 0x700005D4F1A8, 0x7FD4A3D4CE10) = 0 0 fchmod_extended(0x1C, 0x1F5, 0x14) = 0 0 fstat64(0x1C, 0x700005D4EF50, 0x0) = 0 0 fchflags(0x1C, 0x0, 0x0) = 0 0 fchown(0x1C, 0x1F5, 0x14) = 0 0 fchmod(0x1C, 0x1A4, 0x0) = 0 0 fsetattrlist(0x1C, 0x700005D4F000, 0x700005D4EFE0) = 0 0 fstat64(0x1B, 0x700005D4FBA0, 0x0) = 0 0 fstat64_extended(0x1C, 0x700005D4FC48, 0x7FD4A3D4CE10) = 0 0 close(0x1B) = 0 0 close(0x1C) = 0 0 utimes("/Users/cfc4n/Library/Keychains/login.keychain-db.sb-ad1d361c-gMs51v\0", 0x0, 0x0) = 0 0 rename("/Users/cfc4n/Library/Keychains/login.keychain-db.sb-ad1d361c-gMs51v\0", "/Users/cfc4n/Library/Keychains/login.keychain-db\0") = 0 0 flock(0x1A, 0x8, 0x0) = 0 0 close(0x1A) = 0 0 open("/Applications/MOA.app/Contents/Resources/MOA.app/Contents/Info.plist\0", 0x0, 0x1B6) = 26 0 fsync(0x1B, 0x0, 0x0) = 0 0 close(0x1B) = 0 0 lstat64("/Users/cfc4n/Library/Keychains/login.keychain-db.sb-ad1d361c-rmdB2z\0", 0x700005D4F988, 0x0) = 0 0 stat64_extended(0x600003BF5D60, 0x700005D4F988, 0x7FD4A3F210C0) = 0 0 chmod_extended(0x600003BF5D60, 0x1F5, 0x14) = 0 0 lstat64_extended(0x600000299F00, 0x7FD4A3F20FC8, 0x7FD4A3F210C0) = 0 0 open("/Users/cfc4n/Library/Keychains/login.keychain-db\0", 0x0, 0x0) = 27 0 workq_kernreturn(0x100, 0x700005D51B80, 0x2) = 0 Err#-2 getentropy(0x700005D51780, 0x20, 0x0) = 0 0 getentropy(0x700005D517D0, 0x40, 0x0) = 0 0 kevent_id(0x6000036BE600, 0x700005C4AD80, 0x1) = 0 0 kevent_id(0x6000036BE600, 0x700005C4AD50, 0x1) = 0 0 dtrace: error on enabled probe ID 2172 (ID 170: syscall::write:return): invalid kernel access in action #12 at DIF offset 68 kevent_id(0x6000036B1F80, 0x700005C4B6F0, 0x1) = 0 0 workq_kernreturn(0x100, 0x700005D51B80, 0x2) = 0 Err#-2 kevent_id(0x6000036A8500, 0x700005C4BB80, 0x1) = 0 0 close(0x19) = 0 0 workq_kernreturn(0x100, 0x700005C4BB80, 0x1) = 0 Err#-2 bsdthread_ctl(0x100, 0x0, 0x2DE13) = 0 0 workq_kernreturn(0x20, 0x0, 0x1) = 0 0 workq_kernreturn(0x100, 0x700005D51B80, 0x1) = 0 Err#-2 (数据有删减)从上面的dtruss日志来看,accept的syscall很显眼accept(0x4, 0x700005CCE7A0, 0x700005CCE79C) = 25 0,那么,如何来理解这段日志呢? macOS的syscall系统调用也是跟unix的一致,参考BSD System Calls 手册 ACCEPT(2) BSD System Calls Manual NAME accept -- accept a connection on a socket SYNOPSIS #include <sys/socket.h> int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len); RETURN VALUES The call returns -1 on error and the global variable errno is set to indicate the error. If it succeeds, it returns a non-negative integer that is a descriptor for the accepted socket. 从这个syscall的说明来看,参数部分并不重要,重要的是RETURN VALUES的部分,这是TCP SOCKET连接后的所属FD文件描述符,也就是我们BUG重现时,这个网络连接在这个MOA进程中的FD。 BUG出现的对应的accept 找到BUG重现后的FD,那么再找到FD被关闭的地方即可。从dtruss的日志accept(0x4, 0x700005CCE7A0, 0x700005CCE79C) = 25 0来看,这个FD是25,聪明的你立刻就想到,只要找到close的地方就能确定到BUG出现的syscall日志范围。 BUG出现的对应的close 直接搜索close的系统调用吧,很明显在close(0x19) = 0 0就是,只不过十进制的 25 被显示成十六进制的0x19了。 确定BUG出现的范围 对于这个BUG来说,已经确定了其产生对应的syscall日志范围,那么剩下的事情只要顺着close的地方倒推往上来找。 跟踪 从上面的dtruss结果来看,并没有找到准确的TCP SOCKET write的系统调用,只看到几处dtrace: error on enabled probe ID 2172 (ID 170: syscall::write:return): invalid kernel access in action #12 at DIF offset 68 的错误,字面上理解,无法启用相关进程的syscall:write的探测。那么我们将无法定位到到底是哪里的syscall向TCP SOCKET的FD里写入了Fatal Close Notify的错误。 回到dtruss的参数中,可以看到还有一个参数-s用来打印程序运行的stack backtraces。进行重新重现BUG: cfc4n@cnxct-mt:~$ please dtruss -p 78158 -adfs Password: dtrace: system integrity protection is on, some features will not be available PID/THRD RELATIVE ELAPSD CPU SYSCALL(args) = return 78158/0x1527e26: 11190 88 2 bsdthread_ctl(0x100, 0x0, 0x1335B) = 0 0 libsystem_kernel.dylib`__bsdthread_ctl+0xa libdispatch.dylib`_dispatch_set_priority_and_mach_voucher_slow+0xa0 libdispatch.dylib`_dispatch_set_priority_and_voucher_slow+0xc9 libdispatch.dylib`_dispatch_mach_msg_invoke+0x282 libdispatch.dylib`_dispatch_lane_serial_drain+0x10c libdispatch.dylib`_dispatch_mach_invoke+0x1e1 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd 0x10ef9e000 78158/0x1527e26: 11276 106 23 workq_kernreturn(0x20, 0x0, 0x1) = 0 0 libsystem_kernel.dylib`__workq_kernreturn+0xa libdispatch.dylib`_dispatch_root_queue_poke_slow+0xac LaunchServices`LSNotificationReceiver::receiveNotificationFromServer(_xpc_connection_s*, void*)+0x3a3 LaunchServices`___ZN22LSNotificationReceiver23getNotificationListenerEv_block_invoke.10+0x184 libxpc.dylib`_xpc_connection_call_event_handler+0x38 libxpc.dylib`_xpc_connection_mach_event+0x3a5 libdispatch.dylib`_dispatch_client_callout4+0x9 libdispatch.dylib`_dispatch_mach_msg_invoke+0x1b4 libdispatch.dylib`_dispatch_lane_serial_drain+0x10c libdispatch.dylib`_dispatch_mach_invoke+0x1e1 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd 0x10ef9e000 ... //很多,有删减 78158/0x152e9e7: 294 101 21 accept(0x4, 0x700005CCE7A0, 0x700005CCE79C) = 25 0 libsystem_kernel.dylib`__accept+0xa itsec-agent`0x000000010edc0023+0x44 libdispatch.dylib`_dispatch_client_callout+0x8 libdispatch.dylib`_dispatch_continuation_pop+0x19e libdispatch.dylib`_dispatch_source_invoke+0x808 libdispatch.dylib`_dispatch_lane_serial_drain+0x10c libdispatch.dylib`_dispatch_lane_invoke+0x181 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd 78158/0x152ecde: 1209 37 31 open("/Users/cfc4n/Library/Keychains//.fl34AC2A0A\0", 0x200, 0x124) = 26 0 libsystem_kernel.dylib`__open+0xa Security`Security::DbModifier::modifyDatabase()+0x5d Security`Security::AppleDatabase::dataInsert(Security::DbContext&, unsigned int, cssm_db_record_attribute_data const*, Security::CssmData const*)+0x54 Security`Security::DatabaseSession::DataInsert(long, unsigned int, cssm_db_record_attribute_data const*, Security::CssmData const*, cssm_db_unique_record*&)+0xbb Security`cssm_DataInsert(cssm_dl_db_handle, unsigned int, cssm_db_record_attribute_data const*, cssm_data const*, cssm_db_unique_record**)+0x68 Security`CSSM_DL_DataInsert+0x88 Security`SSDatabaseImpl::ssInsert(unsigned int, cssm_db_record_attribute_data const*, cssm_data const*)+0xc5 Security`SSCSPDLSession::makeReferenceKey(SSCSPSession&, unsigned int, Security::CssmKey&, SSDatabase&, unsigned int, Security::CssmData const*)+0xb09 Security`SSCSPSession::UnwrapKey(unsigned long long, Security::Context const&, Security::CssmKey const*, Security::CssmKey const&, unsigned int, unsigned int, Security::CssmData const*, cssm_resource_control_context const*, Security::CssmKey&, Security::CssmData&, unsigned long long)+0x17a Security`non-virtual thunk to SSCSPSession::UnwrapKey(unsigned long long, Security::Context const&, Security::CssmKey const*, Security::CssmKey const&, unsigned int, unsigned int, Security::CssmData const*, cssm_resource_control_context const*, Security::CssmKey&, Security::CssmData&, unsigned long long)+0x21 Security`cssm_UnwrapKey(long, unsigned long long, cssm_context const*, cssm_key const*, cssm_key const*, unsigned int, unsigned int, cssm_data const*, cssm_resource_control_context const*, cssm_key*, cssm_data*, unsigned long long)+0xd0 Security`CSSM_UnwrapKey+0xc7 Security`P12Coder::safeContentsParse(cssm_data const&, SecNssCoder&)+0xd2c Security`impExpPkcs12Import+0x8a0 Security`SecKeychainItemImport+0xd30 Security`SecPKCS12Import+0x100 itsec-agent`0x000000010edd8efe+0x10b itsec-agent`0x000000010ed9f5b8+0x36 itsec-agent`0x000000010edce367+0x39 itsec-agent`0x000000010edce297+0x38 78158/0x152ecde: 15213 33 30 lstat64("/Users/cfc4n/Library/Keychains/login.keychain-db.sb-ad1d361c-3plX5x\0", 0x700005C49A78, 0x0) = 0 0 libsystem_kernel.dylib`lstat$INODE64+0xa Security`Security::DbModifier::commit()+0xda3 Security`Security::AppleDatabase::dataInsert(Security::DbContext&, unsigned int, cssm_db_record_attribute_data const*, Security::CssmData const*)+0xb1 Security`Security::DatabaseSession::DataInsert(long, unsigned int, cssm_db_record_attribute_data const*, Security::CssmData const*, cssm_db_unique_record*&)+0xbb Security`cssm_DataInsert(cssm_dl_db_handle, unsigned int, cssm_db_record_attribute_data const*, cssm_data const*, cssm_db_unique_record**)+0x68 Security`CSSM_DL_DataInsert+0x88 Security`SSDatabaseImpl::ssInsert(unsigned int, cssm_db_record_attribute_data const*, cssm_data const*)+0xc5 Security`SSCSPDLSession::makeReferenceKey(SSCSPSession&, unsigned int, Security::CssmKey&, SSDatabase&, unsigned int, Security::CssmData const*)+0xb09 Security`SSCSPSession::UnwrapKey(unsigned long long, Security::Context const&, Security::CssmKey const*, Security::CssmKey const&, unsigned int, unsigned int, Security::CssmData const*, cssm_resource_control_context const*, Security::CssmKey&, Security::CssmData&, unsigned long long)+0x17a Security`non-virtual thunk to SSCSPSession::UnwrapKey(unsigned long long, Security::Context const&, Security::CssmKey const*, Security::CssmKey const&, unsigned int, unsigned int, Security::CssmData const*, cssm_resource_control_context const*, Security::CssmKey&, Security::CssmData&, unsigned long long)+0x21 Security`cssm_UnwrapKey(long, unsigned long long, cssm_context const*, cssm_key const*, cssm_key const*, unsigned int, unsigned int, cssm_data const*, cssm_resource_control_context const*, cssm_key*, cssm_data*, unsigned long long)+0xd0 Security`CSSM_UnwrapKey+0xc7 Security`P12Coder::safeContentsParse(cssm_data const&, SecNssCoder&)+0xd2c Security`impExpPkcs12Import+0x8a0 Security`SecKeychainItemImport+0xd30 Security`SecPKCS12Import+0x100 itsec-agent`0x000000010edd8efe+0x10b itsec-agent`0x000000010ed9f5b8+0x36 itsec-agent`0x000000010edce367+0x39 itsec-agent`0x000000010edce297+0x38 ... //有删减 78158/0x152e9e7: 1150 71 4 kevent_id(0x6000036BE600, 0x700005CCDD50, 0x1) = 0 0 libsystem_kernel.dylib`kevent_id+0xa libdispatch.dylib`_dispatch_event_loop_end_ownership+0x2b6 libdispatch.dylib`_dispatch_sync_complete_recurse+0x2c libdispatch.dylib`_dispatch_sync_invoke_and_complete_recurse+0x5b libdispatch.dylib`_dispatch_sync_f_slow+0xc3 libcorecrypto.dylib`ccrng_cryptographic_generate+0x132 libcorecrypto.dylib`ccn_random_bits+0x2b libcorecrypto.dylib`ccec_projectify+0x59 libcorecrypto.dylib`ccec_make_pub_from_priv+0xce libcorecrypto.dylib`ccec_generate_key_internal_fips+0xa0 libcorecrypto.dylib`ccec_sign_internal+0x1c9 libcorecrypto.dylib`ccec_sign+0x57 libcorecrypto.dylib`ccec_pairwise_consistency_check+0x84 libcorecrypto.dylib`ccec_generate_key_fips+0x21 libcoretls.dylib`sslEcdhCreateKey+0x40 libcoretls.dylib`SSLEncodeServerKeyExchange+0x253 libcoretls.dylib`SSLPrepareAndQueueMessage+0x27 libcoretls.dylib`SSLAdvanceHandshake+0xb37 libcoretls.dylib`SSLProcessHandshakeRecordInner+0xea libcoretls.dylib`tls_handshake_process+0x55 dtrace: error on enabled probe ID 2172 (ID 170: syscall::write:return): invalid kernel access in action #13 at DIF offset 68 78158/0x152e9e7: 2915 11 5 kevent_id(0x6000036B1F80, 0x700005CCE6F0, 0x1) = 0 0 libsystem_kernel.dylib`kevent_id+0xa libdispatch.dylib`_dispatch_event_loop_poke+0x205 itsec-agent`0x000000010edc2126+0x2e4 itsec-agent`0x000000010edc9109+0x185 itsec-agent`0x000000010edc8655+0x9d8 itsec-agent`0x000000010edc827e+0x96 libdispatch.dylib`_dispatch_call_block_and_release+0xc libdispatch.dylib`_dispatch_client_callout+0x8 libdispatch.dylib`_dispatch_lane_serial_drain+0x25a libdispatch.dylib`_dispatch_lane_invoke+0x181 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd 78158/0x152e9e7: 3016 17 13 close(0x19) = 0 0 libsystem_kernel.dylib`close+0xa libdispatch.dylib`_dispatch_client_callout+0x8 libdispatch.dylib`_dispatch_continuation_pop+0x19e libdispatch.dylib`_dispatch_source_invoke+0x48b libdispatch.dylib`_dispatch_lane_serial_drain+0x10c libdispatch.dylib`_dispatch_lane_invoke+0x181 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd 找到close(0x19)的地方,往上倒推几个syscall日志,可以定位到真正往TCP SOCKET 写入Fatal Close Notify的地方是在libcoretls.dylib\SSLEncodeServerKeyExchange+0x253`。 定位到服务端的密钥交换过程中,EncodeServer时出现了异常。 回到dtruss的其他日志,能看到在调用Security动态链接库中的impExpPkcs12Import方法里,有大量的/Users/cfc4n/Library/Keychains/login.keychain-db的读写。意味着MOA进程在频繁大量的读写keychain。 除了Keychain的频繁读写,还看到了大量MOA主进程的访问 78158/0x152ecde: 18541 85 31 open("/Applications/MOA.app/Contents/Resources/MOA.app/Contents/MacOS/itsec-agent\0", 0x0, 0x1B6) = 26 0 libsystem_kernel.dylib`__open+0xa Security`Security::OSXVerifier::makeLegacyHash(Security::OSXCode*, unsigned char*)+0x101 Security`Security::OSXVerifier::OSXVerifier(Security::OSXCode*)+0x1b1 Security`Security::KeychainCore::TrustedApplication::TrustedApplication()+0x70 Security`Security::KeychainCore::Access::Access(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)+0x70 Security`P12Coder::safeContentsParse(cssm_data const&, SecNssCoder&)+0xe02 Security`impExpPkcs12Import+0x8a0 Security`SecKeychainItemImport+0xd30 Security`SecPKCS12Import+0x100 itsec-agent`0x000000010edd8efe+0x10b itsec-agent`0x000000010ed9f5b8+0x36 itsec-agent`0x000000010edce367+0x39 itsec-agent`0x000000010edce297+0x38 libdispatch.dylib`_dispatch_call_block_and_release+0xc libdispatch.dylib`_dispatch_client_callout+0x8 libdispatch.dylib`_dispatch_lane_serial_drain+0x25a libdispatch.dylib`_dispatch_lane_invoke+0x181 libdispatch.dylib`_dispatch_workloop_worker_thread+0x256 libsystem_pthread.dylib`_pthread_wqthread+0x1a5 libsystem_pthread.dylib`start_wqthread+0xd CodeSigning是macOS的对二进制程序进行数字签名的机制,以确保进程文件没有被篡改,安全保障机制详情见:About Code Signing ,可以确认的是,MOA进程在每次accept TCP连接时,都进行了主进程的签名校验。难道程序签名校验失败了?因为频繁的重新安装进程,而这个老进程又没退出,但文件内容却被覆盖了? 可以这么怀疑,但还是需要证据证明。 但问题定位到这里,好像卡死了,我不确定Security链接库是否还会写入其他日志,也没有时间去看这个链接库的源码。 根据之前做黑盒分析的经验,决定到系统日志里找找看,macOS的系统日志的进程是console.app。 关联系统日志 在控制台中,搜索MOA的进程名itsec-agent,找BUG重现时的时间点,能看到这些报错信息,而根据dtruss中捕获的Keychains的临时文件名,也能关联得到这些日志。 默认 12:05:49.086566 +0800 itsec-agent caught CssmError: -2147416017 CSSMERR_CSP_ACL_ENTRY_TAG_NOT_FOUND 默认 12:05:49.095362 +0800 itsec-agent CSSM Exception: -2147416032 CSSMERR_CSP_OPERATION_AUTH_DENIED 默认 12:05:53.326473 +0800 itsec-agent 0x6000008ffb80 commited /Users/cfc4n/Library/Keychains/login.keychain-db.sb-dbe9e6c6-5v8ECF to /Users/cfc4n/Library/Keychains/login.keychain-db 默认 12:05:53.331646 +0800 itsec-agent CSSM Exception: -2147413719 CSSMERR_DL_INVALID_UNIQUE_INDEX_DATA 默认 12:06:01.089562 +0800 itsec-agent 0x6040008fb980 commited /Users/cfc4n/Library/Keychains/login.keychain-db.sb-dbe9e6c6-yiHh6n to /Users/cfc4n/Library/Keychains/login.keychain-db 默认 12:06:01.096920 +0800 itsec-agent CSSM Exception: -2147413719 CSSMERR_DL_INVALID_UNIQUE_INDEX_DATA 默认 12:06:01.108208 +0800 itsec-agent 0x6040008fc080 commited /Users/cfc4n/Library/Keychains/login.keychain-db.sb-dbe9e6c6-yrTtXC to /Users/cfc4n/Library/Keychains/login.keychain-db 默认 12:06:01.113267 +0800 itsec-agent CSSM Exception: -2147413719 CSSMERR_DL_INVALID_UNIQUE_INDEX_DATA 默认 12:06:01.146973 +0800 itsec-agent caught CssmError: -2147416017 CSSMERR_CSP_ACL_ENTRY_TAG_NOT_FOUND 默认 12:06:01.156853 +0800 itsec-agent CSSM Exception: -2147416032 CSSMERR_CSP_OPERATION_AUTH_DENIED 默认 12:06:01.160022 +0800 itsec-agent CSSM Exception: -2147416032 CSSMERR_CSP_OPERATION_AUTH_DENIED 这些报错信息,我也是第一次见,在网上稍微搜索相关知识了解,能看到这是macOS的Openssl的类库源码中的报错信息,比如CSSMERR_DL_INVALID_UNIQUE_INDEX_DATA、CSSMERR_CSP_OPERATION_AUTH_DENIED等。 在apple的开发者社区中,看到一个相似的案例: inexplicable CSSMERR_CSP_OPERATION_AUTH_DENIED,里面提到这个错误有很多情况产生 apple的CodeSign keychain证书链中的证书itemid丢失 等等,结合前面dtruss的排查结论,问题方向定位到self-sign自签CA证书上。 定位 其实,这里我的MacBook死机电脑重启了,BUG无法重现,但问题的大体方向几乎定位到了。在前面的结论下,以及搜索到的这些案例,我决定使用下面两种方式吃重现 删除Keychain的自签CA,模拟itemid丢失; 覆盖MOA主进程,让MacOS的CodeSigning机制检测失败; 对于方式一,测试结果如下 MOA继续运行 删除了KeyChain里的自签CA证书 重新访问这个TCP SOCKET 问题并没有复现,但出现了一个相近的错误Description: Certificate Unknown,之后也是服务端发送FIN包后关闭连接。 虽然问题没有复现,但我觉得真相已经很近了,配合CodeSigning的检测问题,需要模拟主进程文件被替换的场景,我又想到一种重现方式 清空删除所有MOA安装包 打开wireshark,抓loopback网卡的port 16161端口 安装老版MOA,按照业务流程走一遍,确保所有功能正常 安装新版MOA,chrome开隐私模式(确保不用上次的COOKIE),登陆SSO MOA弹出申请keychain权限的窗口,点“拒绝” 问题重现可以从wireshark的抓包窗口中确认,看到了Tls handshake时的Fatal Close Notify错误 嗯,问题重现,BUG根本原因找到了。但Keychain的安全机制我还不太清楚,时间关系,暂时放过吧,以后有机会再研究。 解题思路 回顾整个排查过程,我并不清楚OC代码是如何写的,都是黑盒分析,完全是看系统调用syscall来知道这个软件运行的行为,定位BUG问题所在的。 有很多知识是跨平台通用的,比如网络通讯原理、动态跟踪技术、动态调试技术、syscall系统调用、二进制文件执行机制等技术,跟所在的是Windows、Linux还是macOS关系不大。 系统调用syscall跟踪软件在Win上有 Process Monitor ,我在2016年黑盒分析英雄联盟启动器时使用过,之前也没做过Windows的程序研发,只是借助Linux的解题思路找对应得替代软件而已。比如下图: 同样,这次遇到macOS的BUG也是,只要找对应得软件就好,解决问题的思路是通用的。 Linux上有strace,macOS上有dtruss,对于更详细的动态跟踪软件,还有Dtrace、SystemTap等我也在学习中,以后如果有机会,我会继续给大家分享。 通用知识 对于这种BUG的解决,重要的是解题思路,遇到问题该如何处理,如何粗略定位问题所在,从哪里入手?靠猜吗?东一锤子西一榔头的靠运气吗? 就怕你整天TCP协议栈倒背如流,操作系统知识滚瓜烂熟,垃圾回收机制如数家珍,但每次一遇到问题,就一脸懵逼、束手无策,这就很不匹配。 大部分主动学习能力并不强,都是靠事件驱动,比如有BUG产生了,才不得不去学习陌生领域的知识,去解决问题,顺便积累经验。可怕的是有部分人BUG发生就发生了,重启能解决就算,从来不关心根本原因是什么。下次遇到同类型的问题时,依旧是束手无策。 套用道友codedump的一句话知行合一(出自王阳明),珍惜每一次BUG的机会,历练自己。在解决每个问题时,是对你整个知识体系的梳理,对逻辑思维的考验,对技术技能的检查,检验自己、磨练自己、强化自己。 解决方案 WSS的https server监听时,使用的tls证书,要走正规CA签发,避免自签self-sign的CA频繁读写keychain ,避免对keychain的root权限获取。(域名解析到127.0.0.1,拿到私钥的安全风险也很小) MOA升级时,先杀死老版本的所有进程,再进行安装、覆盖、启动。 itsec-agent进程 codesign签名。 本文重点 美团点评信息安全中心招聘,职位包括不限于 iOS/macOS技术专家 Web应用防火墙架构师 JAVA\golang\C++高级工程师 数据安全资深专家 数仓安全架构师 基础安全专家 风险数据挖掘专家 IoT安全专家 移动安全工程师、专家 操作系统安全工程师、专家 更多职位信息,见:同学,我看你骨骼精奇,是干安全/研发的好苗子:https://mp.weixin.qq.com/s/9Y9bAUt51gPvy2ItiFRBCA 。 关注本BLOG微信公众号榫卯江湖,回复JD、JOBS、招聘、工作获取更多职位信息。 相似的案例 inexplicable CSSMERR_CSP_OPERATION_AUTH_DENIED : Certificate-private key combo failure: https://forums.developer.apple.com/thread/99801 Internal error (errSSLInternal) on SSLHandshake:https://forums.developer.apple.com/thread/92193 参考资料 macOS Code Signing In Depth 跟踪 UNIX 应用程序的解决方案 openssl类库的SSL_alert_type_string函数手册 macOS平台Security类库源码的CSSMERRCSP*相关错误 https://docs.apigee.com/api-platform/troubleshoot/runtime/ssl-handshake-failures: Re: inexplicable CSSMERR_CSP_OPERATION_AUTH_DENIED: 扩展知识 http://oliveryang.net/2016/07/linux-perf-tools-tips/ System Integrity Protection – Adding another layer to Apple’s security model SystemTap Beginners Guide 2019-09-09 0 0
openresty的unescape_uri函数处理百分号后面字符的小特性 我们的 WAF (Web Application Firewall)是搭建在 nginx 上,使用 lua modules 来实现的。也就是 openresty 的 luajit 功能来实现WEB 攻击判断。处理收到的请求时,一般会进行unescape_uri 处理后,再走规则匹配。 在离线分析的机器学习识别结果里,发现有这么一条漏报/aa%20a?openId=%%3Cscript%3E%,这个问题却出乎我们意料,为此,开始排查产生该现象的原因。 问题描述 源字符串: openresty的unescape_uri函数处理后结果 期望的结果: 问题现象: 转义不对;丢了百分号。 如何重现 通过浏览器,构造一个包含如上特征的 uri,按回车提交到 nginx?浏览器会自动把字符串转码吗?转码是按照什么规范来的?IE9、IE10、chrome、safari是分别如何处理的? 习惯用postman的人,知道postman是否会escape uri呢? 如果你忽略了这一点,那么会对你的测试造成很大的影响,耽误时间经历,让你在重试阶段都对问题产生了怀疑。 从HTTP协议角度考虑,只要确保通过协议发送到 http server 的请求中,uri 是符合我们需要的即可。至于浏览器的 escape做法,可以忽略,直接构造 http 协议来重现。 重现时,通过浏览器 提交「问题字符串」,在 nginx 层 输出 unescape 后的结果。 注意浏览器是否对 URI 进行了 URI_ENCODE,不同浏览器执行的 RFC 标准也不一样。 最根本的方式是,跳过浏览器,直接构造 http request 包,确保源字符串完整发送只 nginx。 重现该问题,开始排查 nginx 对 uri decode 的实现方式。 分析问题: waf的处理方式: 使用ngx.unescape_uri函数 确认处理 uri 的原始函数ngx.unescape_uri 该函数在 nginx lua modules 模块中,在src/ngx_http_lua_string.c 的64、65行: 如上代码,可知ngx.unescape_uri函数是ngx_http_lua_ngx_unescape_uri实现,对于真正 decode 字符串功能,是使用了src/ngx_http_lua_util.c文件内的ngx_http_lua_unescape_uri函数做 decode,该函数代码如下: 代码阅读: 对 uri 的字符串按照从左往右挨个处理 初始化 state 为 sw_usual,当state 为sw_usual时 若第一个字符是「?」,则把「?」赋值给 dst 字符串,然后直接返回 若第一个字符串是「+」,则替换成空格,赋值给 dst 字符串,然后跳出流程,走到下一个字符判断。(nginx 函数ngx_unescape_uri是没这个处理的) 若第一个字符是「%」,则跳过当前字符,对src 源字符串的下一个字符判断,走到sw_quoted流程处理下一个字符。 sw_quoted判断当前字符是符合[0-9a-f],则进入下一个字符的处理。 若不在[0-9a-f]范围内,则赋值给 dst 字符串,跳出当前流程处理下一个字符 sw_quoted_second 处理两个字符串,转换,赋值到 dst 中 结合案例分析: 结合案例中的源字符串%%3Cscript%3E%来看, 第一个字符% 走到上面第5条,不会被赋值到dst中,会继续判断下一个字符 第二个字符也是% ,走到上面第7条,直接被赋值 dst 中 第3、4个字符是普通字符,直接赋值到 dst 中。 第11个字符%走到上面第5条,跳过,继续判断下一个字符 第12、13个字符走到上面第8条,正确unescape转码。 最后一个字符% ,走到上面第5条,跳过,进行下一个字符判断 下一个字符没了,结束 所以,%%3Cscript%3E% 转换为 %3Cscript> ,这里的% 是源字符串的第二个百分号,第一个丢了(因为后面的字符也是%,不符合[0-9a-z]规则);最后一个% 也丢了,因为后面没字符了,while 跳出循环,结束了。 总结问题: 百分号后面还是百分号问题的修复 百分号是最后一个字符串的修复 修复问题: 测试代码: 备注: nginx 底层的 ngx_unescape_uri 函数(/src/core/ngx_string.c)实现部分,跟 lua modules 实现不太一样,openresty 里多了对「+」转空格的处理。() 疑问: nginx的做法是按照哪个 RFC 标准转换的? lua modules的做法符合 RFC 标准吗? uri_decode在其他语言里的是如何实现的? 解答: 翻阅 nginx 官方文档,并没找到 uri unescape的规范文档,只是代码注释里看到了是RFC3986 符合 RFC 规范,但RFC3986 规范并没提到百分号%后面不符合的字符该如何处理,也没提到单独的百分号该如何处理。 你自己去试试? 结论 nginx 的这个处理,并不是 bug,RFC3986 规范里只是规定了百分号%后面的字符哪些字符,转换成哪种格式,并没有规定不符合规范的字符改如何处理,没规定是丢弃还是保留。所以,这只是 nginx 的一个特性feature,特性feature,特性feature,并不是BUG,不是 BUG,不是 BUG。 参考: Uniform Resource Identifier (URI): Generic Syntax 2018-09-20 1 0
etcd clientv3的lease keepalive租约频繁续期bug 排查小记 背景: etcd 3.2.23 golang 1.9.1 centos 6 etcd cluster集群节点7台,节点member之间 https 通讯,与 client 之间也是 https 通讯 测试环境 2W 台主机,每台都要安装这个 agent,每个 agent注册一个包含lease的key,key的 ttl 设置为10s 设置keepalive 产品等着发布上线,持续遇到集群性能问题,长时间找不到原因,感觉我要崩溃了 集群性能问题不解决,线上更多的服务器更是没法部署,领导不认可,不满意,我就要被开除了,我就要辞职了。。。 Etcd github官方提了issue,官方没时间帮我们看(理解官方的苦衷,issue 太多,很多都是使用者的问题)... 问题现象: 带有lease id的 key 无故丢失 leader节点 大部分时间 TIME-WAIT数量非常高 leader 节点的 CLOSE-WAIT 有时也非常高 leader 的 CPU 使用率持续在80-90%的样子 leader 节点的内存使用率持续上升,疑似内存泄露的症状 监控图如下: etcd leader的 etcd 进程入手: 先分析「疑似内存溢出」问题,在 etcd 里,已经有pprof的信息,golang的tool里也有直接读取 http pprof 性能分析的工具,只是不支持 https 的。需要将 pprof 文件下载到本地,再从本地读取 结果如下: 可以看到大量 math 相关的包函数占用了大量的 cpu,而我们程序的业务中,并不涉及数学计算相关业务,也不会引用 math 包。唯一可能性就是在 https 的 tls 证书在做 RSA 算法校验时,用到了大量数学计算。我把 pprof 导出问 svg 图确认了一下,确实是 tls 的证书计算占用了大量 CPU。 但是 https 的链接,不应该是建立1次后就不再建立了吗?为什么会有这么大量的 cpu 计算呢。难道 TIMEWAIT 这么高跟这个有关系吗?回到 TIMEWAIT 的问题上来,此刻,leader 节点是 xxxx-etcdserver-xxx02,且TIME_WAIT非常高 leader 节点上的time-wait来源,都是来自各个member节点,平均每个节点将近几万个 TIMEWAIT,相隔几秒再次查询,还是5万多个。而各个 member 节点上,查到的是跟 leader 节点的2380端口 保持1千多个 ESTABLISHED 状态的连接,也确实是频繁的创建链接。为什么member节点频繁的创建链接呢?tcpdump抓点TCP 通讯包看一下: 如上抓包的图,发现了什么? 120140 2018-06-22 20:37:30.219 10.72.198.234 74 10.20.127.136 TCP 12902 → etcd-server(2380) [SYN] Seq=0 Win=14600 Len=0 MSS=1460 SACK_PERM=1 TSval=3008487334 TSecr=0 WS=128 120141 2018-06-22 20:37:30.219 10.20.127.136 74 10.72.198.234 TCP etcd-server(2380) → 12902 [SYN, ACK] Seq=0 Ack=1 Win=14480 Len=0 MSS=1460 SACK_PERM=1 TSval=2796204056 TSecr=3008487334 WS=128 120362 2018-06-22 20:37:30.242 10.72.198.234 66 10.20.127.136 TCP 12902 → etcd-server(2380) [ACK] Seq=1 Ack=1 Win=14720 Len=0 TSval=3008487358 TSecr=2796204056 120368 2018-06-22 20:37:30.242 10.72.198.234 204 10.20.127.136 TCP 12902 → etcd-server(2380) [PSH, ACK] Seq=1 Ack=1 Win=14720 Len=138 TSval=3008487358 TSecr=2796204056 120369 2018-06-22 20:37:30.242 10.20.127.136 66 10.72.198.234 TCP etcd-server(2380) → 12902 [ACK] Seq=1 Ack=139 Win=15616 Len=0 TSval=2796204080 TSecr=3008487358 174076 2018-06-22 20:37:40.242 10.72.198.234 66 10.20.127.136 TCP 12902 → etcd-server(2380) [FIN, ACK] Seq=139 Ack=1 Win=14720 Len=0 TSval=3008497358 TSecr=2796204080 174377 2018-06-22 20:37:40.282 10.20.127.136 66 10.72.198.234 TCP etcd-server(2380) → 12902 [ACK] Seq=1 Ack=140 Win=15616 Len=0 TSval=2796214120 TSecr=3008497358 从这些数据包中,你能看出什么问题?想一想,打开这张图,想5分钟再往下看。。。 120140是10.72.198.234 的 member 节点作为客户端,向10.20.127.136的2380的 leader 节点发起TCP 连接请求。 120141是 leader 回的握手确认包。120362是 member 发的 ACK 握手完成确认包。这些都很简单明了。 120368是 member 向 leader 发的数据包,120369是 leader 回 member 收到前面数据包的 ack。嗯,这些也很好明白。 174076是什么? member向 leader 发的FIN 关闭 TCP 连接的数据包,为什么?为什么member要关闭连接? 再抽查几个数据包看看,从 “专家信息”里预览的1000多个“SYN”里找.... 可以看出,都是 member主动向 leader 发 FIN 关闭包,这是为什么呢? 仅仅是看出来这些吗? 有没有注意到 member 发的消息包,收到 leader 回的 ack 之后,距离 member 主动发的 FIN 之间的时间间隔?都是10s ?为什么是10s? leader 回了 ack,但没主动向 member 发“FIN,ACK”,为什么没回呢?若没回,那么 leader 上的这个 TCP 五元组的状态是什么?是的,是“CLOSE-WAIT”,难道这就是造成leader上大量 CLOSE-WAIT 的原因?先怀疑一下,继续往下看... 如上所说,member 向 leader 发了一个PSH数据包,发来什么内容呢?为什么 leader 没回信息? payload的总长度是138,完全看不出来是什么数据包?不,应该还是有眼熟的,看前3个字节,0x16,0x03,0x01,如果对 HTTPS 熟悉的话,会联想到这是 TLS 的magic number,0x16对应十进制的22 即“Client Hello 的 Handshake”,后面的0x31,0x01是 TLS 的版本号。再配合我们的 member 跟 leader 之间确实是 https 通讯,可以确定这个地方是 tls 的client Hello握手包。也就是说配合前面发现的 10s 的间隔,可以看出 member 与 leader 之间的 TLS 握手并未完成,leader 并没有回 Server Hello... 整个TCP 包比较短,没有发现其他有用的信息... 去掉 HTTPS,减轻 cpu 负担: 回到之前的 CPU pprof 情况来看,是 TLS 相关证书计算,导致 CPU 使用率较高,但大量的 TCP 连接的发起,才是导致大量 TLS 计算的原因,导致内存飙升,消息积压的原因。不过,我们还是尝试去掉 member 与 leader 时间的 tls 证书校验,尝试减轻 leader 的CPU 使用。但结果影响不大,CPU 使用率依旧很高,不过,这也在意料之中,毕竟不是问题根本原因,查看pprof 如下 CPU 使用率较高的,是 runtime.futex 跟系统调用的 syscall.Syscall,以及runtime下的包,heapBitsForObject、scanobject、greyobject是 golang GC 时的运行时函数,意味着GC 已经占用很大的 CPU 了。 配合memory pprof来看下 如上,可以看出,大约65%的内存都用在了recvBuffer.put 这里的 put 方法是将收到的recvMsg写入到b.backlog这个 slice 中的,func (b recvBuffer) put(r recvMsg)跟 (b recvBuffer) load()两个函数都对b.backlog有读写操作,也就是说,这个slice 写入、读取时,都会进行加锁,严重占用 CPU 以及影响性能,增大系统负载。而且若 (b recvBuffer) load()处理速度跟不上func (b recvBuffer) put(r recvMsg)写入速度,意味着这个slice会持续增长,造成看似内存溢出的现象。。。 那问题来了,func (b recvBuffer) put(r recvMsg)为什么有这么大的调用量,为什么在频繁写入...为什么呢??? 定位到内存溢出的疑似原因: grpc/server.go func (s Server) serveStreams(st transport.ServerTransport) grpc/transport/handler_server.go 文件284行 func (ht serverHandlerTransport) HandleStreams 函数中的334行 然后再走到了,func (b *recvBuffer) put(r recvMsg)函数里,也就是说,这里的消息slice堆积,是因为接收到了大量http request。那接下来就是要找到这些http request 是来自谁了? 阅读 etcd 源码: 配合 member 之间大量的 TCP TIME-WAIT 状态链接,排查方向改到 member 节点上。 要查找 etcd member 建立 TCP/HTTP 链接的地方, 在源码里,还是非常多的,一时间没找到更好的bug 定位方式,只要从etcd 源码去逐步分析排查。 etcdmain/main.go 39行 startEtcdOrProxyV2() etcdmain/etcd.go 103行 startEtcd() etcdmain/etcd.go 186行 embed.StartEtcd(cfg) embed/etcd.go 107行 startPeerListeners() ,用 rafthttp.NewListener 创建了一个Listener。用于 etcd member 之间通讯。依赖github.con/coreos/etcd/pkg/transport包 embed/etcd.go 110行 startClientListeners() 用 net.Listen 创建了一个Listener。用于 client 跟 member 之间通讯。keepalive 使用github.con/coreos/etcd/pkg/transport包 157行 etcdserver.NewServer() 对本地配置读取,数据库目录读取,设置mvcc多版本控制函数,设置 lease 租约续期函数等等 166行 e.servePeers() 进行对 Linster 启动 Serve 接受请求 317行 etcdhttp.NewPeerHandler(e.Server) 设置HTTP Handler,里面函数如下: 如上,etcd 节点之间的通讯,设定了这几个Handler "/" --> http.NotFound "/raft"、"/raft/" --> raftHandler "/members" --> mh "/leases/internal" 、 "/leases" --> raftHandler "/version" --> versionHandler 也就是说,member 之间通讯的大量通讯只会是这些 handler 中间的一个,除非源码找漏了。。。 从这个开源产品功能特性上来看,member 之间需要大量同步的,应该就是数据了,应该把线索放到raftHandler上吗?头大.... raftHandler是s.RaftHandler() 返回的, 值是 EtcdServer.r.transport.Handler()返回值。 EtcdServer.r是在etcdserver/server.go的414行赋值的 etcdserver/server.go 的511行 EtcdServer.r.transport = tr进行赋值,类型为rafthttp.Transport ,其Handler 如下代码: 这里又有几个 Handler ,而且都是 raft 处理相关的,代码读起来比较吃力,很难理解作者意图, 打印http.dial堆栈: 要想从这里查找 TCP 连接的发起代码,恐怕是难上加难,所以,我换了个思路,直接在 TCP 连接 Dail 的地方,判断 addr 是 etcd leader 的地址,打印调用栈。使用 runtime/debug下的debug.PrintStack()函数输出。同时打印了连接完成后的remote.addr,用于校对。不过,这改动是 golang 的系统 runtime 类库,(改了系统库,记得改回来)。要小心谨慎,在http/transport.go 882行 增加如下 重新编译 etcd server,替换到集群中的某一个 member 节点上。 开始压测这个节点,重现大量 TIME-WAIT 的现象。现象重现后,在日志里也拿到了运行堆栈 在压测时,我用 netstat 抽样打印几次当前 member 上的 TCP 连接信息数据,也是用于校对日志输出的数据。 在压测的日志数据里,每建立一个 TCP 连接,都打印了这些堆栈,并且打印了远程 IP 的地址、端口。跟前面抽样打印的 TCP 数据核对一下,发现连接 leader 2380端口的几个 TCP 连接五元组跟 上面打印的remote.addr的五元组一致。 也就是说,是这个函数创建了大量的从 member 到 leader 之间的 TCP 连接。好了,如果找到大量 TCP 连接发起的函数了,又找到了该函数的调用栈了,那岂不是找到问题了? 然而,事情并没有这么简单,在上面的调用栈日志里,只能定位到coreos/etcd/pkg/transport.(*rwTimeoutDialer).Dial,并不能打印出所有调用堆栈,为什么? 找到 etcd member 10s 后主动断开 TCP 连接的原因: 在系统类库里的net/http/transport.go的Transport.dial方法中,如果该Transport自定义了Dial方法,则调用自定义的...这个自定义的Dial方法是在github.com/coreos/etcd/pkg/transport/transport.go里定义的,如下: 在代码dial := func(net, addr string) (net.Conn, error) ... 这里,自定义了一个 Dial 方法,赋值到http.Transport.Dial 函数属性上,并且设定TLS 的超时时间是 TLSHandshakeTimeout: 10 time.Second,这也就是之前抓包时,看到的10s 后,member 节点主动断开连接的原因----超过 TLS 设定的timeout时间了。 函数NewTransport的调用地方,只在NewTimeoutTransport这里找到,如下: 也就是说,找到NewTimeoutTransport调用着,就能确定发起大量 TCP 的代码...但之前的堆栈打印,并没有打印到这些函数调用,只好再想其他办法。 在NewTimeoutTransport的代码里,有一句对 HTTP Keepalive的最大持有空闲长连接数字的设置tr.MaxIdleConnsPerHost = 1024,这个参数决定 golang 的 http client 包对 server 发起 http 请求时,能持有的最大空闲 TCP 连接数量,在 etcd 的代码里,被设置为1024了,也就是说,etcd member 跟 etcd leader 之间空闲时,持有TCP 连接数可以是1024个,便于下次复用。(插一句,有人说,http 是最好的 RPC,这里我是不认同的,光从TCP 连接复用上,就有非常大的差异,在http 1.1 TCP 链接 「另类」复用,http 1.1 的keepalive属性也是仅当前一个请求响应后,这个链接才能继续复用,请求响应串行化。而在 http2里,是不需要等待上一个请求返回的,才有真正意义上的多路复用见:Streams and Multiplexing , 如果你做过 rpc 的功能实现,你就知道 rpc 在 tcp 上是如何实现请求响应的一一对应的,你就能很快明白http2\http1.1的 TCP 链接复用的区别了。) 排查 etcd member 跟 etcd leader 之间的通讯: 回到正题,对于代码这里的定位,我尝试去阅读代码来协助定位,但 etcd 的源码太复杂了,我读到 raft 协议节点之间数据同步这块,卡住了。开始从其他路子尝试解决,之前的 cpu 占用较高问题,我们在 member 节点之间去掉了 tls 证书验证,那么我们在 member 上做 tcp 抓包,抓2380端口数据包,看看数据发送的都是什么内容。 数据包中,可以看出 member 向 leader 上发送了大量的 /leases的 POST 请求。根据这个 uri,找到发起 HTTP 请求的代码,在etcdserver/v3_server.go的252行 阅读这段代码,可以看到函数调用leasehttp.RenewHTTP将请求发送到leader.PeerURLs,即发送到 leader 节点上。继续跟进leasehttp.RenewHTTP, 可以看出,该请求使用 golang 标准包的http.Client.Do来将请求发送出去。http.Client.Transport使用参数传入的rt http.RoundTripper。 定位到10s TLS timeout 的相关函数调用: 回到上一层代码 s.peerRt参数来自*EtcdServer.peerRt,在etcdserver/server.go的429行被赋值peerRt: prt prt变量在该文件的288行被赋值prt, err := rafthttp.NewRoundTripper(cfg.PeerTLSInfo, cfg.peerDialTimeout()) rafthttp.NewRoundTripper函数调用rafthttp.NewTimeoutTransport 没错,rafthttp.NewTimeoutTransport调用了rafthttp.NewTransport函数,也就是之前堆栈打印时,发现大量 TCP 链接创建使用的这个函数,也就是10s TLS timeout 的地方。 这里可以关联上了,确认大量发起 TCP 连接的代码调用关系,证明方向正确,剩下的就是顺藤摸瓜,找到发起大量请求的原因即可。 回到LeaseRenew函数调用的地方,调用func (s *EtcdServer) LeaseRenew函数代码见github.com/coreos/etcd/etcdserver/api/v3rpc/lease.go的88行附近 71行 func (ls *LeaseServer) LeaseKeepAlive函数调用func (ls *LeaseServer) leaseKeepAlive() etcdserver/etcdserverpb/rpc.pb.go的3417行func _Lease_LeaseKeepAlive_Handler(srv interface{}, stream grpc.ServerStream)调用了func (ls *LeaseServer) LeaseKeepAlive函数 etcdserver/etcdserverpb/rpc.pb.go的3480行将函数_Lease_LeaseKeepAlive_Handler设置为 StreamName: "LeaseKeepAlive"的 Handler,在通过*grpc.Server的RegisterService方法,在启动时,注册到监听器中 leaseKeepAlive函数是一个事件循环,从stream.Recv()拿到数据,则进行发送到 leader 节点上 stream.Recv()函数是google.golang.org/grpc/stream.go中的函数,这个不是 etcd 官方自己的代码,而是另外一个开源项目,意味着我还需要再把这块代码再阅读阅读,理解理解。 通过漫长的阅读,可以看到这里收到的消息都是来自 client 发送来的消息,其实可以从变量命名上看到端倪,比如 client 跟 member 之间通讯处理的变量都包含stream字样,而member到 leader 或 member 节点之间都带有peer字样,其实,client 跟 etcd 节点之间通讯用的是 http2传输协议,消息编码用的是grpc+protobuf;而etcd member节点之间是http1.1传输,消息编码也是 protobuf。 etcd lease 租约续期的时机: 就是说,这里的大量lease 租约续期的请求,来自 client 的请求,在我的案例中,我把主机 lease 的 TTL 设置为10S,这块会有异常吗?client 的 lease 续期是谁来发送?client 自己吗?多久发送一次?过期前的1秒? 在 etcd 的 client 类库中,对于租约续期的代码在clientv3/lease.go的474行 nextKeepAlive变量是 lease TTL 的三分之一,用于下次续期时的触发时刻,在后面的sendKeepAliveLoop函数续期时,回每隔500ms 进行一次循环,检测是否续期。也就是说,我们场景下,10s 的 TTL 会每隔3S 一次续期。3W 客户端的话,每秒1W 的写请求,而之前我们压测结果是集群节点可以有4W 以上的 (官方压测数据)qps,不至于这点都撑不住。这是个疑点。不过,前面的分析判断,大量的续约续期导致大量 TCP 链接的建立,大量 TCP 链接的建立,导致 leader 节点上大量对象分配,大量内存申请,golang gc 时,消耗大量 CPU,那这个问题也应该要优化一下,为此,我们把 TTL 设定为600S,即10分钟,也就是说,会在200秒后进行一次续期。 扩大lease TTL,尝试优化集群负载: 修复后的版本,我们在线下发布了6000多台,观察效果,果然,在发布后,内存有明显的下降,CPU 指标也有,但是在1个小时后,指标又上升了。。。 问题依旧: 这是为什么呢?中间大约1个小时的负载指标下降是因为发布过程带来的假象吗?上次变更的 lease 有效果吗?可我更改时,是测试过的,测试报告都有呢----agent 的 etcd lease租约时长 调整 ,TTL 续期是200S 一次了,但这是为什么?为了确认这个问题,我在其中一台 member 上抓包确认 上面确实是大量的 lease 包,那么每个 lease id 之间的发送间隔是多少?是我们预期的200秒吗?或者这些 lease 包是同一个 lease id 吗?一共有多少个 lease id 呢? 分析 tcpdump 的pcapng文件: 之前从 etcd 的源码分析可以看出,etcd node 节点之间的通讯消息编码也是 protobuf 的,我们需要把 http response body 的字节流复制出来,用 protobuf 的对应结果体解码即可。可是从 2018-07-05 16:38:31.015981 到 2018-07-05 16:43:20.420401 之间,间隔5分钟之间,一共103万个数据包,手动复制,实在没法统计。借用pcap包,对整个 tcpdump抓包的pcapng文件,分析, 过滤响应类型是application/protobuf的,取 http response body内容,并对其做 pb.LeaseKeepAliveRequest protobuf的结构体解析,计算 lease 总数与唯一数 运行结果: lease id 总数数: 77550, lease id 唯一数:1382。 这些结果说明,相同的 lease id 在频繁发送续约请求。再次写程序,把这些 lease id 取出来,到 etcd 集群上查看 TTL 剩余时间是否是预期的400-600秒之间,但结果却总是599秒,以其中一个 lease id 为例,每隔1秒去抓取 TTL 信息,但每次结果 TTL 都是599秒, grantTTL 是600,意味着 etcd member 持续不断的为这些 lease id 不停的续期。 我们之前排查结果是, member 的续期是来自 client 的 lease keepalive 请求,那么方向得放到 client 上。 抽查其中一台 client,抓包分析,也确实是在频繁发送/etcdserverpb.Lease/LeaseKeepAlive 请求,这就很奇怪,跟之前我的测试结果完全不一样,明明测试时,是每个200S 一起续期请求,为什么这里变得这么频繁,每秒都发起。 回到 etcd clientv3的代码上: 现象很奇怪,排查这个问题,只能再次从源码层面入手,方向也放到 /etcdserverpb.Lease/LeaseKeepAlive的地方,函数sendKeepAliveLoop这里开始循环处理判断是否需要续期租约 如上代码,每隔 500毫秒一次循环,遍历l.keepAlives map,判断每个LeaseID的nextKeepAlive是否到了,client 决定是否发起LeaseKeepAliveRequest。这个nextKeepAlive的赋值是在clientv3/lease.go的recvKeepAlive函数内,第473行 是的,这块代码之前看过,之前关注的只是 TTL 时间的设定,但仔细看看这段代码ka.nextKeepAlive = nextKeepAlive,这里做下一次lease 续期的赋值,判断方法是select case,判断条件是ch <- karesp,在 golang 里,这种写法表示该channle能写入的话,则进入case里面的流程,对ka.nextKeepAlive进行赋值;否则不赋值。。。否则,不赋值。。。否则,不赋值。。。 那么,这么 channel 长度多少呢?会不会写入的速度大于读取导致没写进去,导致下一次的续期时间没赋值成功呢?继续追查,在clientv3/lease.go的222行KeepAlive函数里 ch := make(chan *LeaseKeepAliveResponse, leaseResponseChSize)进行chan 的申请,长度leaseResponseChSize是常量,值是16。chs是个slice,长度1,只有这一个。那么,接下来,去确认这个 ch 读取的代码吧,可是我读完整个 lease.go也没找到哪里读取这个 channel 的代码,除了几处 close 的回收操作。那问题来了,会不会是这里把 ch channel 写满了,就无法再写入进去,导致ka.nextKeepAlive = nextKeepAlive流程走不到,导致ka.nextKeepAlive一直是上次的过去的续期时间,导致频繁进行续期呢? 找到疑点,每秒2次 lease keepalive 的请求: 开始测试,在 ch channel 写入的 default 分支里,打印“无法写入”等字样,并在for 后面打印这个ka的 TTL 等信息,用于核对。log.Println(fmt.Sprintf("karesp.TTL:%d, nextKeepAlive:%s,ka.nextKeepAlive:%s, ka.deadline:%s",karesp.TTL, nextKeepAlive.String(),ka.nextKeepAlive.String(), ka.deadline.String())) 改动后,重新编译代码,上传至某台 client 上 ,关掉 DEBUG 模式,观察日志输出。 文件见 /xxxx/xxx/core/nohup.out.1 ,在 2018/07/05 21:10:42 启动程序,经过1个多小时的观察,果然在2018/07/05 22:04:05时,lease 续约续期的请求从200秒变成了每秒2次。同时到 etcd 集群上查询响应 lease id,发现 TTL 均为599,grantTTL 为600,则可说明 是客户端在频繁重新发送租约请求。 第一个版本没打印ka.nextKeepAlive ,调整后,重新打印,为了让等待时间变短些,我把600s 改成60,便于观察。得到如下日志 2018/07/05 22:40:02 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:40:02 karesp.TTL:60, nextKeepAlive:2018-07-05 22:40:22.811626819 +0800 CST m=+40.135091508,ka.nextKeepAlive:2018-07-05 22:40:22.811626819 +0800 CST m=+40.135091508, ka.deadline:2018-07-05 22:41:02.811627601 +0800 CST m=+80.135092217 2018/07/05 22:40:22 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:40:22 karesp.TTL:60, nextKeepAlive:2018-07-05 22:40:42.922698966 +0800 CST m=+60.246163715,ka.nextKeepAlive:2018-07-05 22:40:42.922698966 +0800 CST m=+60.246163715, ka.deadline:2018-07-05 22:41:22.922699917 +0800 CST m=+100.246164470 2018/07/05 22:40:43 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:40:43 karesp.TTL:60, nextKeepAlive:2018-07-05 22:41:03.330494852 +0800 CST m=+80.653959932,ka.nextKeepAlive:2018-07-05 22:41:03.330494852 +0800 CST m=+80.653959932, ka.deadline:2018-07-05 22:41:43.330496602 +0800 CST m=+120.653961489 2018/07/05 22:41:03 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:41:03 karesp.TTL:60, nextKeepAlive:2018-07-05 22:41:23.341112484 +0800 CST m=+100.664577164,ka.nextKeepAlive:2018-07-05 22:41:23.341112484 +0800 CST m=+100.664577164, ka.deadline:2018-07-05 22:42:03.341113662 +0800 CST m=+140.664578212 2018/07/05 22:41:23 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:41:23 karesp.TTL:60, nextKeepAlive:2018-07-05 22:41:43.350264635 +0800 CST m=+120.673729313,ka.nextKeepAlive:2018-07-05 22:41:43.350264635 +0800 CST m=+120.673729313, ka.deadline:2018-07-05 22:42:23.350265498 +0800 CST m=+160.673730050 2018/07/05 22:41:43 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:41:43 karesp.TTL:60, nextKeepAlive:2018-07-05 22:42:03.572888276 +0800 CST m=+140.896353032,ka.nextKeepAlive:2018-07-05 22:42:03.572888276 +0800 CST m=+140.896353032, ka.deadline:2018-07-05 22:42:43.572889271 +0800 CST m=+180.896353840 2018/07/05 22:42:03 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:42:03 karesp.TTL:60, nextKeepAlive:2018-07-05 22:42:23.86879775 +0800 CST m=+161.192262488,ka.nextKeepAlive:2018-07-05 22:42:23.86879775 +0800 CST m=+161.192262488, ka.deadline:2018-07-05 22:43:03.868798687 +0800 CST m=+201.192263243 2018/07/05 22:42:23 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:42:23 karesp.TTL:60, nextKeepAlive:2018-07-05 22:42:43.877613966 +0800 CST m=+181.201079020,ka.nextKeepAlive:2018-07-05 22:42:43.877613966 +0800 CST m=+181.201079020, ka.deadline:2018-07-05 22:43:23.877615637 +0800 CST m=+221.201080417 2018/07/05 22:42:43 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:42:43 karesp.TTL:60, nextKeepAlive:2018-07-05 22:43:03.884576518 +0800 CST m=+201.208041122,ka.nextKeepAlive:2018-07-05 22:43:03.884576518 +0800 CST m=+201.208041122, ka.deadline:2018-07-05 22:43:43.884577361 +0800 CST m=+241.208041904 2018/07/05 22:43:03 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:43:03 karesp.TTL:60, nextKeepAlive:2018-07-05 22:43:23.893274193 +0800 CST m=+221.216738996,ka.nextKeepAlive:2018-07-05 22:43:23.893274193 +0800 CST m=+221.216738996, ka.deadline:2018-07-05 22:44:03.893275148 +0800 CST m=+261.216739702 2018/07/05 22:43:23 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:43:24 karesp.TTL:60, nextKeepAlive:2018-07-05 22:43:44.058766258 +0800 CST m=+241.382230998,ka.nextKeepAlive:2018-07-05 22:43:44.058766258 +0800 CST m=+241.382230998, ka.deadline:2018-07-05 22:44:24.058767168 +0800 CST m=+281.382231711 2018/07/05 22:43:44 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:43:44 karesp.TTL:60, nextKeepAlive:2018-07-05 22:44:04.413012991 +0800 CST m=+261.736477737,ka.nextKeepAlive:2018-07-05 22:44:04.413012991 +0800 CST m=+261.736477737, ka.deadline:2018-07-05 22:44:44.41301386 +0800 CST m=+301.736478391 2018/07/05 22:44:04 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:44:04 karesp.TTL:60, nextKeepAlive:2018-07-05 22:44:24.420796492 +0800 CST m=+281.744261106,ka.nextKeepAlive:2018-07-05 22:44:24.420796492 +0800 CST m=+281.744261106, ka.deadline:2018-07-05 22:45:04.420797607 +0800 CST m=+321.744262161 2018/07/05 22:44:24 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:44:24 karesp.TTL:60, nextKeepAlive:2018-07-05 22:44:44.428145652 +0800 CST m=+301.751610589,ka.nextKeepAlive:2018-07-05 22:44:44.428145652 +0800 CST m=+301.751610589, ka.deadline:2018-07-05 22:45:24.428147239 +0800 CST m=+341.751612018 2018/07/05 22:44:44 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:44:44 karesp.TTL:60, nextKeepAlive:2018-07-05 22:45:04.436151288 +0800 CST m=+321.759615961,ka.nextKeepAlive:2018-07-05 22:45:04.436151288 +0800 CST m=+321.759615961, ka.deadline:2018-07-05 22:45:44.436152168 +0800 CST m=+361.759616723 2018/07/05 22:45:04 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:45:04 无法写入 2018/07/05 22:45:04 karesp.TTL:60, nextKeepAlive:2018-07-05 22:45:24.441563525 +0800 CST m=+341.765028110,ka.nextKeepAlive:2018-07-05 22:45:04.436151288 +0800 CST m=+321.759615961, ka.deadline:2018-07-05 22:46:04.44156426 +0800 CST m=+381.765028789 2018/07/05 22:45:04 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:45:04 无法写入 2018/07/05 22:45:04 karesp.TTL:60, nextKeepAlive:2018-07-05 22:45:24.941897369 +0800 CST m=+342.265361977,ka.nextKeepAlive:2018-07-05 22:45:04.436151288 +0800 CST m=+321.759615961, ka.deadline:2018-07-05 22:46:04.941898136 +0800 CST m=+382.265362694 2018/07/05 22:45:05 lease Id KeepAliveRequest:5904610818195269641 2018/07/05 22:45:05 无法写入 再16次之后,即第17次,开始恢复每秒2次的租约请求。。。所以,也验证了此 bug。 也就是说 不管grant的租约TTL设置多久,最终都会变成0.5秒发送lease 续期一次!!!!! 也就是说 不管grant的租约TTL设置多久,最终都会变成0.5秒发送lease 续期一次!!!!! 也就是说 不管grant的租约TTL设置多久,最终都会变成0.5秒发送lease 续期一次!!!!! 之前的134版本灰度灰度后,中间CPU 降低了大约1个小时的样子,这是为什么呢? 解释 cpu 降低1小时的疑点: 我们这么计算, TTL 600,续约续约是200, ka.chs的 数组长度是1,第一个chan <- LeasesKeepAliveResponse的长度是16 ,全部写满的时间是20016/60 = 53分钟。 也非常契合 falcon 上的 cpu 信息截图 另外再从我加日志的第一次测试时间来算: 2018/07/05 22:04:05 - 2018/07/05 21:10:42 约为53分钟。也就是说,将近1小时,跟 falcon 的统计吻合的。。。 找到真凶: 定问题了,就是修复方式了,我并没看到 ch channel 的使用的地方,或许我没理解作者的用意,我觉得,不管 ch channel 是否写入,都必须将下一次nextKeepAlive的时间赋值为最新时间。将ka.nextKeepAlive = nextKeepAlive移到for select外面即可。 不过,我们产品急于推广,保险起见,牺牲了 lease 的需求,去掉了这个功能。发布到服务器后,负载情况有非常大的改善,CPU 从40%-50%降低到2%-5%左右。 回顾总结: 回顾etcd 集群遇到的所有现象,配合这个问题,所有问题都好解释了,那么,我按照事情发生发展的顺序总结一下原因: etcd clientv3的lease keepalive 类库有 bug,不管 ttl 设置多久,都会在续期16次后,变为0.5s 一次 agent 部署到大量服务器上后(2W 台),每个 agent 持有一个 lease id agent(相对于 etcd 来说,是client)连接到 etcd member 上,将lease 续期请求每秒2次发送到 member 上 etcd member 跟 leader 在续期 lease 上是使用 http 1.1,TCP 连接在上一个请求没响应之前,没法复用,几乎都是新创建的 TCP 连接 etcd leader 节点每秒收到 2W * 2 个 TCP 连接请求 早期 etcd node 之间是 https 通讯,TCP 建立后,会进行 TLS 握手,TLS 校验是 RSA 的数学计算,会消耗大量 CPU,导致 leader 节点 CPU 飙升 etcd leader 使用 grpc 类库监听的 http server,每一个 client 连接过来时,都会创建对象、结构体、channel用来通讯,占用更多内存 etcd leader 收到 etcd member转发来的 lease keepalive请求, 放到一个slice里(实际上这块是grpc做的) etcd leader 对存放recvMsg的b.backlog 写入速度大于消费速度,导致 slice 越来越大,占用更多内存,导致内存不停上升,导致内存溢出假象 etcd leader 处理请求的速度小于写入速度,导致请求堆积,超过 lease id 的 TTL,导致 lease id 判断过期,删除 TTL,也解释了 hids server 的 key 莫名其妙消失的现象 etcd member 大量新建 TCP 请求到 etcd leader,leader 处理后就关闭连接,导致 leader 侧会出现大量 TIME-WAIT 状态的链接。 etcd leader 处理 member lease 的请求太慢,超过 TLS 的10s 后,member 触发 TLS 超时,主动断开连接,但 leader 仍处理不过来,TCP 连接变为 CLOSE-WAIT 状态 最终,稻草压死骆驼。 该 bug 已反馈至 etcd 官方 clientv3 sdk will send /etcdserverpb.Lease/LeaseKeepAlive twice every second for lease id after over time 困扰我1个多月的 bug,不会忘记这个时刻 关于 etcd 过载保护的想法: etcd lease 的 keepalive 放到2/3的时间点去做是不是更合适? grpc 的过载保护是不是也要做下,比如接受 client 请求这里从数组改用定长队列,超过丢弃,来保证集群可用。 etcd 节点之间通讯改用 http2是不是更合适 声明 本文可转载,但不得删除文中 招聘信息,以及QQ 群信息。 原文来自:www.cnxct.com/etcd-lease-keepalive-debug-note/ 招聘[上海/北京]安全研发专家 月薪 40k-60k 上海市/ 5-10 年/本科/研发 招聘[上海/北京]美团点评--50-80K 招 Java 高级技术专家 简历请发至 cfc4n[{at}]cnxct.com ,谢谢。 对你的etcd 产品是否影响: 使用etcd clientv3(golang 的客户端类库) ,所有v3版本的客户端(包括3.0.x,3.1.x,3.2.x,3.3.x,3.4.x)。 使用 lease 租约,并设置 keepalive client 量比较大能看出来效果,几百台的话,对性能影响不大。 官方已修复 coreos etcd在2018年7月24日已经修复该 bug,涉及etcd client v3的3个版本效率非常高。详情如下 https://github.com/coreos/etcd/blob/master/CHANGELOG-3.1.md https://github.com/coreos/etcd/blob/master/CHANGELOG-3.2.md https://github.com/coreos/etcd/blob/master/CHANGELOG-3.3.md Fix lease keepalive interval updates when response queue is full. If <-chan *clientv3LeaseKeepAliveResponse from clientv3.Lease.KeepAlive was never consumed or channel is full, client was sending keepalive request every 500ms instead of expected rate of every "TTL / 3" duration. 2018-07-18 +4 8 0
golang语言编译的二进制可执行文件为什么比 C 语言大 最近一位朋友问我“为什么同样的hello world 入门程序”为什么golang编译出来的二进制文件,比 C 大,而且大很多。我做了个测试,来分析这个问题。C 语言的hello world程序: golang 语言的hello world程序: 编译,查看生成文件大小 正如这位朋友所说c.out是12K,而 go_fmt.out是1.5M,差距奇大无比....为什么呢? 这两个二进制可执行文件文件里,都包含了什么? 众所周知,linux 上的二进制可执行文件是 ELF Executable and Linkable Format 可执行和可链接格式 如上图,ELF 文件分为如下: ELF文件的组成:ELF header 程序头:描述段信息 Section头:链接与重定位需要的数据 程序头与Section头需要的数据.text .data 在 Linux 上, 查看elf格式构成可以使用readelf ELF Header:头的信息 ELF 头的长度都是一样的,不会带来总体体积的变化。区别是个别字节的值不一样,比如Entry point address 程序入口点的值不一样等。 接下来是 程序头:,也就是 section部分(在linker连接器的角度是section部分或者装载器角度的segment) 可以看到c.out里引用了一个动态链接库libc.so.6,再看下go_fmt.out的情况 c.out的执行,依赖了libc.so.6, libc.so.6肯定需要ld.so的,看下依赖情况, 也就是说,C的程序默认使用了libc.so动态链接库,go 的程序,默认进行了静态编译,不依赖任何动态链接库。所以体积变大了。 那么,只是这一个原因吗? 我在 golang 的官方文档里找到如下的解释: Why is my trivial program such a large binary? The linker in the gc tool chain creates statically-linked binaries by default. All Go binaries therefore include the Go run-time, along with the run-time type information necessary to support dynamic type checks, reflection, and even panic-time stack traces. A simple C "hello, world" program compiled and linked statically using gcc on Linux is around 750 kB, including an implementation of printf. An equivalent Go program using fmt.Printf is around 1.5 MB, but that includes more powerful run-time support and type information. 将c的程序也使用静态编译试试。。。 可以看到,使用静态编译生成的二进制文件c_static.out为888K,仍然比 GO 写的小了一半,这到底是为什么呢?到底是哪里多了? 在ELF 可执行文件里,就需要以程序编译链接的角度来分析了,对于一个 ELF 文件的分析,上面部分分析过 ELF header部分,以及 dynamic section的情况了。再以看一下剩余的section信息。 ELF中的section主要提供给Linker使用, 而segment提供给Loader用,Linker需要关心.text, .rel.text, .data, .rodata等等,关键是Linker需要做relocation。而Loader只需要知道这个段的Read、Write、Execute的属性。 再去看go_fmt.out里都包含了什么,为了方便校对,写了一个程序来对比 对比一下两个文件的section段信息 发现go_fmt.out多了好多.debug_*开头的 section,这是用于 debug 的段信息。再次编译,去除这些信息,同时也把 C 静态编译的二进制也去除符号表和重定位信息。 如上结果,go_fmt_sw.out为1012K,c_static_gs.out为820K,还大了近200KB。到底是哪里大的呢? 刚刚的两个elf 文件的section对比中,还有一个比较特殊的go_fmt.out中 有一个名字叫.gopclntab的段,类型是SHT_PROGBITS程序段,大小为 282414字节,也就是275K,在c_static.out里并没有这个段的,也没有.gosymtab这个段。二者不一样,section段名字有规范标准吗? 其实,对于linker链接器来说,会关心段(section)的名字,但对loader加载器来说,并不关心名字,只关心这个段(segment)的权限,是否可执行,所在的偏移地址,用于函数的执行。 那.gopclntab段包含了什么内容呢?我写了一个程序分析了这个段的内容,程序代码如下: 编译后执行 如上可以看到,有很多函数是以fmt.(*pp)、strconv.*、sync.*、reflect.*、unicode.*等开头的,后面对应的函数名,也与 golang 的包里对应的包中函数名一致。。。用 IDA来确认一遍 ,果然在 .gopclntab 段里有很多 reflect.*开头的函数。 这就很奇怪了,golang 编译时,默认把 runtime 包编译进来就好了,应该不会把strconv\sync\reflect\unicode等包包含进来啊。程序中,只写了一句fmt.Println(),莫非是fmt包import了其他几个包导致的?回去搜了下代码,果然... 嗯,应该是这里问题,改用 go 的内置函数print试试。 编译后,对比大小 看如上结果,go_print_sw.out 变成了 624K , c_static_gs.out为820K,不光没比C的静态编译的大,还比它小呢。。。 不过呢,这也不能说明什么问题,只是因为其包含的函数内容不一样。 好了,至此已经知道为什么 golang 编译的文件比 C 的大了,因为 go 语言是静态编译的,而 C 的编译(比如 gcc编译器)都是动态链接库形式编译的。所以,导致了 go 编译的文件稍微大的问题。其次,跟其他语言比较字符串输出的话,用print内置函数就好了,就不要使用fmt包下的函数来比较了,因为 fmt 包引入了好多其他的包。。。这也增加编译后的二进制文件的体积。 其实呢,golang 的编译(不涉及 cgo 编译的前提下)默认使用了静态编译,不依赖任何动态链接库,这样可以任意部署到各种运行环境,不用担心依赖库的版本问题。只是体积大一点而已,存储时占用了一点磁盘,运行时,多占用了一点内存。早期动态链接库的产生,是因为早期的系统的内存资源十分宝贵,由于内存紧张的问题在早期的系统中显得更加突出,因此人们首先想到的是要解决内存使用效率不高这一问题,于是便提出了动态装入的思想。也就产生了动态链接库。在现在的计算机里,操作系统的硬盘内存更大了,尤其是服务器,32G、64G 的内存都是最基本的。可以不用为了节省几百 KB 或者1M,几 M 的内存而大大费周折了。而 golang 就采用这种做法,可以避免各种 so 动态链接库依赖的问题,这点是非常值得称赞的。 另外,.gopclntab 跟 .gosymtab段的讨论,以及其他建议 https://groups.google.com/forum/#!topic/golang-dev/EJhwBrITcpA https://groups.google.com/forum/#!msg/golang-dev/EJhwBrITcpA/EMHeWpriGZUJ 如果想要ELF二进制程序变小的方法,还可以使用 linux 的strip,以及 UPX 来压缩 ELF 文件。 推荐一本书《程序员的自我修养-链接、装载与库》链接 参考: 《程序的链接和装入及Linux下动态链接的实现》 《Reversing GO binaries like a pro》 2017-09-21 +10 14 0
golang游戏项目中使用 tls、https 与etcd v3服务通讯 在全球同服的游戏架构中,网络结构一般设计成集群的形式,用户随便在哪个服务器节点上都可以玩,也就要求每个节点的环境都是一致的,节点之间是可以自由下线上线的,用户是可以在每个节点自由注册的...等等,各种需求的出现,会需要一款能主动发现节点上线线,能统计全局所有服务器负载、状态,能实时下发配置等信息到所有节点的这么一款软件,那么就用到 ETCD 了。 etcd 作为 高可用分布式配置同步的软件,一般只对内网服务,偶尔也会暴露到外网。不管是内网,还是外网,都需要安全认证这一步,而其默认是有以及用户名、密码的认证方式,只是需要对每一个索引做权限设定。etcd 官方也后 TSL 的认证,同时也只是 HTTPS 形式传输数据,以确保哪怕在内网传输,也保证数据安全性。 ETCD server的配置的下载地址:https://github.com/coreos/etcd/releases/download/v3.2.7/etcd-v3.2.7-linux-amd64.tar.gz ,解压后,可看到其版本信息 如果不需要证书,不需要鉴权就可以访问,则可以直接使用了。。。 对于需要确保加密传输,需要权限认证的服务,那么还是需要证书来保证的,关于证书生成,,借助工具 cfssl 为了确保游戏集群管理的 ETCD 的高可用,一般都会有多个 ETCD 服务同时运行组成一个 ETCD 的集群。 ETCD 才调用 RAFT 的算法进行主节点选举,日志同步等等。ETCD 节点之间的通讯,也需要证书的认证。为了便于以后的etcd节点变更,一般都会以域名形式来提供服务,故需要解析几个 etcd 的域名,我这里以etcd1.cnxct.com、etcd2.cnxct.com,同时解析到对应服务器上。我这里的例子是解析到本机。 创建证书临时存放的目录(为了看起来好区别) 查看一下生成的文件列表: 在这里的列表中,分为client.pem公钥,client-key.pem私钥。以及 ca.pem 公钥,以及几个 CSR证书请求文件;ca-key.pem 为 自制 ca 私钥文件,需要妥善保管,不能外泄。 证书生成好了,验证一下证书的有效性 如上,目测证书有效。将证书复制到 etcd 的配置目录 开始配置etcd的配置文件 我在从官方下载下来的二进制包中 ,并没有配置文件的例子文件,而且网上搜到的,几乎都是早起etcd 2 时代的 linux 上conf 文件常见配置格式(就是那种井号注释的那种)。在 etcd 3,都搞成了 yaml 格式了。 在网上用 google、baidu 搜了好久,压根找不到yaml的例子。后来在 github 官网找到了 yaml 的例子地址 , https://github.com/coreos/etcd/blob/master/etcd.conf.yml.sample ,这个强烈建议 etcd 项目组将这个配置的例子放到打包的二进制压缩包里。 这里稍微说一下,在3.1.9时, listen-client-urls 跟 listen-client-urls 两个参数,还是可以直接使用域名来配置的。 3.2.x 就不支持了,只能填正确的 IP 来配置。 initial-advertise-peer-urls跟advertise-client-urls两个参数可继续使用域名形式的地址。 测试一下 CA 证书在 etcd 服务器上是否正常工作 同样,测试 client 证书在 etcd 的服务上是否正常使用 如上结果,可以看出,两个证书均正常使用。 开始在 Golang 的 client 中使用证书文件: etcd 本身就是用 golang 语言编写的,项目托管在 etcd 的 github地址,客户端类库在client目录,以及 clientv3下,前一个适用与etcd v2的版本,后一个适用与etcd v3的版本,在 golang 的包里,就是github.com/coreos/etcd/clientv3。在我的项目中,我使用的是v3版本的客户端,使用证书,建立链接的伪代码如下 在游戏集群中,游戏服务器的节点在整个集群中是唯一的,每个节点都拥有一个唯一的节点 ID,而这个唯一 ID 在 ETCD 里的注册,需要用到CAS原子性的操作,在 V2的客户但里,是Set方法时,Options的PrevExist参数是 false 来实现。 在 V3里,是Txn来开启事务,实现 CAS 操作 在游戏服务器节点启动后,把自己注册到 ETCD 后,将从 ETCD 加载自己节点所属的配置信息,比如若是 gateway 节点的话,加载自己将转发到逻辑服务器的节点列表,加载客户端版本过滤信息等等。伪代码如下: 同样,在游戏集群中,经常会遇到配置的调整,在以前的方法中,都是挨个到服务器上修改配置再重新加载,或者高级一点的,自己研发 GMtools 推送消息到游戏的服务进程等等。我们在使用 etcd 时,使用 etcd 的 watch 功能,监控某个 KEY 的变动,若有变化,则立刻刷新到进程中的配置环境上,进行配置变更。当然,有些 DB 链接的 DSN,这些不会跟着改的。这种需求可以使用this.kapi.Watch(context.TODO(),keyName。 对于整个集群的配置管理,一般在 gmtools 里做统一配置管理,将配置写入到 ETCD,再有游戏服务器节点启动时加载,或者游戏服务器节点 watch 监控,遇到更新时也自动同步到其环境中。事例如下: 综上所述,对 etcd server 开启了 https 的加密传输,开启了 tls 的证书认证。可放心的在内网或者公网来传输数据,修改下发数据,确保不会恶意或无意的修改污染了。。。 附golang Test 的截图: Coreos Etcd分布式集群 讨论 QQ 群: 2017-09-15 1 0
Clash Royale皇室战争的黑盒技术分析 今年开春,Clash Royale《皇室战争》在国内上架,又是SuperCell公司的大作,想必会非常火。在去年年底我层玩过封测版本,掉线比较严重,但仍没影响到我对它的好奇,我很想知道它的网络同步是如何实现的,战斗那块是帧同步吗?全是客户端计算的吗?这些疑问一直困扰着我,这次在国内上线了,网络状况也好,调试起来会更会舒心,更方便。 为此,我在工作之余,开始对Clash Royale《皇室战争》进行了黑盒分析,洋洋洒洒写了80多页的PPT,用于团队内部分享。顺便也发在博客上,让博客看上去有点生机。 由于我逆向基础差,对clash royale的libg.so分析零零散散的将近两周,一直没能搞定sc文件内容的编码规则,没搞定通讯的消息包结构编码规则,直到团队内部分享日期逼近,才决定暂停分析,尽快收尾,先完成分享,以后再继续。 而我又不想就这样放弃,在分享前后的几天里,我内心一直纠结这事,放不下来,抽空就想去再调试调试,为此落下了工作,计划中的功能也没给其他兄弟们做个好开头,没专心把主体框架搭建好,直到另外一个兄弟简介的提醒,列出大纲框架,我才意识到已经耽误很大的进度了,羞愧难当,今天(周六)本没打算去公司加班的,又把周五背回来的电脑又背回去,乖乖的写点东西,才让我内心平静下来。现在想想,今日凌晨3点多的恶梦或许就是因为愧疚吧。现在这里做个了结,不在工作时间去分析这个问题,不能再耽误工作进度。 分享里,更多的是介绍思路,我本身因为逆向功底原因,未能实现协议的解密,是借助coc-proxy这个项目来完成的。同样,关于SC文件解码部分,也是参考COC的SC文件拼接规则进行的。感谢github社区的技术资料。 PDF如下,由于内容较多(127页),加载时间可能较长,请耐心等待...另外,本PDF会持续更新,增加内容以及修正错误,请来本站阅读最新版本。 希望自己可以在空闲时间里把遗漏的知识点补充的扎实点。 2016年5月17日,公开了公司内部版(公司内部应该修复完毕),127页,10M左右,包含公司内两款手游的解密过程,以及手机游戏客户端的资源、协议、加密密钥保护方案,常见反外挂方案等。希望可以帮助到同样做游戏的朋友。 下载地址: clash royale皇室战争静态资源解密过程与常见手游客户端保护方案 2016-04-10 +2 6 0
在mac osx上看lol国服ob录像的技术分析 一天,兴起而至,想在mac osx上看lol ob,要看国服的,没现成方案,实现不了,未遂,便决定研究一番。 在拳头游戏官方的api中SPECTATING GAMES riot games api提到观看ob模式的程序启动参数: "C:\Riot Games\League of Legends\RADS\solutions\lol_game_client_sln\releases\0.0.1.74\ deploy\League of Legends.exe" "8394" "LoLLauncher.exe" "" "spectator spectator.na.lol.riotgames.com:80 P+C3YqI3Mg9oHc6t9eTAKWE4T8prxwzR 1726229459 NA1" 程序启动的第三个参数中: 这里面包括ob server地址、端口,以及本场录像文件加密key,以及游戏id,游戏所属平台id。其中 encryptionKey参数每次都不一样,从数据包分析发现,这个key并不是为了在获取录像时的验证,我觉得是roitgames为了安全,为每场游戏的协议数据包做的加密key,客户端观看ob 录像时,用这个key解密,以提高协议安全度。 在riotgames官方api介绍上,以及github上几个开源的lol项目上,以及本人wireshark抓包分析总结出一场ob游戏观看,一共调用了如下http api接口 GET /observer-mode/rest/consumer/version //获取录像文件版本信息,返回字符串 GET /observer-mode/rest/consumer/getGameMetaData/:region/:id/*ignore //获取游戏的基本信息,包括游戏当前桢,最后一个chunkid,游戏开始时间等信息。返回json格式字符串 GET /observer-mode/rest/consumer/getLastChunkInfo/:region/:id/:end/*ignore //获取录像中最后一个chunk基本信息,包括了当前chunkid,下一个chunkid,当前桢的id之类信息,返回json格式字符串 GET /observer-mode/rest/consumer/getGameDataChunk/:region/:id/:chunkId/*ignore //获取对应chunkid的协议数据,返回字节流 GET /observer-mode/rest/consumer/getKeyFrame/:region/:id/:frame/*ignore //获取对应chunkid的协议数据,返回字节流 lol ob观战模式的实现,是将游戏中所有玩家操作,合并成一个chunk块,来发送到客户端的,以减少通讯量。 GET /record/:region/:gameId/:encryptionKey 这个api在github上有提到过,但我抓包过程中,没有看到被调用过。暂时忽略。 至此已经知道lol 英雄联盟的观战通讯过程,但全球大区中,我们腾讯服务器跟欧美服务器不一样,并没有公开的ob server地址。而是在tgp腾讯游戏助手中实现的。 这是个嵌入的html,真实地址是 http://api.tgp.qq.com/spectator/v1410/watch.shtml ,对应的ob 观战列表文件为 http://api.pallas.tgp.qq.com/core/get_ob_list ,以ob文件http://ob.pallas.tgp.qq.com/ob_data/1_1632443320.ob 来举例子。对于国外其它服务器的观战,是连接到官方的观战服务器进行的。而腾讯lol的观战,却是将录像文件下载到本地的,那是否可说明tgp助手在本地建立了一个http服务器,并解析ob文件,实现ob观战服务器的功能呢? 为此开始确认这个过程,在tgp腾讯游戏助手上观战了这场游戏,查看游戏进程的启动参数中,ob server地址是多少,开Process Monitor抓进程启动参数,开tgp助手观战: 如上图所示,启动参数复合riotgame的api介绍,我们来确认下127.0.0.1: 36722 是否正确,以及是哪个进程开启的。 replay.exe进程启动详情: 腾讯tgp助手,确实在本地开启http ob server来实现的。那么进一步抓包验证。对于回环地址,wireshark抓不到的,要用RawCap来抓。 同时,wireshark 抓取 ob录像文件下载的数据包,查看localhost.pcanpng ,果然是lol ob录像系统调用的几个http请求。 一一查看version\getGameMetaData\getLastChunkInfo\getGameDataChunk\getKeyFrame几个请求。也确实以相应字符串、json字符串、字节流的形式返回,但数据源头来自哪里呢?肯定是最初的ob文件了。 如上图,其中开头的每行分隔的字符串,、json字符串也比较好理解,里面的元素也是lol ob录像系统请求相应的json字符串 LOLOB/1.0 abstract:{"src": 1, "area_id": 1, "score": 5625000, "game_length": 1260, "battle_type": 4, "max_tier": [0, 0], "game_id": 1632443320, "start_time": "2015-10-21 08:13:37", "ob_ver": "1.82.89", "encryption_key": "x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt"} source:{"gameId": 1632443320, "gameStartTime": 1445386417603, "platformId": "HN1", "gameMode": "CLASSIC", "mapId": 11, "gameType": "MATCHED_GAME", "gameQueueConfigId": 4, "observers": {"encryptionKey": "x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt"}, "participants": [{"profileIconId": 7, "championId": 60, "summonerName": "\u5fc3\u6001\u597d\u80fd\u4e0a\u5206\u5417", "skinIndex": 2, "bot": false, "spell2Id": 11, "teamId": 100, "spell1Id": 4}, {"profileIconId": 15, "championId": 117, "summonerName": "\u4e5d\u670813\u65e5\u4e36", "skinIndex": 5, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 12}, {"profileIconId": 841, "championId": 58, "summonerName": "\u501a\u7af9\u8f7b\u8bed\u767d\u886b\u5982\u6545", "skinIndex": 2, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 12}, {"profileIconId": 17, "championId": 429, "summonerName": "\u840c\u515c\u515cTcT", "skinIndex": 2, "bot": false, "spell2Id": 7, "teamId": 100, "spell1Id": 4}, {"profileIconId": 914, "championId": 122, "summonerName": "\u91cd\u5e86\u897f\u5357\u5927\u5b66\u6821\u8349", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 14}, {"profileIconId": 922, "championId": 105, "summonerName": "L4ugh1ng", "skinIndex": 8, "bot": false, "spell2Id": 12, "teamId": 200, "spell1Id": 4}, {"profileIconId": 1, "championId": 28, "summonerName": "\u5170\u4ead\u5e8f", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 11}, {"profileIconId": 9, "championId": 203, "summonerName": "\u8292\u5e02\u9189\u9999\u56ed\u996d\u5e84", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 12}, {"profileIconId": 931, "championId": 110, "summonerName": "15\u5c81\u5357\u660c\u4eba\u670d\u4e0d\u670d", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 7}, {"profileIconId": 28, "championId": 432, "summonerName": "\u6218\u4e36\u65d7TV\u767d\u8272\u98ce\u8f66", "skinIndex": 1, "bot": false, "spell2Id": 14, "teamId": 200, "spell1Id": 4}], "gameTypeConfigId": 2, "gameLength": 5, "bannedChampions": [{"teamId": 100, "championId": 41, "pickTurn": 1}, {"teamId": 200, "championId": 157, "pickTurn": 2}, {"teamId": 100, "championId": 82, "pickTurn": 3}, {"teamId": 200, "championId": 114, "pickTurn": 4}, {"teamId": 100, "championId": 4, "pickTurn": 5}, {"teamId": 200, "championId": 76, "pickTurn": 6}]} obmeta:{"lastChunkId": 14, "gameKey": {"platformId": "HN1", "gameId": 1632443320}, "startTime": "Oct 21, 2015 8:13:37 AM", "endGameKeyFrameId": -1, "gameLength": 0, "keyFrameTimeInterval": 60000, "port": 0, "gameServerAddress": "", "interestScore": 2989, "clientBackFetchingEnabled": false, "clientAddedLag": 30000, "endStartupChunkId": 7, "chunkTimeInterval": 30000, "clientBackFetchingFreq": 1000, "pendingAvailableKeyFrameInfo": [], "decodedEncryptionKey": "", "createTime": "Oct 21, 2015 8:10:24 AM", "featuredGame": true, "gameEnded": false, "encryptionKey": "", "delayTime": 150000, "endGameChunkId": -1, "pendingAvailableChunkInfo": [], "startGameChunkId": 9, "lastKeyFrameId": 3} keyframe_tab:[[1, 9], [2, 11], [3, 13], [4, 15], [5, 17], [6, 19], [7, 21], [8, 23], [9, 25], [10, 27], [11, 29], [12, 31], [13, 33], [14, 35], [15, 37], [16, 39], [17, 41], [18, 43], [19, 45], [20, 47], [21, 49]] chunk_tab:[[1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0], [9, 30018], [10, 30008], [11, 29986], [12, 30007], [13, 30000], [14, 29985], [15, 30019], [16, 29981], [17, 30000], [18, 30008], [19, 30003], [20, 29982], [21, 30012], [22, 30011], [23, 29980], [24, 29993], [25, 30018], [26, 29982], [27, 30018], [28, 29984], [29, 30020], [30, 29984], [31, 30016], [32, 29980], [33, 29994], [34, 30009], [35, 30016], [36, 29997], [37, 29976], [38, 30006], [39, 30008], [40, 29985], [41, 30000], [42, 30027], [43, 29972], [44, 30006], [45, 30016], [46, 29977], [47, 30024], [48, 29990], [49, 29994], [50, 20015]] 不难看出,其中“LOLOB/1.0”为tgp助手本身识别的版本信息;abstract是ob录像版本,以及录像数据加密key存储的基本信息;obmeta对应着getGameMetaData的数据;keyframe_tab跟chunk_tab暂时不知道是什么意思,可稍后再回来看。 ob文件的剩下其它部分,显然就是录像中,每个chunk包的录像数据的了,那它是如何拼装的呢? 腾讯tgp助手下载的ob文件中,录像部分开头的字节流是01 00 01 00 00 35 88 85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b,回头看看lol游戏进程获取ob录像chunk部分时数据包的内容开头部分 开头的字节流是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b 7d,相比之下,重复部分是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98...,再多找几个录像获取数据的数据包,核对一下 可以确认,lol的ob录像回放协议中,协议头是85 a0 4a 30 1b 30 27 d5,也就是说,tgp下载的ob文件字节流部分,85 a0 4a 30 1b 30 27 d5前面的都不是lol协议的,这开头的01 00 01 00 00 35 88这7个字节为tgp读取识别的协议头部分。最初replay.exe程序读取理解的部分,本身不是lol客户端读取的协议部分。那么,这01 00 01 00 00 35 887个字节分别代表什么含义呢? 那我们开始找85 a0 4a 30 1b 30 27 d5出现的位置即可(关于在数M数据中,查找几个字节所在位置的地方,你最好有比较简便的方法,千万别告诉我你是一行一行看的。) 可以看出如下结果: 第一个在 00001124 部分,前面7个字节是01 00 01 00 00 35 88; 第二个在 000046B3 部分,前面7个字节是02 00 01 00 00 2b 10; 第三个在 000071CA 部分,前面7个字节是01 00 02 00 00 60 48; (wireshark会告诉你当前行所在的位置编号,记得加上所关注字节的偏移位置) 到了这里,根据编程灵(经)感(验),会这么猜想:第二个位置跟第一个位置相差了46B3-1124=358F个位置,也就是3588+7=358F(tgp协议头中,最后两个字节3588 + 协议头长度 7),用第三个跟第二个最比较以确认猜想对不对。71CA-46B3=2B17,即2B10+7符合猜测。 到此,可确认tgp协议头的7个字节中,最后2个字节表示这后续包的长度。但这后续包长度是否仅仅是后面2个字节所标示的,是不是后面3字节,还是4字节?还不能确定。能确定的是,开头的3个字节肯定不会表示后续包长度作用。如果你还是没发现这规律,那可不能怪我了,谁让你当时没有灵(jing)感(yan)呢? 如此一来,可以用更快的方法验证这个猜想,写一段程序,从ob录像文件中,分析tgp协议头,根据后2个字节对应长度的数据包,打印出前面tgp协议头的7字节,再打印后面8个字节(确认是否为85 a0 4a 30 1b 30 27 d5) [1 0 1 0 0 53 136] [133 160 74 48 27 48 39 213 59 235 178 18 77 140 13 98 82 11 21 159 209 122 212] [2 0 1 0 0 43 16] [133 160 74 48 27 48 39 213 54 53 74 75 93 254 127 91 102 147 88 122 160 207 84] [1 0 2 0 0 96 72] [133 160 74 48 27 48 39 213 240 224 50 221 162 250 17 161 77 240 23 76 184 68 252] [2 0 2 0 0 78 80] [133 160 74 48 27 48 39 213 137 33 26 130 55 190 142 64 241 79 180 111 211 4 8] [1 0 3 0 0 125 208] [133 160 74 48 27 48 39 213 156 71 54 190 104 32 173 195 78 15 247 95 154 20 4] [2 0 3 0 0 124 192] [133 160 74 48 27 48 39 213 71 93 96 97 131 246 103 242 117 117 242 122 102 57 252] [1 0 4 0 0 153 128] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 138 120 76 94 198 235 231] [2 0 4 0 0 126 48] [133 160 74 48 27 48 39 213 161 190 218 133 34 189 202 6 4 210 38 115 68 138 93] [1 0 5 0 0 145 56] [133 160 74 48 27 48 39 213 181 160 40 167 228 7 106 250 239 177 147 143 223 218 189] [2 0 5 0 0 124 208] [133 160 74 48 27 48 39 213 133 89 166 5 196 161 242 20 46 198 139 26 18 52 142] [1 0 6 0 0 180 152] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 60 43 67 53 87 214 163] [2 0 6 0 0 130 120] [133 160 74 48 27 48 39 213 158 207 13 38 213 94 203 78 214 51 158 92 104 197 112] [1 0 7 0 0 34 32] [133 160 74 48 27 48 39 213 178 213 133 20 42 35 24 186 160 140 25 92 252 210 242] [2 0 7 0 0 140 112] [133 160 74 48 27 48 39 213 46 67 112 101 89 72 41 205 82 117 228 27 204 109 200] [1 0 8 0 0 82 24] [133 160 74 48 27 48 39 213 142 246 116 70 48 41 45 65 120 25 184 155 91 123 174] [2 0 8 0 0 132 128] [133 160 74 48 27 48 39 213 253 10 27 170 202 136 197 234 239 187 196 5 166 215 182] [1 0 9 0 0 132 32] [133 160 74 48 27 48 39 213 63 45 71 158 103 71 129 113 196 148 131 177 29 128 162] [2 0 9 0 0 131 200] [133 160 74 48 27 48 39 213 52 170 14 71 17 205 63 28 76 253 41 134 241 227 200] [1 0 10 0 0 70 0] [133 160 74 48 27 48 39 213 9 8 197 104 10 153 18 70 24 239 30 230 123 22 102] [2 0 10 0 0 151 72] [133 160 74 48 27 48 39 213 208 112 28 134 21 105 39 113 51 90 157 36 215 94 173] [1 0 11 0 0 69 192] [133 160 74 48 27 48 39 213 207 49 23 198 37 84 251 4 153 255 28 239 177 47 13] [2 0 11 0 0 147 232] [133 160 74 48 27 48 39 213 64 115 149 233 120 244 31 210 191 42 252 38 126 32 27] [1 0 12 0 1 2 80] [133 160 74 48 27 48 39 213 186 139 142 5 35 179 166 152 135 234 102 89 109 33 6] [178 161 62 95 113 100 101] [113 58 47 114 56 216 181 49 171 213 150 80 50 222 228 94 34 110 55 170 212 163 177] [2 213 106 162 142 22 91] [8 155 150 15 27 47 191 79 102 112 16 90 168 160 96 244 195 13 72 185 16 118 29] 当tgp协议头的第五个字节不为0时,开始发生了错乱,截取后续包的内容是113 58 47 114 56 216 181 49,并非133 160 74 48 27 48 39 213(即85 a0 4a 30 1b 30 27 d5的十进制),说明tgp最后2字节表示后续包长度不对,应该继续增加,从目前的错误来看,起码是3个字节,即第5、6、7个字节。但如果是3个字节,而第4个字节也一直是0,显然也不合适。我决定将用后面4个字节表示剩余包的长度来检验一次。如下图: 结果都正确的打印了tgp协议头,以及后续8字节的85 a0 4a 30 1b 30 27 d5(对应十进制为:133 160 74 48 27 48 39 213) 至此,tgp协议头的后4个字节已经确认含义了,那么剩余前3个字节中,第一个字节有两种数值01、02,这两个数值的含义也比较好弄明白,这时,我的灵(jing)感(yan),他们分别对应getGameDataChunk、getKeyFrame的两种类型,可以从tgp录像请求的http中加以确认,如下图: 从本地抓取127.0.0.1的通讯包中,找http请求为getGameDataChunk、getKeyFrame的相应包,对照响应body中,字节流跟ob文件分析结果中,对应的是否一致。请求url为getGameDataChunk的,到tgp协议头第一个字节01的字节流中找。请求为getKeyFrame的,到第一个字节02中查找。这里也可以确认无误了。 至此,tgp协议头中,只剩下第2、3个字节不名含义。在汇总录像播放时,请求的所有url,你会发现getGameDataChunk、getKeyFrame请求中,对应的id参数是递增的,如下图: 再根据最初打印的所有tgp协议头7字节内容中,当第1字节一样时,发现第2字节不变,而第3个字节持续递增,这时有灵(jing)感(yan)了吗?第3个字节肯定是url请求中第n个包的含义。第二字节也可能跟第三字节构成uint16类型的总包数的含义。若表示一场游戏中,录像回放系统中,全部数据包分包数只用1字节表示的话,那一共可存储1<<8 -1 也就是255个,数量可能不够,所以,我假设第二、第三字节一起表示录像回放总包的含义。至此,tgp的分包方式中,包头含义已经全部表示明白了。那么,剩下的,就时写个结构体或者map来存储这些字节流即可。 到这里,ob文件分析完成了,对于lol ob server请求的几个url中,只剩下getLastChunkInfo请求没有处理了,你要知道,ob文件最初的几个json字符串中,还剩下chunk_tab、keyframe_tab 聪明的你,这时应该有灵(jing)感(yan)了吧。你来实现实时?什么?还是没灵(jing)感(yan)?再见...... 程序写好后,可以体验一下mac上lol客户端,看国服ob咯,不联网也可以看哦,只要从tgp助手那下载了ob录像文件。 国服客户端,提供观战功能并不能用,每次都是无法观战,或者观战数据加载错误之类的,不提供实时观战功能。ob录像请求的url返回404。 英雄联盟,游戏分为4个主进程 更新进程:LoLpatcher 启动进程:LoLLauncher 客户端(游戏大厅):LolClient 游戏进程:LeagueofLegends 这里已实现mac osx上观看lol ob录像,故可得知mac lol 游戏主进程 可以解析 国服lol 产生的lol协议文件,可大约证明游戏进程是兼容的,甚至一致的。。 更新进程上关系不大,只要保证版本一致即可。启动进程跟客户端两个进程实现了登陆认证功能,要做的,是实现这国服qq登陆认证绑定,也就是说mac osx上玩国服lol 理论上可行....要不,你来试试? 代码开源: 2015-10-27 +3 7 0