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

admin 2024年12月9日23:42:37评论12 views字数 24917阅读83分3秒阅读模式

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:Javaapache-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

可以看到依然是提示 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探测数据库种类的payloadif (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 1fiJAVA_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
小结:
我们可以参照 modsecurity 的规则校验:
1)输入参数使用 REQUEST 库中成熟规则和 libinjection 算法对 sql 注入、xss(也可以使用 Antisamy 过滤 xss)、上传文件进行黑名单校验
尤其是中间件内置 waf 直接调用底层函数,获取的参数和业务代码一致,没有因为转义、编程语言不一样等导致的 bypass 问题
2)过滤 RESPONSE 中的信息,防止输出敏感内容
题外话:进一步替换原始的 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)rasp 集成能力
5)进程沙箱化,防止越权读写的能力
最终将安全中间件进行产品化

原文始发于微信公众号(XDsecurity):安全中间件的设计思路和简单实践

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年12月9日23:42:37
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   安全中间件的设计思路和简单实践https://cn-sec.com/archives/1740332.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息