前言
前两天在赛博群里看大家讨论这个洞讨论的火热,简简单单的poc,轻轻松松的执行命令,分分钟杀穿BIGIP,看大家发的文章涉及java层的比较少,正巧这个月没写啥文章,蹭一波热度吧。哈哈哈。
环境搭建
进入产品下载页面。https://downloads.f5.com/esd/productlines.jsp
这里我们找一个存在漏洞的版本下载。
下载13.1.3.6_Virtual-Edition版本
https://downloads.f5.com/esd/ecc.sv?sw=BIG-IP&pro=big-ip_v13.x&ver=13.1.3&container=13.1.3.6_Virtual-Editio
下载BIGIP-13.1.3.6-0.0.4.ALL-vmware.ova
https://downloads.f5.com/esd/serveDownload.jsp?path=/big-ip/big-ip_v13.x/13.1.3/english/13.1.3.6_virtual-edition/&sw=BIG-IP&pro=big-ip_v13.x&ver=13.1.3&container=13.1.3.6_Virtual-Edition&file=BIGIP-13.1.3.6-0.0.4.ALL-vmware.ova
接着选择下载链接。
气抖冷,为啥没有中国。这里我从新加坡下载速度还不错。下载链接如下:
https://downloads-sin-f5.s3.ap-southeast-1.amazonaws.com/big-ip/big-ip_v13.x/13.1.3/english/13.1.3.6_virtual-edition/BIGIP-13.1.3.6-0.0.4.ALL-vmware.ova?response-content-disposition=attachment%3B%20filename%3DBIGIP-13.1.3.6-0.0.4.ALL-vmware.ova&X-Amz-Security-Token=FwoGZXIvYXdzEDIaDK71xrWQ3UmgkAdvXiKCAVWaVR6sN0%2B2BXWF%2FYndZRIPyyO0NL1z04ni754BR7SkbrHY%2FYYJn2SwC3hZBET8yi6XWpCt0mEIMWHZt3SUn5hyYcDkE%2FO3fznwuVsqKdGLX6x0rmHO0Rxm3%2FUuJT1wWzHFr%2BDb4nRggX1bg8pPJny4HAtUVuxOVsucdDwc3RI%2BwHcozaLjkwYyKISafETtiqoumXaqokpUe1kgDC63JzFxD68HbTMKc6I2werJf%2Breeto%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20220509T083110Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86398&X-Amz-Credential=ASIAWZEHK3GDE5X4S3GD%2F20220509%2Fap-southeast-1%2Fs3%2Faws4_request&X-Amz-Signature=03b2b613a6b07a9df8174fc95d77da92e72acebf48f51818b581835f7f531e6f
下载后将其解压下来,使用VMware导入。
导入成功,启动。
登录需要密码,经过在网上查询。默认的用户名是 root,密码是default.
使用ifconfig命令查看发现是ipv6没有ipv4 查看/etc/sysconfig/network-scripts/ifcfg-eth0
内容,发现BOOTROTO内容为static 改为dhcp 如下图
使用config命令更新网络设置,如下图
得到ip。使用xshell连接可以更方便的进行操作。随后我发现了一个问题,那就是访问不了80端口。在网上查阅资料发现走的是https协议,怪不得访问不到 访问https://192.168.50.242/tmui/login.jsp出现登录界面 至此环境准备结束。
漏洞验证
根据网上的poc,尝试发送请求结果直接就执行了命令。
接着具体分析漏洞。
漏洞分析
进行漏洞分析首先需要先定位漏洞点。根据poc发现漏洞是存在于443端口的https的服务上,接着ssh登录上机器,看看443端口绑定的什么服务。
发现是httpd服务。这里看一下httpd服务的配置文件,来找到具体是谁在处理数据。httpd的配置文件叫httpd.conf 这里用find搜索一下有两个结果,再仔细看一下。
文件位于/var/run/config/httpd.conf
仔细检查配置文件。
这里注意一点。AuthPAM开启,说明调用了httpd的某个so文件进行预先的认证。
另外就是发向mgmt的请求,都是8100端口的服务处理的。看一下8100端口是什么服务。
位于此处的是一个java服务。以下为classpath:
classpath :/usr/share/java/rest/f5.rest.adc.bigip.jar:/usr/share/java/rest/f5.rest.adc.shared.jar:/usr/share/java/rest/f5.rest.asm.jar:/usr/share/java/rest/f5.rest.icr.jar:/usr/share/java/rest/f5.rest.jar:/usr/share/java/rest/libs/axis-1.1.jar:/usr/share/java/rest/libs/bcpkix-1.59.jar:/usr/share/java/rest/libs/bcprov-1.59.jar:/usr/share/java/rest/libs/commons-discovery.jar:/usr/share/java/rest/libs/commons-exec-1.3.jar:/usr/share/java/rest/libs/commons-io-1.4.jar:/usr/share/java/rest/libs/commons-lang.jar:/usr/share/java/rest/libs/commons-logging.jar:/usr/share/java/rest/libs/concurrent-trees-2.5.0.jar:/usr/share/java/rest/libs/f5.asmconfig.jar:/usr/share/java/rest/libs/f5.rest.mcp.mcpj.jar:/usr/share/java/rest/libs/f5.rest.mcp.schema.jar:/usr/share/java/rest/libs/f5.soap.licensing.jar:/usr/share/java/rest/libs/federation.jar:/usr/share/java/rest/libs/gson-2.6.2.jar:/usr/share/java/rest/libs/icrd-src.jar:/usr/share/java/rest/libs/icrd.jar:/usr/share/java/rest/libs/jaxrpc-1.1.jar:/usr/share/java/rest/libs/joda-time-2.9.4.jar:/usr/share/java/rest/libs/jsch-0.1.53.jar:/usr/share/java/rest/libs/json_simple.jar:/usr/share/java/rest/libs/libthrift.jar:/usr/share/java/rest/libs/log4j.jar:/usr/share/java/rest/libs/lucene-analyzers-common-4.10.4.jar:/usr/share/java/rest/libs/lucene-core-4.10.4.jar:/usr/share/java/rest/libs/lucene-facet-4.10.4.jar:/usr/share/java/rest/libs/odata4j-0.7.0-core.jar:/usr/share/java/rest/libs/quartz-2.2.1.jar:/usr/share/java/rest/libs/slf4j-api.jar:/usr/share/java/rest/libs/slf4j-log4j12.jar:/usr/share/java/rest/libs/wsdl4j-1.1.jar:/usr/share/java/f5-avr-reporter-api.jar:/usr/share/java/commons-codec.jar com.f5.rest.workers.RestWorkerHost
包这么多,如何定位到出现问题的位置呢?这里我去掉了header的Connection中的X-F5-Auth-Token这样的话java就会产生报错打印出堆栈信息。如下图所示.
通过前面的classpath把载入的java包下载下来进行分析。使用反编译工具简单看一下jar包,这里我使用的是jadx。报错中主要涉及的库,位于f5.rest.jar中。
根据经验来看,底层的栈都是错误处理,真正出现问题的地方在中层。例如以下这个看着就离事发地点很近。
at com.f5.rest.workers.storage.StorageWorker.onQuery(StorageWorker.java:235)",
以下为函数体
从currentGenerationMap 取不到storageKey对应的值引发报错。随后往上层栈翻了翻没找到鉴权相关流程。这里切换一下思路。我们找一找X-F5-Auth-Token的处理流程。找到三个和X-F5-Auth-Token相关的代码。
public static final String ACCESS_CONTROL_ALLOW_HEADERS_VALUE = "X-F5-Auth-Token, X-F5-REST-Coordination-Id, X-Auth-Token, X-Forwarded-For, X-F5-Gossip, Authorization, Cookie, Content-Length, Content-Range, Content-Type, User-Agent";
//该变量设置了准许头的值
public static final String X_F5_AUTH_TOKEN_HEADER = "X-F5-Auth-Token";
public static final String X_F5_AUTH_TOKEN_HEADER_WITH_COLON = "X-F5-Auth-Token:"
查找变量X_F5_AUTH_TOKEN_HEADER的引用发现其在buildRequestHeaders函数中使用。整个函数的作用,就是提取请求的头。以字符串把请求返回去。看看另一个变量的引用,存在这么一段代码:
public String getName() {
return RestOperation.X_F5_AUTH_TOKEN_HEADER_WITH_COLON;
}
public void setData(RestOperation operation, String value) {
operation.setXF5AuthToken(value);
}
public boolean quickCheck(StringBuilder headerLine) {
if (!HttpParserHelper.matchesOneChar(headerLine.charAt(2), 'F', 'f') || HttpParserHelper.matchesHeaderPrefix(headerLine, getName())) {
return false;
}
return true;
}
getName获取参数名字 setData调用operation.setXF5AuthToken 设置值 quickCheck用于简单校验判断是不是这个header,核心代码如下
!HttpParserHelper.matchesOneChar(headerLine.charAt(2), 'F', 'f') || !HttpParserHelper.matchesHeaderPrefix(headerLine, getName()))
这么设计可能是为了节约时间?收起疑问继续分析。operation.setXF5AuthToken函数如下。
public RestOperation setXF5AuthToken(String token) {
setupAuthorizationData();
if (token == null) {
this.authorizationData.xF5AuthTokenState = null;
} else {
this.authorizationData.xF5AuthTokenState = new AuthTokenItemState();
this.authorizationData.xF5AuthTokenState.token = token;
}
return this;
}
该函数首先使用函数创建对象,具体实现代码如下
private void setupAuthorizationData() {
if (this.authorizationData == null) {
this.authorizationData = new AuthorizationData();
}
}
接着判断传入的参数token是否为空 为空把this.authorizationData.xF5AuthTokenState 设置为null。不为空则把值赋值给this.authorizationData.xF5AuthTokenState.token 。往下看发现了getXF5AuthToken和getXF5AuthTokenState方法。
public String getXF5AuthToken() {
if (this.authorizationData == null || this.authorizationData.xF5AuthTokenState == null) {
return null;
}
return this.authorizationData.xF5AuthTokenState.token;
}
public AuthTokenItemState getXF5AuthTokenState() {
if (this.authorizationData == null) {
return null;
}
return this.authorizationData.xF5AuthTokenState;
}
在鉴权过程中必然会使用这两个代码,因此查看这两个函数的引用即可。猜测鉴权过程中先调用getXF5AuthTokenState 确认是否有token 而后使用get获取token。(猜错了哈哈哈) 查看两个函数的引用,getXF5AuthToken的引用如下
重点看第三四个,是EvaluatePermissions的两个方法,英文中 evaluate 的含义是评估Permission 的含义是许可。进入其中查看其代码果然整个EvaluatePermissions是鉴权部分。整个EvaluatePermissions含有三个方法。evaluatePermission,completeEvaluatePermission,failPermissionValidation 其中failPermissionValidation代表鉴权失败,做一些失败后的数据设置
public static void failPermissionValidation(RestOperation request, String error) {
request.setWwwAuthenticate(RestOperation.X_AUTH_TOKEN_HEADER);
String deviceAuthCookie = request.getCookie(DeviceAuthTokenHelper.BIGIP_AUTH_COOKIE);
String authToken = request.getXF5AuthToken();
if (deviceAuthCookie != null && authToken == null) {
request.setWwwAuthenticate(RestOperation.BASIC_REALM_REST_API);
}
request.setBody((String) null);
request.setIsRestErrorResponseRequired(true);
request.setStatusCode(RestOperation.STATUS_UNAUTHORIZED);
request.fail(new SecurityException(error));
}
其中最核心的代码是completeEvaluatePermission函数,而漏洞也出在此处。evaluatePermission函数会根据情况不同,来为completeEvaluatePermission提供不同的参数。
这里可以看到根据authToken的值为completeEvaluatePermission赋予不同的参数。当authToken为null时,completeEvaluatePermission的token参数为空,这时候问题就来了,我们分析completeEvaluatePermission函数。以下是completeEvaluatePermission的部分代码
public static void completeEvaluatePermission(RestOperation request, AuthTokenItemState token, RolesWorker rolesWorker, CompletionHandler<Void> finalCompletion) {
final String path;
if (token != null) {
if (token.expirationMicros.longValue() < RestHelper.getNowMicrosUtc()) {
failPermissionValidation(request, "X-F5-Auth-Token has expired.");
finalCompletion.failed((Exception) null, null);
return;
}
request.setXF5AuthTokenState(token);
}
request.setBasicAuthFromIdentity();
if (!request.getUri().getPath().equals(EXTERNAL_LOGIN_WORKER) || !request.getMethod().equals(RestOperation.RestMethod.POST)) {
final RestReference userRef = request.getAuthUserReference();
if (RestReference.isNullOrEmpty(userRef)) {
failPermissionValidation(request, "Authorization failed: no user authentication header or token detected. Uri:" + request.getUri() + " Referrer:" + request.getReferer() + " Sender:" + request.getRemoteSender());
finalCompletion.failed((Exception) null, null);
} else if (AuthzHelper.isDefaultAdminRef(userRef)) {
finalCompletion.completed(null);
} else {
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(RestHelper.ODATA_EXPAND_FIELD) != null) {
String filterField = request.getParameter(RestHelper.ODATA_FILTER_FIELD);
if (USERS_GROUP_FILTER_STRING.equals(filterField) || USERGROUPS_GROUP_FILTER_STRING.equals(filterField)) {
finalCompletion.completed(null);
return;
}
}
if (token != null) {
if (path.equals(UrlHelper.buildUriPath(EXTERNAL_AUTH_TOKEN_WORKER_PATH, token.token))) {
finalCompletion.completed(null);
return;
}
}
第一步,判断token是否为空,当token不为空时,略过此流程,往下继续走。
request.setBasicAuthFromIdentity()函数方法如下
public void setBasicAuthFromIdentity() {
if (this.authorizationData != null) {
this.authorizationData.basicAuthValue = AuthzHelper.encodeBasicAuth(getAuthUser(), (String) null);
}
}
作用大致为,设置basicAuthValue的值为base64编码后的this.identityData.userName。继续往下走。
出现这样一段代码。
if (!request.getUri().getPath().equals(EXTERNAL_LOGIN_WORKER) || !request.getMethod().equals(RestOperation.RestMethod.POST)) {
大致作用为,对访问路径,请求包的方法进行校验,当我们请求的路径不是登录路径或者不是post方法时,进入后面的流程。接着执行
final RestReference userRef = request.getAuthUserReference();
if (RestReference.isNullOrEmpty(userRef)) {
failPermissionValidation(request, "Authorization failed: no user authentication header or token detected. Uri:" + request.getUri() + " Referrer:" + request.getReferer() + " Sender:" + request.getRemoteSender());
finalCompletion.failed((Exception) null, null);
} else if (AuthzHelper.isDefaultAdminRef(userRef)) {
finalCompletion.completed(null);
获取this.identityData.userReference的值给userRef,随后判断userRef的值是否为空,接着判断userRef是否是admin的userRef值。当为admin的userRef值时。进入finalCompletion.completed函数。从而导致了绕过鉴权。回过头来思考一个问题,this.identityData.userReference是从何获取的呢?通过查看该数据的引用。找到了函数setIdentityData。代码如下。
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(WellKnownPorts.AUTHZ_USERS_WORKER_URI_PATH, segment)))) {
userName = segment;
}
}
if (userName != null && RestReference.isNullOrEmpty(userReference)) {
userReference = new RestReference(UrlHelper.buildPublicUri(UrlHelper.buildUriPath(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;
}
这里分两种情况userReference和userName均为空以及userReference和userName均不为空。这两个变量是函数的参数,往上找上一层的函数。查看其引用发现com.f5.rest.common.RestOperationIdentifier有多次引用,看一下代码。其中有一个setIdentityFromBasicAuth方法。
private static boolean setIdentityFromBasicAuth(RestOperation request) {
String authHeader = request.getBasicAuthorization();
if (authHeader == null) {
return false;
}
request.setIdentityData(AuthzHelper.decodeBasicAuth(authHeader).userName, (RestReference) null, (RestReference[]) null);
return true;
}
看起来像是从header取得数据来进行初始化authHeader变量。实际上返回的是this.authorizationData.basicAuthValue的值,接着找设置该值的函数。也就是setBasicAuthorizationHeader函数,代码如下
public RestOperation setBasicAuthorizationHeader(String value) {
byte[] data;
setupAuthorizationData();
if (value != null && ((data = DatatypeConverter.parseBase64Binary(value)) == null || data.length == 0)) {
LOGGER.warningFmt("Basic Authorization header set to value that is invalid base64. Value: %s", value);
value = null;
}
this.authorizationData.basicAuthValue = value;
return this;
}
接着查看该函数在何处引用,传入的value值是什么。看到了熟悉的一串代码,类似从header的X-F5-Auth-Token提取数据初始化的过程。
BASIC_AUTH {
public String getName() {
return RestOperation.BASIC_AUTHORIZATION_HEADER_LOWERCASE;
}
public void setData(RestOperation operation, String value) {
operation.setBasicAuthorizationHeader(value);
}
public boolean quickCheck(StringBuilder headerLine) {
return HttpParserHelper.matchesOneChar(headerLine.charAt(0), 'A', 'a') && HttpParserHelper.matchesOneChar(headerLine.charAt(1), 'U', 'u') && HttpParserHelper.matchesHeaderPrefix(headerLine, getName());
}
},
查看getname返回的字段RestOperation.BASIC_AUTHORIZATION_HEADER_LOWERCASE定义的变量,代码如下:
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BASIC_AUTHORIZATION_HEADER = "Authorization: Basic ";
public static final int BASIC_AUTHORIZATION_HEADER_LENGTH = BASIC_AUTHORIZATION_HEADER.length();
public static final String BASIC_AUTHORIZATION_HEADER_LOWERCASE = BASIC_AUTHORIZATION_HEADER.toLowerCase();
也就是说该字段是由header的Authorization: Basic设置的。观察poc也存在此字段
Authorization: Basic YWRtaW46
这里我们尝试更改poc中的Authorization数据为dXNlcjo=,也就是user:的base64编码
果然不能执行命令了。YWRtaW46的base64解码正好是admin: 至此整个绕过的思路就清晰了,首先是当X-F5-Auth-Token为空时走入另一条验证流程,而这个流程依赖于我们给header提供的Authorization:字段。因为Authorization字段可控,并且没有复杂的加密处理,从而导致可以轻易绕过鉴权。接着就是如何设置X-F5-Auth-Token为空了。这里涉及到一个hop-by-hop headers abuse的漏洞,可以参考https://nathandavison.com/blog/abusing-http-hop-by-hop-request-headers。
简单来说就是。遇到Keep-Alive、Transfer-Encoding、TE、Connection、Trailer、Upgrade这些标头时,兼容的代理应该处理或操作这些标头所指示的任何内容,而不是将它们转发到下一个跃点。如我们的请求中带有header头:Connection: close, X-Foo, X-Bar,原始请求在转发到代理时,逐跳处理则会将X-Foo和 X-Bar从原始请求中删除。这样我们既通过了对X-F5-Auth-Token 标头的校验,同时又能使其在到达java处理流程时为空。而在实际测试中,我却发现这个和hop-by-hop参考文章里的又不一样。即使我不使用Keep-Alive、Transfer-Encoding、TE、Connection、Trailer、Upgrade这些标头。我一样可以执行命令如下图:
又或者
经过和忍酱、pcat、Zeddy、落沐萧萧等师傅的讨论,我大概理解了,应该是这样的,httpd服务当存在Connection:的时候,不论提供的参数是什么值都会产生逐跳,缺省参数会默认按keep-alive处理。
漏洞修复
具体修复参考官方提供的方法,https://support.f5.com/csp/article/K23605346。
一种是通过下载官方修补后的版本,也就是Fixes introduced的版本。
在https://downloads.f5.com/esd/productlines.jsp里选择最新的版本即可。
如果不想更新版本官方还提供了其他的缓解措施。
-
通过自身 IP 地址阻止 iControl REST 访问(https://support.f5.com/csp/article/K23605346#proc1) -
通过管理界面阻止 iControl REST 访问(https://support.f5.com/csp/article/K23605346#proc2) -
修改 BIG-IP httpd 配置(https://support.f5.com/csp/article/K23605346#proc3)
具体措施可以在官方页面查看。https://support.f5.com/csp/article/K23605346#proc1。
结语
此时是5.10日凌晨2.15终于写完这篇文章,后面状态不是特别好,并且个人对web的知识以及httpd的特性也不是特别了解,如果写的有什么问题欢迎大佬们帮我纠正。
参考文献
https://nathandavison.com/blog/abusing-http-hop-by-hop-request-headers
https://nosec.org/home/detail/4722.html
https://mp.weixin.qq.com/s/6gVZVRSDRmeGcNYjTldw1Q
原文始发于微信公众号(BeFun安全实验室):CVE-2022-1388漏洞分析