安全中间件的设计思路和简单实践

最近安全中间件慢慢走热,而技术设计类似的rasp技术火热的同时面临推广落地的问题:

rasp的侵入式特性和拦截特性导致开发和运维普通不太愿意配合,当生产环境出现问题时往往第一时间先把责任推给rasp,逐渐的安全部门普遍只能把rasp设置为告警模式,而且越是大的集群拦截开的就越少,所以字节的elkeid和某外卖大厂内部的rasp都是告警模式,没有发挥rasp的实际作用。相反的深圳某体制内企业他们的信息系统大部分都是采购的,并采取自研的策略,但是这个企业对外每开放一个端口,都强制要求安装网防G01进行管控。

我思考这个问题得出的答案是:
本质上rasp是安全部门推动的,对业务性来说代码可控力度较弱,强侵入性和强拦截性导致只有话语权较强的企业才能完整落地。尤其排查问题的成本实在过高,所以导致开发、运维、安全三方技术力量在面对生产环境问题时很容易扯皮,最终rasp面临的不是减少拦截性就是减少侵入性。

当然,后面我们再进一步解析安全中间件会发现:本质上这是一个管理问题,还真不是技术问题。

 

安全中间件的优势是:

运维和开发由于合规因素都是相对隔离的,企业人数越多,运维和开发的隔离性就越明显。在运维人员采购以及管控中间件的这部分工作中,安全中间件的优势就出现了:运维部门采购安全中间件后,往往会开启所有的安全策略,但是安全策略的关闭、调整的权限是留给开发部门的。从管理角度上运维人员已经落实了安全责任,如果开发在使用中间件时为了业务逻辑关闭、调整中间件的安全策略,属于是开发部门的安全问题与运维无关。

这也间接解释了国内很多企业的安全部门尴尬的原因:安全工作要落地,但是各部门又没有相关的能力,只能安全部门自身输出安全能力提供安全产品。而安全预算往往又不足,只能安全部门从业务端开始从业务捋到运维,链路太长又气又累,出了问题还要背锅。但安全部门本质上是要求从业务端就开始层层履行安全责任的监管部门,而目前的现状是运动员又是裁判员,这让人确实很难受。

 

接下来聊聊我手工改造tomcat的一些过程,供大家欣赏:

1)准备两套tomcat源码,分别重命名

这样做是因为maven环境下的tomcat开发调试较为方便利于长期开发,而ant是标准的编译方案适合最终发布

2)使用idea直接加载apache-tomcat-8.5.75-src-maven目录

点击modules按钮

如下图,选中java按钮点击 sources 按钮声明源码目录

将以下代码放到pom.xml里面然后放到源码的根目录中

注意:确保采用正确语言级别,tomcat8.5我采用java8的语法

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

  <modelVersion>4.0.0</modelVersion>
  <groupId>org.apache.tomcat</groupId>
  <artifactId>tomcat</artifactId>
  <name>tomcat</name>
  <version>8.5.75</version>
  <properties>
    <maven.compiler.target>1.8</maven.compiler.target>
    <maven.compiler.source>1.8</maven.compiler.source>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.13</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.ant</groupId>
      <artifactId>ant</artifactId>
      <version>1.10.5</version>
    </dependency>
    <dependency>
      <groupId>wsdl4j</groupId>
      <artifactId>wsdl4j</artifactId>
      <version>1.6.3</version>
    </dependency>

    <dependency>
      <groupId>org.apache.geronimo.specs</groupId>
      <artifactId>geronimo-jaxrpc_1.1_spec</artifactId>
      <version>2.1</version>
    </dependency>

    <dependency>
      <groupId>org.eclipse.jdt</groupId>
      <artifactId>ecj</artifactId>
      <version>3.26.0</version>
    </dependency>

    <dependency>
      <groupId>org.easymock</groupId>
      <artifactId>easymock</artifactId>
      <version>4.0.2</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>biz.aQute.bnd</groupId>
      <artifactId>biz.aQute.bnd</artifactId>
      <version>5.2.0</version>
    </dependency>

  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
          <encoding>UTF-8</encoding>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <configuration>
          <encoding>UTF-8</encoding>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

 

3)默认启动是有问题的,我们来做如下修改

3.1)如图删掉examples目录

3.2)找到 java/org/apache/catalina/startup/ContextConfig.java 的 configureStart 方法

在 webConfig(); 这句话下增加下面这句话

