3.2 通读全文代码
前面提到的根据敏感关键字来回溯传入的参数,是一种逆向追踪的思路,我们也提到了这种方式的优缺点,实际上在需要快速寻找漏洞的情况下用回溯参数的方式是非常有效的,但这种方式并不适合运用在企业中做安全运营时的场景,在企业中做自身产品的代码审计时,我们需要了解整个应用的业务逻辑,才能挖掘到更多更有价值的漏洞。
通读全文代码也有一定的技巧,并不是随便找文件逐个读完就可以了,这样你是很难真正读懂这套Web程序的,也很难理解代码的业务逻辑,首先我们要看程序的大体代码结构,如主目录有哪些文件,模块目录有哪些文件,插件目录有哪些文件,除了关注有哪些文件,还要注意文件的大小、创建时间。我们根据这些文件的命名就可以大致知道这个程序实现了哪些功能,核心文件是哪些,discuz的程序主目录如图3-7所示。
图 3-7
在看程序目录结构的时候,我们要特别注意以下几个文件:
1)函数集文件,通常命名中包含functions或者common等关键字,这些文件里面是一些公共的函数,提供给其他文件统一调用,所以大多数文件都会在文件头部包含到其他文件。寻找这些文件一个非常好用的技巧就是去打开index.php或者一些功能性文件,在头部一般都能找到。
2)配置文件,通常命名中包括config关键字,配置文件包括Web程序运行必须的功能性配置选项以及数据库等配置信息。从这个文件中可以了解程序的小部分功能,另外看这个文件的时候注意观察配置文件中参数值是用单引号还是用双引号括起来,如果是双引号,则很可能会存在代码执行漏洞,例如下面Kuwebs的代码,只要我们在修改配置的时候利用PHP可变变量的特性即可执行代码。
< ? php
/* 网站基本信息配置 */
$kuWebsiteURL = "http : //www.kuwebs.com" ;
$kuWebsiteSupportEn = "1" ;
$kuWebsiteSupportSimplifiedOrTraditional = "0" ;
$kuWebsiteDefauleIndexLanguage = "cn" ;
$kuWebsiteUploadFileMax = "2" ;
$kuWebsiteAllowUploadFileFormat = "swf|rar|jpg|zip|gif" ;
/* 邮件设置 */
$kuWebsiteMailType = "1" ;
$kuWebsiteMailSmtpHost = "smtp.qq.com" ;
3)安全过滤文件,安全过滤文件对我们做代码审计至关重要,关系到我们挖掘到的可疑点能不能利用,通常命名中有filter、safe、check等关键字,这类文件主要是对参数进行过滤,比较常见的是针对SQL注入和XSS过滤,还有文件路径、执行的系统命令的参数,其他的则相对少见。而目前大多数应用都会在程序的入口循环对所有参数使用addslashes()函数进行过滤。
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 {
4)index文件,index是一个程序的入口文件,所以通常我们只要读一遍index文件就可以大致了解整个程序的架构、运行的流程、包含到的文件,其中核心的文件又有哪些。而不同目录的index文件也有不同的实现方式,建议最好先将几个核心目录的index文件都简单读一遍。
上面介绍了我们应该注意的部分文件,可以帮助我们更有方向地去读全部的代码,实际上在我们真正做代码审计的时候,经常会遇到各种框架,这时候就会被搞得晕头转向,所以在学习代码审计的前期建议不要去读开源框架或者使用开源框架的应用,先去chinaz、admin5之类的源码下载网站下载一些小应用来读,并且一定要多找几套程序通读全文代码,这样我们才能总结经验,等总结了一定的经验,对PHP也比较熟悉的时候,再去读一些像thinkphp、Yii、Zend Framework等开源框架,才能快速地挖掘高质量的漏洞。
通读全文代码的好处显而易见,可以更好地了解程序的架构以及业务逻辑,能够挖掘到更多、更高质量的逻辑漏洞,一般老手会比较喜欢这种方式。而缺点就是花费的时间比较多,如果程序比较大,读起来也会比较累。
骑士cms通读审计案例
我们已经介绍了代码审计中通读全文代码审计方式的思路,下面我们用案例来说明这种通读方式。
为了方便大家理解,笔者找了一款相对简单容易看懂的应用骑士cms来介绍,版本是3.5.1,具体的审计思路我们在上文中已经有过介绍。
3.2.1.1 查看应用文件结构
首先来看一下骑士cms的大致文件目录结构,如图3-8所示。
图 3-8
首先需要看看有哪些文件和文件夹,寻找名称里有没有带有api、admin、manage、include一类关键字的文件和文件夹,通常这些文件比较重要,在这个程序里,可以看到并没有什么PHP文件,就一个index.php,看到有一个名为include的文件夹,一般比较核心的文件都会放在这个文件夹中,我们先来看看大概有哪些文件,如图3-9所示。
图 3-9
3.2.1.2 查看关键文件代码
在这个文件夹里面我们看到了多个数十K的PHP文件,比如common.fun.php就是本程序的核心文件,基础函数基本在这个文件中实现,我们来看看这个文件里有哪些关键函数,一打开这个文件,立马就看到一大堆过滤函数,这是我们最应该关心的地方,首先是一个SQL注入过滤函数:
function addslashes_deep ( $value )
{
if ( empty ( $value ))
{
return $value ;
}
else
{
if (! get_magic_quotes_gpc ())
{
$value=is_array ( $value )? array_map ( 'addslashes_deep' , $value ): mystrip_ tags ( addslashes ( $value ));
}
else
{
$value=is_array ( $value )? array_map ( 'addslashes_deep' , $value ): mystrip_tags ( $value );
}
return $value ;
}
}
该函数将传入的变量使用addslashes()函数进行过滤,也就过滤掉了单引号、双引号、NULL字符以及斜杠,现在我们要记住,在挖掘SQL注入等漏洞时,只要参数在拼接到SQL语句前,除非有宽字节注入或者其他特殊情况,否则使用了这个函数就不能注入了。
再往下走是一个XSS过滤的函数mystrip_tags(),代码如下:
function mystrip_tags ( $string )
{
$string = new_html_special_chars ( $string );
$string = remove_xss ( $string );
return $string ;
}
这个函数调用了new_html_special_chars()和remove_xss()函数来过滤XSS,就在该函数下方,代码如下:
function new_html_special_chars ( $string ) {
$string = str_replace ( array ( '& ; ' , '" ; ' , '< ; ' , '> ; ' ), array ( '&' , '"' , '<' , '>' ), $string );
$string = strip_tags ( $string );
return $string ;
}
function remove_xss ( $string ) {
$string = preg_replace ( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S' , '' , $string );
$parm1 = Array ( 'javascript' , 'union' , 'vbscript' , 'expression' , 'applet' , 'xml' , 'blink' , 'link' , 'script' , 'embed' , 'object' , 'iframe' , 'frame' , 'frameset' , 'ilayer' , 'layer' , 'bgsound' , 'title' , 'base' );
$parm2 = 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' , 'style' , 'href' , 'action' , 'location' , 'background' , 'src' , 'poster' );
$parm3= Array ( 'alert' , 'sleep' , 'load_file' , 'confirm' , 'prompt' , 'bench-mark' , 'select' , 'update' , 'insert' , 'delete' , 'alter' , 'drop' , 'truncate' , 'script' , 'eval' );
$parm = array_merge ( $parm1 , $parm2 , $parm3 );
for ( $i = 0 ; $i < sizeof ( $parm ); $i++ ) {
$pattern = '/' ;
for ( $j = 0 ; $j < strlen ( $parm[$i] ); $j++ ) {
if ( $j > 0 ) {
$pattern .= ' ( ' ;
$pattern .= ' ( &#[x|X]0 ( [9][a][b] );?)? ' ;
$pattern .= '| ( � ( [9][10][13] );?)? ' ;
$pattern .= ' )? ' ;
}
$pattern .= $parm[$i][$j] ;
}
$pattern .= '/i' ;
$string = preg_replace ( $pattern , '****' , $string );
}
return $string ;
}
在new_html_special_chars()函数中可以看到,这个函数对&符号、双引号以及尖括号进行了html实体编码,并且使用strip_tags()函数进行了二次过滤。而remove_xss()函数则是对一些标签关键字、事件关键字以及敏感函数关键字进行了替换。
再往下走有一个获取IP地址的函数getip(),是可以伪造IP地址的:
function getip ()
{
if ( getenv ( 'HTTP_CLIENT_IP' ) and strcasecmp ( getenv ( 'HTTP_CLIENT_IP' ), 'unknown' )) {
$onlineip=getenv ( 'HTTP_CLIENT_IP' );
}elseif ( getenv ( 'HTTP_X_FORWARDED_FOR' ) and strcasecmp ( getenv ( 'HTTP_X_FORWARDED_FOR' ), 'unknown' )) {
$onlineip=getenv ( 'HTTP_X_FORWARDED_FOR' );
}elseif ( getenv ( 'REMOTE_ADDR' ) and strcasecmp ( getenv ( 'REMOTE_ADDR' ), 'unknown' )) {
$onlineip=getenv ( 'REMOTE_ADDR' );
}elseif ( isset ( $_SERVER['REMOTE_ADDR'] ) and $_SERVER['REMOTE_ADDR'] and strcasecmp ( $_SERVER['REMOTE_ADDR'] , 'unknown' )) {
$onlineip=$_SERVER['REMOTE_ADDR'] ;
}
preg_match ( "/\d{1 , 3}\.\d{1 , 3}\.\d{1 , 3}\.\d{1 , 3}/" , $onlineip , $match );
return $onlineip = $match[0] ? $match[0] : 'unknown' ;
}
很多应用都会由于在获取IP时没有验证IP格式,而存在注入漏洞,不过这里还只是可以伪造IP。
再往下看可以看到一个值得关注的地方,SQL查询统一操作函数inserttable()以及updatetable()函数,大多数SQL语句执行都会经过这里,所以我们要关注这个地方是否还有过滤等问题。
function inserttable ( $tablename , $insertsqlarr , $returnid=0 , $replace = false , $silent=0 )
{global $db ; $insertkeysql = $insertvaluesql = $comma = '' ; foreach ( $insertsqlarr as $insert_key => $insert_value ) {$insertkeysql .= $comma.'`'.$insert_key.'`' ; $insertvaluesql .= $comma.'\''.$insert_value.'\'' ; $comma = ' , ' ; }$method = $replace ? 'REPLACE' : 'INSERT' ; // echo $method." INTO $tablename ( $insertkeysql ) VALUES ( $insertvaluesql ) " , $silent ? 'SILENT' : '' ; die ; $state = $db->query ( $method." INTO $tablename ( $insertkeysql ) VALUES ( $insertvaluesql ) " , $silent ? 'SILENT' : '' ); if ( $returnid && ! $replace ) {return $db->insert_id (); }else {return $state ; }
}
再往下走则是wheresql()函数,是SQL语句查询的Where条件拼接的地方,我们可以看到参数都使用了单引号进行包裹,代码如下:
function wheresql ( $wherearr='' )
{
$wheresql="" ;
if ( is_array ( $wherearr ))
{
$where_set=' WHERE ' ;
foreach ( $wherearr as $key => $value )
{
$wheresql .=$where_set. $comma.$key.'="'.$value.'"' ;
$comma = ' AND ' ;
$where_set=' ' ;
}
}
return $wheresql ;
}
还有一个访问令牌生成的函数asyn_userkey(),拼接用户名、密码salt以及密码进行一次md5,访问的时候只要在GET参数key的值里面加上生成的这个key即可验证是否有权限,被用在注册、找回密码等验证过程中,也就是我们能看到的找回密码链接里面的key,代码如下:
function asyn_userkey ( $uid )
{
global $db ;
$sql = "select * from ".table ( 'members' ) ." where uid = '".intval ( $uid ) ."' LIMIT 1" ;
$user=$db->getone ( $sql );
return md5 ( $user['username'].$user['pwd_hash'].$user['password'] );
}
同目录下的文件如图3-10所示。
图 3-10
图中是具体功能的实现代码,我们这时候还不需要看,先了解下程序的其他结构。
3.2.1.3 查看配置文件
接下来我们找找配置文件,上面我们介绍到配置文件的文件名通常都带有“config”这样的关键字,我们只要搜索带有这个关键字的文件名即可,如图3-11所示。
在搜索结果中我们可以看到搜索出来多个文件,结合文件所在目录这个经验可以判断出data目录下面的config.php以及cache_config.php才是真正的配置文件,打开/data/config.php查看代码,如下所示:
图 3-11
< ? php
$dbhost = "localhost" ;
$dbname = "74cms" ;
$dbuser = "root" ;
$dbpass = "123456" ;
$pre = "qs_" ;
$QS_cookiedomain = '' ;
$QS_cookiepath = "/74cms/" ;
$QS_pwdhash = "K0ciF : RkE4xNhu@S" ;
define ( 'QISHI_CHARSET' , 'gb2312' );
define ( 'QISHI_DBCHARSET' , 'GBK' );? >
很明显看到,很有可能存在我们之前说过的双引号解析代码执行的问题,通常这个配置是在安装系统的时候设置的,或者后台也有设置的地方。另外我们还应该记住的一个点是QISHI_DBCHARSET常量,这里配置的数据库编码是GBK,也就可能存在宽字节注入,不过需要看数据库连接时设置的编码,不妨找找看,找到骑士cms连接MySQL的代码在include\mysql.class.php文件的connect()函数,代码如下:
function connect ( $dbhost , $dbuser , $dbpw , $dbname = '' , $dbcharset = 'gbk' , $connect=1 ) {
$func = empty ( $connect ) ? 'mysql_pconnect' : 'mysql_connect' ;
if (! $this->linkid = @$func ( $dbhost , $dbuser , $dbpw , true )) {
$this->dbshow ( 'Can not connect to Mysql ! ' );
} else {
if ( $this->dbversion () > '4.1' ) {
mysql_query ( "SET NAMES gbk" );
if ( $this->dbversion () > '5.0.1' ) {
mysql_query ( "SET sql_mode = ''" , $this->linkid );
mysql_query ( "SET character_set_connection=".$dbcharset." , character_set_results=".$dbcharset." , character_set_client=binary" , $this-> linkid );
}
}
}
if ( $dbname ) {
if ( mysql_select_db ( $dbname , $this->linkid ) ===false ) {
$this->dbshow ( "Can't select MySQL database ( $dbname )! " );
}
}
}
这段代码里面有个关键的地方,见加粗代码,这里存在安全隐患。
代码首先判断MySQL版本是否大于4.1,如果是则执行如下代码:
mysql_query ( "SET NAMES gbk" );
执行这个语句之后再判断,如果大于5则执行如下代码:
mysql_query ( "SET character_set_connection=".$dbcharset." ,
haracter_set_results=".$dbcharset." , character_set_client=binary" ,
$this->linkid );
也就是说在MySQL版本小于5的情况下是不会执行这行代码的,但是执行了“set names gbk”,我们在之前介绍过“set names gbk”其实干了三件事,等同于:
SET character_set_connection= ’ gbk ’, haracter_set_results= ’ gbk ’,
character_set_client= ’ gbk ’
因此在MySQL版本大于4.1小于5的情况下,基本所有跟数据库有关的操作都存在宽字节注入。
3.2.1.4 跟读首页文件
通过对系统文件大概的了解,我们对这套程序的整体架构已经有了一定的了解,但是还不够,所以我们得跟读一下index.php文件,看看程序运行的时候会调用哪些文件和函数。
打开首页文件index.php可以看到如下代码:
if (! file_exists ( dirname ( __FILE__ ) .'/data/install.lock' ))
header ( "Location : install/index.php" );
define ( 'IN_QISHI' , true );
$alias="QS_index" ;
require_once ( dirname ( __FILE__ ) .'/include/common.inc.php' );
首先判断安装锁文件是否存在,如果不存在则跳转到install/index.php,接下来是包含/include/common.inc.php文件,跟进该文件查看:
require_once ( QISHI_ROOT_PATH.'data/config.php' );
header ( "Content-Type : text/html ; charset=".QISHI_CHARSET );
require_once ( QISHI_ROOT_PATH.'include/common.fun.php' );
require_once ( QISHI_ROOT_PATH.'include/74cms_version.php' );
/include/common.inc.php文件在开头包含了三个文件,data/config.php为数据库配置文件,include/common.fun.php文件为基础函数库文件,include/74cms_version.php为应用版本文件。接着往下看:
if (! empty ( $_GET ))
{
$_GET = addslashes_deep ( $_GET );
}
if (! empty ( $_POST ))
{
$_POST = addslashes_deep ( $_POST );
}
$_COOKIE = addslashes_deep ( $_COOKIE );
$_REQUEST = addslashes_deep ( $_REQUEST );
这段代码调用了include/common.fun.php文件里面的addslashes_deep()函数对GET/POST/COOKIE参数进行了过滤,再往下走可以看到又有一个包含文件的操作:
require_once ( QISHI_ROOT_PATH.'include/tpl.inc.php' );
包含了include/tpl.inc.php文件,跟进看看这个文件做了什么:
include_once ( QISHI_ROOT_PATH.'include/template_lite/class.template.php' );
$smarty = new Template_Lite ;
$smarty -> cache_dir = QISHI_ROOT_PATH.'temp/caches/'.$_CFG['template_dir'] ;
$smarty -> compile_dir = QISHI_ROOT_PATH.'temp/templates_c/'.$_CFG ['template_dir'] ;
$smarty -> template_dir = QISHI_ROOT_PATH.'templates/'.$_CFG['template_dir'] ;
$smarty -> reserved_template_varname = "smarty" ;
$smarty -> left_delimiter = "{#" ;
$smarty -> right_delimiter = "#}" ;
$smarty -> force_compile = false ;
$smarty -> assign ( '_PLUG' , $_PLUG );
$smarty -> assign ( 'QISHI' , $_CFG );
$smarty -> assign ( 'page_select' , $page_select );
首先看到包含了include/template_lite/class.template.php文件,这是一个映射程序模板的类,由Paul Lockaby paul和Mark Dickenson编写,由于该文件较大,我们这里不再仔细分析,继续往下跟进,可以看到这段代码实例化了这个类对象赋值给$smarty变量,继续跟进则回转到index.php文件代码:
if (! $smarty->is_cached ( $mypage['tpl'] , $cached_id ))
{
require_once ( QISHI_ROOT_PATH.'include/mysql.class.php' );
$db = new mysql ( $dbhost , $dbuser , $dbpass , $dbname );
unset ( $dbhost , $dbuser , $dbpass , $dbname );
$smarty->display ( $mypage['tpl'] , $cached_id );
}
else
{
$smarty->display ( $mypage['tpl'] , $cached_id );
}
判断是否已经缓存,然后调用display()函数输出页面,审计到这里是否对整个程序的框架比较熟悉了?接下来像审计index.php文件一样跟进其他功能入口文件即可完成代码通读。