F5从认证绕过到远程代码执行漏洞分析

2021-03-18 14:55 代码审计 漏洞分析

前言

F5 BIG-IP是F5公司的一款应用交付平台,今日F5官方发布安全公告,称有七个有关BIG-IP和BIG-IQ的安全漏洞。其中CVE-2021-22986是一个未认证的远程命令执行漏洞,由于对HTTP请求的认证不完全,可以允许攻击者通过设置特殊的HTTP header来绕过权限认证并访问BIG-IP的REST API从而执行命令。

影响范围

BIG-IP (全部模块) v16.0.0-16.0.1
BIG-IP (全部模块) v15.1.0-15.1.2
BIG-IP (全部模块) v14.1.0-14.1.3.1
BIG-IP (全部模块) v13.1.0-13.1.3.5
BIG-IP (全部模块) v12.1.0-12.1.5.2
BIG-IQ v7.1.0-7.1.0.2
BIG-IQ v7.0.0-7.0.0.1
BIG-IQ v6.0.0-6.1.0

安全版本

BIG-IP v16.0.1.1
BIG-IP v15.1.2.1
BIG-IP v14.1.4
BIG-IP v13.1.3.6
BIG-IP v12.1.5.3
BIG-IQ v8.0.0
BIG-IQ v7.1.0.3
BIG-IQ v7.0.0.2

漏洞分析及复现

通过爆破目录发现所有的REST API都返回401未认证,结合本漏洞得出存在认证绕过的结论。发送到BIG-IP的HTTP请求会先经过HTTPD Apache进行分析过滤后发送到Jetty的REST服务器,由此可得有两个认证绕过的漏洞。更改HTTPD的Proxy配置可以在本机进行流量分析。

文件分析

Apache认证绕过

前面提到请求会先发送到Apache,而REST的API目录前缀都含有mgmt, 于是使用grep指令寻找该字符串并找到mod_pam_auth.so模块。把文件放到IDA中进行反编译,出于不知名原因IDA不能成功将文件反编译为C代码,便通过总体程序流向分析。

通过关键词可以看出以上部分是查看是否请求中有X-F5-Auth-Token,有的话将直接发送到Jetty并进行后续认证。没有的话会对访问目录进行查看,最终会查找Authorization和其他HTTP的header。

通过函数名可以得知是对密码进行操作比较。根据以上逻辑可以得出如果HTTP请求中设置了X-F5-Auth-Token的header,不论值正确与否都将成功绕过Authorization的检测。并且经测试服务器返回结果确实如此。

没有Authorization header但有X-F5-Auth-Token时,401的回复是由Jetty发送。

错误的Authorization header和空的X-F5-Auth-Token也将由Jetty发送回复且甚至还可以绕过认证,此问题将在后续提到。

接下来测试没有X-F5-Auth-Token header的结果,同样是401但是是有Apache返回的结果。

Jetty认证绕过

下面来看Jetty部分的认证绕过。在服务器根目录里找到相关jar文件,将所有文件通过JD进行反编译,后通过关键词查询找到f5.rest.jar。在本文件中发现通过Jetty并不会对通过Apache的HTTP的Basic Authorization进行进一步的认证导致认证绕过。

在f5.rest.workers.authz.AuthzHelper.class中,此函数将Authorization的header解码后返回

  public static BasicAuthComponents decodeBasicAuth(String encodedValue) {
    BasicAuthComponents components = new BasicAuthComponents();
    if (encodedValue == null) {
      return components;
    }

    String decodedBasicAuth = new String(DatatypeConverter.parseBase64Binary(encodedValue));
    int idx = decodedBasicAuth.indexOf(':');
    if (idx > 0) {
      components.userName = decodedBasicAuth.substring(0, idx);
      if (idx + 1 < decodedBasicAuth.length())
      {

        components.password = decodedBasicAuth.substring(idx + 1);
      }
    } 

    return components;
  }

此函数位于f5.rest.common.RestOperationIdentifier.class,通过basicAuth来设置identityData

  private static boolean setIdentityFromBasicAuth(RestOperation request) {
    String authHeader = request.getBasicAuthorization();
    if (authHeader == null) {
      return false;
    }
    AuthzHelper.BasicAuthComponents components = AuthzHelper.decodeBasicAuth(authHeader);
    request.setIdentityData(components.userName, null, null);
    return true;
  }
}
  public RestOperation setIdentityData(String userName, RestReference userReference, RestReference[] groupReferences) {
    if (userName == null && !RestReference.isNullOrEmpty(userReference)) {


      String segment = UrlHelper.getLastPathSegment(userReference.link);
      if (userReference.link.equals(UrlHelper.buildPublicUri(UrlHelper.buildUriPath(new String[] { WellKnownPorts.AUTHZ_USERS_WORKER_URI_PATH, segment }))))
      {
        userName = segment;
      }
    } 
    if (userName != null && RestReference.isNullOrEmpty(userReference)) {
      userReference = new RestReference(UrlHelper.buildPublicUri(UrlHelper.buildUriPath(new String[] { WellKnownPorts.AUTHZ_USERS_WORKER_URI_PATH, userName })));
    }


    this.identityData = new IdentityData();
    this.identityData.userName = userName;
    this.identityData.userReference = userReference;
    this.identityData.groupReferences = groupReferences;
    return this;
  }