context.addServletContainerInitializer(new JasperInitializer(), null);

结果如下图

3.3)修改启动时的vm参数,防止乱码

   

-Dfile.encoding=UTF-8
-Duser.timezone=Asia/Shanghai
-Duser.language=en

3.4)修改代码防止乱码

修改 java/org/apache/tomcat/util/res/StringManager.java 类中的getString函数

if (bundle != null) {
    str = bundle.getString(key);
}

改为以下代码,注意catch中需要增加一个异常

 try {
            // Avoid NPE if bundle is null and treat it like an MRE
            if (bundle != null) {
                //str = bundle.getString(key);
                str = new String(bundle.getString(key).getBytes("ISO-8859-1"), "UTF-8");
            }
        } catch (MissingResourceException | UnsupportedEncodingException mre) {

 

4)找到 java/org/apache/catalina/startup/Bootstrap.java 直接点击就可以启动了

注意:建议修改hosts文件将 123.com指向127.0.0.1方便抓包

5)接下来我们写一个filter直接过滤Multipart和PUT动词的上传漏洞

在 java/org/apache/coyote/sec/SecCheckFilter.java 中写入以下代码,过滤逻辑请看注释

(其实我对coyote的request和reponse都有另外的私有化修改,所以直接把sec代码放在coyote下最为方便)

package org.apache.coyote.sec;

import org.apache.catalina.filters.RequestFilter;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.http.fileupload.FileItem;
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;
import org.apache.tomcat.util.http.fileupload.servlet.ServletRequestContext;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Locale;

/* web.xml注解
  <filter>
    <filter-name>SecCheckFilter</filter-name>
    <filter-class>
        org.apache.coyote.sec.SecCheckFilter
    </filter-class>
    <async-supported>true</async-supported>
  </filter>
  <filter-mapping>
    <filter-name>SecCheckFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
 */
public final class SecCheckFilter extends RequestFilter {

    private final Log log = LogFactory.getLog(SecCheckFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        request.setCharacterEncoding("UTF-8");
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        
        //防止getParameter与getInputStream冲突
        httpRequest.getParameterMap();
        httpRequest = new BufferedServletRequestWrapper(httpRequest);

        response.setCharacterEncoding("UTF-8");
        String httpMethod = httpRequest.getMethod().toLowerCase(Locale.ROOT);
        // 禁用 get post options 之外的其他http请求,防止 put move 等上传攻击
        try {
            switch (httpMethod) {
                case "get":
                case "post":
                case "options":
                    break;
                default:
                    ((HttpServletResponse) response).sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
                    return;
            }

            boolean isMultipart = ServletFileUpload.isMultipartContent(httpRequest);
            if (isMultipart) {
                // 校验Multipart上传时的文件后缀
                DiskFileItemFactory factory = new DiskFileItemFactory();
                ServletFileUpload upload = new ServletFileUpload(factory);
                List<FileItem> fileItems;

                fileItems = upload.parseRequest(new ServletRequestContext(httpRequest));
                if (fileItems != null && fileItems.size() > 0) {
                    //遍历Multipart入参
                    for (FileItem item : fileItems) {
                        if (!item.isFormField()) {
                            String FileName = item.getName().toLowerCase(Locale.ROOT);
                            // 校验文件名中的特殊字符
                            if (FileName.contains("/") || FileName.contains("\\") || FileName.contains(":") || FileName.contains("*")
                                || FileName.contains("?") || FileName.contains("\"") || FileName.contains("<") || FileName.contains(">")
                                || FileName.contains("|")   // windows文件名禁用 / \ : * ? " < > |
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                            // 校验文件后缀
                            String Extension = FileName.substring(FileName.lastIndexOf(".") + 1).trim();
                            if (Extension.startsWith("js")  // jsp jspx js
                                || Extension.startsWith("asp") // asp aspx
                                || Extension.startsWith("jar") // jar
                                || Extension.startsWith("war") // war
                                || Extension.startsWith("php") // php
                                || Extension.startsWith("htm") // htm html
                                || Extension.startsWith("shtm") // shtml
                                || Extension.startsWith("exe") // exe
                                || Extension.startsWith("bat") // bat
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                        }
                    }
                }
            }
            // 全部使用wrapper进行处理
            chain.doFilter(httpRequest, response);

        } catch (Exception e) {
        }
    }

    @Override
    protected Log getLogger() {
        return log;
    }
}

这里有几个注意点:

5.1)文件上传的stream读完一次后就使用完毕,而我们需要复用request流,要做如下处理

新增 java/org/apache/coyote/sec/BufferedServletInputStream.java

package org.apache.coyote.sec;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;

public class BufferedServletInputStream extends ServletInputStream {
    private final ByteArrayInputStream inputStream;

