JBOSS CVE-2017-12149 WAF绕过之旅

场景

在近期做一次红蓝的时候,发现了一个目标存在CVE-2017-12149,也就是/invoker/readonly的反序列。虽然存在WAF对POST BODY反序列关键字有一定的检测,将请求类型修改为PUT后就绕过了,刚好/invoker/readonly路由是一个filter,所以请求模式并不重要(随意写XXX都可以),也能够触发到,最终实现了RCE。

RCE后,在里面稍微折腾了一下就被防守方发现了。然后JBOSS就被下线系统,加强WAF不修漏洞,重新上线。
在重新上线后,再次对WAF测试了下,无论什么请求模式,只要请求/invoker/readonly 就会被拦截。/invoker/x/readonly、/invoker/x 不被拦截。当然也做了路径标准化,/invoker/x/../readonly、/invoker/./readonly,/invoker/readonlyxxxxx 都会被拦截。在折腾了一会发现绕不过去后,只能重新去看一下JBOSS代码,看看有没有什么其他方法来绕过。

JBOSS 其他路由触发反序列

在看代码之前,最想知道的就是CVE-2017-12149除了/invoker/readonly有没有其他的触发路由,所以最开始看web.xml。

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd">

<!-- The http-invoker.sar/invoker.war web.xml descriptor
$Id: web.xml 96504 2009-11-18 19:01:09Z scott.stark@jboss.org $
-->
<web-app>
<filter>
<filter-name>ReadOnlyAccessFilter</filter-name>
<filter-class>org.jboss.invocation.http.servlet.ReadOnlyAccessFilter</filter-class>
<init-param>
<param-name>readOnlyContext</param-name>
<param-value>readonly</param-value>
<description>The top level JNDI context the filter will enforce
read-only access on. If specified only Context.lookup operations
will be allowed on this context. Another other operations or lookups
on any other context will fail. Do not associate this filter with the
JMXInvokerServlets if you want unrestricted access.
</description>
</init-param>
<init-param>
<param-name>invokerName</param-name>
<param-value>jboss:service=NamingBeanImpl</param-value>
<description>The JMX ObjectName of the naming service mbean
</description>
</init-param>
</filter>

