LongLong's Blog

分享IT技术,分享生活感悟,热爱摄影,热爱航天。

使用Taint扩展检测PHP安全漏洞

PHP做Web开发过程中最为常遇到的两种安全问题就是XSS和SQL注入,理论上如果开发人员有较高的安全意识,在开发过程中对于外部输入的数据做合理的验证及过滤操作是可以完全避免这些安全问题的,然而严格的控制开发人员的开发能力是较难实现的,为此最好能够通过代码框架来处理好相应的数据验证和过滤流程,但如果代码并没有基于框架开发或框架中没有集成数据过滤和验证的流程,则通过一些外部的工具进行安全的检测也是一个不错的办法。

PHP的Taint扩展通过监测PHP的执行过程来发现其中没有经过处理就使用用户输入而可能导致的问题,具体可以参考官方项目地址,不过感觉真正在用的人似乎并不太多,目前只能支持PHP 5.4及以下版本和PHP 7,即PHP 5.5和5.6不能支持。

1. Taint的安装

Taint和一般的PHP扩展的安装方法完全相同,执行以下过程即可

#PHP 7
wget http://pecl.php.net/get/taint-2.0.0.tgz
#PHP 5.4
wget http://pecl.php.net/get/taint-1.2.2.tgz

tar -xzvf taint-2.0.0.tgz
phpize
./configure
make && make install

默认Taint不会被开启,需要在PHP配置中增加以下配置,并且注意不要在正式的环境中使用Taint,其会一定程度影响代码的执行效率

extension=taint.so
taint.enable=1

2. 对XSS的检测

能够进行识别的XSS漏洞就是直接输出了一段外部输入的内容,即以下形式的代码

<?php
$id = $_GET['id'];
//...
?>
<a><?php echo $id;?></a>

如果输入的内容包含html标签,就可以向页面中注入一段自定义的内容,虽然这段内容不能够进入数据库,但攻击者可以制造一个这种连接,并引导让用户点击后就可以读取到一些用户信息之类的内容(对于这类问题可以将用户关键的Cookie信息设置为Http Only)或让用户自动进行一些行为(如给好友发带有这种连接的消息进行传播),在安装了Taint的情况下,如果执行以上代码就有相应的警告提示如下

 Warning: main() [echo]: Attempt to echo a string that might be tainted in /srv/http/index.php on line 4

而如果做了相关的过滤处理则就不会有相应的警告了,例如使用strip_tags或是intval等方法。

<?php
$id = strip_tags($_GET['id']);
//...
?>
<a><?php echo $id;?></a>

但通过对内容没有起到实质过滤效果的方法后的变量依然会有相应的警告,例如以下的几种处理

<?php
$id = trim($_GET['id']);
echo $id;
$my_id = 'my' . $_GET['id'];
echo $my_id;
$ids = explode(',', $_GET['ids']);
echo $ids[0];

3. 对SQL注入的检测

Taint对于SQL注入的检测,是其在PHP提供的执行SQL语句的函数(目前只有mysql sqlite和PDO相关的函数)执行前判断SQL语句的变量中是否使用到了没有经过处理的用户输入会有相应的提示,例如以下代码

<?php
function pdo_do_query($sql, $param) {
    static $pdo;
    if (!$pdo) {
        $pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '');
    }
    $stat = $pdo->prepare($sql);
    $stat->execute(array_values($param));
    return $stat->fetchAll();
}
$id = $_GET['id'];
//pdo_do_query("SELECT * FROM t WHERE id > ?", array('i' => $id));
pdo_do_query("SELECT * FROM t WHERE id > {$id}", array());

使用了prepare方法时不会有警告提示,而如果在SQL语句中直接嵌入了变量则会有以下提示

 Warning: pdo_do_query() [prepare]: SQL statement contains data that might be tainted in /srv/http/index.php on line 7

同时如果使用了相应的过滤方法也不会有警告提示,例如以下代码就不会有相应的警告

<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '');
$id = $pdo->quote($_GET['id']);
$sql = "SELECT * FROM t WHERE id > {$id}";
$stat = $pdo->prepare($sql);
$stat->execute();
$stat->fetchAll();

4. Taint代码的几个问题及修改

感觉有可能是使用的人比较少,发现Taint有几个明显的问题但却没有人指出来,首先其对PDO的SQL注入检测目前是不起作用的,其代码如下

if (strncmp("pdo", class_name, cname_len) == 0) {
    if (strncmp("query", fname, len) == 0
        || strncmp("prepare", fname, len) == 0) {
        zval *sql = ZEND_CALL_ARG(ex, arg_count);
        if (IS_STRING == Z_TYPE_P(sql) && TAINT_POSSIBLE(Z_STR_P(sql))) {
            php_taint_error(fname, "SQL statement contains data that might be tainted");
        }
    }
    break;
}

然而经过测试发现PDO的类名实际上是大写字母,即class_name为”PDO”导致以上的判断一直都无法成立,因此会没有作用。另外mysqli没有对prepare方法进行检测,虽然此方法一般情况都是用bind_param传递参数,但也不排除可能有的SQL里也会嵌入一些参数,所以严格意义上也是需要检测的。

最后PDO还遗漏了一个exec的方法也可以执行SQL语句也没有做检测。为此以上代码可以修改为