    public BufferedServletInputStream(byte[] buffer) {
        this.inputStream = new ByteArrayInputStream(buffer);
    }

    @Override
    public int available() throws IOException {
        return inputStream.available();
    }

    @Override
    public int read() throws IOException {
        return inputStream.read();
    }

    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        return inputStream.read(b, off, len);
    }

    @Override
    public boolean isFinished() {
        return false;
    }

    @Override
    public boolean isReady() {
        return false;
    }

    @Override
    public void setReadListener(ReadListener listener) {

    }
}

新增  java/org/apache/coyote/sec/BufferedServletRequestWrapper.java

package org.apache.coyote.sec;


import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class BufferedServletRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] buffer;

    public BufferedServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        InputStream is = request.getInputStream();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        byte[] buff = new byte[1024];
        int read;
        while ((read = is.read(buff)) > 0) {
            baos.write(buff, 0, read);
        }
        this.buffer = baos.toByteArray();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new BufferedServletInputStream(this.buffer);
    }
}

同时在conf目录下的web.xml中引入filter

  <filter>
    <filter-name>SecCheckFilter</filter-name>
    <filter-class>
        org.apache.coyote.sec.SecCheckFilter
    </filter-class>
    <async-supported>true</async-supported>
  </filter>
  <filter-mapping>
    <filter-name>SecCheckFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

 

6)试验一下,成功

 

7)编译apache-tomcat-8.5.75-src-ant发布

在环境变量中准备好ant

ANT_HOME=C:\Java\apache-ant-1.9.15

再将我们在maven工程中调试成功的代码放入ant工程中的同名目录

以及在web.xml放入filter的声明

  <filter>
    <filter-name>SecCheckFilter</filter-name>
    <filter-class>
        org.apache.coyote.sec.SecCheckFilter
    </filter-class>
    <async-supported>true</async-supported>
  </filter>
  <filter-mapping>
    <filter-name>SecCheckFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

最后在根目录下执行ant命令

ant package-zip

我们可以获得下面这个压缩包

 

8)最终试验,以图为证:

可以看到依然是提示400错误,并且tomcat给出的信息与开发模式不一样了。

 

再接下来,我们增加一个语义waf的防护策略:

9)java的servlet在获取参数时使用以下的标准:

9.1) 获取请求方式
request.getMethod();    get和post都可用

9.2) 获取请求类型
request.getContentType();   get和post都可用,示例值:application/json ,multipart/form-data, application/xml等

9.3) 获取所有参数key
request.getParameterNames();   get和post都可用,注:不适用contentType为multipart/form-data

9.4) 获取参数值value
request.getParameter("test");   get和post都可用,注:不适用contentType为multipart/form-data

9.5) 获取取参数请求集合
request.getParameterMap();   get和post都可用,注: 不适用contentType为multipart/form-data

总结:multipart和普通的文本是分开校验的,同时multipart中isFormField方法判断当前是上传文件还是参数输入,所以我们要增加一个对multipart中输入参数的sql注入校验。

9.6)我之前写过一篇文章 https://my.oschina.net/9199771/blog/5085337

下文中我使用的文件是: https://github.com/k4n5ha0/libinjection-Java/blob/master/src/main/java/SqlParse.java

所以在sec目录下我们将waf代码SqlParse.java复制进去(代码过长文章就不放进去了,大家到仓库里去看就行了)

最终在校验http动词后:对所有输入参数和multipart的输入参数,增加语义waf的策略

package org.apache.coyote.sec;

import org.apache.catalina.filters.RequestFilter;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.http.fileupload.FileItem;
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;
import org.apache.tomcat.util.http.fileupload.servlet.ServletRequestContext;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;

/* web.xml注解
  <filter>
    <filter-name>SecCheckFilter</filter-name>
    <filter-class>
        org.apache.coyote.sec.SecCheckFilter
    </filter-class>
    <async-supported>true</async-supported>
  </filter>
  <filter-mapping>
    <filter-name>SecCheckFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
 */
public final class SecCheckFilter extends RequestFilter {

