安全扫描.md 24 KB

title: XSS、SQL注入 author: Gamehu tags:

  • 安全扫描 categories:
  • 工作 date: 2019-09-27 16:04:00 ---

{% asset_img shield.jpg Image by marian anbu juwan from Pixabay %}

背景

由于某些客户现场以及我们公司自身的要求,产品上线之前都会有一轮的安全扫描,用AppScan等这类工具扫一扫,最近就处理了几个安全扫描的问题,虽说处理的比较原始但是还是需要记录一下,你晓得哇,因为比较折磨人。

工作原理

什么交XSS、SQL注入网上一大堆我就不出丑了哈,要解决这块问题那就先得搞清楚AppScan这类软件的工作原理。

AppScan 工作原理小结如下:

  • 通过爬行配置的整个 Web 应用,了解其结构找到接口等信息
  • 根据分析的结果,发送修改后的Request 进行攻击尝试(扫描规则库模拟XSS、SQL注入等操作)
  • 最后分析 Respone ,验证是否符合预期是否存在安全漏洞

所以由此看出处理XSS、SQL注入等问题只能在第二个和第三个环节出手。

处理姿势

处理这类问题我所知道的通常就3种姿势:

  1. 拦截

触发了这类校验直接拦截掉并提示不让其做任何操作,简单粗暴但是不人性化。经常让人很懵逼。

  1. 替换

把可触发这类校验的关键字全部替换为其它字符或者转换为字符串等。这种容易破坏原有的表达。

  1. 加密

这是种欺骗扫描软件的方式,直接前后端约定加密方式,对所有的输入进行统一加密,后端再统一解密,这样扫描软件识别不了任何关键字。我同事就这样干过,虽然说性能上会有一点问题但是好在不用动任何代码。嗯,这种只能说骚操作。

实操

最后和架构师定的方式是通过filter+正则这种最原始最简单的方式来做,我们这是toB的运维系统所以让大家失望了没上高大上的安全策略。

{% asset_img 1.png %}

输入流

需要先搞清楚为什么需要处理输入流,因为 reqeust.getInputStream 方法只能读取一次。我们可以大概捋一下是咋回事。

我们需要输入流所以需要调用reqest.getInputStream(),getInputStream返回值为ServletInputStream,所以我们先看看ServletInputStream。

public abstract class ServletInputStream extends InputStream {
    protected ServletInputStream() {
    }

    public int readLine(byte[] b, int off, int len) throws IOException {
        if (len <= 0) {
            return 0;
        } else {
            int count = 0;

            int c;
            while((c = this.read()) != -1) {
                b[off++] = (byte)c;
                ++count;
                if (c == 10 || count == len) {
                    break;
                }
            }

            return count > 0 ? count : -1;
        }
    }

    public abstract boolean isFinished();

    public abstract boolean isReady();

    public abstract void setReadListener(ReadListener var1);
}

然后知道是继承自InputStream,所以我们先看InputStream,注意read和reset方法。

read方法告诉我们会从输入流一直读取下一个字节、如果以达到末尾侧返回-1。

reset告诉我们可以重置读取的位置。

public abstract class InputStream implements Closeable {

    // MAX_SKIP_BUFFER_SIZE is used to determine the maximum buffer size to
    // use when skipping.
    private static final int MAX_SKIP_BUFFER_SIZE = 2048;

    /**
     * Reads the next byte of data from the input stream. The value byte is
     * returned as an <code>int</code> in the range <code>0</code> to
     * <code>255</code>. If no byte is available because the end of the stream
     * has been reached, the value <code>-1</code> is returned. This method
     * blocks until input data is available, the end of the stream is detected,
     * or an exception is thrown.
     *
     * <p> A subclass must provide an implementation of this method.
     *
     * @return     the next byte of data, or <code>-1</code> if the end of the
     *             stream is reached.
     * @exception  IOException  if an I/O error occurs.
     */
    public abstract int read() throws IOException;
    
