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[] = '&lt
'

        $searchs[] = '>'


        $replaces[] = '&gt
'

        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[] = "&lt
".$value."&gt "

            $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 '&quot ' '"' $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 );