    private final Log log = LogFactory.getLog(SecCheckFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        httpRequest = new BufferedServletRequestWrapper(httpRequest);
        response.setCharacterEncoding("UTF-8");
        String httpMethod = ((HttpServletRequest) request).getMethod().toLowerCase(Locale.ROOT);
        // 禁用 get post options 之外的其他http请求,防止 put move 等上传攻击
        try {
            switch (httpMethod) {
                case "get":
                case "post":
                case "options":
                    break;
                default:
                    ((HttpServletResponse) response).sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
                    return;
            }

            // 使用语义waf策略过滤所有非Multipart提交的输入参数
            Enumeration<String> enums = httpRequest.getParameterNames();
            while (enums.hasMoreElements()) {
                String pn = enums.nextElement();
                String[] vales = httpRequest.getParameterValues(pn);
                // i=1&i=2&i=3 这种情况下,所有的值也都必须过滤一次
                for (String vale : vales) {
                    if (vale.length() > 5 && SqlParse.isSQLi(vale)) {
                        ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                        return;
                    }
                }
            }

            boolean isMultipart = ServletFileUpload.isMultipartContent(httpRequest);
            if (isMultipart) {
                // 校验Multipart上传时的文件后缀
                DiskFileItemFactory factory = new DiskFileItemFactory();
                ServletFileUpload upload = new ServletFileUpload(factory);
                List<FileItem> fileItems;

                fileItems = upload.parseRequest(new ServletRequestContext(httpRequest));
                if (fileItems != null && fileItems.size() > 0) {
                    //遍历Multipart入参
                    for (FileItem item : fileItems) {
                        if (!item.isFormField()) {
                            String FileName = item.getName().toLowerCase(Locale.ROOT);
                            // 校验文件名中的特殊字符
                            if (FileName.contains("/") || FileName.contains("\\") || FileName.contains(":") || FileName.contains("*")
                                || FileName.contains("?") || FileName.contains("\"") || FileName.contains("<") || FileName.contains(">")
                                || FileName.contains("|")   // windows文件名禁用 / \ : * ? " < > |
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                            // 校验文件后缀
                            String Extension = FileName.substring(FileName.lastIndexOf(".") + 1).trim();
                            if (Extension.startsWith("js")  // jsp jspx js
                                || Extension.startsWith("asp") // asp aspx
                                || Extension.startsWith("jar") // jar
                                || Extension.startsWith("war") // war
                                || Extension.startsWith("php") // php
                                || Extension.startsWith("htm") // htm html
                                || Extension.startsWith("shtm") // shtml
                                || Extension.startsWith("exe") // exe
                                || Extension.startsWith("bat") // bat
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                        }
                        // 校验Multipart入参时的sql攻击
                        else {
                            if (item.getString().length() > 5 && SqlParse.isSQLi(item.getString())) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                        }
                    }
                }
            }
            // 全部使用wrapper进行处理
            chain.doFilter(httpRequest, response);

        } catch (Exception e) {
        }
    }

    @Override
    protected Log getLogger() {
        return log;
    }
}

备注:

通过研究modsecurity的策略发现:

使用以下正则判断单个变量存在连续3个或以上的英文特殊字符可以很好的避免sql注入:

// "mzQ1BAN<'\">cjkNLJ" 是sqlmap探测数据库种类的payload
if (Pattern.compile("[`~!@#$%^&*()_+-=,.<>/?;:\\[\\]{}'\"]{3,}").matcher("mzQ1BAN<'\">cjkNLJ").find()) {
    System.out.println("sqli!");
    return;
}

之后参考以下waf的bypass问题:

https://mp.weixin.qq.com/s/GM1YDKB_04sDvZR3ar7d3A

可以得出sql转换成ast语法树的结果如果与web防火墙、db防火墙的结果如果不一致,将会导致防护失效的问题。

所以我们可以得出最合理的sql注入防护应当在数据库底层集成:

https://mariadb.com/kb/en/maxscale-23-filters/

https://www.2ndquadrant.com/en/blog/how-to-protect-your-postgresql-databases-from-cyberattacks-with-sql-firewall/

https://dev.mysql.com/doc/refman/8.0/en/firewall-usage.html

或连接池集成sql注入防护能力,参考Druid的sql注入防护:

https://www.bookstack.cn/read/Druid/ffdd9118e6208531.md

 

10)增加反序列化黑名单

通过之前的研究成果《jdk反序列化安全防护研究:续》: https://my.oschina.net/9199771/blog/5125687

结合工作中的经验,使用以下jvm参数可以获得良好的体验(G1算法在jdk11中是默认的,可以去掉)

-XX:+UseG1GC -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai -Duser.language=en

10.1)windows环境下对应的setclasspath.bat中在

