基于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实例的创建、执行和销毁。
整体的请求处理流程如下图所示:

- Connector组件的Acceptor监听客户端套接字连接并接收Socket;
- 将连接交给线程池Executor处理,开始执行请求响应任务;
- Processor组件读取消息报文,解析请求行、请求体、请求头,封装成Request对象;
- Mapper组件根据请求行的URL值和请求头的Host值匹配对应的Host容器、Context容器、Wrapper容器来处理请求;
- CoyoteAdaptor组件负责将Connector组件和Engine容器关联起来,把生成的Request对象和响应对象Response传递到Engine容器中,调用 Pipeline;
- Engine容器的管道开始处理,管道中包含若干个Valve、每个Valve负责部分处理逻辑。执行完Valve后会执行基础的 Valve——StandardEngineValve,负责调用Host容器的Pipeline;
- Host容器的管道开始处理,流程类似,最后执行基础Valve的invoke方法—StandardHostValve#invoke()调用Context容器的Pipeline;
- Context容器的管道开始处理,流程类似,最后执行基础Valve的invoke方法—StandardContextValve#invoke()调用Wrapper容器的Pipeline;
- 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为例,其完整的实现步骤如下:
-
通过JMX获取StandardContext对象;
JMX中多个mbean都可以获取到StandardContext对象,例如:

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

- 以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中涉及到部分类定义的改变,但实现原理也是类似,其完整实现步骤如下:
- 通过JMX获取StandardContext、Mapper对象(org.apache.tomcat.util.http.mapper.Mapper);

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

- 调用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内置变量,无法在代码执行的上下文中获取到,有两种方法可以解决:
-
重新生成一个pageContext对象
JspFactory.getDefaultFactory().getPageContext(this, request, response, null, true, 8192, true)这种方式的优势在于只需要修改server端的内存shell实现,但JspFactory在javax.servlet.jsp包下,无法在SpringBoot相关的漏洞环境中使用。
-
修改客户端冰蝎代码
修改equals方法参数为包含Request、Response、Session的数组,不再传递PageContext对象。
内存shell持久化
内存shell不落地具有天然的隐藏优势,但若目标JVM重启,则内存shell即失效,可配合Agent型内存shell实现重启后自动注入。以启动时加载Agent来完成内存shell持久化为例,可以修改Tomcat启动脚本(startup.bat、start.sh),加入“javaagent:persist.jar”参数来加载用于内存shell持久化的persist.jar。
浙公网安备 33010602011771号