标签 php 下的文章

PHP该用哪个压缩方法gzdeflate、gzcompress、gzencode

Gzip 是一种非常常见的压缩格式,PHP 也有一系列 gz 开头的函数用于操作 gzip 格式的压缩文件。然而令人困惑的是,同样是做压缩处理,却有着 gzdeflategzcompressgzencode 三个函数可用。那么它们都有着什么区别,平时又究竟应该用哪个呢?

这三个函数使用的压缩算法是一样的,都是 DEFLATE 压缩算法,三者的区别仅在于数据的封装格式不同。

  • gzdeflate 函数输出的是 DEFLATE 算法生成的原始数据,也被称作 RAW 格式的数据。由 RFC1951 定义
  • gzcompress 函数输出的是 zlib 格式的数据,它在 DEFLATE 生成的原始数据开头增加了两个字节的 header,末尾增加了四个字节的 ADLER-32 校验和。由 RFC1950 定义
  • gzencode 函数输出的是 gzip 格式,它是 gzip 工具定义的文件存储格式,理论上讲允许使用不同的压缩算法,但实际上目前只有 DEFLATE 一种实现。gzip 格式在 DEFLATE 生成的原始数据开头加入了一个至少10字节的变长header,末尾加入了固定8个字节的 tailer。由 RFC1952 定义

了解了三个函数的区别,就可以根据需要选择具体用哪个函数了。

  • gzdeflate 适用于可靠数据传输和存储环境下,需要减少数据量的情况。比如把数据压缩后存入 redis 或者 MySQL 的 blob 字段。
  • gzcompress 适用于需要处理 zlib 格式数据的场景。比如向 Accept-Encoding: deflate 的浏览器输出压缩的数据流。
  • gzencode 适用于需要被 gzip 工具解压的情况。比如把数据压缩并保存到 OSS 上,命名为 xxx.gz 的文件,允许用户下载后自行解压。

对了,PHP 还有一个 zlib_encode 函数,允许开发人员指定 $encoding 参数,它可以是 ZLIB_ENCODING_RAWZLIB_ENCODING_DEFLATE 或者 ZLIB_ENCODING_GZIP 三者之一,正好对应了 gzdeflategzcompressgzencode 三个函数的效果。如果读代码的人怕搞混,不妨考虑直接使用这个函数。

将PHP程序打包成可执行的phar文件

最近写了一个命令行脚本,涉及composer安装的几个库,还有自己封装的四五个类。不希望把一堆php脚本拷来拷去,最好能像phpunit那样就直接封装成一个可执行的文件。phpunit的做法是把所有相关文件打包封装到一个phar包里去分发,我也可以这么干。

首先配置php.ini里的phar.readonly=0,默认Php解释器对phar是只读访问的,不能修改phar的内容,以免意外修改整体交付的软件包。修改配置之后才可以用来打包生成phar。改好配置之后,建立程序目录结构如下:

.
├── bin
├── generate-phar.php
└── src
    └── index.php

其中src目录里有完整的代码文件,包括composer引入的第三方库。我们用generate-phar.php程序生成phar文件,并把它放到bin目录里去。

<?php
//在bin目录下创建phar文件
$phar = new Phar(__DIR__ . DIRECTORY_SEPARATOR . 'bin/mytool.phar');

//从src目录构建phar包
$phar->buildFromDirectory('src');

//定义默认执行入口为index.php
$defStub = Phar::createDefaultStub('index.php');

//设置php解释器shell头,让phar可以自己执行
$phar->setStub("#!/usr/bin/env php\n$defStub");

//用bzip2库压缩phar包里的文件(此步要求PHP安装了zlib和bz2扩展,可以跳过)
$phar->compressFiles(Phar::BZ2);

//将phar包改名,去掉phar扩展名
rename('bin/mytool.phar', 'bin/mytool');

//授予phar包可执行权限
chmod('bin/mytool', 0755);

现在可以用bin/mytool直接执行包中的程序了,phar会自动使用系统中安装的PHP解释器,如果想在任意目录运行此程序,可以把phar包拷到$PATH路径覆盖的目录中,比如/usr/local/bin

x2ts最近的几个大动作

