RIPS是一款对PHP源码进行风险扫描的工具,其对代码扫描的方式是常规的正则匹配,确定sink点;还是如flowdroid构建全局数据流图,并分析存储全局数据可达路径;下面就从其源码上略探一二。
1、扫描流程
分析其源码前,我们需要缕清其扫描的流程,方便后面的分析,下图展示其进行扫描的主界面:
先简单介绍下每个标签的基本功能:
path/file:待扫描代码的文件地址; subdirs:是否对代码的子目录进行扫描,勾选将会扫描子目录,不勾选只扫描当前目录下的PHP文件; verbosity level:选择source点,即可控制的输入点,定义在rips下config/sources.php中; vuln type:选择sink点,即可能会触发各种风险的函数,定义在rips下config/sinks.php中; scan:选择好前面的选项,点击该按钮即可开始扫描; code style:扫描结果的展示方式; /regex/:要搜索内容的正则表达式; search:根据正则表达式对全局代码进行搜索;
在进行扫描时一般将扫描文件目录粘贴到第一栏中,点击scan进行扫描,那么这个scan就是执行扫描的开始点;点击scan按钮会调用js/script.js中的scan方法进行扫描,该方法将会获取在主界面中获取的参数,并通过XMLHttpRequest方法传递给rips主目录下的main.php中进行处理。在main.php中主要执行一些赋值的操作,及调用scanner.php进行具体的扫描,下面的代码便是其调用scanner.php的相关代码。
// scan $scan = new Scanner($file_scanning, $scan_functions, $info_functions, $source_functions); $scan->parse(); $scanned_files[$file_scanning] = $scan->inc_map;
其赋值对象主要是$file_scanning, $scan_functions, $info_functions, $source_functions这四个对象,四个对象的含义如下所示:
$file_scanning:表示要扫描的php文件,如果扫描的对象是一个文件,那么该参数就代表这个对象本身;如果扫描对象是一个目录,RIPS将会对目录中的文件进行逐个扫描,该对象就代表目录中的每个文件。 $scan_functions:sink点,会触发漏洞的函数名称的列表,根据选择的vuln type,通过config/sinks.php进行构造。 $info_functions:设备信息,根据扫描文件中使用的函数特征值确定,通过config/info.php进行构造。 $source_functions:source点,可控制的输入点,通过config/sources.php进行构造。
scanner的扫描可以把它大致分为两步,第一步是初始化Scanner对象;第二步则是最关键的漏洞扫描,通过parse()方法进行。
2、代码扫描
2.1 初始化Scanner对象
此处主要通过__construct方法执行一些初始化操作,对其中一些关键代码进行说明:
function __construct($file_name, $scan_functions, $info_functions, $source_functions) { $this->file_name = $file_name; $this->scan_functions = $scan_functions; $this->info_functions = $info_functions; $this->source_functions = $source_functions;
此处主要是将main.php中传递过来的文件赋值给类变量,这几个变量是初始化后面一些类变量的基础。下面将是初始化的关键步骤,为方便说明将在代码中直接进行注释说明:
$this->inc_file_stack = array(realpath($this->file_name)); // 待扫描文件的真实地址,存入数组中 $this->inc_map = array(); $this->include_paths = Analyzer::get_ini_paths(ini_get("include_path")); // 文件所包含的路径,单个结果一般为:Array( [0] => . [1] => ),即文件的自身路径 $this->file_pointer = end($this->inc_file_stack); // 文件地址数组中最后的元素,值为文件自身真实路径 if(!isset($GLOBALS['file_sinks_count'][$this->file_pointer])) $GLOBALS['file_sinks_count'][$this->file_pointer] = 0; // 初始化该文件sink点统计数目 $this->lines_stack = array(); $this->lines_stack[] = file($this->file_name); // 读取待扫描文件内容,存储到一个数组中 $this->lines_pointer = end($this->lines_stack); // 由于文件内容存储在数组的第一个元素中,且数组长度为1,此处代表将文件内容逐行存储在一个数组中 $this->tif = 0; // tokennr in file $this->tif_stack = array(); // preload output echo $GLOBALS['fit'] . '|' . $GLOBALS['file_amount'] . '|' . $this->file_pointer . ' (tokenizing)|' . $GLOBALS['timeleft'] . '|' . "/n"; @ob_flush(); flush(); // tokenizing $tokenizer = new Tokenizer($this->file_pointer); $this->tokens = $tokenizer->tokenize(implode('',$this->lines_pointer)); unset($tokenizer); // 上面几行是整个分析的关键,将在下面进行详细的说明 // add auto includes from php.ini if(ini_get('auto_prepend_file')) { $this->add_auto_include(ini_get('auto_prepend_file'), true);} if(ini_get('auto_append_file')) { $this->add_auto_include(ini_get('auto_append_file'), false);} // 校验php配置文件(php.ini)中是否存在自动包含的文件,如果存在将直接添加到$this->tokens的类变量中
此处将粗略说明$this->tokens类变量的生成,该变量的生成主要调用lib/tokenizer.php中的方法,下面是其关键代码:
public function tokenize($code) { $this->tokens = token_get_all($code); $this->prepare_tokens(); $this->array_reconstruct_tokens(); $this->fix_tokens(); $this->fix_ternary(); #die(print_r($this->tokens)); return $this->tokens; }
通过调用ZEND引擎的token_get_all方法将PHP源码分解成PHP tokens(参考:http://php.net/manual/en/function.token-get-all.php),并对这些tokens进行相关处理优化,处理优化的过程没有进行仔细的研究,此处不做详细介绍。为了让大家对ZEND引擎生成的tokens有个更直观的认识,这是将使用一个简单的例子分别展示源码、token_get_all生成的原始tokens、处理后的tokens,通过后面的对比可以粗略的看出,处理后的tokens比原始生成的tokens更加简洁,去除了一些对于风险扫描无用的tokens,如<?php、?>、空字节等。如下所示:
源码:
<?php echo $_GET('info'); ?>
原始tokens:
Array ( [0] => Array ( [0] => 374 [1] => <?php [2] => 1 ) [1] => Array ( [0] => 317 [1] => echo [2] => 2 ) [2] => Array ( [0] => 377 [1] => [2] => 2 ) [3] => Array ( [0] => 310 [1] => $_GET [2] => 2 ) [4] => ( [5] => Array ( [0] => 316 [1] => 'info' [2] => 2 ) [6] => ) [7] => ; [8] => Array ( [0] => 377 [1] => [2] => 2 ) [9] => Array ( [0] => 376 [1] => ?> [2] => 3 ) )
处理后的tokens:
Array ( [0] => Array ( [0] => 317 [1] => echo [2] => 2 ) [1] => Array ( [0] => 310 [1] => $_GET [2] => 2 ) [2] => ( [3] => Array ( [0] => 316 [1] => 'info' [2] => 2 ) [4] => ) [5] => ; [6] => ; )
2.2 parse扫描
获取了需要扫描的PHP tokens,下一步就是进行最关键的风险扫描了,风险扫描主体函数在lib/scanner.php文件中的parse()方法。该方法中会遍历2.1中生成的tokens,对tokens进行逐个扫描,根据每个token是否为数组(is_array)分别进行操作,由于整体代码比较庞杂,此处挑选处理上的几个关键点,并结合实际的代码,对其扫描的方式进行探究。下面先展示本次测试使用的源码,主要包含两个文件commond_exec.php与para.php两个文件,源码如下所示:
commond_exec.php: <?php include('para.php'); $str = 'command'; $command = para($str); shell_exec( $command ); ?> para.php: <?php function para($str){ return $_GET($str); } ?>
扫描的目标文件是commond_exec.php,此时其生成的tokens如下所示:
Array ( [0] => Array ( [0] => 262 [1] => include [2] => 2 ) [1] => ( [2] => Array ( [0] => 318 [1] => 'para.php' [2] => 2 ) [3] => ) [4] => ; [5] => Array ( [0] => 312 [1] => $str [2] => 4 ) [6] => = [7] => Array ( [0] => 318 [1] => 'command' [2] => 4 ) [8] => ; [9] => Array ( [0] => 312 [1] => $command [2] => 5 ) [10] => = [11] => Array ( [0] => 310 [1] => para [2] => 5 ) [12] => ( [13] => Array ( [0] => 312 [1] => $str [2] => 5 ) [14] => ) [15] => ; [16] => Array ( [0] => 310 [1] => shell_exec [2] => 6 ) [17] => ( [18] => Array ( [0] => 312 [1] => $command [2] => 6 ) [19] => ) [20] => ; [21] => ; )
对tokens进行遍历时,如果该token的类型是数组,那么分别获取该数组中的每个值,如下所示:
$token_name = $this->tokens[$i][0]; // 该token的名称,相当于变量名称 $token_value = $this->tokens[$i][1]; // 该token的值,相当于变量的值 $line_nr = $this->tokens[$i][2]; // token出现在源码的第几行
2.2.1 文件包含处理
对token进行逐个扫描时,第一个出现的token就是便是include函数,RIPS遇到这个函数时会根据文件包含出现的位置,获取被包含文件的tokens,插入到原tokens语句的后面,其具体的操作代码如下所示:
$tokenizer = new Tokenizer($try_file); $inc_tokens = $tokenizer->tokenize(implode('',$inc_lines)); unset($tokenizer); // if(include('file')) { - include tokens after { and not into the condition :S if($this->in_condition) { $this->tokens = array_merge( array_slice($this->tokens, 0, $this->in_condition+1), // before include in condition $inc_tokens, // included tokens array(array(T_INCLUDE_END, 0, 1)), // extra END-identifier array_slice($this->tokens, $this->in_condition+1) // after condition ); } else { // insert included tokens in current tokenlist and mark end $this->tokens = array_merge( array_slice($this->tokens, 0, $i+$skip), // before include $inc_tokens, // included tokens array(array(T_INCLUDE_END, 0, 1)), // extra END-identifier array_slice($this->tokens, $i+$skip) // after include ); }
最后生成的包含include文件的tokens如下所示,对比下会发现5-19的token是新添加的,为被包含文件para.php的tokens。
Array ( [0] => Array ( [0] => 262 [1] => include [2] => 2 ) [1] => ( [2] => Array ( [0] => 318 [1] => 'para.php' [2] => 2 ) [3] => ) [4] => ; [5] => Array ( [0] => 337 [1] => function [2] => 2 ) [6] => Array ( [0] => 310 [1] => para [2] => 2 ) [7] => ( [8] => Array ( [0] => 312 [1] => $str [2] => 2 ) [9] => ) [10] => { [11] => Array ( [0] => 339 [1] => return [2] => 3 ) [12] => Array ( [0] => 312 [1] => $_GET [2] => 3 ) [13] => ( [14] => Array ( [0] => 312 [1] => $str [2] => 3 ) [15] => ) [16] => ; [17] => } [18] => ; [19] => Array ( [0] => 380 [1] => 0 [2] => 1 ) [20] => Array ( [0] => 312 [1] => $str [2] => 4 ) [21] => = [22] => Array ( [0] => 318 [1] => 'command' [2] => 4 ) [23] => ; [24] => Array ( [0] => 312 [1] => $command [2] => 5 ) [25] => = [26] => Array ( [0] => 310 [1] => para [2] => 5 ) [27] => ( [28] => Array ( [0] => 312 [1] => $str [2] => 5 ) [29] => ) [30] => ; [31] => Array ( [0] => 310 [1] => shell_exec [2] => 6 ) [32] => ( [33] => Array ( [0] => 312 [1] => $command [2] => 6 ) [34] => ) [35] => ; [36] => ; )
2.2.2 添加数据源(source点)
当扫描到第11个token return时,此时会判断返回的语句是否是用户可以控制的语句,如果这条语句是用户能够控制的语句,比如此处使用$_GET进行赋值表明是用户可以控制的语句;也就是说para()方法的返回值是用户可以控制的,那么该方法返回的数据将被认为是一个被污染的数据源,即source点并将该方法添加到source_functions的数组中。对于return返回参数是否是用户可控制的判断,主要是通过函数scan_parameter()实现的,下面抽取几个关键点来了解判断流程的实现,当遇到token为return的语句时,会向后遍历token,直到该语句结束,代码的实现上是通过“;”是否出现进行判断,如下所示:
while( $this->tokens[$i + $c] !== ';' )
对于每个token,判断该token是否是一个数组,如果是一个数组则检查数组元素是否是一个变量,如下所示:
if( is_array($this->tokens[$i + $c]) ) { if( $this->tokens[$i + $c][0] === T_VARIABLE )
如果该token是一个数组且为变量,则使用scan_parameter()函数对其进行检查,该函数调用形式如下。该调用的参数比较多,但是本例中实际起到判断作用的只有第三个参数,即这个token本身:$this->tokens[$i+$c],具体的值为:tokens[12],即$_GET函数。
$new_find = new VulnTreeNode(); $userinput = $this->scan_parameter( $new_find, $new_find, $this->tokens[$i+$c], $this->tokens[$i+$c][3], $i+$c, $this->var_declares_local, $this->var_declares_global, false, $GLOBALS['F_SECURES_ALL'], TRUE );
由于$_GET函数为定义的source函数,因此将直接认为返回值是用户可输入的,即$userinput=true。最后将此函数名添加到source_functions列中,以后的扫描该函数将作为source点看待。
if($userinput == 1 || $GLOBALS['userfunction_taints']) { $this->source_functions[] = $this->function_obj->name; }
2.2.3 添加风险点(sink点)
此处实际是RIPS的一个误报,RIPS将$_GET()作为可变函数名对待,如果函数名可变那么就可以将该函数名赋值为eval,从而造成代码执行的漏洞,sink点的添加也是在scan_parameter()中进行。由于此处是$_GET(),显然此函数包含在source函数中,因此使用scan_parameter()函数其返回值肯定为true,那么在函数内部将会触发如下代码块的执行。
if($this->in_function && !$return_scan) { $this->addtriggerfunction($mainparent); }
触发后主要执行的函数是addtriggerfunction(),该函数的作用主要是向$GLOBALS变量中添加该函数。
$GLOBALS['user_functions'][$this->file_name][$this->function_obj->name][0][0] = 0; // no securings $GLOBALS['user_functions'][$this->file_name][$this->function_obj->name][1] = array(); // doesnt matter if called with userinput or not $GLOBALS['user_functions'][$this->file_name][$this->function_obj->name][3] = true;
最后在包含文件扫描结束时,即token=”}”,此处是第17个token,将被全局变量合并到scan_functions中,即添加到sink点。
if(isset($GLOBALS['user_functions'][$this->file_name])) { $this->scan_functions = array_merge($this->scan_functions, $GLOBALS['user_functions'][$this->file_name]); }
2.2.4 命令执行(shell_exec)漏洞
这个漏洞是本代码中实际包含的一个漏洞,在上面各种准备工作完成后,来看一下这个实际漏洞的扫描流程;当token为shell_exec时,由于该函数是一个危险函数,即包含在sink点,那么分析将直接跳转到TAINT ANALYSIS中进行。同2.2.2类似,会跳转到scan_parameter()函数中对函数的参数进行分析,确定该参数是否是用户可控制的,即包含在source点内。该函数的参数是变量$command,该参数是一个自定义变量,RIPS对于自定义变量会进行自动扫描并通过函数variable_add()添加到var_declares_local、var_declares_global两个变量中的一个。
下面先对variable_add()函数进行简单介绍,当遍历到tokens[24],$command的赋值操作时,会触发该函数的执行。该函数调用形式如下,其中比较关键的是第二个参数,调用Analyzer::getBraceEnd()静态方法,获取该变量声明的所有token,此处$command的token的序列号为24-30,将这些tokens存储到一个数组中,最后将该变量的相关信息存入var_declares_global数组中。这样就完成了对一个文件中的全局遍历的发现及存储。
$this->variable_add( $token_value, array_slice($this->tokens, $i-$c, $c+Analyzer::getBraceEnd($this->tokens, $i)), '', 0, 0, $line_nr, $i, isset($this->tokens[$i][3]) ? $this->tokens[$i][3] : array() );
由于存储了该变量的tokens信息,那么对于自定义变量的分析,就转变成了对该变量的tokens的分析,遍历该变量的tokens,如果该token来自用户可控制的输入,即sorce点数据源,那么表明自定义变量的也是可控的,此处的source点就是自添加的函数para(),这样就存在一个用户可控制的数据源(source)流向危险函数(sink),形成了一个漏洞触发的完整路径。
for($i=$var_declare->tokenscanstart; $i<$var_declare->tokenscanstop; $i++) { ... else if( in_array($tokens[$i][1], $this->source_functions) ) { $userinput = true; $var_trace->marker = 4; $mainparent->title = 'Userinput returned by function <i>'.$tokens[$i][1].'()</i> reaches sensitive sink.';
3、结语
上面对于RIPS的源码进行了简单的分析,从中可以看出,其工作的流程大致为遍历token,发现sink点,然后对sink点的参数使用scan_parameter进行后向追踪,如果这个参数是用户可控制的参数,及包含在source点中,那么就存在一条从source到sink的联通路径,及存在一条漏洞触发的路径,则认为是一个风险点。
原创文章,作者:kepupublish,如若转载,请注明出处:https://blog.ytso.com/tech/aiops/55076.html