PreparedStatement重新认知(2)——防止SQL注入

回顾

上篇,我们对PreparedStatement在MySQL下的工作机制进行了探究,了解到它在一般情况下并不比Statement更快(具体分析可参看: PreparedStatement重新认知(1)——它真的预编译吗),但我们还是建议使用它的原因是,它有一个非常重要的特性是Statement所不具备的:防止SQL注入

正文

引用wikipedia对SQL注入的定义:

SQL injection is a code injection technique, used to attack data-driven applications, in which malicious SQL statements are inserted into an entry field for execution (e.g. to dump the database contents to the attacker).[1] SQL injection must exploit a security vulnerability in an application's software, for example, when user input is either incorrectly filtered for string literal escape characters embedded in SQL statements or user input is not strongly typed and unexpectedly executed. SQL injection is mostly known as an attack vector for websites but can be used to attack any type of SQL database.

SQL注入是一个老生常谈的话题了,在初始学习开发的过程中就能接触到SQL注入的概念,也知道通常的解决手段是依靠PreparedStatement,受限于我们当时的理解能力与知识面,老师们一般鲜有详述防止SQL注入的原理。离开院校走向实际工作岗位,很少再使用原生的JDBC进行编程,也因此更少直接使用PreparedStatement执行SQL语句,大多是使用ORM框架(如: Hibernate)或半ORM框架(如: Mybatis),它们的底层尽管使用了PreparedStatement,但对上层暴露给用户的API抽象却屏蔽了JDBC相关的概念,渐渐地,我们离底层原来越远,离原理越来越模糊,因此,趁着这个机会,一起来探究一下防止SQL注入的实际原理

通过上篇内容,我们知道PreparedStatement既存在客户端的预编译,也存在数据库端的预编译,因此,防止SQL注入时,同样存在客户端的防止手段,与数据库端的防止手段

案例

使用Statement时:

String bar = "test";
ResultSet resultSet = statement.executeQuery("select * from foo where bar = '" + bar + "'");

很明然,这个语句很容易SQL注入,只要在test的后边添加1' or 1='1即可,如下示:

String bar = "test 1' or 1='1";
ResultSet resultSet = statement.executeQuery("select * from foo where bar = '" + bar + "'");

这样,构成的完整SQL语句就变成了select * from foo where bar = 'test 1' or 1='1';

1=1 是恒成立的条件,因此也就相当于把整张表都"拖"了出来。如果数据不重要,那么上述方式也只是存在数据泄露风险,但如果恶作剧之人把1' or 1='1变成';DROP TABLE user;,后果不堪设想...

使用PreparedStatement时:

String bar = "test 1' or 1='1";
PreparedStatement preparedStatement = connection.prepareStatement("select * from foo where bar = ?");
preparedStatement.setString(1, bar);
ResultSet resultSet = preparedStatement.executeQuery();

此时,只有bar字段的值确切等于test 1' or 1='1才会查询出内容,并不会发生SQL注入

那么问题来了,使用Statement是应用程序代码主动替换bar的值,而使用PreparedStatement是由JDBC驱动来替换bar的值,这二者内部处理有何区别,才导致一种方式存在SQL注入的风险而另一种方式不存在?

原理

对于Statement,由应用程序将变量的值替换后,基本上就是将替换后的SQL原样发送到数据库端去执行,不做校验。

对于PreparedStatement,是有处理的,处理又分两种,一种是JDBC客户端驱动处理,另一种是数据库端处理。

JDBC客户端:

JDBC驱动在做参数替换时,会将参数值进行转义,并将转义后的SQL拼在参数化模板上,发送到数据库端去执行。本案例的bar由test 1' or 1='1转义成了'test 1\' or 1=\'1',转义后的SQL就不再具体攻击性,也不具备SQL注入的能力

数据库端:

要理解数据库端如何防止SQL注入,需先理解数据库收到一条SQL后,发生了什么事

  1. Parsing and Normalization Phase

    此阶段,要进行语法和语义分析,检查表跟字段是否存在等。当然,该阶段还有很多事要做,但不是本文重点

  2. Compilation Phase

    此阶段,关键字(如: select\from\where)被解析成机器能够理解的格式,目的是让数据库能够理解SQL的含义与目的,让SQL能被正确执行(如: 查询记录,删除数据、调用存储过程)。当然,该阶段也有很多事要做,但同样不是本文重点

  3. Query Optimization Plan

    此阶段,构建决策树,并用决策树来判定哪一条执行路径最优。决策树会列出所有的可执行路径与每条执行路径的执行成本,此后会选择一条最优的路径去执行

  4. Cache

    上一阶段最优执行路径被选出来之后就在此阶段被缓存起来,下一次相同的SQL查询到来的时候,就不需要再经过1、2、3个阶段,直接从缓存中拿出来执行

  5. Execution Phase

    此阶段真正执行SQL语句并将结果返回给用户