通过

request.setIdentityData(components.userName, null, null);

之后的request有以下性质:

identityData.userName = 'admin';
identityData.userReference = 'http://localhost/mgmt/shared/authz/users/admin'
identityData.groupReference = null;

f5.rest.jar中有authn和authz两种class,根据关键词查询发现authn中有BIGIPAuthCookie及其他与BIGIP有关的Cookie, 而authz库中只有basic auth相关的方法函数。由此判断出若请求中有BIGIP相关Cookie则由authn认证,若有Authorization则由authz进行认证。

因为REST服务器默认Basic Authorization数据已经由Apache进行认证所以不需要重新验证账户密码,所以在给identityData赋值时直接是根据用户名的。

在f5.rest.workers.EvaluatePermissions.class中,通过此函数可以了解到第二个认证绕过是如何发生的。

  private static void completeEvaluatePermission(final RestOperation request, AuthTokenItemState token, final CompletionHandler<Void> finalCompletion) {
    final String path;
      //由于token没有值, 所以是null,绕过第一个F5 token的认证
    if (token != null) {
      if (token.expirationMicros.longValue() < RestHelper.getNowMicrosUtc()) {
        String error = "X-F5-Auth-Token has expired.";
        setStatusUnauthorized(request);
        finalCompletion.failed(new SecurityException(error), null);

        return;
      } 
      request.setXF5AuthTokenState(token);
    } 


//此处的request是前面返回的request物件,这个setBasicAuthFromIdentity仅将identity.userName重新进行编码,并不会查看密码。

    request.setBasicAuthFromIdentity();

//由于uri不符合所以跳过以下两个比较
    if (request.getUri().getPath().equals(EXTERNAL_LOGIN_WORKER) && request.getMethod().equals(RestOperation.RestMethod.POST)) {

      finalCompletion.completed(null);

      return;
    } 

    if (request.getUri().getPath().equals(UrlHelper.buildUriPath(new String[] { EXTERNAL_LOGIN_WORKER, "available" })) && request.getMethod().equals(RestOperation.RestMethod.GET)) {



      finalCompletion.completed(null);


      return;
    } 

     //此处的userRef是admin的ref,因为basic auth的用户名是admin
    final RestReference userRef = request.getAuthUserReference();


    if (RestReference.isNullOrEmpty(userRef)) {
      String error = "Authorization failed: no user authentication header or token detected. Uri:" + request.getUri() + " Referrer:" + request.getReferer() + " Sender:" + request.getRemoteSender();



      setStatusUnauthorized(request);
      finalCompletion.failed(new SecurityException(error), null);

      return;
    } 

      //因为admin是DefaultAdminRef, 所以认证成功
    if (AuthzHelper.isDefaultAdminRef(userRef)) {
      finalCompletion.completed(null);


      return;
    } 

      //认证成功所以并不会执行以下所有代码

    if (UrlHelper.hasODataInPath(request.getUri().getPath())) {
      path = UrlHelper.removeOdataSuffixFromPath(UrlHelper.normalizeUriPath(request.getUri().getPath()));
    } else {

      path = UrlHelper.normalizeUriPath(request.getUri().getPath());
    } 

    final RestOperation.RestMethod verb = request.getMethod();



    if (path.startsWith(EXTERNAL_GROUP_RESOLVER_PATH) && request.getParameter("$expand") != null) {

      String filterField = request.getParameter("$filter");
      if (USERS_GROUP_FILTER_STRING.equals(filterField) || USERGROUPS_GROUP_FILTER_STRING.equals(filterField)) {

        finalCompletion.completed(null);


        return;
      } 
    } 

    if (token != null && path.equals(UrlHelper.buildUriPath(new String[] { EXTERNAL_AUTH_TOKEN_WORKER_PATH, token.token }))) {

      finalCompletion.completed(null);

      return;
    } 
    roleEval.evaluatePermission(request, path, verb, new CompletionHandler<Boolean>()
        {
          public void completed(Boolean result)
          {
            if (result.booleanValue()) {
              finalCompletion.completed(null);

              return;
            } 
            String error = "Authorization failed: user=" + userRef.link + " resource=" + path + " verb=" + verb + " uri:" + request.getUri() + " referrer:" + request.getReferer() + " sender:" + request.getRemoteSender();






            EvaluatePermissions.setStatusUnauthorized(request);
            finalCompletion.failed(new SecurityException(error), null);
          }


          public void failed(Exception ex, Boolean result) {
            request.setBody(null);
            request.setStatusCode(500);
            String error = "Internal server error while authorizing request";
            finalCompletion.failed(new Exception(error), null);
          }
        });
  }

