- 云原生安全:攻防實踐與體系構建
- 劉文懋 江國龍 浦明 阮博男 葉曉虎
- 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漏洞的成因。