那么对于具体的一条PreparedStatement语句,例如: select * from foo where bar = ?,参数值为test 1' or 1='1到底发生了什么

  1. PreparedStatement语句首次抵达数据库服务端时是不完整的,还包含着占位符,这些占位符会在真正执行SQL时才替换为真实的用户数据

  2. 数据库收到PreparedStatement语句后,会依次经历上面提及的的1、2、3、4阶段,注意,此时第4阶段存储的SQL并非完整SQL,而是带占位符的SQL模板(例如本例中: select * from foo where bar = ?)

  3. 用户真实数据(例如本例中:test 1' or 1='1)送达数据库端,数据库从Cache中找出SQL模板,并执行占位符替换

  4. 数据库执行SQL并将结果返回给用户

重点在于,在占位符替换之后,执行SQL前不再重新执行编译过程。数据库将用户发送过来的数据完全当成"纯"数据对待,不把数据当成SQL语句,就不存在语法、词法分析、转换成机器能够理解的格式等过程,因此这些"纯"数据对数据库而言仅仅是一堆无意义的字符流,占位符被替换后数据库直接执行SQL语句,也就不存在SQL注入(画外音:只有数据被当成SQL去解析、编译,才有可能被数据库认识并执行,无意义的字符数据库是不会管的)

另外,我们说PreparedStatement"预编译",是由于存储在Cache中的SQL模板早已经历过解析、编译阶段,转换成了机器能识别的格式,只要占位符被替换成用户数据就能直接执行;也正是由于PreparedStatement的"预编译",用户数据到来的时候就不会再次编译,直接占位符替换并执行,免受了SQL注入的风险

源码解析

Statement原理在上面已经分析过,源码简单且不是重点,故跳过,感兴趣的朋友可以自行查看

PreparedStatement的客户端处理:

注: 上篇文章分析到,要开启PreparedStatement客户端的预编译,不可在连接参数中添加useServerPrepStmts=true。可以不设置useServerPrepStmts参数(默认值是false),或者将参数值设为false。同理,启用客户端的处理(占位符替换)需要useServerPrepStmts=false

客户端的占位符替换发生在preparedStatement.setString(1, bar);,如下示:

// com.mysql.jdbc.PreparedStatement#setString

public void setString(int parameterIndex, String x) throws SQLException {
    // ...(省略)
    
    // isLoadDataQuery是PreparedStatement的成员变量,默认值为false
    // isEscapeNeededForString 用于判断参数值是否要进行转义
    if (this.isLoadDataQuery || isEscapeNeededForString(x, stringLength)) {
        needsQuoted = false; // saves an allocation later

        // buf用于存储转义后的参数值
        StringBuilder buf = new StringBuilder((int) (x.length() * 1.1));

        // 首先添加单引号 -> '
        buf.append('\'');

        //
        // Note: buf.append(char) is _faster_ than appending in blocks, because the block append requires a System.arraycopy().... go figure...
        //
        // 遍历参数值的每一个字符,判断其是否为需要转义的特殊字符,如果需要,就将字符转义并添加到buf中
        for (int i = 0; i < stringLength; ++i) {
            char c = x.charAt(i);

            switch (c) {
                case 0: /* Must be escaped for 'mysql' */
                    buf.append('\\');
                    buf.append('0');

                    break;

                case '\n': /* Must be escaped for logs */
                    buf.append('\\');
                    buf.append('n');

                    break;

                case '\r':
                    buf.append('\\');
                    buf.append('r');

                    break;

                case '\\':
                    buf.append('\\');
                    buf.append('\\');

                    break;

                // 如果参数值的字符中包含单引号 -> ',就在该字符前面添加转义符 -> \,形成 -> \'
                // 其它特殊字符如双引号 -> ",换行符 -> \n, 回车符 -> \r 等同理
                case '\'':
                    buf.append('\\');
                    buf.append('\'');

                    break;

                case '"': /* Better safe than sorry */
                    if (this.usingAnsiMode) {
                        buf.append('\\');
                    }

                    buf.append('"');

                    break;

                case '\032': /* This gives problems on Win32 */
                    buf.append('\\');
                    buf.append('Z');

                    break;

                case '\u00a5':
                case '\u20a9':
                    // escape characters interpreted as backslash by mysql
                    if (this.charsetEncoder != null) {
                        CharBuffer cbuf = CharBuffer.allocate(1);
                        ByteBuffer bbuf = ByteBuffer.allocate(1);
                        cbuf.put(c);
                        cbuf.position(0);
                        this.charsetEncoder.encode(cbuf, bbuf, true);
                        if (bbuf.get(0) == '\\') {
                            buf.append('\\');
                        }
                    }
                    buf.append(c);
                    break;

                default:
                    // 非特殊字符,不做任何转义,原样添加
                    buf.append(c);
            }
        }
        // 最后在参数值默认添加一个单引号 -> ' 做为结尾
        buf.append('\'');

        parameterAsString = buf.toString();
    }
    // ...(省略)
}