    /**
     * Repositions this stream to the position at the time the
     * <code>mark</code> method was last called on this input stream.
     *
     * <p> The general contract of <code>reset</code> is:
     *
     * <ul>
     * <li> If the method <code>markSupported</code> returns
     * <code>true</code>, then:
     *
     *     <ul><li> If the method <code>mark</code> has not been called since
     *     the stream was created, or the number of bytes read from the stream
     *     since <code>mark</code> was last called is larger than the argument
     *     to <code>mark</code> at that last call, then an
     *     <code>IOException</code> might be thrown.
     *
     *     <li> If such an <code>IOException</code> is not thrown, then the
     *     stream is reset to a state such that all the bytes read since the
     *     most recent call to <code>mark</code> (or since the start of the
     *     file, if <code>mark</code> has not been called) will be resupplied
     *     to subsequent callers of the <code>read</code> method, followed by
     *     any bytes that otherwise would have been the next input data as of
     *     the time of the call to <code>reset</code>. </ul>
     *
     * <li> If the method <code>markSupported</code> returns
     * <code>false</code>, then:
     *
     *     <ul><li> The call to <code>reset</code> may throw an
     *     <code>IOException</code>.
     *
     *     <li> If an <code>IOException</code> is not thrown, then the stream
     *     is reset to a fixed state that depends on the particular type of the
     *     input stream and how it was created. The bytes that will be supplied
     *     to subsequent callers of the <code>read</code> method depend on the
     *     particular type of the input stream. </ul></ul>
     *
     * <p>The method <code>reset</code> for class <code>InputStream</code>
     * does nothing except throw an <code>IOException</code>.
     *
     * @exception  IOException  if this stream has not been marked or if the
     *               mark has been invalidated.
     * @see     java.io.InputStream#mark(int)
     * @see     java.io.IOException
     */
    public synchronized void reset() throws IOException {
        throw new IOException("mark/reset not supported");
    }

所以从上面可以得知ServletInputStream是没有重写r关键的reset方法,所以行为是与InputStream保持一致的即输入流读取一遍之后就没了后续就一直返回-1。

所以解决办法就是找到重写reset的方法的类,即就找到我们常用的ByteArrayInputStream也是继承自InputStream,但是其重写了reset等方法。

我们看下源码:

注意read里面的pos变量,它是标识现在读取的流的位置,所以如果我们想多次读取输入流,需要调用上面说的reset方法重置pos为0。

public
class ByteArrayInputStream extends InputStream {
    /**
     * The currently marked position in the stream.
     * ByteArrayInputStream objects are marked at position zero by
     * default when constructed.  They may be marked at another
     * position within the buffer by the <code>mark()</code> method.
     * The current buffer position is set to this point by the
     * <code>reset()</code> method.
     * <p>
     * If no mark has been set, then the value of mark is the offset
     * passed to the constructor (or 0 if the offset was not supplied).
     *
     * @since   JDK1.1
     */
    protected int mark = 0;
    
    /**
     * The index of the next character to read from the input stream buffer.
     * This value should always be nonnegative
     * and not larger than the value of <code>count</code>.
     * The next byte to be read from the input stream buffer
     * will be <code>buf[pos]</code>.
     */
    protected int pos;
    /**
     * Reads the next byte of data from this input stream. The value
     * byte is returned as an <code>int</code> in the range
     * <code>0</code> to <code>255</code>. If no byte is available
     * because the end of the stream has been reached, the value
     * <code>-1</code> is returned.
     * <p>
     * This <code>read</code> method
     * cannot block.
     *
     * @return  the next byte of data, or <code>-1</code> if the end of the
     *          stream has been reached.
     */
    public synchronized int read() {
        return (pos < count) ? (buf[pos++] & 0xff) : -1;
    }
    
     /**
     * Resets the buffer to the marked position.  The marked position
     * is 0 unless another position was marked or an offset was specified
     * in the constructor.
     */
    public synchronized void reset() {
        pos = mark;
    }

这些搞清楚之后就是看怎么能在到咱们的filter的时候得到的request是可以读取多次同时又不影响其它地方的读取(比如controller),刚好severlet.api提供了一个叫HttpServletRequestWrapper的东西,刚好提供一种包装(专业名词:装饰器模式)的手法让我们可以包装request请求对象使其可扩展其它能力。包装高富帅哪里都吃得开。

/**
 * @author Gamehu
 * @date 2019/5/9 18:32
 * @description sql注入问题,前置处理输入流,避免输入流获取一次以后失效导致系统异常
 */
public class xxxHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private static final Logger LOGGER = LoggerFactory.getLogger(SqlInjectHttpServletRequestWrapper.class);
    /**
     * 存储请求输入流
     */
    private byte[] body;


