GBK编码中%BF吞掉转义符研究

零、PHP的addslashes函数

在PHP中,常常使用addslashes函数来防止SQL注入。

看名字就知道addslashes函数的作用是添加(add)反斜杠(slashes),该函数的输入和输出都是字符串,
该函数会给输入的字符串中的特殊字符前加上反斜杠(\)。特殊字符有四个:

  • 单引号(’)
  • 双引号(”)
  • 反斜杠(\)
  • NULL

如:

    <?php
        $str = "It's red";
        echo "select * from pets where description = '$str'";
        // 输出: select * from pets where description = 'It's red'
        $str = addslashes($str);
        echo "select * from pets where description = '$str'";
        // 输出: select * from pets where description = 'It\'s red'
    ?>

一、字符编码与字符串存储

众所周知,字符编码有很多种,每种编码都可以理解为一个字符到数值的映射,而且这个映射是一个一一映射。
集合A是字符集合,集合B是数值集合,字符编码是集合A到集合B的映射f,
并且对于集合B中的任一元素,在集合A中有且仅有一个原象。
由于一一映射的特殊性,说f是集合B到集合A的映射也是没有问题的。

在数学上,1和00000001是相等的,区别仅仅在于书写时费的笔墨多少,而这是无关紧要的事。
但在计算机中1和00000001虽然数值相等,但占用的存储空间大小不同,这是重要且无法忽略的事情。
为了避免混乱,字符编码中的数值除了指定数值本身的大小外,还需要指定存储这个数值所花去的存储空间的大小。
为便于计算机处理,存储空间大小一般都是8比特(一字节)的整数倍。
在某些编码中存储空间大小是固定的,所有字符对应的数值都用相同大小的存储空间存储,如ASCII码;
在另一些编码中,存储空间大小是不固定的,某些字符对应的数值占用的存储空间大,某些字符对应的数值占用的存储空间小。

字符串由字符组成,每个字符又可以映射成一个长度明确的数值,将这些长度明确的数值连续存储,便存储了字符串。
(我在数据结构课本上也看到过用链表存储字符串的方案,链表中每个节点存储一个字符。但在实际的计算机系统中,我从未遇到过以这种方式存储的字符串。)

如在GBK编码中,用一字节长度的97表示字符“a”(即0x61),用两字节长度的53947表示字符“一”(即0xD2BB),
则字符串“a一”在计算机中存储为:0x61D2BB。

“用一字节长度的97”这样的叙述显得很啰嗦,在计算机世界中,我们习惯于用16进制表示数。
一字节长度的97用16进制表示为0x61,两字节长度的97用16进制表示为0x0061,以此类推。
这样的16进制表示法,同时表示了数值的大小和存储空间大小,很是方便,下文中的数值均使用这种方法表示。

二、GBK编码如何吞掉转义符

0x27是GBK编码中的单引号“’”,0x5C是GBK编码中的反斜杠“\”。
0xBF和0xBF27都不是有效的GBK编码,0xBF5C是GBK编码中的汉字“縗”。

现在,我们将字符串0xBF27作为函数addslashes的输入,
该函数发现0xBF和0xBF27都不是有效的字符编码从而跳过了无法识别的0xBF,
又发现0x27是特殊字符单引号“’”,所以在0x27前加上了“\”(0x5C),最终输出0xBF5C27。

0xBF5C27这个字符串将被理所当然地理解为由0xBF5C和0x27这两个字符组成,
于是addslashes的输出便成了“縗’”,原本该被用来转义“’”的“\”被0xBF吞掉了。

下面是实际测试,设有如下的php代码:

    <?php
            header("Content-Type:text/html;charset=GBK");
            $u = $_GET['u'];
            echo "input is: $u<br>";
            $u = addslashes($u);
            echo "after addslashes is: $u<br>";
            $sql = "select * from user where user='$u'";
            echo "sql is: $sql<br>";
    ?>

将该代码保存为test.php,在浏览器中访问该文件。

先输入:test.php?u=a’,输出为:

    input is: a'
    after addslashes is: a\'
    sql is: select * from user where user='a\''

再输入:test.php?u=%BF’,输出为:

    input is: �'
    after addslashes is: 縗'
    sql is: select * from user where user='縗''

可见,GBK编码中,0xBF确实吞掉了转义符“\”。

三、为何是0xBF

想要0xXY能够吞掉转义“’”的转义符“\”需要满足以下几个条件:

  • 0xXY不是有效字符编码
  • 0xXY27不是有效字符编码
  • 0xXY5C是有效字符编码

满足这一条件的只有0xBF吗?当然不,GBK编码中的单字节字符完全兼容ASCII编码,双字节字符的总体编码范围为0x8140~0xFEFE,首字节在0x81~0xFE之间,尾字节在0x40~0xFE之间,剔除0xXX7F一条线。

我们注意到,尾字节范围在0x40~0xFE之间,0x27不在此范围内,0x5C恰巧在此范围内。
所以能够吞掉转义“’”的转义符“\”的0xXY有很多很多,远不止0xBF一个。
只要0xXY在0x81~0xFE之间就都可以达到此效果。简单测试下确实如此。

为何网上广为流传的会是0xBF呢?类似0xAA这样的组合多好记啊。

四、只有GBK编码有此问题吗

只有GBK编码中存在神奇的0xXY可以吞掉“\”,在其他编码中是否也存在这样的0xXY呢?

gb2312和GBK同源同根,GBK是gb2312的扩充,对GBK有效的0xXY对gb2312也有效。

除了gb2312和GBK外,我们最常见的字符编码便是UTF-8了。UTF-8中存在特殊的0xXY吗?很遗憾,并不存在。
下面这段UTF-8的编码规则摘自参考文献[3]:

UTF-8是一种变长字节编码方式。对于某一个字符的UTF-8编码,如果只有一个字节则其最高二进制位为0;如果是多字节,其第一个字节从最高位开始,连续的二进制位值为1的个数决定了其编码的位数,其余各字节均以10开头。UTF-8最多可用到6个字节。

    1字节 0xxxxxxx
    2字节 110xxxxx 10xxxxxx
    3字节 1110xxxx 10xxxxxx 10xxxxxx
    4字节 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
    5字节 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
    6字节 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

可见,UTF-8中多字节字符的每个节的最高位一定是1,而0x5C的最高位为0,
所以并不存在0xXY,使得0xXY5C是效字符编码,也不存在0xXYZW,使得0xXYZW5C是效字符编码。

所以,UTF-8编码无此问题。

参考文献

  1. addslashes
  2. GBK编码范围
  3. UTF-8编码规则

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

17 − 16 =