<filter-mapping>
<filter-name>ReadOnlyAccessFilter</filter-name>
<url-pattern>/readonly/*</url-pattern>
</filter-mapping>

<!-- ### Servlets -->
<servlet>
<servlet-name>EJBInvokerServlet</servlet-name>
<description>The EJBInvokerServlet receives posts containing serlized
MarshalledInvocation objects that are routed to the EJB invoker given by
the invokerName init-param. The return content is a serialized
MarshalledValue containg the return value of the inovocation, or any
exception that may have been thrown.
</description>
<servlet-class>org.jboss.invocation.http.servlet.InvokerServlet</servlet-class>
<init-param>
<param-name>invokerName</param-name>
<param-value>jboss:service=invoker,type=http</param-value>
<description>The RMI/HTTP EJB compatible invoker</description>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet>
<servlet-name>JMXInvokerServlet</servlet-name>
<description>The JMXInvokerServlet receives posts containing serlized
MarshalledInvocation objects that are routed to the invoker given by
the the MBean whose object name hash is specified by the
invocation.getObjectName() value. The return content is a serialized
MarshalledValue containg the return value of the inovocation, or any
exception that may have been thrown.
</description>
<servlet-class>org.jboss.invocation.http.servlet.InvokerServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>

<servlet>
<servlet-name>JNDIFactory</servlet-name>
<description>A servlet that exposes the JBoss JNDI Naming service stub
through http. The return content is a serialized
MarshalledValue containg the org.jnp.interfaces.Naming stub. This
configuration handles requests for the standard JNDI naming service.
</description>
<servlet-class>org.jboss.invocation.http.servlet.NamingFactoryServlet</servlet-class>
<init-param>
<param-name>namingProxyMBean</param-name>
<param-value>jboss:service=invoker,type=http,target=Naming</param-value>
</init-param>
<init-param>
<param-name>proxyAttribute</param-name>
<param-value>Proxy</param-value>
</init-param>
<load-on-startup>2</load-on-startup>
</servlet>

<servlet>
<servlet-name>ReadOnlyJNDIFactory</servlet-name>
<description>A servlet that exposes the JBoss JNDI Naming service stub
through http, but only for a single read-only context. The return content
is a serialized MarshalledValue containg the org.jnp.interfaces.Naming
stub.
</description>
<servlet-class>org.jboss.invocation.http.servlet.NamingFactoryServlet</servlet-class>
<init-param>
<param-name>namingProxyMBean</param-name>
<param-value>jboss:service=invoker,type=http,target=Naming,readonly=true</param-value>
</init-param>
<init-param>
<param-name>proxyAttribute</param-name>
<param-value>Proxy</param-value>
</init-param>
<load-on-startup>2</load-on-startup>
</servlet>

<!-- ### Servlet Mappings -->
<servlet-mapping>
<servlet-name>JNDIFactory</servlet-name>
<url-pattern>/JNDIFactory/*</url-pattern>
</servlet-mapping>
<!-- A mapping for the NamingFactoryServlet that only allows invocations
of lookups under a read-only context. This is enforced by the
ReadOnlyAccessFilter
-->
<servlet-mapping>
<servlet-name>ReadOnlyJNDIFactory</servlet-name>
<url-pattern>/ReadOnlyJNDIFactory/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>EJBInvokerServlet</servlet-name>
<url-pattern>/EJBInvokerServlet/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>JMXInvokerServlet</servlet-name>
<url-pattern>/JMXInvokerServlet/*</url-pattern>
</servlet-mapping>
<!-- A mapping for the JMXInvokerServlet that only allows invocations
of lookups under a read-only context. This is enforced by the
ReadOnlyAccessFilter
-->
<servlet-mapping>
<servlet-name>JMXInvokerServlet</servlet-name>
<url-pattern>/readonly/JMXInvokerServlet/*</url-pattern>
</servlet-mapping>

<!-- Alternate mappings that place the servlets under the restricted
path to required authentication for access. Remove the unsecure mappings
if only authenticated users should be allowed.
-->
<servlet-mapping>
<servlet-name>JNDIFactory</servlet-name>
<url-pattern>/restricted/JNDIFactory/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>JMXInvokerServlet</servlet-name>
<url-pattern>/restricted/JMXInvokerServlet/*</url-pattern>
</servlet-mapping>

<!-- An example security constraint that restricts access to the HTTP invoker
to users with the role HttpInvoker Edit the roles to what you want and
configure the WEB-INF/jboss-web.xml/security-domain element to reference
the security domain you want.
-->
<security-constraint>
<web-resource-collection>
<web-resource-name>HttpInvokers</web-resource-name>
<description>An example security config that only allows users with the
role HttpInvoker to access the HTTP invoker servlets
</description>
<url-pattern>/restricted/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
</web-resource-collection>
<auth-constraint>
<role-name>HttpInvoker</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>BASIC</auth-method>
<realm-name>JBoss HTTP Invoker</realm-name>
</login-config>

<security-role>
<role-name>HttpInvoker</role-name>
</security-role>
</web-app>

可以发现存在以下路由,
/invoker/JNDIFactory
/invoker/ReadOnlyJNDIFactory
/invoker/EJBInvokerServlet
/invoker/JMXInvokerServlet
/invoker/readonly/JMXInvokerServlet
/invoker/restricted/JNDIFactory
/invoker/restricted/JMXInvokerServlet

包含JNDIFactory的路由,都是触发的org.jboss.invocation.http.servlet.NamingFactoryServlet,该Servlet没有反序列,直接抛弃。
/invoker/EJBInvokerServlet
/invoker/JMXInvokerServlet
/invoker/readonly/JMXInvokerServlet # 存在/invoker/readonly 也直接被拦截
/invoker/restricted/JMXInvokerServlet
以上Servlet都能够触发到反序列,一个一个测试,发现竟然只有最后一个没被拦截。


但是最后一个路由访问是返回401, 在web.xml中有对/restricted/路由做验证,必须在登录之后才能触发,看起来是没毛用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<security-constraint>
<web-resource-collection>
<web-resource-name>HttpInvokers</web-resource-name>
<description>An example security config that only allows users with the
role HttpInvoker to access the HTTP invoker servlets
</description>
<url-pattern>/restricted/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
</web-resource-collection>
<auth-constraint>
<role-name>HttpInvoker</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>BASIC</auth-method>
<realm-name>JBoss HTTP Invoker</realm-name>
</login-config>

<security-role>
<role-name>HttpInvoker</role-name>
</security-role>

在网上流传的CVE-2017-12149修复方法中,其中一个方法就是修改security-constraint的url-pattern为/*,这样/invoker/readonly也需要在登录后才能够使用,缓解了漏洞。

在来仔细看一下JBOSS的security-constraint,很明显能够看出只对GET/POST请求模式进行验证,如果非GET/POST那么还是能够访问到/invoker/restricted/JMXInvokerServlet,但是因为这个路由是Servlet不是Filter,所以不能够随意修改请求模式,请求模式必须是RFC 2068里所定义的GET/POST/PUT/DELETE等。
再来看一下这个Servlet的代码,如果实现了doPut等其他方法并且存在反序列漏洞,那么还是可以绕过这个认证实现反序列。

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
protected void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
boolean trace = log.isTraceEnabled();
if (trace) {
log.trace("processRequest, ContentLength: " + request.getContentLength());
log.trace("processRequest, ContentType: " + request.getContentType());
}

Boolean returnValueAsAttribute = (Boolean)request.getAttribute("returnValueAsAttribute");

try {
response.setContentType(RESPONSE_CONTENT_TYPE);
MarshalledInvocation mi = (MarshalledInvocation)request.getAttribute("MarshalledInvocation");
if (mi == null) {
ServletInputStream sis = request.getInputStream();
ObjectInputStream ois = new ObjectInputStream(sis);
mi = (MarshalledInvocation)ois.readObject();
ois.close();
}

if (mi.getPrincipal() == null && mi.getCredential() == null) {
mi.setPrincipal(InvokerServlet.GetPrincipalAction.getPrincipal());
mi.setCredential(InvokerServlet.GetCredentialAction.getCredential());
}

Object[] params = new Object[]{mi};
String[] sig = new String[]{"org.jboss.invocation.Invocation"};
ObjectName invokerName = this.localInvokerName;
if (invokerName == null) {
Integer nameHash = (Integer)mi.getObjectName();
invokerName = (ObjectName)Registry.lookup(nameHash);
if (invokerName == null) {
throw new ServletException("Failed to find invoker name for hash(" + nameHash + ")");
}
}

Object value = this.mbeanServer.invoke(invokerName, "invoke", params, sig);
if (returnValueAsAttribute != null && returnValueAsAttribute) {
request.setAttribute("returnValue", value);
} else {
MarshalledValue mv = new MarshalledValue(value);
ServletOutputStream sos = response.getOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(sos);
oos.writeObject(mv);
oos.close();
}
} catch (Throwable var13) {
Throwable t = JMXExceptionDecoder.decode(var13);
if (t instanceof InvocationTargetException) {
InvocationTargetException ite = (InvocationTargetException)t;
t = ite.getTargetException();
}

InvocationException appException = new InvocationException(t);
if (returnValueAsAttribute != null && returnValueAsAttribute) {
log.debug("Invoke threw exception", t);
request.setAttribute("returnValue", appException);
} else if (response.isCommitted()) {
log.error("Invoke threw exception, and response is already committed", t);
} else {
response.resetBuffer();
MarshalledValue mv = new MarshalledValue(appException);
ServletOutputStream sos = response.getOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(sos);
oos.writeObject(mv);
oos.close();
}
}

}

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.processRequest(request, response);
}

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.processRequest(request, response);
}

很可惜在该Servlet中只实现了doGet和doPost方法,所以不能像预想的PUT绕过。不过观察代码发现反序列在processRequest方法中触发,在doGet和doPost中都调用了processRequest方法。这就有点意思了,GET也能触发反序列。

1
2
3
4
else if (method.equals("HEAD")) {
lastModified = this.getLastModified(req);
this.maybeSetLastModified(resp, lastModified);
this.doHead(req, resp);
1
2
3
4
5
6
7
8
protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (DispatcherType.INCLUDE.equals(req.getDispatcherType())) {
this.doGet(req, resp);
} else {
NoBodyResponse response = new NoBodyResponse(resp);
this.doGet(req, response);
response.setContentLength();
}

虽然GET也需要认证,但是给Servlet发送Head请求时触发的还是Servlet的doGet方法,只是NoBodyResponse,同时security-constraint中没有限制HEAD方法,所以可以绕过登录实现反序列。同时因为Head是NobodyResponse,所以需要直接回显命令结果的话,可以将执行结果添加到Response Headers中。

最终通过该方法绕过了WAF :)