而以上通过设置Authorization的header可以绕过Authn而进行Authz认证。

代码执行

通过了以上认证绕过的请求已经拥有了管理员admin的权限,故此可以访问REST API。通过查询F5的官方文档得知可以通过POST请求到/mgmt/tm/util/bash地址来执行指令,请求格式为JSON。

{
    "command": "run",
    "utilCmdArgs": "-c id"
}

修复方法

通过比较修复前后的Jetty端代码可发现F5如何应对并修复的漏洞。

-  private static boolean setIdentityFromBasicAuth(RestOperation request) {
+
+
+  private static boolean setIdentityFromBasicAuth(final RestOperation request, final Runnable runnable) {
     String authHeader = request.getBasicAuthorization();
     if (authHeader == null) {
       return false;
     }
-    AuthzHelper.BasicAuthComponents components = AuthzHelper.decodeBasicAuth(authHeader);
-    request.setIdentityData(components.userName, null, null);
+    final AuthzHelper.BasicAuthComponents components = AuthzHelper.decodeBasicAuth(authHeader);
+
+
+
+
+
+    String xForwardedHostHeaderValue = request.getAdditionalHeader("X-Forwarded-Host");
+
+
+
+    if (xForwardedHostHeaderValue == null) {
+      request.setIdentityData(components.userName, null, null);
+      if (runnable != null) {
+        runnable.run();
+      }
+      return true;
+    }
+
+
+
+    String[] valueList = xForwardedHostHeaderValue.split(", ");
+    int valueIdx = (valueList.length > 1) ? (valueList.length - 1) : 0;
+    if (valueList[valueIdx].contains("localhost") || valueList[valueIdx].contains("127.0.0.1")) {
+
+      request.setIdentityData(components.userName, null, null);
+      if (runnable != null) {
+        runnable.run();
+      }
+      return true;
+    }
+
+
+    if (!PasswordUtil.isPasswordReset().booleanValue()) {
+      request.setIdentityData(components.userName, null, null);
+      if (runnable != null) {
+        runnable.run();
+      }
+      return true;
+    }
+
+    AuthProviderLoginState loginState = new AuthProviderLoginState();
+    loginState.username = components.userName;
+    loginState.password = components.password;
+    loginState.address = request.getRemoteSender();
+    RestRequestCompletion authCompletion = new RestRequestCompletion()
+      {
+        public void completed(RestOperation subRequest) {
+          request.setIdentityData(components.userName, null, null);
+          if (runnable != null) {
+            runnable.run();
+          }
+        }
+
+
+        public void failed(Exception ex, RestOperation subRequest) {
+          RestOperationIdentifier.LOGGER.warningFmt("Failed to validate %s", new Object[] { ex.getMessage() });
+          if (ex.getMessage().contains("Password expired")) {
+            request.fail(new SecurityException(ForwarderPassThroughWorker.CHANGE_PASSWORD_NOTIFICATION));
+          }
+          if (runnable != null) {
+            runnable.run();
+          }
+        }
+      };
+
+    try {
+      RestOperation subRequest = RestOperation.create().setBody(loginState).setUri(UrlHelper.makeLocalUri(new URI(TMOS_AUTH_LOGIN_PROVIDER_WORKER_URI_PATH), null)).setCompletion(authCompletion);
+
+
+      RestRequestSender.sendPost(subRequest);
+    } catch (URISyntaxException e) {
+      LOGGER.warningFmt("ERROR: URISyntaxEception %s", new Object[] { e.getMessage() });
+    }
     return true;
   }
 }

而通过阅读以上代码可以发现首先Jetty会解码并查看比较Basic Auth header的账号密码,后面也会查看发送请求的IP是否为localhost,最后也对密码和账户状态进行一系列的查看。

其他文献

出去本文提到的认证绕过导致的RCE,F5仍然还存在着SSRF导致的RCE漏洞,对于这个的漏洞分析可于https://attackerkb.com/topics/J6pWeg5saG/k03009991-icontrol-rest-unauthenticated-remote-command-execution-vulnerability-cve-2021-22986 找到。

参考链接

https://support.f5.com/csp/article/K02566623
https://support.f5.com/csp/article/K43371345
https://attackerkb.com/topics/J6pWeg5saG/k03009991-icontrol-rest-unauthenticated-remote-command-execution-vulnerability-cve-2021-22986

评论(1)

匿名

2021/03/19 11:53
你这不是还是要授权的吗,这分析的不是未授权rce那个洞吧

发表评论

captcha