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)——它真的预编译吗

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • 本节介绍Statement接口及其子类PreparedStatement和CallableStatement。 它...
    zlb阅读 1,134评论 0 0
  • SQL注入 概念 危害 原理 实例 防御 基础 - ### SQL语句所用符号不同数据库的sql注入与提权常见S...
    yddchsc君阅读 1,310评论 1 10
  • java类和数据库表的关系 类对应表,字段对应属性,一行数据对应一个对象,所以类的属性名要和表的字段名相同才能进行...
    孙浩j阅读 549评论 0 1
  • JDBC简介 SUN公司为了简化、统一对数据库的操作,定义了一套Java操作数据库的规范,称之为JDBC。JDBC...
    奋斗的老王阅读 1,504评论 0 51
  • 1.过滤URL中的一些特殊字符,动态SQL语句使用PrepareStatement.. 2.注入的方式就是在查询条...
    Explorer_Mi阅读 1,260评论 0 0