基于Tomcat容器的内存Shell

内存Shell,顾名思义,存在于内存中的shell。近些年随着攻防对抗的升级,传统的Webshell攻击技术因存在需落地文件、木马特征明显等特点,在实战环境中往往容易受制于人,使得攻击方在对抗处于劣势。另一方面,以SpringBoot、SpringMVC为首的Java Web技术栈在Web 2.0中应用广泛,大量的依赖组件衍生出多种漏洞攻击方式,同时模版引擎也逐渐更新换代,这都进一步加速了文件Webshell的淘汰。自此,内存Webshell技术开始被深入研究,大行其道。

本文主要总结基于Tomcat容器的内存shell实现方案。

内存shell原理

内存shell实现上本质是Hook(在正常程序流中添加额外的控制流程),Hook的对象是Tomcat对Http请求的处理流程,我们可以通过多种方式在这一复杂的处理流程中插入内存shell的代码逻辑。 原理层面需要了解的有两点,如何Hook及其具体在哪些位置Hook。

如何Hook?

根据不同的Hook方式,我把内存Shell的实现分为以下两类:

  • 基于Servlet API
    • Listener型
    • Filter型
    • Servlet型
  • 基于Java Instrumentation API
    • Agent参数(premain方法)
    • Attach进程(agentmain方法)

基于Servlet API

Listener、Filter、Servlet是Tomcat在处理Http请求时会依次调用的三个组件。

  • Listener:监听器,Servlet规范中定义的一种特殊类,它用于监听web应用程序中的ServletContext、HttpSession和 ServletRequest这三类对象的创建、销毁以及属性修改的事件,当事件发生时可执行Listener内响应代码;
  • Filter:过滤器,在请求到达Servlet之前或响应返回客户端之前,对请求或响应进行修改;
  • Servlet:处理请求,并生成对应的响应。

我们可以在代码执行环境中自定义Listener、Filter、Servlet类,生成对应的对象并将其添加到当前容器的上下文中,实现对Http请求处理流程的Hook。

基于Java Instrumentation API

Instrumentation接口位于Jdk java.lang.instrument包下,可以用来监控和扩展JVM上运行的应用程序,提供一套代理机制,支持独立于JVM应用程序之外的程序以代理的方式连接和访问JVM。

我们可以使用Java的Instrumentation接口(java.lang.instrument)编写Agent,在目标JVM启动或运行时加载Agent来替换、修改Tomcat中某些关键Java类的定义,相当于是JVM层面的Hook。

Hook实现位置?

首先要了解Tomcat处理请求的具体流程,Tomcat组成如下图,主要由Container 和 Connector 以及相关组件构成:

Server:指的就是整个Tomcat服务器,包含多组服务,负责管理和启动各个 Service,同时监听 8005端口发过来的 shutdown 命令,用于关闭整个容器 ;

Service:Tomcat封装的、对外提供完整的、基于组件的web服务,包含Connectors、Container两个核心组件,以及多个功能组件,各个Service之间是独立的,但是共享同一 JVM的资源;

Connector:Tomcat与外部世界的连接器,监听固定端口接收外部请求,传递给 Container,并 将 Container 处理的结果返回给外部;

Container:Catalina,Servlet 容器,内部有多层容器组成,用于管理Servlet生命周期,调用servlet相关方法;

Loader:封装了 Java ClassLoader,用于 Container加载类文件;

Realm:Tomcat 中为 web应用程序提供访问认证和角色管理的机制;

JMX:Java SE 中定义技术规范,是一个为应用程序、设备、系统等植入管理功能的框架,通过JMX可以远程监控 Tomcat的运行状态,我们可以利用其获取运行时状态的Tomcat中的对象;

Jasper:Tomcat的Jsp解析引擎,用于将Jsp转换成Java文件,并编译成class文件;

Session:负责管理和创建session,以及Session的持久化(可自定义),支持session的集群;

Pipeline:在容器中充当管道的作用,管道中可以设置各种 valve(阀门),请求和响应在经由管道中各个阀门处理,提供了一种灵活可配置的处理请求和响应的机制;

