- 云原生安全:攻防实践与体系构建
- 刘文懋 江国龙 浦明 阮博男 叶晓虎
- 1574字
- 2021-11-04 18:12:36
4.3.2 漏洞分析
我们首先对漏洞涉及的处理流程进行分析描述,在明确了相关流程后,再对漏洞点进行剖析。
漏洞位于staging/src/k8s.io/apimachinery/pkg/util/proxy/upgradeaware.go中。upgradeaware.go主要用来处理API Server的代理逻辑。其中ServeHTTP函数用来具体处理一个代理请求:
//staging/src/k8s.io/apimachinery/pkg/util/proxy/upgradeaware.go //ServeHTTP handles the proxy request func (h *UpgradeAwareHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { if h.tryUpgrade(w, req) { return } if h.UpgradeRequired { h.Responder.Error(w, req, errors.NewBadRequest("Upgrade request required")) return } //省略 }
它在最开始调用了tryUpgrade函数,尝试进行协议升级。漏洞正存在于该函数的处理逻辑之中,我们仔细看一下。
首先,该函数要判断原始请求是否为协议升级请求(请求头中是否包含Connection和Upgrade项):
if !httpstream.IsUpgradeRequest(req) { glog.V(6).Infof("Request was not an upgrade") return false }
接着,它建立了到后端服务的连接:
if h.InterceptRedirects { glog.V(6).Infof("Connecting to backend proxy (intercepting redirects) %s\n Headers: %v", &location, clone.Header) backendConn, rawResponse, err = utilnet.ConnectWithRedirects(req.Method, &location, clone.Header, req.Body, utilnet.DialerFunc(h.DialForUpgrade)) } else { glog.V(6).Infof("Connecting to backend proxy (direct dial) %s\n Headers: %v", &location, clone.Header) clone.URL = &location backendConn, err = h.DialForUpgrade(clone) } if err != nil { glog.V(6).Infof("Proxy connection error: %v", err) h.Responder.Error(w, req, err) return true } defer backendConn.Close()
然后,tryUpgrade函数进行了HTTP Hijack操作,简单来说,就是不再将HTTP连接处理委托给Go语言内置的处理流程,程序自身在TCP连接基础上进行HTTP交互,这是从HTTP升级到WebSocket的关键步骤之一:
//Once the connection is hijacked, the ErrorResponder will no longer work, so //hijacking should be the last step in the upgrade. requestHijacker, ok := w.(http.Hijacker) if !ok { glog.V(6).Infof("Unable to hijack response writer: %T", w) h.Responder.Error(w, req, fmt.Errorf("request connection cannot be hijacked: %T", w)) return true } requestHijackedConn, _, err := requestHijacker.Hijack() if err != nil { glog.V(6).Infof("Unable to hijack response: %v", err) h.Responder.Error(w, req, fmt.Errorf("error hijacking connection: %v", err)) return true } defer requestHijackedConn.Close()
紧接着,tryUpgrade将后端针对上一次请求的响应返回给客户端:
//Forward raw response bytes back to client. if len(rawResponse) > 0 { glog.V(6).Infof("Writing %d bytes to hijacked connection", len(rawResponse)) if _, err = requestHijackedConn.Write(rawResponse); err != nil { utilruntime.HandleError(fmt.Errorf("Error proxying response from backend to client: %v", err)) } }
函数的最后,客户端到后端服务的代理通道被建立起来:
//Proxy the connection. wg := &sync.WaitGroup{} wg.Add(2) go func() { var writer io.WriteCloser //省略 }() go func() { var reader io.ReadCloser //省略 }() wg.Wait() return true
这是API Server视角下建立代理的流程。那么,在这个过程中,后端服务又是如何参与的呢?
我们以Kubelet为例,当用户对某个Pod执行exec操作时,该请求经过上面API Server的代理,发给Kubelet。Kubelet在初始化时会启动一个自己的API Server(为便于区分,后文所有单独出现的API Server均指的是Kubernetes API Server,用Kubelet API Server指代Kubelet内部的API Server),其代码实现在pkg/kubelet/server/server.go中。从该文件中我们可以看到,Kubelet启动时会注册一系列API,/exec就在其中(由InstallDebuggingHandlers函数注册),注册的对应处理函数为:
//getExec handles requests to run a command inside a container. func (s *Server) getExec(request *restful.Request, response *restful.Response) { params := getExecRequestParams(request) //创建一个Options实例 streamOpts, err := remotecommandserver.NewOptions(request.Request) if err != nil { utilruntime.HandleError(err) response.WriteError(http.StatusBadRequest, err) return } pod, ok := s.host.GetPodByName(params.podNamespace, params.podName) if !ok { response.WriteError(http.StatusNotFound, fmt.Errorf("pod does not exist")) return } //将客户端与Pod对接,客户端直接与Pod交互,执行命令,获取结果 podFullName := kubecontainer.GetPodFullName(pod) url, err := s.host.GetExec(podFullName, params.podUID, params.containerName, params.cmd, *streamOpts) if err != nil { streaming.WriteError(err, response.ResponseWriter) return } if s.redirectContainerStreaming { http.Redirect(response.ResponseWriter, request.Request, url.String(), http.StatusFound) return } proxyStream(response.ResponseWriter, request.Request, url) }
也就是说,如果一切顺利的话,当客户端发起一个对Pod执行exec操作的请求时,经过API Server的代理、Kubelet的转发,最终客户端与Pod间建立起了连接。
那么,问题可能出现在什么地方呢?我们分情况讨论一下:
1)如果请求本身不具有相应Pod的操作权限,它在API Server环节就会被拦截下来,不会到达Kubelet,这个处理没有问题。
2)如果请求本身具有相应Pod的操作权限,且请求符合API要求(URL正确、参数齐全等),API Server建立起代理,Kubelet将流量转发到Pod上,一条客户端到指定Pod的命令执行连接被建立,这也没有问题,因为客户端本身具有相应Pod的操作权限。
3)如果请求本身具有相应Pod的操作权限,但是发出的请求并不符合API要求(如参数指定错误等),API Server同样会建立起代理,将请求转发给Kubelet,这种情况下会发生什么呢?
回顾上面给出的在Kubelet的/exec处理函数getExec中,一个Options实例被创建:
streamOpts, err := remotecommandserver.NewOptions(request.Request)
跟进看一下remotecommandserver.NewOptions函数:
//NewOptions creates a new Options from the Request. func NewOptions(req *http.Request) (*Options, error) { tty := req.FormValue(api.ExecTTYParam) == "1" stdin := req.FormValue(api.ExecStdinParam) == "1" stdout := req.FormValue(api.ExecStdoutParam) == "1" stderr := req.FormValue(api.ExecStderrParam) == "1" if tty && stderr { glog.V(4).Infof("Access to exec with tty and stderr is not supported, bypassing stderr") stderr = false } if !stdin && !stdout && !stderr { return nil, fmt.Errorf("you must specify at least 1 of stdin, stdout, stderr") } return &Options{ Stdin: stdin, Stdout: stdout, Stderr: stderr, TTY: tty, }, nil }
可以看到,如果请求中stdin、stdout和stderr三个参数都没有给出,Options实例将创建失败,getExec函数将直接返回给客户端一个http.StatusBadRequest信息:
if err != nil { utilruntime.HandleError(err) response.WriteError(http.StatusBadRequest, err) return }
回到我们上面说的第三种情况。结合API Server tryUpgrade代码可以发现,API Server并没有对这种错误情况进行处理,依然通过两个Goroutine为客户端到Kubelet建立了WebSocket连接!问题在于,这个连接并没有对接到某个Pod上(因为前面getExec失败返回了),也没有被销毁,客户端可以继续通过这个连接向Kubelet下发指令。由于经过了API Server的代理,因此指令是以API Server的权限向Kubelet下发的。也就是说,客户端自此能够自由向该Kubelet下发指令而不受限制,从而实现了权限提升,这就是CVE-2018-1002105漏洞的成因。