云原生安全:攻防实践与体系构建
上QQ阅读APP看书,第一时间看更新

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漏洞的成因。