经过上述代码的处理,buf = 'test 1\' or 1=\'1',即完成了转义

在转义处理前还预先判断参数值是否包含特殊字符,预判断方式的技巧是:从左到右扫描参数值的字符,若当前为特殊字符,直接短路,返回true,表示需要转义。接着,只有判断需要转义才会真正进入转义逻辑,否则不转义。这样,在绝大部分正常SQL参数值的情况下并不需要转义,避免了不必要的性能消耗

// com.mysql.jdbc.PreparedStatement#isEscapeNeededForString
// 判断参数值是否包含特殊字符,若包含,表示需要转义,返回true
private boolean isEscapeNeededForString(String x, int stringLength) {
    boolean needsHexEscape = false;

    for (int i = 0; i < stringLength; ++i) {
        char c = x.charAt(i);

        switch (c) {
            case 0: /* Must be escaped for 'mysql' */

                needsHexEscape = true;
                break;

            case '\n': /* Must be escaped for logs */
                needsHexEscape = true;

                break;

            case '\r':
                needsHexEscape = true;
                break;

            case '\\':
                needsHexEscape = true;

                break;

            case '\'':
                needsHexEscape = true;

                break;

            case '"': /* Better safe than sorry */
                needsHexEscape = true;

                break;

            case '\032': /* This gives problems on Win32 */
                needsHexEscape = true;
                break;
        }

        if (needsHexEscape) {
            break; // no need to scan more
        }
    }
    return needsHexEscape;
}

服务端的占位符替换,需要开启useServerPrepStmts,即令该参数值为true

此时,preparedStatement.setString(1, bar);的处理如下:

// com.mysql.jdbc.ServerPreparedStatement#setString

public void setString(int parameterIndex, String x) throws SQLException {
    checkClosed();

    if (x == null) {
        setNull(parameterIndex, java.sql.Types.CHAR);
    } else {
        BindValue binding = getBinding(parameterIndex, false);
        resetToType(binding, this.stringTypeCode);

        binding.value = x;
    }
}

可以看到,驱动并没有对参数值进行转义处理,直接将值赋给了binding.value,因此做占位符替换后,发送的也是直接替换后的SQL

上边我们说过,数据库收到参数值的时候,会将用户参数数据当成"纯"数据对待,不再进行编译,而所谓的"纯"数据,实际上是将数据在数据库端进行了转义,即JDBC驱动发送原样的参数值(如:test 1' or 1='1)之后,数据库端将参数值转义成了'test 1\' or 1=\'1',将原本在客户端的转义工作挪到了数据库端(可通过开启MySQL的日志进行查看验证)

总结

本文对PreparedStatement防止SQL注入的原理进行了探究,分为客户端处理与数据库端处理,其基本原理都是在占位符替换时对特殊字符进行转义,转义之后的参数值就成了纯字符流,对数据库而言不再有害。另一方面,若是由服务端处理,不旦对参值数进行了转义,而且还经过解析、编译阶段SQL模板的阶段(预编译),提升性能的同时,在参数值到来后直接进行占位符替换,不再进行编译,也再一次防止了SQL注入的风险


导读: PreparedStatement重新认知(1)——它真的预编译吗

你可能感兴趣的:(PreparedStatement重新认知(2)——防止SQL注入)