if ""%1"" == ""debug"" goto needJavaHome

上方填加以下代码

set "JAVA_OPTS=%JAVA_OPTS% -XX:+UseG1GC -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai -Duser.language=en"
set "JAVA_OPTS=%JAVA_OPTS% -Djdk.serialFilter=maxarray=5000;!java.util.PriorityQueue;!org.apache.commons.collections.functors.ChainedTransformer;!org.apache.commons.collections.functors.InvokerTransformer;!org.apache.commons.collections.functors.InstantiateTransformer;!org.apache.commons.collections4.functors.InvokerTransformer;!org.apache.commons.collections4.functors.InstantiateTransformer;!org.codehaus.groovy.runtime.ConvertedClosure;!org.codehaus.groovy.runtime.MethodClosure;!org.springframework.beans.factory.ObjectFactory;!com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;!org.apache.xalan.xsltc.trax.TemplatesImpl;!com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase;"

 10.2)linux环境下对应的setclasspath.sh中在

if [ -z "$JAVA_HOME" ] && [ -z "$JRE_HOME" ]; then

上方填加以下代码,并且多了一个禁止root启动的判断

if [[ $EUID -eq 0 ]]; then
	echo "Error:tomcat can't be run as root!" 1>&2
	exit 1
fi
JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai -Duser.language=en"
JAVA_OPTS="$JAVA_OPTS -Djdk.serialFilter=maxarray=5000;!java.util.PriorityQueue;\
    !org.apache.commons.collections.functors.ChainedTransformer;\
    !org.apache.commons.collections.functors.InvokerTransformer;\
    !org.apache.commons.collections.functors.InstantiateTransformer;\
    !org.apache.commons.collections4.functors.InvokerTransformer;\
    !org.apache.commons.collections4.functors.InstantiateTransformer;\
    !org.codehaus.groovy.runtime.ConvertedClosure;\
    !org.codehaus.groovy.runtime.MethodClosure;\
    !org.springframework.beans.factory.ObjectFactory;\
    !com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;\
    !org.apache.xalan.xsltc.trax.TemplatesImpl;\
    !com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase;"

 

11)对server.xml进行修改增强安全性

11.1)unpackWARs设置为false可以减少攻击面,autoDeploy是否修改为false取决于devops的流程设计

<Host name="localhost" appBase="webapps" unpackWARs="false" autoDeploy="true">

11.2)隐藏tomcat报错信息和版本信息,顺便说一句网上很多隐藏版本信息的方法不是官方标准的

<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false" />

修改好后的结果如下图

 

12)接下来讲一个很有趣的设计,利用filter对输出内容进行过滤

BufferedServletResponseWrapper.java 是新增的代理类,作用类似于请求的代理类

package org.apache.coyote.sec;

import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import java.io.*;

public class BufferedServletResponseWrapper extends HttpServletResponseWrapper {
    private ByteArrayOutputStream buffer;
    private ServletOutputStream out;
    private PrintWriter writer;

    public BufferedServletResponseWrapper(HttpServletResponse resp) throws IOException {
        super(resp);
        buffer = new ByteArrayOutputStream();//真正存储数据的流
        out = new WapperedOutputStream(buffer);
        writer = new PrintWriter(new OutputStreamWriter(buffer));
    }

    // 过滤响应包的head头
    @Override
    public void setHeader(String name, String value) {
        if ("allow".equalsIgnoreCase(name)) {
            return;
        }
        if ("server".equalsIgnoreCase(name)) {
            return;
        }
        if ("WWW-Authenticate".equalsIgnoreCase(name)) {
            return;
        }
        super.setHeader(name, value);
    }

    // 过滤响应包的head头
    @Override
    public void addHeader(String name, String value) {
        if ("allow".equalsIgnoreCase(name)) {
            return;
        }
        if ("server".equalsIgnoreCase(name)) {
            return;
        }
        if ("WWW-Authenticate".equalsIgnoreCase(name)) {
            return;
        }
        super.setHeader(name, value);
    }