    public SqlInjectHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        try {
            body = inputStreamToByte(request.getInputStream());
        } catch (IOException e) {
            throw RiilExceptionUtils.bizException(e, BaseWebErrorCode.SecurityConstant.ERROR, BaseWebErrorMsg.SecurityConstant.ERROR_MSG);
        }
    }

    /**
     * 流转 字节数组
     *
     * @param is
     * @return
     * @throws IOException
     */
    private byte[] inputStreamToByte(InputStream is) throws IOException {
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        int ch;
        while ((ch = is.read(buffer)) != -1) {
            byteStream.write(buffer, 0, ch);
        }
        byte[] data = byteStream.toByteArray();
        byteStream.close();
        return data;
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    /**
     * 重写方法用于多次获取流,防止读取用于校验过后,后面服务无法获取参数的情况
     *
     * @return
     */
    @Override
    public ServletInputStream getInputStream() {
        final ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {

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

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

            @Override
            public void setReadListener(ReadListener readListener) {
                LOGGER.info("setReadListener");
            }

            @Override
            public int read() {
                return bais.read();
            }
        };


    }

OK,到这儿输入流总算搞定了,nice,然后开始Filter上场了。

Filter


/**
 * @author Gamehu
 * @date 2018/12/20 15:18
 * @description  XSS、SQL注入校验
 */
@WebFilter(urlPatterns = "/*", filterName = "xxFilter")
public class xxFilter implements Filter {
   

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        ...
        //安全扫描问题(sql注入、xss)处理,对参数进行校验
        securityScanParamsValidate(filterChain, response, request);
    }
 

    /**
     * 安全扫描问题(sql注入、xss)处理,对参数进行校验
     *
     * @param filterChain
     * @param response
     * @param request
     * @throws IOException
     * @throws ServletException
     */
    private void securityScanParamsValidate(final FilterChain filterChain, final HttpServletResponse response, final HttpServletRequest request) throws IOException, ServletException {
        final  String paramsAndValues = SecurityScanUtil.extractPostRequestBody(request);
        if (StringUtils.isEmpty(paramsAndValues)) {
            filterChain.doFilter(request, response);
        } else {
            //根据参数是否能转换为json,执行不同的校验
            parseParamAndValidate(filterChain, response, request, paramsAndValues);
        }

    }

    private void parseParamAndValidate(final FilterChain filterChain, final HttpServletResponse response, final HttpServletRequest request, final String paramsAndValues) throws IOException, ServletException {
        try {
            final JSONObject paramsObj = JSONObject.parseObject(paramsAndValues);
            if (paramsObj.size() == 0) {
                filterChain.doFilter(request, response);
            } else {
                //对参数进行拆分校验,只校验每个参数值
                jsonParamValidate(filterChain, response, request, paramsAndValues);
            }

        } catch (JSONException ex) {
            LOGGER.error("isJSONValid,不是有效的JSON字符串,{}", ex.getMessage());
            //参数不是合法地json格式则进行整句校验(不进行任何拆分)
            notJsonParamValidate(filterChain, response, request, paramsAndValues);
        }
    }

    /**
     * 非json格式参数安全问题校验
     *
     * @param filterChain
     * @param response
     * @param request
     * @param paramsAndValues
     * @throws IOException
     * @throws ServletException
     */
    private void notJsonParamValidate(final FilterChain filterChain, final HttpServletResponse response, final HttpServletRequest request, final String paramsAndValues) throws IOException, ServletException {
        final String uri = request.getRequestURI();
        // sql注入校验
        if (SecurityScanUtil.execSqlInjectValidate(uri, NO_KEY, paramsAndValues)) {
            response.sendRedirect(SQL_INJECT_ERROR);
        } else if (XssUtil.xssMatcher(uri, NO_KEY, paramsAndValues)) {
            //xss校验
            response.sendRedirect(XSS_ERROR);
        } else {
            filterChain.doFilter(request, response);
        }
    }

    /**
     * json格式参数安全问题校验(sql注入、xss)
     *
     * @param filterChain
     * @param response
     * @param request
     * @param paramsAndValues
     * @throws IOException
     * @throws ServletException
     */
    private void jsonParamValidate(final FilterChain filterChain, final HttpServletResponse response, final HttpServletRequest request, final String paramsAndValues) throws IOException, ServletException {
        //防sql注入校验
        if (SecurityScanUtil.sqlInjectValidate(request.getRequestURI(), paramsAndValues)) {
            response.sendRedirect(SQL_INJECT_ERROR);
        } else if (SecurityScanUtil.xssValidate(request.getRequestURI(), paramsAndValues)) {
            //xss校验
            response.sendRedirect(XSS_ERROR);
        } else {
            filterChain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {
        LOGGER.info("destroy");
    }


}
/**
 * 
 * @author Gamehu
 * @description  Xss和Sql注入检查 工具类 
 * @date 2019/5/10 9:54
 */
public class SecurityScanUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(SecurityScanUtil.class);
    private static final String GRAPHQL = "graphql";

    private SecurityScanUtil() {
    }

    /**
     * 单个参数的sql注入校验
     *
     * @param paramName
     * @param value
     * @return
     */
    public static String sqlInjectionValidate(final HttpServletRequest request, final String paramName, final String value) {
        if (StringUtils.isEmpty(value)) {
            return null;
        }
        /*
         * 防sql注入,如果存在抛出校验异常
         */
        if (SqlInjectUtil.haveSqlInject(request.getRequestURI(), paramName, value)) {
            return "sqlError";
        }

        return value;
    }


    /**
     * 读取输入流中参数
     *
     * @param request
     * @return
     */

    public static String extractPostRequestBody(final HttpServletRequest request) {

        final Scanner s;
        try {
            s = new Scanner(request.getInputStream(), "UTF-8").useDelimiter("\\A");
        } catch (IOException e) {
            throw RiilExceptionUtils.bizException(e, BaseWebErrorCode.SecurityConstant.INPUTSTREAM_ERROR, BaseWebErrorMsg.SecurityConstant.INPUTSTREAM_ERROR_MSG);
        }
        return s.hasNext() ? s.next() : "";

    }


    /**
     * 效验sql注入问题
     * <h3>SQL注入的几种方式:</h3>
     * <pre>
     * 1) 使用 ' or 语句,将查询条件扩大,实现破坏性查询(操作)
     * 2) 使用 ; 将SQL分成两部分,在后面一部分实现破坏性操作
     * 3) 使用注释,将后面的条件取消掉,将查询条件扩大,注意MySQL有三种注释的方法,都需要处理
     *
     * 为了简化处理,这里只考虑字符串类型参数注入情况(整型等其它类型在应用内部类型转换会失败,所以基本可以忽略)
     * </pre>
     *
     * @return
     * @throws IOException
     */
    public static boolean sqlInjectValidate(final String uri, final String paramsAndValues) {
        //获取参数列表以及参数值
        final JSONObject paramsObj = JSONObject.parseObject(paramsAndValues);
        final Set<String> keys = paramsObj.keySet();
        return keys.stream().anyMatch(key -> haveSqlInjectCondition(uri, paramsObj, key));
    }
 
    /**
     * 判断是否存在sql注入的表达式
     *
     * @param uri
     * @param paramsObj
     * @param key
     * @return
     */
    private static Boolean haveSqlInjectCondition(final String uri, final JSONObject paramsObj, final String key) {
        //graphql 不进行校验
        if (key.equalsIgnoreCase(GRAPHQL)) {
            return false;
        }
        final String value = convertParamToString(paramsObj, key);
        return SqlInjectUtil.haveSqlInject(uri, key, value);
    }

    /**
     * 效验XSS问题
     *
     * @param uri
     * @param paramsAndValues
     * @return
     */
    public static boolean xssValidate(final String uri, final String paramsAndValues) {
        //获取参数列表以及参数值
        final JSONObject paramsObj = JSONObject.parseObject(paramsAndValues);
        final Set<String> keys = paramsObj.keySet();
        return keys.stream().anyMatch(key -> haveXssCondition(uri, paramsObj, key));
    }

    /**
     * 判断是否存在xss的表达式
     *
     * @param uri
     * @param paramsObj
     * @param key
     * @return
     */
    private static boolean haveXssCondition(final String uri, final JSONObject paramsObj, final String key) {
        final String value = convertParamToString(paramsObj, key);
        //判断是否存在xss攻击问题
        return XssUtil.haveXss(value, key, uri);
    }

    /**
     * 处理参数为字符串
     *
     * @param paramsObj
     * @param key
     * @return
     */
    private static String convertParamToString(final JSONObject paramsObj, final String key) {
        final Object obj = paramsObj.get(key);

        String value = null;

        if (obj instanceof JSONObject) {
            value = ((JSONObject) obj).toJSONString();
        }

        if (obj instanceof JSONArray) {
            value = ((JSONArray) obj).toJSONString();
        }

        if (obj instanceof String) {
            value = (String) obj;
        }
        return value;
    }
 
}

/**
 * @author Gamehu
 * @date 2019/9/26 12:12
 * @description XSS校验的工具类
 */
public class XssUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(XssUtil.class);
    private static final String GRAPHQL = "graphql";
    private static Pattern[] patterns = new Pattern[]{
            // Script fragments
            Pattern.compile("<script>(.*?)</script>", Pattern.CASE_INSENSITIVE),
            // src='...'
            Pattern.compile("src[\r\n]*=[\r\n]*\\\'(.*?)\\\'", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
            Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
            // lonely script tags
            Pattern.compile("</script>", Pattern.CASE_INSENSITIVE),
            Pattern.compile("<script(.*?)>", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
            // eval(...)
            Pattern.compile("eval\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
            // expression(...)
            Pattern.compile("expression\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
            // javascript:...
            Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE),
            // vbscript:...
            Pattern.compile("vbscript:", Pattern.CASE_INSENSITIVE),
            // onload(...)=...
            Pattern.compile("onload(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL)
    };


    /**
     * 判断是否存在xss攻击
     *
     * @param value
     * @param key
     * @param uri
     * @return true表示存在,false表示不存在
     */
    public static Boolean haveXss(final String value, final String key, final String uri) {
        if (key.equalsIgnoreCase(GRAPHQL)) {
            return false;
        }
        return xssMatcher(value, key, uri);
    }

    public static boolean xssMatcher(final String value, final String key, final String uri) {
        if (StringUtils.isNotBlank(value)) {
            Matcher matcher;
            for (Pattern pattern : patterns) {
                matcher = pattern.matcher(value);
                // 匹配
                if (matcher.find()) {
                    LOGGER.error("存在xss风险,URI:{},参数:{},参数值:{}", uri, key, value);
                    return true;
                }
            }

        }
        return false;
    }
    
}

/**
 * @author Gamehu
 * @date 2019/9/26 12:12
 * @description SQL注入校验的工具类
 */
public class SqlInjectUtil {
    /**
     * SQL语法检查正则:只检查一个关键字可能存在误判情况,这里要求必须符合两个关键字(有先后顺序)才算匹配
     * 第一组关键字
     */
    final static String sqlInjectGroup = "select|update|and|or|delete|insert|trancate|char|into|substr|ascii|declare|exec|count|master|drop|execute";

    /**
     * 构造SQL语法检查正则
     * (?i)忽略字母的大小写,\s.*空白+字符
     */
    final static Pattern sqlSyntaxPattern = Pattern.compile("(?i)(.*)\\b(" + sqlInjectGroup + " )\\b\\s.*", Pattern.CASE_INSENSITIVE);


    /**
     * 读取输入流中参数
     *
     * @param request
     * @return
     */

    public static String extractPostRequestBody(final HttpServletRequest request) {

        final Scanner s;
        try {
            s = new Scanner(request.getInputStream(), "UTF-8").useDelimiter("\\A");
        } catch (IOException e) {
            throw RiilExceptionUtils.bizException(e, BaseWebErrorCode.SecurityConstant.INPUTSTREAM_ERROR, BaseWebErrorMsg.SecurityConstant.INPUTSTREAM_ERROR_MSG);
        }
        return s.hasNext() ? s.next() : "";

    }

    /**
     * 执行SQL注入校验
     *
     * @param uri
     * @param key
     * @param oldValue
     * @return
     */
    public static boolean haveSqlInject(final String uri, final String key, final String oldValue) {
        if (StringUtils.isNotBlank(oldValue)) {
            //统一转为小写
            final String newValue = oldValue.toLowerCase();
            final String logStr = "存在sql注入风险,URL:{},参数:{},参数值:{}";
            // 检查是否包含SQL注入敏感字符
            if (sqlSyntaxPattern.matcher(newValue).find()) {
                LOGGER.error(logStr, uri, key, newValue);
                return true;
            }
        }
        return false;
    }
    
}

因为我们用了graphql,有些地方还用了dsl,所以正则是魔鬼,我写崩溃了差点,当然如果有更好的方法请告诉我,万分感谢。

长出一口气.....

本文引用的内容,如有侵权请联系我删除,给您带来的不便我很抱歉。