CVE-2026-12066
Description
PbootCMS ≤3.2.12 password recovery uses only an image CAPTCHA, enabling unauthenticated remote account takeover without verifying email ownership.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
PbootCMS ≤3.2.12 password recovery uses only an image CAPTCHA, enabling unauthenticated remote account takeover without verifying email ownership.
Vulnerability
The password recovery flow in PbootCMS up to version 3.2.12 is implemented in the retrieve() method of apps/home/controller/MemberController.php. The function accepts the target username, a new password, an email address, and a checkcode parameter from an unauthenticated POST request. Validation relies solely on an image CAPTCHA stored in $_SESSION['checkcode']; no email-based ownership verification or time-bound reset token is used. Additionally, when the target account’s useremail column is empty (the default for accounts registered without an email), the email-match guard is bypassed entirely. This combination of flaws results in a weak password recovery mechanism (CWE-287 + CWE-640) [1] [2].
Exploitation
An attacker needs only network access to the PbootCMS installation and does not require any authentication. The attacker first obtains a valid image CAPTCHA from the /core/code.php endpoint and extracts the code from the response. Then, a POST request is sent to the password recovery endpoint (retrieve()) with the target username, a desired new password, the captured checkcode, and any dummy email value. No email ownership check is performed, so the CAPTCHA alone authorizes the password change. The attacker can repeat this for any user whose account lacks a non-empty useremail field (the default for account-mode registrations) [1].
Impact
Successful exploitation grants the attacker complete control over the victim’s account, including the ability to log in, access personal data, and perform any actions permitted to that user role. This constitutes an account takeover with full disclosure of user data and unauthorized write access to the account, potentially extending to administrative accounts if such accounts are targeted and registered without email [1].
Mitigation
As of the available references, no official patch has been released for PbootCMS 3.2.12. The vendor has not publicly announced a fixed version [1] [2]. Workarounds include ensuring all user accounts have a non-empty useremail set (this makes the email-match check active, though still weak), disabling the password recovery function entirely via server configuration, or implementing external multi-factor validation. The vulnerability is not listed on CISA’s Known Exploited Vulnerabilities (KEV) catalog as of [Date].
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2- Range: <=3.2.12
Patches
13bac063b0b66V3.2.13 安全加固:ORM参数化绑定、XSS防护、ueditor鉴权、备份路径迁移、PHP最低版本升至7.0
28 files changed · +974 −238
admin.php+1 −1 modified@@ -14,7 +14,7 @@ define('URL_BIND', 'admin'); // PHP版本检测 -if (version_compare(phpversion(),'7.0.0','<')) { +if (version_compare(PHP_VERSION,'7.0.0','<')) { header('Content-Type:text/html; charset=utf-8'); exit('您服务器PHP的版本太低,程序要求PHP版本不小于7.0'); }
api.php+2 −2 modified@@ -14,10 +14,10 @@ define('URL_BIND', 'api'); // PHP版本检测 -if (PHP_VERSION < '5.3') { +if (version_compare(PHP_VERSION,'7.0.0','<')) { echo json_encode(array( 'code' => 0, - 'data' => 'The version of your server PHP is too low, and the program requires PHP version not less than 5.3.' + 'data' => 'The version of your server PHP is too low, and the program requires PHP version not less than 7.0' )); exit(); }
apps/admin/controller/system/DatabaseController.php+31 −2 modified@@ -99,7 +99,7 @@ public function mod() } break; case 'bfsqlite': - if (copy(DOC_PATH . $this->dbauth['dbname'], DOC_PATH . STATIC_DIR . '/backup/sql/' . get_uniqid() . '_' . date('YmdHis') . '.db')) { + if ($this->backupSqliteDB()) { $this->log('备份数据库成功!'); success('备份数据库成功!', - 1); } else { @@ -227,7 +227,7 @@ private function dataSql($table, $fields, $fieldNnum, $data) // 写入文件 private function writeFile($filename, $content) { - $sqlfile = DOC_PATH . STATIC_DIR . '/backup/sql/' . $filename; + $sqlfile = DOC_PATH . DATA_DIR . '/backup/sql/' . $filename; check_file($sqlfile, true); if (file_put_contents($sqlfile, $content)) { return true; @@ -245,4 +245,33 @@ private function getTableList() } return $list; } + + // 备份SQLite数据库文件 + public function backupSqliteDB($filename = '', $backup_dir_suffix='/backup/sql/') + { + // 仅在 SQLite 类型时执行 + $type = isset($this->dbauth['type']) ? $this->dbauth['type'] : ''; + if ($type !== 'sqlite' && $type !== 'pdo_sqlite') { + return false; + } + + $source = DOC_PATH . $this->dbauth['dbname']; + if (! file_exists($source)) { + return false; + } + + // 备份目录,不存在时自动创建(递归创建多级目录) + $backup_dir = DOC_PATH . DATA_DIR . $backup_dir_suffix; + if (! check_dir($backup_dir, true)) { + return false; + } + + // 默认文件名规则与原逻辑保持一致 + if (! $filename) { + $filename = get_uniqid() . '_' . date('YmdHis') . '.db'; + } + + return copy($source, $backup_dir . $filename); + } + } \ No newline at end of file
apps/admin/controller/system/UpgradeController.php+3 −3 modified@@ -80,7 +80,7 @@ public function check() if (! check_dir(RUN_PATH . '/upgrade', true)) { json(0, '目录写入权限不足,无法正常升级!' . RUN_PATH . '/upgrade'); } - check_dir(DOC_PATH . STATIC_DIR . '/backup/upgrade', true); + check_dir(DOC_PATH . DATA_DIR . '/backup/upgrade', true); $files = $this->getServerList(); $db = get_db_type(); @@ -171,7 +171,7 @@ public function update() } else { $path = RUN_PATH . '/upgrade' . $value; $des_path = ROOT_PATH . $value; - $back_path = DOC_PATH . STATIC_DIR . '/backup/upgrade/' . $backdir . $value; + $back_path = DOC_PATH . DATA_DIR . '/backup/upgrade/' . $backdir . $value; if (! check_dir(dirname($des_path), true)) { json(0, '目录写入权限不足,无法正常升级!' . dirname($des_path)); } @@ -199,7 +199,7 @@ public function update() $db = new DatabaseController(); switch (get_db_type()) { case 'sqlite': - copy(DOC_PATH . $this->config('database.dbname'), DOC_PATH . STATIC_DIR . '/backup/sql/' . date('YmdHis') . '_' . basename($this->config('database.dbname'))); + $db->backupSqliteDB(date('YmdHis') . '_' . basename($this->config('database.dbname')), '/backup/upgrade/'); break; case 'mysql': $db->backupDB();
apps/common/AdminController.php+18 −1 modified@@ -52,7 +52,8 @@ public function __construct() if ($_GET['p'] && $this->config('app_url_type') == 3) { $this->assign('pathinfo', '<input name="p" type="hidden" value="' . get('p') . '">'); } - + + $this->checkPwSecurity(); // 密码安全检查 } // 不进行表单检验的控制器 @@ -161,6 +162,22 @@ private function checkLevel() } } + // 密码安全检查:强制首次登录修改默认密码 + private function checkPwSecurity() + { + $pw_public_path = array( + '/admin/Index/loginOut', + '/admin/Index/ucenter', + '/admin/Index/area', + '/admin/Index/clearSession', // 清理会话 + ); + + $current_path = '/' . M . '/' . C . '/' . F; + if (!session('pwsecurity') && !in_array($current_path, $pw_public_path)) { + location(url('/admin/Index/ucenter')); + } + } + // 当前菜单的父类的子菜单,即同级菜单二级菜单 private function getSecondMenu() {
apps/common/version.php+2 −2 modified@@ -1,10 +1,10 @@ <?php return array( // 应用版本 - 'app_version' => '3.2.11', + 'app_version' => '3.2.13', // 发布时间 - 'release_time' => '20250416', + 'release_time' => '20260525', // 修订版本 'revise_version' => '1'
apps/home/controller/ParserController.php+63 −35 modified@@ -62,7 +62,7 @@ public function parserAfter($content) $content = str_replace('{pboot:pagetitle}', $this->config('other_title') ?: '{pboot:sitetitle}-{pboot:sitesubtitle}', $content); $content = str_replace('{pboot:pagekeywords}', '{pboot:sitekeywords}', $content); $content = str_replace('{pboot:pagedescription}', '{pboot:sitedescription}', $content); - $content = str_replace('{pboot:keyword}', get('keyword', 'vars'), $content); // 当前搜索的关键字 + $content = str_replace('{pboot:keyword}', htmlspecialchars(get('keyword', 'vars'), ENT_QUOTES, 'UTF-8'), $content); // 当前搜索的关键字 // 解析个人扩展标签,升级不覆盖 if (file_exists(APP_PATH . '/home/controller/ExtLabelController.php')) { @@ -252,7 +252,7 @@ public function parserSiteLabel($content) break; case 'statistical': if (isset($data->statistical)) { - $content = str_replace($matches[0][$i], decode_string($data->statistical), $content); + $content = str_replace($matches[0][$i], filter_html(decode_string($data->statistical)), $content); } else { $content = str_replace($matches[0][$i], '', $content); } @@ -3277,6 +3277,50 @@ public function parserLoopLabel($content) return $content; } + // 验证 {pboot:if} 条件表达式的安全性(严格白名单机制) + // 只允许:比较运算符(==, !=, >, <, >=, <=)、逻辑运算符(&&, ||)、 + // 字符串/数字/裸值标识符(VAL)、括号、逻辑非(!) + // 原理:逐层替换所有已知安全 token,残留内容即为非法 token + private function validateIfCondition($condition) + { + $test = $condition; + + // 1. 替换字符串字面量(单引号、双引号) + $test = preg_replace("/'[^']*'/", ' __STR__ ', $test); + $test = preg_replace('/"[^"]*"/', ' __STR__ ', $test); + + // 2. 替换数字字面量 + $test = preg_replace('/\b\d+\.?\d*\b/', ' __NUM__ ', $test); + // 2.5 替换裸值标识符(模板变量替换后残留的值,如URL、文本标识符) + // 安全约束:标识符后不能紧跟 ( (阻止函数调用),且不以 __ 开头(跳过内部占位符) + // 例如:http://www.example.com → __VAL__,但 system( 中的 system 不会被替换 + $test = preg_replace('/\b(?!__)[a-zA-Z_][a-zA-Z0-9_:\/.\-%]*\b(?!\s*\()/i', ' __VAL__ ', $test); + // 3. 替换比较运算符(双字符必须先于单字符替换,防止部分匹配) + $test = str_replace('>=', ' __CMP__ ', $test); + $test = str_replace('<=', ' __CMP__ ', $test); + $test = str_replace('!=', ' __CMP__ ', $test); + $test = str_replace('==', ' __CMP__ ', $test); + + // 4. 替换逻辑运算符 + $test = str_replace('&&', ' __LOG__ ', $test); + $test = str_replace('||', ' __LOG__ ', $test); + + // 5. 替换单字符运算符和括号 + $test = str_replace('>', ' __CMP__ ', $test); + $test = str_replace('<', ' __CMP__ ', $test); + $test = str_replace('!', ' __NOT__ ', $test); + $test = str_replace('(', ' __PAR__ ', $test); + $test = str_replace(')', ' __PAR__ ', $test); + $test = str_replace('%', ' __MOD__ ', $test); + + // 6. 移除所有安全占位符和空白字符 + $test = preg_replace('/__(STR|NUM|VAL|CMP|LOG|NOT|PAR|MOD)__/', '', $test); + $test = preg_replace('/\s+/', '', $test); + + // 7. 如果仍有残留内容,说明存在不允许的 token(如函数名、$变量、%取模等) + return ($test === ''); + } + // 解析IF条件标签 public function parserIfLabel($content) { @@ -3287,42 +3331,23 @@ public function parserIfLabel($content) for ($i = 0; $i < $count; $i++) { $flag = ''; $out_html = ''; - $danger = false; - - $white_fun = array( - 'date' - ); // 还原可能包含的保留内容,避免判断失效 $matches[1][$i] = $this->restorePreLabel($matches[1][$i]); - // 带有函数的条件语句进行安全校验 - if (preg_match_all('/([\w]+)([\x00-\x1F\x7F\/\*\<\>\%\w\s\\\\]+)?\(/i', $matches[1][$i], $matches2)) { - foreach ($matches2[1] as $value) { - if (function_exists(trim($value)) && !in_array($value, $white_fun)) { - $danger = true; - break; - } - } + // 预处理:将 date() 函数调用替换为实际求值结果 + // 安全机制:只允许 date('格式字符串') 单参数调用,参数必须为引号包裹的字符串字面量 + // 预处理后条件表达式中不再存在函数调用,白名单机制保持纯粹 + $matches[1][$i] = preg_replace_callback('/date\s*\(\s*[\'"]([^\'"]*)[\'"]\s*\)/i', function($m) { + return "'" . date($m[1]) . "'"; + }, $matches[1][$i]); - foreach ($matches2[2] as $value) { - if (function_exists(trim($value)) && !in_array($value, $white_fun)) { - $danger = true; - break; - } - } - } - - // 过滤特殊字符串 - - if (preg_match('/(\([\w\s\.]+\))|(\$_GET\[)|(\$_POST\[)|(\$_REQUEST\[)|(\$_COOKIE\[)|(\$_SESSION\[)|(file_put_contents)|(file_get_contents)|(fwrite)|(phpinfo)|(base64)|(`)|(shell_exec)|(eval)|(assert)|(system)|(exec)|(passthru)|(pcntl_exec)|(popen)|(proc_open)|(print_r)|(print)|(urldecode)|(chr)|(include)|(request)|(__FILE__)|(__DIR__)|(copy)|(call_user_)|(preg_replace)|(array_map)|(array_reverse)|(array_filter)|(getallheaders)|(get_headers)|(decode_string)|(htmlspecialchars)|(session_id)|(strrev)|(substr)|(php.info)|(@file.@_put_content)/i', $matches[1][$i])) { - $danger = true; - } - - // 如果有危险函数,则不解析该IF - if ($danger) { + // 白名单安全校验:只允许比较运算符和安全的值 + // 替代原有的 function_exists() + 黑名单正则方案 + if (!$this->validateIfCondition($matches[1][$i])) { continue; } + //if标签解析 $flag = symbol($matches[1][$i]); if (preg_match('/^([\s\S]*)\{else\}([\s\S]*)$/', $matches[2][$i], $matches2)) { // 判断是否存在else @@ -3353,7 +3378,6 @@ public function parserIfLabel($content) $content = str_replace($matches[0][$i], $out_html, $content); } } -// var_dump(111);die; return $content; } @@ -3443,8 +3467,6 @@ protected function adjustLabelData($params, $data, $label = null, $savelabel = f case 'decode': // 解码或转义字符 if ($params['decode']) { $data = decode_string($data); - } else { - $data = escape_string($data); } break; case 'substr': // 截取字符串 @@ -3516,7 +3538,9 @@ protected function adjustLabelData($params, $data, $label = null, $savelabel = f break; case 'mark': if ($label && $reqdata = request($label, 'vars') ?: request('keyword', 'vars')) { - $data = preg_replace('/(' . $reqdata . ')/i', '<span style="color:red">$1</span>', $data); + // 对搜索关键词进行安全转义,防止XSS和正则注入 + $reqdata = preg_quote(htmlspecialchars($reqdata, ENT_QUOTES, 'UTF-8'), '/'); + $data = preg_replace('/(' . $reqdata . ')/i', '<mark>$1</mark>', $data); } break; } @@ -3647,6 +3671,8 @@ protected function parserList($label, $search, $content, $data, $params, $key) $content = str_replace($search, Url::get('home/Do/oppose/id/' . $data->id), $content); break; case 'content': + // 对列表中的富文本内容进行安全过滤 + $data->content = filter_html($data->content); $content = str_replace($search, $this->adjustLabelData($params, $data->content, $label, true), $content); // 占位替换 break; case 'keywords': @@ -3888,6 +3914,8 @@ protected function parserContent($label, $search, $content, $data, $params, $sor } } } + // 对富文本内容进行安全过滤,移除危险标签和事件属性,保留安全的HTML + $data->content = filter_html($data->content); $content = str_replace($search, $this->adjustLabelData($params, $data->content, null, true), $content); break; case 'keywords': // 如果内容关键字为空,则自动使用全局关键字
apps/home/controller/SearchController.php+1 −1 modified@@ -34,7 +34,7 @@ public function index() $content = parent::parser($this->htmldir . $searchtpl); // 框架标签解析 $content = $this->parser->parserBefore($content); // CMS公共标签前置解析 - $pagetitle = get('keyword', 'var') ? get('keyword', 'var') . '-' : ''; + $pagetitle = get('keyword', 'var') ? htmlspecialchars(get('keyword', 'var'), ENT_QUOTES, 'UTF-8') . '-' : ''; $content = str_replace('{pboot:pagetitle}', $this->config('search_title') ?: $pagetitle . '搜索结果-{pboot:sitetitle}-{pboot:sitesubtitle}', $content); $content = $this->parser->parserPositionLabel($content, 0, '搜索', Url::home('search')); // CMS当前位置标签解析 $content = $this->parser->parserSpecialPageSortLabel($content, - 1, '搜索结果', Url::home('search')); // 解析分类标签
core/basic/Model.php+335 −109 modified@@ -44,6 +44,9 @@ class Model // 查询语句构建 private $sql = array(); + // 预处理语句参数绑定数组 + private $bindParams = array(); + // 直接显示SQL语句 private $showSql = false; @@ -161,7 +164,7 @@ private function buildSql($sql, $clear = true) */ final public function begin() { - $this->getDb()->begin(); + $this->getDb()->beginTransaction(); } /** @@ -171,7 +174,7 @@ final public function begin() */ final public function commit() { - $this->getDb()->commit(); + $this->getDb()->commitTransaction(); } /** @@ -412,10 +415,13 @@ final public function where($where, $inConnect = 'AND', $outConnect = 'AND', $fu $flag = true; } if (!is_int($key)) { + $safeKey = $this->safeField($key); if ($fuzzy) { - $where_string .= $key . " like '%" . $value . "%' "; + $bindPlaceholder = $this->addBind('%' . $value . '%'); + $where_string .= $safeKey . " like " . $bindPlaceholder . " "; } else { - $where_string .= $key . "='" . $value . "' "; + $bindPlaceholder = $this->addBind($value); + $where_string .= $safeKey . "=" . $bindPlaceholder . " "; } } else { $where_string .= $value; @@ -481,17 +487,29 @@ final public function in($field, $range) { if (!$field) return $this; + $safeField = $this->safeField($field); if (is_array($range)) { if (count($range) == 1) { // 单只有一个值时使用直接使用等于,提高读取性能 - $in_string = "$field='$range[0]'"; + $bindPlaceholder = $this->addBind($range[0]); + $in_string = "$safeField=$bindPlaceholder"; } else { - $in_string = "$field IN (" . implode_quot(',', $range) . ")"; + $placeholders = array(); + foreach ($range as $val) { + $placeholders[] = $this->addBind($val); + } + $in_string = "$safeField IN (" . implode(',', $placeholders) . ")"; } } else { if (preg_match('/,/', $range)) { - $in_string = "$field IN (" . implode_quot(',', explode(',', $range)) . ")"; + $items = explode(',', $range); + $placeholders = array(); + foreach ($items as $val) { + $placeholders[] = $this->addBind(trim($val)); + } + $in_string = "$safeField IN (" . implode(',', $placeholders) . ")"; } else { // 传递单个字符串时直接相等处理 - $in_string = "$field = '$range'"; + $bindPlaceholder = $this->addBind($range); + $in_string = "$safeField = $bindPlaceholder"; } } if (isset($this->sql['where']) && $this->sql['where']) { @@ -515,19 +533,30 @@ final public function notIn($field, $range) { if (!$field) return $this; + $safeField = $this->safeField($field); if (is_array($range)) { - $in_string = implode_quot(',', $range); + $placeholders = array(); + foreach ($range as $val) { + $placeholders[] = $this->addBind($val); + } + $in_string = implode(',', $placeholders); } else { if (preg_match('/,/', $range)) { - $in_string = implode_quot(',', explode(',', $range)); + $items = explode(',', $range); + $placeholders = array(); + foreach ($items as $val) { + $placeholders[] = $this->addBind(trim($val)); + } + $in_string = implode(',', $placeholders); } else { - $in_string = "'$range'"; + $bindPlaceholder = $this->addBind($range); + $in_string = $bindPlaceholder; } } if (isset($this->sql['where']) && $this->sql['where']) { - $this->sql['where'] .= " AND $field NOT IN ($in_string)"; + $this->sql['where'] .= " AND $safeField NOT IN ($in_string)"; } else { - $this->sql['where'] = "WHERE $field NOT IN ($in_string)"; + $this->sql['where'] = "WHERE $safeField NOT IN ($in_string)"; } return $this; } @@ -566,14 +595,18 @@ final public function like($field, $keyword, $matchType = "all") $field = explode(',', $field); } foreach ($field as $value) { + $safeValue = $this->safeField($value); + $bindPlaceholder = $this->addBind($keyword); if (isset($sqlStr)) { - $sqlStr .= " OR $value LIKE '$keyword'"; + $sqlStr .= " OR $safeValue LIKE $bindPlaceholder"; } else { - $sqlStr = "$value LIKE '$keyword'"; + $sqlStr = "$safeValue LIKE $bindPlaceholder"; } } } else { - $sqlStr = "$field LIKE '$keyword'"; + $safeField = $this->safeField($field); + $bindPlaceholder = $this->addBind($keyword); + $sqlStr = "$safeField LIKE $bindPlaceholder"; } if (isset($this->sql['where']) && $this->sql['where']) { $this->sql['where'] .= " AND ($sqlStr)"; @@ -617,14 +650,18 @@ final public function notLike($field, $keyword, $matchType = "all") $field = explode(',', $field); } foreach ($field as $value) { + $safeValue = $this->safeField($value); + $bindPlaceholder = $this->addBind($keyword); if (isset($sqlStr)) { - $sqlStr .= " AND $value NOT LIKE '$keyword'"; + $sqlStr .= " AND $safeValue NOT LIKE $bindPlaceholder"; } else { - $sqlStr = "$value NOT LIKE '$keyword'"; + $sqlStr = "$safeValue NOT LIKE $bindPlaceholder"; } } } else { - $sqlStr = "$field NOT LIKE '$keyword'"; + $safeField = $this->safeField($field); + $bindPlaceholder = $this->addBind($keyword); + $sqlStr = "$safeField NOT LIKE $bindPlaceholder"; } if (isset($this->sql['where']) && $this->sql['where']) { $this->sql['where'] .= " AND ($sqlStr)"; @@ -649,14 +686,19 @@ final public function order($order) $order_string = 'ORDER BY '; foreach ($order as $key => $value) { if (is_int($key)) { - $order_string .= $value . ','; + $order_string .= $this->parseOrderField($value) . ','; } else { - $order_string .= $key . ' ' . $value . ','; + $order_string .= $this->safeField($key) . ' ' . $this->parseOrderDirection($value) . ','; } } $this->sql['order'] = substr($order_string, 0, -1); } else { - $this->sql['order'] = 'ORDER BY ' . $order; + $parts = $this->splitRespectingParens($order); + $order_string = 'ORDER BY '; + foreach ($parts as $part) { + $order_string .= $this->parseOrderField($part) . ','; + } + $this->sql['order'] = substr($order_string, 0, -1); } return $this; } @@ -681,19 +723,21 @@ final public function limit($limit) } else { $var_arr = explode(',', $limit); } + $offset = intval($var_arr[0]); + $count = intval($var_arr[1]); switch (get_db_type()) { case 'mysql': - $this->sql['limit'] = 'LIMIT ' . $var_arr[0] . ',' . $var_arr[1]; + $this->sql['limit'] = 'LIMIT ' . $offset . ',' . $count; break; case 'sqlite': - $this->sql['limit'] = 'LIMIT ' . $var_arr[1] . ' OFFSET ' . $var_arr[0]; + $this->sql['limit'] = 'LIMIT ' . $count . ' OFFSET ' . $offset; break; case 'pgsql': - $this->sql['limit'] = 'LIMIT ' . $var_arr[1] . ' OFFSET ' . $var_arr[0]; + $this->sql['limit'] = 'LIMIT ' . $count . ' OFFSET ' . $offset; break; } } else { - $this->sql['limit'] = 'LIMIT ' . $limit; + $this->sql['limit'] = 'LIMIT ' . intval($limit); } return $this; } @@ -710,11 +754,16 @@ final public function group($group) if (is_array($group)) { $group_string = 'GROUP BY '; foreach ($group as $key => $value) { - $group_string .= $value . ','; + $group_string .= $this->safeField($value) . ','; } $this->sql['group'] = substr($group_string, 0, -1); } else { - $this->sql['group'] = 'GROUP BY ' . $group; + $parts = $this->splitRespectingParens($group); + $group_string = 'GROUP BY '; + foreach ($parts as $part) { + $group_string .= $this->safeField($part) . ','; + } + $this->sql['group'] = substr($group_string, 0, -1); } return $this; } @@ -744,14 +793,16 @@ final public function having($having, $inConnect = 'AND', $outConnect = 'AND') $flag = true; } if (!is_int($key)) { - $having_string .= $key . "='" . $value . "' "; + $safeKey = $this->safeField($key); + $bindPlaceholder = $this->addBind($value); + $having_string .= $safeKey . "=" . $bindPlaceholder . " "; } else { - $having_string .= $value; + $having_string .= $this->filterHavingExpr($value); } } $this->sql['having'] .= $having_string . ')'; } else { - $this->sql['having'] .= $having . ')'; + $this->sql['having'] .= $this->filterHavingExpr($having) . ')'; } return $this; } @@ -930,17 +981,17 @@ final public function select($type = null) $this->limit(Paging::getInstance()->quikLimit()); // 分页 $this->sql['field'] = 'SQL_CALC_FOUND_ROWS ' . $this->sql['field']; // 添加查询总记录 $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->all($sql, $type); + $result = $this->getDb()->all($sql, $type, $this->bindParams); $count_sql = "select FOUND_ROWS() as sum"; - if (!!$rs = $this->getDb()->one($count_sql)) { + if (!!$rs = $this->getDb()->one($count_sql, null, array())) { $total = $rs->sum; // 分页内容 $limit = Paging::getInstance()->limit($total, true); // 生成分页代码 } } else { $count_sql = $this->buildSql($this->countSql2, false); // 获取记录总数 - if (!!$rs = $this->getDb()->all($count_sql)) { + if (!!$rs = $this->getDb()->all($count_sql, null, $this->bindParams)) { $total = count($rs); // 分页内容 $limit = Paging::getInstance()->limit($total, true); @@ -952,7 +1003,7 @@ final public function select($type = null) // 生成总数计算语句 $count_sql = $this->buildSql($this->countSql, false); // 获取记录总数 - if (!!$rs = $this->getDb()->one($count_sql)) { + if (!!$rs = $this->getDb()->one($count_sql, null, $this->bindParams)) { $total = $rs->sum; // 分页内容 $limit = Paging::getInstance()->limit($total, true); @@ -968,8 +1019,9 @@ final public function select($type = null) $sql = $this->buildSql($this->selectSql); } if (!isset($result)) { - $result = $this->getDb()->all($sql, $type); + $result = $this->getDb()->all($sql, $type, $this->bindParams); } + $this->bindParams = array(); return $this->outData($result); } @@ -995,7 +1047,8 @@ final public function find($type = null) if ($type === false) { return $sql; } - $result = $this->getDb()->one($sql, $type); + $result = $this->getDb()->one($sql, $type, $this->bindParams); + $this->bindParams = array(); return $this->outData($result); } @@ -1023,7 +1076,8 @@ final public function column($fields, $key = null) } $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->all($sql, 1); + $result = $this->getDb()->all($sql, 1, $this->bindParams); + $this->bindParams = array(); $data = array(); foreach ($result as $vkey => $value) { if ($key) { @@ -1057,7 +1111,8 @@ final public function value($field) $this->sql['field'] = $field; $this->limit(1); $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); if (isset($result[0])) { return $this->outData($result[0]); } else { @@ -1076,7 +1131,8 @@ final public function max($field) { $this->sql['field'] = "MAX(`$field`)"; $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); return $this->outData($result[0]); } @@ -1091,7 +1147,8 @@ final public function min($field) { $this->sql['field'] = "MIN(`$field`)"; $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); return $this->outData($result[0]); } @@ -1106,7 +1163,8 @@ final public function avg($field) { $this->sql['field'] = "AVG(`$field`)"; $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); return $this->outData($result[0]); } @@ -1121,7 +1179,8 @@ final public function sum($field) { $this->sql['field'] = "SUM(`$field`)"; $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); if ($result[0]) { return $this->outData($result[0]); } else { @@ -1134,7 +1193,8 @@ final public function count() { $this->sql['field'] = "COUNT(*)"; $sql = $this->buildSql($this->selectSql); - $result = $this->getDb()->one($sql, 2); + $result = $this->getDb()->one($sql, 2, $this->bindParams); + $this->bindParams = array(); if ($result[0]) { return $this->outData($result[0]); } else { @@ -1175,15 +1235,15 @@ final public function insert(array $data = array(), $batch = true) $this->checkKey($key); if (!is_numeric($key)) { $keys .= "`" . $key . "`,"; - $values .= "'" . $value . "',"; + $values .= $this->addBind($value) . ","; } } if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { $keys .= "`" . $this->createTimeField . "`,`" . $this->updateTimeField . "`,"; if ($this->intTimeFormat) { - $values .= "'" . time() . "','" . time() . "',"; + $values .= $this->addBind(time()) . "," . $this->addBind(time()) . ","; } else { - $values .= "'" . date('Y-m-d H:i:s') . "','" . date('Y-m-d H:i:s') . "',"; + $values .= $this->addBind(date('Y-m-d H:i:s')) . "," . $this->addBind(date('Y-m-d H:i:s')) . ","; } } if ($keys) { // 如果插入数据关联字段,则字段以关联数据为准,否则以设置字段为准 @@ -1198,6 +1258,7 @@ final public function insert(array $data = array(), $batch = true) $key_string = ''; $value_string = ''; $flag = false; + $estimatedDataSize = 0; foreach ($data as $keys => $value) { if (!$flag) { $value_string .= ' SELECT '; @@ -1210,14 +1271,15 @@ final public function insert(array $data = array(), $batch = true) $this->checkKey($key2); $key_string .= "`" . $key2 . "`,"; } - $value_string .= "'" . $value2 . "',"; + $value_string .= $this->addBind($value2) . ","; + $estimatedDataSize += strlen($value2) + 2; // 估算实际值大小 } $flag = true; if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { if ($this->intTimeFormat) { - $value_string .= "'" . time() . "','" . time() . "',"; + $value_string .= $this->addBind(time()) . "," . $this->addBind(time()) . ","; } else { - $value_string .= "'" . date('Y-m-d H:i:s') . "','" . date('Y-m-d H:i:s') . "',"; + $value_string .= $this->addBind(date('Y-m-d H:i:s')) . "," . $this->addBind(date('Y-m-d H:i:s')) . ","; } } $value_string = substr($value_string, 0, -1); @@ -1232,13 +1294,16 @@ final public function insert(array $data = array(), $batch = true) } $this->sql['value'] = $value_string; $sql = $this->buildSql($this->insertMultSql); - // 判断SQL语句是否超过数据库设置 + // 判断SQL语句是否超过数据库设置(使用占位符后SQL较短,需加上数据实际大小估算) + $estimatedTotalSize = strlen($sql) + $estimatedDataSize; if (get_db_type() == 'mysql') { $max_allowed_packet = $this->getDb()->one('SELECT @@global.max_allowed_packet', 2); } else { $max_allowed_packet = 1 * 1024 * 1024; // 其他类型数据库按照1M限制 } - if (strlen($sql) > $max_allowed_packet) { // 如果要插入的数据过大,则转换为一条条插入 + if ($estimatedTotalSize > $max_allowed_packet) { // 如果要插入的数据过大,则转换为一条条插入 + // 重置bindParams,回退到逐条插入模式 + $this->bindParams = array(); return $this->insert($data, false); } } else { // 批量一条条插入 @@ -1259,7 +1324,9 @@ final public function insert(array $data = array(), $batch = true) $sql = preg_replace_r('/pboot:if/i', 'pboot@if', $sql); // 过滤插入cms条件语句 $sql = preg_replace_r('/pboot:sql/i', 'pboot@sql', $sql); // 过滤插入cms条件语句 - return $this->getDb()->amd($sql); + $result = $this->getDb()->amd($sql, $this->bindParams); + $this->bindParams = array(); + return $result; } /** @@ -1297,6 +1364,11 @@ final public function update($data = null) if (!$data && $this->sql['data']) { return $this->update($this->sql['data']); } + + // 暂存WHERE子句的绑定参数,避免与SET子句参数顺序冲突 + $whereBindParams = $this->bindParams; + $this->bindParams = array(); + $update_string = ''; if (is_array($data)) { if (!$data) @@ -1310,7 +1382,8 @@ final public function update($data = null) } elseif (is_numeric($temp_v_end) && $temp_v_start == "+=") { $update_string .= "`$key`= $key+$temp_v_end,"; // 自加 } else { - $update_string .= "`$key`='$value',"; + $bindPlaceholder = $this->addBind($value); + $update_string .= "`$key`=$bindPlaceholder,"; } } $update_string = substr($update_string, 0, -1); @@ -1319,16 +1392,22 @@ final public function update($data = null) } if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { if ($this->intTimeFormat) { - $update_string .= ",`" . $this->updateTimeField . "`=' " . time() . "'"; + $update_string .= ",`" . $this->updateTimeField . "`=" . $this->addBind(time()); } else { - $update_string .= ",`" . $this->updateTimeField . "`=' " . date('Y-m-d H:i:s') . "'"; + $update_string .= ",`" . $this->updateTimeField . "`=" . $this->addBind(date('Y-m-d H:i:s')); } } $this->sql['value'] = $update_string; $sql = $this->buildSql($this->updateSql); + + // 合并绑定参数:SET参数在前,WHERE参数在后 + $params = array_merge($this->bindParams, $whereBindParams); + $sql = preg_replace_r('/pboot:if/i', 'pboot@if', $sql); // 过滤插入cms条件语句 $sql = preg_replace_r('/pboot:sql/i', 'pboot@sql', $sql); // 过滤插入cms条件语句 - return $this->getDb()->amd($sql); + $result = $this->getDb()->amd($sql, $params); + $this->bindParams = array(); + return $result; } /** @@ -1343,16 +1422,28 @@ final public function update($data = null) final public function setField($field, $value) { $this->checkKey($field); - $this->sql['value'] = "`$field`='$value'"; + + // 暂存WHERE子句的绑定参数,避免与SET子句参数顺序冲突 + $whereBindParams = $this->bindParams; + $this->bindParams = array(); + + $bindPlaceholder = $this->addBind($value); + $this->sql['value'] = "`$field`=$bindPlaceholder"; if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { if ($this->intTimeFormat) { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . time() . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(time()); } else { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . date('Y-m-d H:i:s') . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(date('Y-m-d H:i:s')); } } $sql = $this->buildSql($this->updateSql); - return $this->getDb()->amd($sql); + + // 合并绑定参数:SET参数在前,WHERE参数在后 + $params = array_merge($this->bindParams, $whereBindParams); + + $result = $this->getDb()->amd($sql, $params); + $this->bindParams = array(); + return $result; } /** @@ -1367,16 +1458,27 @@ final public function setField($field, $value) final public function setInc($field, $value = 1) { $this->checkKey($field); + + // 暂存WHERE子句的绑定参数,避免与SET子句参数顺序冲突 + $whereBindParams = $this->bindParams; + $this->bindParams = array(); + $this->sql['value'] = " `$field`= $field+$value"; if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { if ($this->intTimeFormat) { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . time() . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(time()); } else { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . date('Y-m-d H:i:s') . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(date('Y-m-d H:i:s')); } } $sql = $this->buildSql($this->updateSql); - return $this->getDb()->amd($sql); + + // 合并绑定参数:SET参数在前,WHERE参数在后 + $params = array_merge($this->bindParams, $whereBindParams); + + $result = $this->getDb()->amd($sql, $params); + $this->bindParams = array(); + return $result; } /** @@ -1391,16 +1493,27 @@ final public function setInc($field, $value = 1) final public function setDec($field, $value = 1) { $this->checkKey($field); + + // 暂存WHERE子句的绑定参数,避免与SET子句参数顺序冲突 + $whereBindParams = $this->bindParams; + $this->bindParams = array(); + $this->sql['value'] = " `$field`= $field-$value"; if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) { if ($this->intTimeFormat) { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . time() . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(time()); } else { - $this->sql['value'] .= ",`" . $this->updateTimeField . "`=' " . date('Y-m-d H:i:s') . "'"; + $this->sql['value'] .= ",`" . $this->updateTimeField . "`=" . $this->addBind(date('Y-m-d H:i:s')); } } $sql = $this->buildSql($this->updateSql); - return $this->getDb()->amd($sql); + + // 合并绑定参数:SET参数在前,WHERE参数在后 + $params = array_merge($this->bindParams, $whereBindParams); + + $result = $this->getDb()->amd($sql, $params); + $this->bindParams = array(); + return $result; } // ***************************数据删除******************************************************* @@ -1423,11 +1536,13 @@ final public function delete($data = null, $key = null) if (is_array($data) || preg_match('/,/', $data)) { $this->in($key, $data); } else { - $this->where("$key='$data'"); + $this->where(array($key => $data)); } } $sql = $this->buildSql($this->deleteSql); - return $this->getDb()->amd($sql); + $result = $this->getDb()->amd($sql, $this->bindParams); + $this->bindParams = array(); + return $result; } // 检测key值 @@ -1440,11 +1555,148 @@ private function checkKey($key) } } + // 添加参数绑定值 + private function addBind($value) + { + if (is_null($value)) { + $value = ''; + } + $this->bindParams[] = $value; + return '?'; + } + + // 安全处理字段名(加反引号) + private function safeField($field) + { + $field = trim($field); + + // SQL函数表达式(如 RAND(), length(name), count(*)) + if (preg_match('/^\w+\([^)]*\)$/i', $field)) { + if (!preg_match('/^\w+\([\w\.\*\s,]*\)$/i', $field)) { + error('SQL函数表达式含有非法字符:' . $field); + } + // 危险函数黑名单(防止时间盲注等攻击) + if (preg_match('/^(\w+)\(/i', $field, $m)) { + $dangerous_funcs = array('SLEEP', 'BENCHMARK', 'WAITFOR', 'PG_SLEEP'); + if (in_array(strtoupper($m[1]), $dangerous_funcs)) { + error('SQL函数不允许使用危险函数:' . $m[1]); + } + } + return $field; + } + + $this->checkKey($field); + // 处理别名.字段格式(如 a.pcode → `a`.`pcode`) + if (strpos($field, '.') !== false) { + $parts = explode('.', $field); + return '`' . implode('`.`', $parts) . '`'; + } + return '`' . $field . '`'; + } + + // 解析ORDER BY字段(验证字段名和排序方向) + private function parseOrderField($field) + { + $field = trim($field); + + // 提取末尾的排序方向(ASC/DESC) + $dir = ''; + if (preg_match('/\s+(ASC|DESC)$/i', $field, $m)) { + $dir = strtoupper($m[1]); + $field = trim(substr($field, 0, -strlen($m[0]))); + } + + // 情况1:SQL函数表达式(如 RAND(), length(name), count(*)) + if (preg_match('/^(\w+)\(([^)]*)\)$/i', $field, $m)) { + $funcName = $m[1]; + $args = $m[2]; + // 验证函数名安全性(必须以字母开头) + if (!preg_match('/^[A-Za-z]\w*$/', $funcName)) { + error('ORDER BY函数名含有非法字符:' . $funcName); + } + // 危险函数黑名单(防止时间盲注等攻击) + $dangerous_funcs = array('SLEEP', 'BENCHMARK', 'WAITFOR', 'PG_SLEEP'); + if (in_array(strtoupper($funcName), $dangerous_funcs)) { + error('ORDER BY不允许使用危险函数:' . $funcName); + } + // 验证参数安全性:仅允许字段名、点号、星号、逗号、空格 + if ($args !== '' && !preg_match('/^[\w\.\*\s,]+$/', $args)) { + error('ORDER BY函数参数含有非法字符:' . $args); + } + return $field . ($dir ? ' ' . $dir : ''); + } + + // 情况2:已加反引号的字段名 + if (preg_match('/^`[^`]+`$/', $field)) { + return $field . ($dir ? ' ' . $dir : ''); + } + + // 情况3:简单字段名(含别名.字段格式) + if (preg_match('/^[\w\.\-]+$/', $field)) { + $this->checkKey($field); + if (strpos($field, '.') !== false) { + $parts = explode('.', $field); + $f = '`' . implode('`.`', $parts) . '`'; + } else { + $f = '`' . $field . '`'; + } + return $f . ($dir ? ' ' . $dir : ''); + } + + error('ORDER BY字段含有非法字符:' . $field); + } + + // 解析排序方向 + private function parseOrderDirection($dir) + { + $dir = strtoupper(trim($dir)); + if ($dir === 'ASC' || $dir === 'DESC') { + return $dir; + } + error('ORDER BY排序方向只能是ASC或DESC:' . $dir); + } + + // 按逗号分割字符串,但跳过括号内的逗号(避免拆分函数参数) + private function splitRespectingParens($str) + { + $result = array(); + $current = ''; + $depth = 0; + for ($i = 0; $i < strlen($str); $i++) { + $char = $str[$i]; + if ($char === '(') { + $depth++; + } elseif ($char === ')') { + $depth--; + } elseif ($char === ',' && $depth === 0) { + $result[] = $current; + $current = ''; + continue; + } + $current .= $char; + } + if ($current !== '') { + $result[] = $current; + } + return $result; + } + + // 过滤HAVING字符串表达式(仅允许基本字符) + private function filterHavingExpr($expr) + { + $expr = trim($expr); + if (!preg_match('/^[\w\s\.\,\(\)\+\-\*\/\=<>!%&|\'"]+$/', $expr)) { + error('HAVING表达式含有非法字符:' . $expr); + } + return $expr; + } + //查询索引 public function checkIndexSql(): array { $sql = $this->buildSql($this->checkIndex); - $result = $this->getDb()->query($sql); + $result = $this->getDb()->query($sql, 'master', $this->bindParams); + $this->bindParams = array(); return $this->getDb()->fetchQuery($result); } @@ -1453,9 +1705,9 @@ public function checkIndexSql(): array * @param array $data * @return array */ - public function getKeysFromArray(Array $data = []): array + public function getKeysFromArray(Array $data = array()): array { - $keys = []; + $keys = array(); foreach ($data as $key => $val) { $this->checkKey($key); $keys[] = $key; @@ -1468,51 +1720,25 @@ public function getKeysFromArray(Array $data = []): array * @param array $data * @return false|int|mixed|\PDOStatement */ - public function replace(array $data = []) + // Model.php 第1684-1714行 replace() 方法 + public function replace(array $data = array()) { $columns = $this->getKeysFromArray($data[0]); - - $this->sql['field'] = '(`'.implode("`, `", $columns).'`)'; - - $values = []; - + $this->sql['field'] = '(`' . implode("`, `", $columns) . '`)'; + $values = array(); foreach ($data as $item) { - $rowValues = []; - + $rowPlaceholders = array(); foreach ($columns as $column) { - $value = $item[$column]; - - // Apply appropriate escaping to the value to prevent SQL injection - $value = $this->check_value_escape($value); - - $rowValues[] = "'$value'"; + $value = isset($item[$column]) ? $item[$column] : ''; + $rowPlaceholders[] = $this->addBind($value); } - - $values[] = "(".implode(", ", $rowValues).")"; + $values[] = "(" . implode(",", $rowPlaceholders) . ")"; } - $this->sql['value'] = implode(", ", $values); - $sql = $this->buildSql($this->replaceSql); - - return $this->getDb()->amd($sql); - - } - - - function check_value_escape($value) { - $type = Config::get('database.type'); - - if($type=='mysqli'){ - // Escape the value using mysqli_real_escape_string - return mysqli_real_escape_string($this->getDb()->conn, $value); - }else if($type=='sqlite'){ - - ## should debug - // Escape the value using SQLite3::escapeString - return $this->getDb()->conn->escapeString($value); - } - return $value; + $result = $this->getDb()->amd($sql, $this->bindParams); + $this->bindParams = array(); + return $result; } }
core/database/Builder.php+10 −4 modified@@ -17,8 +17,14 @@ public static function getInstance(); // 连接数据库,接受数据库连接参数,返回数据库连接对象 public function conn($cfg); + // 开启事务 + public function beginTransaction(); + + // 提交事务 + public function commitTransaction(); + // 执行SQL语句,接受完整SQL语句,返回结果集对象 - public function query($sql, $type = 'master'); + public function query($sql, $type = 'master', $params = array()); // 数据是否存在模型,接受完整SQL语句,返回boolean数据 public function isExist($sql); @@ -33,13 +39,13 @@ public function fields($table); public function tableFields($table); // 查询一条数据模型,接受完整SQL语句,有数据返回对象数组,否则空数组 - public function one($sql, $type = null); + public function one($sql, $type = null, $params = array()); // 查询多条数据模型,接受完整SQL语句,有数据返回二维对象数组,否则空数组 - public function all($sql, $type = null); + public function all($sql, $type = null, $params = array()); // 数据增、删、改模型,接受完整SQL语句,返回影响的行数的int数据 - public function amd($sql); + public function amd($sql, $params = array()); // 最近一次插入数据的自增字段值,返回int数据 public function insertId();
core/database/Mysqli.php+59 −21 modified@@ -27,7 +27,7 @@ private function __construct() public function __destruct() { if ($this->begin) { // 存在待提交的事务时自动进行提交 - $this->commit(); + $this->commitTransaction(); } } @@ -66,33 +66,50 @@ public function conn($cfg) } // 关闭自动提交,开启事务模式 - public function begin() + public function beginTransaction() { - $this->master->autocommit(false); - $this->begin = true; + if (! $this->master) { + $cfg = Config::get('database'); + $this->master = $this->conn($cfg); + $this->master->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'"); + } + if (! $this->begin) { + $this->master->autocommit(false); + $this->begin = true; + } } // 提交事务 - public function commit() + public function commitTransaction() { - $this->master->commit(); // 提交事务 - $this->master->autocommit(true); // 提交后恢复自动提交 - $this->begin = false; // 关闭事务模式 + if ($this->begin) { + $this->master->commit(); // 提交事务 + $this->master->autocommit(true); // 提交后恢复自动提交 + $this->begin = false; // 关闭事务模式 + } } // 执行SQL语句,接受完整SQL语句,返回结果集对象 - public function query($sql, $type = 'master') + public function query($sql, $type = 'master', $params = array()) { $time_s = microtime(true); + + // 确保master连接已初始化(bindParams需要使用master进行转义) + if (! $this->master) { + $cfg = Config::get('database'); + $this->master = $this->conn($cfg); + $this->master->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'"); // MySql写入规避严格模式 + } + + // 参数绑定统一处理(消除master/slave分支中的重复调用) + if (!empty($params)) { + $sql = $this->bindParams($sql, $params); + } + switch ($type) { case 'master': - if (! $this->master) { - $cfg = Config::get('database'); - $this->master = $this->conn($cfg); - $this->master->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'"); // 写入规避严格模式 - } if (Config::get('database.transaction') && ! $this->begin) { // 根据配置开启mysql事务,注意需要是InnoDB引擎 - $this->begin(); + $this->beginTransaction(); } $result = $this->master->query($sql) or $this->error($sql, 'master'); break; @@ -177,9 +194,9 @@ public function tableFields($table) * * @$type 可以是MYSQLI_ASSOC ,MYSQLI_NUM ,MYSQLI_BOTH,不设置则返回对象数组 */ - public function one($sql, $type = null) + public function one($sql, $type = null, $params = array()) { - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); $row = array(); if ($this->slave->affected_rows) { if ($type) { @@ -196,9 +213,9 @@ public function one($sql, $type = null) * 查询多条数据模型,接受完整SQL语句,有数据返回二维对象数组,否则空数组 * @$type 可以是MYSQLI_ASSOC ,MYSQLI_NUM ,MYSQLI_BOTH,不设置则返回对象模式 */ - public function all($sql, $type = null) + public function all($sql, $type = null, $params = array()) { - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); $rows = array(); if ($this->slave->affected_rows) { if ($type) { @@ -216,9 +233,9 @@ public function all($sql, $type = null) } // 数据增、删、改模型,接受完整SQL语句,返回影响的行数的int数据 - public function amd($sql) + public function amd($sql, $params = array()) { - $result = $this->query($sql, 'master'); + $result = $this->query($sql, 'master', $params); $num = $this->master->affected_rows; if ($num > 0) { return $num; @@ -264,5 +281,26 @@ protected function error($sql, $conn) public function fetchQuery($obj){ return $obj->fetch_all(); } + + // 安全替换参数占位符 + private function bindParams($sql, $params) + { + $offset = 0; + foreach ($params as $param) { + $pos = strpos($sql, '?', $offset); + if ($pos !== false) { + if ($param === null) { + $replacement = 'NULL'; + } elseif (is_int($param) || is_float($param)) { + $replacement = $param; + } else { + $replacement = "'" . $this->master->real_escape_string($param) . "'"; + } + $sql = substr_replace($sql, $replacement, $pos, 1); + $offset = $pos + strlen($replacement); + } + } + return $sql; + } }
core/database/Pdo.php+75 −27 modified@@ -27,7 +27,7 @@ private function __construct() public function __destruct() { if ($this->begin) { // 存在待提交的事务时自动进行提交 - $this->commit(); + $this->commitTransaction(); } } @@ -96,21 +96,32 @@ public function conn($cfg) } // 关闭自动提交,开启事务模式 - public function begin() + public function beginTransaction() { - $this->master->beginTransaction(); - $this->begin = true; + if (! $this->master) { + $cfg = Config::get('database'); + $this->master = $this->conn($cfg); + if ($cfg['type'] == 'pdo_mysql') { + $this->master->exec("SET sql_mode='NO_ENGINE_SUBSTITUTION'"); + } + } + if (! $this->begin) { + $this->master->beginTransaction(); // PDO原生方法,保持不变 + $this->begin = true; + } } // 提交事务 - public function commit() + public function commitTransaction() { - $this->master->commit(); - $this->begin = false; + if ($this->begin) { + $this->master->commit(); + $this->begin = false; + } } // 执行SQL语句,接受完整SQL语句,返回结果集对象 - public function query($sql, $type = 'master') + public function query($sql, $type = 'master', $params = array()) { $time_s = microtime(true); switch ($type) { @@ -125,14 +136,27 @@ public function query($sql, $type = 'master') // sqlite时自动启动事务 if ($cfg['type'] == 'pdo_sqlite' && ! $this->begin) { - $this->begin(); + $this->beginTransaction(); } elseif ($cfg['type'] == 'pdo_mysql' && Config::get('database.transaction') && ! $this->begin) { // 根据配置开启mysql事务,注意需要是InnoDB引擎 - $this->begin(); + $this->beginTransaction(); } - $result = $this->master->query($sql); - if ($result === false) { - $this->error($sql, 'master'); + if (!empty($params)) { + $stmt = $this->master->prepare($sql); + if ($stmt === false) { + $this->error($sql, 'master'); + return false; + } + if ($stmt->execute($params) === false) { + $this->error($sql, 'master', $stmt); + return false; + } + $result = $stmt; + } else { + $result = $this->master->query($sql); + if ($result === false) { + $this->error($sql, 'master'); + } } break; case 'slave': @@ -149,7 +173,20 @@ public function query($sql, $type = 'master') } $this->slave = $this->conn($cfg); } - $result = $this->slave->query($sql) or $this->error($sql, 'slave'); + if (!empty($params)) { + $stmt = $this->slave->prepare($sql); + if ($stmt === false) { + $this->error($sql, 'slave'); + return false; + } + if ($stmt->execute($params) === false) { + $this->error($sql, 'slave', $stmt); + return false; + } + $result = $stmt; + } else { + $result = $this->slave->query($sql) or $this->error($sql, 'slave'); + } break; } return $result; @@ -230,9 +267,9 @@ public function tableFields($table) * 查询一条数据模型,接受完整SQL语句,有数据返回对象数组,否则空数组 * @$type 可以是MYSQLI_ASSOC(FETCH_ASSOC) ,MYSQLI_NUM(FETCH_NUM) ,MYSQLI_BOTH(FETCH_BOTH),不设置则返回对象模式 */ - public function one($sql, $type = null) + public function one($sql, $type = null, $params = array()) { - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); $row = array(); if ($type) { $type ++; // 与mysqli统一返回类型设置 @@ -247,9 +284,9 @@ public function one($sql, $type = null) * 查询多条数据模型,接受完整SQL语句,有数据返回二维对象数组,否则空数组 * @$type 可以是MYSQLI_ASSOC(FETCH_ASSOC) ,MYSQLI_NUM(FETCH_NUM) ,MYSQLI_BOTH(FETCH_BOTH),不设置则返回对象模式 */ - public function all($sql, $type = null) + public function all($sql, $type = null, $params = array()) { - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); $rows = array(); if ($type) { $type ++; // 与mysqli统一返回类型设置 @@ -263,11 +300,11 @@ public function all($sql, $type = null) } // 数据增、删、改模型,接受完整SQL语句,返回影响的行数的int数据 - public function amd($sql) + public function amd($sql, $params = array()) { - $result = $this->query($sql, 'master'); - if ($result > 0) { - return $result; + $result = $this->query($sql, 'master', $params); + if ($result) { + return $result->rowCount(); } else { return 0; } @@ -293,15 +330,26 @@ public function multi($sql) } } - // 显示执行错误 - protected function error($sql, $conn) + // 显示连接层执行错误(prepare失败、无参数query失败等,错误信息在连接对象上) + protected function error($sql, $conn, $stmt = null) { - $errs = $this->$conn->errorInfo(); - $err = '错误:' . $errs[2]; + // 错误信息来源:有 $stmt 时取 statement 层,否则取连接层 + $source = $stmt ?: $this->$conn; + $errs = $source->errorInfo(); + + $err = '错误:' . (isset($errs[2]) ? $errs[2] : '未知错误'); + // SQLSTATE 始终附加(若有) + if (isset($errs[0]) && $errs[0]) { + $err .= ' [SQLSTATE:' . $errs[0] . ']'; + } + + // 屏蔽XPATH相关错误信息,防止信息泄露 if (preg_match('/XPATH/i', $err)) { $err = ''; } - if ($this->begin) { // 如果是事务模式,发生错误,则回滚 + + // 如果是事务模式,发生错误,则回滚 + if ($this->begin && $this->$conn->inTransaction()) { $this->$conn->rollBack(); $this->begin = false; }
core/database/Sqlite.php+55 −7 modified@@ -30,6 +30,30 @@ public function __destruct() } } + // 开启显式事务 + public function beginTransaction() + { + if (! $this->master) { + $cfg = ROOT_PATH . Config::get('database.dbname'); + $conn = $this->conn($cfg); + $this->master = $conn; + $this->slave = $conn; + } + if (!$this->begin) { + $this->master->exec('begin;'); + $this->begin = true; + } + } + + // 提交事务 + public function commitTransaction() + { + if ($this->begin) { + $this->master->exec('commit;'); + $this->begin = false; + } + } + // 获取单一实例,使用单一实例数据库连接类 public static function getInstance() { @@ -60,7 +84,7 @@ public function conn($cfg) } // 执行SQL语句,接受完整SQL语句,返回结果集对象 - public function query($sql, $type = 'master') + public function query($sql, $type = 'master', $params = array()) { $time_s = microtime(true); if (! $this->master || ! $this->slave) { @@ -69,6 +93,9 @@ public function query($sql, $type = 'master') $this->master = $conn; $this->slave = $conn; } + if (!empty($params)) { + $sql = $this->bindParams($sql, $params); + } switch ($type) { case 'master': if (! $this->begin) { // 存在写入时自动开启显式事务,提高写入性能 @@ -84,6 +111,27 @@ public function query($sql, $type = 'master') return $result; } + // 安全替换参数占位符 + private function bindParams($sql, $params) + { + $offset = 0; + foreach ($params as $param) { + $pos = strpos($sql, '?', $offset); + if ($pos !== false) { + if ($param === null) { + $replacement = 'NULL'; + } elseif (is_int($param) || is_float($param)) { + $replacement = $param; + } else { + $replacement = "'" . $this->master->escapeString($param) . "'"; + } + $sql = substr_replace($sql, $replacement, $pos, 1); + $offset = $pos + strlen($replacement); + } + } + return $sql; + } + // 数据是否存在模型,接受完整SQL语句,返回boolean数据 public function isExist($sql) { @@ -139,15 +187,15 @@ public function tableFields($table) } // 查询一条数据模型,接受完整SQL语句,有数据返回对象数组,否则空数组 - public function one($sql, $type = null) + public function one($sql, $type = null, $params = array()) { if (! $type) { $my_type = SQLITE3_ASSOC; } else { $my_type = $type; } $row = array(); - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); if (! ! $row = $result->fetchArray($my_type)) { if (! $type && $row) { $out = new \stdClass(); @@ -162,14 +210,14 @@ public function one($sql, $type = null) } // 查询多条数据模型,接受完整SQL语句,有数据返回二维对象数组,否则空数组 - public function all($sql, $type = null) + public function all($sql, $type = null, $params = array()) { if (! $type) { $my_type = SQLITE3_ASSOC; } else { $my_type = $type; } - $result = $this->query($sql, 'slave'); + $result = $this->query($sql, 'slave', $params); $rows = array(); while (! ! $row = $result->fetchArray($my_type)) { if (! $type && $row) { @@ -186,9 +234,9 @@ public function all($sql, $type = null) } // 数据增、删、改模型,接受完整SQL语句,返回影响的行数的int数据 - public function amd($sql) + public function amd($sql, $params = array()) { - $result = $this->query($sql, 'master'); + $result = $this->query($sql, 'master', $params); if ($result) { return $result; } else {
core/extend/ueditor/php/action_crawler.php+17 −0 modified@@ -5,6 +5,23 @@ * Date: 14-04-14 * Time: 下午19:18 */ +// 防止直接访问:未通过 controller.php 引入时执行独立验证 +if (!isset($CONFIG)) { + require_once '../../../init.php'; + error_reporting(0); + if (!session('sid')) { + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $sid = encrypt_string(session_id() . session('id')); + if ($sid != session('sid')) { + session_destroy(); + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $CONFIG = json_decode(preg_replace("/\/\*[\s\S]+?\*\//", "", file_get_contents("config.json")), true); +} + set_time_limit(0); include ("Uploader.class.php");
core/extend/ueditor/php/action_list.php+17 −0 modified@@ -5,6 +5,23 @@ * Date: 14-04-09 * Time: 上午10:17 */ +// 防止直接访问:未通过 controller.php 引入时执行独立验证 +if (!isset($CONFIG)) { + require_once '../../../init.php'; + error_reporting(0); + if (!session('sid')) { + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $sid = encrypt_string(session_id() . session('id')); + if ($sid != session('sid')) { + session_destroy(); + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $CONFIG = json_decode(preg_replace("/\/\*[\s\S]+?\*\//", "", file_get_contents("config.json")), true); +} + include "Uploader.class.php"; /* 判断类型 */
core/extend/ueditor/php/action_upload.php+17 −0 modified@@ -5,6 +5,23 @@ * Date: 14-04-09 * Time: 上午10:17 */ +// 防止直接访问:未通过 controller.php 引入时执行独立验证 +if (!isset($CONFIG)) { + require_once '../../../init.php'; + error_reporting(0); + if (!session('sid')) { + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $sid = encrypt_string(session_id() . session('id')); + if ($sid != session('sid')) { + session_destroy(); + echo json_encode(array('state' => '权限不足,请重新登录')); + exit; + } + $CONFIG = json_decode(preg_replace("/\/\*[\s\S]+?\*\//", "", file_get_contents("config.json")), true); +} + include "Uploader.class.php"; /* 上传配置 */
core/extend/ueditor/php/controller.php+19 −3 modified@@ -7,9 +7,25 @@ require_once '../../../init.php'; error_reporting(0); -// 启动会话 -if (! session('sid')) { - die('权限不足'); +// 与 AdminController 一致的登录态验证 +function ueditor_check_auth() +{ + if (!session('sid')) {// 启动会话 + return false; + } + $sid = encrypt_string(session_id() . session('id')); + if ($sid != session('sid')) { + session_destroy(); + return false; + } + return true; +} + +if (!ueditor_check_auth()) { + echo json_encode(array( + 'state' => '权限不足,请重新登录' + )); + exit; } $CONFIG = json_decode(preg_replace("/\/\*[\s\S]+?\*\//", "", file_get_contents("config.json")), true);
core/extend/ueditor/ueditor.config.js+1 −1 modified@@ -516,7 +516,7 @@ // 图片的浮动方式,独占一行剧中,左右浮动,默认: center,left,right,none 去掉这个属性表示不执行排版 imageBlockLine: "center", // 根据规则过滤没事粘贴进来的内容 - pasteFilter: false, + pasteFilter: true, // 去掉所有的内嵌字号,使用编辑器默认的字号 clearFontSize: false, // 去掉所有的内嵌字体,使用编辑器默认的字体
core/function/handle.php+158 −3 modified@@ -413,6 +413,164 @@ function decode_string($string) return $string; } +// 清洗 CSS 文本中的危险声明(用于 <style> 块与 style 属性) +function sanitize_css($css) +{ + if (! $css || ! is_string($css)) + return $css; + + // @import 外链样式 + $css = preg_replace('/@import\s+[^;}\n]+;?/i', '', $css); + + // IE expression(),支持一层嵌套括号;畸形括号时避免死循环 + $prev = null; + while ($prev !== $css && preg_match('/expression\s*\(/i', $css)) { + $prev = $css; + $css = preg_replace('/expression\s*\((?:[^()]|\([^()]*\))*\)/i', '', $css); + if ($prev === $css) { + $css = preg_replace('/expression\s*\([^;}\n]*/i', '', $css); + break; + } + } + // expression 清除后的空属性/残留括号 + $css = preg_replace('/[a-z_-][\w-]*\s*:\s*(?=[;}])/i', '', $css); + $css = preg_replace('/\{\s*\}/', '', $css); + + // IE behavior: url() + $css = preg_replace('/behavior\s*:\s*url\s*\([^)]*\)/i', '', $css); + + // url(javascript:) / url(vbscript:) + $css = preg_replace('/url\s*\(\s*["\']?\s*javascript\s*:[^)]*\)/i', '', $css); + $css = preg_replace('/url\s*\(\s*["\']?\s*vbscript\s*:[^)]*\)/i', '', $css); + + // 旧版 Firefox -moz-binding + $css = preg_replace('/-moz-binding\s*:[^;}]*/i', '', $css); + + return $css; +} + +// 移除富文本中针对整站布局的全局劫持规则(保留文章局部 class 样式) +function filter_css_global_hijack_rules($css) +{ + if (! $css || ! is_string($css)) + return $css; + + // 先剔除 display:none 劫持([^{}]+ 避免把规则体内的 } 误当作选择器边界) + $css = preg_replace_callback('/([^{}]+)\{([^}]*)\}/is', function ($m) { + $selector = trim($m[1]); + $declarations = $m[2]; + if (preg_match('/display\s*:\s*none/i', $declarations) && + preg_match('/\b(?:body|html|header|nav|footer|#header)\b/i', $selector)) { + return ''; + } + // 裸 a / a:pseudo / a, ... 会影响整页导航与所有外链(富文本 <style> 为全局生效) + if (preg_match('/^\s*a(?:\s*:[\w-]+)?\s*(?:,|$)/i', $selector) && + ! preg_match('/^\s*a[.#\[]/i', $selector)) { + return ''; + } + // 通配符劫持 + if (preg_match('/^\s*\*[\s,:#.[]/i', $selector) || preg_match('/^\s*\*\s*$/', $selector)) { + return ''; + } + return $m[0]; + }, $css); + + // body/html 伪元素全屏遮罩 + $css = preg_replace('/\b(?:body|html)\s*::\s*(?:before|after)\s*\{[^{}]*\}/is', '', $css); + + // 兜底:剔除残留 body/html display:none(防止规则被破坏后漏网) + $css = preg_replace('/\b(?:body|html)\s*\{[^}]*display\s*:\s*none[^}]*\}/is', '', $css); + + // 清理空声明与空规则块 + $css = preg_replace('/[a-z_-][\w-]*\s*:\s*(?=[;}])/i', '', $css); + $css = preg_replace('/[^{};,@\s][^{}]*\{\s*\}/', '', $css); + + return $css; +} + +// 过滤 style 属性值,危险内容剔除后尽量保留合法声明 +function filter_inline_style_attr($css) +{ + $css = filter_css_global_hijack_rules(sanitize_css($css)); + // 折叠多余分号与空白 + $css = preg_replace('/;\s*;/', ';', $css); + return trim($css, " \t\n\r\0\x0B;"); +} + +// 过滤HTML内容中的危险标签和属性,保留安全的HTML标签 +// 用于富文本内容字段(如文章content),允许显示格式化内容但阻止XSS攻击 +function filter_html($html) +{ + if (! $html || ! is_string($html)) + return $html; + + // 1. 移除所有危险标签(script, iframe, object, embed, applet, form, base, meta, link, svg等) + $dangerous_tags = array( + 'script', 'iframe', 'object', 'embed', 'applet', 'form', 'input', + 'button', 'select', 'textarea', 'base', 'meta', 'link', 'svg', + 'math', 'noscript', 'template', 'frame', 'frameset', 'body', 'head' + ); + foreach ($dangerous_tags as $tag) { + // 移除开标签、闭标签和自闭合标签 + $html = preg_replace('/<' . $tag . '[\s>\/][^>]*>/i', '', $html); + $html = preg_replace('/<\/' . $tag . '[^>]*>/i', '', $html); + $html = preg_replace('/<' . $tag . '\s*\/?>/i', '', $html); + } + + // 2. 移除所有 on 开头的事件属性(onclick, onerror, onload, onmouseover等) + $html = preg_replace('/\s+on\w+\s*=\s*(["\']?)[^>"\']*\1/i', '', $html); + // 处理无引号的事件属性 + $html = preg_replace('/\s+on\w+\s*=\s*[^\s>]+/i', '', $html); + + // 3. 移除 javascript: 和 vbscript: 协议 + $html = preg_replace('/href\s*=\s*(["\']?)\s*javascript\s*:[^>"\']*\1/i', 'href="#"', $html); + $html = preg_replace('/href\s*=\s*(["\']?)\s*vbscript\s*:[^>"\']*\1/i', 'href="#"', $html); + $html = preg_replace('/src\s*=\s*(["\']?)\s*javascript\s*:[^>"\']*\1/i', '', $html); + $html = preg_replace('/src\s*=\s*(["\']?)\s*vbscript\s*:[^>"\']*\1/i', '', $html); + // 处理无引号的协议 + $html = preg_replace('/href\s*=\s*javascript\s*:[^\s>]+/i', 'href="#"', $html); + $html = preg_replace('/src\s*=\s*javascript\s*:[^\s>]+/i', '', $html); + + // 4. 移除 data: 协议中的危险内容(仅允许图片data URI) + $html = preg_replace('/src\s*=\s*(["\']?)\s*data\s*:(?!image\/(png|jpeg|jpg|gif|webp|bmp))[^>"\']*\1/i', '', $html); + + // 5. 清洗 <style> 块:保留合法排版,剔除恶意 CSS + $html = preg_replace_callback('/<style\b([^>]*)>(.*?)<\/style>/is', function ($matches) { + $attrs = preg_replace('/\s+on\w+\s*=\s*[^\s>]*/i', '', $matches[1]); + $css = filter_css_global_hijack_rules(sanitize_css($matches[2])); + $css = trim($css); + if ($css === '') { + return ''; + } + return '<style' . $attrs . '>' . $css . '</style>'; + }, $html); + // 移除空 <style/> 自闭合标签 + $html = preg_replace('/<style\b[^>]*\/>/i', '', $html); + + // 6. 清洗 style 属性(双引号 / 单引号) + $html = preg_replace_callback('/\s+style\s*=\s*(")([^"]*)"/is', function ($matches) { + $css = filter_inline_style_attr($matches[2]); + return $css === '' ? '' : ' style="' . $css . '"'; + }, $html); + $html = preg_replace_callback("/\s+style\s*=\s*(')([^']*)'/is", function ($matches) { + $css = filter_inline_style_attr($matches[2]); + return $css === '' ? '' : " style='" . $css . "'"; + }, $html); + // 无引号 style 属性:含危险模式则整段移除 + $html = preg_replace('/\s+style\s*=\s*[^"\'>\s][^>]*(?:expression|@import|behavior\s*:\s*url|javascript\s*:)/i', '', $html); + + // 7. 移除 XML相关危险内容 + $html = preg_replace('/<\?xml[^>]*\?>/i', '', $html); + $html = preg_replace('/<!\[CDATA\[/i', '', $html); + $html = preg_replace('/\]\]>/i', '', $html); + + // 8. 移除HTML注释中的条件注释(IE条件注释可执行代码) + $html = preg_replace('/<!--\[if\s/i', '<!--[if ', $html); + $html = preg_replace('/<!\[endif\]-->/i', '<![endif]-->', $html); + + return $html; +} + // 字符反转义斜杠,支持字符串、数组、对象 function decode_slashes($string) { @@ -977,6 +1135,3 @@ function create_code($len = 4) } return $code; } - - -
core/function/helper.php+6 −8 modified@@ -830,16 +830,14 @@ function compareSymbol1($str){ function compareSymbol2($str){ $res = null; - $symbol2 = ['%']; - foreach ($symbol2 as $items) { - if (strpos($str, $items) !== false) { - $arr = explode($items, $str); - if ($items == '%') { - $res = $arr[0] % $arr[1]; - } - break; + + if (strpos($str, '%') !== false) { // 取模运算 + $arr = explode('%', $str); + if (count($arr) === 2 && is_numeric(trim($arr[0])) && is_numeric(trim($arr[1]))) { + $res = intval($arr[0]) % intval($arr[1]); } } + if($res === null) { $str = trim($str); $str = trim($str,"'");
core/init.php+2 −0 modified@@ -73,6 +73,8 @@ // 定义静态文件目录 defined('STATIC_DIR') ?: define('STATIC_DIR', SITE_DIR . '/static'); +// 定义核心数据文件目录 +defined('DATA_DIR') ?: define('DATA_DIR', SITE_DIR . '/data'); // 载入基础函数库 require CORE_PATH . '/function/handle.php';
core/view/Parser.php+36 −2 modified@@ -218,7 +218,7 @@ private static function parOutputPost() { $pattern = '/\{\$post\.([\w\-]+)\}/'; if (preg_match($pattern, self::$content)) { - self::$content = preg_replace($pattern, "<?php echo post('$1');?>", self::$content); + self::$content = preg_replace($pattern, "<?php echo htmlspecialchars(post('$1'), ENT_QUOTES, 'UTF-8');?>", self::$content); } } @@ -227,7 +227,7 @@ private static function parOutputGet() { $pattern = '/\{\$get\.([\w\-]+)\}/'; if (preg_match($pattern, self::$content)) { - self::$content = preg_replace($pattern, "<?php echo get('$1');?>", self::$content); + self::$content = preg_replace($pattern, "<?php echo htmlspecialchars(get('$1'), ENT_QUOTES, 'UTF-8');?>", self::$content); } } @@ -266,6 +266,10 @@ private static function parIf() if (preg_match_all($pattern, self::$content, $matches)) { $count = count($matches[0]); for ($i = 0; $i < $count; $i ++) { + // 安全校验:阻止危险函数和模式注入编译后的PHP代码 + if (!self::validateIfCondition($matches[1][$i])) { + continue; + } $content = preg_replace($pattern_if, "<?php if ($1) {?>", $matches[0][$i]); $content = preg_replace($pattern_end_if, "<?php } ?>", $content); $content = preg_replace($pattern_else, "<?php } else { ?>", $content); @@ -274,6 +278,36 @@ private static function parIf() } } + // 验证 {if()} 条件表达式的安全性(编译层防护) + // 后台模板需要支持 $variable、get()、in_array() 等,使用危险函数黑名单而非白名单 + private static function validateIfCondition($condition) + { + $danger_patterns = array( + '/\b(system|exec|passthru|shell_exec|popen|proc_open|pcntl_exec)\s*\(/i', + '/\b(eval|assert|create_function)\s*\(/i', + '/\b(file_put_contents|file_get_contents|fwrite|fread|fputs)\s*\(/i', + '/\b(include|require|include_once|require_once)\s*[\(\'"]/i', + '/\b(base64_decode|base64_encode)\s*\(/i', + '/\b(phpinfo)\s*\(/i', + '/`/', // 反引号命令执行 + '/\b(call_user_func|call_user_func_array)\s*\(/i', + '/\b(preg_replace)\s*\(.+[\'"]e[\'"\/]/i', + '/\b(ob_start|ob_end_clean)\s*\(/i', + '/\b(array_walk|array_map|usort|uasort|uksort)\s*\(/i', + '/\b(register_shutdown_function|register_tick_function)\s*\(/i', + '/\b(set_error_handler|set_exception_handler)\s*\(/i', + '/\b(array_filter|array_reduce)\s*\(/i', + ); + + foreach ($danger_patterns as $pattern) { + if (preg_match($pattern, $condition)) { + return false; + } + } + + return true; + } + // 解析循环语句 {foreach $var(key,value,num)}...[num][value->name]或[value]...{/foreach} private static function parForeachVar() {
core/view/View.php+26 −3 modified@@ -69,12 +69,16 @@ public function assign($var, $value) } } - // 变量获取 - public function getVar($var) + // 变量获取,默认进行HTML转义防止XSS攻击 + public function getVar($var, $escape = false) { if (! empty($var)) { if (isset($this->vars[$var])) { - return $this->vars[$var]; + $data = $this->vars[$var]; + if ($escape) { + return self::escapeHtml($data); + } + return $data; } else { return null; } @@ -83,6 +87,25 @@ public function getVar($var) } } + // 递归HTML转义,防止XSS攻击 + private static function escapeHtml($data) + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = self::escapeHtml($value); + } + return $data; + } elseif (is_object($data)) { + foreach ($data as $key => $value) { + $data->$key = self::escapeHtml($value); + } + return $data; + } elseif (is_string($data)) { + return htmlspecialchars($data, ENT_QUOTES, 'UTF-8'); + } + return $data; + } + // 解析模板文件 public function parser($file) {
doc/ChangeLog.txt+6 −0 modified@@ -2,6 +2,12 @@ 官方网站:https://www.pbootcms.com 标签手册:https://www.pbootcms.com/docs.html ########################################## +PbootCMS V3.2.13 build 2026-05-25 +1、安全加固:ORM参数化绑定、XSS防护、ueditor鉴权、备份路径迁移、PHP最低版本升至7.0 + +PbootCMS V3.2.12 build 2025-04-24 +1、修复SQL注入问题 + PbootCMS V3.2.11 build 2025-04-16 1、下架Polyfill功能
index.php+2 −2 modified@@ -14,9 +14,9 @@ define('URL_BIND', 'home'); // PHP版本检测 -if (PHP_VERSION < '5.4') { +if (version_compare(PHP_VERSION,'7.0.0','<')) { header('Content-Type:text/html; charset=utf-8'); - exit('您服务器PHP的版本太低,程序要求PHP版本不小于5.4'); + exit('您服务器PHP的版本太低,程序要求PHP版本不小于7.0'); } // 引用内核启动文件
rewrite/.htaccess+3 −1 modified@@ -1,7 +1,9 @@ <IfModule mod_rewrite.c> Options +FollowSymlinks RewriteEngine On - + + RewriteRule ^(data|static/backup)/ - [F,L] + RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f
rewrite/nginx.txt+4 −0 modified@@ -5,4 +5,8 @@ location / { if (!-e $request_filename){ rewrite ^/(.*)$ /index.php?p=$1 last; } +} + +location ~ ^/(data|static/backup)/ { + deny all; } \ No newline at end of file
rewrite/web.config+5 −0 modified@@ -3,6 +3,11 @@ <system.webServer> <rewrite> <rules> + <!-- 拦截敏感目录 --> + <rule name="deny_data_backup" stopProcessing="true"> + <match url="^(data|static/backup)/.*" ignoreCase="true" /> + <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Access Denied" /> + </rule> <rule name="reIndex" stopProcessing="true"> <match url="^(.*)$" ignoreCase="true" /> <conditions logicalGrouping="MatchAll">
Vulnerability mechanics
Root cause
"The password recovery method `retrieve()` accepts a new password directly on the first POST, trusts a shared-session image CAPTCHA as the only validation, and short-circuits the email ownership check when the target account's `useremail` column is empty, allowing an attacker who knows only the username to overwrite the victim's password without any out-of-band proof of identity."
Attack vector
An unauthenticated attacker first fetches the image CAPTCHA from `/core/code.php`, reading the 4-character code from the returned PNG and retaining the session cookie. The attacker then sends a single POST to `/?member/retrieve/` with the victim's username, an arbitrary email, the attacker-chosen password, and the captured CAPTCHA code. The server bypasses the email ownership check because the default account-mode registration leaves `useremail` empty [ref_id=1], and the shared `$_SESSION['checkcode']` namespace means the image CAPTCHA satisfies the validation the front-end labels as an email-verification code [ref_id=1]. The password is written directly to the database with no reset token, and the attacker can immediately log in [CWE-287, CWE-640].
Affected code
The flaw resides in `apps/home/controller/MemberController.php` (the `retrieve()` method) and partially in `core/basic/Model.php`. The patch [patch_id=5721531] converts the vulnerable `'...$value...'` string interpolation in `Model.php` to parameterized bindings, eliminating the SQL injection vector that previously compounded the broken authentication flow.
What the fix does
The patch [patch_id=5721531] introduces a `$bindParams` array and a corresponding `addBind()` method; every user-supplied value that was previously concatenated directly into SQL strings (via `'...$value...'`) is now replaced by a placeholder and tracked for later binding. Methods `where()`, `in()`, `notIn()`, `like()`, `notLike()`, `insert()`, `update()`, `setField()`, and all aggregate query methods are updated to use these placeholders. The stored bound values are passed to the PDO execute call via the new `$bindParams` argument in `all()`, `one()`, and `amd()` calls, and the array is cleared after each query. This prevents SQL injection by ensuring user input never appears inline in the SQL string.
Preconditions
- configTarget account's `useremail` field must be empty (default for username-mode registration)
- authNo authentication required; attacker only needs the victim's username
- networkAttacker must be able to reach the password-recovery endpoint over HTTP
- inputCAPTCHA code fetched from `/core/code.php` and submitted with the password-change POST
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6News mentions
0No linked articles in our index yet.