9.1 第三方过滤函数与类
在一些中小型的Web应用程序中,由于大多数开发者是不怎么懂安全的,所以都会选择一些第三方的过滤函数或者类,直接拿过去套着用,并不知道效果到底怎么样。在PHP安全过滤的类里面,比较出名的有出自80sec团队给出的一个SQL注入过滤的类,在国内大大小小的程序像discuz、dedecms、phpmywind等都使用过。
目前大多数应用都有一个参数过滤的统一入口,类似于dedecms的代码,如下所示:
foreach ( Array ( '_GET' , '_POST' , '_COOKIE' ) as $_request )
{
foreach ( $$_request as $_k => $_v )
{
if ( $_k == 'nvarname' ) ${$_k} = $_v ;
else ${$_k} = _RunMagicQuotes ( $_v );
}
}
跟进_RunMagicQuotes()函数之后的代码如下:
function _RunMagicQuotes ( &$svar )
{
if (! get_magic_quotes_gpc ())
{
if ( is_array ( $svar ) )
{
foreach ( $svar as $_k => $_v ) $svar[$_k] = _RunMagicQuotes ( $_v );
}
else
{
if ( strlen ( $svar ) >0&& preg_match ( '#^ ( cfg_|GLOBALS|_GET|_POST|_COOKIE ) #' , $svar ))
{
exit ( 'Request var not allow ! ' );
}
$svar = addslashes ( $svar );
}
}
return $svar ;
}
而这里仅仅是使用addslashes()函数过滤,确实能防御住一部分漏洞,但是对特定的场景和漏洞就不那么好使了。所以除了总入口,在具体的功能点也需要进行一些过滤。
9.1.1 discuz SQL安全过滤类分析
discuz全称Crossday Discuz!Board,是康盛创想(北京)科技有限公司(英文简称Comsenz)推出的一套开源通用的社区论坛软件系统,使用PHP+MySQL开发,现已被腾讯收购,由于用户量巨大,discuz一直是众多安全爱好者重点研究的对象,所以也被公布过不少的安全漏洞。经过数年的沉淀,如今的discuz主程序在代码安全方面已经做得比较成熟。
discuz在专门有一个SQL注入过滤类来过滤SQL注入请求,不过也出现了多次绕过的情况,下面我们来分析它的这个SQL注入过滤的类。
首先我们先看到discuz的配置文件/config/config_global.php中的“CONFIG SECURITY”部分内容,如下:
// ------------------------- CONFIG SECURITY -------------------------- //
$_config['security']['authkey'] = '3ca530i1uCe7lRke' ;
$_config['security']['urlxssdefend'] = 1 ;
$_config['security']['attackevasive'] = '0' ;
$_config['security']['querysafe']['status'] = 1 ; // 是否开启 SQL 注入防御
// 以下是过滤规则
$_config['security']['querysafe']['dfunction']['0'] = 'load_file' ;
$_config['security']['querysafe']['dfunction']['1'] = 'hex' ;
$_config['security']['querysafe']['dfunction']['2'] = 'substring' ;
$_config['security']['querysafe']['dfunction']['3'] = 'if' ;
$_config['security']['querysafe']['dfunction']['4'] = 'ord' ;
$_config['security']['querysafe']['dfunction']['5'] = 'char' ;
$_config['security']['querysafe']['daction']['0'] = '@' ;
$_config['security']['querysafe']['daction']['1'] = 'intooutfile' ;
$_config['security']['querysafe']['daction']['2'] = 'intodumpfile' ;
$_config['security']['querysafe']['daction']['3'] = 'unionselect' ;
$_config['security']['querysafe']['daction']['4'] = ' ( select' ;
$_config['security']['querysafe']['daction']['5'] = 'unionall' ;
$_config['security']['querysafe']['daction']['6'] = 'uniondistinct' ;
$_config['security']['querysafe']['dnote']['0'] = '/*' ;
$_config['security']['querysafe']['dnote']['1'] = '*/' ;
$_config['security']['querysafe']['dnote']['2'] = '#' ;
$_config['security']['querysafe']['dnote']['3'] = '--' ;
$_config['security']['querysafe']['dnote']['4'] = '"' ;
$_config['security']['querysafe']['dlikehex'] = 1 ;
$_config['security']['querysafe']['afullnote'] = '0' ;
根据笔者的标注(上面加粗代码),我们可以看到discuz配置文件中可以设置是否开启SQL注入防御,这个选项默认开启,一般不会有管理员去关闭,再往下的内容:
$_config['security']['querysafe']['daction'] 以及 $_config['security']['querysafe']['dnote']
都是SQL注入过滤类的过滤规则,规则里包含了常见的注入关键字。
Discuz执行SQL语句之前会调用\source\class\discuz\discuz_database.php文件discuz_database_safecheck类下面的checkquery($sql)函数进行过滤,我们来跟进这个函数看看,代码如下:
public static function checkquery ( $sql ) {if ( self :: $config === null ) { self :: $config = getglobal ( 'config/security/querysafe' ); }if ( self :: $config['status'] ) { $check = 1 ; $cmd = strtoupper ( substr ( trim ( $sql ), 0 , 3 )); if ( isset ( self :: $checkcmd[$cmd] )) { $check = self :: _do_query_safe ( $sql ); } elseif ( substr ( $cmd , 0 , 2 ) === '/*' ) { $check = -1 ; } if ( $check < 1 ) { throw new DbException ( 'It is not safe to do this query' , 0 , $sql ); }}return true ;
}
从代码中可以看到,程序首先加载配置文件中的config/security/querysafe,根据$config['status']判断SQL注入防御是否开启,再到$check=self::_do_query_safe($sql);可以看到该函数又调用了同类下的_do_query_safe()函数对SQL语句进行过滤,我们继续跟进_do_query_safe()函数,代码如下:
private static function _do_query_safe ( $sql ) {
$sql = str_replace ( array ( '\\\\' , '\\\'' , '\\"' , '\'\'' ), '' , $sql );
$mark = $clean = '' ;
if ( strpos ( $sql , '/' ) === false && strpos ( $sql , '#' ) === false && strpos ( $sql , '-- ' ) === false && strpos ( $sql , '@' ) === false && strpos ( $sql , '`' ) === false ) { $clean = preg_replace ( "/' ( .+ ?) '/s" , '' , $sql );
} else { $len = strlen ( $sql ); $mark = $clean = '' ; for ( $i = 0 ; $i < $len ; $i++ ) { $str = $sql[$i] ; switch ( $str ) { case '`' : if (! $mark ) { $mark = '`' ; $clean .= $str ; } elseif ( $mark == '`' ) { $mark = '' ; } break ; case '\'' : if (! $mark ) { $mark = '\'' ; $clean .= $str ; } elseif ( $mark == '\'' ) { $mark = '' ; } break ; case '/' : if ( empty ( $mark ) && $sql[$i + 1] == '*' ) { $mark = '/*' ; $clean .= $mark ; $i++ ; } elseif ( $mark == '/*' && $sql[$i - 1] == '*' ) { $mark = '' ; $clean .= '*' ; } break ; case '#' : if ( empty ( $mark )) { $mark = $str ; $clean .= $str ; } break ; case "\n" : if ( $mark == '#' || $mark == '--' ) { $mark = '' ; } break ; case '-' : if ( empty ( $mark ) && substr ( $sql , $i , 3 ) == '-- ' ) { $mark = '-- ' ; $clean .= $mark ; } break ; default : break ; }$clean .= $mark ? '' : $str ; }}
if ( strpos ( $clean , '@' ) ! == false ) {
return '-3' ;
}
$clean = preg_replace ( "/[^a-z0-9_\-\ ( \ ) #\*\/\"]+/is" , "" , strtolower ( $clean ));
if ( self :: $config['afullnote'] ) {
$clean = str_replace ( '/**/' , '' , $clean );
}
if ( is_array ( self :: $config['dfunction'] )) {
foreach ( self :: $config['dfunction'] as $fun ) {
if ( strpos ( $clean , $fun . ' ( ' ) ! == false )
return '-1' ;
}
}
if ( is_array ( self :: $config['daction'] )) {
foreach ( self :: $config['daction'] as $action ) {
if ( strpos ( $clean , $action ) ! == false )
return '-3' ;
}
}
if ( self :: $config['dlikehex'] && strpos ( $clean , 'like0x' )) {
return '-2' ;
}
if ( is_array ( self :: $config['dnote'] )) {
foreach ( self :: $config['dnote'] as $note ) {
if ( strpos ( $clean , $note ) ! == false )
return '-4' ;
}
}
return 1 ;
}
从如上代码我们可以看到,该函数首先使用:
$sql = str_replace ( array ( '\\\\' , '\\\'' , '\\"' , '\'\'' ), '' , $sql );
将SQL语句中的\\、\'、\"以及''替换为空,紧接着是一个if else判断逻辑来选择过滤的方式:
if ( strpos ( $sql , '/' ) === false && strpos ( $sql , '#' ) === false && strpos ( $sql , '-- ' ) === false && strpos ( $sql , '@' ) === false && strpos ( $sql , '`' ) === false ) {
$clean = preg_replace ( "/' ( .+ ?) '/s" , '' , $sql );
} else {
这段代码表示当SQL语句里存在'/'、#'、'--'、'@'、'`'这些字符时,则直接调用preg_replace()函数将单引号(')中间的内容替换为空,这里之前存在一个绕过,只要把SQL注入的语句放到单引号中间,则会被替换为空,进行下面再判断的时候已经检测不到SQL注入的关键字,导致绕过的出现,在MySQL中使用@`'`代表null,SQL语句可以正常执行。
else条件中是对整段SQL语句进行逐个字符进行判断,比如
case '/' :
if ( empty ( $mark ) && $sql[$i + 1] == '*' ) {
$mark = '/*' ;
$clean .= $mark ;
$i++ ;
} elseif ( $mark == '/*' && $sql[$i - 1] == '*' ) {
$mark = '' ;
$clean .= '*' ;
}
break ;
这段代码的逻辑是,当检查到SQL语句中存在斜杠(/)时,则去判断下一个字符是不是星号(*),如果是星号(*)就把这两个字符拼接起来,即/*,然后继续判断下一个字符是不是星号(*),如果是星号则再继续拼接起来,得到/**,最后在如下代码中判断是否存在原来拦截规则里面定义的字符,如果存在则拦截SQL语句执行:
if ( is_array ( self :: $config['dnote'] )) {
foreach ( self :: $config['dnote'] as $note ) {
if ( strpos ( $clean , $note ) ! == false )
return '-4' ;
}
}
国内知名的多款cms应用如dedecms等,都有使用类似这个过滤类,另外由于应用的基础架构不一样,这个过滤类应用起来的实际效果也各不太一样,discuz目前做得相对较好。
9.1.2 discuz XSS标签过滤函数分析
目前大多数XSS过滤都是基于黑名单的形式,编程语言和编码结合起来千变万化,基于黑名单的过滤总给人不靠谱的感觉,事实确实是这样,目前好像还没有看到基于黑名单过滤的规则一直没有被绕过,其实在XSS的防御上,只要过滤掉尖括号以及单、双引号就能干掉绝大部分的payload。下面我们来看看discuz的HTML标签过滤代码,如下所示:
function checkhtml ( $html ) {
if (! checkperm ( 'allowhtml' )) {
preg_match_all ( "/\< ( [^\<]+ ) \>/is" , $html , $ms );
$searchs[] = '<' ;
$replaces[] = '< ; ' ;
$searchs[] = '>' ;
$replaces[] = '> ; ' ;
if ( $ms[1] ) {
$allowtags = 'img|a|font|div|table|tbody|caption|tr|td|th|br|p|b|strong|i|u|em|span|ol|ul|li|blockquote|object|param' ;
$ms[1] = array_unique ( $ms[1] );
foreach ( $ms[1] as $value ) {
$searchs[] = "< ; ".$value."> ; " ;
$value = str_replace ( '&' , '_uch_tmp_str_' , $value );
$value = dhtmlspecialchars ( $value );
$value = str_replace ( '_uch_tmp_str_' , '&' , $value );
$value = str_replace ( array ( '\\' , '/*' ), array ( '.' , '/.' ), $value );
$skipkeys = array ( 'onabort' , 'onactivate' , 'onafterprint' , 'onafterupdate' , 'onbeforeactivate' , 'onbeforecopy' , 'onbeforecut' , 'onbeforedeactivate' ,
'onbeforeeditfocus' , 'onbeforepaste' , 'onbeforeprint' , 'onbeforeunload' , 'onbeforeupdate' , 'onblur' , 'onbounce' , 'oncellchange' , 'onchange' ,
'onclick' , 'oncontextmenu' , 'oncontrolselect' , 'oncopy' , 'oncut' , 'ondataavailable' , 'ondatasetchanged' , 'ondatasetcomplete' , 'ondblclick' ,
'ondeactivate' , 'ondrag' , 'ondragend' , 'ondragenter' , 'ondragleave' , 'ondragover' , 'ondragstart' , 'ondrop' , 'onerror' , 'onerrorupdate' ,
'onfilterchange' , 'onfinish' , 'onfocus' , 'onfocusin' , 'onfocusout' , 'onhelp' , 'onkeydown' , 'onkeypress' , 'onkeyup' , 'onlayoutcomplete' ,
'onload' , 'onlosecapture' , 'onmousedown' , 'onmouseenter' , 'onmouseleave' , 'onmousemove' , 'onmouseout' , 'onmouseover' , 'onmouseup' , 'onmousewheel' ,
'onmove' , 'onmoveend' , 'onmovestart' , 'onpaste' , 'onpropertychange' , 'onreadystatechange' , 'onreset' , 'onresize' , 'onresizeend' , 'onresizestart' ,
'onrowenter' , 'onrowexit' , 'onrowsdelete' , 'onrowsinserted' , 'onscroll' , 'onselect' , 'onselectionchange' , 'onselectstart' , 'onstart' , 'onstop' ,
'onsubmit' , 'onunload' , 'javascript' , 'script' , 'eval' , 'behaviour' , 'expression' , 'style' , 'class' );
$skipstr = implode ( '|' , $skipkeys );
$value = preg_replace ( array ( "/ ( $skipstr ) /i" ), '.' , $value );
if (! preg_match ( "/^[\/|\s] ?( $allowtags )( \s+|$ ) /is" , $value )) { $value = '' ; }
$replaces[] = empty ( $value )? '' : "<".str_replace ( '" ; ' , '"' , $value ) .">" ;
} } $html = str_replace ( $searchs , $replaces , $html ); }return $html ;
}
从代码中可以看到,这里首先定义了一条正则取出来尖括号中间的内容:
preg_match_all ( "/\< ( [^\<]+ ) \>/is" , $html , $ms );
然后在if($ms[1])这个if条件里对这些标签中的关键字进行筛选,$skipkeys变量定义了很多on事件的敏感字符,如下代码中可以看到,最后拼接正则将这些字符串替换掉:
$skipstr = implode ( '|' , $skipkeys );
value = preg_replace ( array ( "/ ( $skipstr ) /i" ), '.' , $value );