Naming:命名服务,JNDI, Java命名和目录接口,是一组在Java应用中访问命名和目录服务的API。命名服务将名称和对象联系起来,使得我们可以用名称访问对象,目录服务也是一种命名 服务,对象不但有名称,还有属性。Tomcat 中可以使用 JNDI定义数据源、配置信息,用于开发与部署的分离。

其中我们Hook的调用点主要在Container组件内,其组成图如下:

Engine:Servlet的顶层容器,包含一 个或多个Host子容器;
Host:虚拟主机,负责 web 应用的部署和 Context的创建;
Context:Web应用上下文,包含多个 Wrapper,负责 web配置的解析、管理所有的Web资源;
Wrapper:最底层的容器,是对 Servlet的封装,负责Servlet实例的创建、执行和销毁。

整体的请求处理流程如下图所示:

  1. Connector组件的Acceptor监听客户端套接字连接并接收Socket;
  2. 将连接交给线程池Executor处理,开始执行请求响应任务;
  3. Processor组件读取消息报文,解析请求行、请求体、请求头,封装成Request对象;
  4. Mapper组件根据请求行的URL值和请求头的Host值匹配对应的Host容器、Context容器、Wrapper容器来处理请求;
  5. CoyoteAdaptor组件负责将Connector组件和Engine容器关联起来,把生成的Request对象和响应对象Response传递到Engine容器中,调用 Pipeline;
  6. Engine容器的管道开始处理,管道中包含若干个Valve、每个Valve负责部分处理逻辑。执行完Valve后会执行基础的 Valve——StandardEngineValve,负责调用Host容器的Pipeline;
  7. Host容器的管道开始处理,流程类似,最后执行基础Valve的invoke方法—StandardHostValve#invoke()调用Context容器的Pipeline;
  8. Context容器的管道开始处理,流程类似,最后执行基础Valve的invoke方法—StandardContextValve#invoke()调用Wrapper容器的Pipeline;
  9. Wrapper容器的管道开始处理,流程类似,执行基础Valve的invoke方法StandardWrapperValve#invoke(),其中会获取匹配该请求的所有过滤器并依次执行,最后调用Wrapper容器对应servlet对象的处理方法service()。

基于Servlet API的内存shell中,Listener的调用取决于事件类型,以ServletRequestListener为例,当Tomcat执行到StandardHostValve#invoke()时,会获取存储在StandardContext.ApplicationEventListeners中的监听器,并遍历调用我们继承于ServletRequestListener的自定义Listener的requestInitialized(),即上述步骤中7。Filter和Servlet的调用均在StandardWrapperValve#invoke()中,即上述步骤中9。

基于Java Instrumentation API的内存shell中,理论上可以Hook上述步骤中多个点,但为避免影响Tomcat正常运行,应尽可能修改后面步骤中的关键类。本文中的相关实现参考 https://github.com/rebeyond/memShell 修改类org.apache.catalina.core.ApplicationFilterChain,该类中的internalDoFilter()在上述步骤中9被调用。

内存shell实现

基于Servlet API实现

以servlet型内存shell为例,原理一节中提到,其本质上是在请求处理流程中加入一个能针对特定请求执行内存shell代码逻辑的servlet。

基于动态注册

Servlet 3.0引入了动态注册的新特性,对应地,在Tomcat 7.0.x及以上版本中,我们可以通过ServletContext接口中相关方法实现动态注册Servlet、Filter和Listener。

代码示例:

    // Register Servlet
    ServletRegistration sr = servletContext.addServlet("DynamicServlet",
      "web.servlet.dynamicregistration.TestServlet");
    sr.setInitParameter("servletInitName", "servletInitValue");
    sr.addMapping("/*");

    // Register Filter
    FilterRegistration fr = servletContext.addFilter("DynamicFilter",
      "web.servlet.dynamicregistration.TestFilter");
    fr.setInitParameter("filterInitName", "filterInitValue");
    fr.addMappingForServletNames(EnumSet.of(DispatcherType.REQUEST),
                   true, "DynamicServlet");

    // Register ServletRequestListener
    servletContext.addListener("web.servlet.dynamicregistration.TestServletRequestListener");