最近在将x2ts由git submodule使用方式重构到composer上,顺便检讨之前设计不合理的地方并加以修订。预计将会有以下几大调整:

  1. 使用composer管理依赖
  2. 遵循PSR-1开发规范
  3. 使用PSR-4 autoload机制
  4. 日志模块基于monolog实现
  5. 配置不再保存于static变量中,迁移到Configuration类
  6. 加入事件支持和全局事件总线
  7. 路由器切分为路由和规则两部分
  8. Router和Action类中的事件改向全局事件总线触发
  9. 除MVC外的组件迁移到单独的库中,作为可选模块使用
  10. 加入Unit Test

PDO prepare足以防止SQL注入吗?

这是一篇翻译的文章,可以点击查看原文


简单来说不能,PDO prepare无法防御全部的SQL注入攻击。比如一些潜在的边缘情况。

我调整了这个答案以适应PDO的场景……

完整的答案是不太容易,这篇文章证明了这种攻击的可能。

攻击实现

那么,接下来我来演示这种攻击……

$pdo->query('SET NAMES gbk');
$var = "\xbf\x27 OR 1=1 /*";
$query = 'SELECT * FROM test WHERE name = ? LIMIT 1';
$stmt = $pdo->prepare($query);
$stmt->execute(array($var));

某些情况下,上面的代码会返回多于一行数据。让我们剖析一下这里到底发生了什么:

1. 选择一个编码集

$pdo->query('SET NAMES gbk');

要让这种攻击生效,我们需要服务编码和连接编码两者都把'以ASCII形式编码,也就是0x27并且这种编码中要存在某个字符以ASCII的\也就是0x5c结尾。事实上,MySQL5.6默认支持的编码中有五种符合这一要求:big5cp932gb2312gbk还有sjis。我们这里用gbk举例。

现在,请格外注意这里的SET NAMES指令,它改变了服务器上的字符集。当然还有另一种方法修改字符集,我们后面会谈到。

2. 要嵌入的字符串

我们用于演示SQL注入的字符串以0xbf27开头。在gbk编码中,这是个不合法的多字节字符,而在latin1编码中,它是字符串¿'。注意,不论latin1还是gbk编码,0x27都是半角单引号'字符。

我们选择这个字串的原因在于,如果我们对它调用addslashes(),我们会在'字符前插入一个ASCII码的\字符,即0x5c。然后我们得到字符串编码是0xbf5c27,在gbk编码中,这是两个字符0xbf5c,后面接着0x27。换句话说,这是一个合法的字符后面跟着一个没转义的'。不过我们没调用addslashes(),那么会进行下一步。

3. $stmt->execute()

这里有一件重要的事情需要搞清楚的是,PDO在默认情况下并不会真的对语句做prepare,而是会模拟这一行为(对于MySQL)。也就是说,PDO会在内部构建查询语句,对每个绑定的字符串调用mysql_real_escape_string()(MySQL的C语言API中的函数)。

C语言API调用mysql_real_escape_string()addslashes()的不同点在于前者知道MySQL连接的字符集,所以它可以正确地根据服务器要使用的字符集进行转义。然而,正因为这一点,MySQL客户端会使用latin1进行转义——因为我们从来没有通知它切换字符集的事情。我们的确告诉服务器我们在用gbk,可是客户端仍以为是latin1

这样的情况下调用mysql_real_escape_string()时会插入反斜杠\,而那个挂单的'就会被转义出来!实际上,如果我们以gbk编码查看$var的值是:

縗' OR 1=1 /*

以上就是进行攻击的环境要求。

4. 生成的查询语句

这部分只是为了完整,下面是生成的SQL查询语句:

SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1

恭喜,你刚刚完成了一次针对PDO prepare程序的攻击。

简单的修复方法

值得注意的是你可以简单地通过禁用prepare语句模拟功能来防御这种攻击:

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

这样一般导致PDO执行真正的prepare操作。(数据会独立于SQL语句传送给服务器。)不过还是得小心PDO会在本地的MySQL无法完成prepare时悄悄地恢复使用模拟prepare语句的状态。具体情况在MySQL手册中已然列出,请注意选择对于你服务器版本的手册。

正确的修复方法

这里真正的问题在于我们没有调用MySQL的C语言API中的mysql_set_charset(),而是使用了SET NAMES语句。如果正确调用了mysql_set_charset(),那么使用2006年之后发布的MySQL版本都将安全。

