1 缘由
前段时间在折腾discuz的PHP版本升级,据说PHP7比PHP5的性能提升了很多,于是新建了一个论坛镜像,将其中PHP版本从5.x升级了7.x版本,将原来跑PHP5的容器替换为PHP7的容器,docker在做升级软件的时候确实很方便,不会影响宿主机环境。从统计数据看,测试论坛的平均响应时间确实缩短了20%左右,效果不错。只是第二天有同事反馈,说论坛有用户发帖称自己的用户名变成乱码了。
2 查证
刚刚收到这个问题的时候,有点疑惑,看到这个用户名中有繁体字”誠“,想着可能是繁体字的问题,于是在测试环境中发了个帖子,帖子内容就是这个用户名,发现果然显示乱码了,查了下数据库表,发现存储的内容是截去了部分字节。接着用其他的几个繁体字测试了下,发现居然没有乱码,看来不是所有繁体字都会导致乱码。
一开始是比较困惑的,只是升级了PHP版本,按理不会引起这种BUG才对,这看起来是MySQL的编码设置的问题,也就是character_set_client, character_set_connection, character_set_result
这几个变量设置错误导致的。于是,就去查看discuz在新旧版本的PHP版本下数据库连接代码的不同的地方。最终发现discuz升级到PHP7以后,用的是source/class/db/db_driver_mysqli.php
下面的连接代码,这里与PHP5使用的source/class/db/db_driver_mysql.php
略有不同,PHP7用的新的 mysqli 库,PHP5用的是老的 mysql 库,两个库本身的功能是兼容的,但是数据库连接这里的编码设置发现了一些不同之处。
#PHP5采用的连接代码,dbcharset在我们系统中是GBK
$dbcharset = $dbcharset ? $dbcharset : $this->config[1]['dbcharset'];
$serverset = $dbcharset ? 'character_set_connection='.$dbcharset.
', character_set_results='.$dbcharset.', character_set_client=binary' : '';
$serverset && mysql_query("SET $serverset", $link);
#PHP7采用的连接代码
$link->set_charset($dbcharset ? $dbcharset : $this->config[1]['dbcharset']);
$serverset .= 'character_set_client=binary';
$serverset && $link->query("SET $serverset");
乍一看似乎是一样的,都是设置了 character_set_client=binary
,然后将其他几项编码设置为GBK。(注意这里的character_set_client=binary
设置,这是很重要的一个设置,在GBK编码的数据库中,MySQL的相关编码如果设置不对很容易引起宽字节注入,这个设置项就是为了防止SQL的宽字节注入的。关于宽字节注入这篇文章有很详细的说明 http://www.freebuf.com/articles/web/31537.html)
回到我们的问题,对比两个版本的数据库字符集设置的代码,由于PHP7使用了新的mysqli库,它是通过 set_charset()
函数来指定的编码的,另外,还额外加了一个character_set_client=binary
的配置。这个与PHP5的直接设置三个字符集的有所不同,set_charset
是官方推荐的设置方式,从参考资料一可以知道,它除了SET NAMES xxx
之外,还设定了escape_string
时采用的编码mysql->charset
。当数据库操作的时候,对变量转义会用到mysqli
库的escape_string
函数,即比如对于\, '
这些字符,在数据库操作之前转义为\\, \'
等。而新旧版本的不同之处在于,新版本的set_charset函数设置了escape_string
所采用的字符集,而乱码问题恰恰是因为set_charset
和character_set_client=binary
这两个设置混用导致的。
#set_charset函数部分代码
sprintf(buff, "SET NAMES %s", cs_name);
if (!mysql_real_query(mysql, buff, strlen(buff)))
{
mysql->charset= cs;
}
创建一个测试表如下:
CREATE TABLE `post` (
`idx` int(11) DEFAULT NULL,
`content` varchar(60) DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=gbk;
测试代码如下:
<?php
$link = new mysqli();
$link->real_connect('localhost', 'dztest', 'dztestpasswd', 'enctest', null, null, MYSQLI_CLIENT_COMPRESS);
#老版本的编码设置,最终数据没有乱码
mysqli_query($link, "SET character_set_connection=gbk, character_set_results=gbk,character_set_client=binary");
$v = "\xd5\x5c\xca\xb5"; // "誠实" 的GBK编码
$v1 = $link->escape_string($v);
echo "$v1\n"; // output: “誠\实"
#新版本编码设置,最终产生乱码
$link->set_charset('gbk');
$link->query("SET character_set_client=binary");
#在set_charset后,escape_string会考虑当前字符集GBK.
$v2 = $link->escape_string($v);
echo "$v2\n"; // output: “誠实"
$sql = "INSERT INTO post values(1, '$v1')";
$ret = mysqli_query($link, $sql);
$sql = "INSERT INTO post values(2, '$v2')";
$ret = mysqli_query($link, $sql);
?>
如测试代码中所示,对于带有繁体字的 誠实
,它的GBK编码为\xd5\x5c\xca\xb5
,注意到编码中的字节0x5c对应的ASCII字符就是\
。在下面的示例代码中,输出1是誠\实
,也就是说escape_string只是将誠实
当做普通的ASCII字符处理,将\xd5\x5c\xca\xb5
转义成了\xd5\x5c\x5c\xca\xb5
,而并不考虑当前的字符集编码为GBK,因为没有设置escape_string用到的字符集mysql->charaset为GBK。恰巧又有character_set_client=binary
,于是mysql在编码转换的时会进行类似unescape处理,最终存储到数据库的是正确的誠实
,通过SELECT hex(content) FROM post
,查看发现字段内容为d55ccab5
,没有乱码。
而在新版本的这种设置方式下,输出2是誠实
,也就是说在escape_string的时候考虑了当前字符集为GBK,因为我们通过set_charset("GBK")设置了escape_string用到的字符集mysql->charset=GBK。转义后还是\xd5\x5c\xca\xb5
,而由于character_set_client=binary
,在mysql中由 character_set_client
->character_set_connection
->column character_set
时,即binary->gbk->gbk时,会进行unescape,由于\x5c
后面跟的是并不能unescape的字符,最终存储的数据变成了帐
,它的GBK编码是d5ca
。也就是说除了去掉\x5c
,还把最后的\xb5
截掉最后留下两个字节。
最终表的内容如下:
mysql> select * from post;
+------+---------+
| idx | content |
+------+---------+
| 1 | 誠实 |
| 2 | 帐 |
?>
那要修复这个问题,有两种方案:
- 其一是使用
set_charset()
来统一设定所有编码,而不要再额外添加character_set_client=binary
,但是这样的话就要保证代码中所有涉及数据库的变量的转义操作采用的是mysqli_escape_string
,不要用addslashes
以及mysql_escape_string
这些不考虑字符集的转义函数,否则会有宽字节注入的风险。 - 其二是如果老的系统中不能保证变量都正确转义了的话,则最好采用
character_set_client=binary
的方式,而不使用set_charset()
函数。看起来有点阴差阳错,不过最终的编码恰好是正确的,这也是discuz在PHP5版本中采用的方式。
简单总结下,MySQL采用GBK编码在设置连接字符集的时候要当心,设置错误就可能会导致乱码或者宽字节注入漏洞,使用UTF8编码应该可以规避宽字节注入问题。另外提一点,GB2312也没有这个问题,GB2312的编码范围和GBK其实是不同的。