首先解释下什么是ServletContext,ServletContext是javax.servlet包内定义的接口,Web容器会为每个Web程序构造一个实现该接口的对象实例,通过这个对象,Servlet可以和web容器进行交互,如获取Web容器版本号,通过Web容器的日志机制记录信息等。ServletContext中用于动态注册的相关方法及参数如下:

    /**
    * Add servlet
    */
    public ServletRegistration.Dynamic addServlet(String servletName, String className);
    public ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet);
    public ServletRegistration.Dynamic addServlet(String servletName,
            Class<? extends Servlet> servletClass);

    /**
    * Add filter
    */
    public FilterRegistration.Dynamic addFilter(String filterName, String className);
    public FilterRegistration.Dynamic addFilter(String filterName, Filter filter);
    public FilterRegistration.Dynamic addFilter(String filterName,
            Class<? extends Filter> filterClass);

    /**
    * Add listener
    */
    public void addListener(String className);
    public <T extends EventListener> void addListener(T t);
    public void addListener(Class<? extends EventListener> listenerClass);

但是上述方法只能在容器初始化时进行注册,运行时调用却会抛出异常,而注入内存shell往往需要在容器运行时动态注册。Tomcat中对应ServletContext接口的实现类是org.apache.catalina.core.ApplicationContext,ApplicationContext与此前提到的StandardContext是两个概念,但两个类定义中都有变量用于互相引用,也可以说两者是相互包含的关系。

以Tomcat 8.5.51为例,分析addServlet方法实现:

由912行可知,首先会判断Tomcat运行状态,若非LifecycleState.STARTING_PREP,则会抛出异常,这是为什么只能在容器初始化时进行注册。后面的代码逻辑可概括为:从context中取出servlet对应的wrapper,用context和wrapper构造一个ApplicationServletRegistration对象,由其构造方法可知,此处的context对象为StandardContext类。

为绕过动态注册对Tomcat运行状态的限制,可以在调用addServlet()等方法前通过反射修改Tomcat当前状态变量,也可以不调用addServlet()等方法,重新实现其内部关键代码逻辑来完成动态注册,这里采用第二种方式。

以servlet型内存shell为例,其完整的实现步骤如下:

  1. 通过JMX获取StandardContext对象;

    JMX中多个mbean都可以获取到StandardContext对象,例如:

  1. 定义含内存shell代码逻辑的Servlet类,将其实例封装为Wrapper对象;

  1. 以StandardContext对象和Wrapper对象为变量构造ApplicationServletRegistration对象,调用其addMapping方法,添加url到servlet的映射;

基于反射

动态注册的相关API包括ApplicationServletRegistration等类,是Servlet 3.0新增的,适用于Tomcat 7.0.x以上版本,那么对于Tomcat 6.x,该如何实现内存shell呢?其实这个问题近乎等同于动态注册的相关类/方法是如何实现。

同样以Tomcat 8.5.51中动态注册servlet为例,ServletContext中的addServlet方法已经分析过了,接下来分析ApplicationServletRegistration类中的addMapping方法,其进一步调用了StandardContext对象的addServletMappingDecoded方法:

动态调试的调用栈如下图:

由调用栈可知,其最终调用的是org.apache.catalina.mapper.Mapper类的addWrapper方法。Mapper正是负责URL到servlet的路由规则,对应原理一节中Tomcat的请求处理流程步骤4。

在addWrapper方法中实现了URL到封装了内存shell servlet的Wrapper对象的映射。

访问对应的URL时,会被路由到封装了内存shell servlet的Wrapper容器。

以上是Tomcat 8.5.51中动态注册servlet的实现,虽然Tomcat 6.x中涉及到部分类定义的改变,但实现原理也是类似,其完整实现步骤如下:

  1. 通过JMX获取StandardContext、Mapper对象(org.apache.tomcat.util.http.mapper.Mapper);

  1. 定义含内存shell代码逻辑的Servlet类,将其实例封装为Wrapper对象;

  1. 调用Mapper中addWrapper方法,将Wrapper对象添加到StandardContext中,并建立其与URL的映射关系。

基于Java Instrumentation 实现

Agent实现流程

Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:

[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);

