笔者主要负责美团内部操作系统安全, 云原生安全, 重大高危漏洞应急响应. 10 + 年安全行业经验, 熟悉多个安全领域, 涉及渗透测试, web 安全, 二进制安全, Iot 安全, Linux/Android 内核安全等, 6 + 年的时间到至今, 长期专注于 Linux 内核安全及开源软件安全. 21 岁时受邀在第一届中国互联网安全领袖峰会 (CSS) 演讲, 知名 Linux/Android 内核远程漏洞 CVE-2017-8890 发现者.
概述:
云原生 (Cloud Native) 是一套技术体系和方法论. 云原生 (Cloud Native) 由 2 个词组成, 云 (Cloud) 和原生 (Native). 云(Cloud) 表示应用程序位于云中, 而不是传统的数据中心; 原生 (Native) 表示应用程序从设计之初即考虑到云的环境, 原生为云而设计, 在云上以最佳状态运行, 充分利用和发挥云平台的弹性和分布式优势.
云原生的代表技术包括容器, 服务网格(Service Mesh), 微服务(Micro Service), 不可变基础设施和声明式 API.
更多对于云原生的介绍请参考文末链接 1.
云原生安全技术沙盘(Security View)
笔者将 "云原生安全" 抽象成如上图所示的技术沙盘. 自底向上看, 底层从硬件安全 (可信环境) 到宿主机安全 . 将容器编排技术 (Kubernetes 等) 看作云上的 "操作系统", 它负责自动化部署, 扩缩容, 管理应用等. 在它之上由微服务, Service Mesh, 容器技术 (Docker 等), 容器镜像(仓库) 组成. 它们之间相辅相成, 以这些技术为基础构建云原生安全.
再对容器安全做一层抽象, 又可以看作构建时安全(Build), 部署时安全(Deployment), 运行时安全(Runtime).
在美团内部镜像安全由容器镜像分析平台保障. 它以规则引擎的形式运营监管容器镜像, 默认规则支持对镜像中 dockerfile, 可疑文件, 敏感权限, 敏感端口, 基础软件漏洞, 业务软件漏洞以及 CIS 和 NIST 的最佳实践做检查, 并提供风险趋势, 同时它确保部分构建时安全.
容器在云原生架构下是由容器编排技术 (例如: Kubernetes) 负责部署的, 部署安全同时也与上文提及的容器编排安全有交集.
运行安全管控交由 HIDS 负责(可以参考, 分布式 HIDS 集群架构设计, 文末链接 2). 本文所讨论的范畴也属于运行安全之一, 主要解决以容器逃逸为模型构建的风险(在本文中, 若无特殊说明, 容器指代 Docker).
对于安全实施准则, 我们将其分为三个阶段:
1. 攻击前: 裁剪攻击面, 减少对外暴露的攻击面(本文涉及的场景关键词: 隔离).
2. 攻击时: 降低攻击成功率(本文涉及的场景关键词: 加固).
3. 攻击后: 减少攻击成功后攻击者所能获取的有价值的信息, 数据以及增加留后门难度等.
近些年, 数据中心的基础架构逐渐从传统的虚拟化 (例如: KVM+Qemu 架构) 转向容器化 (Kubernetes+Docker 架构), 但逃逸始终都是企业要在这 2 种架构下所需要面对的最严峻的安全问题, 同时它也是容器风险中最具代表性的安全问题. 笔者将以容器逃逸为切入点, 从攻击者角度(容器逃逸) 到防御者角度 (缓解容器逃逸) 去阐述容器安全实践, 从而缓解容器风险.
容器风险
容器提供了将应用程序的代码, 配置, 依赖项打包到单个对象的标准方法. 容器建立在 2 项关键技术之上, Linux Namespace 和 Linux Cgroups.
Namespace 创建一个近乎隔离的用户空间并为应用程序提供系统资源(文件系统, 网络栈, 进程和用户 ID).Cgroup 强制限制硬件资源, 如 CPU, 内存, 设备和网络.
容器和 VM 不同之处在于, VM 模拟硬件系统, 每个 VM 都可以在独立环境中运行 OS. 管理程序模拟 CPU, 内存, 存储, 网络资源等, 这些硬件可由多个 VM 共享多次.
容器攻击面(Container Attack Surface)
容器一共有 7 个攻击面: Linux Kernel,Namespace/Cgroups/Aufs,Seccomp-bpf,Libs,Language VM,User Code,Container(Docker) engine.
笔者以容器逃逸为风险模型, 提炼出 3 个攻击面:
1. Linux 内核漏洞
2. 容器自身
3. 不安全部署(配置)
一, Linux 内核漏洞
容器的内核与宿主内核共享, 使用 Namespace 与 Cgroups 这两项技术使容器内的资源与宿主机隔离, 所以 Linux 内核产生的漏洞能导致容器逃逸.
内核提权 VS 容器逃逸 -- 通用 Linux 内核提权方法论
信息收集
收集一切对写 exploit 有帮助的信息. 如: 内核版本, 需要确定攻击的内核是什么版本? 这个内核版本开启了哪些加固配置? 还需知道在写 shellcode 的时候会调用哪些内核函数? 这时候就需要查询内核符号表, 得到函数地址. 还可从内核中得到一些对编写利用有帮助的地址信息, 结构信息等等.
触发阶段
触发相关漏洞, 控制 RIP, 劫持内核代码路径, 简而言之获取在内核中任意执行代码的能力.
布置 shellcode
在编写内核 exploit 代码的时候需要找到一块内存来存放我们的 shellcode . 这块内存至少得满足两个条件:
第一: 在触发漏洞的时候我们要劫持的代码路径, 必须保证代码路径可以到达存放 shellcode 的内存.
第二: 这块内存是可以被执行的, 换句话说, 存放 shellcode 的这块内存具有可执行权限.
执行阶段
第一: 获取高于当前用户的权限, 一般我们都是直接获取 root 权限, 毕竟它是 Linux 中最高权限, 也就是执行我们的 shellcode.
第二: 保证内核稳定, 不能因为我们需要提权而破坏原来内核的代码路径, 内核结构, 内核数据等等, 使内核崩溃了, 这样的话, 即使得到 root 权限也没什么太大的意义.
简而言之, 收集对编写 exploit 有帮助的信息, 然后触发漏洞去执行特权代码, 达到提权的效果.
容器逃逸简易模型(Container Escape Model)
容器逃逸和内核提权只有细微的差别, 需要突破 namespace 的限制. 将高权限的 namespace 赋到 exploit 进程的 task_struct 中. 这部分的详细技术细节不在本文讨论范围内, 笔者会抽空再写一篇关于容器逃逸的技术文章, 详细介绍相关技术细节.
经典的 DirtyCoW
笔者以 Dirty CoW 漏洞来说明 Linux 漏洞导致的容器逃逸. 漏洞虽老, 奈何太过经典. 写到这, 笔者不禁想问: 多年过去, 目前国内外各大厂, Dirty Cow 漏洞的存量机器修复率是多少?
在 Linux 内核的内存子系统处理私有只读内存映射的写时复制 (Copy-on-Write,CoW) 机制的方式中发现了一个竞争冲突. 一个没有特权的本地用户可能会利用此漏洞获得对其他情况下只读内存映射的写访问权限, 从而增加他们在系统上的特权, 这就是知名的 Dirty CoW 漏洞.
Dirty CoW 漏洞的逃逸这里的实现思路和上述的思路不太一样, 采取 Overwrite vDSO 技术.
vDSO(Virtual Dynamic Shared Object)是内核为了减少内核与用户空间频繁切换, 提高系统调用效率而设计的机制. 它同时映射在内核空间以及每一个进程的虚拟内存中, 包括那些以 root 权限运行的进程. 通过调用那些不需要上下文切换 (context switching) 的系统调用可以加快这一步骤 (定位 vDSO).vDSO 在用户空间(userspace) 映射为 R/X, 而在内核空间 (kernelspace) 则为 R/W. 这允许我们在内核空间修改它, 接着在用户空间执行. 又因为容器与宿主机内核共享, 所以可以直接使用这项技术逃逸容器.
利用步骤如下:
1. 获取 vDSO 地址, 在新版的 glibc 中可以直接调用 getauxval()函数获取.
2. 通过 vDSO 地址找到 clock_gettime()函数地址, 检查是否可以 hijack.
3. 创建监听 socket.
4. 触发漏洞, Dirty CoW 是由于内核内存管理系统实现 CoW 时产生的漏洞. 通过条件竞争, 把握好在恰当的时机, 利用 CoW 的特性可以将文件的 read-only 映射为 write. 子进程不停的检查是否成功写入. 父进程创建二个线程, ptrace_thread 线程向 vDSO 写入 shellcode.
madvise_thread 线程释放 vDSO 映射空间, 影响 ptrace_thread 线程 CoW 的过程, 产生条件竞争, 当条件触发就能写入成功.
5. 执行 shellcode, 等待从宿主机返回 root shell, 成功后恢复 vDSO 原始数据.
二, 容器自身
我们先简单的看一下 Docker 的架构图:
Docker 架构图(图片来自网络如有侵权联系删除)
Docker 本身由 docker(docker client)和 dockerd(docker daemon)组成. 但从 Docker 1.11 开始, Docker 不再是简单的通过 docker dameon 来启动, 而是集成许多组件, 包括 containerd,runc 等等.
Docker client 是 docker 的客户端程序, 用于将用户请求发送给 dockerd.dockerd 实际调用的是 containerd 的 API 接口, containerd 是 dockerd 和 runc 之间的一个中间交流组件, 主要负责容器运行, 镜像管理等. containerd 向上为 dockerd 提供了 gRPC 接口, 使得 dockerd 屏蔽下面的结构变化, 确保原有接口向下兼容; 向下, 通过 containerd-shim 与 runc 结合创建及运行容器. 更多的相关内容, 请参考文末链接 4,5,6. 了解清楚这些之后, 我们就可以结合自身的安全经验, 从这些组件相互间的通信方式, 依赖关系等寻找能导致逃逸的漏洞.
下面我们以 docker 中的 runc 组件所产生的漏洞来说明因容器自身的漏洞导致的逃逸.
CVE-2019-5736:runc - container breakout vulnerability
runc 在使用文件系统描述符时存在漏洞, 该漏洞可导致特权容器被利用, 造成容器逃逸以及访问宿主机文件系统; 攻击者也可以使用恶意镜像, 或修改运行中的容器内的配置来利用此漏洞.
攻击方式 1:(该途径需要特权容器) 运行中的容器被入侵, 系统文件被恶意篡改 ==> 宿主机运行 docker exec 命令 在该容器中创建新进程 ==> 宿主机 runc 被替换为恶意程序 ==> 宿主机执行 docker run/exec 命令时触发执行恶意程序;
攻击方式 2:(该途径无需特权容器) docker run 命令启动了被恶意修改的镜像 ==> 宿主机 runc 被替换为恶意程序 ==> 宿主机运行 docker run/exec 命令时触发执行恶意程序;
当 runc 在容器内执行新的程序时, 攻击者可以欺骗它执行恶意程序. 通过使用自定义二进制文件替换容器内的目标二进制文件来实现指回 runc 二进制文件.
例如, 如果目标二进制文件是 / bin/bash, 这可以用指定解释器的可执行脚本替换 #!/proc/self/exe; 因此, 在容器内执行 / bin/bash,/proc/self/exe 的目标将被执行, 将目标指向 runc 二进制文件.
然后攻击者可以继续写入 / proc/self/exe 目标, 尝试覆盖主机上的 runc 二进制文件. 这里需要使用 O_PATH flag 打开 / proc/self/exe 文件描述符, 然后以 O_WRONLY flag 通过 / proc/self/fd/<nr > 重新打开二进制文件, 并且用单独的一个进程不停地写入. 当写入成功时, runc 会退出.
三, 不安全部署(配置)
在实际中, 经常会遇到这种状况: 不同的业务会根据自身业务需求有自己的一套配置, 而这套配置并未得到有效的管控审计, 使得内部环境变的复杂多样, 无形之中又增加了许多风险点. 譬如, 最常见的:
特权容器或者以 root 权限运行容器.
不合理的 Capability 配置(权限过大的 Capability).
面对特权容器, 在容器内简单的执行一下命令就可以轻松的在宿主机上留下后门.
在美团内部已经有效的收敛了特权容器问题.
这部分业界已经给出了最佳实践, 从宿主机配置, Dockerd 配置, 容器镜像, Dockerfile, 容器运行时等方面保障安全, 更多细节请参考文末链接 10, 同时 Docker 官方已经将其实现成自动化工具(见文末链接 11).
安全实践
为解决上述部分所阐述的容器逃逸问题, 下文将重点从隔离 (安全容器) 与加固 (安全内核) 两个角度去讨论.
一, 安全容器
安全容器的技术本质其实就是隔离. gVisor 和 Kata Container 是比较具有代表性的实现方式, 当然目前学术界有在探索基于 Intel SGX 的安全容器.
简单的说, gVisor 是在用户态和内核态之间抽象出一层, 封装成 API, 有点像 user-mode kernel, 以此实现隔离; Kata Container 是采用轻量级虚拟机隔离, 与传统的 VM 比较类似, 但是它实现了无缝集成当前的 Kubernetes 加 Docker 架构. 我们接着来看 gVisor 与 Kata Container 的异同.
Case 1: gVisor
gVisor 是用 Golang 编写的用户态内核, 或者说是沙箱技术, 它主要实现了大部分的 system call. 它运行在应用程序和内核之间, 为它们提供隔离. gVisor 被使用在 Google 云计算平台的 App Engine,Cloud Functions 和 Cloud ML 中. gVisor 运行时, 是由多个沙箱组成, 这些沙箱进程共同覆盖了一个或多个容器. 通过拦截从应用程序到主机内核的所有系统调用并使用用户空间中的 Sentry 处理它们, gVisor 充当 guest kernel 的角色, 且无需通过虚拟化硬件转换, 可以将他看做 vmm 与 guest kernel 的 **, 或是 seccomp 的增强版.
gVisor 架构图(图片来自网络如有侵权联系删除)
Case 2: Kata Container
Kata Container 的 Container Runtime 是用 hypervisor , 是用 hardware virtualization 实现的, 如同虚拟机. 所以每一个像这样的 Kata Container 的 Pod, 都是一个轻量级虚拟机, 它是拥有完整的 Linux 内核. 所以 Kata Container 与 VM 一样能提供强隔离性, 但由于它的优化和性能设计, 它拥有与容器相媲美的敏捷性.
Kata Container 架构图(图片来自网络如有侵权联系删除)
Kata Container 在主机上有一个 kata-runtime 来启动和配置新容器. 对于 Kata VM 中的每个容器, 主机上都有相应的 Kata Shim.Kata Shim 接收来自客户端的 API 请求 (例如: docker 或 kubectl), 并通过 VSock 将请求转发给 Kata VM 内的代理. Kata 容器进一步优化以减少 VM 启动时间. 使用 QEMU 的轻量级版本 NEMU, 删除了约 80%的设备和包. VM-Templating 创建运行 Kata VM 实例的克隆, 并与其他新创建的 Kata VM 共享, 这样减少了启动时间和 Guest VM 内存消耗. Hotplug 功能允许 VM 使用最少的资源(例如: CPU, 内存, virtio 块) 进行引导, 并在以后请求时添加其他资源.
gVisor VS Kata Container
在二者之间笔者更愿选择 gVisor, 因为 gVisor 设计上相比与 Kata Container 更加 "轻" 量级, 但 gVisor 的性能问题始终是一道暂时无法逾越的槛. 综合二者的优劣, Kata Container 目前来看会更适合企业内部. 总体而言, 安全容器技术还需做诸多探索, 以解决不同企业内部基础架构上面临的挑战.
二, 安全内核
众所周知, Android 由于不同厂商都维护着自己的 Android 版本, 又因为 Android 内核态代码来自于 Linux kernel upstrem, 当一个漏洞产生在 upstrem 内核, 安全补丁推送到 Google, 再从 Google 下发到各大厂商, 最终到终端用户. Android 生态的碎片化, 补丁周期非常之长, 使得终端用户的安全, 在这过程中始终处于 "空窗期". 把目光重新聚焦在 Linux 上, 它也同样存在类似的问题.
1. 内核面临的问题
漏洞生命周期(The Vulnerability Life Cycle)
内核补丁
当一个安全漏洞被披露, 通常是由漏洞发现者通过 RedHat,OpenSuse,Debian 等社区反馈或直接提交至上游相关子系统 maintainer. 在企业内部面临多个不同内核大版本, 内核定制化, 针对不同版本从上游代码 backport 相关补丁及制作相关热补丁, 定制内核还需对补丁进行二次开发, 再升级生产环境内核或 hotfix 内核. 不仅修复周期过长, 而且推进修复过程人员沟通也存在成本, 拉长了漏洞危险期. 在危险期间对于漏洞是毫无防护能力的.
内核版本碎片化
内核版本碎片化在任意具备一定规模的公司都是无法避免的问题. 随着技术日新月异, 不断迭代, 基础架构上的技术栈需要较新版本的内核功能去支持, 久而久之产生内核版本碎片化. 碎片化问题的存在, 使得在安全补丁的推送方面, 遭遇了很大的挑战. 本身补丁还需要做针对性的适配, 包括不同版本的内核, 并进行测试验证, 碎片化使得维护成本也十分高昂. 最重要的是, 由于维护工作量大, 必然拉长了测试补丁的时间线. 也就是说, 暴露在攻击者面前的危险期变得更长, 被攻击的可能性大大增加.
内核版本定制化
同样, 因不同公司的基础架构不同, 需求不同, 导致的定制化内核问题. 对于定制化内核, 无法简单的通过从上游内核合并补丁, 还需对补丁做一些本地化来适配定制化内核. 这又拉长了危险期.
解决之道
我们使用安全特性去针对某一类漏洞或是针对某一类利用方式做防御与检测. 比如 SLAB_FREELIST_HARDENED, 针对 double free 类型漏洞做实时检测, 且防御 overwrite freelist 链表, 性能损耗仅 0.07%(参考 upstrem 内核源码, commit id: 2482ddec).
当完成所有全部安全特性, 漏洞在被反馈之前和漏洞补丁被及时推送至生产环境前, 无需关心漏洞的细节, 就能防御. 当然, 安全补丁该打还是得打的, 这里我们主要解决在安全补丁最终落在生产环境过程中 "空窗期" 对于漏洞与利用毫无防御能力的问题, 同时也可以对 0day 有一定的检测及防御能力.
实施策略
已经合并进 Linux 主线版本的安全特性, 如果公司的内核支持该特性, 选择开启配置, 对开启前后内核做性能测试, 分析安全特性原理, 行业数据, 给出 real world 攻击案例(自己写 exploit 去证明), 将报告结论反馈给内核团队, 内核团队再做评估, 结合安全团队与内核团队双方意见, 最终评估落地.
已经合并进 Linux 主线版本但未被合并进 RedHat 的安全特性, 可选择从 Linux 内核主线版本中移植, 这点上代码质量上得到了保障, 同时社区也做了性能测试, 将其合并到公司的内核再做复测.
未被合并进 Linux 内核主线版本, 从 Grsecurity/PaX 中做移植, 在 Grsecurity/PaX 的诸多安全特性中, 评估选择, 选取代码改动少的, 收益高的安全特性优先移植, 比如改动较少的内核代码又能有效解决某一类的漏洞, 再打个比方, dirty cow 的全量修复可能需要花费 1-2 年, 加了某个安全特性, 即使未修复也能防御.
内核后话
最后, 分享一下笔者眼中较为理想中的状况. 当然, 我们得根据实际情况 "因地制宜", 在不同阶段做出不同的取舍与选择.
将内核团队看成社区, 我们向他们提交代码, 如同 Linux 内核社区有 RFC(Request for Comment),patch review 等, 无争议后合并进公司内核.
先挑选实用的安全特性且代码量少的, 去移植, 去实现, 并落地. 代码量少意味着, 对内核代码改动少, 出问题的可能性越小, 稳定性越高, 性能损耗越低.
一年完成几个安全特性, 不需要多, 1~2 个即可, 对于内核态的加固, 慎重慎重再慎重, 譬如国外 G 家公司数据中心的内核发版前大概需要 6~7 个月时间做性能, 稳定性测试.
需要做到加固某个安全特性后, 使用 0day 或 Nday 去验证防御效果, 且基于该内核跑业务是稳定, 性能损耗在可接受范围之内或者可控. 每个安全特性需要技术评审. 为保障代码质量的问题, 找实际的高吞吐以及高并发低延迟的服务器小范围灰度测试, 无争议后, 推送给内核团队.
最后, 还可以通过将安全特性的代码直接提交给 Linux 内核社区, 如果代码有不足的地方也可以和社区协同解决, 合并进 Linux 内核主线代码, 从而侧面推动落地.
参考文献
- https://mp.weixin.qq.com/s/5pVKNI6_hzhaXTOUxU9bTA
- https://dirtycow.ninja/
- https://github.com/opencontainers/runc
- https://github.com/containerd/containerd
- https://github.com/Frichetten/CVE-2019-5736-PoC
- https://github.com/docker
- https://www.cisecurity.org/benchmark/docker/
- https://gvisor.dev/docs/
- https://github.com/kata-containers/documentation/
- https://www.kernel.org/
- https://www.redhat.com/
- https://lwn.net/Articles/531114/
- https://lwn.net/Articles/604609/
- https://github.com/Pray3r/container-security
- A Short Story: Bypass SMEP on Linux
来源: http://www.tuicool.com/articles/YRFBreY