如果你仍在使用更早版本的MySQL,那么mysql_real_escape_string()中的一个bug会导致它把类似我们例子中的不合法的多字节字符串当作单字节字符串加以处理,这样即便客户端正确地指定了连接编码集和服务器编码集,仍会存在被攻击的风险。这个bug在MySQL 4.1.205.0.225.1.11中得以修复。

不过最糟的问题在于PHP 5.3.6之前版本的PDO压根就没暴露C语言API中的mysql_set_charset()方法,所以早于此版本的用户根本不可能阻止这种类型的攻击。现在PDO以一个DSN参数的形式暴露了这个接口,应该用这种方法取代SET NAMES……

其它好办法

话说回来,这种攻击能生效的前提条件是数据库连接使用了薄弱的字符编码。utf8mb4是一种不存在此类弱点的字符集编码方法,而且它能支持所有的Unicode字符——你应该考虑切换到这种编码上。可惜只有MySQL 5.5.3以上版本支持这一编码。另一个可以考虑的替代字符集编码是utf8,它也不存在前述弱点,而且能支持所有Unicode基本多文种平面中的字符

此外,你也可以启用NO_BACKSLASH_ESCAPES SQL模式,这会改变mysql_real_escape_string()的内部行为(也包括其它东西)。当这个模式启动时,0x27会被替换为0x2727而不是0x5c27,这样转义后输出的字符在所有的薄弱的编码字符集中都不会和前面的字符连成合法的存在(比如0xbf27转义出来还是0xbf27),最终服务器就会拒绝这个非法的字符串。不过请看看@eggyal的回答以了解这种SQL模式下的别的漏洞(尽管与PDO无关)。

安全的例子

以下例子是安全的:

mysql_query('SET NAMES utf8');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为服务器期待utf8……

mysql_set_charset('gbk');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为已经正确地设置和服务器匹配的客户端字符集。

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为关掉了prepare模拟。

$pdo = new PDO('mysql:host=localhost;dbname=testdb;charset=gbk', $user, $password);
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为已经正确地设置字符集。

$mysqli->query('SET NAMES gbk');
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "\xbf\x27 OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();

因为MySQLi总是执行真正的prepare操作。

总结一下

如果你:

  • 使用了现代版本的MySQL(晚期版本的5.1,所有的5.5,所有的5.6等等)并且使用了PDO的DSN里的charset参数(PHP ≥ 5.3.6)

或者

  • 没用有弱点的连接字符集编码(比如只使用utf8latin1ascii等等)

或者

  • 启用了NO_BACKSLASH_ESCAPES SQL模式

那么你100%安全。

其它情况下,即使你用了PDO的prepare方法也依然存在SQL注入风险

补充

我正在慢慢编写一个补丁程序让未来版本的PHP默认不要使用模拟prepare方法。导致我进展缓慢的原因在于实在太多测试阻止我这么做。其中一个问题是模拟prepare功能只会在执行的时候返回SQL语法错误,然而真正的prepare可能会在进行prepare的时候就回报错误,这会带来许多麻烦(这也是导致那些测试出错的部分原因)。

吐槽微信的AES加密设计

微信公众号平台的消息可以使用AES加密,他们在技术文档中说明,他们使用了256位AES加密算法,并按pkcs#7标准对加密数据进行补位,也提供了官方的PHP版加解密库程序。

我浏览了一下他们提供的PHP程序,不得不吐槽一下这个DEMO的开发者。看代码的感觉完全是一个非PHP程序员一边查着手册一边写出来的。代码里有不少低效率的写法和实现,所以我决定要重写一遍他们的加解密库。

考虑到OpenSSL库实现的AES性能是mcrypt库的好几倍,所以我优先考虑基于OpenSSL函数来开发。结果问题就出来了,AES加密算法规定的block-size是128位,不是整数倍block-size的数据需要先补位才能进行AES加密。腾讯选择pkcs#7规范进行补位我没意见,可是你定义的最大补位长度为什么是32字节?按理说最大补位长度应该和AES的block-size一样,128位应该是16字节才对呀。腾讯的这个奇怪规定直接导致无法使用OpenSSL库内置的补位支持。PHP的OpenSSL函数库到PHP5.4版以上才加入了0补位的选项参数,这才能实现自定义补位,这直接导致我的代码无法兼容PHP5.3以前的版本。

我把微信公众号开发用到的库整理出来,放到github上,目前先有这个加解密库,后续准备把微信的Web Service API接口也封装一下,逐渐发布。项目地址: https://github.com/SyuTingSong/php-wechat