聊一聊《Bvp47 美国NSA方程式的顶级后门》中的BPF隐藏信道 背景 Bvp47的BPF使用 上周看了盘古实验室发表的《Bvp47 美国NSA方程式的顶级后门》的文章,里面提到后门使用BPF技术做通信信道的隐藏,本身不监听端口,通过特定SYN包唤醒后门。而且,此后门隐藏近二十年之久,至今才被发现。 Bvp47 直接利用 BPF 的这个特性作为隐蔽信道环节中在 Linux 内核层面的高级技巧,避免直接的 内核网络协议栈HOOK被追踪者检测出来。 具体的 BPF 汇编如下,只有满足这部分规则的 SYN 数据包(还包括UDP包)才会进入下一个加解密 流程进行处理: 文中一句带过,没有过多的细节描述。也能理解,毕竟是逆向黑盒分析,没有源码,还是有一些吃力。 eBPF在网络层恶意利用。 恰巧笔者近期在学习eBPF,且在春节前用eBPF技术实现了类似功能的后门DEMO,对这块特别感兴趣。 好奇它的兼容性是如何做的,HOOK点是如何选择的?用的BPF/eBPF哪个内核版本的类库? 演示DEMO的后门,绕过了服务器内的多款防御产品。 iptables防火墙绕过 :利用对外开放的80端口作为通讯隧道; webIDS绕过:流量到达服务器后,并不传递给nginx; NIDS绕过:入侵者流量在局域网之间流传并无异常,只是无法解密; HIDS绕过:是否信任了防火墙,忽略了本机/局域网来源的SSHD登录? 之前的演示视频做的比较潦草,缺少相关技术原理图,不太好理解,不推荐看了。新的视频已经剪辑好,近期笔者会在文章里分享。 当然,笔者是为了网络安全的红蓝对抗,才写的后门恶意利用的DEMO,用于作为HIDS等防御产品规划的论据,也是提醒同行需要对eBPF技术的恶意利用多加防范。 好奇之下,笔者根据自己eBPF网络信道隐藏的DEMO实现,猜测了一下Bvp47的隐藏原理。 猜测 从PDF里的汇编图来看,后门使用的是BPF技术,不是eBPF。且受影响版本都是linux 内核为2.6系列。 BPF/eBPF历程 回顾BPF发展历程: 1997年,linux kernel 2.1 首次引入BPF,BSD包过滤,支持网络事件处理。 2011年,linux kernel 3.0 增加即时编译。 2014年,linux kernel 4.1 革命性更新,eBPF出世,通用虚拟机。处理事件包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 2015年,BCC等各种辅助类库出现。 2016年,丰富了eBPF事件源,Cilium项目发布。 2017年,BPF成为内核独立子模块,Netflix、Facebook等公司,把eBPF应用在跟踪、DDos防御、L4负载均衡等。 之后快速发展,BTF规范产生,增加不同内核的兼容性等。增加尾调用、热更新等。 后门的实现猜测 文中提到受影响内核是2.6.x, 那使用的BPF版本还比较老,只能过滤网络流量。 理论是跟eBPF类似,大约在XDP层附近,工作在网卡驱动附近,可以做流量过滤处理,之后再交付给内核的网络协议相关函数。 libpcap的原理是包的clone,再传递,本身不影响应用层拿到原始包。在这个后门里做敲门的功能是足够了。至于传过去的自定义格式SYN标志包,应用层能否解析,影响也不大,也很难会被发现。 但如果是eBPF里,可以在XDP层读取、修改、丢弃包,TCP栈这层都拿不到包,更不用说应用层了,可以隐藏的更深。 普通内核网络部分函数HOOK的话,大约在网络层那块。所以,能很好的隐藏入口流量。 补个eBPF的图,方便大家理解。虽然后门用的是BPF技术, Packet flow in Netfilter and General Networking图出自: https://commons.wikimedia.org/wiki/File:Netfilter-packet-flow.svg ,做网络相关工作的运维SRE、研发工程师、安全工程师建议收藏。 后门原型cd00r 怀着好奇的心理,笔者在国外的安全网站找到了这个后门原型 ,在2002年,有安全网站分享出来了。 cd00r.c is a proof of concept code to test the idea of a completely invisible (read: not listening) backdoor server. Standard backdoors and remote access services have one major problem - the port's they are listening on are visible on the system console as well as from outside (by port scanning). To activate the remote access service, one has to send several packets (TCP SYN) to ports on the target system. Which ports in which order and how many of them can be defined in the source code. 涉及BPF的核心代码如下: /* variables for the pcap functions */ #define CDR_BPF_PORT "port " #define CDR_BPF_ORCON " or " char pcap_err[PCAP_ERRBUF_SIZE]; /* buffer for pcap errors */ pcap_t *cap; /* capture handler */ bpf_u_int32 network,netmask; struct pcap_pkthdr *phead; struct bpf_program cfilter; /* the compiled filter */ struct iphdr *ip; struct tcphdr *tcp; u_char *pdata; ... if (cports[0]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[0]); filter=(char *)smalloc(strlen(CDR_BPF_PORT)+strlen(portnum)+1); strcpy(filter,CDR_BPF_PORT); strcat(filter,portnum); } else { if (cdr_noise) fprintf(stderr,"NO port code\n"); exit (0); } /* here, all other ports will be added to the filter string which reads * like this: * port <1> or port <2> or port <3> ... * see tcpdump(1) */ for (i=1;i<cportcnt;i++) { if (cports[i]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[i]); if ((filter=(char *)realloc(filter, strlen(filter)+ strlen(CDR_BPF_PORT)+ strlen(portnum)+ strlen(CDR_BPF_ORCON)+1)) ==NULL) { if (cdr_noise) fprintf(stderr,"realloc() failed\n"); exit (0); } strcat(filter,CDR_BPF_ORCON); strcat(filter,CDR_BPF_PORT); strcat(filter,portnum); } 后门的全部代码,笔者放在了github上:https://github.com/ehids/rootkit-sample/blob/master/cd00r.c 。 同时,笔者还找到了二十多年前,GIAC公司对这款后门的分析预警,笔者也放在上面的git仓库里了。 cd00r后门原理 后门原型的代码比较少,读起来也不复杂。核心实现是调用libpcap类库进行网络包的过滤读取,识别每个TCP包中SYN标识位的body部分内容,找到相关特征进行匹配验证,验证通过,则启动后门连接进程,开启端口监听。 这后门默认不监听端口,会读取主机所有流量过滤,找到secret knock密码特征后,再执行入侵者预留的后门代码。 cd00r后门在过滤数据包时,筛选了常用的200、80、22、53、3等端口,再启动inetd等进程,监听5002/tcp端口,让这个进程与入侵者交互。真正的交互后门进程,是通过secret knock特征敲门后启动,还是有一些隐蔽性。并且,这个后门敲门后执行cdr_open_door()函数,启动的是inetd进程,实际上可以修改为其他任意代码。 cd00r后门业务流程 整个流程如下: 初始化cd00r变量、嗅探网卡、secret knock敲门端口等 初始化libpcap类库,创建tcpdump规则。没有全端口监听,是为了减少CPU负载,增加隐蔽性。 获取嗅探网卡的IP地址 初始化数据包捕获设备 创建规则字符串的过滤器,这里规则的执行,依赖BPF指令。 关联过滤器与嗅探器 开启守护进程fork for循环处理每个过滤器后的网络包,判断是否与secret knock密码匹配,不匹配则跳过 如果secret knock密码匹配,则执行入侵者的代码,比如执行inetd进程。 从代码来看,流程、逻辑都比较清晰,不是很复杂。从后门构思上来看,比较巧妙。若是现在的环境,有eBPF支持下,那会更加可怕。可以参考笔者的eBPF恶意利用相关视频。 Bvp47通信 回到主题上,根据盘古的PDF报告,Bvp47后门是2002年,cd00r是在2000年,相信Bvp后门的设计也参考了cd00r的灵感,在此基础上做了很多演进与扩展。 隐藏敲门 Bvp47后门使用的应该是最初版本的BPF指令,而且应该不是直接使用,而是引用了libpcap类库间接用了BPF指令。 在SYNKnock敲门机制上,应该与cd00r一致,这也能从PDF的截图里看到。 敲门后,才启动控制端,监听端口。 隐藏信道 在PDF里有很详细的描述,原理是通过SYN包附带payload,来逃避常规软件的检测。并对payload做了加密。 后门缺陷 兼容性 后门的SYNKnock敲门机制依赖libpcap做网卡嗅探,依赖BPF指令做网络包过滤。这些类库与BPF指令有着系统版本的限制,Bvp47后门用的是linux kernel 2.6内核的BPF版本,只能在2.6版本的内核上运行。这也是这篇PDF里展示受害系统都是2.6的原因。 若后门考虑兼容性,那么必定会根据不同版本分别做代码兼容编写、编译、分发才能做到。 在2022年,eBPF有了BTF规范的加持,CO-RE特性有着更好的系统兼容性,在为应用程序提供更便捷的维护成本外,也给木马后门带来了便捷。 行为明显 后门在SYNKnock敲门上做了隐藏,不需要监听端口就可以发起指令。在后续的通讯交互中,会启动新的进程。 新的进程内会包含大量用户态的行为,产生各种日志。防御方还是有一定痕迹可循。 总结 话说回来,这是二十年前的后门技术,隐藏信道部分从技术原理上来看,并不复杂,只是没有被大家重视。 而且,这后门只是在端口监听上,敲门阶段没有监听端口,在后门的连接、信息传输阶段,还是开了端口的。 在后门账号上,采用写入系统用户名、密码的方式,来实现SSHD的认证存储,本身还是有/etc/passwd等文件读写的用户态行为,现有HIDS都具备发现的能力。 但是,这是二十年前的技术,如今,BPF已经演进为eBPF,其功能更是强大,覆盖范围不止网络控制,还包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 笔者演示的两个基于eBPF的后门行为来看,可以做的更加隐蔽,不产生用户态行为,更加可怕,给防御方的挑战也越来越大。 检测机制 随着云原生场景迅速发展,eBPF技术的后门场景也越来越多,你的服务器上,是不是已经有这种后门在运行了?这种类型的安全产品该如何检测防御呢? 笔者整理了一篇《Linux中基于eBPF的恶意利用与检测机制》,近期分享给大家,请关注榫卯江湖微信公众号。 参考 《Bvp47 美国NSA方程式的顶级后门》 cd00r隐藏后门源码 Get a handle on cd00r: The invisible backdoor eBPF技术的rootkit恶意利用 2022-03-01 1 0
分享一些eBPF技术相关的PDF 说明 笔者在学习eBPF技术期间,收集了很多高质量的PDF,覆盖网络安全、网络编排、运维监控、性能抽样、调试跟踪等场景,所谓吃水不忘挖井人,在这里分享给大家,帮助大家一起成长。希望在座的各位,也都能回馈业界。 文件首页展 PDF/slide列表 Security 安全 Stories from BPF Security Auditing at Google - Brendan Jackman.pdf eBPF Summit 2021 - Stories from BPF Security Auditing at Google - Brendan Jackman.pdf Think eBPF for Kernel Security Monitoring - Falco at Apple.pdf eBPF, I thought we were friends.pdf Rex Guo Junyuan Zeng - Phantom Attack - Evading System Call Monitoring.pdf BPFContain_Fixing_the_Soft_Underbelly_of_Container.pdf 2021-Secure Namespaced Kernel Audit for Containers.pdf SSTIC2021-Article-runtime_security_with_ebpf-fournier_afchain_baubeau.pdf eBPF_and_the_future_of_osquery_on_Linux.pdf Kernel_Runtime_Security_Instrumentation.pdf Container_Security_Issues_Challenges_and_the_Road_Ahead.pdf Fast and Safe Production Monitoring of JVM Applications with BPF Magic.pdf Kernel Runtime Security Instrumentation.pdf Creating and countering the next generation of Linux rootkits using eBPF.pdf us-21-With-Friends-Like-EBPF-Who-Needs-Enemies.pdf Mitigating Attacks on a Supercomputer with KRSI.pdf networking 网络 Efficient Software Packet Processing on FPGA NICs Fast-Packet-Processing-using-eBPF-and-XDP bpf-rethinkingthelinuxkernel bpf-turninglinuxintoamicroservices-awareoperatingsystem tracing profiling 跟踪抽样 Aug19_Designing_a_gRPC_Interface_for_Kernel_Tracing_with_eBPF_Leonardo_DiDonato Data-Centric Tracing with BPF - Alan Maguire Why is my eBPF code slow_ - Simar Singh observability monitoring 观测监控 Advanced_BPF_Kernel_Features_for_the_Container_Age_FOSDEM Performance Analysis Superpowers with Linux eBPF eBPF基础知识 eBPF Library Ecosystem Overview in Go, Rust, Python, C and More - Kyle Quest Beginners_guide_to_eBPF_programming_for_networking 高效入门eBPF-西安邮电大学-贺东升 LIz_Rice-Beginners_guide_to_eBPF eBPF进阶知识 LPC21_Towards_truly_portable_eBPF bpf internals tracing examples brendan gregg demystify-ebpf-jit-compiler eBPF Summit 2021 - BPF and Spectre - Daniel Borkmann - Final android eBPF-super-powers-on-ARM64-and-android 经典BLOG/站点 见 BLOG.md github 笔者把这些PDF都放到github仓库了,后续也会持续更新。也希望大家分享高质量PDF。 https://github.com/gojue/ebpf-slide 2022-01-25 0 0
内核态eBPF程序实现容器逃逸与隐藏账号rootkit 提示 本文不同于 云原生安全攻防|使用eBPF逃逸容器技术分析与实践,腾讯同学的实现,是用eBPF技术在内核态hook,修改文件,让用户态APP产生行为日志(cron的文件变化,且需要执行shell),完成逃逸。在此过程中,还是会留下一些日志行为,能被常规HIDS感知捕获,触发告警。 本文是在kernel space内核态,hook内核态函数,更改内核态返回用户态缓冲区数据,达到用户态欺骗的目的。用户态进程拿到被篡改的数据,从而被骗通过认证。在此过程,不改变任何文件、进程、网络行为,不产生日志。 常规HIDS、HIPS产品无法感知。 背景 前段时间,调研了业界基于eBPF实现的HIDS类产品,包括Cilium、datadog、tracee、falco等产品。 在工程研发上,了解了业界在eBPF技术的应用方式,覆盖场景,实现的安全业务功能等。 在K8S生态下,涌现大批基于eBPF技术实现的集群网络管理插件,比如Calico、cilium等。而业务实现网络管理服务是以容器化方式部署,且有需要给这些容器启用SYS_BPF_ADMIN权限以支持eBPF系统调用。 这些业务形态,恰恰给这种攻击手段一个完美的发挥空间。 目的 随着业务形态变化,在强大的eBPF技术下,会给安全上带来什么样的威胁呢?正巧前段时间也看到腾讯@neargle 同学们写的 云原生安全攻防|使用eBPF逃逸容器技术分析与实践 文章,深知eBPF的威胁远比这更可怕,细想其功能覆盖XDP、TC、probe、socket等,每个功能点都能实现内核态的篡改行为,从而使得用户态完全致盲,哪怕是基于内核模块的HIDS,一样无法感知这些行为。 为此,笔者跟大家分享这篇文章,演示eBPF技术的强大,给防御产品HIDS/HIPS预警,尽快完成eBPF相关入侵行为监控的建设,提升IDC的防御检测能力。 原理 既然容器启动时,已经授权启用了eBPF权限,那么理论上可以做任何事情,包括容器逃逸获得宿主机权限、实现超级隐藏的rootkit后门、绕过网络防火墙等。 那么eBPF技术还能做哪些事情呢?为了回答这个问题,我们回顾一下eBPF的hook点 从图中可以看出,eBPF的hook点功能包括以下几部分 可以在Storage、Network等与内核交互之前; 也可以在内核中的功能模块交互之间; 又可以在内核态与用户态交互之间; 更可以在用户态进程空间。 这意味着,eBPF技术都可以在这些HOOK点做各种手脚,为所欲为,十分可怕。而更可怕的是,在前两个地方,都是在内核态以及内核态之前做手脚,而现有常见的HIDS都是基于内核里或者用户态做行为监控,这恰恰就绕开了大部分HIDS的监控,且不产生任何日志,简直细思极恐、不寒而栗。 演示环境 版本 宿主机系统版本 系统内核版本 root@vmubuntu:/home/cfc4n#uname -a Linux vmubuntu 5.11.0-41-generic #45-Ubuntu SMP Fri Nov 5 11:37:01 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux Linux 发行版 root@vmubuntu:/home/cfc4n#cat /etc/os-release NAME="Ubuntu" VERSION="21.04 (Hirsute Hippo)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 21.04" VERSION_ID="21.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=hirsute UBUNTU_CODENAME=hirsute 容器引擎版本 docker容器版本为20.10.12,比较新。 root@vmubuntu:/home/cfc4n#docker version Client: Docker Engine - Community Version: 20.10.12 API version: 1.41 Go version: go1.16.12 Git commit: e91ed57 Built: Mon Dec 13 11:45:34 2021 OS/Arch: linux/amd64 Context: default Experimental: true Server: Docker Engine - Community Engine: Version: 20.10.12 API version: 1.41 (minimum version 1.12) Go version: go1.16.12 Git commit: 459d0df Built: Mon Dec 13 11:43:42 2021 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.4.11 GitCommit: 5b46e404f6b9f661a205e28d59c982d3634148f8 runc: Version: 1.0.2 GitCommit: v1.0.2-0-g52b36a2 docker-init: Version: 0.19.0 GitCommit: de40ad0 镜像版本 容器镜像版本 root@vmubuntu:/home/cfc4n# docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE ubuntu21 latest 5355960aa2c5 5 days ago 119MB ubuntu hirsute d662230a2592 3 weeks ago 80MB ubuntu latest ba6acccedd29 2 months ago 72.8MB python 3.7.9-alpine3.11 8b32838a7ca6 11 months ago 41.9MB 环境 系统账号文件hash root@vmubuntu:/home/cfc4n# md5sum /etc/passwd /etc/shadow f43a244c13b639a41a8f555b28c1ff27 /etc/passwd 31dd98746685de12d8b6fb40ef22975e /etc/shadow 演示视频 B 站 1080P原画 :https://www.bilibili.com/video/BV1f34y1z7nN/ 检测防御 视频演示了eBPF技术的强大,只使用了tracepoint一个bpf prog类型实现容器逃逸与隐藏root的rootkit,不改动任何文件,用户态全程无感知。而eBPF还支持kprobe/kretprobe、uprobe/uretprobe、XDP、TC、socket、cgroup等程序类型,覆盖文件、网络、socket、syscall等事件,都是可以被黑客利用的地方。 比如,在XDP层实现一个IP转换,利用http(80端口)来透传sshd(22端口)的通信,完成绕过防火墙的目的,而主机的网络防火墙完全感知不到,更无法溯源出网络通信来源,就问你,可怕不可怕? 那针对这些威胁,站在防御者角度,该如何防范?eBPF实现的rootkit不同于常规rootkit,本身不改变函数地址、代码,防御检测的思路会有什么差别呢? 笔者近期将在「美团安全应急响应中心」微信公众号跟大家分享,敬请期待。 (已更新至: https://www.cnxct.com/evil-use-ebpf-and-how-to-detect-ebpf-rootkit-in-linux/ ) 最后 临近元旦,祝大家玩得开心,我们...节后见。 2021-12-30 2 0
datadog的eBPF安全检测机制分析 前言 代码版本为 datadog-Agent 2021年12月9日的 c9ddf6e854276cfc91aabe76415a203e17de5355 datadog-agent产品架构 在该产品相关演讲PPT里,找到了产品介绍资料--Runtime Security Monitoring with eBPF ,里面不仅包含产品架构图,还有总结了当下快速变化的架构下,如何实现运行时安全监控收集应用级别与容器级别的上下文,实现安全事件感知的思路。 架构图如下: 工程视角分析 invoke是python的任务管理工具,在这个项目里重度依赖,需要提前安装好。 python管理编译 invoke agent.build是官方文档介绍中用来编译项目的命令。invoke --list会显示当前项目所有可使用的命令列表。该命令会读取tasks目录下python的脚本,读取可执行函数。 本文重点在于eBPF相关功能实现,故只列出eBPF相关命令 cfc4n@vmubuntu:~/project/datadog-agent$ invoke --list Available tasks: ... system-probe.build Build the system_probe system-probe.clang-format Format C code using clang-format system-probe.clang-tidy Lint C code using clang-tidy system-probe.generate-cgo-types system-probe.generate-runtime-files system-probe.nettop Build and run the `nettop` utility for testing system-probe.object-files object_files builds the eBPF object files system-probe.test Run tests on eBPF parts ... 通过阅读源码、查阅资料,确定核心的eBPF源码都在system-probe.build命令中生成,并编译成.o的eBPF字节码。这个命令,会调用tasks\system_probe.py中build函数,再调用build_object_files来生成所有probe的eBPF字节码。这些probe字节码按照业务类型来划分,可以分为网络、runtime两类。 网络probe编译 build_network_ebpf_files函数会调用clang命令,编译链接pkg/network/ebpf/c/prebuild目录下4个文件,生成对应的bc与o文件 dns.c http.c offset-guess.c tracer.c runtime probe编译 合并生成runtime-security.c generate_runtime_files函数会调用go命令,合并生成runtime-security.c文件。 合并的命令是调用了go generate命令,选择tags参数为linux_bpf go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/oom_kill.go go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/tcp_queue_length.go go generate -mod=mod -tags linux_bpf ./pkg/network/http/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/connection/kprobe/compile.go go generate -mod=mod -tags linux_bpf ./pkg/security/probe/compile.go 在这些go文件的头部,有相应的go:generate指令,调用根目录的pkg/ebpf/include_headers.go来合并对应文件,并保存到pkg/ebpf/bytecode/build/runtime/目录下,生成的文件包括如下几个C文件。 conntrack.c http.c oom-kill.c runtime-security.c tcp-queue-length.c tracer.c 同时,在pkg/ebpf/bytecode/runtime/目录下生成相应的.go文件。 编译链接eBPF字节码 bindata_files.extend(build_security_ebpf_files(ctx, build_dir=build_dir, parallel_build=parallel_build)) system_probe.py的613行会调用的build_security_ebpf_files函数,调用外部命令clang编译生产bc文件,并调用llc链接bc文件生产.o的字节码。 clang -D__KERNEL__ -DCONFIG_64BIT -D__BPF_TRACING__ -DKBUILD_MODNAME=\\"ddsysprobe\\" -Wno-unused-value -Wno-pointer-sign -Wno-compare-distinct-pointer-types -Wunused -Wall -Werror -include ./pkg/ebpf/c/asm_goto_workaround.h -O2 -emit-llvm -fno-stack-protector -fno-color-diagnostics -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-jump-tables -I./pkg/ebpf/c -I./pkg/security/ebpf/c -DUSE_SYSCALL_WRAPPER=0 -c \'./pkg/security/ebpf/c/prebuilt/probe.c\' -o \'./pkg/ebpf/bytecode/build/runtime-security.bc bundle_files函数会对上面生成的文件一并进行编译链接。 go run github.com/shuLhan/go-bindata/cmd/go-bindata -tags 'ebpf_bindata' -split -pkg bindata -prefix 'pkg/.*/' -modtime 1 -o './pkg/ebpf/bytecode/bindata' './pkg/ebpf/bytecode/build/runtime-security.o' './pkg/ebpf/bytecode/build/runtime-security-syscall-wrapper.o' probe 模块编译 go build -mod=mod -v -a -tags "kubeapiserver python zk ec2 npm apm consul orchestrator systemd process jetson cri zlib containerd jmx podman clusterchecks netcgo secrets docker gce etcd kubelet linux_bpf" -o ./bin/system-probe/system-probe -gcflags="" -ldflags="-X github.com/DataDog/datadog-agent/pkg/version.Commit=c9ddf6e85 -X github.com/DataDog/datadog-agent/pkg/version.AgentVersion=7.34.0-devel+git.124.c9ddf6e -X github.com/DataDog/datadog-agent/pkg/serializer.AgentPayloadVersion=v5.0.4 -X github.com/DataDog/datadog-agent/pkg/config.ForceDefaultPython=true -X github.com/DataDog/datadog-agent/pkg/config.DefaultPython=3 " github.com/DataDog/datadog-agent/cmd/system-probe 从编译参数可以看出,核心的probe功能都在 cmd/system-probe 这个包里,这个包会独立编译成一个进程,也就是说,我们分析的ebpf使用相关功能都在这个包里。 datadog的probe hook点 在cmd/system-probe包里,一共5个模块分别是 NetworkTracerModule ModuleName = "network_tracer" OOMKillProbeModule ModuleName = "oom_kill_probe" TCPQueueLengthTracerModule ModuleName = "tcp_queue_length_tracer" SecurityRuntimeModule ModuleName = "security_runtime" ProcessModule ModuleName = "process" 同时,上面编译生产的eBPF字节码文件中,只有6个,分别是 dns.o / dns-debug.o http.o / http-debug.o offset-guest.o / offset-guest-debug.o runtime-security.o runtime-security-syscell-wrapper.o tracer.o /tracer-debug.o 从文件名来看,并不能与模块名一一匹配,那么,他们是如何加载的呢? NetworkTracerModule模块 eBPF字节码加载 网络跟踪模块tracer.NewTracer初始化时,判断配置中是否启用运行时编译配置,若启用,则调用runtime.Tracer.Compile进行源码编译,应该是暂时没实现CO-RE,需要每个主机上进行一次编译。 如果没启用运行时编译,则调用了netebpf.ReadOffsetBPFModule来加载之前编译生成的offset-guess.o,如果启动了调试模式,则加载offset-guess-debug.o。 模块管理器初始化 newManager函数初始化manager.Manager结构体时,初始化了eBPF maps跟eBPF probe列表。 probe probe列表覆盖TCP、UDP的链接创建、发送数据、关闭链接等事件(包含入口、出口),支持IPv4\IPv6,并针对linux kernel 4.7以上版本做了更多HOOK点。 从安全场景来看,建模所需数据,也满足网络后门的需求。 // pkg/network/ebpf/probes/probes.go // InetCskListenStop traces the inet_csk_listen_stop system call (called for both ipv4 and ipv6) InetCskListenStop ProbeName = "kprobe/inet_csk_listen_stop" // TCPv6Connect traces the v6 connect() system call TCPv6Connect ProbeName = "kprobe/tcp_v6_connect" // TCPv6ConnectReturn traces the return value for the v6 connect() system call TCPv6ConnectReturn ProbeName = "kretprobe/tcp_v6_connect" // TCPSendMsg traces the tcp_sendmsg() system call TCPSendMsg ProbeName = "kprobe/tcp_sendmsg" // TCPSendMsgPre410 traces the tcp_sendmsg() system call on kernels prior to 4.1.0. This is created because // we need to load a different kprobe implementation TCPSendMsgPre410 ProbeName = "kprobe/tcp_sendmsg/pre_4_1_0" // TCPSendMsgReturn traces the return value for the tcp_sendmsg() system call // XXX: This is only used for telemetry for now to count the number of errors returned // by the tcp_sendmsg func (so we can have a # of tcp sent bytes we miscounted) TCPSendMsgReturn ProbeName = "kretprobe/tcp_sendmsg" // TCPGetSockOpt traces the tcp_getsockopt() kernel function // This probe is used for offset guessing only TCPGetSockOpt ProbeName = "kprobe/tcp_getsockopt" // SockGetSockOpt traces the sock_common_getsockopt() kernel function // This probe is used for offset guessing only SockGetSockOpt ProbeName = "kprobe/sock_common_getsockopt" // TCPSetState traces the tcp_set_state() kernel function TCPSetState ProbeName = "kprobe/tcp_set_state" // TCPCleanupRBuf traces the tcp_cleanup_rbuf() system call TCPCleanupRBuf ProbeName = "kprobe/tcp_cleanup_rbuf" // TCPClose traces the tcp_close() system call TCPClose ProbeName = "kprobe/tcp_close" // TCPCloseReturn traces the return of tcp_close() system call TCPCloseReturn ProbeName = "kretprobe/tcp_close" // We use the following two probes for UDP sends IPMakeSkb ProbeName = "kprobe/ip_make_skb" IP6MakeSkb ProbeName = "kprobe/ip6_make_skb" IP6MakeSkbPre470 ProbeName = "kprobe/ip6_make_skb/pre_4_7_0" // UDPRecvMsg traces the udp_recvmsg() system call UDPRecvMsg ProbeName = "kprobe/udp_recvmsg" // UDPRecvMsgPre410 traces the udp_recvmsg() system call on kernels prior to 4.1.0 UDPRecvMsgPre410 ProbeName = "kprobe/udp_recvmsg/pre_4_1_0" // UDPRecvMsgReturn traces the return value for the udp_recvmsg() system call UDPRecvMsgReturn ProbeName = "kretprobe/udp_recvmsg" // UDPDestroySock traces the udp_destroy_sock() function UDPDestroySock ProbeName = "kprobe/udp_destroy_sock" // UDPDestroySockrReturn traces the return of the udp_destroy_sock() system call UDPDestroySockReturn ProbeName = "kretprobe/udp_destroy_sock" // TCPRetransmit traces the return value for the tcp_retransmit_skb() system call TCPRetransmit ProbeName = "kprobe/tcp_retransmit_skb" TCPRetransmitPre470 ProbeName = "kprobe/tcp_retransmit_skb/pre_4_7_0" // InetCskAcceptReturn traces the return value for the inet_csk_accept syscall InetCskAcceptReturn ProbeName = "kretprobe/inet_csk_accept" // InetBind is the kprobe of the bind() syscall for IPv4 InetBind ProbeName = "kprobe/inet_bind" // Inet6Bind is the kprobe of the bind() syscall for IPv6 Inet6Bind ProbeName = "kprobe/inet6_bind" // InetBind is the kretprobe of the bind() syscall for IPv4 InetBindRet ProbeName = "kretprobe/inet_bind" // Inet6Bind is the kretprobe of the bind() syscall for IPv6 Inet6BindRet ProbeName = "kretprobe/inet6_bind" // SocketDnsFilter is the socket probe for dns SocketDnsFilter ProbeName = "socket/dns_filter" // SockMapFdReturn maps a file descriptor to a kernel sock SockMapFdReturn ProbeName = "kretprobe/sockfd_lookup_light" // IPRouteOutputFlow is the kprobe of an ip_route_output_flow call IPRouteOutputFlow ProbeName = "kprobe/ip_route_output_flow" // IPRouteOutputFlow is the kretprobe of an ip_route_output_flow call IPRouteOutputFlowReturn ProbeName = "kretprobe/ip_route_output_flow" // ConntrackHashInsert is the probe for new conntrack entries ConntrackHashInsert ProbeName = "kprobe/__nf_conntrack_hash_insert" // SockFDLookup is the kprobe used for mapping socket FDs to kernel sock structs SockFDLookup ProbeName = "kprobe/sockfd_lookup_light" // SockFDLookupRet is the kretprobe used for mapping socket FDs to kernel sock structs SockFDLookupRet ProbeName = "kretprobe/sockfd_lookup_light" // DoSendfile is the kprobe used to trace traffic via SENDFILE(2) syscall DoSendfile ProbeName = "kprobe/do_sendfile" // DoSendfileRet is the kretprobe used to trace traffic via SENDFILE(2) syscall DoSendfileRet ProbeName = "kretprobe/do_sendfile" maps 在初始化的map中,分为两类,一类是BPF_MAP_TYPE_PERF_EVENT_ARRAY的map,但只有ConnCloseEventMap BPFMapName = "conn_close_event"这一个。其他的都是别的类型map。 对于ConnCloseEventMap这个map,使用perfHandlerTCP函数来处理DataChannel和LostChannel两类事件。 这里最终是把数据发送给每个网络客户端。 对于PerfEventArray类的map数据,使用dumpMapsHandler来统一处理事件,实现比较粗糙,直接打印出来。 整个模块中,内核态与用户态交互的map列表如下 // pkg/network/ebpf/probes/probes.go // BPFMapName stores the name of the BPF maps storing statistics and other info type BPFMapName string const ( ConnMap BPFMapName = "conn_stats" TcpStatsMap BPFMapName = "tcp_stats" ConnCloseEventMap BPFMapName = "conn_close_event" TracerStatusMap BPFMapName = "tracer_status" PortBindingsMap BPFMapName = "port_bindings" UdpPortBindingsMap BPFMapName = "udp_port_bindings" TelemetryMap BPFMapName = "telemetry" ConnCloseBatchMap BPFMapName = "conn_close_batch" ConntrackMap BPFMapName = "conntrack" ConntrackTelemetryMap BPFMapName = "conntrack_telemetry" SockFDLookupArgsMap BPFMapName = "sockfd_lookup_args" DoSendfileArgsMap BPFMapName = "do_sendfile_args" SockByPidFDMap BPFMapName = "sock_by_pid_fd" PidFDBySockMap BPFMapName = "pid_fd_by_sock" ) 可以看到,不仅提供了网络的数据,还提供了网络产生的进程ID的数据,安全威胁建模上,可以更好的关联到进程数据了。 probe点 在完成eBPF字节码读取后,开始读取当前模块的probe点信息,当前模块启用的probe点包含TCP的链接创建、UDP的信息发送。在kernel大于4.7版本上,会对^ip6_make_skb$函数进行hook。并按照配置支持IPv4/IPv6两种IP模式。 datadog-agent使用了自研的DataDog/ebpf类库,基于系统调用封装的ebpf管理模块,实现了setion加载、probe加载处理、过滤,event map读取包等功能。 在加载字节码后,开始启动probe的初始化 配置激活的probes 修改eBPF常量 修改map spec 修改program maps 加载pinned maps和programs到内核 使用验证器加载eBPF program到内核 eBPF maps读取 网络跟踪模块的maps读取分三种,分为TCP链接关闭事件、TCP链接列表map、TCP状态map三种。 在pkg/network/tracer/connection/kprobe/tracer.go使用newMapIterator函数对eBPF map进行迭代读取。 在加载eBPF程序到内核后, 遍历PerfMaps,开协程读取perfbuf事件,使用前面设置的LostHandler和DataHandler两个函数来毁掉消费数据。 挂载probe点到系统,让系统生产事件,并发送到map。 检查probe的选择器 更新模块的Map属性路由规则 更新模块的program的路由规则 HTTP 接口 在向外提供数据里,除了提供了统计当前链接总数等常规需求外,还提供了debug需求的数据,提供当前模块的eBPF数据大盘数据。 同时,在HTTP接口的服务器启动是,会根据配置选择启动相关metrics的模块,加载相应tracer.o/tracer-debug.o、http.o/http-debug.o、dns.o/dns-debug.o、offset-guess.o/offset-guess-debug.o、 OOMKillProbe模块 该模块从名字上来看是用来hook linux系统上OOM(Out Of Memory)发生时相关进程名、次数等信息,业务功能上比较简单。 eBPF字节码加载 不支持编译好的.o字节码加载,直接即时编译。 compiledOutput, err := runtime.OomKill.Compile(cfg, nil) 编译过程跟用统一的封装函数,不在赘述。 模块管理器初始化 同理,map跟probe比较少。分别是oom_statsmap 与 kprobe/oom_kill_process probe。(笔者吐槽一下,这里的map名字是硬编码的字符串,跟网络跟踪模块NetworkTracer不一样,非常不统一,以及搭建分析过程中的各种起码做法,简直无法理解,这代码简直是当代屎山。) 数据读取 OOM数据是以HTTP接口方式,提供给客户端调用的,HTTP接口调用时,OOMKillProbe.GetAndFlush函数会读取oom_statsmap里的所有数据,并清空map。将结果返回给HTTP response。 Process 模块 当前模块名字上来看,是用于提供进程数据的接口,但从代码分析上来,并不涉及eBPF probe的HOOK。提供的HTTP接口,也只是根据HTTP request中的PIDS,遍历/proc/{PID}/目录获取相应的进程数据。 SecurityRuntime模块 模块管理器初始化 system-probe包的runCmd命令启动时,把包的所有模块都注册启动了。函数的调用顺序是 run(...) ->StartSystemProbe() ->api.StartServer(cfg) ->module.Register(cfg, mux, modules.All) //module是package的名字 ->module.Register(router) //module是模块名字, module, err := factory.Fn(cfg) 调用每个probe 模块的Register方法,进行模块初始化、模块运行,比如SecurityRuntime模块,在pkg/security/module/module.go的71行 // Register the runtime security agent module func (m *Module) Register(_ *module.Router) error { if err := m.Init(); err != nil { return err } return m.Start() } eBPF字节码加载 在Init方法中,对eBPF 的probe模块进行初始化 // initialize the eBPF manager and load the programs and maps in the kernel. At this stage, the probes are not // running yet. if err := m.probe.Init(m.statsdClient); err != nil { return errors.Wrap(err, "failed to init probe") } USE_SYSCALL_WRAPPER 在模块初始化第一步,读取/proc/kallsyms,搜索open函数的具体syscall函数名,以笔者AMD64 ubuntu 21.04为例,对应的函数名为__x64_sys_open。 如果该函数不包含SyS_或者sys_,则设定useSyscallWrapper变量为true。 编译或加载 根据配置信息中是否启用运行时编译,决定编译或者加载编译好的.o字节码文件。 若编译,则调用runtime.RuntimeSecurity.Compile编译,CLANG编译参数的cflags中增加DUSE_SYSCALL_WRAPPER=1参数。编译过程机制与网络模块一致,略过。 如果是加载预编译,根据useSyscallWrapper的值,选择runtime-security.o或者runtime-security-syscall-wrapper.o相应的版本。 笔者注:关于这参数,网上信息特别少,参见C library system-call wrappers, or the lack thereof和glibc syscall wrapper 内部实现 加载机制跟网络模块一样,走datadog/ebpf-manager包的Manager.InitWithOptions统一处理,不再赘述。 datadog/ebpf-manager基于github.com/cilium/ebpf包做了封装。 perfMap事件处理 handle的设定,与网络模块一样,在模块的managerOptions设置时,做了赋值 //pkg/security/probe/probe.go 160行 p.perfMap.PerfMapOptions = manager.PerfMapOptions{ DataHandler: p.reOrderer.HandleEvent, LostHandler: p.handleLostEvents, } p.reOrderer.HandleEvent对perfMap处理时,增加了metric的统计信息,之后调用p.handleEvent来处理。该函数定义在 pkg/security/probe/probe.go中。 单向写入 在内核态中,所有probe hook的点产生的时间,都写入到events这个perfMap中。 普通Map管理 datadog-agent的用户态与内核态数据交互需求上,也实现也基于eBPF map的双向读写。 concurrent_syscalls exec_count_bb exec_count_fb noisy_processes_fb proc_cache pid_cache 内核态读写 这些map在内核态会被写入数据,而在用户态会用于查找、删除操作。 比如,进程创建时,触发SEC("kprobe/do_dentry_open") HOOK点后,内核态调用handle_exec_event函数会先查询proc_cache map是否有当前进程对应父进程的数据,之后再把自己进程信息存入proc_cache map。 用户态读 在用户态,也可以根据PID信息查找pid_cache map ,拿到cookie后,再到proc_cache map查询进程entry信息。 events的数据结构 在SecurityRuntime模块里,eBPF map用在内核态与用户态通讯时,使用统一封装的Event结构体,在eBPF 的内核态对应struct syscall_cache_t,包含多个事件类型的union struct。 events解析派发 用户态的go语言中,采用统一封装的大结构体Event解析内核态发来的数据。event.UnmarshalBinary读取包header信息,设定事件类型,再根据事件类型交付给Event的对应属性,对应解码方法解析消息内容。比如Event.Chmod.UnmarshalBinary来解码字节流。以下是Go中结构体的: // pkg/security/secl/model/model.go line 118 type Event struct { ID string `field:"-"` Type uint64 `field:"-"` TimestampRaw uint64 `field:"-"` Timestamp time.Time `field:"timestamp"` // Timestamp of the event ProcessContext ProcessContext `field:"process" event:"*"` SpanContext SpanContext `field:"-"` ContainerContext ContainerContext `field:"container"` Chmod ChmodEvent `field:"chmod" event:"chmod"` // [7.27] [File] A file’s permissions were changed Chown ChownEvent `field:"chown" event:"chown"` // [7.27] [File] A file’s owner was changed Open OpenEvent `field:"open" event:"open"` // [7.27] [File] A file was opened Mkdir MkdirEvent `field:"mkdir" event:"mkdir"` // [7.27] [File] A directory was created Rmdir RmdirEvent `field:"rmdir" event:"rmdir"` // [7.27] [File] A directory was removed Rename RenameEvent `field:"rename" event:"rename"` // [7.27] [File] A file/directory was renamed Unlink UnlinkEvent `field:"unlink" event:"unlink"` // [7.27] [File] A file was deleted Utimes UtimesEvent `field:"utimes" event:"utimes"` // [7.27] [File] Change file access/modification times Link LinkEvent `field:"link" event:"link"` // [7.27] [File] Create a new name/alias for a file SetXAttr SetXAttrEvent `field:"setxattr" event:"setxattr"` // [7.27] [File] Set exteneded attributes RemoveXAttr SetXAttrEvent `field:"removexattr" event:"removexattr"` // [7.27] [File] Remove extended attributes Exec ExecEvent `field:"exec" event:"exec"` // [7.27] [Process] A process was executed or forked SetUID SetuidEvent `field:"setuid" event:"setuid"` // [7.27] [Process] A process changed its effective uid SetGID SetgidEvent `field:"setgid" event:"setgid"` // [7.27] [Process] A process changed its effective gid Capset CapsetEvent `field:"capset" event:"capset"` // [7.27] [Process] A process changed its capacity set SELinux SELinuxEvent `field:"selinux" event:"selinux"` // [7.30] [Kernel] An SELinux operation was run BPF BPFEvent `field:"bpf" event:"bpf"` // [7.33] [Kernel] A BPF command was executed Mount MountEvent `field:"-"` Umount UmountEvent `field:"-"` InvalidateDentry InvalidateDentryEvent `field:"-"` ArgsEnvs ArgsEnvsEvent `field:"-"` MountReleased MountReleasedEvent `field:"-"` } 这里实现了多event类型的统一处理,但各个event之间耦合严重,event类型增加减少都需要影响上层代码,从设计模式上来看,问题比较大。并且,大的结构体的实力化也带来较大的内存占用,冗余严重。这里设计很差。 在内核发送来的事件处理上,针对进程事件除了解析之外,还有额外调用ProcessResolver.AddExecEntry将进程创建数据缓存。便于HTTP接口获取当前主机进程的全量列表。 统计对账 在handleEvent处理事件的同时,调用了perfBufferMonitor.CountEvent对事件的类型、时间、次数、长度、CPU ID做了计数,用于事件完整率对账,同时间接的用于观测当前系统的事件量大小,用作评估系统繁忙的间接依据。 事件威胁风险评估 Event结构体属性修正完后,*Probe.DispatchEvent将这个事件派发出去,送给规则引擎做安全模型评估。 规则引擎的实现,在NewRuleSet中,根据配置信息,针对不同的事件类型选择不同的规则集合,分别进行风险威胁评估。如果事件类型没有规则,则直接放过。 // 模块的HandleEvent调用ruleSet.Evaluate来评估事件。这里在处理多个事件时,采用了对象池的做法来管理内存。ruleSet规则集合中的eventRuleBuckets属性是模块的包含所有事件类型的总规则集合。 每种事件类型对应的bucket包含多个rules []*Rule, 评估时,遍历所有rules进行规则判断。 规则全匹配 如果命中规则,则将命中结果、命中规则信息、事件信息发送给规则的所有监听器(比如告警系统,关联事件处理器等) 规则部分匹配 如果没命中规则,则根据配置的每个字段,进行部分匹配判断, 若命中,则返回true。(但是,笔者看代码中,没发现有判断Evaluate函数的返回值) 若没命中,则准备丢弃,将事件与规则信息,发送给规则的所有监听器,以及发送到远程服务器,便于做规则调整。 风险检测规则引擎 实现上,是datadog自己做了一套AST语法分析的包,在pkg/security/secl/compiler下,识别的表达式,也是类似go语法的逻辑判断表达式。 规则配置时,只要根据上面Event的每个子event的属性来编写即可。比如 rules := []string{ `open.filename == "/etc/passwd" && process.uid != 0`, `(open.filename =~ "/sbin/*" || open.filename =~ "/usr/sbin/*") && process.uid != 0 && open.flags & O_CREAT > 0`, `(open.filename =~ "/var/run/*") && open.flags & O_CREAT > 0 && process.uid != 0`, `(mkdir.filename =~ "/var/run/*") && process.uid != 0`, } 规则字符串看上去比较直观,容易理解。 项目中,提供了一个默认的规则模版,在仓库里的runtime/default.policy 下。 事件结果上报 在规则引擎判断完威胁后,不论结果如何,都仍会将结果异步发送至远程数据中心。 这些上报的数据中,包含完整的内核态发送来的原始数据,以及本地规则引擎命中信息,识别结果等数据。方便远程数据中心做大数据分析、建模、验证、调整检测规则,来提升召回率和准确率。 TCPQueueLength模块 该模块与OOMKillProbe模块类似,只支持本地编译字节码,不支持加载预先编译的.o字节码。 业务功能上偏向于TCP的发送、接收包大小,与安全无关。加载机制上也与其他模块一直,故不再分析。 产品视角总结 datadog-agent支持包含windows、linux、IOT、android等多种系统平台,当然不同平台的支持程度不一样,本文的总结以支持eBPF的Linux为背景,给出这些总结。 业务特性区分不同map 产品中分别使用了*PerfMap对应BPF_MAP_TYPE_PERF_EVENT_ARRAY,这类业务对时间顺序有严格要求,事件读取后,会按照CPU时间排序。若对时间顺序无要求,则使用BPF_MAP_TYPE_ARRAY 、BPF_MAP_TYPE_LRU_HASH、BPF_MAP_TYPE_HASH等其他类型。 事件类型丰富 多个probe的字节码文件,支持包含系统运行时、网络、内核等事件的感知发送。包含 文件 权限变更 所有者变更 打开 目录创建 改名 软链 查看时间 别名 设置属性 移除属性 进程 进程创建 UID设置 GID设置 cap设置 内核 SELinux命令 BPF调用 系统 目录挂载 目录卸载 ARGS/ENGS设置 OOM事件 网络事件(IPv4/IPv6) TCP链接创建、发送数据、关闭链接 UDP链接创建、发送数据、关闭链接 SOCKET链接创建、发送数据、关闭链接 字段属性丰富 比如进程事件,包含 进程属性 进程文件系统属性 进程启动参数 环境变量 父进程信息(不全) 其他进程事件不在一一罗列,见pkg/security/secl/model/model.go内相关事件结构体。 多事件信息合并 在模块内部有部分事件的缓存,用于应对多个事件之间的关联,也可以用作本地规则引擎判断的数据补充。比如先设置环境变量,再执行进程创建命令。 配置识别判断规则远程下发 远程重新加载配置,以更新网络规则、安全事件识别规则。 多重事件风险判断 本地判断 包含完整匹配 部分匹配 远程判断、纠偏 本地判断更快的处理威胁场景,减轻远程数据中心处理压力。远程数据中心离线分析,威胁识别校验,感知新的威胁事件,优化改进识别规则。新规则快速应用到本地,提升检出效率。 事件数据管理 灵活注册事件接收器 事件数据监控(告警在远程服务器) 事件数据统计对账 linux多版本支持 不支持CO-RE,采用本地编译方式,依赖本地编译环境,不适合大型企业的HIDS场景。 以我司为例,内核版本分布相对清晰,版本种类不多,更适合预先编译相应字节码文件,HIDS Agent按照系统版本加载相应的字节码文件。 总结 datadog的system-probe模块使用了eBPF技术研发,实现了网络管理、运行时安全、系统状态感知等几个功能,支持运维、安全两个场景。其中核心模块在网络与运行时安全两个场景。故本文重点分析了这两个场景的技术实现与业务特性。 优点: 在上面产品视角分析中已经提到了。是一个功能、数据比较齐全的安全产品,数据满足各种入侵类型的建模,并且有多重安全模型分析机制,具备良好的事件上报、监控告警能力,具备数据统计、对账能力。 不足: 该产品以安全数据收集、检测为主,缺少安全防御功能。在已有功能中,没有看到限速限流、资源占用控制等功能。 在工程质量上吐槽点特别多,比如奇葩包的路径、几十种go package的go/C混编译、跨包修改变量、go generate/go build 乱调、混合C/C++/go/python多种语言、自定义N种go build tags、硬编码严重、部分功能耦合严重、扩展性差。这可能就是历经N年,多代程序员累计的shitcode屎山吧。 参考资料 https://github.com/DataDog/datadog-agent Runtime Security Monitoring with eBPF 2021-12-16 0 0
Cilium eBPF实现机制源码分析 目的 本文面向eBPF开发者,旨在研究学习高质量开源产品设计思路、编码规范,学习更好得使用eBPF方法经验。内容比较干燥,谨慎阅读。 本文涉及cilium代码版本为2021-08-17的1695d9c59a版本 Cilium产品介绍 Cilium是由革命性内核技术eBPF驱动,用于提供、保护和观察容器工作负载(云原生)之间的网络连接的网络组件。 Cilium使用eBPF的强大功能来加速网络,并在Kubernetes中提供安全性和可观测性。现在 Cilium将eBPF的优势带到了Service Mesh的世界。Cilium服务网格使用eBPF来管理连接,实现了服务网格必备的流量管理、安全性和可观测性。 项目理解 从Cilium的架构图来看,位于容器编排系统和Linux Kernel之间,使用eBPF技术来控制容器网络的转发行为以及安全策略执行。其使用的eBPF功能包括宿主机网卡流量控制,容器container功能管理等。与系统交互的模块是Cilium Daemon,负责eBPF字节码生成、字节码注入到linux kernel,并进行数据读取等。 所以,本文重点将放在Cilium Daemon实现上。 Cilium Daemon模块在源码里对应https://github.com/cilium/cilium/tree/master/daemon目录,从main.go主文件开始阅读即可。但在阅读之前,先对cilium项目的目录结构做一个认识。 目录结构: 当前目标是理解分析cilium的eBPF应用,故笔者在总结时,只列出eBPF相关代码。 按照cilium官方的文档介绍github.com/cilium/cilium,从目录结构中,摘录了eBPF相关的目录。 顶级目录 bpf : eBPF datapath收发包路径相关代码,eBPF源码存放目录。 daemon : 各node节点上运行的cilium-agent代码,也是跟内核做交互,处理eBPF相关的核心代码。 pkg : 项目依赖的各种包。 pkg/bpf : eBPF运行时交互的抽象层 pkg/datapath datapath交互的抽象层 pkg/maps eBPF map的描述定义目录 pkg/monitor eBPF datapath 监控器抽象 源码阅读 cilium C语言eBPF源码 源码功能分类 bpf目录下有很多eBPF实现的源码,文件列表如下 bpf_alignchecker.c C与Go的消息结构体格式校验 bpf_host.c 物理层的网卡tc ingress\egress相关过滤器 bpf_lxc.c 容器上的网络环境、网络流量管控等 bpf_network.c 网络控制相关 bpf_overlay.c 叠加网络控制代码 bpf_sock.c sock控制相关,包含流量大小控制、TCP状态变化控制 bpf_xdp.c XDP层控制相关 sockops 目录下有多个文件,用于sockops相关控制,流量重定向等性能优化。 cilium-probe-kernel-hz.c probe测试的,忽略 作用解释 关于上面eBPF文件源码的作用,在官网也有文档eBPF Datapath Introduction解释,笔者稍做整理。 在详细阐述每个模块源码之前,我们先来复习一下linux kernel的网络栈 如图所示,入口网络流量在到达NIC后,依次经过XDP、TC、Netfilter、TCP、socket层。 cilium的ebpf相关程序,核心功能是针对pod宿主机、pod、容器几个角色之间的网络流量管控。那么其功能肯定是在这几个栈的对应部位做响应hook。 常用的nftables、iptables模块对应在Netfiliter层,站在安全的视角里,HOOK点如何选择,选在那一层,需要结合安全需求来确定。 XDP XDP BPF hook 是网络驱动程序中最早的一关,可以在接收到数据包时触发BPF程序。在这里实现了最高效的数据包处理。这个HOOK非常适合运行过滤程序来处理恶意或意外流量,以及实现常见的 DDOS保护机制。 Traffic Control Ingress/Egress 显然,是附加到流量控制 (TC) Ingress HOOK的BPF程序,与XDP类似,区别是其在网络堆栈完成后,数据包初始处理后运行。此HOOK在内核整个网络堆栈的L3层之前运行,可以读取与数据包关联的大部分元数据。很适合进行本地节点处理,比如应用L3/L4端点策略进行流量重定向等。 容器场景常使用veth pair的虚拟设备,它充当容器与主机的虚拟网桥。通过attach到这个veth pair的宿主机的TC Ingress钩子,Cilium 可以监控管理容器的所有流量。 socket operations sockops Hook是attach到特定的cgroup上,在TCP事件上一并运行。Cilium的实现是把BPF sockops程序attach到根cgroup上,来监视TCP状态转换,进行相关业务处理。 socket 发送/接收 该钩子在TCP socket执行的每个发送操作上运行。这个钩子可以对消息进行读取、删除、重定向到另一个socket。(PS:笔者说一句,这个就很可怕,在安全场景里,这可以很简单得实现端口复用的后门程序。复用80端口,监听unix socket做木马后门,HIDS如何发现?) cilium的eBPF场景应用 Cilium使用上面几个Hook与几个接口功能相结合,创建了以下几个网络对象。 虚拟接口(cilium_host、cilium_net) 可选接口(cilium_vxlan) linux内核加密支持 用户空间代理(Envoy) eBPF Hooks 预过滤器 prefilter XDP层实现的网络流量过滤过滤器规则。比如,由Cilium agent提供的一组CIDR映射用于查找定位、处理丢弃等。 endpoint策略 Cilium endpoint来继承实现。使用映射查找与身份和策略相关的数据包,该层可以很好地扩展到许多端点。根据策略,该层可能会丢弃数据包、转发到本地端点、转发到服务对象或转发到 L7 策略对象以获取进一步的L7规则。这是Cilium数据路径中的主要对象,负责将数据包映射到身份并执行L3和L4策略。 Service TC栈上的HOOK,用于L3/L4层的网络负载均衡功能。 L3 加密器 L3层处理IPsec头的流量加密解密等。 Socket Layer Enforcement socket层的两个钩子,即sockops hook和socket send/recv hook。用来监视管理Cilium endpoint关联的所有TCP套接字,包括任何L7代理。 L7 策略 L7策略对象将代理流量重定向到Cilium用户空间代理实例。使用Envoy实例作为其用户空间代理。然后,根据配置的L7策略转发流量。 如上组件是Cilium实现的灵活高效的 datapath。下图展示端点到端点的进出口网络流量经过的链路,以及涉及的cilium相关网络对象。 总结 综合C的代码,从数据流向来看,分为两类 用户态向内核态发送控制指令、数据 内核态向用户态发送数据 第一部分,cilium调用类bpftool工具来进行eBPF字节码注入。(具体实现的方式,go代码分析时会讲到); LB部分,会直接向map写入数据内容。(lb.h) 第二部分是内核向用户态发送数据,而数据内容几乎都是其他eBPF的运行日志。尤其是dbg.h里定义的cilium_dbg* 方法,实现了skb_event_output()和xdp_event_output()两种函数输出,来代替trace_printk()函数,方便用户快速读取日志。两种函数对应的事件输出都是用了perf buf类型的map来实现,对应go代码里做了详细的实现,抽象的非常好,后面笔者会重点介绍。 cilium go源码分析 eBPF map初始化 上面提到Cilium Daemon是管理eBPF的模块,那么从这个模块的入口文件开始阅读。 ebpf map是在ebpf prog加载之前,预先初始化的,在daemon/cmd/daemon.go469行 err = d.initMaps() initMaps函数实现在daemon/cmd/datapath.go文件的272行。 // initMaps opens all BPF maps (and creates them if they do not exist). This // must be done *before* any operations which read BPF maps, especially // restoring endpoints and services. func (d *Daemon) initMaps() error { lxcmap.LXCMap.OpenOrCreate() ipcachemap.IPCache.OpenParallel() metricsmap.Metrics.OpenOrCreate() tunnel.TunnelMap.OpenOrCreate() egressmap.EgressMap.OpenOrCreate() eventsmap.InitMap(possibleCPUs) signalmap.InitMap(possibleCPUs) policymap.InitCallMap() } initMaps函数中初始化了cilium的所有eBPF map,功能包括xdp、ct等网络对象处理。 eBPF maps作用博主rexrock在文章 https://rexrock.github.io/post/cilium2/中做个直观的图,见 本文挑选其中一个例子来讲。就是前提提到的events maps初始化,用于内核的ebpf字节码调试输出的日志,对应代码eventsmap.InitMap(possibleCPUs)。 代码文件在pkg/map/eventsmap/eventsmap.go的53行 eventsMap := bpf.NewMap(MapName, bpf.MapTypePerfEventArray, &Key{}, int(unsafe.Sizeof(Key{})), &Value{}, int(unsafe.Sizeof(Value{})), MaxEntries, 0, 0, bpf.ConvertKeyValue, ) 从代码中可以看到,map名字是cilium_events,类型是MapTypePerfEventArray。 eBPF代码编译 回到daemon/cmd/daemon.go文件,接着往下看,在816行对eBPF应用场景进行初始化。 err = d.init() //跳转到285行的init函数 // Remove any old sockops and re-enable with _new_ programs if flag is set sockops.SockmapDisable() sockops.SkmsgDisable() 对新老map进行删除、替换。 在237行进行datapath的重新初始化加载。 if err := d.Datapath().Loader().Reinitialize(d.ctx, d, d.mtuConfig.GetDeviceMTU(), d.Datapath(), d.l7Proxy); err != nil { datapath初始化ebpf环境 Reinitialize函数是抽象的interface的函数,具体实现在pkg/datapath/loader/base.go的230行 该函数前半部分对启动参数进行整理汇总。核心逻辑在421行。 prog := filepath.Join(option.Config.BpfDir, "init.sh") cmd := exec.CommandContext(ctx, prog, args...) cmd.Env = bpf.Environment() if _, err := cmd.CombinedOutput(log, true); err != nil { return err } 是的,你没看错,调用了外部的shell命令进行ebpf代码编译。对应文件是bpf/init.sh,这个shell里会进行编译ebpf文件。 比如:bpf_compile bpf_alignchecker.c bpf_alignchecker.o obj "" ,生成eBPF字节码.o文件。后面将用于校验C跟GO的结构体对齐情况。 bpf_compile也是封装的clang的编译函数,依旧使用llvm\llc编译链接eBPF字节码文件。 eBPF字节码加载 同样bpf/init.sh也会对bpf/*.c进行编译,再调用tc等命令,对编译生成的eBPF字节码进行加载。 其次,go代码里也有加载的地方,见pkg/datapath/loader/netlink.go的replaceDatapath函数内91行使用ip 或tc 命令对字节码文件进行加载,使内核加载新的字节码。完成新老字节码的注入替换。 C跟go结构体格式校验 430行,使用go代码,验证C跟G结构体对齐情况。 alignchecker.CheckStructAlignments(defaults.AlignCheckerName) 在pkg/alignchecker/alignchecker.go里,CheckStructAlignments函数会读取.o的eBPF字节码文件,按照elf格式进行解析,并获取DWARF段信息,查找.debug_*段或者.zdebug_段信息。 getStructInfosFromDWARF函数会按照elf里段内结构体名字与被检测结构体名字进行对比,验证类型,长度等等。 ebpf编译加载的其他方式 在pkg/datapath/loader/base.go210行左右reinitializeXDPLocked函数 调用compileAndLoadXDPProg函数进行ebpf字节码编译与加载。 // compileAndLoadXDPProg compiles bpf_xdp.c for the given XDP device and loads it. func compileAndLoadXDPProg(ctx context.Context, xdpDev, xdpMode string, extraCArgs []string) error { args, err := xdpCompileArgs(xdpDev, extraCArgs) if err != nil { return fmt.Errorf("failed to derive XDP compile extra args: %w", err) } if err := compile(ctx, prog, dirs); err != nil { return err } if err := ctx.Err(); err != nil { return err } objPath := path.Join(dirs.Output, prog.Output) return replaceDatapath(ctx, xdpDev, objPath, symbolFromHostNetdevEp, "", true, xdpMode) } 函数中,先进行参数重组,在调用pkg/datapath/loader/compile.go的compile函数进行编译。该函数依旧是调用了clang进行编译。 其他代码细节不在赘述。 go源码分析总结 编译:直接或间接调用clang/llc命令进行编译链接。 加载:调用外部bpftool\tc\ss\ip等命令加载。 MAP管理:调用外部命令或go cilium/ebpf库进行map删除、创建等 CORE兼容:会在每个endpoint上编译,没有使用eBPF CORE。 更新:每次重新加载都会编译。 内核态与用户态数据交互 交互map 名字 类型 所属文件 数据流向 备注 SIGNAL_MAP BPF_MAP_TYPE_PERF_EVENT_ARRAY signal.h ? LB4_REVERSE_NAT_SK_MAP BPF_MAP_TYPE_LRU_HASH bpf_sock.c ? LB6_REVERSE_NAT_SK_MAP BPF_MAP_TYPE_LRU_HASH bpf_sock.c ? CIDR4_HMAP_NAME BPF_MAP_TYPE_HASH bpf_xdp.c ? CIDR4_LMAP_NAME BPF_MAP_TYPE_LPM_TRIE bpf_xdp.c CIDR6_HMAP_NAME BPF_MAP_TYPE_HASH bpf_xdp.c CIDR6_LMAP_NAME BPF_MAP_TYPE_LPM_TRIE bpf_xdp.c bytecount_map BPF_MAP_TYPE_HASH bytecount.h cilium_xdp_scratch BPF_MAP_TYPE_PERCPU_ARRAY xdp.h EVENTS_MAP BPF_MAP_TYPE_PERF_EVENT_ARRAY event.h IPV4_FRAG_DATAGRAMS_MAP BPF_MAP_TYPE_LRU_HASH ipv4.h LB6_REVERSE_NAT_MAP BPF_MAP_TYPE_HASH lb.h LB6_SERVICES_MAP_V2 BPF_MAP_TYPE_HASH lb.h ENDPOINTS_MAP BPF_MAP_TYPE_HASH maps.h METRICS_MAP BPF_MAP_TYPE_PERCPU_HASH maps.h POLICY_CALL_MAP BPF_MAP_TYPE_PROG_ARRAY THROTTLE_MAP BPF_MAP_TYPE_HASH EP_POLICY_MAP BPF_MAP_TYPE_HASH_OF_MAPS maps.h ? Map to link endpoint id to per endpoint cilium_policy map POLICY_MAP BPF_MAP_TYPE_HASH maps.h ? Per-endpoint policy enforcement map EVENTS_MAP BPF_MAP_TYPE_SOCKHASH bpf_sockops.h ? 太多了,而且比较偏向cilium的业务功能,偏离本文主题,不写了。后面会按照数据流向分三类,总结说明。 map作用分类 内核态自用 常用与程序内部的临时缓存。比如__section("cgroup/connect4")时,TCP socket的状态每次变化,都需要将之前endpoint信息存储起来,下次状态变化时,再读取更改。 举个例子🌰 //bpf/sockops/bpf_sockops.c line 127 __section("sockops") int bpf_sockmap(struct bpf_sock_ops *skops) { // 调用bpf_sock_ops_ipv4 函数 sock_hash_update(skops, &SOCK_OPS_MAP, &key, BPF_NOEXIST); } //bpf/sockops/bpf_redir.c line 42 __section("sk_msg") int bpf_redir_proxy(struct sk_msg_md *msg) { msg_redirect_hash(msg, &SOCK_OPS_MAP, &key, flags); } 内核态写,用户态读 有个典型的场景,就是eBPF字节码运行日志的输出。以cilium events map为例,该map是内核态代码的日志输出map。 EVENTS_MAP map创建 MapName = "cilium_events" //eventsmap.go line 19 eventsMap := bpf.NewMap(MapName, bpf.MapTypePerfEventArray, &Key{}, int(unsafe.Sizeof(Key{})), &Value{}, int(unsafe.Sizeof(Value{})), MaxEntries, 0, 0, bpf.ConvertKeyValue, ) map的路径会被拼接,最终全路径时/sys/fs/bpf/tc/globals/cilium_events, // Path to where bpffs is mounted , /sys/fs/bpf mapRoot = defaults.DefaultMapRoot // Prefix for all maps (default: tc/globals) mapPrefix = defaults.DefaultMapPrefix m.path = filepath.Join(mapRoot, mapPrefix, name) // 即 /sys/fs/bpf/tc/globals/cilium_events 拼接好map路径后,调用os.MkdirAll创建/sys/fs/bpf/tc/globals目录;调用CreateMap函数,使用unix.Syacall创建BPF_MAP_CREATE操作的FD; ret, _, err := unix.Syscall( unix.SYS_BPF, BPF_MAP_CREATE, uintptr(unsafe.Pointer(&uba)), unsafe.Sizeof(uba), ) 调用objPin对map ID和cgroup path绑定,保存到pkg/bpf/map_Register_linx.go的mapRegister Map里,完成整个map的创建、关联。 map数据写入 map内数据写入是由dbg.h内cilium_dbg*相关函数写入,代码参见 static __always_inline void cilium_dbg(struct __ctx_buff *ctx, __u8 type, __u32 arg1, __u32 arg2) { struct debug_msg msg = { __notify_common_hdr(CILIUM_NOTIFY_DBG_MSG, type), .arg1 = arg1, .arg2 = arg2, }; ctx_event_output(ctx, &EVENTS_MAP, BPF_F_CURRENT_CPU, &msg, sizeof(msg)); } 其中,写入的map名字是EVENTS_MAP常量,定义在bpf/node_config.h里,默认是test_cilium_events,需要总控远程下发这个头文件。方便由go这边统一控制map的名字。详细代码在pkg/datapath/linux/config/config.go里WriteNodeConfig函数部分。比如 cDefinesMap["EVENTS_MAP"] = eventsmap.MapName cDefinesMap["SIGNAL_MAP"] = signalmap.MapName cDefinesMap["POLICY_CALL_MAP"] = policymap.PolicyCallMapName cDefinesMap["EP_POLICY_MAP"] = eppolicymap.MapName cDefinesMap["LB6_REVERSE_NAT_MAP"] = "cilium_lb6_reverse_nat" cDefinesMap["LB6_SERVICES_MAP_V2"] = "cilium_lb6_services_v2" 回到cilium_dbg函数,是内核eBPF部分最底层的日志事件输出函数,map声明在bpf/lib/events.h struct bpf_elf_map __section_maps EVENTS_MAP = { .type = BPF_MAP_TYPE_PERF_EVENT_ARRAY, .size_key = sizeof(__u32), .size_value = sizeof(__u32), .pinning = PIN_GLOBAL_NS, .max_elem = __NR_CPUS__, }; 调用的地方比较简单,不在一一赘述。 小结 map名统一由go部分控制,起到统一管理作用,避免两端不一致。 事件日志/调试日志避开trace_printk函数输出,统一发送至用户态go部分,避免人工查看/sys/kernel/debug/tracing/trace_pipe,提升工作效率。 由go部分决策如何处理。比如发送给相关模块订阅的角色,或者统一上传到日志中心,便于大规模分析展示。这是个可以借鉴的好思路。 map数据读取 在monitor/agent/agent.go里,初始化时对map进行了pin操作。 笔者要吐槽的是cilium_events map名字的常量是eventsMapName,这跟创建map时用的pkg/maps/eventsmap/eventsmap.go下的MapName不是同一个,而是重新定义一个。影响代码分析。 path := oldBPF.MapPath(eventsMapName) eventsMap, err := ebpf.LoadPinnedMap(path, nil) 在handleEvents函数中进行事件读取,并对异常错误进行计数,用作数据完整性校对。(笔者还没细跟进) func (a *Agent) processPerfRecord(scopedLog *logrus.Entry, record perf.Record) { a.Lock() defer a.Unlock() if record.LostSamples > 0 { // 丢失数据大小统计 a.MonitorStatus.Lost += int64(record.LostSamples) // 通知所有内部消费者,告诉他们数据丢失部分大小 a.notifyPerfEventLostLocked(record.LostSamples, record.CPU) // 存入外部订阅者队列,在队列的消费处,发送给所有监听者 a.sendToListenersLocked(&payload.Payload{ CPU: record.CPU, Lost: record.LostSamples, Type: payload.RecordLost, }) } // ... } 这里的事件发送分为两种接受者 monitor进程内部的消费者,抽象为consumer.MonitorConsumer,比如数据丢失监控、事件处理dispather派发器等。对应consumers属性,使用RegisterNewConsumer函数来注册为消费者。 monitor进程外部的订阅者,抽象为listener.MonitorListener,比如与其交互的外部进程,远程数据数据库、中心事件处理总控等。对应newListener属性,使用RegisterNewListener函数注册,目前只支持自定义的Version1_2,方便以后扩展。 不管是consumer还是listener,都是一对多的关系,遍历多个consumers\listener进行发送。 cilium的配套可视化组件Hubble就是作为其中一个consumer来接收数据的。 对于事件的进程外部发送,cilium采用本地unix socket的方式,监听/var/run/cilium/monitor1_2.sock,来支持本机进程间数据通讯。 func ServeMonitorAPI(monitor *Agent) error { listener, err := buildServer(defaults.MonitorSockPath1_2) if err != nil { return err } s := &server{ listener: listener, monitor: monitor, } log.Infof("Serving cilium node monitor v1.2 API at unix://%s", defaults.MonitorSockPath1_2) go s.connectionHandler1_2(monitor.Context()) return nil } 小结 对于内核态程序、稳定性质量做监控,结合内核态数据对服务更好的掌控。 事件的派发角色需要结合业务,进程内消费角色如何划分(对账、解码),进程间消费角色如何设计,多版本升级,通许协议如何设计等。 cilium在代码层面,角色功能上做了非常好的抽象,扩展性比较好。比如MonitorListener接口设计时,只规范了Enqueue(pl *payload.Payload)、Version() Version、Close()三个方法,实现的时候,可以随意扩展。 用户态写,内核态读 以XDP层的IP过滤为例,对应map path : cilium_cidr_v4_dyn,来给大家讲一下这个场景。 事件触发是由HTTP接口接收控制指令触发的,在daemon/cmd/prefilter.go的55行附近,patchPrefilter.Handle函数接收HTTP request,读取策略文件中的CIDRs,准备调用preFilter.Insert写入到eBPF Maps中。 reFilter.Insert是接口函数,抽象的实现在pkg/datapath/prefilter/prefilter.go119行Insert函数中实现。 打开 CIDRs写入到eBPF maps里之前,先进行map选择 for _, cidr := range cidrs { ones, bits := cidr.Mask.Size() which := p.selectMap(ones, bits) if which == mapCount || p.maps[which] == nil { ret = fmt.Errorf("No map enabled for CIDR string %s", cidr.String()) break } err := p.maps[which].InsertCIDR(cidr) if err != nil { ret = fmt.Errorf("Error inserting CIDR string %s: %s", cidr.String(), err) break } else { undoQueue = append(undoQueue, cidr) } } 循环遍历CIDRs,每个IP都判断是IPv4还是IPv6,选择对应的map,准备写入。写入的map在PreFilter.initOneMap函数里做了初始化读取。 先判断IP的类型prefixesV4Dyn、prefixesV4Fix、prefixesV6Dyn、prefixesV6Fix,再调用pkg/maps/cidrmap/cidrmap.go中147行cidrmap.OpenMapElems函数打开当前map。 打开map时,会先尝试创建bpf.MapTypeLPMTrie类型(也就是BPF_MAP_TYPE_LPM_TRIE类型)的map,若不支持,则改为MapTypeHash类型,来兼容低版本内核的linux。 写入 PreFilter.Insert调用CIDRMap.InsertCIDR,再调用bpf.UpdateElement写入相应CIDRs。 内核态读取 在bpf/bpf_xdp.c的check_v4函数中,map_lookup_elem函数查找CIDR4_LMAP_NAMEeBPF map,若包含在内,则直接返回CTX_ACT_DROP丢弃包。 #ifdef CIDR4_LPM_PREFILTER if (map_lookup_elem(&CIDR4_LMAP_NAME, &pfx)) return CTX_ACT_DROP; #endif 这段代码在__section("from-netdev")段运行,起到XDP层就可以过滤IP的作用。 CIDR4_LMAP_NAME常量就是对应的cilium_cidr_v4_dyn eBPF map ,老样子,也是由go层的代码生成的filter_config.h头文件,会把CIDR4_LMAP_NAME改为全路径的cilium_cidr_v4_dyn。 其中,go部分生成头文件的地方在pkg/datapath/prefilter/prefilter.go的59行WriteConfig函数里。 fmt.Fprintf(fw, "#define CIDR4_LMAP_NAME %s\n", path.Base(p.maps[prefixesV4Dyn].String())) 小结 eBPF map可以做内核态用户态数据交互 不同数据类型,选择不同的eBPF map类型,LPMTrie与HASH当前类库都支持。 在自己的项目中,也可以考虑内核态做基本的过滤策略,且策略内容可以动态下发。 总结 Cilium产品是面向微服务场景下的网络管理方案,涉及的安全也只是网络链路的可达性。对系统安全几乎没有涉猎。 但该产品是使用eBPF技术大规模应用的优秀项目之一,分析学习他的实现,可以帮助我们快速理解eBPF在go语言中的使用技巧。 通过笔者的分析学习,可以宏观的了解到Cilium在eBPF内核技术使用时,场景覆盖网络处理的XDP、TC、SOCKET等L3、L4、L7层,业务覆盖防火墙、网络路由、网络隔离、负载均衡等。通过集中式管理eBPF文件源码,下发到各endpoint分发式编译挂载。调试日志作为eBPF map事件统一收集处理。 支持用户态、内核态相互之间用eBPF map做双向通讯,实现策略下发与数据收集。具备数据对账、监控告警能力。 不足的地方在资源占用、熔断机制等功能。但考虑到cilium是宿主机上主要业务,CPU、内存等资源优先使用,对熔断机制需求不强烈。 这点不同于HIDS等安全防御产品,需要让资源给业务,严格控制自身资源使用。 2021-12-09 +1 5 0
[译]使用eBPF per-CPU Cgroup本地存储进行低功耗统计 译者注 背景 译者还在学习eBPF,在学习计划中,分了阅读、翻译、模仿、研发等几个阶段。这篇文章是译者学习过程中的第二篇eBPF文章翻译。在阅读过程中,动手练习,理解原文目的、思路、方法。加深对知识点的理解。 本文翻译自Using eBPF per-CPU Cgroup local storage for low overhead accounting,由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。 第一篇翻译文章为:使用 eBPF(并绕过 TCP/IP)加速云原生应用程序的经验教训。 对于本文中涉及的代码,都放在GitHub:ebpf-demo仓库下的bpf-accounting下,请自取。 per-CPU 随着多CPU架构的成熟发展,BPF Map也引入了per-cpu类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。参考 原文 前言 Linux eBPF生态系统正在迅速增长,每个发布周期都会出现新功能。从本质上讲,eBPF是一种通过attach到各种per-defines hooks和导出函数的方式,来编写Linux内核脚本的安全方式。在eBPF中,可以使用各种“map”类型来实现kernel space和user space通信。 每隔几个内核版本,就会引入或改进新的“map”类型,并且在3年前BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE引入,现在可以在4.20以后的内核中使用,git提交日志:bpf: introduce per-cpu cgroup local storage。 译者注: BPF Compiler Collection (BCC)维护了eBPF新特性变化的日志,包含每个特性的增加时间、内核版本号、commit时间等,详情见:BPF Features by Linux Kernel Version 我对它很感兴趣,想通过一个简单的Cgroup网络吞吐量监控应用来了解它。 通常情况下,在资源受限的机器上运行多个工作负载时的实际用例。但事实证明,我很难理解如何快速有效地使用它。之后,我决定写一篇文章,希望可以帮助你快速学习per-CPU eBPF Cgroup local storage。 示例演示 这篇文章将 在用户空间的应用使用Cilium 的ebpf库。当然,在C中使用libbpf或 在 Python 中使用BCC可以轻松实现类似的效果。内核侧eBPF无需更改,可以配合其他任何语言实现的eBPF数据读取应用。 为了监控目标Cgroup入口和出口吞吐量,演示程序将附加到cgroup_skb/ingress和cgroup_skb/egress Cgroup eBPF 钩子。当然,将map类型的result发送到用户空间BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE。 为了保持这篇文章简单并专注于eBPF部分,这篇文章将使用一个硬编码的cgroup地址。 示例代码 如下代码,这是eBPF内核态程序的概要。以一个伪注释开始,以便让go编译器不要编译它。然后是 linux内核以及BPF helper等各种头文件。 license可能是多余的,但eBPF程序与普通c内核模块遵循相同的规则。通常,某些符号导出受“仅GPL”约束(或兼容)。 C代码 // +build ignore #include <stdbool.h> #include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <netinet/ip.h> #include <netinet/ip6.h> // --------------------------------------------- // -- The real program will be somewhere here -- // --------------------------------------------- char __license[] __attribute__((section("license"), used)) = "MIT"; 编译器clang(llvm)将这个C文件编译为eBPF的字节码文件: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o 当然,需要先安装Clang,以及linux kernel对应的头文件,安装方法可以参考上篇译者的eBPF开发环境 go代码 这是现在的Go代码模版。它主要包含设置阶段和监控阶段两部分代码,监控阶段每秒运行一次,按“Ctrl+C”或TERM信号终止运行。 设定好TARGET_CGROUP_V2_PATH,确保关注的是Cgroup V2版本。在笔者开发环境的路径是/sys/fs/cgroup/unified前缀,因系统而异,这里是Ubuntu,但其Systemd配置仍使用v1的Cgroup。 如果bpf-accounting.c里的eBPF部分代码没有被调用,那么EBPF_PROG_ELF加载部分,就需要好好调整。 package main import ( "log" "os" "os/signal" "syscall" "time" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "golang.org/x/sys/unix" ) const ( TARGET_CGROUP_V2_PATH = "/sys/fs/cgroup/unified/yadutaf" // <--- Change as needed for your system/use-case EBPF_PROG_ELF = "./bpf-accounting.o" ) func main() { log.Printf("Attaching eBPF monitoring programs to cgroup %s\n", TARGET_CGROUP_V2_PATH) // ------------------------------------------------------------ // -- The real program initialization will be somewhere here -- // ------------------------------------------------------------ // Wait until signaled c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT) signal.Notify(c, syscall.SIGTERM) // Periodically check counters ticker := time.NewTicker(1 * time.Second) out: for { select { case <-ticker.C: log.Println("-------------------------------------------------------------") // ------------------------------------------ // -- And here will be the counters report -- // ------------------------------------------ case <-c: log.Println("Exiting...") break out } } } 数据结构 现在介绍一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型。但请先思考一个问题,为什么不使用最典型、更容易使用的BPF_MAP_TYPE_HASHmap类型? 对于本文的用例,BPF_MAP_TYPE_HASH将会特别适合。但是,如果应用程序需要监视所有cgroup,并且在创建动态时attach,或在Cgroup终止时清除相关资源,BPF_MAP_TYPE_CGROUP_STORAGE类型则可能是更合适的选择。 但是,如果可以从多个CPU并发访问收集的数据,那么per-CPU对应的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型是避免自旋锁的最佳选择。 从原理上看,BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE实际上是一个/virtual/映射,或更准确地说是叫Cgrouplocal storage本地存储的内核术语。在内部,Cgroup数据结构有一个专用于cgroup本地存储的成员。因此,指定类型的程序将为指定的Cgroup共享相同的存储区域。默认情况下,不同类型的程序在Cgroup中都会有一个固定的存储区域。然而,可以在指定的Cgroup的所有程序类型之间共享这个存储区域。 几个要点: 根据设计,map(虚拟)的key在概念上是一个(cgroup_id, program_type)元组。这不是固定的。比如,叶成员类型是灵活的。 如果多个相同类型的程序需要在同一个Cgroup中存储数据,那么必须配合进行叶子成员类型定义。 程序只能访问它们所连接的cgroup的存储区域。换句话说,即使由子Cgroup foo/bar中的事件触发,attach到Cgroup的程序foo也只会访问Cgroup的存储foo的值。如果需要专门跟踪子 Cgroup事件,则还需要将相同的代码逻辑attach到关注的Cgroup。 提醒一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE: 由于per-CPU是map类型,因此group中的存储不是叶子成员类型,而是这种类型的per-CPU数组。这对程序的eBPF部分是完全透明的,只有它当前的cgroup+CPU视图。然而,它需要对Go部分进行特殊处理,来获取每个cgroup+CPU存储的完整数据视图。 eBPF逻辑实现 对于本文,有两种业务目标 (cgroup_skb/ingress和 cgroup_skb/egress)来匹配incomming和outgoing流量。由于目标是测量每个方向传输的字节总数,因此叶子成员类型可以是uint64。 内核态C代码 在eBPF端,结构声明很简单: struct bpf_map_def SEC("maps") cgroup_counters_map = { .type = BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE, .key_size = sizeof(struct bpf_cgroup_storage_key), .value_size = sizeof(__u64), }; 这定义了 eBPF map cgroup_counters_map类型BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,其主键struct bpf_cgroup_storage_key的类型为bpf.h中定义的类型,为无符号uint64,用它作为map的member来保存字节大小。 用户态Go代码 用户态的逻辑代码方面涉及更多,但别担心。首先,程序需要增加最大“locked”内存。虽然这个例子来说不是必需的,这里只是普通的字节大小计数器,不会占用啥太大内存。但在使用eBPF的场景里,这里可能是最容易疏忽的陷阱。因此,需要实现为: // Increase max locked memory (for eBPF maps) // For a real program, make sure to adjust to actual needs unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{ Cur: unix.RLIM_INFINITY, Max: unix.RLIM_INFINITY, }) 现在需要应用层调用内核eBPF接口加载eBPF字节码的eBPF ELF文件。这里是Cilium的ebpf库的亮点。解析ELF字节码文件并提取map定义和所有程序metadata “collection”: collec, err := ebpf.LoadCollection(EBPF_PROG_ELF) if err != nil { log.Fatal(err) } 从collection中获取map的句柄非常简单: // Get a handle on the statistics map cgroup_counters_map := collec.Maps["cgroup_counters_map"] 在访问map时,程序需要key和member类型定义。重点是,Cilium的库还没有内置定义。由于key format是由内核的ABI强加的,因此需要按照struct bpf_cgroup_storage_key 从内核文档:BPF_MAP_TYPE_CGROUP_STORAGE 完成结构体的解析,以及注意32位虚拟字段的对齐问题: type BpfCgroupStorageKey struct { CgroupInodeId uint64 AttachType ebpf.AttachType _ uint32 } go程序需要定义成员类型。由于程序使用per-CPU数据结构,这需要是一个slice: type PerCPUCounters []uint64 由于我们只对传输的字节总数感兴趣,并不关注其他事情,那么直接for循环slice,进行相加即可。: func sumPerCpuCounters(perCpuCounters PerCPUCounters) uint64 { sum := uint64(0) for _, counter := range perCpuCounters { sum += counter } return sum } 最后一件事:要访问map的entry,只是定义map数据结构是不够的。其中需要有一些实际值。在AttachType已经讨论的,这是节目的类型(ingress VS egress)。最棘手的部分是CgroupInodeId。该字段是cgroup路径的Inode编号,需要提前读取。 由于这是一个简单的例子,那就先硬编码TARGET_CGROUP_V2_PATH cgroup的值。因此,可以间接写死Inode的ID: // Get cgroup folder inode number to use as a key in the per-cgroup map cgroupFileinfo, err := os.Stat(TARGET_CGROUP_V2_PATH) if err != nil { log.Fatal(err) } cgroupStat, ok := cgroupFileinfo.Sys().(*syscall.Stat_t) if !ok { log.Fatal("Not a syscall.Stat_t") } cgroupInodeId := cgroupStat.Ino 现在,我们来在结构中实际存储一些我们需要的数据。 ingress/egress大小统计 有几种情况需要考虑: 传输的数据包可能位于ingress或egress路径上。 传输的数据包可以是IPv4或IPv6。 所有这些情况都可以在 eBPF内核侧的一个通用函数中简单地处理: inline int handle_skb(struct __sk_buff *skb) { __u16 bytes = 0; // Extract packet size from IPv4 / IPv6 header switch (skb->family) { case AF_INET: { struct iphdr iph; bpf_skb_load_bytes(skb, 0, &iph, sizeof(struct iphdr)); bytes = ntohs(iph.tot_len); break; } case AF_INET6: { struct ip6_hdr ip6h; bpf_skb_load_bytes(skb, 0, &ip6h, sizeof(struct ip6_hdr)); bytes = ntohs(ip6h.ip6_plen); break; } default: // This should never be the case as this eBPF hook is called in // netfilter context and thus not for AF_PACKET, AF_UNIX nor AF_NETLINK // for instance. return true; } // Update counters in the per-cgroup map __u64 *bytes_counter = bpf_get_local_storage(&cgroup_counters_map, 0); __sync_fetch_and_add(bytes_counter, bytes); // Let the packet pass return true; } 实现起来特别简单。最大的难点来自IPv4与IPv6与错误处理。不过,我们关注的主要还是与map相关的那几行。 bpf_get_local_storage()封装实现了(AttachedCgroup, ProgramType, CPU)三元组加载到存储区的功能,直接调用即可。 由于eBPF程序不应该被中断并且数据是per-CPU的,所以__sync_fetch_and_add是个递增原子计数器。在当前代码例子中可能不需要。但上游的运行是支持eBPF抢占的。 这个通用函数可以同时处理ingress和egress两种流量: // Ingress hook - handle incoming packets SEC("cgroup_skb/ingress") int ingress(struct __sk_buff *skb) { return handle_skb(skb); } // Egress hook - handle outgoing packets SEC("cgroup_skb/egress") int egress(struct __sk_buff *skb) { return handle_skb(skb); } eBPF内核态部分现在已经完成。下一步是加载这些程序并将其attach到关注的Cgroup上,并对收集的数据做计算。 attach程序并收集数据 为了监控ingress和egress流量,需要为两个目标cgroup分别attach一次。同样,生成的数据需要在两个方向上进行查询。 为了保持代码简洁,声明一个简单的helper结构体: type BPFCgroupNetworkDirection struct { Name string AttachType ebpf.AttachType } var BPFCgroupNetworkDirections = []BPFCgroupNetworkDirection{ { Name: "ingress", AttachType: ebpf.AttachCGroupInetIngress, }, { Name: "egress", AttachType: ebpf.AttachCGroupInetEgress, }, } 如上代码为每个方向分别定义了attach的类型。 接下来,使用简单的循环将程序从加载的目标cgroup对应collection中读取数据: // Attach program to monitored cgroup for _, direction := range BPFCgroupNetworkDirections { link, err := link.AttachCgroup(link.CgroupOptions{ Path: TARGET_CGROUP_V2_PATH, Attach: direction.AttachType, Program: collec.Programs[direction.Name], }) if err != nil { log.Fatal(err) } defer link.Close() } 然后,用map查询结果: for _, direction := range BPFCgroupNetworkDirections { var perCPUCounters PerCPUCounters mapKey := BpfCgroupStorageKey{ CgroupInodeId: cgroupInodeId, AttachType: direction.AttachType, } if err := cgroup_counters_map.Lookup(mapKey, &perCPUCounters); err != nil { log.Printf("%s: error reading map (%v)", direction.Name, err) } else { log.Printf("%s: %d\n", direction.Name, sumPerCpuCounters(perCPUCounters)) } } 同样,此代码段在预定义的流量卡点上一直循环。然后不停的查询目标方向的流量产生的数据。 查询功能实现在很大程度上依赖于Cilium的eBPF库,用于内核态和Go用户态之间的数据通讯。其他部分代码的都不重要。 测试 现在一切就绪,假设目标cgroup和文件名保持不变,现在可以编译和运行程序。 编译: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o llvm-strip -g bpf-accounting.o go build . 要设置测试环境,最简单的方法是打开终端并运行: # Create the Cgroup sudo mkdir -p /sys/fs/cgroup/unified/yadutaf # Register the current shell PID in the cgroup echo $$ | sudo tee /sys/fs/cgroup/unified/yadutaf/cgroup.procs # Start an advanced networking command ping -n 2001:4860:4860::8888 # Or, for IPv4: ping -n 8.8.8.8 # 或者 wget下载几个文件之类的。 最后,再开一个shell: sudo ./bpf-accounting 译者提醒 这里需要提醒一下,上面cgroup的设定是针对执行命令的shell进程的,如果在其他shell进程里执行网络访问行为,那么这个程序就无法收集到流量了。就是说/sys/fs/cgroup/unified/yadutaf/cgroup.procs里的shell进程id是被cgroup监控的,产生网络流量的行为,必须也在这个进程里才行。 运行结果 应该打印如下内容: 2021/08/22 17:20:50 Attaching eBPF monitoring programs to cgroup /sys/fs/cgroup/unified/yadutaf 2021/08/22 17:20:51 ------------------------------------------------------------- 2021/08/22 17:20:51 ingress: 64 2021/08/22 17:20:51 egress: 64 2021/08/22 17:20:52 ------------------------------------------------------------- 2021/08/22 17:20:52 ingress: 128 2021/08/22 17:20:52 egress: 128 2021/08/22 17:20:53 ------------------------------------------------------------- 2021/08/22 17:20:53 ingress: 192 2021/08/22 17:20:53 egress: 192 2021/08/22 17:20:54 ------------------------------------------------------------- 2021/08/22 17:20:54 ingress: 256 2021/08/22 17:20:54 egress: 256 ^C2021/08/22 17:20:54 Exiting... 就酱! 😊 结论 本文通过一个简单的per-cgroup的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE eBPF类型,对网络吞吐量进行监控,展示了local storage的eBPF虚拟map使用。 总结重点 在性能敏感的上下文中存储per-cgroup测量或状态时使用这个存储方式。 如果性能不是问题,请考虑使用BPF_MAP_TYPE_CGROUP_STORAGE以避免增加管理per-cpu数据的复杂性。 attach到指定Cgroup的所有给定类型的eBPF cgroup将共享相同的数据存储空间。 将eBPF程序attach到每个目标cgroup:此存储与特定附件相关联。 此示例可用作监控应用程序的基础。例如,可以监视Cgroup创建并动态地attach程序。还可以通过 OpenMetrics(Prometheus)等进行数据收集,集成到更大的系统中。 最后,我要感谢Cilium的ebpf社区在我写这篇文章时帮助我修复了库中一个微小但必不可少的错误。 提醒 对于本文中创建的bpf map,可以使用bpftool进行查询,安装方式见编译bpftool工具部分。 root@vmubuntu:/home/cfc4n# bpftool prog show 3: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 4: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 5: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 6: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 7: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 8: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 12: cgroup_skb name ingress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) 13: cgroup_skb name egress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) root@vmubuntu:/home/cfc4n# bpftool -p map show id 6 { "id": 6, "type": "percpu_cgroup_storage", "name": "cgroup_counters", "flags": 0, "bytes_key": 16, "bytes_value": 8, "max_entries": 0, "bytes_memlock": 0, "frozen": 0, "pids": [{ "pid": 1480, "comm": "bpf-accounting" } ] } cgroup目录清理: apt install cgroup-tools/hirsute cgdelete unified/yadutaf 或重启系统。 2021-11-09 0 0
[译]使用eBPF(绕过 TCP/IP)加速云原生应用程序的经验教训 译者注 本文翻译自 使用eBPF加速云原生应用程序的经验教训 ,前篇文章为How to use eBPF for accelerating Cloud Native applications,已经由国内博主ArthurChiao翻译+实践整理给出:[译] 利用 ebpf sockmap/redirection 提升 socket 性能 本文对比了eBPF绕过TCP/IP栈与普通TCP协议两种模式下,网络通讯的吞吐量、延迟、事务量等指标。 其技术原理在上篇中已经讲过,本篇的技术难度不大,译者参考原文,进行环境搭建,编码测试,根据自己的测试结果,对原文进行更加深刻的解读。 eBPF与主机安全 译者从事IDC的安全防御检测类产品研发,包括主机层面的入侵检测系统等。IDC领域,随着容器化技术不断演进,早已从主机时代转化到容器时代。而主机安全的研发思维,还停留在主机时代。比如: 物理机、虚拟机时代:进程间通讯是IPC、unixdomain socket,而容器化下,是GRPC\HTTP。 容器化时代:利用高效的eBPF技术做网络隔离,重新规划,能否应付得来 容器化时代:同一个宿主机上多个容器通讯,TCP改为本地socket转发(类似本文的技术) 当容器化技术依赖越来越多的内核新技术eBPF后,HIDS产品的检测技术,该如何演进? 木马后门利用了eBPF技术后,端口复用等问题变得相当容易,也没有独立的进程,没特征,HIDS产品该如何去发现? 翻译目的 带着这些问题,译者开始学习eBPF相关知识,为了更快更好的掌握eBPF,译者采用边学边动手的方式,来加快对新技术的理解,加深知识点的记忆。在学习过程过,感谢博主ArthurChiao 的一系列博文。 译者的开发环境 MacOS Big Sur 11.6 VMware Fusion 12.1.2 UBUNTU 21.04 server版的虚拟服务器(分配2核CPU,4G内存) 安装步骤 镜像地址: http://tw.archive.ubuntu.com/ubuntu-cd/hirsute/ubuntu-21.04-live-server-amd64.iso 安装相应工具包、类库 sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex graphviz sudo apt-get install -y make gcc clang llvm git pkg-config dpkg-dev gcc-multilib 修改apt源 修改源 /etc/apt/sources.list ,便于下载kernel源码 deb-src http://archive.ubuntu.com/ubuntu hirsute main deb-src http://archive.ubuntu.com/ubuntu hirsute- main sudo su apt update apt-get source linux-image-$(uname -r) apt-get source linux-image-unsigned-$(uname -r) apt install libbfd-dev libcap-dev zlib1g-dev libelf-dev libssl-dev 编译 bpftool 工具 cd /home/cfc4n/download/linux-5.11.0/tools/bpf/bpftool make sudo make install 编译bpf字节码 cd ~/project/os-eBPF/sockredir cfc4n@vmubuntu:~/project/os-eBPF/sockredir$ clang -O2 -g -target bpf -I /usr/include/linux/ -I /usr/src/linux-headers-5.11.0-37/include/ -c bpf_sockops_v4.c -o bpf_sockops_v4.o 加载bpf字节码 root@vmubuntu:/home/cfc4n/project/os-eBPF/sockredir# bpftool prog load bpf_sockops_v4.o "/sys/fs/bpf/bpf_sockops" libbpf: elf: skipping unrecognized data section(8) .rodata.str1.1 root@vmubuntu:/home/cfc4n/project/os-eBPF/sockredir# bpftool cgroup attach "/sys/fs/cgroup/unified/" sock_ops pinned "/sys/fs/bpf/bpf_sockops" 测试工具 netperf是本文的测试工具,安装方式 sudo apt install netperf netperf -V Netperf version 2.7.0 操作系统版本 uname -a Linux vmubuntu 5.11.0-37-generic #41-Ubuntu SMP Mon Sep 20 16:39:20 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux Linux内核TCP参数 拥塞控制 cat /proc/sys/net/ipv4/tcp_congestion_control cubic 接收缓冲区 buffer size 最小、默认、最大 cfc4n@vmubuntu:~$ cat /proc/sys/net/ipv4/tcp_rmem 4096 131072 6291456 发送缓冲区 cfc4n@vmubuntu:~$ cat /proc/sys/net/ipv4/tcp_wmem 4096 16384 4194304 eBPF TCP链路绕过实现 译文与原文 展示了如何编写简单的BPF程序实现socket level 重定向(redirection)。对于源和目的端都在同一台宿主机器的应用来说,可以实现绕过整个TCP/IP协议栈,直接将数据发送到 socket对端。效果如下图 译者注:在大公司的基础组件部门,可以在宿主机上,自动实现网络加速,不需要业务层根据不同环境而去分别实现unix domain socket和TCP两种sockt。大大减轻业务压力,提升基础架构部门的效率。 原文 我们多数客户对我们的服务有极高的性能SLA需求,我们也不断投资于性能增强以提高我们的服务质量。我们研究的其中一项技术就是eBPF,用于加速后端各种微服务之间的通信。 由于业界很少有文章全面介绍如何使用eBPF,我们决定与大家分享我们学到的知识。 这是由两部分组成的博客系列的第二部分,我们将在其中详细分享我们如何利用 eBPF 进行网络加速以及我们在此过程中所做的一些有趣观察。 在之前的博文中,我们分享 如何编写用于套接字数据重定向的 eBPF代码 以及如何使用 bpftool 将生成的eBPF字节码注入内核。在这篇文章中,我们深入探讨了从常规 TCP/IP 切换到eBPF后的各种性能提升。 使用 eBPF 进行网络加速——性能评估 一旦我们可以使用eBPF实现绕过TCP/IP堆栈,就必须要看到性能提升后的效果。为此,我们使用了较为知名的测量网络性能的工具netperf来评估吞吐量、延迟和事务率方面的收益。我们在以下两种情况下使用netperf评估了这些指标 : 流量正常通过TCP/IP堆栈, 流量使用eBPF sockhash映射重定向绕过TCP/IP堆栈 测试配置 测试设置如下: 在 MacOS High Sierra 上运行的 VirtualBox 6.1 上的 Ubuntu Bionic 18.04.03 LTS Linux 内核 5.3.0-40-generic TCP 设置 拥塞控制:cubic recv() 调用的默认缓冲区大小:131072 Bytes send() 调用的默认缓冲区大小:16384 Bytes MSS最大报文段长度(Maximum Segment Size):65483 Bytes netperf 2.6.0 测试步骤 网络吞吐测试 使用netserver服务端和netperf客户端进行60秒的各种发送消息大小的吞吐量测试: netserver -p 1000 netperf -H 127.0.0.1 -p 1000 -l 60 -- -m $msg_size 网络延迟测试 使用netperf命令,执行时长参数为60秒的各种请求和响应消息大小,进行延迟测量(第 50、90 和 99%): netserver -p 1000 netperf -P 0 -t TCP_RR -H 127.0.0.1 -p 1000 -l 60 -- -r $req_size, $resp_size -o P50_LATENCY, P90_LATENCY, P99_LATENCY 网络事务率测试 使用netperf命令来测试60秒运行的各种请求和响应消息大小的事务率: netserver -p 1000 netperf -t TCP_RR -H 127.0.0.1 -p 1000 -l 60 -- -r $req_size, $resp_size 测试结果 网络吞吐量结果 吞吐量:eBPF sockhash修改过的TCP/IP与常规TCP/IP比较 上图显示了eBPF sockhash修改过的TCP/IP与常规TCP/IP堆栈的吞吐量对比。比较有趣得是吞吐量的性能增长对于使用eBPF的发送请求消息大小是线性的。因为当应用程序在发送较大的消息时,几乎没有开销。 这结果与大家的认知不一致:会好奇为什么与常规TCP/IP路径相比吞吐量性能很差。事实证明,罪魁祸首是默认情况下在 TCP/IP堆栈中启用了Nagle算法。引入Nagle算法是为了解决小数据包淹没慢速网络从而导致拥塞的问题。因为如果它小于TCP MSS的大小,算法只需要一个TCP段是未完成的(未确认),在我们的测试中,这会导致TCP对数据进行批量传输。这种批处理会导致更多的数据传输和分摊开销,并且能够超过eBPF绕过TCP/IP的性能收益,它对每个发送调用缓冲区大小有固定的开销(见图7)。 当数据包大小接近TCP MSS时,常规TCP/IP没有合并发送的优势,TCP MSS中可以容纳的数据包更少(测试环境的MSS设置为65483 bytes)并通过以下方式发送到目的地TCP/IP内核堆栈。在这些大数据包发送大小下,eBPF凭借其低开销远远超过使用Nagle算法的TCP/IP堆栈的吞吐。接下来禁用Nagle算法(-D参数)来netperf重新测试: netserver -p 1000 netperf -H 127.0.0.1 -p 1000 -l 60 -- -m $msg_size -D 吞吐量:TCP/IP(禁用Nagle算法)与使用 eBPF sockhash 染过TCP/IP 禁用Nagle算法后,我们看到与eBPF sockhash重定向绕过相比,常规TCP的吞吐量优势完全消失。TCP/IP堆栈和eBPF sockhash映射的性能如预期的那样线性增加----eBPF比常规 TCP/IP具有更大的成长曲线,因为eBPF每次发送调用的固定成本开销。对于较大的发送消息大小和较小的 TCP MSS,这种性能差距更为明显。 译者测试结果 译者在测试时,把三种测试都集中在一张图里了。但测试使用TCP Nagle算法与eBPF绕过的差异并没有很大,而且也没有曲线上的交叉。译者暂时不清楚其中原因,大概是我测试的环境是macbook,恰恰同时我还在使用这台笔记本工作,导致了CPU、网络等不稳定吧。 吞吐量:eBPF VS TCP VS TCP-Disable-Nagle的比较 动态趋势图: 延迟测试结果 使用netperf重复我们的性能运行来测量延迟。我们测量了延迟的第50个、第90个和第99 个百分位数,这会改变发送和接收缓冲区(消息)的大小。测试结果里,使用中位数的延迟来显示趋势图。此外,我们还展示了64字节和256字节的不同请求消息大小的趋势。(请注意:netperf一次只有一个事务未完成。) 延迟测试:使用 eBPF sockhash绕过TCP/IP与常规TCP/IP 如图所示,eBPF sockhash绕过优于常规TCP/IP堆栈。其性能比常规 TCP/IP 堆栈高出近50%。考虑到eBPF通过将数据包从源socket的传输队列重定向到目标socket的接收队列,与TCP/IP中的那些相比,eBPF消除了任何协议级别的开销(慢启动、拥塞避免、流量控制等),这些优势带来的结果是显而易见的。此外,请求消息长度对延迟没有影响。我们没有尝试测量接近TCP MSS 的发送消息大小的延迟。 译者测试结果 译者对这个延迟指标也进行了测试,如下图 eBPF sockhash重定向与常规TCP堆栈的延迟对比 不管是64字节还是256字节的发送消息大小上,都比原生TCP的网络延迟更低。 虽然图表上的结果与原作者测试结果有一定差异,但整体分布比较均衡,也很清楚的体现出eBPF在低延迟上的优势。 动态趋势图 网络事务率 接下来,我们使用netperf继续测试事务率: 如果我们简单地将事务率曲线向左翻转,它们的趋势与延迟测量相同。 事务率:使用 eBPF sockhash重定向与TCP/IP 如图,针对不同的接收端消息大小绘制了各种发送消息请求大小的事务率。在延迟测量的情况下,我们看到事务率不受常规TCP/IP和eBPF sockhash重定向绕过以及请求响应消息大小的影响。 不同请求大小的事务率:TCP/IP vs TCP/IP bypass using eBPF sockhash map 译者测试结果 译者也进行了测试,只是测试环境不太稳定,macbook开的VM虚拟机,在性能测试的时候,同时还在用于办公。测试结果波动比较大,但总体来看,还是能从点的分布看得出eBPF与常规TCP的区别,深色的点是eBPF的结果,都分布在Y轴上面。浅色点是常规TCP的结果,都分布在Y轴下面。如下图: 动态图表 eBPF模式下网络吞吐量测试的内核开销 在下图中,我们绘制了当执行netperf吞吐量实验时eBPF sockhash映射绕过在内核中花费的时间。 可以看到对于256字节的发送消息大小,eBPF开销约为1.5秒,并且开销随着发送消息大小的增加而减少。 eBPF sockhash map重定向在内核中花费的时间 综述 eBPF确实是一项强大的技术,它允许从用户空间前所未有地访问Linux内核资源。当应用程序对延迟敏感时,使用eBPF绕过TCP/IP堆栈与同一主机上的另一个程序通信是一个最佳的使用场景。尤其是时下最热门的容器编排领域:在Kubernetes集群的同一pod中使用相互依赖的微服务就特别适合这个功能。也就是说,基于eBPF的TCP/IP绕过可以极大地减轻与使用RPC 和REST API进行大量通信的微服务相关的延迟。 我们还看到,虽然eBPF可以带来强大的性能提升,但盲目使用可能会产生意想不到的后果。具体来说,当我们只关心原始单向吞吐量时,使用默认设置,常规TCP/IP堆栈可以胜过eBPF(Nagle算法合并大量小包的情况)。 译者注:主机安全HIDS的未来趋势 正如本文演示的功能,用eBPF来解决K8S场景中,同一pod的多个docker container之间通讯延迟的问题。那在这种场景下,原来的HIDS产品能否感知到?物理机、虚拟机时代,原来的进程间通讯是IPC、unixdomain socket,而容器化下,是GRPC\HTTP,安全场景上已经有很大变化... 在当下微服务架构日益盛行的时代,必定追求更好性能,更快的速度,更大的负载方向走。而linux kernel的eBPF技术,必定是未来容器化、容器编排领域的热门技术。那么,在信息安全的主机安全领域,HIDS之类产品该何去何从?例如本文中的性能优化,在容器编排场景是特别有价值的使用,甚至已经有了成熟的产品上市。那HIDS还能检测的出来吗? 我们现在使用的单机化、虚拟化的传统思维模式研发安全防护产品,在面对云原生时代容器化的IDC安全问题时,还能跟得上吗?是时候用CIDS来代替HIDS了。 注: HIDS:Host-based Intrusion Detection System CIDS:Container Intrusion Detection System 附 测试脚本 <?php //benchmark.php function testTp($req_size) { $file = 'result/bench_tp_ebpf_r'.$req_size.'_30s'; $strCmd = "netperf -t TCP_RR -H 127.0.0.1 -p 1000 -l 30 -- -r $req_size, %d"; $arrAll = array(); for ($i=1; $i<=60; $i++) { $msg_size = $i*500; $cmd = sprintf($strCmd,$msg_size); echo $file, ": start to exec ",$cmd,"\n"; $arrExec = array(); exec($cmd, $arrExec); $r = array_pop($arrExec); $r = array_pop($arrExec); $r1 = preg_split('/\s+/',$r); $arrAll[$msg_size] = trim($r1[5]); $f = sprintf($file,$msg_size); } var_dump($arrAll); file_put_contents($f,json_encode($arrAll)); } $arrTest = array( 64, 128, 256); $strCmdServer = 'netserver -p 1000'; $rs = system($strCmdServer); var_dump($rs); foreach ($arrTest as $v) { echo "--------------------------------\n"; echo "--------开始测试:$v-----------\n"; echo "--------------------------------\n"; testTp($v); } echo "done\n"; 结果处理 执行./load.sh脚本,启用ebpf,执行php benchmark.php 合并 result/bench_tp_ebpf_*中几个文件,把json的结果转化到csv中 使用plotly.com创建图表,导入csv文件,开始制表画图 后续 我会开始分析Cilium的eBPF源码,了解eBPF在K8s产品中实现原理,更好的研发用eBPF实现行为观测的数据收集,安全防护的CIDS(HIDS)产品 2021-11-01 1 0
[转] 写给工程师:关于证书(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
密码保护:不及格的答卷 背景 前段时间,我参加公司的晋升考核,没通过。是的,失败了。 回顾 回顾这一年,在研发中,我曾在团队最低谷时,冲锋陷阵,带领大家逆转战局反败为胜,拿出漂亮的成绩单; 在推动中,我负责的项目也能做到做一个成一个,带领团队拿到很好的成绩,持续给团队信心与成就感; 在态度上,我从不会放弃任何一个疑难杂症,也都会以身作则,躬先表率,理清楚每一个细节; 在工时上,我也依然是遥遥领先的那位,每次深夜时,诺大的办公室,只有听到我敲击键盘的声音。 我以为我的产出、业绩、责任心、态度来说,我不输于任何人,这些成果,都是我信心的来源。而这次晋升也是我短期的目标,我对自己非常有信心,我还一度认为老子第一,啥都能干,别人的工作我都能做,而且会比他们做得好,不可能不通过这次考核。 然而,还是没通过。理由是问题分析抽象能力不足,拆解、归类、推演等方法论不足。 怀疑 看到这个结论我笑了,觉得非常荒唐、非常可笑....瞬间脑袋里出现很多疑问: 考核不看业绩的吗? 技术答辩不问技术知识的吗?问得问题都方法论之类的问题,这么务虚吗? 评委对信息安全并不专业,问的问题完全偏离主题,是如何评判的呢? 我去其他大公司,也是很轻松的拿到相应的职级,为何在这里就这么难? 重新面试进来,也比晋升考核简单,不是吗? 这一系列不满、气愤情绪在我脑海中蔓延开来... 我开始怀疑目标、计算得失、衡量收益,渐渐得我失去了奋斗的动力,越来越低落。 我开始回避话题、怨天忧人、毫无斗志,慢慢地我失去了胜利的信心,越来越消沉。 反省 念头 在群聊中,看到同事分享了一篇老王写的心得: 《领导梯队》这本书把一个人的领导梯队成长分了六、七层,你在下面一层获得成功的原因是你在上面一层失败的原因,你只是沿着过去的路径,想在上面一层做得成功的话是不可能的。所以领导梯队的成长很多都是否定过去自己的,是非常痛苦的,每一环都是非常痛苦的。 我不知道自己的级别是不是处于这个梯队的爬升中,但我觉得现在的状态就非常痛苦。我意识到我不能被这件事拖得太久,不能总是影响我的情绪。哪怕要离开也要以胜利者姿态离开,为此,我偶尔也会反思自己哪里做的不好... 转折 一次看到朋友老C对人的爱好做了总结分类: 看电影、听音乐、旅游、吃美食这些都是输入型爱好,只要有钱有时间都可以做到,都是打发时间的工具,过于浅薄,都不值得成为爱好。 而弹钢琴、画画、编程等是输出型爱好,这类爱好不但要求你参与,而且还需要对外有输出,这会要求你能静下心学习,且有目标规划,有恒心去完成,这些输出型爱好可以提升一个人的个人能力与社会价值的。 一个人如果长期只有输入没有输出是难以进步的,人与人之间的差距是在于输出的质量和频率的高低。在了解一个人的爱好后,已经可以给这个人打分了。 思考 我看了后,觉得非常有道理,且逻辑划分很清晰。突然,我意识到自己之前从来没这么归纳总结过,如果让我来总结,那是什么样的分类呢?又是如何做分类呢?这样的思维模式是如何构建成的,是如何培养出来的? 对于评委给我的评价「结构性思维不足」该如何理解?我开始思考「什么是结构性思维」,一个优秀的考核答卷是什么样子的?我跟他们的差距是什么?又该如何改进? 正视不足 结构性思维 带着这个疑问,我找了很多关于思维方式的资料,很多都是《金字塔原理》这本书的个人理解,公司的KM上也有好几期的视频供大家学习,只是我没有在意。我在学习结构化思维时,经常找些小话题来做练习,以至于我看到的任何事件都想用结构化思维来归纳。 比如刚刚提到的爱好分类的事情,我也有新的理解 看电影是关注过程型爱好,而画画则是关注结果型爱好。 同样,看书的目标设定为看完这本书的话,这就是关注过程型目标,且无法量化,无法衡量。如果改为看完这本书并写一篇读书笔记,那么就提升为关注结果型目标,且可以去实现量化与衡量。同时输出一篇读书笔记的目标会倒逼自己认真得去执行目标,能有更大的收益。 失败复盘 对于同一件事情,我有了新的看法后,觉得一切都变得有意思起来。趁着新学的金字塔思维知识还热乎的,分析了一下职级晋升受影响的因素,结合自身情况,找找自己的优势劣势,做一下复盘总结。 分析、解构 我在整理这份结构图时开始并没有清晰的树状结构,也是先列出所有因素,再按照自下而上的方式归纳总结成结构。 头脑风暴列出所有原因 提取标签,归类分组(来源、可变性、可控性、等等) 结构提炼,按照MECE(Mutually Exclusive, Collectively Exhaustive,即相互独立、完全穷尽)原则整理 观点补充 归类、结构 最终形成这样的,肯定不是准确的,也会随着我的认知慢慢迭代。 因素分为两类: 内部,是自己以及所属团队,因素可控。比如业务产出、团队形象可以通过自身努力而改变的。 外部,外部评委团队,因素不可控。无法通过自身改变而改变。 配色排版改进 对于内部这个因素,我划分为软能力、硬能力。 硬能力这块就是业绩产出了,哪怕我最近两次业绩考核都很好,但好像在整个述职中并没有起到任何作用。 述职材料部分分为大纲、配色、排版、阐述方式。述职材料是评委在没见到述职者本人之前的第一个材料,决定着对这个人的第一印象。 再回头看自己的述职材料时,也确实发现了很多配色上不忍直视,结构上凌乱不堪的地方。请了公司以为经验丰富的其他通道评委前辈协助。果不其然,材料中很大一部分描述都是结构混乱的,毫无逻辑,分类不清晰的论据,有很大提升空间。 在材料的尺寸上,从25.4:14.3改成68:38的宽屏版本,尽管都是16:9的比例,但从投屏效果上来看,更为大气。在配色上,通篇采用公司风格的浅蓝、浅黄、橙色以及白色、黑色字体配色。字体上,通篇统一微软雅黑字体。 内容结构改进 事例1 材料的风格上统一后,整体视觉效果会上一个档次。下面就是内容的结构化调整,举个例子,比如在工作产出这块,先是按照项目划分,再是每个项目里列出了一些成果。 这些成果罗列在一起,并没有根据一定的分类、特性进行归类划分。而且所有的产出都是项目的业绩成果,视角范围也小了一些。 导致这种描述的原因就是缺乏结构性思维,没有做到对产出的拆分,没有做到解构与结构。 参考MECE相互独立,完全穷尽原则把这页描述的内容重新做了结构化的调整,拆分为业务产出与技术产出,二者做到相互独立。且视角上,也不止是业务上的成果,放宽到隐性的能力积累。 在业务产出这块在按照项目来划分;在技术产出上按照负载支撑、系统质量、能力积累来划分,整体的目录结构更显得结构化。调整之后,整个页面焕然一新了 事例2 同样的,另外一个页面优化前 优化后 这里的排版,还体现了结构化思维的结构思维。 比如把问题分类合并为多、少、快特点,再统一合并到问题这一类,这里体现了归纳法。 然后问题到方案再到演进的阶段划分,以及详细的演进过程,体现演绎法; 归纳与演绎法构成了结构性思维的结构特性。 事例3 优化前,产出都时平铺的,既不美观,也没逻辑结构。 优化后,突出了行动,产生了影响,也体现了个人影响力的扩散形式。 金字塔原理 如果我不是认真看过结构性思维以及《金字塔原理》这些知识,那么可能也不会认识到这么短的内容会又这么多门道。 当然,材料正文还要配合一些汇报材料的技巧,比如SCQA框架结构聚焦范围和问题,用MECE架构清晰的主题等等。 态度 软能力就体现在语言表达、逻辑思维、临场发挥等等,而这些技能不是一蹴而就的,是需要长时间的练习才能养成的。 其实,我觉得我还有一个因素我没画上去,就是对待这件事的态度。开始,我并没有太重视这件事,很多时间都放在工作上,忽视这述职材料的重要性,直到临近开始前两周才开始准备,结果就是整份材料看上去非常粗制滥造。而一份优质的述职材料,起码经过十次以上的迭代修改,时间上根本不够。这也应该是我失败的原因之一。 自己摸索 我的上级给我的绩效考核是他对我的主动认知,而不是我的汇报。这不需要考察我的语言组织能力,提炼总结水平。 同样,在我的上级眼里,我的优点远大于缺点,从而没有能及时指正出来,这也是导致我这次失败的原因之一。 而团队原因部分,因为自己与上级述职的通道不一样,我的这条路我是第一个走的,而且评委通道里,没有一个我们部门的人。没有前人栽树,在摸索的过程中,势必要经历失败,撞几次南墙。 没有方向 在准备述职材料时,我准备的知识都是技术细节的,比如raft原理、netlink机制、cap理论、sandbox方案等等。而评委们提问的问题却是推演法则、分析建模、问题拆解、产品规划这一些,别说猜题了,我练题目范围都没猜中。 评委根本无法区分你是研发还是产品,分不清你们工种的特殊性。部门没有高P在评委中,我连考题范围都不知道。这个墙撞的也很疼。 沙盘 以前,我总是不停的向前跑,从未停下来思考,担心停下来就会落后。现在才知道总结和复盘才是跑得更快的致胜法宝。除了这些,更不能少了沙盘推演。 推演产品的演变规划,称之为系统规划;推演评委的考评问题,称之为猜题。有了这些推演,才能让自己在应答中做到心中有数,应答如流。 外部因素 对于外部因素,比如收入大头的团队光环远比基础保障型团队的大,比如腾讯的王者荣耀部门是腾讯游戏的现金牛,相同技术技能的人,肯定比其他部门的人优势要大,更被认可。他们的业绩更容易用经济价值来衡量,而运维保障、网络安全等部门的产出,就很难用经济价值来衡量。 我也有过做评委的经历,感受过一些规则。评委中有自己部门评委或认识的情况下,也有着潜在的优势。一来,对于认识的评委,意味着有业务上的来往,对自己负责的业务情况更为熟知,会快速消除陌生感,更容易理解述职者的汇报。同时,自己部门的评委在众多评委中也有着潜在的影响力,当其他评委给评价时,还是会受到一些影响的。 当有名额限制时,外部们的人80分,本部门的78分,那评委们会受到影响吗? 其他还有很多我不知道的因素,也会随着阅历的增长,有着新的理解与感悟。 总结 收获 经历这次失败后,我对收获的认知也有改变,除了金钱、地位、名声等,更是把扩大到了自身能力、韧性、意识等隐性潜质之上,比如: 对待事情的态度 思考问题的方法 应对挫折的韧性 自我修复的意识 我曾与前辈探讨过,问他「我能不能往某个方向上发展」,前辈没有直面回答,我能感受到,那些领域已经触及我的天花板了。 我不知道这些自我认知、自我调整能不能突破我的天花板,但我想试试。 认知 回到这次考核上,对于评委的结论,我不完全否认;正视自己的缺点,复盘挫折,迭代自身状态,提升韧性,提升个人能力,以应对以后更大的挫折。对于晋升的结果,我不完全认可;没有完全公平的规则,规则都是有缺陷的。我改变不了这些,那就改变自己。而且这些因素我控制不了,一切看脸吧。 从意气风发、稳操胜券到灰头土脸、一败涂地,只经历了一次述职答辩; 从万念俱灰、萎靡不振到柳暗花明、峰回路转,只完成了一个思维转变; 我曾踏足山巅,也曾跌落谷底,二者都让我受益良多。 学习文献 金字塔原理 科学运营的“三步法” 什么是结构化思维 哪些思维方式是你刻意训练过的? 阅读书籍 《输赢》 《管理梯队》 《OKR工作法》 微笑 2019-12-25 +14 18 0