    //重载父类获取outputstream的方法
    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return out;
    }

    //重载父类获取writer的方法
    @Override
    public PrintWriter getWriter() throws UnsupportedEncodingException {
        return writer;
    }

    //重载父类获取flushBuffer的方法
    @Override
    public void flushBuffer() throws IOException {
        if (out != null) {
            out.flush();
        }
        if (writer != null) {
            writer.flush();
        }
    }

    @Override
    public void reset() {
        buffer.reset();
    }

    public String getContentString() throws IOException {
        //将out和writer中的数据强制输出到WapperedResponse的buffer里面,否则取不到数据
        flushBuffer();
        return buffer.toString();
    }

    public byte[] getContentBytes() throws IOException {
        //将out和writer中的数据强制输出到WapperedResponse的buffer里面,否则取不到数据
        flushBuffer();
        return buffer.toByteArray();
    }

    //内部类,对ServletOutputStream进行包装
    private class WapperedOutputStream extends ServletOutputStream {
        private ByteArrayOutputStream bos;

        public WapperedOutputStream(ByteArrayOutputStream stream) {
            bos = stream;
        }

        @Override
        public void write(int b) throws IOException {
            bos.write(b);
        }

        @Override
        public boolean isReady() {
            return false;
        }

        @Override
        public void setWriteListener(WriteListener listener) {

        }
    }
}

如上所示我们重写了setheader和addheader两个方法以防止输出一些等保测评中导致无法通过的http响应头

备注:也可以直接将安全逻辑写到底层的response类中,做到全面防护

 

对应的我们的过滤器代码也发生了变化,具体逻辑请看代码中的注释:

package org.apache.coyote.sec;

import org.apache.catalina.filters.RequestFilter;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.http.fileupload.FileItem;
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;
import org.apache.tomcat.util.http.fileupload.servlet.ServletRequestContext;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;

/* web.xml注解
  <filter>
    <filter-name>SecCheckFilter</filter-name>
    <filter-class>
        org.apache.coyote.sec.SecCheckFilter
    </filter-class>
    <async-supported>true</async-supported>
  </filter>
  <filter-mapping>
    <filter-name>SecCheckFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
 */
public final class SecCheckFilter extends RequestFilter {

    private final Log log = LogFactory.getLog(SecCheckFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        httpRequest = new BufferedServletRequestWrapper(httpRequest);
        // 对响应数据使用wrapper进行代理
        BufferedServletResponseWrapper httpResponse = new BufferedServletResponseWrapper((HttpServletResponse) response);
        response.setCharacterEncoding("UTF-8");
        String httpMethod = ((HttpServletRequest) request).getMethod().toLowerCase(Locale.ROOT);
        // 禁用 get post options 之外的其他http请求,防止 put move 等上传攻击
        try {
            switch (httpMethod) {
                case "get":
                case "post":
                case "options":
                    break;
                default:
                    ((HttpServletResponse) response).sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
                    return;
            }

            // 使用语义waf策略过滤所有非Multipart提交的输入参数
            Enumeration<String> enums = httpRequest.getParameterNames();
            while (enums.hasMoreElements()) {
                String pn = enums.nextElement();
                String[] vales = httpRequest.getParameterValues(pn);
                // i=1&i=2&i=3 这种情况下,所有的值也都必须过滤一次
                for (String vale : vales) {
                    if (vale.length() > 5 && SqlParse.isSQLi(vale)) {
                        ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                        return;
                    }
                }
            }

            boolean isMultipart = ServletFileUpload.isMultipartContent(httpRequest);
            if (isMultipart) {
                // 校验Multipart上传时的文件后缀
                DiskFileItemFactory factory = new DiskFileItemFactory();
                ServletFileUpload upload = new ServletFileUpload(factory);
                List<FileItem> fileItems;

                fileItems = upload.parseRequest(new ServletRequestContext(httpRequest));
                if (fileItems != null && fileItems.size() > 0) {
                    //遍历Multipart入参
                    for (FileItem item : fileItems) {
                        if (!item.isFormField()) {
                            String FileName = item.getName().toLowerCase(Locale.ROOT);
                            // 校验文件名中的特殊字符
                            if (FileName.contains("/") || FileName.contains("\\") || FileName.contains(":") || FileName.contains("*")
                                || FileName.contains("?") || FileName.contains("\"") || FileName.contains("<") || FileName.contains(">")
                                || FileName.contains("|")   // windows文件名禁用 / \ : * ? " < > |
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                            // 校验文件后缀
                            String Extension = FileName.substring(FileName.lastIndexOf(".") + 1).trim();
                            if (Extension.startsWith("js")  // jsp jspx js
                                || Extension.startsWith("asp") // asp aspx
                                || Extension.startsWith("jar") // jar
                                || Extension.startsWith("war") // war
                                || Extension.startsWith("php") // php
                                || Extension.startsWith("htm") // htm html
                                || Extension.startsWith("shtm") // shtml
                                || Extension.startsWith("exe") // exe
                                || Extension.startsWith("bat") // bat
                            ) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                        }
                        // 校验Multipart入参时的sql攻击
                        else {
                            if (item.getString().length() > 5 && SqlParse.isSQLi(item.getString())) {
                                ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
                                return;
                            }
                        }
                    }
                }
            }
            // 全部使用wrapper进行处理
            chain.doFilter(httpRequest, httpResponse);

            // 请求的filter逻辑走完,就开始处理响应流
            byte[] content = httpResponse.getContentBytes();
            if (content.length > 0) {
                // 如果存在敏感的sql关键词则输出错误,防止sql注入
                String str = httpResponse.getContentString();
                if (str.contains("You have an error in your SQL syntax")) {
                    ((HttpServletResponse) httpResponse).sendError(HttpServletResponse.SC_BAD_REQUEST);
                    return;
                }
                // 正常输出响应流的内容
                ServletOutputStream out = response.getOutputStream();
                out.write(content);
                out.flush();
            }

        } catch (Exception e) {
        }
    }

    @Override
    protected Log getLogger() {
        return log;
    }
}

如下图,如果响应中出现以下内容就说明存在sql报错,应该终止响应,当然我的代码非常不严谨主要是为了展示思路所以精简了逻辑,请勿擡杠。

我们加入屏蔽sql对外报错的逻辑:

可以看出,sqlmap完全认不出来了,连数据库类型都判断不出来,这也说明了现在的openrasp和数据库安全防火墙都采用的“屏蔽数据库报错”的设计是多么的简单有效,在增加开发人员重视软件质量的同时又增加了攻击者sql注入的攻击成本。

modsecurity的sql报错关键词地址如下:

https://github.com/coreruleset/coreruleset/blob/v3.4/dev/rules/sql-errors.data

 

13)进一步替换原始的java类

