淺談Discuz插件代碼安全 2017-02-21

目錄

Discuz介紹

Crossday Discuz! Board(簡稱 Discuz!)是北京康盛新創科技有限責任公司推出的一套通用的社區論壇軟件系統。自2001年6月面世以來,Discuz!已擁有15年以上的應用歷史和200多萬網站用戶案例,是全球成熟度最高、覆蓋率最大的論壇軟件系統之一。目前最新版本Discuz! X3.2正式版于2015年6月9日發布,首次引入應用中心的開發模式。2010年8月23日,康盛創想與騰訊達成收購協議,成為騰訊的全資子公司。(摘自百度百科)

Discuz代碼非常靈活,支持自定義模板和插件,這讓Discuz擁有了極強的diy性,再加上操作簡單快捷,入門門檻低,使得這款開源軟件在中國發展異常迅猛,成為市面上主流的論壇程序。

本文主要介紹Discuz插件相關的安全問題。

Discuz插件介紹

Discuz插件主要分為“程序鏈接”、“擴展項目”、“程序腳本”三類。

程序鏈接:允許插件在Discuz中某些特定導航位置加入菜單項,可自主指派菜單鏈接的 URL,也可以調用插件的一個模塊,模塊文件名指派為 source/plugin/插件目錄/插件模塊名.inc.php”。注意:由于引用外部程序,因此即便設置了模塊的使用等級,您的程序仍需進行判斷使用等級是否合法。

擴展項目:允許插件在更多的位置增加菜單項/管理模塊,以及可在后臺插件列表頁增添一個遠程鏈接(X3.1新增)。

程序腳本:允許插件設置一個包含頁面嵌入腳本的模塊,該模塊可用于在普通電腦及移動端訪問的頁面顯示。模塊文件名指派為 “source/plugin/插件目錄/插件模塊名.class.php”,以及設置一個特殊主題腳本的模塊,模塊文件名指派為“source/plugin/插件目錄/插件模塊名.class.php”。

可以為每個模塊設置不同的使用等級,例如設置為“超級版主”,則超級版主及更高的管理者可以使用此模塊。

擴展項目模塊可以在社區的特定位置擴展出新的功能,通常用于擴展新的設置項目。項目的腳本文件以 .inc.php 結尾(如 test.inc.php),模版為固定文件名,位于插件目錄的 template/ 子目錄中,文件名與腳本名同名(如 test.htm),擴展名為 .htm。添加相應的擴展項目模塊時,需注明程序模塊、菜單名稱。例如我們添加個人面板項目,程序模塊為 test,菜單名稱是“測試”,當插件啟用后,個人面板即家園的設置中會出現“測試”拓展項目。

在新插件內核中,通過 plugin.php 方式訪問的插件可直接通過 plugin.php?id=xxx:yyy 方式調用而無需再在后臺定義為普通腳本模塊,只要 source/plugin/xxx/yyy.inc.php 文件存在即可。如果 xxx 和 yyy 同名,可直接通過 plugin.php?id=xxx 方式訪問。

結合實例講解Discuz插件安全

我們知道Discuz插件主要分為“程序鏈接”、“擴展項目”、“程序腳本”三類。

這里我們主要著重分析”程序腳本“,因為大部分跟數據庫相關及邏輯相關的代碼僅能在這種插件類型中存在,存在安全問題的可能性最大。

這里我們以一款名為”小說閱讀器“的插件為例,深入了解Discuz插件機制及漏洞挖掘。

首先我們安裝并啟用該插件:

2083730474

隨后首頁多出了一個”小說主頁“的導航:

106874796

并且我們可以看到當前的url是plugin.php?id=xxx:xxx上面我們已經講過這種格式的頁面訪問到的最終文件在插件目錄下xxx.inc.php文件中。

那么這個“小說主頁”的相關文件就在jameson_read目錄下的readmain.inc.php中:

我們查找并打開相關文件:

2464478022

跟我們預想的一樣,這個文件果然是存在的。我們來輸出寫數字然后exit()確認一下我們的想法:

2643658443

查看頁面:

2069710902OK,現在我們繼續來看這個插件的邏輯是怎么樣的,是不是有相關的安全問題存在。

其中第7行:

/*排序字段*/
$orderfield = isset($_GET['orderfield']) && trim($_GET['orderfield'])?trim($_GET['orderfield']):'views';

很明顯,從get請求中獲取了orderfield賦值給$orderfield并且只使用trim()函數進行了處理,這里明顯是有問題的。

繼續往下跟進發現傳進了fetch_by_get函數的第3個參數:

$categoryarray[$row['category_id']]['sub'][$subrow['category_id']]['book'] = C::t('#jameson_read#jamesonread_books')->fetch_by_get($subrow['category_id'],4,$orderfield,1);

繼續跟進fetch_by_get函數,文件路徑在:/Users/striker/www/discuz3/upload/source/plugin/jameson_read/table/table_jamesonread_books.php第120行:

function fetch_by_get($cate=0,$num,$orderfield){
	return DB::fetch_all("SELECT * FROM %t WHERE category_id=%d AND is_top=1 ORDER BY %i DESC,ordernum DESC LIMIT %d",array($this->_table,$cate,$orderfield,$num));
}

發現將$orderfield直接傳入了Discuz自帶的DB::fetch_all函數中執行,我們繼續跟進fetch_all函數:

public static function fetch_all($sql, $arg = array(), $keyfield = '', $silent=false) {

	$data = array();
	$query = self::query($sql, $arg, $silent, false);
	while ($row = self::$db->fetch_array($query)) {
		if ($keyfield && isset($row[$keyfield])) {
			$data[$row[$keyfield]] = $row;
		} else {
			$data[] = $row;
		}
	}
	self::$db->free_result($query);
	return $data;
}

這個函數將sql語句又傳入到self::query函數:

public static function query($sql, $arg = array(), $silent = false, $unbuffered = false) {
	if (!empty($arg)) {
		if (is_array($arg)) {
			$sql = self::format($sql, $arg);
		} elseif ($arg === 'SILENT') {
			$silent = true;

		} elseif ($arg === 'UNBUFFERED') {
			$unbuffered = true;
		}
	}
	self::checkquery($sql);

	$ret = self::$db->query($sql, $silent, $unbuffered);
	if (!$unbuffered && $ret) {
		$cmd = trim(strtoupper(substr($sql, 0, strpos($sql, ' '))));
		if ($cmd === 'SELECT') {

		} elseif ($cmd === 'UPDATE' || $cmd === 'DELETE') {
			$ret = self::$db->affected_rows();
		} elseif ($cmd === 'INSERT') {
			$ret = self::$db->insert_id();
		}
	}
	return $ret;
}

這個函數又調用了self::format()進行格式化語句:

public static function format($sql, $arg) {
	$count = substr_count($sql, '%');
	if (!$count) {
		return $sql;
	} elseif ($count > count($arg)) {
		throw new DbException('SQL string format error! This SQL need "' . $count . '" vars to replace into.', 0, $sql);
	}

	$len = strlen($sql);
	$i = $find = 0;
	$ret = '';
	while ($i <= $len && $find < $count) {
		if ($sql{$i} == '%') {
			$next = $sql{$i + 1};
			if ($next == 't') {
				$ret .= self::table($arg[$find]);
			} elseif ($next == 's') {
				$ret .= self::quote(is_array($arg[$find]) ? serialize($arg[$find]) : (string) $arg[$find]);
			} elseif ($next == 'f') {
				$ret .= sprintf('%F', $arg[$find]);
			} elseif ($next == 'd') {
				$ret .= dintval($arg[$find]);
			} elseif ($next == 'i') {
				$ret .= $arg[$find];
			} elseif ($next == 'n') {
				if (!empty($arg[$find])) {
					$ret .= is_array($arg[$find]) ? implode(',', self::quote($arg[$find])) : self::quote($arg[$find]);
				} else {
					$ret .= '0';
				}
			} else {
				$ret .= self::quote($arg[$find]);
			}
			$i++;
			$find++;
		} else {
			$ret .= $sql{$i};
		}
		$i++;
	}
	if ($i < $len) {
		$ret .= substr($sql, $i);
	}
	return $ret;
}

這個函數首先判斷了%出現的次數,如果沒有出現則扔出錯誤。

然后寫兩個一個while循環來拼接sql語句,查找百分號后面的字母,我們這里的$orderfield傳入時是%i所以我們只關注這個分支:

elseif ($next == 'i') {
	$ret .= $arg[$find];

如果百分號后面是i的話,就直接拼接帶入進去,沒有進行其他的處理。

最終format函數返回了拼接后的sql語句。

為了驗證我們的想法,我們來在返回以后輸出一下返回的sql語句,我們提交orderfield為111select

1400635157

最終SQL報錯,可以看到我們的數據是帶入到SQL查詢中了。

我們可控的注入點是在ORDER BY后面,

而且Discuz現在是有一個全局的waf,過濾了一些字符,導致很難進行注入。

后面有機會再發一篇DiscuzWAF相關的文章吧。

最終使用如下payload成功注入:

http://discuz3.localhost/plugin.php?id=jameson_read:readmain&orderfield=extractvalue(1,%20concat(0x3a,%20version()))%20

3652212866

這里感謝@mLT 以及@雨了個雨 師傅不吝賜教。

結語

Discuz是當下比較火的一個論壇社區程序,很多的網站,尤其是某些建站公司為了完成目標,肆意使用各種插件,甚至是沒有經過官方審核的第三方插件(當然,經過審核的也會出現安全問題),導致原本很安全的Discuz變得脆弱。

使用第三方的插件,還是找時間多review code比較好呀。

一级A片不卡在线观看