if (strncmp("PDO", class_name, cname_len) == 0) {
    if (strncmp("query", fname, len) == 0
    	|| strncmp("exec", fname, len) == 0
        || strncmp("prepare", fname, len) == 0) {
        zval *sql = ZEND_CALL_ARG(ex, arg_count);
        if (IS_STRING == Z_TYPE_P(sql) && TAINT_POSSIBLE(Z_STR_P(sql))) {
            php_taint_error(fname, "SQL statement contains data that might be tainted");
        }
    }
    break;
}

以上的问题已提交给作者,希望之后的版本中能够得到修复。

使用Nginx进行页面静态化

对于动态的页面内容,我们可以保存一份静态页面用于在不能提供动态服务时,例如应用服务出现故障或事进行维护等情况时使用保存的静态页面提供一个降级的服务,以降低故障或维护时产生的影响,使网站一只可以处于能够访问的状态。这一过程会涉及两个事情,首先是如果保存一份静态化的网站,另外就是如何利用静态化的网站提供服务以及进行自动的切换。

1. 生成静态化的网站的方法

对于生成网站静态化镜像,最为简单的方法就是使用wget进行递归的页面抓取,使用方式大致如下

wget -r http://www.sina.com.cn

wget会分析页面中的地址,并生成一个www.sina.com.cn的目录,里面会按照页面地址的url生成目录结构和对应的文件,理论上只要存在引用关系的页面都可以被收录。操作非常简便,但缺点就是不能够做增量的更新,在网站内容较多时完整生成一次需要较长的时间。

另外的方法就是使用CDN保存网站的镜像,总体操作也是比较简单的,但涉及的一些细节处理会稍微麻烦一些,如我们只能够将匿名用户访问的结果作为静态化的结果,在CDN上作这些处理会比较难实施一些。这里尝试使用Nginx的proxy_cache功能将从后端获得的页面在本地保存一份。

2. 对访问的分流处理

由于只能将匿名用户访问的结果作为静态化的结果,则需要对用户身份进行分流,只对匿名用户的请求作静态化存储。一般的网站都会用Cookie来标示用户的身份,将含有合法Cookie的用户认定为某个特定的用户,因此简单的判断就可以是将没有身份认证Cookie的用户认为是匿名用户,这样虽然不能做到准确分流,但总是不会有错误的。对应的Nginx配置如下

location / {
	if ($cookie_auth != "") {
		rewrite ^ /direct last;
	}
	#缓存文件的配置
}

location /direct {
	internal;
	rewrite ^ $request_uri break;
	proxy_pass http://backend;
}

将可能是登录用户的请求重定向到一个直接访问,不进行静态化的location中。

3. 存储静态化页面

存储静态化页面主要是用Nginx proxy模块中的proxy_cache指令,这里主要是需要处理当访问的是一个目录时需要将文件保存成对应的目录下的index.html文件,并且由于有些页面中包含请求参数,不同的请求参数可能会对应两个完全不同的页面,因此需要将请求参数也作为文件名的一部分进行保存。另外由于动态内容没有Last-Modified头,会导致每次都会覆盖之前的文件,如果不需要这样的行为可以进行一下文件存在的判断,如果存在则和登录用户一样处理,其大致的配置如下

set $store_uri $host$request_uri;
if ($request_uri ~ "/$") {
	set $store_uri $host$request_uri/index.html;
}
if (-f $document_root/$store_uri) {
	rewrite ^ /direct last;
}
proxy_set_header Accept-Encoding ""; 
proxy_pass http://backend;
proxy_store $document_root/$store_uri;

由于同一个server可能会对应多个域名,这里将域名也作为存储目录的一部分进行保存,同时由于存在压缩的问题,这里进行一个proxy的gzip hack,即强制获得一个不压缩的页面,然后再根据用户的请求需求进行压缩处理。

4. 静态化文件的使用和自动切换

使用静态化文件时需要注意,如果直接将缓存的目录作为对应的根目录,则当有请求参数时,Nginx会忽略请求参数查找对应的文件,会导致保存的带有参数的文件不起作用,这里需要使用try_files指令制定查找带有参数的文件,其配置如下

error_page 500 502 504 = @error;

location @error {
	try_files /$host$request_uri /$host$request_uri/index.html = 404;
} 

同时将错误页面指向这里就可以在出错时自动使用静态化的网站进行临时的服务,待到服务恢复时切换会正常的服务。

5. 最终完整的配置

最终完整的配置如下

server {
    listen      8088;
    server_name  localhost;

    charset utf-8;

    root data;

    location / { 
        if ($cookie_auth != "") {
            rewrite ^ /direct last;
        }   

        set $store_uri $host$request_uri;
        if ($request_uri ~ "/$") {
            set $store_uri $host$request_uri/index.html;
        }   
        if (-f $document_root/$store_uri) {
            rewrite ^ /direct last;
        }   

        proxy_set_header Accept-Encoding ""; 
        proxy_pass http://backend;
        proxy_store $document_root/$store_uri;
    }   

    location /direct {
        rewrite ^ $request_uri break;
        proxy_pass http://backend;
    }   

    error_page 500 502 504 = @error;

    location @error {
        try_files /$host$request_uri /$host$request_uri/index.html = 404;
    }   
}