JVM将首先寻找[1],如果没有发现[1],再寻找[2]。如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:

[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);

这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。

指定Main-Class,Agent需要打包成一个jar包,在ManiFest属性中指定“Premain-Class”或者“Agent-Class”:

Premain-Class: class
Agent-Class: class

也可通过Maven插件自动生成ManiFest文件:

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>2.5.5</version>
    <configuration>
        <finalName>${project.artifactId}-${project.version}-all</finalName>
        <appendAssemblyId>false</appendAssemblyId>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifest>
                <mainClass></mainClass>
            </manifest>
            <manifestEntries>
                <Premain-Class>agent.instrumentation.Agent</Premain-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。如果选择在目标JVM启动时加载Agent,则可以使用 “-javaagent:”参数,具体的使用方法可以使用“Java -Help”来查看。

如果想要在运行时挂载Agent到目标JVM,可以参考下面的demo。

com.sun.tools.attach.VirtualMachine 这个类代表一个JVM对象,可以通过这个类找到目标JVM,并且将Agent挂载到目标JVM上。下面是使用com.sun.tools.attach.VirtualMachine进行动态挂载Agent的一般实现:

    private void attachAgentToTargetJVM() throws Exception {
        List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();
        VirtualMachineDescriptor targetVM = null;
        for (VirtualMachineDescriptor descriptor : virtualMachineDescriptors) {
            if (descriptor.id().equals(configure.getPid())) {
                targetVM = descriptor;
                break;
            }
        }
        if (targetVM == null) {
            throw new IllegalArgumentException("could not find the target jvm by process id:" + configure.getPid());
        }
        VirtualMachine virtualMachine = null;
        try {
            virtualMachine = VirtualMachine.attach(targetVM);
            virtualMachine.loadAgent("{agent}", "{params}");
        } catch (Exception e) {
            if (virtualMachine != null) {
                virtualMachine.detach();
            }
        }
    }

首先通过指定的进程ID找到目标JVM,然后通过Attach挂载到目标JVM上,执行加载Agent操作。VirtualMachine的Attach方法就是用来将Agent挂载到目标JVM上去的,而Detach则是将Agent从目标JVM卸载。

类增强

以目标JVM启动时加载Agent为例,premain方法实现如下:

Tomcat执行main方法前会先执行premain方法,走instrumentation代理程序,在应用程序加载类的时候就会进入自定义的ClassFileTransformer实现类ShellTransformer中调用transform方法对要加载的类进行修改,

其代码实现如下图:

transform方法中使用javassist框架对org.apache.catalina.core.ApplicationFilterChain类进行修改,在internalDoFilter方法执行前加入内存shell代码:首先判断uri是否是内存shell地址,若是则执行内存shell的代码逻辑。

适配冰蝎

本文中内存shell的代码逻辑均基于冰蝎webshell改造,其webshell实现如下:

当改造用于内存shell时,会遇到一个通用问题,即pageContext为Jsp内置变量,无法在代码执行的上下文中获取到,有两种方法可以解决:

  1. 重新生成一个pageContext对象

    JspFactory.getDefaultFactory().getPageContext(this, request, response, null, true, 8192, true)
    

    这种方式的优势在于只需要修改server端的内存shell实现,但JspFactory在javax.servlet.jsp包下,无法在SpringBoot相关的漏洞环境中使用。

  2. 修改客户端冰蝎代码

    修改equals方法参数为包含Request、Response、Session的数组,不再传递PageContext对象。

内存shell持久化

内存shell不落地具有天然的隐藏优势,但若目标JVM重启,则内存shell即失效,可配合Agent型内存shell实现重启后自动注入。以启动时加载Agent来完成内存shell持久化为例,可以修改Tomcat启动脚本(startup.bat、start.sh),加入“javaagent:persist.jar”参数来加载用于内存shell持久化的persist.jar。

参考

Java 动态调试技术原理及实践

JSP Webshell那些事 -- 攻击篇(下)

Tomcat处理请求流程

基于Tomcat无文件Webshell研究

利用“进程注入”实现无文件不死webshell

posted on 2021-03-05 19:16  Welk1n  阅读(44)  评论(0)    收藏  举报

导航