java提供了名为endorsed技术,可以的简单理解为-Djava.endorsed.dirs指定的目录面放置的jar文件,将有覆盖系统API的功能,可以把自己修改后的API打入到JVM指定的启动API中,取而代之。

方法1:将包和类名和java自带的一样的类,打包成一个jar包,放入到-Djava.endorsed.dirs指定的目录中

方法2:将包和类名和java自带的一样的类,打包成一个jar包,放入到 $JAVA_HOME/jre/lib/endorsed 目录中

  1. 能够覆盖的类是有限制的,其中不包括java.lang包中的类,比如java.lang.String这种就不行
  2. endorsed目录:.[jdk安装目录]./jre/lib/endorsed,不是jdk/lib/endorsed,目录中放的是Jar包,不是.java或.class文件,哪怕只重写了一个类也要打包成jar包
  3. 可以在dos模式查看修改后的效果(javac、java),在eclipse需要将运行选项中的JRE栏设置为jre(若设置为jdk将看不到效果)。
  4. 重写的类必须满足jdk中的规范,例如:自定义的ArrayList类也必须实现List等接口。
  5. 这个特性最高只支持到java8

比如我们要自定义一个CJException类,先如下图,将lib目录设置为依赖目录

之后我们如下图这样双击CJException类名,就可以看到源码了

如下图在tomcat源码中建好同样的CJException类

之后我们新建一个maven项目叫做jarx,新建jarx项目是为了快速打包让tomcat引用的,必须保持两者中同名CJException类代码必须一致

同样在源码目录下放入CJException

 

我们将tomcat和jarx中的setVendorCode都做如下的修改

直接maven packge打出一个jar包

然后在tomcat项目中引用jarx的jar目录

以调试模式启动tomcat项目,如下图所示可以成功下断点

 

全文总结:

通过本文的研究,我们可以通过使用filter过滤器对tomcat进行安全加固,从输入数据到输出数据都能增加安全逻辑校验,后期在本文基础上开发:

1)json内容过滤能力

2)安全规则热拔插能力

3)安全管控可视化能力

4)将进程沙箱化,防止越权读取的能力

等等,可以将安全中间件进行产品化。

 

禁止转载

谢谢!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章